Source commit: 1714a9eedd5b1e30c6c9aceb7a999f2f695ba83c Public tree identity: sha256:e5daffad23fa7c7f1822adccd3c3b4ee0bc7f1a45e0d321eb9a6fb8282b269db
369 lines
10 KiB
Rust
369 lines
10 KiB
Rust
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<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(),
|
|
])
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Scope {
|
|
pub tenant: TenantId,
|
|
pub project: ProjectId,
|
|
pub process: Option<ProcessId>,
|
|
pub task: Option<TaskId>,
|
|
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 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
|
|
);
|
|
}
|
|
}
|