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).
This commit is contained in:
jwinterm
2026-06-26 06:06:54 -04:00
parent 041860bc94
commit 49df1d1ae5
5 changed files with 296 additions and 0 deletions
+30
View File
@@ -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);
+171
View File
@@ -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<Utc>,
) -> 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<AdminAuditLog, AppError> {
// 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<AdminAuditLog, AppError> {
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<bool, AppError> {
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<Vec<AdminAuditLog>, 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)
}
}
+1
View File
@@ -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;
+28
View File
@@ -280,6 +280,34 @@ pub struct NewAdminKey {
pub activation_deadline: Option<DateTime<Utc>>,
}
/// 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<String>,
pub target: Option<String>,
pub details: Option<serde_json::Value>,
pub ip_address: Option<IpNetwork>,
pub created_at: DateTime<Utc>,
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<String>,
pub target: Option<String>,
pub details: Option<serde_json::Value>,
pub ip_address: Option<IpNetwork>,
}
/// 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)]
+66
View File
@@ -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());
}