# Audit Log Integrity (append-only)

Scope: the `audit_logs` table (model `App\Models\AuditEntry`). Audit records are
**append-only** — once written, an entry must never be modified, and must not be
deletable by the application. This underpins the auditability guarantee for sensitive
financial and administrative events.

## Controls in place (enforced in code / DB)

1. **Model layer** — `AuditEntry::booted()` throws `ImmutableAuditLogException` on any
   Eloquent `updating`/`deleting`. `$guarded = ['*']` and `$timestamps = false`; the
   recorder sets columns via `forceFill`/`setAttribute` only, so request input can never
   reach audit columns. Secrets are redacted before insert (`App\Audit\Redactor`).
2. **Storage layer (UPDATE)** — a MySQL `BEFORE UPDATE` trigger
   (`audit_logs_block_update`, migration `2026_11_08_100000_…`) rejects an in-place
   modification of any audit row, including a raw query-builder `update()` that bypasses
   model events. This is the key anti-tamper property: audit **content** can never be
   edited. The trigger is MySQL-only and does not fire on schema migrations, so it is
   safe for migrations and disaster recovery.

## Controls deferred to infrastructure (CANNOT be enforced safely in dev)

The shared-user WAMP development database runs migrations, the app, and tests as **one**
MySQL account, so the following production controls are **documented, not migrated** —
applying them in dev would break migrations, tests, and local DR:

1. **DELETE protection via DB privileges.** In production, grant the application DB user
   `INSERT, SELECT` **only** on `audit_logs` (no `UPDATE`, no `DELETE`). The UPDATE trigger
   already blocks edits; withholding the `DELETE` grant blocks evidence destruction while
   keeping append + read working for the app.
   ```sql
   REVOKE UPDATE, DELETE ON app_db.audit_logs FROM 'app_user'@'%';
   GRANT  INSERT, SELECT ON app_db.audit_logs TO   'app_user'@'%';
   ```
   A DB-level DELETE **trigger** is intentionally NOT used: it applies to every
   connection and would also block authorized retention/archival and DR restores.
2. **Retention / archival as a separate privileged role.** When a retention policy is
   introduced, run it under a dedicated role that holds `DELETE` on `audit_logs` (ideally
   after copying rows to cold storage / an archive table). The app user never holds this
   grant. Prefer time-based **partitioning** so retention is a `DROP PARTITION` (fast, and
   does not fire row triggers) rather than row-by-row `DELETE`.
3. **Backups / WORM.** Ship audit backups to write-once / object-lock storage so a
   compromised DB host cannot rewrite history retroactively.

## Verification

`tests/Feature/Audit/AuditLogAppendOnlyTest.php` asserts the model rejects
update/delete, and (on MySQL) that a raw `DB::table('audit_logs')->update()` is rejected
by the trigger.

## Production blocker

Items 1–3 above require production DB administration and are **pre-launch
infrastructure tasks** — they are not code changes and cannot be validated in the dev
environment. Track them in the production hardening runbook before go-live.
