M1-24: Audit: hash chain, recorder and aggregation (ADR 0021)¶
Status: ready | Depends on: M1-15, M1-18, M1-26 | Milestone: M1
Goal¶
After this ticket every module can write the audit log through one port, kernel/auditing.
Entries of a business transaction land in audit_events in the same transaction, chained by
audit_seq and SHA-256 over a canonical form (RFC 8785); denials whose business transaction
rolled back land in their own transaction, and identical denials are aggregated per
audit.deny_aggregate_window. The application role can only insert and read. Client IP
addresses appear only as keyed HMAC with a key id. M1-50 (verification, checkpoints, API) and every
audited action of M1 build on this.
M1-18 is needed for the correlation ID and the client IP in the request context
(internal/kernel/reqmeta); M1-11 (shutdown step for the aggregation flush) comes with it.
M1-26 is needed for the shared text validator internal/kernel/textcheck that checks reason.
Read first¶
docs/adr/0021-hash-chained-audit-log-separate-from-telemetry.md(all of it).docs/spec/03-data-model.md: "1. Principles" (1, 2, 8, 9, 11, 13), "3. Retention classes" (audit), "4.1" tablesequence_counters, "4.12 Audit, privacy, status" tablesaudit_events,audit_checkpointsandaudit_ip_keys, "6.1 Global event sequence", "6.3 Lock order", "6.4 I1 check" (denial audit in an own transaction), R-DM-28 in the decisions table.docs/spec/04-sync-protocol.md: "3.2 Pattern with a counter row", "3.3 Other counters".docs/spec/05-actions.md: "1. Binding rules" (rules 5 and 10), "2.4 Columns Owner, Step-up, Audit, Permissions" (aggregation, R-AZ-04), "2.5 Decision", "10. Configuration values" (audit.deny_aggregate_window,audit.deny_aggregate_max_keys).docs/spec/07-permissions.md: "4.1 Decisions and reasons" (closed list of reason codes), "11.2 Required tests" (AZ-P13).docs/spec/02-architecture.md: "4.2 Module list" (audit), "4.3 Ports, events and sources per module", "4.4 Allowed dependencies" (auditmay useinstance), "4.5 Cross-cutting ports" (kernel/auditing), "5. Layers within a module", "7.3 Other checks in the gate", "8.1 Flow" (phase 3.3), "8.2 Lock order", "8.5 What never happens in a transaction", "14.1 Error kinds", "15.3 Shutdown".docs/spec/19-security.md: "1. Trust assumptions", "9." (SEC-LOG-4, SEC-LOG-5), "10. Secrets" (SEC-SEC-3), "12. Supply chain, backups and audit integrity" (SEC-AUD-1, SEC-AUD-4 canonical form, SEC-AUD-5 schemaaudit_details.v1, SEC-AUD-6 aggregation), "14. Tests" (SEC-T16), "16. Configuration values".docs/spec/20-privacy.md: "4.5 Audit log and IP addresses", "10. Tests" (DP-T02, DP-T06), "13. Configuration values" (privacy.audit_ip_key_period,privacy.audit_ip_key_retention,privacy.audit_ip_kek_ref);docs/operations/configuration.mdsection 5 (audit_ip_kek).docs/spec/16-operations.md: "7.1 Roles" (bothy_app,bothy_maint,bothy_owner).docs/spec/17-observability.mdrule 6 and R-OB-07,docs/observability/audit-vs-telemetry.md.docs/api/openapi.yamlschemasAuditEvent,AuditDecision,Timestamp(the canonical form equals this representation withouthash).docs/PLAN.md: section 7, rows M1-13, M1-15, M1-16, M1-18, M1-24, M1-28, M1-29, M1-50, M1-69; "Prerequisites from the core contract" (rows for M1-24); decisions P-09, P-11, P-14.docs/security/threat-model.mdregister row T-DB-01.services/server/internal/audit/README.md,services/server/README.md(kernel table).- Code merged by earlier tickets:
internal/kernel/txnand the unit of work ininternal/platform/pg(M1-13, phase hooks, golden file of registrations),internal/instanceportCounters(M1-15),internal/platform/pg/pgtest(M1-12),internal/platform/lifecycle(M1-11),internal/kernel/actorand the correlation ID and client IP thatinternal/platform/httpserverputs into the request context (M1-18),internal/platform/config(M1-07),architecture/modules.yaml,sqlc.yaml(M1-05),make migrations-lint(M1-01).
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behaviour change starts with a failing test (red output goes into the report), then code until green, then cleanup.
- No en dash and no em dash in any file you write. White label: examples use
example.com, people Alex, Sam, Robin, addresses from RFC 5737 and RFC 3849. - Configuration only through named keys listed in
docs/operations/configuration.md. - ADR 0021 and 03
audit_events:seqfromsequence_counters('audit_seq')in the same transaction;prev_hashis thehashof rowseq - 1, 32 zero bytes for the first row;hash = SHA-256(prev_hash || canonical_json(row without hash))with JCS (RFC 8785). - 03 "6.3 Lock order":
audit_seqis locked last, afterevent_seq; 02 "8.1": the audit phase hook is phase 3.3. - SEC-AUD-1 and 03: the application role has only
INSERTandSELECTonaudit_events;UPDATEandDELETEare blocked byREVOKEand a trigger. - 05 "2.4" (R-AZ-04) and SEC-AUD-6: every denial is audited; identical denials (same principal,
or same IP pseudonym without login; same action; same reason) are aggregated per
audit.deny_aggregate_windowinto one row per key, written at window end, at shutdown or whenaudit.deny_aggregate_max_keyskeys are open (then the oldest). - 03 "6.4": a denial whose business transaction rolled back is written in an own transaction.
- 20 "4.5" and K-DP-2: IP addresses only as
ip_hmacwithip_key_id, one key perprivacy.audit_ip_key_period, stored encrypted inaudit_ip_keys(03) under the key ofprivacy.audit_ip_kek_ref. - ADR 0021, SEC-LOG-4, SEC-SEC-3: no content, names, e-mail addresses or secrets in
details. - 17 rule 6: the audit log is not telemetry; no audit rows leave through the collector.
Scope¶
All Go paths are below services/server/.
- Core contract and spec. 03 defines
audit_ip_keys(moduleaudit;key_ciphertextAEAD-encrypted,key_ref,destroyed_at, checks and permissions as listed there); 19 defines the canonical form (SEC-AUD-4), the schemaaudit_details.v1(SEC-AUD-5) and the aggregation (SEC-AUD-6); 05 holdsaudit.deny_aggregate_max_keys; 20 "4.5" says where the keys live. Build exactly that; change a chapter only if the build proves it wrong, in the same change. - Configuration and secrets. Schema entries
audit.deny_aggregate_window,audit.deny_aggregate_max_keys,privacy.audit_ip_key_period,privacy.audit_ip_kek_ref; the catalogue entryaudit_ip_kek(generatorkey, serviceserver) in the secrets catalogue of M1-09 (find it withgrep -rn token_pepper services/server/internal/platform/secrets). Regenerate section 4 ofdocs/operations/configuration.mdif the schema adds keys. -
Port
internal/kernel/auditing(fill the package that M1-05 created):type Decision string // "allow", "deny", "error" (audit_events.decision) type Reason string // closed list of 07 "4.1 Decisions and reasons" (deny_reason) const ReasonUnauthenticated Reason = "unauthenticated" // ... one constant per row of 07 4.1 func KnownReasons() []Reason // Entry is one auditable decision. Actor, client IP and correlation ID come from ctx. type Entry struct { Action string // action id from 05, system actions included Decision Decision Reason Reason // empty for allow ResourceKind string // optional ResourceID ids.ID // optional; zero value means none Attrs Attrs // optional; flat members of details (19 SEC-AUD-5) } type Attrs map[string]Value // keys must not collide with ip_hmac, ip_key_id, aggregate, violations type Value struct { /* unexported */ } func ID(v ids.ID) Value func Code(s string) Value // short machine code, never free text func Int(n int64) Value func Bool(b bool) Value func Time(t time.Time) Value func List(vs ...Value) Value // list of one of the kinds above // Violations is the reserved member for i1_violation (19 SEC-AUD-5); M1-29 fills it. type Violation struct { Code string; AgentID, CredentialHolderID ids.ID; PrincipalIDs []ids.ID } type Recorder interface { // Record adds e to the caller's unit of work; the audit phase hook writes it before commit. // Outside a unit of work it returns an internal error. Record(ctx context.Context, e Entry) error // RecordDenial aggregates a denial whose business transaction rolls back and writes it in // an own transaction later. It returns an error only for an invalid entry, never for storage. RecordDenial(ctx context.Context, e Entry) error // RecordDetached writes e at once in an own transaction, never joining an ambient one // (decision error, for example unknown_action). RecordDetached(ctx context.Context, e Entry) error } -
Module
internal/audit:domain:Row(all columns ofaudit_events),CanonicalRow(Row) ([]byte, error),ChainHash(prev [32]byte, canonical []byte) [32]byte,ZeroHash,DetailsV1with validation,IPHMAC(key []byte, addr netip.Addr) string, aggregation key and bucket logic as pure functions with the time passed in. Standard library only.app: theauditing.Recorderimplementation, the aggregator (Run(ctx)with a timer at the earliest bucket deadline,Flush(ctx)), the phase-3.3 hook logic, the keyring (current key, in-memory cache untilvalid_until), outgoing ports for storage. Imports only the own port, own domain,kerneland theinstanceport.adapters/postgres: sqlc queries inqueries/audit.sql(insert rows, read hash byseq, read current IP key, insert IP key withON CONFLICT (valid_from) DO NOTHING, read key by id), repository,sqlc.yamlentry foraudit.internal/wire: construct the recorder, register the hook in the audit slot (phase 3.3) as a method value of theaudithook type (never a closure ininternal/wire, 02 "Flow") and update the golden file of registrations, start the aggregator with the role's lifecycle and register a shutdown step that flushes it before the pool closes (both roles), fill the typedauditandprivacyconfig.
- Migration in
migrations/with the next free sequential number (followmake migrations-lint):audit_eventsexactly as in 03 (columns,CHECKondecision, PKseq, uniqueidandhash, indexes(actor_principal_id, occurred_at DESC),(resource_kind, resource_id),(action, occurred_at DESC), BRIN(occurred_at));REVOKE ALL ON audit_events FROM PUBLIC;GRANT SELECT, INSERT ON audit_events TO bothy_app;REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM bothy_app(default privileges may have granted them); a row triggerBEFORE UPDATE OR DELETEthat raises for every role except aDELETEbybothy_maint, and a statement triggerBEFORE TRUNCATEthat always raises. The IP key table from item 0 with its grants. No grants forbothy_mainthere (the retention ticket of M7 adds them). - Configuration schema:
audit.deny_aggregate_window(default60s,> 0),audit.deny_aggregate_max_keys(default10000,> 0),privacy.audit_ip_key_period(default30d),privacy.audit_ip_key_retention(default90d); cross check retention>=period. - Register and docs:
architecture/modules.yamllists the new table foraudit;internal/audit/README.md(English: status, tables, port, hook, canonical form, details schema, aggregation, keyring, limits); package doc ofkernel/auditingwith an example;docs/security/threat-model.mdrow T-DB-01 notes the part of SEC-T16 this ticket proves;docs/DECISIONS.mdentries only for the IP canonical form and the location of the reason list (the canonical form,audit_details.v1and the aggregation are in 19 and 05);services/server/CHANGELOG.mdunderUnreleased(Added,Security).
Out of scope¶
audit.verify, checkpoints,audit_checkpoints, the jobaudit.chain_verify, the operationslistAuditEvents,exportAuditEvents,verifyAuditChainand the rest of SEC-T16 (detection of a changed row and of a rewritten chain): M1-50. ExposeCanonicalRowandChainHashso M1-50 reuses them.- Calling the recorder from
authorizeaccording to the "Audit" column of 05 (end-to-end AZ-P13): M1-28 and M1-29 ownauthorizeand the permission changes. This ticket proves aggregation at the recorder. - Truncation with anchor, grants for
bothy_maint, destruction of IP keys afterprivacy.audit_ip_key_retentionand DP-T06: M7 (P-14). - Telemetry about audit entries: none (17 rule 6).
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-24-audit-chain, created from the base branch the launcher names. One writer per worktree. - Conventional Commits with the module as scope (for example
feat(audit): hash chain in the commit phase), each commit signed off for the DCO (git commit -s); squash into meaningful commits before the merge. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above, except
docs/DECISIONS.md,services/server/CHANGELOG.mdand generated references named above. - The core contract (00, 03, 04, 05) is read-only (item 0 and P-11).
- Follow the host rules supplied by the launcher. PostgreSQL 18 only through
pgtestas a throwaway container on a random port bound to127.0.0.1(P-09, P-10); without a Docker runtime the integration tests fail, they never skip. - Stop only processes and containers you started, by PID or ID. Temporary directories only via
mktemp -d(in Go testst.TempDir()), removed at the end. - Never read or print secrets; never open
.envfiles. - No network calls except package downloads for pinned tools and the pinned PostgreSQL image.
- Do not lower a threshold, disable a gate or delete a test.
Design notes¶
- Write path. One path for all entries: the phase-3.3 hook.
Recordappends to a per-transaction buffer held through the unit of work (use the per-transaction state kernel/txn offers; if it offers none, add a minimal one there and report it). The hook does nothing when the buffer is empty (no lock onaudit_seq). Otherwise it reservesnnumbers throughinstance.Counters(the lock onaudit_seqholds until commit), readshashof rowfirst - 1(ZeroHashwhenfirst == 1; a missing row is an internal erroraudit chain brokenthat fails the transaction and is logged), computes the rows in order and inserts them in one statement.RecordDenialbuckets andRecordDetachedrun the same hook in an own unit of work that never joins an ambient transaction. If the hook fails, the business transaction fails ("if the audit fails, the change fails"). - Canonical form of a row (the JSON object
canonical_jsonhashes). Members are the column names excepthash.seqis a JSON integer (reject values above 2^53 - 1);id,actor_principal_id,actor_device_id,actor_session_id,resource_id,correlation_idare lower-case hyphenated UUID strings;occurred_atis RFC 3339 UTC with exactly three fractional digits andZ, the APITimestampform (the recorder truncates the time to milliseconds before storing and hashing, so the database round trip is exact);action,decision,deny_reason,resource_kindare strings;detailsis the object as stored;prev_hashis lower-case hex. NULL columns are omitted, nevernull(03 rule 13). Serialise per RFC 8785 in the restricted domain: object keys sorted (all keys are ASCII, so byte order equals UTF-16 order); strings escape only",\and control characters below U+0020 (\b \t \n \f \r, otherwise\u00xxwith lower-case hex); everything else, including non-ASCII,<,>,&, U+2028 and U+2029, is written literally in UTF-8; integers in decimal without exponent; no floats, nonull. Values read back fromjsonbare decoded withUseNumberso integers stay exact. With this form the API representationAuditEventwithouthashis exactly the hashed object, so an exported log can be verified offline (M1-50 must export the same form). - Schema
audit_details.v1exactly as 19 SEC-AUD-5: a flat object with the reserved membersip_hmac,ip_key_id,aggregate(countinteger>= 1,first_at,last_atin the canonical time form),violationsandreason(the only free text: the reason a management action requires, checked withkernel/textcheck; port functionReasonText(s string) Value, accepted only under the keyreason), plus attribute members whose keys match^[a-z][a-z0-9_]*$and whose values are only theValuekinds of the port (UUID strings, codes, integers, booleans, times, lists of these). Codes match^[a-z0-9][a-z0-9_.:-]*$with a length bound held by a named constant and a test. No free text, nonull, no floats, no NUL characters. A value outside these forms is rejected atRecordtime (a programming error); later tickets that need more extend the schema deliberately in the code and in 19. - IP pseudonyms. Input: the client IP from the request context,
Unmap(), zone removed, text form ofnetip.Addr.String().ip_hmac = hex(HMAC-SHA-256(key, text)), full length. Keys: one row ofaudit_ip_keysper period;valid_from = floor(now / period) * periodin Unix time,valid_until = valid_from + period; on a cache miss the keyring reads the row coveringnow, or inserts a new one in an own short transaction: 32 random bytes fromcrypto/rand, encrypted with AES-256-GCM under the key behindprivacy.audit_ip_kek_ref(hex-decoded to 32 bytes, generatorkeyof M1-09; random nonce stored in front of the ciphertext, the rowidas additional data),key_refset to that reference (ON CONFLICT (valid_from) DO NOTHING, then read again). The decrypted key is cached untilvalid_until. A destroyed key (key_ciphertextNULL) is never used. Raw IP addresses are stored nowhere inaudit_events. The destruction job and DP-T06 belong to M7-05. - Actor and correlation.
actor_principal_id,actor_device_id,actor_session_idcome fromkernel/actor; they are absent for system actions and server commands.correlation_idis the trace ID that M1-18 puts into the request context, rendered as UUID text; it is not a UUIDv7 and must not be validated as one (02 "Cross-cutting ports"). Read correlation ID and client IP throughinternal/kernel/reqmeta(M1-18), never fromplatform. - Aggregation of denials. Key: actor principal (or, for anonymous requests,
ip_hmac), action, reason. The first denial of a key opens a bucket withfirst_at; later identical denials withinaudit.deny_aggregate_windowfromfirst_atonly raisecountandlast_at. A bucket is written as one row when its window ends, atFlush, at shutdown, or at once whenaudit.deny_aggregate_max_keysbuckets are open and a new key arrives (the oldest is written). The row carries the actor, resource and correlation of the first denial,occurred_at = first_at,decision = deny, anddetails.aggregate. A denial after the window opens a new bucket. If writing fails, the buckets stay and the next timer retries; only when the cap is reached and the oldest bucket still cannot be written is it dropped with anErrorlog naming action, reason and count (principal only as ID). Aggregation is per process; several processes may each write one row per window. A hard crash loses the open buckets of at most one window (residual risk, 19 SEC-AUD-6 and threat model T-AZ-04); name it in the README. - Reason list (R-DM-28).
kernel/auditing.Reasonis the single closed list of 07 "4.1";authzreuses it in M1-28 instead of defining its own. The database has noCHECKondeny_reason; the recorder writes an unknown reason anyway (never lose an entry) and logs anError. - Errors. Storage and chain errors are kind
internalwith codeinternal_errortowards clients (02 "14.1"); details only in the log with the correlation ID. - Never in a transaction: no network calls (02 "8.5"); the keyring touches only PostgreSQL.
Tests (write them first)¶
Unit tests in internal/audit/domain and internal/audit/app (fake clock from kernel/clock,
fake storage); integration tests in internal/audit/adapters/postgres and internal/wire with
pgtest (PostgreSQL 18).
TestCanonicalRow_Golden:testdata/chain_v1.jsonwith at least three rows (one with all optional columns, one with none, one with non-ASCII text in an attribute code is not allowed, so use a control character case throughdeny_reasontext read from the database path instead); for at least one row the expected canonical string is written literally in the test, not computed; expected hashes are stored in the file.TestCanonicalRow_Escaping: quote, backslash, each control character class,<>&, U+2028 and a non-ASCII letter in a string value.TestCanonicalRow_OmitsNull,TestCanonicalRow_KeyOrderIndependent,TestCanonicalRow_RejectsOutOfDomain(float,null, non-ASCII key, integer above 2^53 - 1, NUL).TestChainHash_FirstRowZeroPrevHash,TestCanonicalRow_TimeHasMilliseconds.TestDetailsV1_ClosedSchema: reserved member with a wrong shape, attribute key colliding with a reserved member, free-text attribute, float, bad key, bad code rejected;violationsaccepted.TestReasonsMatchChapter07: parses the table indocs/spec/07-permissions.mdsection "4.1 Decisions and reasons" (first column) and compares withKnownReasons()both ways.TestIPKey_EncryptedAtRest(the storedkey_ciphertextdiffers from the key and decrypts only with the configured key),TestIPHMAC_Vectors(fixed key,192.0.2.44and2001:db8::44, literal expected hex),TestIPHMAC_MappedEqualsIPv4,TestIPHMAC_ZoneIgnored.TestAZP13_IdenticalDenialsInWindowYieldOneEntry(count equals the number of denials),TestAZP13_DifferentKeysSeparate(other principal, action or reason),TestAZP13_NewWindowNewEntry,TestAZP13_AnonymousKeyedByIPHMAC,TestDenyAggregation_CapWritesOldest,TestDenyAggregation_FlushOnShutdown,TestDenyAggregation_WriteFailureKeepsBuckets.TestAuditChain_ConcurrentWritersGapless: many goroutines run units of work that record entries, a random part of them roll back; afterwardsseqruns from 1 without gaps, everyprev_hashequals the previoushash, everyhashrecomputes fromCanonicalRowread back from the database.TestAudit_InTxEntryRolledBackWithBusinessTx,TestAudit_DenialSurvivesBusinessRollback(deny after rollback,Flush, row present),TestAudit_RecordOutsideTransactionFails,TestAudit_NoEntriesNoCounterLock(audit_sequnchanged after a unit of work without entries).TestSECT16_AppRoleCannotUpdateOrDelete: connected asbothy_app,UPDATE,DELETEandTRUNCATEfail with SQLSTATE42501;TestAudit_TriggerBlocksOwnerUpdate: asbothy_ownerthe trigger raises onUPDATE.TestAudit_IPStoredOnlyAsHMAC: a request context with client IP192.0.2.44yieldsdetails.ip_hmacanddetails.ip_key_id; the row text does not contain192.0.2.44.TestAuditIPKeys_OneKeyPerPeriodUnderConcurrency,TestAuditIPKeys_NewPeriodNewKey.- The golden file of hook registrations (M1-13) shows the audit hook in phase 3.3.
BenchmarkAuditAppend(reported, not gating).
Commands that must pass: make test, make migrations-lint, make arch, make codegen-check,
make register-check, make docs, make check.
Acceptance criteria¶
- The chain stays gapless under concurrency (
audit_seq), and every hash recomputes from the canonical form of the row as read back. - SEC-T16, part application role:
UPDATE,DELETEandTRUNCATEbybothy_appare refused; the trigger also stops the table owner. - AZ-P13 at the recorder: identical denials produce one entry with a counter per
audit.deny_aggregate_window; different keys stay separate. - A denial entry survives the rollback of its business transaction.
- IP fields only as
ip_hmacwithip_key_id; one key perprivacy.audit_ip_key_period. -
detailsfollowsaudit_details.v1; unknown members, free text, floats andnullare refused. - The reason list equals 07 "4.1" (test).
- The module README,
modules.yamlandconfiguration.mddescribe what was built; 03, 05, 19 and 20 still match it. -
make checkis green.
Definition of done¶
- Code, tests and documentation from the scope are complete: doc comment on every public symbol
(purpose, parameters, return value, errors, example for
Recorder), module README,docs/DECISIONS.mdentries named in Scope, CHANGELOG entry underUnreleasedinservices/server/CHANGELOG.md. ADR 0021 covers the architecture; write a new ADR only if you take a decision it does not cover. make checkis green.- Independent review:
make reviewplus a code review over the diff; then the red team ofdocs/quality/red-team-gate.mdover the diff with verdictJAorNEIN, recorded in the report. OnNEIN: fix with a test per finding and have the delta reviewed again. - Merge locally into the base branch only if review and red team are green and
make checkis green on the merged state; thengit worktree removethe worktree.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, test results with counts (including the red output of the
first test run), what is not proven (at least: end-to-end AZ-P13 through authorize, the full
SEC-T16), and follow-ups that could not be done in scope with the reason. Then write
{{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.