Files
smirk-backend-core/tests/admin_audit.rs
T
jwinterm 49df1d1ae5 feat(admin): tamper-evident privileged-action audit chain [operator §0.4]
A separate, low-volume audit trail for admin/setup/erasure actions, hash-chained
so a DB-write attacker cannot silently rewrite history. Kept distinct from the
high-volume user audit_logs (which keeps its enum action) — the chain applies
only where the privacy-maximalist mandate requires it.

- migration 20260626000001: admin_audit_logs (free-text action, actor_kind,
  actor_pubkey_prefix [prefix only, no social graph], target, details, real-peer
  ip, prev_hash, row_hash; monotonic seq).
- db::admin_audit: append_admin_audit(&mut PgConnection) so the audit row commits
  in the SAME transaction as the state change it records (fail-closed, not a
  best-effort side effect); record_admin_audit (own tx) for standalone audits;
  verify_admin_audit_chain (boot/doctor walk — detects edit/delete/reorder/broken
  linkage); list_admin_audit (panel). row_hash = HMAC(integrity_secret,
  prev_hash || canonical(row)); appends serialize on a tx advisory lock so
  seq/prev_hash cannot race.
- AdminAuditLog/NewAdminAudit models; 1 L1 test (link + verify + tamper-detect +
  restore).
2026-06-26 06:06:54 -04:00

67 lines
2.1 KiB
Rust

//! L1 integration: the tamper-evident privileged-action audit chain.
//!
//! One sequential test: the chain + verify are global to the table, so running
//! the link/verify and tamper/restore steps in parallel would race. It asserts
//! relative linkage (each row points at its predecessor) and chain validity
//! rather than absolute seq values (the test DB persists rows across runs).
mod common;
use smirk_backend_core::models::db::NewAdminAudit;
const SECRET: &str = "admin-audit-integrity-secret-0123456789ab";
fn entry(action: &str) -> NewAdminAudit {
NewAdminAudit {
action: action.into(),
actor_kind: "cli".into(),
actor_pubkey_prefix: Some("aabbccdd".into()),
target: None,
details: Some(serde_json::json!({ "k": action })),
ip_address: None,
}
}
#[tokio::test]
async fn chain_links_verifies_and_detects_tampering() {
let app = require_app!();
let db = &app.state.db;
// Appends are monotonic and linked.
let a = db
.record_admin_audit(&entry("admin_login"), SECRET)
.await
.unwrap();
let b = db
.record_admin_audit(&entry("admin_key_added"), SECRET)
.await
.unwrap();
assert_eq!(b.seq, a.seq + 1);
assert_eq!(b.prev_hash, a.row_hash);
// The chain verifies under the correct secret, not a different one.
assert!(db.verify_admin_audit_chain(SECRET).await.unwrap());
assert!(!db.verify_admin_audit_chain("WRONG").await.unwrap());
// An edited row without re-hashing breaks the chain...
sqlx::query("UPDATE admin_audit_logs SET action = 'tampered' WHERE id = $1")
.bind(b.id)
.execute(db.pool())
.await
.unwrap();
assert!(
!db.verify_admin_audit_chain(SECRET).await.unwrap(),
"edited row breaks the chain"
);
// ...and restoring the exact value makes it whole again (and leaves the
// shared table valid for later runs).
sqlx::query("UPDATE admin_audit_logs SET action = $2 WHERE id = $1")
.bind(b.id)
.bind(&b.action)
.execute(db.pool())
.await
.unwrap();
assert!(db.verify_admin_audit_chain(SECRET).await.unwrap());
}