use crate::audit::AuditDb;
use crate::policy::model::{RetentionConfig, SqlCategory};
use std::collections::BTreeMap;
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq)]
pub struct CleanupResult {
pub audit_deleted: u64,
pub audit_deleted_by_category: BTreeMap<&'static str, u64>,
pub backup_deleted: u64,
pub bytes_reclaimed: u64,
pub ran_at: String,
}
const CATEGORIES: [SqlCategory; 5] = [
SqlCategory::Read,
SqlCategory::Write,
SqlCategory::Ddl,
SqlCategory::Admin,
SqlCategory::TxCtrl,
];
fn day_cutoff(days: u32) -> String {
let now = time::OffsetDateTime::now_utc();
let cutoff = now - time::Duration::seconds(i64::from(days) * 24 * 60 * 60);
cutoff
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
}
fn page_bytes(c: &rusqlite::Connection) -> u64 {
let count: i64 = c
.query_row("PRAGMA page_count", [], |r| r.get(0))
.unwrap_or(0);
let size: i64 = c
.query_row("PRAGMA page_size", [], |r| r.get(0))
.unwrap_or(0);
(count.max(0) as u64) * (size.max(0) as u64)
}
fn iso_now() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
}
pub fn cleanup_audit(
audit: &Arc<AuditDb>,
config: &RetentionConfig,
dry_run: bool,
) -> CleanupResult {
let ran_at = iso_now();
let mut by_category: BTreeMap<&'static str, u64> =
CATEGORIES.iter().map(|c| (c.as_str(), 0)).collect();
let dry_shape = if dry_run {
let mut counts: BTreeMap<&'static str, u64> = BTreeMap::new();
audit
.with(|c| {
for cat in &CATEGORIES {
let cutoff = day_cutoff(config.retention_days_by_category.get(*cat));
let n: i64 = c.query_row(
"SELECT COUNT(*) FROM audit_log WHERE category = ?1 AND ts < ?2",
[cat.as_str(), cutoff.as_str()],
|r| r.get(0),
)?;
counts.insert(cat.as_str(), n.max(0) as u64);
}
let backup_cutoff = day_cutoff(config.backup_days);
let b: i64 = c.query_row(
"SELECT COUNT(*) FROM backup WHERE ts < ?1",
[&backup_cutoff],
|r| r.get(0),
)?;
Ok::<_, rusqlite::Error>(b.max(0) as u64)
})
.ok()
.map(|b| {
let total: u64 = counts.values().sum();
(counts, total, b)
})
} else {
None
};
let result = audit.with_tx(|c| {
let before = page_bytes(c);
if dry_run {
let Some((counts, total, b)) = dry_shape else {
unreachable!("dry_run set means dry_shape is Some");
};
return Ok::<_, rusqlite::Error>(CleanupResult {
audit_deleted: total,
audit_deleted_by_category: counts,
backup_deleted: b,
bytes_reclaimed: 0,
ran_at: ran_at.clone(),
});
}
let mut audit_deleted: u64 = 0;
let tx = c;
for cat in &CATEGORIES {
let cutoff = day_cutoff(config.retention_days_by_category.get(*cat));
let n = tx.execute(
"DELETE FROM audit_log WHERE category = ?1 AND ts < ?2",
[cat.as_str(), cutoff.as_str()],
)? as u64;
by_category.insert(cat.as_str(), n);
audit_deleted += n;
}
let backup_cutoff = day_cutoff(config.backup_days);
let mut backup_deleted =
tx.execute("DELETE FROM backup WHERE ts < ?1", [&backup_cutoff])? as u64;
let audit_max = u64::from(config.audit_max_mb) * 1024 * 1024;
let backup_max = u64::from(config.backup_max_mb) * 1024 * 1024;
let cur = page_bytes(tx);
if cur > audit_max + backup_max {
backup_deleted += tx.execute(
"DELETE FROM backup WHERE id IN (
SELECT id FROM backup ORDER BY id ASC LIMIT
(SELECT COUNT(*) FROM backup) / 5
)",
[],
)? as u64;
audit_deleted += tx.execute(
"DELETE FROM audit_log WHERE id IN (
SELECT id FROM audit_log ORDER BY id ASC LIMIT
(SELECT COUNT(*) FROM audit_log) / 5
)",
[],
)? as u64;
}
tx.execute(
"INSERT INTO meta (key, value) VALUES ('last_cleanup_at', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[&ran_at],
)?;
let after = page_bytes(c);
Ok(CleanupResult {
audit_deleted,
audit_deleted_by_category: by_category.clone(),
backup_deleted,
bytes_reclaimed: before.saturating_sub(after),
ran_at: ran_at.clone(),
})
});
if !dry_run {
let _ = audit.with(|c| c.execute("VACUUM", []));
}
result.unwrap_or(CleanupResult {
audit_deleted: 0,
audit_deleted_by_category: by_category.clone(),
backup_deleted: 0,
bytes_reclaimed: 0,
ran_at,
})
}
pub fn maybe_auto_cleanup(audit: &Arc<AuditDb>, config: &RetentionConfig) -> Option<CleanupResult> {
if config.auto_cleanup_hours == 0 {
return None;
}
let last: Option<String> = audit
.with(|c| {
c.query_row(
"SELECT value FROM meta WHERE key = 'last_cleanup_at'",
[],
|r| r.get(0),
)
})
.ok();
if let Some(last) = last
&& let Ok(ts) =
time::OffsetDateTime::parse(&last, &time::format_description::well_known::Rfc3339)
{
let age = time::OffsetDateTime::now_utc() - ts;
if age.whole_hours() < i64::from(config.auto_cleanup_hours) {
return None;
}
}
Some(cleanup_audit(audit, config, false))
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
fn tmp_audit() -> (tempfile::TempDir, Arc<AuditDb>) {
let dir = tempfile::TempDir::new().unwrap();
let audit = Arc::new(AuditDb::at_path(&dir.path().join("a.sqlite")).unwrap());
(dir, audit)
}
#[test]
fn dry_run_counts_and_cleanup_deletes_by_category() {
let (_dir, audit) = tmp_audit();
let old = "2020-01-01T00:00:00Z";
for cat in &CATEGORIES {
insert_raw(&audit, cat.as_str(), old);
insert_raw(&audit, cat.as_str(), &iso_now());
}
let cfg = RetentionConfig::default();
let dry = cleanup_audit(&audit, &cfg, true);
assert_eq!(dry.audit_deleted, 5, "one old row per category");
assert_eq!(dry.audit_deleted_by_category["read"], 1);
let count: i64 = audit
.with(|c| c.query_row("SELECT COUNT(*) FROM audit_log", [], |r| r.get(0)))
.unwrap();
assert_eq!(count, 10);
let run = cleanup_audit(&audit, &cfg, false);
assert_eq!(run.audit_deleted, 5);
let count: i64 = audit
.with(|c| c.query_row("SELECT COUNT(*) FROM audit_log", [], |r| r.get(0)))
.unwrap();
assert_eq!(count, 5, "recent rows survive");
let last: String = audit
.with(|c| {
c.query_row(
"SELECT value FROM meta WHERE key = 'last_cleanup_at'",
[],
|r| r.get(0),
)
})
.unwrap();
assert!(last.starts_with("20"));
}
fn insert_raw(audit: &Arc<AuditDb>, category: &str, ts: &str) {
audit
.with(|c| {
c.execute(
"INSERT INTO audit_log
(ts, request_id, connection, databases, category, ast_type,
sql_raw, sql_redacted, decision, confirmed, outcome)
VALUES (?1, ?2, 'c', '[]', ?3, NULL, 'SELECT 1', 'SELECT 1',
'allow', 0, 'approved')",
params![ts, uuid::Uuid::new_v4().to_string(), category],
)
})
.unwrap();
}
#[test]
fn auto_cleanup_respects_interval() {
let (_dir, audit) = tmp_audit();
let cfg = RetentionConfig {
auto_cleanup_hours: 24,
..RetentionConfig::default()
};
assert!(maybe_auto_cleanup(&audit, &cfg).is_some());
assert!(maybe_auto_cleanup(&audit, &cfg).is_none());
let off = RetentionConfig {
auto_cleanup_hours: 0,
..RetentionConfig::default()
};
assert!(maybe_auto_cleanup(&audit, &off).is_none());
}
}