use serde::{Deserialize, Serialize}; use crate::{AgentId, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskId, TenantId, UserId}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Actor { User(UserId), Agent(AgentId), Node(NodeId), Task(TaskId), } 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 CliLoginFlow { pub verification_url: String, pub user_code: String, pub device_code: String, pub expires_in_seconds: u64, pub yields_long_lived_secret_directly: bool, } #[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 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 { 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(), ]) } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Scope { pub tenant: TenantId, pub project: ProjectId, pub process: Option, pub task: Option, pub node: Option, pub artifact: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Authorization { pub allowed: bool, pub reason: String, } impl Authorization { pub fn allow(reason: impl Into) -> Self { Self { allowed: true, reason: reason.into(), } } pub fn deny(reason: impl Into) -> 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 task_credentials_reject_user_session_tokens() { for credential in [ CredentialKind::BrowserSession, CredentialKind::CliDeviceSession, ] { let authz = task_credentials_do_not_contain_user_session( &Actor::Task(TaskId::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(TaskId::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(TaskId::from("task")).kind(), IdentityKind::Task); let scope = Scope { tenant: TenantId::from("tenant"), project: ProjectId::from("project"), process: Some(ProcessId::from("process")), task: Some(TaskId::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 ); } }