Audit and meter public debug operations

This commit is contained in:
Michel Paulissen 2026-07-03 21:39:58 +02:00
parent 0b14889c79
commit 28779a2866
5 changed files with 211 additions and 10 deletions

View file

@ -1525,6 +1525,16 @@ fn debug_attach_report_with_dap(args: DebugAttachArgs, dap: String) -> Result<Va
.cloned()
.unwrap_or_else(|| json!(false)),
"authorization": authorization,
"audit_event": response.get("audit_event").cloned().unwrap_or(Value::Null),
"charged_debug_read_bytes": response
.get("charged_debug_read_bytes")
.cloned()
.unwrap_or_else(|| json!(0)),
"used_debug_read_bytes": response
.get("used_debug_read_bytes")
.cloned()
.unwrap_or_else(|| json!(0)),
"debug_reads_quota_limited": true,
"private_website_required": false,
"coordinator_session_requests": session.requests(),
}));
@ -1537,6 +1547,7 @@ fn debug_attach_report_with_dap(args: DebugAttachArgs, dap: String) -> Result<Va
"project": args.scope.project,
"dap": dap,
"authorized": "unknown_without_coordinator",
"debug_reads_quota_limited": "unknown_without_coordinator",
"private_website_required": false,
}))
}
@ -2688,6 +2699,16 @@ fn task_restart_request_summary(response: &Value, requires_confirmation: bool) -
.cloned()
.unwrap_or_else(|| json!(true)),
"message": response.get("message").cloned().unwrap_or(Value::Null),
"audit_event": response.get("audit_event").cloned().unwrap_or(Value::Null),
"charged_debug_read_bytes": response
.get("charged_debug_read_bytes")
.cloned()
.unwrap_or_else(|| json!(0)),
"used_debug_read_bytes": response
.get("used_debug_read_bytes")
.cloned()
.unwrap_or_else(|| json!(0)),
"debug_reads_quota_limited": true,
"website_required": false,
})
}
@ -4744,7 +4765,7 @@ mod tests {
assert!(line.contains(r#""process":"vp""#));
stream
.write_all(
br#"{"type":"debug_attach","process":"vp","actor":"user","authorization":{"allowed":true,"reason":"debug attach authorized for project"}}"#,
br#"{"type":"debug_attach","process":"vp","actor":"user","authorization":{"allowed":true,"reason":"debug attach authorized for project"},"audit_event":{"tenant":"tenant","project":"project","process":"vp","task":null,"actor":"user","operation":"debug_attach","allowed":true,"reason":"debug attach authorized for project","charged_debug_read_bytes":1024,"used_debug_read_bytes":1024},"charged_debug_read_bytes":1024,"used_debug_read_bytes":1024}"#,
)
.unwrap();
stream.write_all(b"\n").unwrap();
@ -4773,6 +4794,10 @@ mod tests {
report["authorization"]["reason"],
"debug attach authorized for project"
);
assert_eq!(report["audit_event"]["operation"], "debug_attach");
assert_eq!(report["charged_debug_read_bytes"], 1024);
assert_eq!(report["used_debug_read_bytes"], 1024);
assert_eq!(report["debug_reads_quota_limited"], true);
assert_eq!(report["private_website_required"], false);
}
@ -5309,7 +5334,7 @@ mod tests {
assert!(line.contains(r#""task":"compile-linux""#));
stream
.write_all(
br#"{"type":"task_restart","process":"vp","task":"compile-linux","actor":"user","accepted":false,"clean_boundary_available":false,"active_task":true,"completed_event_observed":false,"requires_whole_process_restart":true,"message":"selected task is still active; clean task restart requires a captured checkpoint boundary"}"#,
br#"{"type":"task_restart","process":"vp","task":"compile-linux","actor":"user","accepted":false,"clean_boundary_available":false,"active_task":true,"completed_event_observed":false,"requires_whole_process_restart":true,"message":"selected task is still active; clean task restart requires a captured checkpoint boundary","audit_event":{"tenant":"tenant","project":"project","process":"vp","task":"compile-linux","actor":"user","operation":"restart_task","allowed":true,"reason":"selected task is still active; clean task restart requires a captured checkpoint boundary","charged_debug_read_bytes":1024,"used_debug_read_bytes":1024},"charged_debug_read_bytes":1024,"used_debug_read_bytes":1024}"#,
)
.unwrap();
stream.write_all(b"\n").unwrap();
@ -5342,6 +5367,13 @@ mod tests {
true
);
assert_eq!(report["restart_request"]["active_task"], true);
assert_eq!(
report["restart_request"]["audit_event"]["operation"],
"restart_task"
);
assert_eq!(report["restart_request"]["charged_debug_read_bytes"], 1024);
assert_eq!(report["restart_request"]["used_debug_read_bytes"], 1024);
assert_eq!(report["restart_request"]["debug_reads_quota_limited"], true);
assert_eq!(report["restart_request"]["website_required"], false);
assert_eq!(report["coordinator_session_requests"], 1);
}

View file

@ -15,8 +15,8 @@ pub use postgres_store::{
};
pub use service::{
CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError,
SourcePreparationDisposition, SourcePreparationStatus, TaskAssignment, TaskCancellationTarget,
TaskCompletionEvent, TaskTerminalState,
DebugAuditEvent, SourcePreparationDisposition, SourcePreparationStatus, TaskAssignment,
TaskCancellationTarget, TaskCompletionEvent, TaskTerminalState,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -21,6 +21,7 @@ use crate::{
};
const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024;
const DEBUG_CONTROL_READ_BYTES: u64 = 1024;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@ -411,6 +412,20 @@ pub struct TaskCompletionEvent {
pub artifact_size_bytes: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugAuditEvent {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub task: Option<TaskId>,
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<NodeId, NodeDescriptor>,
enrollment_grants: BTreeMap<EnrollmentGrantKey, disasmer_core::EnrollmentGrant>,
task_events: Vec<TaskCompletionEvent>,
debug_audit_events: Vec<DebugAuditEvent>,
task_assignments: BTreeMap<TaskAssignmentKey, VecDeque<TaskAssignment>>,
active_tasks: BTreeSet<TaskControlKey>,
task_cancellations: BTreeSet<TaskControlKey>,
@ -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<TaskId>,
actor: UserId,
operation: &'static str,
allowed: bool,
reason: impl Into<String>,
) -> Result<DebugAuditEvent, CoordinatorServiceError> {
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]