Skip to content

fix(audit): bring authentication events under the tamper-evident chain - #414

Merged
0xmanhnv merged 1 commit into
developfrom
fix/audit-chain-covers-auth-events
Aug 4, 2026
Merged

fix(audit): bring authentication events under the tamper-evident chain#414
0xmanhnv merged 1 commit into
developfrom
fix/audit-chain-covers-auth-events

Conversation

@0xmanhnv

@0xmanhnv 0xmanhnv commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

86% of the audit trail — including every authentication record — has no tamper evidence.

The gap

The hash chain is keyed by tenant. Auth events have no tenant (at login a user may belong to several tenants and has not chosen one), so appendChainEntry returned early:

tenantPtr := log.TenantID()
if tenantPtr == nil {
    return // system-level events bypass the per-tenant chain
}

Live database:

audit_logs: 1075    chained: 132    (12%)

  action        | resource_type | count   <- all unchained
 ---------------+---------------+-------
  auth.login    | user          |   814
  auth.register | user          |    87
  auth.failed   | token         |    20
  auth.logout   | user          |     4
                                    925

An intruder with database access could delete the record of their own login, or the failed attempts that preceded it, and GET /audit-logs/verify would report the trail intact — because it only ever walks rows that were chained.

Nothing documented this. Migration 000154 describes the chain as per-tenant and says nothing about excluding authentication. It was a consequence of the design, not a decision.

How I got here (and what I nearly filed instead)

The live logs show audit chain break 80× per run, and my own notes record that I once wrongly declared this control healthy. So I checked the data rather than the code.

  • The TruncateRound fix from that earlier round is in place; the code is correct now.
  • Chain rows stop at 2026-07-21 — but that is not a bug: since 2026-07-23 every audit log has been tenant-less, and this is an idle demo environment. I nearly filed "chaining stopped working."
  • The 80 warnings are the same historical pre-fix breaks re-reported each run.

What the data did show is the real gap above.

The fix — three changes that only work together

1. Tenant-less events extend a dedicated system chain.

Sentinel rather than making audit_log_chain.tenant_id nullable: that column is a tenant-isolation boundary and loosening it is the more dangerous change. All-Fs is deliberate — its UUID version nibble is f, and uuid.NewV7/uuid.New can only emit 7 or 4 there, so no generated id can collide with it. All-zeros was rejected for the opposite reason: it is shared.IDs zero value, which call sites already test with IsZero() to mean "unset".

2. The verifier walks it — first. ListActiveTenantIDs can never return the system chain, so the controller adds it explicitly. Writing hashes nobody checks is not tamper evidence. It goes first because a run cut short by its context deadline skips whatever is last, and this is the chain an intruder has the most reason to edit.

3. VerifyChain resolves system entries with a new GetSystemByID (WHERE tenant_id IS NULL). Without this, the tenant-scoped getter cannot see those rows and would report every one of them as audit_log_missing — a fabricated tamper signal on the control that exists to detect real ones.

GetSystemByID is a separate repository method, not a relaxed GetByTenantAndID. That one is a tenant-isolation boundary; widening it so a sentinel also matches NULL rows is exactly the change that later leaks a real tenants rows. This one can only ever return tenant-less rows.

Verification

Tests run through the real repository against a real database — the question is not "does the Go branch take the right path" but "does a row land in audit_log_chain", and this gap survived precisely because each component was individually correct.

Proven fail-before/pass-after by restoring the early return:

--- FAIL: TestSystemChain_AuthEventIsChained
    no chain row for a tenant-less auth event (sql: no rows in result set)...
--- FAIL: TestSystemChain_VerifiesClean
    the system chain verified 0 entries: nothing is being checked

Also covered: tenant events still go to their own chain (a fix that swept everything into the system chain would merge tenants trails), the verifier walks the system chain and walks it first, and the sentinel cannot collide with a generated id (asserted as a property, not a constant, plus 2000 NewID() draws).

  • GOWORK=off go build ./... — ok
  • GOWORK=off go test ./... against app_test — all green (9 test doubles gained GetSystemByID)
  • GOWORK=off make lint-ci — clean

Tradeoff worth naming

Every login now takes chainMu and does one LatestChainHash read plus one insert, where before it did neither. All authentication auditing serialises on a single chain. Login rate bounds it and the per-tenant path already had this shape, but on a high-login-rate deployment this is the thing to watch.

Not in scope

The 132 historical rows still carry pre-fix hash breaks. Clearing them needs RebaselineChain, which is admin-gated and must stay a deliberate human action — it is not something a fix PR should trigger.

The audit hash chain is keyed by tenant. Authentication events have no tenant —
at login a user may belong to several tenants and has not chosen one yet — so
appendChainEntry returned early for them:

    tenantPtr := log.TenantID()
    if tenantPtr == nil {
        return // system-level events bypass the per-tenant chain
    }

On the live database that is 925 of 1075 audit rows, 86%: every auth.login
(814), auth.register (87), auth.failed (20) and auth.logout (4). None of them
carried any tamper evidence. An intruder with database access could delete the
record of their own login, or the failed attempts that preceded it, and
GET /audit-logs/verify would report the trail intact — because it only ever
walked rows that were chained.

Nothing documented this. It was a consequence of the per-tenant design, not a
decision: migration 000154 describes the chain as per-tenant and says nothing
about excluding authentication.

Tenant-less events now extend a dedicated system chain.

Why a sentinel tenant id rather than making audit_log_chain.tenant_id nullable:
that column is a tenant-isolation boundary and loosening it is the more
dangerous change. All-Fs is deliberate — its UUID version nibble is 'f', and
uuid.NewV7 / uuid.New can only ever emit 7 or 4 there, so no generated id can
collide with it. The all-ZEROS UUID was rejected for the opposite reason: it is
the zero value of shared.ID, which several call sites already test with
IsZero() to mean "unset".

Three things had to change together, and any one of them alone would have been
worse than the bug:

  1. appendChainEntry appends tenant-less events to SystemChainTenantID.

  2. The verifier walks that chain. ListActiveTenantIDs can never return it —
     it is not a tenant — so the controller adds it explicitly, and FIRST: a run
     cut short by its context deadline would otherwise skip whatever is last,
     and this is the chain an intruder has the most reason to edit. Writing
     hashes nobody checks is not tamper evidence.

  3. VerifyChain resolves system entries with a new GetSystemByID
     (WHERE tenant_id IS NULL) instead of the tenant-scoped getter, which cannot
     see those rows and would have reported every single one as
     audit_log_missing — a fabricated tamper signal on the control that exists
     to detect real ones.

GetSystemByID is a separate repository method rather than a relaxed
GetByTenantAndID on purpose: that one is a tenant-isolation boundary, and
widening it so a sentinel also matches NULL rows is exactly the kind of change
that later leaks a real tenant's rows. GetSystemByID can only ever return rows
with no tenant.

Tests run through the real repository against a real database, because the
question is not "does the Go branch take the right path" but "does a row land
in audit_log_chain" — and the reason this gap survived is that each component
was individually correct. Verified fail-before/pass-after by restoring the
early return: "no chain row for a tenant-less auth event" and "the system chain
verified 0 entries".

Tradeoff worth naming: every login now takes chainMu and does one
LatestChainHash read plus one insert, where before it did neither. All
authentication auditing serialises on a single chain. Login rate bounds it and
the existing per-tenant path already had the same shape, but on a
high-login-rate deployment this is the thing to watch.
@0xmanhnv
0xmanhnv merged commit 437e25f into develop Aug 4, 2026
17 checks passed
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