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

@ -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]