The audit log
Every authenticated write your API accepts is recorded, and each record is hashed together with the one before it. Changing a row after the fact breaks every hash from that row onward, and the admin will tell you which row it was.
What problem it solves
An ordinary log answers "what happened". It does not answer "is this log still true". Anybody with write access to the database can update a row, delete one, or insert history that never happened, and a plain table cannot tell you afterwards. That is not a hypothetical: the person most likely to edit an audit trail is the person who has credentials to the database, which is the same set of people the trail exists to hold accountable.
So Grit chains the rows. Each entry stores the hash of the entry before it, and its own hash covers both. The log is still just a table you can query; it has simply stopped being a table you can quietly change.
How the chain works
Each row's hash is
hash = SHA-256( prev_hash || canonical(row) )
where canonical(row) is a stable JSON serialisation of the fields that matter: who, method, path, status, payload digest, address, user agent, duration, the creation time as Unix nanoseconds, and the resource fields when a read was recorded. The row's own id, prev_hash and hash are left out, because an id is random and the other two are derived rather than input.
Edit any field of any row and that row's hash no longer matches, and neither does every hash after it, because each one was computed over the previous hash. Delete a row and the link from its successor points at nothing. Insert a forged row and it has no valid place in the chain. All three show up the same way.
FOR UPDATE lock on the current head inside the same transaction as the insert, and a single writer goroutine per process feeds it. Two concurrent requests cannot fork the chain, and the lock is held for the length of one insert rather than the length of a request.Verifying it
System hub → Audit log → Verify chain recomputes every hash from the first entry forward and compares each one against what is stored. It reports either the number of entries it verified, or the first entry where the recomputed hash and the stored hash disagree. The first mismatch is the useful part: it is where the edit happened, and everything after it is only broken as a consequence.
Verification walks the chain in created_at then id order, so it is deterministic even for two entries written in the same nanosecond. It is a read-only pass over the table and safe to run at any time, though on a log with millions of rows it is a full scan and worth running off-peak.
What is recorded
By default: every authenticated POST, PUT, PATCH and DELETE that returned a 2xx. Not GET, not a request that failed, not a request with no signed-in user.
| Field | What goes in it |
|---|---|
| when | The moment the request finished, to the nanosecond, stored in UTC. |
| who | The signed-in user id. An unauthenticated request is not recorded: there is nobody to attribute it to. |
| request | Method and path, as routed. Query strings are not stored on a write. |
| status | The HTTP status. Only 2xx responses are recorded: a request that failed changed nothing. |
| digest | SHA-256 of the request body. Not the body: see below. |
| resource | For a read of an --audit-reads resource: which resource, which record ids, and how many rows came back. |
| ip / agent | The caller address and user agent, as the proxy reported them. |
| duration | How long the request took, in milliseconds. |
| prev_hash / hash | The chain. Derived, never input. |
Why the body is a digest
Request bodies are stored as a SHA-256 digest, not verbatim. A verbatim audit log of an authentication API contains passwords; of a payments API, card details; of a health API, diagnoses. It becomes the most sensitive table in the database and the one nobody remembers to encrypt, rotate or exclude from a backup they email somebody.
The digest still does the job an audit trail needs: given a payload, you can prove it is the one that was sent. What you cannot do is read the payload back out of the log, which is the point. File uploads are skipped entirely, since hashing a 40MB file into memory buys nothing.
Recording reads
Reads are not recorded by default, because a dashboard that fetches six lists on every page load would bury the writes within a day. Where a read is the event that matters, and in health, finance and anything with a "who looked at my record" requirement it is, generate the resource with --audit-reads:
$grit generate resource LabResult \$ --fields "test:string,result:text" \$ --owned-by user \$ --audit-reads
Every read of that resource then lands in the same chain, recording which records were returned and how many. The query string is stored as a digest too, so a name typed into a search box is not kept either.
Retention and pruning
The log is append-only: nothing in the app updates or deletes an entry. It does grow, so a weekly audit:prune job trims entries older than AUDIT_RETENTION_DAYS and re-anchors the chain so what remains still verifies. Set it to 0 to keep everything.
DELETE FROM activity_logs is indistinguishable from an attacker covering their tracks, so the log reports it as tampering. Pruning goes through the job, which re-anchors the oldest remaining entry inside the same transaction. It runs in chunks, each one a transaction of its own, so a prune that is interrupted leaves a log that still verifies.What it does not defend against
A hash chain proves that the rows have not been changed by somebody with database access. It cannot prove anything against somebody with code execution on the running server: they hold the same key material the writer does and can recompute the entire chain from scratch. Defending against that needs an anchor outside the machine, publishing the daily root hash somewhere append-only that you do not control, which Grit does not do yet.
It also records what the API was asked to do, not what the database ended up holding. A write that goes around the API, a migration, a fixture, a console session, leaves no entry, and the chain over the entries that do exist stays valid.
Reading it
The admin screen filters by path prefix, by record id, and by method, with a separate tab for security events (sign-ins, lockouts, permission denials). The table is the model ActivityLog, so anything the screen does not do is a GORM query away, and GORM Studio at /studio will browse it.
