Skip to content

feat(audit): chain the audit log so tampering shows - #561

Merged
bmscomp merged 6 commits into
mainfrom
feat/audit-hash-chain
Oct 30, 2026
Merged

bmscomp merged 6 commits into
mainfrom
feat/audit-hash-chain

Conversation

@bmscomp

@bmscomp bmscomp commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Merge #560 first. This branch carries #560's commit (a23a435) so CI runs against main, and its V28 must follow #560's V27. Once #560 squash-merges, I'll merge main in; the squash of this PR should then use only the messages of 40004c7 and 7dc0a2a.

Why

Anyone with database access could change, remove or add an audit row without a trace, so the audit log couldn't serve as evidence for an auditor (DORA, SOC 2, ISO 27001). This is Phase 3b of the evidence pack: the log becomes a hash chain, and one call says whether it still holds and, if not, where it broke.

What

  • V28: audit_events gains prev_hash and row_hash. row_hash is a SHA-256 over the previous row's hash and the row's fields (action, type, target, details, created_at, actor, principal type), each length-prefixed so field boundaries can't shift.
  • One writer at a time. One audit_chain_head row holds the newest hash. AuditChain.append locks it (SELECT … FOR UPDATE) and the lock is held until the caller's transaction ends, so the chain never forks, replicas included. A rolled-back caller rolls back its row and the head together.
  • Old rows. Rows from before V28 have no hash; the chain starts after them. A hash-less row after the chain began breaks it.
  • GET /api/audit/verify (scope read) walks every row up to the head as it stood when the walk began, oldest first, 1,000 at a time. It answers intact, chained, unchained, headId, headHash, and the first brokenAt row with its problem: changed, doesn't follow the row before it (a row removed or added there), or the newest rows are missing.
  • GET /api/audit now lists each chained row's rowHash. A head hash kept outside Kates can then be compared later; that is what catches a rewrite of the whole table, hashes included.
  • Timestamps. created_at is cut to microseconds, which is what Postgres and H2 keep, so the hash covers what is read back.
  • Test schema. Tests build the schema from the entities, not Flyway, so src/test/resources/import.sql inserts the chain head there.
  • MCP refs. The refs to AuditService.java and AuditResource.java in cli/cmd/mcp_tools_runs_activity.go are re-pointed.
  • Book. The security chapter's Audit Logging section explains the chain and how to keep a head hash.

Risks

  • Audited writes are serialized, and the head lock is held for the rest of the caller's transaction. A caller that audits and then locks other rows in the same transaction could deadlock with another writer. Most callers audit last; I didn't check every call site.
  • Verify reads the whole table every call. On a large log it is slow; there is no period filter yet.

Deliberately not done

  • No kates audit verify command; the evidence export PR will call the endpoint.
  • No period-bounded verify (since/until).
  • No anchor row for the pre-V28 rows. They are counted as unchained, not covered.

Tests

Locally:

  • ./mvnw test -Dquarkus.grpc.codegen.skip=true -Dquarkus.http.test-port=0: 1783 tests, 0 failures, 3 skipped.
    • AuditChainTest (8): changed row; row removed in the middle, at the start, at the end; pre-chain rows; a hash-less row slipped in; field boundaries and null vs empty.
    • AuditVerifyTest (2, own profile so tampering breaks no other test): 40 writes from 8 threads keep one intact chain, and the head hash matches the head row's rowHash in /api/audit; a row edited in the database is named by id.
  • spotless:check
  • go test -count=1 ./cmd -run TestMCPCaveat
  • golangci-lint run --new-from-rev=origin/main ./... (0 issues)
  • go vet ./cmd
  • scripts/check-book-style.sh

On real PostgreSQL (Testcontainers), AuditChainIT: V28 through Flyway; 200 writes from 16 threads keep one intact chain; a caller that rolls back takes its row and the head with it; a row edited with SQL is named by id. ./mvnw verify on this branch: 1783 unit tests and 98 ITs, 0 failures. The test-only import.sql doesn't run under the ITs' Flyway schema, so it doesn't collide with V28's head row.

A compliance check answered its report and kept nothing, so the history
of a cluster's checks lived only in pipeline logs. An auditor asking what
was checked, when and by whom had nothing to be shown. This is the first
piece of the evidence pack (plans/enterprise.md, Phase 3a).

- V27 compliance_checks: the report as JSON, when it ran, the reference
  name and the SHA-256 of the body sent, the cluster id, the verdict, and
  the actor. Nothing deletes these rows.
- POST /api/conformity/check stores each report and names it in Location.
  A refused reference stores nothing. If the store fails, the caller still
  gets its verdict without a Location, and the error is logged. The call
  is now @Audited (RUN, compliance), its target the check's id.
- GET /api/conformity/checks lists them newest first, bounded by since
  and until (ISO-8601; anything else is a 400), paged as /api/audit is.
- GET /api/conformity/checks/{id} answers one, with its full report.
- API reference and the compliance chapter no longer say a check stores
  nothing.

Verified locally: mvnw test (1773, 0 failures), spotless:check,
TestMCPCaveatRefAnchors, check-book-style.sh. V27 has run only on H2;
CI's ITs are its first Postgres run.
Anyone with database access could change, remove or add an audit row
without a trace, so the log could not serve as evidence. This is Phase 3b
of the evidence pack (plans/enterprise.md).

- V28: audit_events gains prev_hash and row_hash, a SHA-256 over the
  previous row's hash and the row's fields, each length-prefixed. One
  audit_chain_head row holds the newest hash; AuditChain.append locks it
  (SELECT ... FOR UPDATE) until the caller's transaction ends, so writers
  take turns and the chain never forks, replicas included. Older rows have
  no hash and the chain starts after them.
- GET /api/audit/verify walks every row up to the head, oldest first, and
  answers intact, the chained and unchained counts, the head's id and
  hash, and the first broken row with why: changed, not following the row
  before it, or newest rows missing.
- GET /api/audit lists each chained row's rowHash, so a head hash kept
  outside Kates can be compared later; that is what catches a rewrite of
  the whole table.
- created_at is cut to microseconds, what the database keeps, so the hash
  covers what is read back.
- Tests build the schema from the entities, so src/test/resources/
  import.sql inserts the chain head there.
- MCP refs to AuditService.java and AuditResource.java re-pointed.
- Security chapter: how the chain works and how to keep a head hash.

Verified locally: mvnw test (1783, 0 failures), spotless:check,
TestMCPCaveatRefAnchors, golangci-lint --new-from-rev=origin/main,
go vet, check-book-style.sh. V28 has run only on H2.
ComplianceChecksIT runs V27 as Flyway applies it and the period queries
with either bound or none, which ComplianceResourceTest only runs on H2.
A microsecond checkedAt reads back unchanged.
AuditChainIT runs V28 as Flyway applies it and the head's row lock under
16 concurrent writers (200 rows, one intact chain), which AuditVerifyTest
only runs on H2. A caller that rolls back takes its row and the head with
it, and a row edited with SQL is named by id.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant