Source commit: 20b59dc46b72103c2f8a516c692b5cc3d54fab19 Public tree identity: sha256:aaa5ac7b58b2a0d23c6b11e5f76324bf3839ca1f62653a83deb736932adcfbd9
720 lines
22 KiB
Rust
720 lines
22 KiB
Rust
#[cfg(not(target_arch = "wasm32"))]
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
use crate::{
|
|
AgentId, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, UserId,
|
|
};
|
|
|
|
pub fn admin_request_proof(
|
|
admin_token: &str,
|
|
operation: &str,
|
|
tenant: &str,
|
|
actor_user: &str,
|
|
target_tenant: &str,
|
|
nonce: &str,
|
|
issued_at_epoch_seconds: u64,
|
|
) -> Digest {
|
|
admin_request_proof_from_token_digest(
|
|
&Digest::sha256(admin_token),
|
|
operation,
|
|
tenant,
|
|
actor_user,
|
|
target_tenant,
|
|
nonce,
|
|
issued_at_epoch_seconds,
|
|
)
|
|
}
|
|
|
|
pub fn admin_request_proof_from_token_digest(
|
|
admin_token_digest: &Digest,
|
|
operation: &str,
|
|
tenant: &str,
|
|
actor_user: &str,
|
|
target_tenant: &str,
|
|
nonce: &str,
|
|
issued_at_epoch_seconds: u64,
|
|
) -> Digest {
|
|
let issued_at_epoch_seconds = issued_at_epoch_seconds.to_string();
|
|
Digest::from_parts([
|
|
b"disasmer-admin-request-proof:v1".as_slice(),
|
|
admin_token_digest.as_str().as_bytes(),
|
|
operation.as_bytes(),
|
|
tenant.as_bytes(),
|
|
actor_user.as_bytes(),
|
|
target_tenant.as_bytes(),
|
|
nonce.as_bytes(),
|
|
issued_at_epoch_seconds.as_bytes(),
|
|
])
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Actor {
|
|
User(UserId),
|
|
Agent(AgentId),
|
|
Node(NodeId),
|
|
Task(TaskInstanceId),
|
|
}
|
|
|
|
impl Actor {
|
|
pub fn kind(&self) -> IdentityKind {
|
|
match self {
|
|
Self::User(_) => IdentityKind::User,
|
|
Self::Agent(_) => IdentityKind::Agent,
|
|
Self::Node(_) => IdentityKind::Node,
|
|
Self::Task(_) => IdentityKind::Task,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum IdentityKind {
|
|
User,
|
|
Agent,
|
|
Node,
|
|
Project,
|
|
Task,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum CredentialKind {
|
|
BrowserSession,
|
|
CliDeviceSession,
|
|
PublicKey,
|
|
NodeCredential,
|
|
TaskCredential,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AuthContext {
|
|
pub tenant: TenantId,
|
|
pub project: ProjectId,
|
|
pub actor: Actor,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Action {
|
|
CreateProject,
|
|
AttachNode,
|
|
CreateNodeEnrollmentGrant,
|
|
ExchangeNodeEnrollmentGrant,
|
|
LoginBrowser,
|
|
LoginCli,
|
|
EnrollAgent,
|
|
List,
|
|
Inspect,
|
|
Mutate,
|
|
ClaimTask,
|
|
DebugAttach,
|
|
DebugRead,
|
|
DownloadArtifact,
|
|
PublishArtifact,
|
|
RunNativeCommand,
|
|
RunContainer,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct BrowserLoginFlow {
|
|
pub authorization_url: String,
|
|
pub callback_path: String,
|
|
pub state: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct PublicKeyIdentity {
|
|
pub subject: Actor,
|
|
pub public_key: String,
|
|
pub fingerprint: Digest,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AgentSignedRequest {
|
|
pub nonce: String,
|
|
pub issued_at_epoch_seconds: u64,
|
|
pub signature: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct NodeSignedRequest {
|
|
pub nonce: String,
|
|
pub issued_at_epoch_seconds: u64,
|
|
pub signature: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct EnrollmentGrant {
|
|
pub tenant: TenantId,
|
|
pub project: ProjectId,
|
|
pub grant_id: String,
|
|
pub scope: String,
|
|
pub expires_at_epoch_seconds: u64,
|
|
pub consumed: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct NodeCredential {
|
|
pub node: NodeId,
|
|
pub tenant: TenantId,
|
|
pub project: ProjectId,
|
|
pub public_key_fingerprint: Digest,
|
|
pub scope: String,
|
|
pub capability_policy_digest: Digest,
|
|
pub credential_kind: CredentialKind,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum EnrollmentError {
|
|
Expired,
|
|
AlreadyConsumed,
|
|
WrongScope,
|
|
}
|
|
|
|
impl EnrollmentGrant {
|
|
pub fn exchange_for_node_identity(
|
|
&mut self,
|
|
node: NodeId,
|
|
public_key: &str,
|
|
requested_scope: &str,
|
|
now_epoch_seconds: u64,
|
|
) -> Result<NodeCredential, EnrollmentError> {
|
|
if self.consumed {
|
|
return Err(EnrollmentError::AlreadyConsumed);
|
|
}
|
|
if now_epoch_seconds > self.expires_at_epoch_seconds {
|
|
return Err(EnrollmentError::Expired);
|
|
}
|
|
if requested_scope != self.scope {
|
|
return Err(EnrollmentError::WrongScope);
|
|
}
|
|
self.consumed = true;
|
|
let capability_policy_digest =
|
|
node_capability_policy_digest(&self.tenant, &self.project, &self.scope);
|
|
Ok(NodeCredential {
|
|
node,
|
|
tenant: self.tenant.clone(),
|
|
project: self.project.clone(),
|
|
public_key_fingerprint: Digest::sha256(public_key),
|
|
scope: self.scope.clone(),
|
|
capability_policy_digest,
|
|
credential_kind: CredentialKind::NodeCredential,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn node_capability_policy_digest(
|
|
tenant: &TenantId,
|
|
project: &ProjectId,
|
|
scope: &str,
|
|
) -> Digest {
|
|
Digest::from_parts([
|
|
b"node-capability-policy:v1".as_slice(),
|
|
tenant.as_str().as_bytes(),
|
|
project.as_str().as_bytes(),
|
|
scope.as_bytes(),
|
|
])
|
|
}
|
|
|
|
pub fn agent_ed25519_public_key_from_private_key(private_key: &str) -> Result<String, String> {
|
|
let private_key = decode_ed25519_key(private_key, 32, "agent private key")?;
|
|
let private_key: [u8; 32] = private_key
|
|
.try_into()
|
|
.map_err(|_| "agent private key must be 32 bytes".to_owned())?;
|
|
let signing_key = SigningKey::from_bytes(&private_key);
|
|
Ok(format!(
|
|
"ed25519:{}",
|
|
STANDARD.encode(signing_key.verifying_key().to_bytes())
|
|
))
|
|
}
|
|
|
|
pub fn node_ed25519_public_key_from_private_key(private_key: &str) -> Result<String, String> {
|
|
agent_ed25519_public_key_from_private_key(private_key)
|
|
}
|
|
|
|
pub fn derive_ed25519_private_key_from_seed(seed: &str) -> String {
|
|
let digest = Digest::sha256(seed);
|
|
let hex = digest.as_str().trim_start_matches("sha256:");
|
|
let bytes = hex::decode(hex).expect("sha256 digest hex should decode");
|
|
format!("ed25519:{}", STANDARD.encode(bytes))
|
|
}
|
|
|
|
/// Generates a new Ed25519 private key from the operating system CSPRNG.
|
|
///
|
|
/// Seed-derived keys remain available for deterministic test fixtures, but
|
|
/// must not be used for persisted user, Agent, or Node credentials.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub fn generate_ed25519_private_key() -> Result<String, String> {
|
|
let mut bytes = [0_u8; 32];
|
|
getrandom::fill(&mut bytes)
|
|
.map_err(|err| format!("operating system random source failed: {err}"))?;
|
|
Ok(format!("ed25519:{}", STANDARD.encode(bytes)))
|
|
}
|
|
|
|
/// Generates an opaque, URL-safe 256-bit token suitable for one-time grants,
|
|
/// session secrets, and nonces. The label is non-secret domain separation.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub fn generate_opaque_token(label: &str) -> Result<String, String> {
|
|
let mut bytes = [0_u8; 32];
|
|
getrandom::fill(&mut bytes)
|
|
.map_err(|err| format!("operating system random source failed: {err}"))?;
|
|
Ok(format!("{label}_{}", URL_SAFE_NO_PAD.encode(bytes)))
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct AgentWorkflowScope<'a> {
|
|
pub tenant: &'a TenantId,
|
|
pub project: &'a ProjectId,
|
|
pub agent: &'a AgentId,
|
|
pub request_kind: &'a str,
|
|
pub process: &'a ProcessId,
|
|
pub task: Option<&'a TaskInstanceId>,
|
|
}
|
|
|
|
pub fn sign_agent_workflow_request(
|
|
private_key: &str,
|
|
scope: AgentWorkflowScope<'_>,
|
|
payload_digest: &Digest,
|
|
nonce: String,
|
|
issued_at_epoch_seconds: u64,
|
|
) -> Result<AgentSignedRequest, String> {
|
|
let private_key = decode_ed25519_key(private_key, 32, "agent private key")?;
|
|
let private_key: [u8; 32] = private_key
|
|
.try_into()
|
|
.map_err(|_| "agent private key must be 32 bytes".to_owned())?;
|
|
let signing_key = SigningKey::from_bytes(&private_key);
|
|
let message =
|
|
agent_workflow_signature_message(scope, payload_digest, &nonce, issued_at_epoch_seconds);
|
|
let signature: Signature = signing_key.sign(&message);
|
|
Ok(AgentSignedRequest {
|
|
nonce,
|
|
issued_at_epoch_seconds,
|
|
signature: format!("ed25519:{}", STANDARD.encode(signature.to_bytes())),
|
|
})
|
|
}
|
|
|
|
pub fn verify_agent_workflow_signature(
|
|
public_key: &str,
|
|
scope: AgentWorkflowScope<'_>,
|
|
payload_digest: &Digest,
|
|
signed_request: &AgentSignedRequest,
|
|
) -> Result<(), String> {
|
|
let public_key = decode_ed25519_key(public_key, 32, "agent public key")?;
|
|
let public_key: [u8; 32] = public_key
|
|
.try_into()
|
|
.map_err(|_| "agent public key must be 32 bytes".to_owned())?;
|
|
let verifying_key = VerifyingKey::from_bytes(&public_key)
|
|
.map_err(|_| "agent public key is not a valid Ed25519 verifying key".to_owned())?;
|
|
let signature = decode_ed25519_key(&signed_request.signature, 64, "agent signature")?;
|
|
let signature: [u8; 64] = signature
|
|
.try_into()
|
|
.map_err(|_| "agent signature must be 64 bytes".to_owned())?;
|
|
let signature = Signature::from_bytes(&signature);
|
|
let message = agent_workflow_signature_message(
|
|
scope,
|
|
payload_digest,
|
|
&signed_request.nonce,
|
|
signed_request.issued_at_epoch_seconds,
|
|
);
|
|
verifying_key
|
|
.verify(&message, &signature)
|
|
.map_err(|_| "agent signature does not verify against the registered public key".to_owned())
|
|
}
|
|
|
|
pub fn sign_node_request(
|
|
private_key: &str,
|
|
node: &NodeId,
|
|
request_kind: &str,
|
|
payload_digest: &Digest,
|
|
nonce: String,
|
|
issued_at_epoch_seconds: u64,
|
|
) -> Result<NodeSignedRequest, String> {
|
|
let private_key = decode_ed25519_key(private_key, 32, "node private key")?;
|
|
let private_key: [u8; 32] = private_key
|
|
.try_into()
|
|
.map_err(|_| "node private key must be 32 bytes".to_owned())?;
|
|
let signing_key = SigningKey::from_bytes(&private_key);
|
|
let message = node_request_signature_message(
|
|
node,
|
|
request_kind,
|
|
payload_digest,
|
|
&nonce,
|
|
issued_at_epoch_seconds,
|
|
);
|
|
let signature: Signature = signing_key.sign(&message);
|
|
Ok(NodeSignedRequest {
|
|
nonce,
|
|
issued_at_epoch_seconds,
|
|
signature: format!("ed25519:{}", STANDARD.encode(signature.to_bytes())),
|
|
})
|
|
}
|
|
|
|
pub fn verify_node_request_signature(
|
|
public_key: &str,
|
|
node: &NodeId,
|
|
request_kind: &str,
|
|
payload_digest: &Digest,
|
|
signed_request: &NodeSignedRequest,
|
|
) -> Result<(), String> {
|
|
let public_key = decode_ed25519_key(public_key, 32, "node public key")?;
|
|
let public_key: [u8; 32] = public_key
|
|
.try_into()
|
|
.map_err(|_| "node public key must be 32 bytes".to_owned())?;
|
|
let verifying_key = VerifyingKey::from_bytes(&public_key)
|
|
.map_err(|_| "node public key is not a valid Ed25519 verifying key".to_owned())?;
|
|
let signature = decode_ed25519_key(&signed_request.signature, 64, "node signature")?;
|
|
let signature: [u8; 64] = signature
|
|
.try_into()
|
|
.map_err(|_| "node signature must be 64 bytes".to_owned())?;
|
|
let signature = Signature::from_bytes(&signature);
|
|
let message = node_request_signature_message(
|
|
node,
|
|
request_kind,
|
|
payload_digest,
|
|
&signed_request.nonce,
|
|
signed_request.issued_at_epoch_seconds,
|
|
);
|
|
verifying_key
|
|
.verify(&message, &signature)
|
|
.map_err(|_| "node signature does not verify against the enrolled public key".to_owned())
|
|
}
|
|
|
|
fn decode_ed25519_key(value: &str, expected_len: usize, kind: &str) -> Result<Vec<u8>, String> {
|
|
let encoded = value
|
|
.strip_prefix("ed25519:")
|
|
.ok_or_else(|| format!("{kind} must use ed25519:<base64> encoding"))?;
|
|
let bytes = STANDARD
|
|
.decode(encoded)
|
|
.map_err(|_| format!("{kind} is not valid base64"))?;
|
|
if bytes.len() != expected_len {
|
|
return Err(format!("{kind} must be {expected_len} bytes"));
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn agent_workflow_signature_message(
|
|
scope: AgentWorkflowScope<'_>,
|
|
payload_digest: &Digest,
|
|
nonce: &str,
|
|
issued_at_epoch_seconds: u64,
|
|
) -> Vec<u8> {
|
|
let issued_at = issued_at_epoch_seconds.to_string();
|
|
let task = scope.task.map(TaskInstanceId::as_str).unwrap_or("");
|
|
let parts = [
|
|
"disasmer-agent-workflow-signature:v2",
|
|
scope.tenant.as_str(),
|
|
scope.project.as_str(),
|
|
scope.agent.as_str(),
|
|
scope.request_kind,
|
|
scope.process.as_str(),
|
|
task,
|
|
payload_digest.as_str(),
|
|
nonce,
|
|
&issued_at,
|
|
];
|
|
let mut message = Vec::new();
|
|
for part in parts {
|
|
message.extend_from_slice(part.len().to_string().as_bytes());
|
|
message.push(b':');
|
|
message.extend_from_slice(part.as_bytes());
|
|
message.push(b'\n');
|
|
}
|
|
message
|
|
}
|
|
|
|
fn node_request_signature_message(
|
|
node: &NodeId,
|
|
request_kind: &str,
|
|
payload_digest: &Digest,
|
|
nonce: &str,
|
|
issued_at_epoch_seconds: u64,
|
|
) -> Vec<u8> {
|
|
let issued_at = issued_at_epoch_seconds.to_string();
|
|
let parts = [
|
|
"disasmer-node-request-signature:v2",
|
|
node.as_str(),
|
|
request_kind,
|
|
payload_digest.as_str(),
|
|
nonce,
|
|
&issued_at,
|
|
];
|
|
let mut message = Vec::new();
|
|
for part in parts {
|
|
message.extend_from_slice(part.len().to_string().as_bytes());
|
|
message.push(b':');
|
|
message.extend_from_slice(part.as_bytes());
|
|
message.push(b'\n');
|
|
}
|
|
message
|
|
}
|
|
|
|
/// Computes the stable digest covered by node and agent request signatures.
|
|
///
|
|
/// The proof field itself is excluded, and explicit JSON nulls are normalized
|
|
/// with omitted optional fields because the wire protocol deserializes both to
|
|
/// the same request. Every semantically meaningful key and value remains bound
|
|
/// by the signature.
|
|
pub fn signed_request_payload_digest(value: &Value) -> Digest {
|
|
fn canonicalize(value: &Value, top_level: bool) -> Value {
|
|
match value {
|
|
Value::Object(object) => Value::Object(
|
|
object
|
|
.iter()
|
|
.filter(|(key, value)| {
|
|
!value.is_null()
|
|
&& (!top_level
|
|
|| !matches!(key.as_str(), "agent_signature" | "node_signature"))
|
|
})
|
|
.map(|(key, value)| (key.clone(), canonicalize(value, false)))
|
|
.collect(),
|
|
),
|
|
Value::Array(values) => Value::Array(
|
|
values
|
|
.iter()
|
|
.map(|value| canonicalize(value, false))
|
|
.collect(),
|
|
),
|
|
value => value.clone(),
|
|
}
|
|
}
|
|
|
|
let canonical = canonicalize(value, true);
|
|
let bytes = serde_json::to_vec(&canonical)
|
|
.expect("canonical JSON request values are always serializable");
|
|
Digest::sha256(bytes)
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Scope {
|
|
pub tenant: TenantId,
|
|
pub project: ProjectId,
|
|
pub process: Option<ProcessId>,
|
|
pub task: Option<TaskInstanceId>,
|
|
pub node: Option<NodeId>,
|
|
pub artifact: Option<ArtifactId>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Authorization {
|
|
pub allowed: bool,
|
|
pub reason: String,
|
|
}
|
|
|
|
impl Authorization {
|
|
pub fn allow(reason: impl Into<String>) -> Self {
|
|
Self {
|
|
allowed: true,
|
|
reason: reason.into(),
|
|
}
|
|
}
|
|
|
|
pub fn deny(reason: impl Into<String>) -> Self {
|
|
Self {
|
|
allowed: false,
|
|
reason: reason.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn same_tenant_project(context: &AuthContext, scope: &Scope) -> Authorization {
|
|
if context.tenant != scope.tenant {
|
|
return Authorization::deny("tenant mismatch");
|
|
}
|
|
if context.project != scope.project {
|
|
return Authorization::deny("project mismatch");
|
|
}
|
|
Authorization::allow("same tenant and project")
|
|
}
|
|
|
|
pub fn task_credentials_do_not_contain_user_session(
|
|
task: &Actor,
|
|
credentials: &[CredentialKind],
|
|
) -> Authorization {
|
|
if !matches!(task, Actor::Task(_)) {
|
|
return Authorization::deny("credential check requires task actor");
|
|
}
|
|
if credentials.iter().any(|credential| {
|
|
matches!(
|
|
credential,
|
|
CredentialKind::BrowserSession | CredentialKind::CliDeviceSession
|
|
)
|
|
}) {
|
|
return Authorization::deny(
|
|
"user OAuth/session tokens must not be passed to nodes as task credentials",
|
|
);
|
|
}
|
|
Authorization::allow("task credentials are scoped runtime credentials")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn tenant_project_scope_denies_cross_tenant_access() {
|
|
let context = AuthContext {
|
|
tenant: TenantId::from("tenant-a"),
|
|
project: ProjectId::from("project-a"),
|
|
actor: Actor::User(UserId::from("user-a")),
|
|
};
|
|
let scope = Scope {
|
|
tenant: TenantId::from("tenant-b"),
|
|
project: ProjectId::from("project-a"),
|
|
process: None,
|
|
task: None,
|
|
node: None,
|
|
artifact: None,
|
|
};
|
|
|
|
assert!(!same_tenant_project(&context, &scope).allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn node_enrollment_exchanges_short_lived_grant_once() {
|
|
let mut grant = EnrollmentGrant {
|
|
tenant: TenantId::from("tenant"),
|
|
project: ProjectId::from("project"),
|
|
grant_id: "grant".to_owned(),
|
|
scope: "node:attach".to_owned(),
|
|
expires_at_epoch_seconds: 100,
|
|
consumed: false,
|
|
};
|
|
|
|
let credential = grant
|
|
.exchange_for_node_identity(NodeId::from("node"), "public-key", "node:attach", 99)
|
|
.unwrap();
|
|
|
|
assert_eq!(credential.credential_kind, CredentialKind::NodeCredential);
|
|
assert_eq!(credential.tenant, TenantId::from("tenant"));
|
|
assert_eq!(credential.project, ProjectId::from("project"));
|
|
assert_eq!(credential.node, NodeId::from("node"));
|
|
assert_eq!(credential.scope, "node:attach");
|
|
assert_eq!(
|
|
credential.capability_policy_digest,
|
|
node_capability_policy_digest(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("project"),
|
|
"node:attach"
|
|
)
|
|
);
|
|
assert_eq!(
|
|
grant.exchange_for_node_identity(
|
|
NodeId::from("node2"),
|
|
"public-key",
|
|
"node:attach",
|
|
99
|
|
),
|
|
Err(EnrollmentError::AlreadyConsumed)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn node_capability_policy_digest_is_scoped() {
|
|
let base = node_capability_policy_digest(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("project"),
|
|
"node:attach",
|
|
);
|
|
let other_project = node_capability_policy_digest(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("other"),
|
|
"node:attach",
|
|
);
|
|
let other_scope = node_capability_policy_digest(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("project"),
|
|
"node:limited",
|
|
);
|
|
|
|
assert!(base.is_valid_sha256());
|
|
assert_ne!(base, other_project);
|
|
assert_ne!(base, other_scope);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_ed25519_private_keys_are_random_and_valid() {
|
|
let first = generate_ed25519_private_key().unwrap();
|
|
let second = generate_ed25519_private_key().unwrap();
|
|
|
|
assert_ne!(first, second);
|
|
assert!(agent_ed25519_public_key_from_private_key(&first)
|
|
.unwrap()
|
|
.starts_with("ed25519:"));
|
|
assert!(node_ed25519_public_key_from_private_key(&second)
|
|
.unwrap()
|
|
.starts_with("ed25519:"));
|
|
}
|
|
|
|
#[test]
|
|
fn generated_opaque_tokens_are_random_and_url_safe() {
|
|
let first = generate_opaque_token("grant").unwrap();
|
|
let second = generate_opaque_token("grant").unwrap();
|
|
|
|
assert_ne!(first, second);
|
|
assert!(first.starts_with("grant_"));
|
|
assert!(first
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')));
|
|
}
|
|
|
|
#[test]
|
|
fn task_credentials_reject_user_session_tokens() {
|
|
for credential in [
|
|
CredentialKind::BrowserSession,
|
|
CredentialKind::CliDeviceSession,
|
|
] {
|
|
let authz = task_credentials_do_not_contain_user_session(
|
|
&Actor::Task(TaskInstanceId::from("task")),
|
|
&[CredentialKind::TaskCredential, credential],
|
|
);
|
|
|
|
assert!(!authz.allowed);
|
|
assert!(authz.reason.contains("must not be passed"));
|
|
}
|
|
|
|
let scoped = task_credentials_do_not_contain_user_session(
|
|
&Actor::Task(TaskInstanceId::from("task")),
|
|
&[
|
|
CredentialKind::TaskCredential,
|
|
CredentialKind::NodeCredential,
|
|
],
|
|
);
|
|
assert!(scoped.allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn identities_remain_distinct_for_authorization() {
|
|
assert_eq!(Actor::User(UserId::from("user")).kind(), IdentityKind::User);
|
|
assert_eq!(
|
|
Actor::Agent(AgentId::from("agent")).kind(),
|
|
IdentityKind::Agent
|
|
);
|
|
assert_eq!(Actor::Node(NodeId::from("node")).kind(), IdentityKind::Node);
|
|
assert_eq!(
|
|
Actor::Task(TaskInstanceId::from("task")).kind(),
|
|
IdentityKind::Task
|
|
);
|
|
|
|
let scope = Scope {
|
|
tenant: TenantId::from("tenant"),
|
|
project: ProjectId::from("project"),
|
|
process: Some(ProcessId::from("process")),
|
|
task: Some(TaskInstanceId::from("task")),
|
|
node: Some(NodeId::from("node")),
|
|
artifact: Some(ArtifactId::from("artifact")),
|
|
};
|
|
assert_eq!(scope.process, Some(ProcessId::from("process")));
|
|
assert_eq!(scope.artifact, Some(ArtifactId::from("artifact")));
|
|
|
|
assert_ne!(
|
|
CredentialKind::BrowserSession,
|
|
CredentialKind::CliDeviceSession
|
|
);
|
|
assert_ne!(CredentialKind::PublicKey, CredentialKind::NodeCredential);
|
|
assert_ne!(
|
|
CredentialKind::NodeCredential,
|
|
CredentialKind::TaskCredential
|
|
);
|
|
}
|
|
}
|