diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 07e1634..e926f47 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "6341cce4cf9f801a3bfe2248d6e7e6a119cea715", - "release_name": "dryrun-6341cce4cf9f", + "source_commit": "1bb23a07ddd082b491fd20b9be7bd7a6f9c46916", + "release_name": "dryrun-1bb23a07ddd0", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 697fd25..1a0df0c 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1153,15 +1153,36 @@ fn node_descriptors_report( fn node_revoke_report(args: NodeRevokeArgs) -> 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 node = args.node.clone(); let mut session = JsonLineSession::connect(coordinator)?; let response = session.request(json!({ "type": "revoke_node_credential", - "node": args.node, + "tenant": tenant, + "project": project, + "actor_user": user, + "node": node, }))?; return Ok(json!({ "command": "node revoke", "coordinator": coordinator, "requires_confirmation": !args.yes, + "tenant": tenant, + "project": project, + "user": user, + "node": node, + "credential_revoked": response.get("type").and_then(Value::as_str) == Some("node_credential_revoked"), + "descriptor_removed": response + .get("descriptor_removed") + .cloned() + .unwrap_or_else(|| json!(false)), + "queued_assignments_removed": response + .get("queued_assignments_removed") + .cloned() + .unwrap_or_else(|| json!(0)), + "node_credentials_separate_from_user_session": true, "response": response, "coordinator_session_requests": session.requests(), })); @@ -4436,6 +4457,50 @@ mod tests { assert_eq!(revoked["attribution"]["revoked_by_user"], "user"); } + #[test] + fn node_revoke_reports_scoped_credential_revocation() { + 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 (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(r#""type":"revoke_node_credential""#)); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""actor_user":"user""#)); + assert!(line.contains(r#""node":"node-a""#)); + stream + .write_all( + br#"{"type":"node_credential_revoked","node":"node-a","tenant":"tenant","project":"project","actor":"user","descriptor_removed":true,"queued_assignments_removed":2}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let revoked = node_revoke_report(NodeRevokeArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + node: "node-a".to_owned(), + yes: true, + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!(revoked["command"], "node revoke"); + assert_eq!(revoked["node"], "node-a"); + assert_eq!(revoked["credential_revoked"], true); + assert_eq!(revoked["descriptor_removed"], true); + assert_eq!(revoked["queued_assignments_removed"], 2); + assert_eq!(revoked["node_credentials_separate_from_user_session"], true); + } + #[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 6ba35e9..49538f0 100644 --- a/crates/disasmer-coordinator/src/lib.rs +++ b/crates/disasmer-coordinator/src/lib.rs @@ -516,6 +516,35 @@ impl Coordinator { Ok(()) } + pub fn revoke_node_credential( + &mut self, + context: &AuthContext, + node: &NodeId, + ) -> Result { + let identity = self + .durable + .node_identities + .get(node) + .ok_or(CoordinatorError::UnknownNode)? + .clone(); + if identity.tenant != context.tenant || identity.project != context.project { + return Err(CoordinatorError::Unauthorized( + "node credential is outside the signed-in tenant/project scope".to_owned(), + )); + } + if !matches!(context.actor, Actor::User(_)) { + return Err(CoordinatorError::Unauthorized( + "node credential revocation requires a user identity".to_owned(), + )); + } + self.durable.node_identities.remove(node); + self.durable.credentials.remove(&format!("node:{node}")); + for active in self.active_processes.values_mut() { + active.connected_nodes.remove(node); + } + Ok(identity) + } + pub fn list_projects(&self, context: &AuthContext) -> Vec { self.durable .projects @@ -751,6 +780,71 @@ mod tests { assert!(coordinator.node_identity(&NodeId::from("node")).is_some()); } + #[test] + fn node_credential_revocation_is_project_scoped_and_removes_identity() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.enroll_node( + TenantId::from("tenant"), + ProjectId::from("project"), + NodeId::from("node"), + "public-key", + "node:attach", + ); + coordinator.durable.credentials.insert( + "node:node".to_owned(), + CredentialRecord { + subject: "node:node".to_owned(), + tenant: TenantId::from("tenant"), + project: Some(ProjectId::from("project")), + kind: CredentialKind::NodeCredential, + public_key_fingerprint: Some(Digest::sha256("public-key")), + }, + ); + coordinator.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process"), + ); + coordinator + .reconnect_node( + &NodeId::from("node"), + Some((&ProcessId::from("process"), 1)), + ) + .unwrap(); + + let foreign = coordinator + .revoke_node_credential( + &AuthContext { + tenant: TenantId::from("other"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }, + &NodeId::from("node"), + ) + .unwrap_err(); + assert!(matches!(foreign, CoordinatorError::Unauthorized(_))); + + let revoked = coordinator + .revoke_node_credential( + &AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }, + &NodeId::from("node"), + ) + .unwrap(); + assert_eq!(revoked.id, NodeId::from("node")); + assert!(coordinator.node_identity(&NodeId::from("node")).is_none()); + assert!(!coordinator.durable.credentials.contains_key("node:node")); + assert!(!coordinator + .active_process(&ProcessId::from("process")) + .unwrap() + .connected_nodes + .contains(&NodeId::from("node"))); + } + #[test] fn project_listing_is_filtered_by_tenant() { let store = InMemoryDurableStore::default(); diff --git a/crates/disasmer-coordinator/src/service.rs b/crates/disasmer-coordinator/src/service.rs index e979d01..3ab305e 100644 --- a/crates/disasmer-coordinator/src/service.rs +++ b/crates/disasmer-coordinator/src/service.rs @@ -112,6 +112,12 @@ pub enum CoordinatorRequest { project: String, actor_user: String, }, + RevokeNodeCredential { + tenant: String, + project: String, + actor_user: String, + node: String, + }, ScheduleTask { tenant: String, project: String, @@ -471,6 +477,14 @@ pub enum CoordinatorResponse { descriptors: Vec, actor: UserId, }, + NodeCredentialRevoked { + node: NodeId, + tenant: TenantId, + project: ProjectId, + actor: UserId, + descriptor_removed: bool, + queued_assignments_removed: usize, + }, TaskPlacement { placement: Placement, }, @@ -1017,6 +1031,45 @@ impl CoordinatorService { .collect(); Ok(CoordinatorResponse::NodeDescriptors { descriptors, actor }) } + CoordinatorRequest::RevokeNodeCredential { + tenant, + project, + actor_user, + node, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let node = NodeId::new(node); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor.clone()), + }; + self.coordinator.revoke_node_credential(&context, &node)?; + let descriptor_removed = self.node_descriptors.remove(&node).is_some(); + let queued_assignments_removed = self + .task_assignments + .remove(&(tenant.clone(), project.clone(), node.clone())) + .map_or(0, |assignments| assignments.len()); + self.active_tasks + .retain(|(task_tenant, task_project, _, task_node, _)| { + task_tenant != &tenant || task_project != &project || task_node != &node + }); + self.task_cancellations + .retain(|(task_tenant, task_project, _, task_node, _)| { + task_tenant != &tenant || task_project != &project || task_node != &node + }); + self.coordinator.persist(&mut self.store); + Ok(CoordinatorResponse::NodeCredentialRevoked { + node, + tenant, + project, + actor, + descriptor_removed, + queued_assignments_removed, + }) + } CoordinatorRequest::ScheduleTask { tenant, project, @@ -2682,6 +2735,78 @@ mod tests { assert!(events.is_empty()); } + #[test] + fn service_revokes_node_credentials_and_live_descriptors() { + let mut service = CoordinatorService::new(7); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: vec![], + dependency_cache_digests: vec![], + source_snapshots: vec![], + artifact_locations: vec![], + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::NodeCredentialRevoked { + node, + tenant, + project, + actor, + descriptor_removed, + queued_assignments_removed, + } = service + .handle_request(CoordinatorRequest::RevokeNodeCredential { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + node: "node".to_owned(), + }) + .unwrap() + else { + panic!("expected node credential revocation"); + }; + assert_eq!(node, NodeId::from("node")); + assert_eq!(tenant, TenantId::from("tenant")); + assert_eq!(project, ProjectId::from("project")); + assert_eq!(actor, UserId::from("user")); + assert!(descriptor_removed); + assert_eq!(queued_assignments_removed, 0); + + let heartbeat = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + }) + .unwrap_err(); + assert!(heartbeat.to_string().contains("not enrolled")); + + let CoordinatorResponse::NodeDescriptors { descriptors, .. } = service + .handle_request(CoordinatorRequest::ListNodeDescriptors { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected node descriptors"); + }; + assert!(descriptors.is_empty()); + } + #[test] fn service_delivers_cancellation_to_connected_node_and_records_terminal_state() { let mut service = CoordinatorService::new(7); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index b14f320..7be9468 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -106,6 +106,7 @@ for (const [name, pattern] of [ ["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\(\)/], + ["CLI node revoke coverage", /fn node_revoke_reports_scoped_credential_revocation\(\)/], ["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\(\)/], @@ -137,6 +138,11 @@ expect( "coordinator agent key lifecycle coverage", /fn service_manages_project_scoped_agent_public_keys\(\)/ ); +expect( + coordinator, + "coordinator node revoke coverage", + /fn service_revokes_node_credentials_and_live_descriptors\(\)/ +); 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 bb0b7fe..7ef6189 100755 --- a/scripts/user-session-token-boundary-smoke.js +++ b/scripts/user-session-token-boundary-smoke.js @@ -52,6 +52,7 @@ for (const variant of [ "RevokeAgentPublicKey", "NodeHeartbeat", "ReportNodeCapabilities", + "RevokeNodeCredential", "RequestRendezvous", "RequestSourcePreparation", "CompleteSourcePreparation",