Public dry run dryrun-20b59dc46b72
Source commit: 20b59dc46b72103c2f8a516c692b5cc3d54fab19 Public tree identity: sha256:aaa5ac7b58b2a0d23c6b11e5f76324bf3839ca1f62653a83deb736932adcfbd9
This commit is contained in:
commit
2bef715211
221 changed files with 79792 additions and 0 deletions
720
crates/disasmer-core/src/auth.rs
Normal file
720
crates/disasmer-core/src/auth.rs
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue