use std::collections::VecDeque;
use std::sync::Mutex;
#[derive(Debug, Clone, serde::Serialize)]
pub struct AuditEvent {
pub ts_unix_nanos: u128,
pub principal: String,
pub action: String,
pub detail: String,
}
pub struct AuditLog {
buf: Mutex<VecDeque<AuditEvent>>,
cap: usize,
}
impl AuditLog {
pub fn new(cap: usize) -> Self {
Self {
buf: Mutex::new(VecDeque::with_capacity(cap.min(4096))),
cap: cap.max(1),
}
}
pub fn record(
&self,
principal: impl Into<String>,
action: impl Into<String>,
detail: impl Into<String>,
) {
let event = AuditEvent {
ts_unix_nanos: now_unix_nanos(),
principal: principal.into(),
action: action.into(),
detail: detail.into(),
};
eprintln!(
"[audit] {} {} \u{2014} {}",
event.principal, event.action, event.detail
);
let mut guard = self.buf.lock().expect("audit log not poisoned");
if guard.len() >= self.cap {
guard.pop_front();
}
guard.push_back(event);
}
pub fn recent(&self) -> Vec<AuditEvent> {
self.buf
.lock()
.expect("audit log not poisoned")
.iter()
.cloned()
.collect()
}
}
fn now_unix_nanos() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
pub fn is_audited_sql(sql: &str) -> bool {
sql.split(';').any(|seg| {
let lower = strip_leading_comments_and_ws(seg).to_ascii_lowercase();
lower.starts_with("create ")
|| lower.starts_with("drop ")
|| lower.starts_with("alter ")
|| lower.starts_with("grant ")
|| lower.starts_with("revoke ")
|| lower.starts_with("truncate ")
})
}
fn strip_leading_comments_and_ws(mut s: &str) -> &str {
loop {
let trimmed = s.trim_start();
if let Some(rest) = trimmed.strip_prefix("--") {
s = rest.split_once('\n').map(|(_, r)| r).unwrap_or("");
} else if let Some(rest) = trimmed.strip_prefix("/*") {
s = rest.split_once("*/").map(|(_, r)| r).unwrap_or("");
} else if trimmed.len() == s.len() {
return trimmed;
} else {
s = trimmed;
}
}
}
pub fn redacted_ddl_detail(sql: &str, ok: bool) -> (&'static str, String) {
let action = if ok { "ddl.ok" } else { "ddl.fail" };
let detail = if sql.to_ascii_lowercase().contains("password") {
"[redacted credential statement]".to_string()
} else {
sql.chars().take(120).collect()
};
(action, detail)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_buffer_evicts_oldest_when_full() {
let log = AuditLog::new(2);
log.record("alice", "login.ok", "first");
log.record("alice", "login.ok", "second");
log.record("bob", "login.ok", "third");
let recent = log.recent();
assert_eq!(recent.len(), 2, "capacity 2 keeps only the last 2");
assert_eq!(recent[0].detail, "second");
assert_eq!(recent[1].detail, "third");
assert_eq!(recent[1].principal, "bob");
}
#[test]
fn record_populates_timestamp_and_fields() {
let log = AuditLog::new(8);
log.record("carol", "ddl", "CREATE TABLE t");
let e = &log.recent()[0];
assert_eq!(e.principal, "carol");
assert_eq!(e.action, "ddl");
assert_eq!(e.detail, "CREATE TABLE t");
assert!(e.ts_unix_nanos > 0, "timestamp should be set");
}
#[test]
fn is_audited_sql_matches_ddl_and_privilege_prefixes() {
assert!(is_audited_sql("CREATE TABLE t (id int)"));
assert!(is_audited_sql(" drop index i"));
assert!(is_audited_sql("ALTER TABLE t ADD COLUMN c"));
assert!(is_audited_sql("GRANT SELECT ON t TO r"));
assert!(is_audited_sql("REVOKE ALL FROM u"));
assert!(is_audited_sql("truncate t"));
assert!(!is_audited_sql("SELECT * FROM t"));
assert!(!is_audited_sql("INSERT INTO t VALUES (1)"));
}
#[test]
fn is_audited_sql_catches_multistatement_and_comment_evasion() {
assert!(is_audited_sql("SELECT 1; DROP TABLE t"));
assert!(is_audited_sql(
"BEGIN; INSERT INTO t VALUES (1); TRUNCATE t"
));
assert!(is_audited_sql("/* hint */ DROP TABLE t"));
assert!(is_audited_sql("-- ignore me\nALTER TABLE t ADD COLUMN c"));
assert!(!is_audited_sql("/* x */ SELECT 1"));
}
#[test]
fn redacted_ddl_detail_redacts_passwords() {
let (act, det) = redacted_ddl_detail("CREATE USER alice WITH PASSWORD 's3cret'", true);
assert_eq!(act, "ddl.ok");
assert!(!det.contains("s3cret"), "password must not appear: {det}");
assert!(!det.contains("alice"), "redacted wholesale");
let (act, _) = redacted_ddl_detail("ALTER USER bob WITH PASSWORD 'pw'", false);
assert_eq!(act, "ddl.fail");
let (_, det) = redacted_ddl_detail("DROP TABLE items", true);
assert!(det.contains("DROP TABLE items"));
}
}