mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user