diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index e926f47..3aa0153 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "1bb23a07ddd082b491fd20b9be7bd7a6f9c46916", - "release_name": "dryrun-1bb23a07ddd0", + "source_commit": "00a47f2c6f213d43fcef11fdf7926320241da30d", + "release_name": "dryrun-00a47f2c6f21", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 1a0df0c..eaccb7b 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1481,13 +1481,27 @@ fn quota_status_report(args: QuotaStatusArgs, cwd: PathBuf) -> Result { fn admin_status_report(args: AdminStatusArgs) -> Result { if let Some(coordinator) = &args.scope.coordinator { + let tenant = args.scope.tenant.clone(); + let user = args.scope.user.clone(); let mut session = JsonLineSession::connect(coordinator)?; - let response = session.request(json!({ "type": "ping" }))?; + let response = session.request(json!({ + "type": "admin_status", + "tenant": tenant, + "actor_user": user, + }))?; return Ok(json!({ "command": "admin status", "coordinator": coordinator, + "tenant": tenant, + "user": user, + "suspended": response + .get("suspended") + .cloned() + .unwrap_or_else(|| json!(false)), "response": response, "safe_default": "read_only", + "private_website_required": false, + "coordinator_session_requests": session.requests(), })); } Ok(json!({ @@ -1516,24 +1530,34 @@ fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result { .target_tenant .unwrap_or_else(|| args.scope.tenant.clone()); if let Some(coordinator) = &args.scope.coordinator { + let actor_tenant = args.scope.tenant.clone(); + let actor_user = args.scope.user.clone(); let mut session = JsonLineSession::connect(coordinator)?; let response = session.request(json!({ "type": "suspend_tenant", - "tenant": tenant, + "tenant": actor_tenant, + "actor_user": actor_user, + "target_tenant": tenant, }))?; return Ok(json!({ "command": "admin suspend-tenant", "coordinator": coordinator, "requires_confirmation": !args.yes, + "tenant": tenant, + "actor_tenant": actor_tenant, + "actor_user": actor_user, + "suspended": response.get("type").and_then(Value::as_str) == Some("tenant_suspended"), + "private_website_required": false, "response": response, "coordinator_session_requests": session.requests(), })); } Ok(json!({ "command": "admin suspend-tenant", - "status": "requires_private_hosted_coordinator", + "status": "requires_coordinator", "requires_confirmation": !args.yes, "tenant": tenant, + "private_website_required": false, })) } @@ -4501,6 +4525,69 @@ mod tests { assert_eq!(revoked["node_credentials_separate_from_user_session"], true); } + #[test] + fn admin_status_and_suspend_use_public_coordinator_api() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for (expected, response) in [ + ( + "admin_status", + r#"{"type":"admin_status","tenant":"tenant","actor":"admin","suspended":false,"safe_default":"read_only"}"#, + ), + ( + "suspend_tenant", + r#"{"type":"tenant_suspended","tenant":"tenant","actor":"admin","policy":{"tenant":"tenant","name":"tenant:suspended","digest":"sha256:suspension"}}"#, + ), + ] { + 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":"admin-tenant""#) + || line.contains(r#""tenant":"tenant""#) + ); + assert!(line.contains(r#""actor_user":"admin""#)); + if expected == "suspend_tenant" { + assert!(line.contains(r#""target_tenant":"tenant""#)); + } + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "admin-tenant".to_owned(), + project: "project".to_owned(), + user: "admin".to_owned(), + json: false, + }; + + let status = admin_status_report(AdminStatusArgs { + scope: scope.clone(), + }) + .unwrap(); + let suspended = admin_suspend_tenant_report(AdminSuspendTenantArgs { + scope, + target_tenant: Some("tenant".to_owned()), + yes: true, + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!(status["command"], "admin status"); + assert_eq!(status["safe_default"], "read_only"); + assert_eq!(status["private_website_required"], false); + assert_eq!(status["suspended"], false); + assert_eq!(suspended["command"], "admin suspend-tenant"); + assert_eq!(suspended["tenant"], "tenant"); + assert_eq!(suspended["actor_tenant"], "admin-tenant"); + assert_eq!(suspended["suspended"], true); + assert_eq!(suspended["private_website_required"], false); + } + #[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 49538f0..8b080f5 100644 --- a/crates/disasmer-coordinator/src/lib.rs +++ b/crates/disasmer-coordinator/src/lib.rs @@ -334,6 +334,34 @@ impl Coordinator { ); } + pub fn suspend_tenant(&mut self, tenant: TenantId, actor: UserId) -> ServicePolicyRecord { + self.upsert_tenant(tenant.clone()); + let name = "tenant:suspended".to_owned(); + let digest = Digest::from_parts([ + b"tenant-suspension:v1".as_slice(), + tenant.as_str().as_bytes(), + actor.as_str().as_bytes(), + ]); + self.upsert_service_policy_record(tenant.clone(), name.clone(), digest); + self.service_policy_record(&tenant, &name) + .expect("tenant suspension record was just inserted") + .clone() + } + + pub fn tenant_suspended(&self, tenant: &TenantId) -> bool { + self.service_policy_record(tenant, "tenant:suspended") + .is_some() + } + + pub fn ensure_tenant_active(&self, tenant: &TenantId) -> Result<(), CoordinatorError> { + if self.tenant_suspended(tenant) { + return Err(CoordinatorError::Unauthorized( + "tenant is suspended by admin controls".to_owned(), + )); + } + Ok(()) + } + pub fn grant_project_debug(&mut self, tenant: TenantId, project: ProjectId, user: UserId) { self.durable.project_permissions.insert( (tenant.clone(), project.clone(), user.clone()), @@ -870,6 +898,27 @@ mod tests { assert_eq!(projects[0].id, ProjectId::from("project-a")); } + #[test] + fn tenant_suspension_is_durable_admin_policy_state() { + let mut store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + let record = + coordinator.suspend_tenant(TenantId::from("tenant"), UserId::from("admin-user")); + + assert_eq!(record.tenant, TenantId::from("tenant")); + assert_eq!(record.name, "tenant:suspended"); + assert!(coordinator.tenant_suspended(&TenantId::from("tenant"))); + assert!(coordinator + .ensure_tenant_active(&TenantId::from("tenant")) + .unwrap_err() + .to_string() + .contains("suspended")); + + coordinator.persist(&mut store); + let restarted = Coordinator::boot(&store, 2); + assert!(restarted.tenant_suspended(&TenantId::from("tenant"))); + } + #[test] fn agent_public_keys_are_project_user_scoped_and_restart_durable() { let mut store = InMemoryDurableStore::default(); diff --git a/crates/disasmer-coordinator/src/service.rs b/crates/disasmer-coordinator/src/service.rs index 3ab305e..f4e177d 100644 --- a/crates/disasmer-coordinator/src/service.rs +++ b/crates/disasmer-coordinator/src/service.rs @@ -26,6 +26,15 @@ const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024; #[serde(tag = "type", rename_all = "snake_case")] pub enum CoordinatorRequest { Ping, + AdminStatus { + tenant: String, + actor_user: String, + }, + SuspendTenant { + tenant: String, + actor_user: String, + target_tenant: String, + }, CreateProject { tenant: String, actor_user: String, @@ -427,6 +436,17 @@ pub enum CoordinatorResponse { Pong { epoch: u64, }, + AdminStatus { + tenant: TenantId, + actor: UserId, + suspended: bool, + safe_default: String, + }, + TenantSuspended { + tenant: TenantId, + actor: UserId, + policy: crate::ServicePolicyRecord, + }, ProjectCreated { project: ProjectRecord, actor: UserId, @@ -670,6 +690,40 @@ impl CoordinatorService { CoordinatorRequest::Ping => Ok(CoordinatorResponse::Pong { epoch: self.coordinator.coordinator_epoch(), }), + CoordinatorRequest::AdminStatus { tenant, actor_user } => { + let tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + Ok(CoordinatorResponse::AdminStatus { + suspended: self.coordinator.tenant_suspended(&tenant), + tenant, + actor, + safe_default: "read_only".to_owned(), + }) + } + CoordinatorRequest::SuspendTenant { + tenant, + actor_user, + target_tenant, + } => { + let actor_tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + let target_tenant = TenantId::new(target_tenant); + self.coordinator.upsert_tenant(actor_tenant.clone()); + self.coordinator.upsert_user( + actor_tenant, + actor.clone(), + CredentialKind::CliDeviceSession, + ); + let policy = self + .coordinator + .suspend_tenant(target_tenant.clone(), actor.clone()); + self.coordinator.persist(&mut self.store); + Ok(CoordinatorResponse::TenantSuspended { + tenant: target_tenant, + actor, + policy, + }) + } CoordinatorRequest::CreateProject { tenant, actor_user, @@ -679,6 +733,7 @@ impl CoordinatorService { let tenant = TenantId::new(tenant); let actor = UserId::new(actor_user); let project = ProjectId::new(project); + self.coordinator.ensure_tenant_active(&tenant)?; if let Some(existing) = self.coordinator.project(&project) { if existing.tenant != tenant { return Err(CoordinatorError::Unauthorized( @@ -770,6 +825,7 @@ impl CoordinatorService { let project = ProjectId::new(project); let actor = UserId::new(user); let agent = AgentId::new(agent); + self.coordinator.ensure_tenant_active(&tenant)?; if let Some(existing) = self.coordinator.project(&project) { if existing.tenant != tenant { return Err(CoordinatorError::Unauthorized( @@ -842,6 +898,7 @@ impl CoordinatorService { let tenant = TenantId::new(tenant); let project = ProjectId::new(project); let node = NodeId::new(node); + self.coordinator.ensure_tenant_active(&tenant)?; self.coordinator.upsert_tenant(tenant.clone()); self.coordinator.upsert_user( tenant.clone(), @@ -881,6 +938,7 @@ impl CoordinatorService { let tenant = TenantId::new(tenant); let project = ProjectId::new(project); let actor = UserId::new(actor_user); + self.coordinator.ensure_tenant_active(&tenant)?; self.coordinator.upsert_tenant(tenant.clone()); self.coordinator.upsert_user( tenant.clone(), @@ -926,6 +984,7 @@ impl CoordinatorService { let tenant = TenantId::new(tenant); let project = ProjectId::new(project); let node = NodeId::new(node); + self.coordinator.ensure_tenant_active(&tenant)?; let grant = self .enrollment_grants .get_mut(&enrollment_grant_key(&tenant, &project, &enrollment_grant)) @@ -1336,6 +1395,7 @@ impl CoordinatorService { let tenant = TenantId::new(tenant); let project = ProjectId::new(project); let process = ProcessId::new(process); + self.coordinator.ensure_tenant_active(&tenant)?; if let Some(active) = self .coordinator .active_process_for_project(&tenant, &project) @@ -2464,6 +2524,79 @@ mod tests { assert!(cross_tenant.to_string().contains("tenant scope")); } + #[test] + fn service_reports_and_enforces_public_admin_tenant_suspension() { + let mut service = CoordinatorService::new(7); + + let CoordinatorResponse::AdminStatus { + tenant, + actor, + suspended, + safe_default, + } = service + .handle_request(CoordinatorRequest::AdminStatus { + tenant: "tenant".to_owned(), + actor_user: "admin".to_owned(), + }) + .unwrap() + else { + panic!("expected admin status"); + }; + assert_eq!(tenant, TenantId::from("tenant")); + assert_eq!(actor, UserId::from("admin")); + assert!(!suspended); + assert_eq!(safe_default, "read_only"); + + let CoordinatorResponse::TenantSuspended { + tenant, + actor, + policy, + } = service + .handle_request(CoordinatorRequest::SuspendTenant { + tenant: "admin-tenant".to_owned(), + actor_user: "admin".to_owned(), + target_tenant: "tenant".to_owned(), + }) + .unwrap() + else { + panic!("expected tenant suspension"); + }; + assert_eq!(tenant, TenantId::from("tenant")); + assert_eq!(actor, UserId::from("admin")); + assert_eq!(policy.name, "tenant:suspended"); + + let CoordinatorResponse::AdminStatus { suspended, .. } = service + .handle_request(CoordinatorRequest::AdminStatus { + tenant: "tenant".to_owned(), + actor_user: "admin".to_owned(), + }) + .unwrap() + else { + panic!("expected suspended admin status"); + }; + assert!(suspended); + + let create = service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant".to_owned(), + actor_user: "user".to_owned(), + project: "project".to_owned(), + name: "Demo".to_owned(), + }) + .unwrap_err(); + assert!(create.to_string().contains("tenant is suspended")); + + let start = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + restart: false, + }) + .unwrap_err(); + assert!(start.to_string().contains("tenant is suspended")); + } + #[test] fn service_manages_project_scoped_agent_public_keys() { let mut service = CoordinatorService::new(7); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 7be9468..24b8e6f 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -107,6 +107,7 @@ for (const [name, pattern] of [ ["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\(\)/], + ["CLI admin public API coverage", /fn admin_status_and_suspend_use_public_coordinator_api\(\)/], ["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\(\)/], @@ -143,6 +144,11 @@ expect( "coordinator node revoke coverage", /fn service_revokes_node_credentials_and_live_descriptors\(\)/ ); +expect( + coordinator, + "coordinator public admin suspension coverage", + /fn service_reports_and_enforces_public_admin_tenant_suspension\(\)/ +); 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 7ef6189..cfab810 100755 --- a/scripts/user-session-token-boundary-smoke.js +++ b/scripts/user-session-token-boundary-smoke.js @@ -45,6 +45,8 @@ function assertNoUserSessionCredential(surface, text) { const coordinatorService = read("crates/disasmer-coordinator/src/service.rs"); for (const variant of [ + "AdminStatus", + "SuspendTenant", "AttachNode", "RegisterAgentPublicKey", "ListAgentPublicKeys",