Audit and meter public debug operations
This commit is contained in:
parent
0b14889c79
commit
28779a2866
5 changed files with 211 additions and 10 deletions
|
|
@ -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/**",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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/],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue