From 9f2d6ec010e6f4815e66ac0bbbdada6b34e4550c Mon Sep 17 00:00:00 2001 From: Michel Paulissen <862400+MichelPaulissen@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:50:16 +0200 Subject: [PATCH] Public dry run dryrun-6341cce4cf9f Source commit: 6341cce4cf9f801a3bfe2248d6e7e6a119cea715 Public tree identity: sha256:d909cba912925e66131fc55fe33774a2b26b1ff2c12b53ec4ef217c9788d8b7a --- DISASMER_PUBLIC_TREE.json | 4 +- crates/disasmer-cli/src/main.rs | 185 +++++++++++++- crates/disasmer-coordinator/src/lib.rs | 194 +++++++++++++- .../src/postgres_store.rs | 45 +++- crates/disasmer-coordinator/src/service.rs | 241 +++++++++++++++++- scripts/cli-first-contract-smoke.js | 8 +- scripts/user-session-token-boundary-smoke.js | 4 + 7 files changed, 654 insertions(+), 27 deletions(-) diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 8b3ab8f..07e1634 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "199c53541aa226880d242f7bb5c5d122961d9d87", - "release_name": "dryrun-199c53541aa2", + "source_commit": "6341cce4cf9f801a3bfe2248d6e7e6a119cea715", + "release_name": "dryrun-6341cce4cf9f", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 7b71620..697fd25 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -771,18 +771,64 @@ fn auth_logout_report(args: AuthLogoutArgs, cwd: PathBuf) -> Result { fn key_add_report(args: KeyAddArgs) -> Result { if let Some(coordinator) = &args.scope.coordinator { + let tenant = args.scope.tenant.clone(); + let project = args.scope.project.clone(); + let user = args.scope.user.clone(); + let agent = args.agent.clone(); + let public_key_fingerprint = Digest::sha256(&args.public_key); let mut session = JsonLineSession::connect(coordinator)?; let response = session.request(json!({ "type": "register_agent_public_key", - "tenant": args.scope.tenant, - "project": args.scope.project, - "user": args.scope.user, - "agent": args.agent, + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, "public_key": args.public_key, }))?; + let record = response.get("record").cloned().unwrap_or_else(|| { + json!({ + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, + "public_key_fingerprint": public_key_fingerprint, + "scopes": ["project:read", "project:run"], + "human_account_creation_privilege": false, + "browser_interaction_required_each_run": false, + }) + }); return Ok(json!({ "command": "key add", "coordinator": coordinator, + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, + "public_key_fingerprint": record + .get("public_key_fingerprint") + .cloned() + .unwrap_or_else(|| json!(public_key_fingerprint)), + "credential_scope": { + "tenant": tenant, + "project": project, + "actions": record + .get("scopes") + .cloned() + .unwrap_or_else(|| json!(["project:read", "project:run"])), + "human_account_creation_privilege": record + .get("human_account_creation_privilege") + .cloned() + .unwrap_or_else(|| json!(false)), + }, + "browser_interaction_required_each_run": record + .get("browser_interaction_required_each_run") + .cloned() + .unwrap_or_else(|| json!(false)), + "attribution": { + "registered_by_user": user, + "agent": agent, + "credential_kind": "public_key", + }, "response": response, "coordinator_session_requests": session.requests(), })); @@ -798,16 +844,32 @@ fn key_add_report(args: KeyAddArgs) -> Result { fn key_list_report(args: KeyListArgs) -> Result { if let Some(coordinator) = &args.scope.coordinator { + let tenant = args.scope.tenant.clone(); + let project = args.scope.project.clone(); + let user = args.scope.user.clone(); let mut session = JsonLineSession::connect(coordinator)?; let response = session.request(json!({ "type": "list_agent_public_keys", - "tenant": args.scope.tenant, - "project": args.scope.project, - "user": args.scope.user, + "tenant": tenant, + "project": project, + "user": user, }))?; return Ok(json!({ "command": "key list", "coordinator": coordinator, + "tenant": tenant, + "project": project, + "user": user, + "records": response + .get("records") + .cloned() + .unwrap_or_else(|| json!([])), + "credential_scope": { + "tenant": tenant, + "project": project, + "listed_for_user": user, + "human_account_creation_privilege": false, + }, "response": response, "coordinator_session_requests": session.requests(), })); @@ -821,18 +883,41 @@ fn key_list_report(args: KeyListArgs) -> Result { fn key_revoke_report(args: KeyRevokeArgs) -> Result { if let Some(coordinator) = &args.scope.coordinator { + let tenant = args.scope.tenant.clone(); + let project = args.scope.project.clone(); + let user = args.scope.user.clone(); + let agent = args.agent.clone(); let mut session = JsonLineSession::connect(coordinator)?; let response = session.request(json!({ "type": "revoke_agent_public_key", - "tenant": args.scope.tenant, - "project": args.scope.project, - "user": args.scope.user, - "agent": args.agent, + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, }))?; return Ok(json!({ "command": "key revoke", "coordinator": coordinator, "requires_confirmation": !args.yes, + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, + "revoked": response + .pointer("/record/revoked") + .cloned() + .unwrap_or_else(|| json!(true)), + "credential_scope": { + "tenant": tenant, + "project": project, + "revoked_for_user": user, + "human_account_creation_privilege": false, + }, + "attribution": { + "revoked_by_user": user, + "agent": agent, + "credential_kind": "public_key", + }, "response": response, "coordinator_session_requests": session.requests(), })); @@ -4273,6 +4358,84 @@ mod tests { } } + #[test] + fn key_lifecycle_reports_project_scoped_agent_credentials() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let add_response = concat!( + r#"{"type":"agent_public_key","actor":"user","record":{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":false,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}}"# + ); + let list_response = concat!( + r#"{"type":"agent_public_keys","actor":"user","records":[{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":false,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}]}"# + ); + let revoke_response = concat!( + r#"{"type":"agent_public_key","actor":"user","record":{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":true,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}}"# + ); + for (expected, response) in [ + ("register_agent_public_key", add_response), + ("list_agent_public_keys", list_response), + ("revoke_agent_public_key", revoke_response), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(&format!(r#""type":"{expected}""#))); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""user":"user""#)); + if expected != "list_agent_public_keys" { + assert!(line.contains(r#""agent":"agent-ci""#)); + } + if expected == "register_agent_public_key" { + assert!(line.contains(r#""public_key":"agent-key-v1""#)); + } + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }; + + let added = key_add_report(KeyAddArgs { + scope: scope.clone(), + agent: "agent-ci".to_owned(), + public_key: "agent-key-v1".to_owned(), + }) + .unwrap(); + let listed = key_list_report(KeyListArgs { + scope: scope.clone(), + }) + .unwrap(); + let revoked = key_revoke_report(KeyRevokeArgs { + scope, + agent: "agent-ci".to_owned(), + yes: true, + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!(added["command"], "key add"); + assert_eq!(added["agent"], "agent-ci"); + assert_eq!(added["credential_scope"]["actions"][0], "project:read"); + assert_eq!( + added["credential_scope"]["human_account_creation_privilege"], + false + ); + assert_eq!(added["browser_interaction_required_each_run"], false); + assert_eq!(added["attribution"]["registered_by_user"], "user"); + assert_eq!(listed["records"].as_array().unwrap().len(), 1); + assert_eq!(listed["credential_scope"]["listed_for_user"], "user"); + assert_eq!(revoked["revoked"], true); + assert_eq!(revoked["attribution"]["revoked_by_user"], "user"); + } + #[test] fn human_report_is_text_not_json() { let report = json!({ diff --git a/crates/disasmer-coordinator/src/lib.rs b/crates/disasmer-coordinator/src/lib.rs index 4874afb..6ba35e9 100644 --- a/crates/disasmer-coordinator/src/lib.rs +++ b/crates/disasmer-coordinator/src/lib.rs @@ -1,8 +1,9 @@ use std::collections::{BTreeMap, BTreeSet}; use disasmer_core::{ - Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant, - NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId, + Actor, AgentId, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, + EnrollmentGrant, NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, + UserId, }; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -78,6 +79,21 @@ pub struct ProjectPermissionRecord { pub can_debug: bool, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentPublicKeyRecord { + pub tenant: TenantId, + pub project: ProjectId, + pub user: UserId, + pub agent: AgentId, + pub public_key: String, + pub public_key_fingerprint: Digest, + pub version: u64, + pub revoked: bool, + pub scopes: Vec, + pub human_account_creation_privilege: bool, + pub browser_interaction_required_each_run: bool, +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct DurableState { pub tenants: BTreeMap, @@ -89,6 +105,7 @@ pub struct DurableState { BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>, pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>, pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>, + pub agent_public_keys: BTreeMap<(TenantId, ProjectId, AgentId), AgentPublicKeyRecord>, } pub trait DurableStore { @@ -329,6 +346,105 @@ impl Coordinator { ); } + pub fn register_agent_public_key( + &mut self, + tenant: TenantId, + project: ProjectId, + user: UserId, + agent: AgentId, + public_key: impl Into, + ) -> AgentPublicKeyRecord { + let key = (tenant.clone(), project.clone(), agent.clone()); + let version = self + .durable + .agent_public_keys + .get(&key) + .map_or(1, |record| record.version.saturating_add(1)); + let public_key = public_key.into(); + let public_key_fingerprint = Digest::sha256(&public_key); + let record = AgentPublicKeyRecord { + tenant: tenant.clone(), + project: project.clone(), + user: user.clone(), + agent: agent.clone(), + public_key, + public_key_fingerprint: public_key_fingerprint.clone(), + version, + revoked: false, + scopes: vec!["project:read".to_owned(), "project:run".to_owned()], + human_account_creation_privilege: false, + browser_interaction_required_each_run: false, + }; + self.durable.agent_public_keys.insert(key, record.clone()); + let subject = format!("agent:{tenant}:{project}:{agent}"); + self.durable.credentials.insert( + subject.clone(), + CredentialRecord { + subject, + tenant, + project: Some(project), + kind: CredentialKind::PublicKey, + public_key_fingerprint: Some(public_key_fingerprint), + }, + ); + record + } + + pub fn list_agent_public_keys(&self, context: &AuthContext) -> Vec { + self.durable + .agent_public_keys + .values() + .filter(|record| record.tenant == context.tenant && record.project == context.project) + .filter(|record| match &context.actor { + Actor::User(user) => &record.user == user, + Actor::Agent(agent) => &record.agent == agent, + Actor::Node(_) | Actor::Task(_) => false, + }) + .cloned() + .collect() + } + + pub fn revoke_agent_public_key( + &mut self, + context: &AuthContext, + agent: &AgentId, + ) -> Result { + let key = ( + context.tenant.clone(), + context.project.clone(), + agent.clone(), + ); + let record = self + .durable + .agent_public_keys + .get_mut(&key) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "agent public key is not registered for this project".to_owned(), + ) + })?; + match &context.actor { + Actor::User(user) if &record.user == user => {} + Actor::User(_) => { + return Err(CoordinatorError::Unauthorized( + "agent public key is outside the signed-in user scope".to_owned(), + )); + } + _ => { + return Err(CoordinatorError::Unauthorized( + "agent public-key revocation requires a user identity".to_owned(), + )); + } + } + record.revoked = true; + let subject = format!( + "agent:{}:{}:{}", + context.tenant, context.project, record.agent + ); + self.durable.credentials.remove(&subject); + Ok(record.clone()) + } + pub fn start_process( &mut self, tenant: TenantId, @@ -660,6 +776,80 @@ mod tests { assert_eq!(projects[0].id, ProjectId::from("project-a")); } + #[test] + fn agent_public_keys_are_project_user_scoped_and_restart_durable() { + let mut store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.upsert_tenant(TenantId::from("tenant")); + coordinator.upsert_user( + TenantId::from("tenant"), + UserId::from("user"), + CredentialKind::CliDeviceSession, + ); + coordinator.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo"); + + let registered = coordinator.register_agent_public_key( + TenantId::from("tenant"), + ProjectId::from("project"), + UserId::from("user"), + AgentId::from("agent-ci"), + "agent-key-v1", + ); + assert_eq!(registered.version, 1); + assert!(!registered.revoked); + assert!(!registered.human_account_creation_privilege); + assert_eq!(registered.scopes, vec!["project:read", "project:run"]); + + let rotated = coordinator.register_agent_public_key( + TenantId::from("tenant"), + ProjectId::from("project"), + UserId::from("user"), + AgentId::from("agent-ci"), + "agent-key-v2", + ); + assert_eq!(rotated.version, 2); + assert_eq!(rotated.public_key, "agent-key-v2"); + + coordinator.persist(&mut store); + let mut restarted = Coordinator::boot(&store, 2); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let listed = restarted.list_agent_public_keys(&context); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].agent, AgentId::from("agent-ci")); + assert_eq!(listed[0].version, 2); + + let foreign_user_context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("other-user")), + }; + assert!(restarted + .list_agent_public_keys(&foreign_user_context) + .is_empty()); + + let foreign_project_context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("other-project"), + actor: Actor::User(UserId::from("user")), + }; + assert!(restarted + .list_agent_public_keys(&foreign_project_context) + .is_empty()); + + let revoked = restarted + .revoke_agent_public_key(&context, &AgentId::from("agent-ci")) + .unwrap(); + assert!(revoked.revoked); + assert!(!restarted + .durable + .credentials + .contains_key("agent:tenant:project:agent-ci")); + } + #[test] fn node_cannot_claim_process_outside_authorized_scope() { let store = InMemoryDurableStore::default(); diff --git a/crates/disasmer-coordinator/src/postgres_store.rs b/crates/disasmer-coordinator/src/postgres_store.rs index fe944af..7cc7c1b 100644 --- a/crates/disasmer-coordinator/src/postgres_store.rs +++ b/crates/disasmer-coordinator/src/postgres_store.rs @@ -3,7 +3,7 @@ use serde::{de::DeserializeOwned, Serialize}; use thiserror::Error; use crate::{ - CredentialRecord, DurableState, FallibleDurableStore, NodeIdentityRecord, + AgentPublicKeyRecord, CredentialRecord, DurableState, FallibleDurableStore, NodeIdentityRecord, ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord, SourceProviderConfigRecord, TenantRecord, UserRecord, }; @@ -41,6 +41,11 @@ pub const POSTGRES_DURABLE_TABLES: &[PostgresTable] = &[ durable_record: "credentials", restart_surviving: true, }, + PostgresTable { + name: "disasmer_agent_public_keys", + durable_record: "agent public keys", + restart_surviving: true, + }, PostgresTable { name: "disasmer_source_provider_configs", durable_record: "source-provider configuration", @@ -148,6 +153,18 @@ impl FallibleDurableStore for PostgresDurableStore { )? { state.credentials.insert(record.subject.clone(), record); } + for record in self.query_records::( + "SELECT record FROM disasmer_agent_public_keys ORDER BY tenant_id, project_id, agent_id", + )? { + state.agent_public_keys.insert( + ( + record.tenant.clone(), + record.project.clone(), + record.agent.clone(), + ), + record, + ); + } for record in self.query_records::( "SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key", )? { @@ -187,6 +204,7 @@ impl FallibleDurableStore for PostgresDurableStore { DELETE FROM disasmer_project_permissions; DELETE FROM disasmer_service_policy_records; DELETE FROM disasmer_source_provider_configs; + DELETE FROM disasmer_agent_public_keys; DELETE FROM disasmer_credentials; DELETE FROM disasmer_node_identities; DELETE FROM disasmer_projects; @@ -236,6 +254,19 @@ impl FallibleDurableStore for PostgresDurableStore { &[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value], )?; } + for record in state.agent_public_keys.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_agent_public_keys (tenant_id, project_id, user_id, agent_id, record) VALUES ($1, $2, $3, $4, $5)", + &[ + &record.tenant.as_str(), + &record.project.as_str(), + &record.user.as_str(), + &record.agent.as_str(), + &value, + ], + )?; + } for ((_, _, provider_key), record) in &state.source_provider_configs { let value = Self::record_value(record)?; tx.execute( @@ -305,6 +336,15 @@ CREATE TABLE IF NOT EXISTS disasmer_credentials ( record JSONB NOT NULL ); +CREATE TABLE IF NOT EXISTS disasmer_agent_public_keys ( + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE, + agent_id TEXT NOT NULL, + record JSONB NOT NULL, + PRIMARY KEY (tenant_id, project_id, agent_id) +); + CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs ( tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, @@ -345,12 +385,13 @@ mod tests { .map(|table| table.name) .collect::>(); - assert_eq!(names.len(), 8); + assert_eq!(names.len(), 9); assert!(names.contains(&"disasmer_tenants")); assert!(names.contains(&"disasmer_users")); assert!(names.contains(&"disasmer_projects")); assert!(names.contains(&"disasmer_node_identities")); assert!(names.contains(&"disasmer_credentials")); + assert!(names.contains(&"disasmer_agent_public_keys")); assert!(names.contains(&"disasmer_source_provider_configs")); assert!(names.contains(&"disasmer_service_policy_records")); assert!(names.contains(&"disasmer_project_permissions")); diff --git a/crates/disasmer-coordinator/src/service.rs b/crates/disasmer-coordinator/src/service.rs index 6959ec0..e979d01 100644 --- a/crates/disasmer-coordinator/src/service.rs +++ b/crates/disasmer-coordinator/src/service.rs @@ -4,19 +4,21 @@ use std::net::{SocketAddr, TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use disasmer_core::{ - Actor, ArtifactId, ArtifactRegistry, Capability, CapabilityReportError, CredentialKind, - DataPlaneObject, DataPlaneScope, DefaultScheduler, Digest, DirectBulkTransferPlan, - DownloadLink, DownloadPolicy, EnvironmentRequirements, LimitError, LimitKind, - NativeQuicTransport, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, PanelEvent, - PanelEventKind, PanelState, PanelWidget, PanelWidgetKind, Placement, PlacementRequest, - ProcessId, ProjectId, RateLimit, RendezvousRequest, ResourceLimits, ResourceMeter, Scheduler, - SourcePreparation, SourceProviderKind, StorageLocation, TaskId, TenantId, TransportError, - UserId, VfsPath, + Actor, AgentId, ArtifactId, ArtifactRegistry, Capability, CapabilityReportError, + CredentialKind, DataPlaneObject, DataPlaneScope, DefaultScheduler, Digest, + DirectBulkTransferPlan, DownloadLink, DownloadPolicy, EnvironmentRequirements, LimitError, + LimitKind, NativeQuicTransport, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, + PanelEvent, PanelEventKind, PanelState, PanelWidget, PanelWidgetKind, Placement, + PlacementRequest, ProcessId, ProjectId, RateLimit, RendezvousRequest, ResourceLimits, + ResourceMeter, Scheduler, SourcePreparation, SourceProviderKind, StorageLocation, TaskId, + TenantId, TransportError, UserId, VfsPath, }; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::{Coordinator, CoordinatorError, InMemoryDurableStore, ProjectRecord}; +use crate::{ + AgentPublicKeyRecord, Coordinator, CoordinatorError, InMemoryDurableStore, ProjectRecord, +}; const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024; @@ -39,6 +41,31 @@ pub enum CoordinatorRequest { tenant: String, actor_user: String, }, + RegisterAgentPublicKey { + tenant: String, + project: String, + user: String, + agent: String, + public_key: String, + }, + ListAgentPublicKeys { + tenant: String, + project: String, + user: String, + }, + RotateAgentPublicKey { + tenant: String, + project: String, + user: String, + agent: String, + public_key: String, + }, + RevokeAgentPublicKey { + tenant: String, + project: String, + user: String, + agent: String, + }, AttachNode { tenant: String, project: String, @@ -406,6 +433,14 @@ pub enum CoordinatorResponse { projects: Vec, actor: UserId, }, + AgentPublicKey { + record: AgentPublicKeyRecord, + actor: UserId, + }, + AgentPublicKeys { + records: Vec, + actor: UserId, + }, NodeAttached { node: NodeId, tenant: TenantId, @@ -703,6 +738,87 @@ impl CoordinatorService { actor, }) } + CoordinatorRequest::RegisterAgentPublicKey { + tenant, + project, + user, + agent, + public_key, + } + | CoordinatorRequest::RotateAgentPublicKey { + tenant, + project, + user, + agent, + public_key, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(user); + let agent = AgentId::new(agent); + if let Some(existing) = self.coordinator.project(&project) { + if existing.tenant != tenant { + return Err(CoordinatorError::Unauthorized( + "project id is outside the signed-in tenant scope".to_owned(), + ) + .into()); + } + } + self.coordinator.upsert_tenant(tenant.clone()); + self.coordinator.upsert_user( + tenant.clone(), + actor.clone(), + CredentialKind::CliDeviceSession, + ); + self.coordinator + .upsert_project(tenant.clone(), project.clone(), "local"); + let record = self.coordinator.register_agent_public_key( + tenant, + project, + actor.clone(), + agent, + public_key, + ); + self.coordinator.persist(&mut self.store); + Ok(CoordinatorResponse::AgentPublicKey { record, actor }) + } + CoordinatorRequest::ListAgentPublicKeys { + tenant, + project, + user, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(user); + let context = disasmer_core::AuthContext { + tenant, + project, + actor: Actor::User(actor.clone()), + }; + Ok(CoordinatorResponse::AgentPublicKeys { + records: self.coordinator.list_agent_public_keys(&context), + actor, + }) + } + CoordinatorRequest::RevokeAgentPublicKey { + tenant, + project, + user, + agent, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(user); + let agent = AgentId::new(agent); + let context = disasmer_core::AuthContext { + tenant, + project, + actor: Actor::User(actor.clone()), + }; + let record = self.coordinator.revoke_agent_public_key(&context, &agent)?; + self.coordinator.persist(&mut self.store); + Ok(CoordinatorResponse::AgentPublicKey { record, actor }) + } CoordinatorRequest::AttachNode { tenant, project, @@ -2295,6 +2411,113 @@ mod tests { assert!(cross_tenant.to_string().contains("tenant scope")); } + #[test] + fn service_manages_project_scoped_agent_public_keys() { + let mut service = CoordinatorService::new(7); + + let CoordinatorResponse::AgentPublicKey { record, actor } = service + .handle_request(CoordinatorRequest::RegisterAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + agent: "agent-ci".to_owned(), + public_key: "agent-key-v1".to_owned(), + }) + .unwrap() + else { + panic!("expected agent public key registration"); + }; + assert_eq!(actor, UserId::from("user")); + assert_eq!(record.tenant, TenantId::from("tenant")); + assert_eq!(record.project, ProjectId::from("project")); + assert_eq!(record.user, UserId::from("user")); + assert_eq!(record.agent, AgentId::from("agent-ci")); + assert_eq!(record.version, 1); + assert!(!record.revoked); + assert_eq!(record.scopes, vec!["project:read", "project:run"]); + assert!(!record.human_account_creation_privilege); + assert!(!record.browser_interaction_required_each_run); + + let CoordinatorResponse::AgentPublicKey { record, .. } = service + .handle_request(CoordinatorRequest::RegisterAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + agent: "agent-ci".to_owned(), + public_key: "agent-key-v2".to_owned(), + }) + .unwrap() + else { + panic!("expected agent public key rotation by re-add"); + }; + assert_eq!(record.version, 2); + assert_eq!(record.public_key, "agent-key-v2"); + + let CoordinatorResponse::AgentPublicKeys { records, actor } = service + .handle_request(CoordinatorRequest::ListAgentPublicKeys { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected agent public key list"); + }; + assert_eq!(actor, UserId::from("user")); + assert_eq!(records.len(), 1); + assert_eq!(records[0].agent, AgentId::from("agent-ci")); + + let CoordinatorResponse::AgentPublicKeys { records, .. } = service + .handle_request(CoordinatorRequest::ListAgentPublicKeys { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "other-user".to_owned(), + }) + .unwrap() + else { + panic!("expected foreign user agent public key list"); + }; + assert!(records.is_empty()); + + let CoordinatorResponse::AgentPublicKeys { records, .. } = service + .handle_request(CoordinatorRequest::ListAgentPublicKeys { + tenant: "other-tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected foreign tenant agent public key list"); + }; + assert!(records.is_empty()); + + let foreign_user_revoke = service + .handle_request(CoordinatorRequest::RevokeAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "other-user".to_owned(), + agent: "agent-ci".to_owned(), + }) + .unwrap_err(); + assert!(foreign_user_revoke + .to_string() + .contains("signed-in user scope")); + + let CoordinatorResponse::AgentPublicKey { record, actor } = service + .handle_request(CoordinatorRequest::RevokeAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + agent: "agent-ci".to_owned(), + }) + .unwrap() + else { + panic!("expected agent public key revocation"); + }; + assert_eq!(actor, UserId::from("user")); + assert!(record.revoked); + } + #[test] fn service_attaches_node_starts_process_and_records_scoped_task_event() { let mut service = CoordinatorService::new(7); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 2568eb4..b14f320 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -54,7 +54,7 @@ expect( expect( criteria, "future hosted business non-goal", - /billing, paid-plan checkout, full support tooling, broad moderation consoles, and durable account\/business-process management are intentionally outside this CLI-first MVP slice/ + /billing, paid-plan checkout, team\/org management, provider setup wizards, secret-manager UI, full support tooling, broad moderation consoles, and durable account\/business-process management are intentionally outside this CLI-first MVP slice/ ); const lines = criterionLines(criteria); @@ -105,6 +105,7 @@ for (const [name, pattern] of [ ["CLI version coverage", /fn top_level_version_is_available\(\)/], ["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/], ["CLI human output coverage", /fn human_report_is_text_not_json\(\)/], + ["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/], ["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/], ["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/], ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], @@ -131,6 +132,11 @@ expect( "coordinator single active process coverage", /fn service_rejects_second_active_process_unless_restarting_same_process\(\)/ ); +expect( + coordinator, + "coordinator agent key lifecycle coverage", + /fn service_manages_project_scoped_agent_public_keys\(\)/ +); for (const [name, pattern] of [ ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], diff --git a/scripts/user-session-token-boundary-smoke.js b/scripts/user-session-token-boundary-smoke.js index 0483677..bb0b7fe 100755 --- a/scripts/user-session-token-boundary-smoke.js +++ b/scripts/user-session-token-boundary-smoke.js @@ -46,6 +46,10 @@ function assertNoUserSessionCredential(surface, text) { const coordinatorService = read("crates/disasmer-coordinator/src/service.rs"); for (const variant of [ "AttachNode", + "RegisterAgentPublicKey", + "ListAgentPublicKeys", + "RotateAgentPublicKey", + "RevokeAgentPublicKey", "NodeHeartbeat", "ReportNodeCapabilities", "RequestRendezvous",