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
+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)]