Public dry run dryrun-1714a9eedd5b
Source commit: 1714a9eedd5b1e30c6c9aceb7a999f2f695ba83c Public tree identity: sha256:e5daffad23fa7c7f1822adccd3c3b4ee0bc7f1a45e0d321eb9a6fb8282b269db
This commit is contained in:
commit
20c72e6066
102 changed files with 32054 additions and 0 deletions
369
crates/disasmer-core/src/auth.rs
Normal file
369
crates/disasmer-core/src/auth.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue