use super::*;
use crate::ops_health_diagnostics::{scoped_audit_events, scoped_notification_events};
impl BucketWarden {
pub fn event_category_report(
&mut self,
principal: &str,
bucket: Option<&str>,
) -> Result<EventCategoryReport, RuntimeError> {
let (scope, target) = event_scope_and_target(bucket);
self.require_operator_action(
principal,
OperatorAction::ReadAudit,
&target,
"ops:GetEventCategoryReport",
)?;
self.require_ops_bucket_scope(bucket)?;
let notification_events = scoped_notification_events(self, bucket);
let audit_events = scoped_audit_events(self, bucket);
let mut notification_categories = BTreeMap::new();
for event in ¬ification_events {
*notification_categories
.entry(notification_category(&event.event_name).to_string())
.or_insert(0) += 1;
}
let mut audit_categories = BTreeMap::new();
let mut audit_outcomes = BTreeMap::new();
for event in &audit_events {
*audit_categories
.entry(audit_category(&event.action).to_string())
.or_insert(0) += 1;
*audit_outcomes
.entry(format!("{:?}", event.outcome))
.or_insert(0) += 1;
}
let report = EventCategoryReport {
scope: scope.to_string(),
target: target.clone(),
generated_at_epoch_seconds: self.clock_epoch_seconds,
notification_event_count: notification_events.len(),
audit_event_count: audit_events.len(),
notification_categories,
audit_categories,
audit_outcomes,
};
self.audit.append(
principal,
"ops:GetEventCategoryReport",
&target,
AuditOutcome::Allowed,
Some(format!(
"notification_events={},audit_events={}",
report.notification_event_count, report.audit_event_count
)),
);
Ok(report)
}
pub fn event_compliance_report(
&mut self,
principal: &str,
bucket: Option<&str>,
) -> Result<EventComplianceReport, RuntimeError> {
let (scope, target) = event_scope_and_target(bucket);
self.require_operator_action(
principal,
OperatorAction::ReadAudit,
&target,
"ops:GetEventComplianceReport",
)?;
self.require_ops_bucket_scope(bucket)?;
let notification_events = scoped_notification_events(self, bucket);
let audit_events = scoped_audit_events(self, bucket);
let report = EventComplianceReport {
scope: scope.to_string(),
target: target.clone(),
generated_at_epoch_seconds: self.clock_epoch_seconds,
notification_event_count: notification_events.len(),
audit_event_count: audit_events.len(),
first_notification_event_id: notification_events
.first()
.map(|event| event.event_id.clone()),
last_notification_event_id: notification_events
.last()
.map(|event| event.event_id.clone()),
first_audit_sequence: audit_events.first().map(|event| event.sequence),
last_audit_sequence: audit_events.last().map(|event| event.sequence),
notification_event_ids_contiguous: notification_event_ids_contiguous(
¬ification_events,
),
audit_sequences_contiguous: audit_sequences_contiguous(&audit_events),
notification_json_lines: notification_events_json_lines(¬ification_events),
audit_json_lines: audit_json_lines(&audit_events),
};
self.audit.append(
principal,
"ops:GetEventComplianceReport",
&target,
AuditOutcome::Allowed,
Some(format!(
"notification_events={},audit_events={},notification_contiguous={},audit_contiguous={}",
report.notification_event_count,
report.audit_event_count,
report.notification_event_ids_contiguous,
report.audit_sequences_contiguous
)),
);
Ok(report)
}
}
fn event_scope_and_target(bucket: Option<&str>) -> (&'static str, String) {
match bucket {
Some(bucket) => ("bucket", bucket.to_string()),
None => ("runtime", "*".to_string()),
}
}
fn notification_category(event_name: &str) -> &'static str {
if event_name.starts_with("s3:ObjectCreated:") || event_name.starts_with("s3:ObjectRemoved:") {
"data"
} else if event_name.starts_with("s3:ObjectRestore:") {
"restore"
} else if event_name.starts_with("s3:Replication:") {
"replication"
} else if event_name.starts_with("s3:LifecycleExpiration:") {
"lifecycle"
} else if event_name.starts_with("s3:ObjectLock:") {
"retention"
} else {
"other"
}
}
fn audit_category(action: &str) -> &'static str {
if action.starts_with("auth:") {
"auth"
} else if action.contains("Policy") {
"policy"
} else if action.contains("Retention")
|| action.contains("LegalHold")
|| action.contains("ObjectLock")
{
"retention"
} else if action.starts_with("ops:") || action.contains("Bucket") {
"admin"
} else if action.starts_with("s3:") {
"data"
} else {
"other"
}
}
fn notification_event_ids_contiguous(events: &[&NotificationEvent]) -> bool {
let Some(first) = events.first() else {
return true;
};
let Some(mut expected) = first
.event_id
.strip_prefix('e')
.and_then(|value| value.parse::<u64>().ok())
else {
return false;
};
for event in events {
let Some(current) = event
.event_id
.strip_prefix('e')
.and_then(|value| value.parse::<u64>().ok())
else {
return false;
};
if current != expected {
return false;
}
expected += 1;
}
true
}
fn audit_sequences_contiguous(events: &[&AuditEvent]) -> bool {
let Some(first) = events.first() else {
return true;
};
let mut expected = first.sequence;
for event in events {
if event.sequence != expected {
return false;
}
expected += 1;
}
true
}
fn notification_events_json_lines(events: &[&NotificationEvent]) -> String {
events
.iter()
.map(|event| {
format!(
"{{\"event_id\":\"{}\",\"event_name\":\"{}\",\"bucket\":\"{}\",\"key\":\"{}\",\"version_id\":\"{}\",\"rule_id\":\"{}\",\"target_kind\":\"{}\",\"target_arn\":\"{}\",\"event_time_epoch_seconds\":{}}}",
escape_json(&event.event_id),
escape_json(&event.event_name),
escape_json(&event.bucket),
escape_json(&event.key),
escape_json(&event.version_id),
escape_json(&event.rule_id),
escape_json(&event.target_kind),
escape_json(&event.target_arn),
event.event_time_epoch_seconds
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn audit_json_lines(events: &[&AuditEvent]) -> String {
events
.iter()
.map(|event| {
let detail = event
.detail
.as_ref()
.map(|detail| format!("\"{}\"", escape_json(detail)))
.unwrap_or_else(|| "null".to_string());
format!(
"{{\"sequence\":{},\"subject\":\"{}\",\"action\":\"{}\",\"resource\":\"{}\",\"outcome\":\"{:?}\",\"detail\":{}}}",
event.sequence,
escape_json(&event.subject),
escape_json(&event.action),
escape_json(&event.resource),
event.outcome,
detail
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn escape_json(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
'\u{08}' => escaped.push_str("\\b"),
'\u{0c}' => escaped.push_str("\\f"),
character if character.is_control() => {
escaped.push_str(&format!("\\u{:04x}", character as u32));
}
character => escaped.push(character),
}
}
escaped
}