diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index d884b57..741e327 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "bc3642c6900c2551532a7e2263e822fd1c668ba8", - "release_name": "dryrun-bc3642c6900c", + "source_commit": "7aeb51ff791bd445afc2d51757239b6e306db015", + "release_name": "dryrun-7aeb51ff791b", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 3d48d2f..93b6ad9 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1007,6 +1007,7 @@ fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { "checked": false, "reason": "no project or session coordinator configured", "suspension_known": false, + "account_state_known": false, "account_status": "unknown", "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, @@ -1040,6 +1041,7 @@ fn coordinator_auth_status_summary( "source": "public_coordinator_api", "account_status": "unknown", "suspension_known": false, + "account_state_known": false, "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, "machine_error": cli_error_summary(&message), @@ -1064,6 +1066,7 @@ fn coordinator_auth_status_summary( "source": "public_coordinator_api", "account_status": "unknown", "suspension_known": false, + "account_state_known": false, "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, "machine_error": cli_error_summary(&message), @@ -1085,6 +1088,7 @@ fn coordinator_auth_status_summary( "source": "public_coordinator_api", "account_status": "unknown", "suspension_known": false, + "account_state_known": false, "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, "machine_error": cli_error_summary(message), @@ -1101,15 +1105,27 @@ fn coordinator_auth_status_summary( .get("disabled") .and_then(Value::as_bool) .unwrap_or(false); + let deleted = response + .get("deleted") + .and_then(Value::as_bool) + .unwrap_or(false); + let manual_review = response + .get("manual_review") + .and_then(Value::as_bool) + .unwrap_or(false); let account_status = response .get("account_status") .and_then(Value::as_str) .map(str::to_owned) .unwrap_or_else(|| { - if suspended { - "suspended" + if deleted { + "deleted" } else if disabled { "disabled" + } else if suspended { + "suspended" + } else if manual_review { + "manual_review" } else { "active" } @@ -1130,8 +1146,11 @@ fn coordinator_auth_status_summary( "source": "public_coordinator_api", "account_status": account_status, "suspension_known": true, + "account_state_known": true, "suspended": suspended, "disabled": disabled, + "deleted": deleted, + "manual_review": manual_review, "sanitized_reason": sanitized_reason, "next_actions": next_actions, "private_moderation_details_exposed": false, @@ -2887,6 +2906,12 @@ fn human_report(value: &Value) -> String { if let Some(disabled) = account.get("disabled").and_then(Value::as_bool) { lines.push(format!("account disabled: {disabled}")); } + if let Some(deleted) = account.get("deleted").and_then(Value::as_bool) { + lines.push(format!("account deleted: {deleted}")); + } + if let Some(manual_review) = account.get("manual_review").and_then(Value::as_bool) { + lines.push(format!("account manual review: {manual_review}")); + } push_nested_string_field(&mut lines, account, "sanitized_reason", "account reason"); if let Some(exposed) = account .get("private_moderation_details_exposed") @@ -7842,6 +7867,117 @@ mod tests { assert!(!rendered.contains("private moderation note")); } + #[test] + fn auth_status_reports_disabled_deleted_and_manual_review_safely() { + 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 (tenant, status, reason, flags) in [ + ( + "tenant-disabled", + "disabled", + "account or tenant is disabled by hosted policy", + (false, true, false, false), + ), + ( + "tenant-deleted", + "deleted", + "account or tenant is no longer active", + (false, false, true, false), + ), + ( + "tenant-review", + "manual_review", + "account or tenant is pending hosted review", + (false, false, false, true), + ), + ] { + 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":"auth_status""#)); + assert!(line.contains(&format!(r#""tenant":"{tenant}""#))); + let (suspended, disabled, deleted, manual_review) = flags; + writeln!( + stream, + "{}", + json!({ + "type": "auth_status", + "tenant": tenant, + "project": "project-live", + "actor": "user-live", + "authenticated": true, + "account_status": status, + "suspended": suspended, + "disabled": disabled, + "deleted": deleted, + "manual_review": manual_review, + "sanitized_reason": reason, + "next_actions": ["contact the hosted operator"], + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "abuse_score": 99, + "moderation_notes": "private moderation note", + "signup_policy_trace": "private signup trace", + }) + ) + .unwrap(); + } + }); + + for (tenant, status, rendered_marker) in [ + ("tenant-disabled", "disabled", "account disabled: true"), + ("tenant-deleted", "deleted", "account deleted: true"), + ( + "tenant-review", + "manual_review", + "account manual review: true", + ), + ] { + let temp = tempfile::tempdir().unwrap(); + let report = auth_status_report( + AuthStatusArgs { + scope: CliScopeArgs { + coordinator: Some(addr.clone()), + tenant: tenant.to_owned(), + project: "project-live".to_owned(), + user: "user-live".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!( + report["coordinator_account_status"]["account_status"], + status + ); + assert_eq!( + report["coordinator_account_status"]["account_state_known"], + true + ); + assert_eq!( + report["coordinator_account_status"]["private_moderation_details_exposed"], + false + ); + assert_eq!( + report["coordinator_account_status"]["signup_failure_details_exposed"], + false + ); + let serialized = serde_json::to_string(&report).unwrap(); + assert!(!serialized.contains("abuse_score")); + assert!(!serialized.contains("moderation_notes")); + assert!(!serialized.contains("signup_policy_trace")); + assert!(!serialized.contains("private moderation note")); + let rendered = human_report(&report); + assert!(rendered.contains(&format!("account status: {status}"))); + assert!(rendered.contains(rendered_marker)); + assert!(!rendered.contains("private moderation note")); + } + server.join().unwrap(); + } + #[test] fn cli_first_mvp_command_surface_parses() { for args in [ diff --git a/crates/disasmer-coordinator/src/lib.rs b/crates/disasmer-coordinator/src/lib.rs index 74048f6..1618b7e 100644 --- a/crates/disasmer-coordinator/src/lib.rs +++ b/crates/disasmer-coordinator/src/lib.rs @@ -71,6 +71,17 @@ pub struct ServicePolicyRecord { pub digest: Digest, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountPolicyState { + pub account_status: String, + pub suspended: bool, + pub disabled: bool, + pub deleted: bool, + pub manual_review: bool, + pub sanitized_reason: Option, + pub next_actions: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ProjectPermissionRecord { pub tenant: TenantId, @@ -353,11 +364,71 @@ impl Coordinator { .is_some() } + pub fn tenant_disabled(&self, tenant: &TenantId) -> bool { + self.service_policy_record(tenant, "tenant:disabled") + .is_some() + } + + pub fn tenant_deleted(&self, tenant: &TenantId) -> bool { + self.service_policy_record(tenant, "tenant:deleted") + .is_some() + } + + pub fn tenant_manual_review(&self, tenant: &TenantId) -> bool { + self.service_policy_record(tenant, "tenant:manual_review") + .is_some() + } + + pub fn account_policy_state(&self, tenant: &TenantId) -> AccountPolicyState { + let suspended = self.tenant_suspended(tenant); + let disabled = self.tenant_disabled(tenant); + let deleted = self.tenant_deleted(tenant); + let manual_review = self.tenant_manual_review(tenant); + let account_status = if deleted { + "deleted" + } else if disabled { + "disabled" + } else if suspended { + "suspended" + } else if manual_review { + "manual_review" + } else { + "active" + } + .to_owned(); + let sanitized_reason = match account_status.as_str() { + "deleted" => Some("account or tenant is no longer active".to_owned()), + "disabled" => Some("account or tenant is disabled by hosted policy".to_owned()), + "suspended" => Some("account or tenant is suspended by hosted policy".to_owned()), + "manual_review" => Some("account or tenant is pending hosted review".to_owned()), + _ => None, + }; + let next_actions = if account_status == "active" { + Vec::new() + } else { + vec![ + "disasmer auth status --json".to_owned(), + "contact the hosted operator or use a self-hosted coordinator".to_owned(), + ] + }; + AccountPolicyState { + account_status, + suspended, + disabled, + deleted, + manual_review, + sanitized_reason, + next_actions, + } + } + 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(), - )); + let account_state = self.account_policy_state(tenant); + if account_state.account_status != "active" { + return Err(CoordinatorError::Unauthorized(format!( + "tenant is {} by admin controls", + account_state.account_status + ))); } Ok(()) } @@ -953,6 +1024,63 @@ mod tests { assert!(restarted.tenant_suspended(&TenantId::from("tenant"))); } + #[test] + fn account_policy_state_summarizes_private_admin_records_safely() { + let tenant = TenantId::from("tenant"); + let mut coordinator = Coordinator::boot(&InMemoryDurableStore::default(), 1); + + let active = coordinator.account_policy_state(&tenant); + assert_eq!(active.account_status, "active"); + assert!(active.sanitized_reason.is_none()); + assert!(active.next_actions.is_empty()); + + coordinator.upsert_service_policy_record( + tenant.clone(), + "tenant:manual_review", + Digest::from_parts([b"manual-review".as_slice()]), + ); + let manual_review = coordinator.account_policy_state(&tenant); + assert_eq!(manual_review.account_status, "manual_review"); + assert!(manual_review.manual_review); + assert_eq!( + manual_review.sanitized_reason.as_deref(), + Some("account or tenant is pending hosted review") + ); + + coordinator.upsert_service_policy_record( + tenant.clone(), + "tenant:disabled", + Digest::from_parts([b"disabled".as_slice()]), + ); + let disabled = coordinator.account_policy_state(&tenant); + assert_eq!(disabled.account_status, "disabled"); + assert!(disabled.disabled); + assert!(disabled.manual_review); + assert_eq!( + disabled.sanitized_reason.as_deref(), + Some("account or tenant is disabled by hosted policy") + ); + + coordinator.upsert_service_policy_record( + tenant.clone(), + "tenant:deleted", + Digest::from_parts([b"deleted".as_slice()]), + ); + let deleted = coordinator.account_policy_state(&tenant); + assert_eq!(deleted.account_status, "deleted"); + assert!(deleted.deleted); + assert!(deleted.disabled); + assert!(deleted.manual_review); + assert_eq!( + deleted.sanitized_reason.as_deref(), + Some("account or tenant is no longer active") + ); + assert!(deleted + .next_actions + .iter() + .any(|action| action.contains("hosted operator"))); + } + #[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 973f906..de1561a 100644 --- a/crates/disasmer-coordinator/src/service.rs +++ b/crates/disasmer-coordinator/src/service.rs @@ -502,6 +502,8 @@ pub enum CoordinatorResponse { account_status: String, suspended: bool, disabled: bool, + deleted: bool, + manual_review: bool, sanitized_reason: Option, next_actions: Vec, private_moderation_details_exposed: bool, @@ -807,28 +809,19 @@ impl CoordinatorService { let tenant = TenantId::new(tenant); let project = ProjectId::new(project); let actor = UserId::new(actor_user); - let suspended = self.coordinator.tenant_suspended(&tenant); - let account_status = if suspended { "suspended" } else { "active" }.to_owned(); - let sanitized_reason = - suspended.then(|| "account or tenant is suspended by hosted policy".to_owned()); - let next_actions = if suspended { - vec![ - "disasmer auth status --json".to_owned(), - "contact the hosted operator or use a self-hosted coordinator".to_owned(), - ] - } else { - Vec::new() - }; + let account_state = self.coordinator.account_policy_state(&tenant); Ok(CoordinatorResponse::AuthStatus { tenant, project, actor, authenticated: true, - account_status, - suspended, - disabled: false, - sanitized_reason, - next_actions, + account_status: account_state.account_status, + suspended: account_state.suspended, + disabled: account_state.disabled, + deleted: account_state.deleted, + manual_review: account_state.manual_review, + sanitized_reason: account_state.sanitized_reason, + next_actions: account_state.next_actions, private_moderation_details_exposed: false, signup_failure_details_exposed: false, }) @@ -2994,6 +2987,65 @@ mod tests { assert!(!private_moderation_details_exposed); assert!(!signup_failure_details_exposed); + for (tenant, policy_name, expected_status, expected_reason) in [ + ( + "manual-tenant", + "tenant:manual_review", + "manual_review", + "account or tenant is pending hosted review", + ), + ( + "disabled-tenant", + "tenant:disabled", + "disabled", + "account or tenant is disabled by hosted policy", + ), + ( + "deleted-tenant", + "tenant:deleted", + "deleted", + "account or tenant is no longer active", + ), + ] { + service.coordinator.upsert_service_policy_record( + TenantId::from(tenant), + policy_name, + Digest::from_parts([tenant.as_bytes(), policy_name.as_bytes()]), + ); + let CoordinatorResponse::AuthStatus { + account_status, + suspended, + disabled, + deleted, + manual_review, + sanitized_reason, + next_actions, + private_moderation_details_exposed, + signup_failure_details_exposed, + .. + } = service + .handle_request(CoordinatorRequest::AuthStatus { + tenant: tenant.to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected inactive auth status"); + }; + assert_eq!(account_status, expected_status); + assert_eq!(suspended, expected_status == "suspended"); + assert_eq!(disabled, expected_status == "disabled"); + assert_eq!(deleted, expected_status == "deleted"); + assert_eq!(manual_review, expected_status == "manual_review"); + assert_eq!(sanitized_reason.as_deref(), Some(expected_reason)); + assert!(next_actions + .iter() + .any(|action| action.contains("hosted operator"))); + assert!(!private_moderation_details_exposed); + assert!(!signup_failure_details_exposed); + } + let CoordinatorResponse::AdminStatus { tenant, actor, diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 9096234..c32b64e 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -33,6 +33,7 @@ function expect(source, name, pattern) { const criteria = read("cli_acceptance_criteria.md"); const cli = read("crates/disasmer-cli/src/main.rs"); const coordinator = read("crates/disasmer-coordinator/src/service.rs"); +const coordinatorLib = read("crates/disasmer-coordinator/src/lib.rs"); const cliFirstAcceptance = read("scripts/acceptance-cli-first.sh"); const outputModeSmoke = read("scripts/cli-output-mode-smoke.js"); const errorExitSmoke = read("scripts/cli-error-exit-smoke.js"); @@ -96,6 +97,11 @@ expect( "hosted signup sanitized failure criteria", /Signup failures do not disclose moderation, abuse-scoring, or allow\/deny internals[\s\S]*stripping issuer, allowlist, abuse-score, moderation-note, and provider-secret details/ ); +expect( + criteria, + "hosted account state privacy criteria", + /Account suspension, deletion, manual review, and abuse handling are private hosted-admin concerns[\s\S]*public CLI displays clear suspended, disabled, deleted, and manual-review status[\s\S]*strips private moderation\/signup details/ +); const lines = criterionLines(criteria); assert(lines.length > 0, "CLI-first criteria must contain criteria lines"); @@ -507,6 +513,16 @@ expect( "CLI auth status does not expose raw private moderation response fields", /fn auth_status_queries_coordinator_account_state_without_private_moderation_details[\s\S]*abuse_score[\s\S]*moderation_notes[\s\S]*!serialized\.contains\("abuse_score"\)[\s\S]*!serialized\.contains\("moderation_notes"\)/ ); +expect( + cli, + "CLI auth status reports inactive account states safely", + /fn auth_status_reports_disabled_deleted_and_manual_review_safely[\s\S]*account_status[\s\S]*disabled[\s\S]*deleted[\s\S]*manual_review[\s\S]*!serialized\.contains\("signup_policy_trace"\)/ +); +expect( + coordinatorLib, + "coordinator summarizes private account policy state safely", + /fn tenant_disabled\(&self, tenant: &TenantId\) -> bool[\s\S]*tenant:disabled[\s\S]*fn tenant_deleted\(&self, tenant: &TenantId\) -> bool[\s\S]*tenant:deleted[\s\S]*fn tenant_manual_review\(&self, tenant: &TenantId\) -> bool[\s\S]*tenant:manual_review[\s\S]*fn account_policy_state\(&self, tenant: &TenantId\) -> AccountPolicyState[\s\S]*"deleted"[\s\S]*"disabled"[\s\S]*"manual_review"[\s\S]*"account or tenant is pending hosted review"/ +); expect( cli, "CLI renders sanitized account status",