Repository navigation
feat(audit): chain the audit log so tampering shows - #561
Merged
Merged
Conversation
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.
This was referenced Oct 30, 2026
This was referenced Oct 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
audit_eventsgainsprev_hashandrow_hash.row_hashis 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.audit_chain_headrow holds the newest hash.AuditChain.appendlocks 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.GET /api/audit/verify(scoperead) walks every row up to the head as it stood when the walk began, oldest first, 1,000 at a time. It answersintact,chained,unchained,headId,headHash, and the firstbrokenAtrow with itsproblem: changed, doesn't follow the row before it (a row removed or added there), or the newest rows are missing.GET /api/auditnow lists each chained row'srowHash. A head hash kept outside Kates can then be compared later; that is what catches a rewrite of the whole table, hashes included.created_atis cut to microseconds, which is what Postgres and H2 keep, so the hash covers what is read back.src/test/resources/import.sqlinserts the chain head there.AuditService.javaandAuditResource.javaincli/cmd/mcp_tools_runs_activity.goare re-pointed.Risks
Deliberately not done
kates audit verifycommand; the evidence export PR will call the endpoint.since/until).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'srowHashin/api/audit; a row edited in the database is named by id.spotless:checkgo test -count=1 ./cmd -run TestMCPCaveatgolangci-lint run --new-from-rev=origin/main ./...(0 issues)go vet ./cmdscripts/check-book-style.shOn 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 verifyon this branch: 1783 unit tests and 98 ITs, 0 failures. The test-onlyimport.sqldoesn't run under the ITs' Flyway schema, so it doesn't collide with V28's head row.