diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 28f49cb..c67d5b7 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "e50394e956649c00db26cca4307bc244988ccecd", - "release_name": "dryrun-e50394e95664", + "source_commit": "ed1fbc0c464627dc5cd56d5b01343bd084e04c95", + "release_name": "dryrun-ed1fbc0c4646", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 5c38ff2..abe365b 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1525,6 +1525,16 @@ fn debug_attach_report_with_dap(args: DebugAttachArgs, dap: String) -> Result Result, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DebugAuditEvent { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub task: Option, + pub actor: UserId, + pub operation: String, + pub allowed: bool, + pub reason: String, + pub charged_debug_read_bytes: u64, + pub used_debug_read_bytes: u64, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TaskAssignment { pub tenant: TenantId, @@ -575,6 +590,9 @@ pub enum CoordinatorResponse { completed_event_observed: bool, requires_whole_process_restart: bool, message: String, + audit_event: DebugAuditEvent, + charged_debug_read_bytes: u64, + used_debug_read_bytes: u64, }, DebugCommand { process: ProcessId, @@ -585,6 +603,9 @@ pub enum CoordinatorResponse { process: ProcessId, actor: UserId, authorization: Authorization, + audit_event: DebugAuditEvent, + charged_debug_read_bytes: u64, + used_debug_read_bytes: u64, }, TaskLogRecorded { process: ProcessId, @@ -670,6 +691,7 @@ pub struct CoordinatorService { node_descriptors: BTreeMap, enrollment_grants: BTreeMap, task_events: Vec, + debug_audit_events: Vec, task_assignments: BTreeMap>, active_tasks: BTreeSet, task_cancellations: BTreeSet, @@ -683,6 +705,8 @@ pub struct CoordinatorService { rendezvous_meter: ResourceMeter, download_limits: ResourceLimits, download_meter: ResourceMeter, + debug_limits: ResourceLimits, + debug_meter: ResourceMeter, } impl CoordinatorService { @@ -695,6 +719,7 @@ impl CoordinatorService { node_descriptors: BTreeMap::new(), enrollment_grants: BTreeMap::new(), task_events: Vec::new(), + debug_audit_events: Vec::new(), task_assignments: BTreeMap::new(), active_tasks: BTreeSet::new(), task_cancellations: BTreeSet::new(), @@ -708,6 +733,8 @@ impl CoordinatorService { rendezvous_meter: ResourceMeter::default(), download_limits: ResourceLimits::community_tier_defaults(), download_meter: ResourceMeter::default(), + debug_limits: ResourceLimits::community_tier_defaults(), + debug_meter: ResourceMeter::default(), } } @@ -1613,6 +1640,16 @@ impl CoordinatorService { ); let authorization = self.coordinator.authorize_debug_attach(&context, &process); if !authorization.allowed { + let _ = self.record_debug_audit_event( + tenant, + project, + process, + Some(task), + actor, + "restart_task", + false, + authorization.reason.clone(), + )?; return Err(CoordinatorError::Unauthorized(format!( "task restart denied: {}", authorization.reason @@ -1643,6 +1680,16 @@ impl CoordinatorService { } else { "selected task is not known in the active process; restart the whole virtual process or inspect task list" }; + let audit_event = self.record_debug_audit_event( + tenant, + project, + process.clone(), + Some(task.clone()), + actor.clone(), + "restart_task", + true, + message, + )?; Ok(CoordinatorResponse::TaskRestart { process, task, @@ -1653,6 +1700,9 @@ impl CoordinatorService { completed_event_observed, requires_whole_process_restart: true, message: message.to_owned(), + charged_debug_read_bytes: audit_event.charged_debug_read_bytes, + used_debug_read_bytes: audit_event.used_debug_read_bytes, + audit_event, }) } CoordinatorRequest::DebugAttach { @@ -1670,13 +1720,29 @@ impl CoordinatorService { project: project.clone(), actor: Actor::User(actor.clone()), }; - self.coordinator - .upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession); + self.coordinator.upsert_user( + tenant.clone(), + actor.clone(), + CredentialKind::BrowserSession, + ); let authorization = self.coordinator.authorize_debug_attach(&context, &process); + let audit_event = self.record_debug_audit_event( + tenant, + project, + process.clone(), + None, + actor.clone(), + "debug_attach", + authorization.allowed, + authorization.reason.clone(), + )?; Ok(CoordinatorResponse::DebugAttach { process, actor, authorization, + charged_debug_read_bytes: audit_event.charged_debug_read_bytes, + used_debug_read_bytes: audit_event.used_debug_read_bytes, + audit_event, }) } CoordinatorRequest::PollDebugCommand { @@ -2142,6 +2208,43 @@ impl CoordinatorService { } } + fn record_debug_audit_event( + &mut self, + tenant: TenantId, + project: ProjectId, + process: ProcessId, + task: Option, + actor: UserId, + operation: &'static str, + allowed: bool, + reason: impl Into, + ) -> Result { + let charged_debug_read_bytes = if allowed { + self.debug_meter.charge( + &self.debug_limits, + LimitKind::DebugReadBytes, + DEBUG_CONTROL_READ_BYTES, + )?; + DEBUG_CONTROL_READ_BYTES + } else { + 0 + }; + let event = DebugAuditEvent { + tenant, + project, + process, + task, + actor, + operation: operation.to_owned(), + allowed, + reason: reason.into(), + charged_debug_read_bytes, + used_debug_read_bytes: self.debug_meter.used(&LimitKind::DebugReadBytes), + }; + self.debug_audit_events.push(event.clone()); + Ok(event) + } + fn export_endpoint( &self, node: &NodeId, @@ -3200,6 +3303,9 @@ mod tests { process, actor, authorization, + audit_event, + charged_debug_read_bytes, + used_debug_read_bytes, } = service .handle_request(CoordinatorRequest::DebugAttach { tenant: "tenant".to_owned(), @@ -3214,8 +3320,19 @@ mod tests { assert_eq!(process, ProcessId::from("process")); assert_eq!(actor, UserId::from("user")); assert!(authorization.allowed); + assert_eq!(audit_event.operation, "debug_attach"); + assert_eq!(audit_event.actor, UserId::from("user")); + assert!(audit_event.allowed); + assert_eq!(charged_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); - let CoordinatorResponse::DebugAttach { authorization, .. } = service + let CoordinatorResponse::DebugAttach { + authorization, + audit_event, + charged_debug_read_bytes, + used_debug_read_bytes, + .. + } = service .handle_request(CoordinatorRequest::DebugAttach { tenant: "tenant".to_owned(), project: "project".to_owned(), @@ -3228,8 +3345,18 @@ mod tests { }; assert!(!authorization.allowed); assert!(authorization.reason.contains("explicit project permission")); + assert!(!audit_event.allowed); + assert_eq!(audit_event.charged_debug_read_bytes, 0); + assert_eq!(charged_debug_read_bytes, 0); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); - let CoordinatorResponse::DebugAttach { authorization, .. } = service + let CoordinatorResponse::DebugAttach { + authorization, + audit_event, + charged_debug_read_bytes, + used_debug_read_bytes, + .. + } = service .handle_request(CoordinatorRequest::DebugAttach { tenant: "other-tenant".to_owned(), project: "project".to_owned(), @@ -3242,6 +3369,10 @@ mod tests { }; assert!(!authorization.allowed); assert!(authorization.reason.contains("tenant or project")); + assert!(!audit_event.allowed); + assert_eq!(charged_debug_read_bytes, 0); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); + assert_eq!(service.debug_audit_events.len(), 3); } #[test] @@ -3282,6 +3413,9 @@ mod tests { completed_event_observed, requires_whole_process_restart, message, + audit_event, + charged_debug_read_bytes, + used_debug_read_bytes, .. } = service .handle_request(CoordinatorRequest::RestartTask { @@ -3301,6 +3435,11 @@ mod tests { assert!(!completed_event_observed); assert!(requires_whole_process_restart); assert!(message.contains("not known")); + assert_eq!(audit_event.operation, "restart_task"); + assert_eq!(audit_event.task, Some(TaskId::from("task"))); + assert!(audit_event.allowed); + assert_eq!(charged_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); service .handle_request(CoordinatorRequest::AttachNode { @@ -3349,6 +3488,8 @@ mod tests { active_task, completed_event_observed, message, + audit_event, + used_debug_read_bytes, .. } = service .handle_request(CoordinatorRequest::RestartTask { @@ -3365,6 +3506,11 @@ mod tests { assert!(active_task); assert!(!completed_event_observed); assert!(message.contains("still active")); + assert_eq!( + audit_event.charged_debug_read_bytes, + DEBUG_CONTROL_READ_BYTES + ); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES * 2); service .handle_request(CoordinatorRequest::TaskCompleted { @@ -3391,6 +3537,8 @@ mod tests { active_task, completed_event_observed, message, + audit_event, + used_debug_read_bytes, .. } = service .handle_request(CoordinatorRequest::RestartTask { @@ -3407,6 +3555,12 @@ mod tests { assert!(!active_task); assert!(completed_event_observed); assert!(message.contains("terminal event metadata")); + assert_eq!( + audit_event.charged_debug_read_bytes, + DEBUG_CONTROL_READ_BYTES + ); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES * 3); + assert_eq!(service.debug_audit_events.len(), 4); } #[test] diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index cb99791..0b98034 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -161,6 +161,21 @@ expect( "coordinator task restart boundary coverage", /fn service_reports_task_restart_boundary_through_public_api\(\)/ ); +expect( + coordinator, + "public debug operation audit event", + /pub struct DebugAuditEvent[\s\S]*charged_debug_read_bytes[\s\S]*used_debug_read_bytes/ +); +expect( + coordinator, + "public debug operation metering", + /record_debug_audit_event\([\s\S]*LimitKind::DebugReadBytes[\s\S]*DEBUG_CONTROL_READ_BYTES/ +); +expect( + cli, + "CLI surfaces debug audit and quota fields", + /debug_reads_quota_limited[\s\S]*charged_debug_read_bytes[\s\S]*used_debug_read_bytes/ +); for (const [name, pattern] of [ ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],