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

@ -1481,13 +1481,27 @@ fn quota_status_report(args: QuotaStatusArgs, cwd: PathBuf) -> Result<Value> {
fn admin_status_report(args: AdminStatusArgs) -> Result<Value> {
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<Value> {
.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!({

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) {
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();

View file

@ -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);