From 49df1d1ae596ef0f182baf99a0a8da69cc7b778a Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 26 Jun 2026 06:06:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(admin):=20tamper-evident=20privileged-acti?= =?UTF-8?q?on=20audit=20chain=20[operator=20=C2=A70.4]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- migrations/20260626000001_admin_audit.sql | 30 ++++ src/infra/db/admin_audit.rs | 171 ++++++++++++++++++++++ src/infra/db/mod.rs | 1 + src/models/db.rs | 28 ++++ tests/admin_audit.rs | 66 +++++++++ 5 files changed, 296 insertions(+) create mode 100644 migrations/20260626000001_admin_audit.sql create mode 100644 src/infra/db/admin_audit.rs create mode 100644 tests/admin_audit.rs diff --git a/migrations/20260626000001_admin_audit.sql b/migrations/20260626000001_admin_audit.sql new file mode 100644 index 0000000..4e5449c --- /dev/null +++ b/migrations/20260626000001_admin_audit.sql @@ -0,0 +1,30 @@ +-- Tamper-evident audit log for privileged actions (operator §0.4). +-- +-- Separate from user `audit_logs` (which keeps its enum action for high-volume +-- login traffic): this table is the LOW-VOLUME admin/setup/erasure trail, with a +-- free-text action and a hash chain. Each row's row_hash = +-- HMAC(ADMIN_KEY_INTEGRITY_SECRET, prev_hash || canonical(row)); prev_hash is the +-- previous row's row_hash (or a genesis constant). A DB-write attacker who edits, +-- deletes, or reorders a row breaks the chain, which a boot/doctor check detects. +-- Keyed by the integrity secret (separate from DATABASE_URL) so the chain is +-- tamper-EVIDENT against a DB-write attacker (forgeable only under host +-- compromise — defense-in-depth, not a guarantee). +-- +-- actor_pubkey_prefix stores only a short prefix, never the full pubkey, so this +-- table does not encode the admin social graph. +CREATE TABLE admin_audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + seq BIGINT NOT NULL UNIQUE, -- monotonic order, assigned under a lock + action TEXT NOT NULL, -- 'admin_login' | 'admin_key_added' | ... + actor_kind TEXT NOT NULL, -- 'user' | 'admin' | 'cli' | 'bootstrap' + actor_pubkey_prefix TEXT, -- first chars of the actor pubkey (no full key) + target TEXT, -- optional target id/pubkey + details JSONB, + ip_address INET, -- real TCP peer, never XFF + created_at TIMESTAMPTZ NOT NULL, + prev_hash TEXT NOT NULL, + row_hash TEXT NOT NULL +); + +CREATE INDEX idx_admin_audit_logs_seq ON admin_audit_logs (seq); +CREATE INDEX idx_admin_audit_logs_action ON admin_audit_logs (action); diff --git a/src/infra/db/admin_audit.rs b/src/infra/db/admin_audit.rs new file mode 100644 index 0000000..5ccb260 --- /dev/null +++ b/src/infra/db/admin_audit.rs @@ -0,0 +1,171 @@ +//! Tamper-evident audit chain for privileged actions. +//! +//! Each appended row's `row_hash = HMAC(secret, prev_hash ‖ canonical(row))`, +//! linking it to the previous row; a boot/`doctor` check walks the chain and +//! detects any edit, deletion, or reorder. Appends serialize on a transaction +//! advisory lock so `seq` and `prev_hash` cannot race. The append takes a +//! `&mut PgConnection`, so a caller can run its state change and the audit row in +//! ONE transaction — making the audit write fail-closed (roll back together) +//! rather than a best-effort side effect. + +use chrono::{DateTime, SubsecRound, Utc}; +use sqlx::PgConnection; +use tracing::instrument; +use uuid::Uuid; + +use crate::core::crypto::pepper::peppered_hex; +use crate::error::AppError; +use crate::models::db::{AdminAuditLog, NewAdminAudit}; + +use super::Database; + +/// First-row predecessor hash. +const GENESIS: &str = "smirk-admin-audit-genesis"; +/// Fixed key for the per-append transaction advisory lock. +const CHAIN_LOCK_KEY: i64 = 0x5311_4D17_4155_0117; + +const COLS: &str = "id, seq, action, actor_kind, actor_pubkey_prefix, target, \ + details, ip_address, created_at, prev_hash, row_hash"; + +/// `HMAC(secret, "admin_audit" ‖ prev_hash ‖ canonical-fields)`. The canonical +/// form is a JSON array (unambiguous escaping); timestamps are microsecond epochs. +fn chain_hash( + secret: &str, + prev_hash: &str, + seq: i64, + e: &NewAdminAudit, + created_at: DateTime, +) -> String { + let ip = e.ip_address.map(|i| i.to_string()); + let canonical = serde_json::json!([ + seq, + e.action, + e.actor_kind, + e.actor_pubkey_prefix, + e.target, + e.details, + ip, + created_at.timestamp_micros(), + ]) + .to_string(); + peppered_hex( + secret, + "admin_audit", + &format!("{prev_hash}\u{1f}{canonical}"), + ) +} + +fn row_as_new(r: &AdminAuditLog) -> NewAdminAudit { + NewAdminAudit { + action: r.action.clone(), + actor_kind: r.actor_kind.clone(), + actor_pubkey_prefix: r.actor_pubkey_prefix.clone(), + target: r.target.clone(), + details: r.details.clone(), + ip_address: r.ip_address, + } +} + +impl Database { + /// Append a privileged-action row to the chain, ON THE CALLER'S connection so + /// it can be part of the same transaction as the state change it records. + /// Pass `&mut *tx`. The advisory lock requires this to run inside a tx. + #[instrument(skip(self, conn, entry, secret), fields(action = %entry.action))] + pub async fn append_admin_audit( + &self, + conn: &mut PgConnection, + entry: &NewAdminAudit, + secret: &str, + ) -> Result { + // Serialize appends so seq/prev_hash cannot race (released at tx end). + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(CHAIN_LOCK_KEY) + .execute(&mut *conn) + .await?; + + let last: Option<(i64, String)> = + sqlx::query_as("SELECT seq, row_hash FROM admin_audit_logs ORDER BY seq DESC LIMIT 1") + .fetch_optional(&mut *conn) + .await?; + let (prev_seq, prev_hash) = last.unwrap_or((0, GENESIS.to_string())); + let seq = prev_seq + 1; + let created_at = Utc::now().trunc_subsecs(6); + let row_hash = chain_hash(secret, &prev_hash, seq, entry, created_at); + + let sql = format!( + "INSERT INTO admin_audit_logs \ + (id, seq, action, actor_kind, actor_pubkey_prefix, target, details, ip_address, created_at, prev_hash, row_hash) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING {COLS}" + ); + let row = sqlx::query_as::<_, AdminAuditLog>(&sql) + .bind(Uuid::new_v4()) + .bind(seq) + .bind(&entry.action) + .bind(&entry.actor_kind) + .bind(&entry.actor_pubkey_prefix) + .bind(&entry.target) + .bind(&entry.details) + .bind(entry.ip_address) + .bind(created_at) + .bind(&prev_hash) + .bind(&row_hash) + .fetch_one(&mut *conn) + .await?; + Ok(row) + } + + /// Append a row in its own transaction (standalone audit, e.g. the CLI path). + #[instrument(skip(self, entry, secret), fields(action = %entry.action))] + pub async fn record_admin_audit( + &self, + entry: &NewAdminAudit, + secret: &str, + ) -> Result { + let mut tx = self.pool().begin().await?; + let row = self.append_admin_audit(&mut tx, entry, secret).await?; + tx.commit().await?; + Ok(row) + } + + /// Walk the chain in order and verify every link. Returns `false` on any + /// edited field, deleted row (seq gap), reorder, or broken linkage. + #[instrument(skip(self, secret))] + pub async fn verify_admin_audit_chain(&self, secret: &str) -> Result { + let sql = format!("SELECT {COLS} FROM admin_audit_logs ORDER BY seq ASC"); + let rows = sqlx::query_as::<_, AdminAuditLog>(&sql) + .fetch_all(self.pool()) + .await?; + + let mut prev = GENESIS.to_string(); + for (i, row) in rows.iter().enumerate() { + // Rows are seq-ordered and gap-free from 1; index pins the expected seq. + let expected_seq = i as i64 + 1; + if row.seq != expected_seq || row.prev_hash != prev { + return Ok(false); + } + let recomputed = chain_hash( + secret, + &row.prev_hash, + row.seq, + &row_as_new(row), + row.created_at, + ); + if recomputed != row.row_hash { + return Ok(false); + } + prev = row.row_hash.clone(); + } + Ok(true) + } + + /// Most-recent privileged-action rows (newest first), for the admin panel. + #[instrument(skip(self))] + pub async fn list_admin_audit(&self, limit: i64) -> Result, AppError> { + let sql = format!("SELECT {COLS} FROM admin_audit_logs ORDER BY seq DESC LIMIT $1"); + let rows = sqlx::query_as::<_, AdminAuditLog>(&sql) + .bind(limit) + .fetch_all(self.pool()) + .await?; + Ok(rows) + } +} diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 07fdd71..f1577fd 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -6,6 +6,7 @@ //! `seed_fingerprint`) are peppered inside these methods so the plaintext never //! reaches a column and the peppering cannot drift between call sites. +mod admin_audit; mod admin_keys; mod admin_sessions; mod audit; diff --git a/src/models/db.rs b/src/models/db.rs index ac2ee9b..0ba59d6 100644 --- a/src/models/db.rs +++ b/src/models/db.rs @@ -280,6 +280,34 @@ pub struct NewAdminKey { pub activation_deadline: Option>, } +/// A row in the tamper-evident privileged-action audit chain. +#[derive(Debug, Clone, FromRow)] +pub struct AdminAuditLog { + pub id: Uuid, + pub seq: i64, + pub action: String, + pub actor_kind: String, + pub actor_pubkey_prefix: Option, + pub target: Option, + pub details: Option, + pub ip_address: Option, + pub created_at: DateTime, + pub prev_hash: String, + pub row_hash: String, +} + +/// Input for appending a privileged-action audit row. `seq`, timestamps, and the +/// hash-chain fields are computed by the db layer. +#[derive(Debug, Clone)] +pub struct NewAdminAudit { + pub action: String, + pub actor_kind: String, + pub actor_pubkey_prefix: Option, + pub target: Option, + pub details: Option, + pub ip_address: Option, +} + /// Input for an admin session row. The id and access_jti are generated by the /// caller (the refresh token embeds the id, the access token embeds the jti). #[derive(Debug, Clone)] diff --git a/tests/admin_audit.rs b/tests/admin_audit.rs new file mode 100644 index 0000000..ca8e09b --- /dev/null +++ b/tests/admin_audit.rs @@ -0,0 +1,66 @@ +//! 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()); +}