audit: hash chain, refused writes audited, journal copy !499

merged merged by cmc on 2026-09-28 21:50 UTC · krz/gitbay:audit-refusals-chain into main

Discussion

cmc

The audit log is hash-chained, refused writes are audited, and the daemon journals every row.

  • Migration 0064: each audit row stores the hash of [prev, id, actor_ref, action, created_at, data], written in the same transaction that reads the previous hash; the timestamp is taken under the write lock. Rows from before the migration are unchained. Verification detects an edited, removed or reordered row, a changed actor_id, and chained rows posing as unchained.
  • Retention deletes audit rows by id (id <= MAX(id) older than the cutoff), so a clock step cannot punch a hole.
  • The daemon writes every audit row to its journal (INFO audit id=… hash=…).
  • Refused mutating commands (exit 3 or 4, not read-only) and refused pushes are audited as refused <path>, with flag names and the first positional only; ten a minute per account and 600 across the instance, counted per process, then one refused.throttled row.
  • gitbayd admin audit verify prints rows, unchained, first, last and the last hash, and exits 1 on a break or when every row is unchained.
  • Removing the newest rows, or reusing their ids afterwards, is not detectable from the database; comparing verify's last id and hash with the journal shows it. Under ssh.mode = "system" (bay1 runs embedded) gitbayd shell has no journal copy and its own refusal count. Both are in Known-Gaps.
  • Admin, Threat-Model and Architecture pages; CHANGELOG.

Stacked on !494 (hook-socket-auth).

Closes #275