Public dry run dryrun-00a47f2c6f21

Source commit: 00a47f2c6f213d43fcef11fdf7926320241da30d

Public tree identity: sha256:c41c788b9938cfa66d0c047c16729942bfe6ba4f884acec7d4cd482da762f81a
This commit is contained in:
Michel Paulissen 2026-07-03 21:08:32 +02:00
parent a40778221a
commit 005b47e4c0
6 changed files with 282 additions and 5 deletions

View file

@ -1,7 +1,7 @@
{ {
"kind": "disasmer-filtered-public-tree", "kind": "disasmer-filtered-public-tree",
"source_commit": "1bb23a07ddd082b491fd20b9be7bd7a6f9c46916", "source_commit": "00a47f2c6f213d43fcef11fdf7926320241da30d",
"release_name": "dryrun-1bb23a07ddd0", "release_name": "dryrun-00a47f2c6f21",
"filtered_out": [ "filtered_out": [
"private/**", "private/**",
"experiments/**", "experiments/**",

View file

@ -1481,13 +1481,27 @@ fn quota_status_report(args: QuotaStatusArgs, cwd: PathBuf) -> Result<Value> {
fn admin_status_report(args: AdminStatusArgs) -> Result<Value> { fn admin_status_report(args: AdminStatusArgs) -> Result<Value> {
if let Some(coordinator) = &args.scope.coordinator { 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 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!({ return Ok(json!({
"command": "admin status", "command": "admin status",
"coordinator": coordinator, "coordinator": coordinator,
"tenant": tenant,
"user": user,
"suspended": response
.get("suspended")
.cloned()
.unwrap_or_else(|| json!(false)),
"response": response, "response": response,
"safe_default": "read_only", "safe_default": "read_only",
"private_website_required": false,
"coordinator_session_requests": session.requests(),
})); }));
} }
Ok(json!({ Ok(json!({
@ -1516,24 +1530,34 @@ fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result<Value> {
.target_tenant .target_tenant
.unwrap_or_else(|| args.scope.tenant.clone()); .unwrap_or_else(|| args.scope.tenant.clone());
if let Some(coordinator) = &args.scope.coordinator { 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 mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(json!({ let response = session.request(json!({
"type": "suspend_tenant", "type": "suspend_tenant",
"tenant": tenant, "tenant": actor_tenant,
"actor_user": actor_user,
"target_tenant": tenant,
}))?; }))?;
return Ok(json!({ return Ok(json!({
"command": "admin suspend-tenant", "command": "admin suspend-tenant",
"coordinator": coordinator, "coordinator": coordinator,
"requires_confirmation": !args.yes, "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, "response": response,
"coordinator_session_requests": session.requests(), "coordinator_session_requests": session.requests(),
})); }));
} }
Ok(json!({ Ok(json!({
"command": "admin suspend-tenant", "command": "admin suspend-tenant",
"status": "requires_private_hosted_coordinator", "status": "requires_coordinator",
"requires_confirmation": !args.yes, "requires_confirmation": !args.yes,
"tenant": tenant, "tenant": tenant,
"private_website_required": false,
})) }))
} }
@ -4501,6 +4525,69 @@ mod tests {
assert_eq!(revoked["node_credentials_separate_from_user_session"], true); 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] #[test]
fn human_report_is_text_not_json() { fn human_report_is_text_not_json() {
let report = json!({ let report = json!({

View file

@ -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) { pub fn grant_project_debug(&mut self, tenant: TenantId, project: ProjectId, user: UserId) {
self.durable.project_permissions.insert( self.durable.project_permissions.insert(
(tenant.clone(), project.clone(), user.clone()), (tenant.clone(), project.clone(), user.clone()),
@ -870,6 +898,27 @@ mod tests {
assert_eq!(projects[0].id, ProjectId::from("project-a")); 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] #[test]
fn agent_public_keys_are_project_user_scoped_and_restart_durable() { fn agent_public_keys_are_project_user_scoped_and_restart_durable() {
let mut store = InMemoryDurableStore::default(); let mut store = InMemoryDurableStore::default();

View file

@ -26,6 +26,15 @@ const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024;
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum CoordinatorRequest { pub enum CoordinatorRequest {
Ping, Ping,
AdminStatus {
tenant: String,
actor_user: String,
},
SuspendTenant {
tenant: String,
actor_user: String,
target_tenant: String,
},
CreateProject { CreateProject {
tenant: String, tenant: String,
actor_user: String, actor_user: String,
@ -427,6 +436,17 @@ pub enum CoordinatorResponse {
Pong { Pong {
epoch: u64, epoch: u64,
}, },
AdminStatus {
tenant: TenantId,
actor: UserId,
suspended: bool,
safe_default: String,
},
TenantSuspended {
tenant: TenantId,
actor: UserId,
policy: crate::ServicePolicyRecord,
},
ProjectCreated { ProjectCreated {
project: ProjectRecord, project: ProjectRecord,
actor: UserId, actor: UserId,
@ -670,6 +690,40 @@ impl CoordinatorService {
CoordinatorRequest::Ping => Ok(CoordinatorResponse::Pong { CoordinatorRequest::Ping => Ok(CoordinatorResponse::Pong {
epoch: self.coordinator.coordinator_epoch(), 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 { CoordinatorRequest::CreateProject {
tenant, tenant,
actor_user, actor_user,
@ -679,6 +733,7 @@ impl CoordinatorService {
let tenant = TenantId::new(tenant); let tenant = TenantId::new(tenant);
let actor = UserId::new(actor_user); let actor = UserId::new(actor_user);
let project = ProjectId::new(project); let project = ProjectId::new(project);
self.coordinator.ensure_tenant_active(&tenant)?;
if let Some(existing) = self.coordinator.project(&project) { if let Some(existing) = self.coordinator.project(&project) {
if existing.tenant != tenant { if existing.tenant != tenant {
return Err(CoordinatorError::Unauthorized( return Err(CoordinatorError::Unauthorized(
@ -770,6 +825,7 @@ impl CoordinatorService {
let project = ProjectId::new(project); let project = ProjectId::new(project);
let actor = UserId::new(user); let actor = UserId::new(user);
let agent = AgentId::new(agent); let agent = AgentId::new(agent);
self.coordinator.ensure_tenant_active(&tenant)?;
if let Some(existing) = self.coordinator.project(&project) { if let Some(existing) = self.coordinator.project(&project) {
if existing.tenant != tenant { if existing.tenant != tenant {
return Err(CoordinatorError::Unauthorized( return Err(CoordinatorError::Unauthorized(
@ -842,6 +898,7 @@ impl CoordinatorService {
let tenant = TenantId::new(tenant); let tenant = TenantId::new(tenant);
let project = ProjectId::new(project); let project = ProjectId::new(project);
let node = NodeId::new(node); let node = NodeId::new(node);
self.coordinator.ensure_tenant_active(&tenant)?;
self.coordinator.upsert_tenant(tenant.clone()); self.coordinator.upsert_tenant(tenant.clone());
self.coordinator.upsert_user( self.coordinator.upsert_user(
tenant.clone(), tenant.clone(),
@ -881,6 +938,7 @@ impl CoordinatorService {
let tenant = TenantId::new(tenant); let tenant = TenantId::new(tenant);
let project = ProjectId::new(project); let project = ProjectId::new(project);
let actor = UserId::new(actor_user); let actor = UserId::new(actor_user);
self.coordinator.ensure_tenant_active(&tenant)?;
self.coordinator.upsert_tenant(tenant.clone()); self.coordinator.upsert_tenant(tenant.clone());
self.coordinator.upsert_user( self.coordinator.upsert_user(
tenant.clone(), tenant.clone(),
@ -926,6 +984,7 @@ impl CoordinatorService {
let tenant = TenantId::new(tenant); let tenant = TenantId::new(tenant);
let project = ProjectId::new(project); let project = ProjectId::new(project);
let node = NodeId::new(node); let node = NodeId::new(node);
self.coordinator.ensure_tenant_active(&tenant)?;
let grant = self let grant = self
.enrollment_grants .enrollment_grants
.get_mut(&enrollment_grant_key(&tenant, &project, &enrollment_grant)) .get_mut(&enrollment_grant_key(&tenant, &project, &enrollment_grant))
@ -1336,6 +1395,7 @@ impl CoordinatorService {
let tenant = TenantId::new(tenant); let tenant = TenantId::new(tenant);
let project = ProjectId::new(project); let project = ProjectId::new(project);
let process = ProcessId::new(process); let process = ProcessId::new(process);
self.coordinator.ensure_tenant_active(&tenant)?;
if let Some(active) = self if let Some(active) = self
.coordinator .coordinator
.active_process_for_project(&tenant, &project) .active_process_for_project(&tenant, &project)
@ -2464,6 +2524,79 @@ mod tests {
assert!(cross_tenant.to_string().contains("tenant scope")); 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] #[test]
fn service_manages_project_scoped_agent_public_keys() { fn service_manages_project_scoped_agent_public_keys() {
let mut service = CoordinatorService::new(7); let mut service = CoordinatorService::new(7);

View file

@ -107,6 +107,7 @@ for (const [name, pattern] of [
["CLI human output coverage", /fn human_report_is_text_not_json\(\)/], ["CLI human output coverage", /fn human_report_is_text_not_json\(\)/],
["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/], ["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/],
["CLI node revoke coverage", /fn node_revoke_reports_scoped_credential_revocation\(\)/], ["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 unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/],
["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/], ["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/],
["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/],
@ -143,6 +144,11 @@ expect(
"coordinator node revoke coverage", "coordinator node revoke coverage",
/fn service_revokes_node_credentials_and_live_descriptors\(\)/ /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 [ for (const [name, pattern] of [
["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],

View file

@ -45,6 +45,8 @@ function assertNoUserSessionCredential(surface, text) {
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs"); const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
for (const variant of [ for (const variant of [
"AdminStatus",
"SuspendTenant",
"AttachNode", "AttachNode",
"RegisterAgentPublicKey", "RegisterAgentPublicKey",
"ListAgentPublicKeys", "ListAgentPublicKeys",