mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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).
67 lines
2.1 KiB
Rust
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());
|
|
}
|