feat(audit): write login/logout rows to sys_audit_log and attribute the last_login_at update - #8244
Conversation
…he last_login_at update (#8144) sys_audit_log.action declares `login` and `logout`, the shipped `auth_events` list view filters on them, and two System Overview widgets chart them — but nothing ever wrote either row: the audit writers subscribe to the ObjectQL CRUD lifecycle, so create/update/delete/restore were the only actions that could materialize. The whole trace a sign-in left behind was an unattributed `update sys_user` row (user_id null) diffing last_login_at. - plugin-audit registers the `audit` service — the ledger's ingress for events that are not CRUD. The row shape stays owned by plugin-audit; the caller hands over an EVENT with a closed `login | logout` union, which is the only structural protection available on an object whose action enum nothing validates in either direction (#8203). - plugin-auth emits from better-auth's session lifecycle hooks: session.create.after => login (covers every sign-in method, not just /sign-in/email), session.delete.after under /sign-out => logout. Revokes, bans, erasure and expired-row collection are deliberately NOT logout — they already carry their cause on the ADR-0069 D4 tombstone, and naming them logout would be a wrong record rather than a vague one. - stampLastLogin now carries attributedUserId (#4586), so the last_login_at diff row names the person who signed in. Attributed rather than excluded: the write still authorizes as the system, and suppressing it would delete the last_login_ip trail repo-wide. Neither package depends on the other; a stack without plugin-audit writes no auth rows, exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
…it-login-logout-writers
…8144) #8200 retired `permission_change` / `export` from the action enum and narrowed the `auth_events` view in the same PR. A hard-coded copy of the old filter kept querying a value nothing can hold while still reporting success — the view has exactly the shape that hides it, since the login rows alone satisfy the assertion. Read the shipped filter instead, so the test tracks the view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…it-login-logout-writers
…tuple (#8144) `vi.fn(async () => undefined)` types `mock.calls` as `[][]` — a zero-length tuple — so every `calls[0][0]` in this file reached past the end of a tuple the type system believed was empty (TS2493 x3), and the dereference that followed was `possibly undefined` (TS18048 x3). Reading the argument back is the entire point of these cases, so the fix is to declare what the spy receives rather than to soften the read: the spy's implementation now names its parameter, and the call log is pulled through `recordedEvents` / `firstEvent`, which name the "never called" case instead of letting it surface as a TypeError. Pinning the element type to `AuthSessionAuditEventInput` also makes these assertions type-check against the real event surface instead of `any`: a renamed field now fails at compile time rather than quietly comparing `undefined` to `undefined`. One `(c: any[])` map goes away with it. Behaviour unchanged — 1113/1113 plugin-auth tests pass, same 20 cases. Why now: #8225 lowered this package's TEST_DEBT ceiling 131 -> 111 after this branch was cut, so these six errors stopped being slack and became a violation in the merge queue. Measured at 117 against the merged tree, 111 after this commit — exactly the ceiling, ledger untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Fixes #8144
Sub-issue A of #7675, carrying the maintainer ruling of 2026-08-12 (comment 5261744983 on #7675), quoted verbatim and untranslated:
The defect
sys_audit_log.actiondeclaresloginandlogout, the shippedauth_eventslist view filters on them, and two System Overview widgets chart them — but nothing in the platform ever wrote either row. The audit writers subscribe to the ObjectQL CRUD lifecycle, socreate/update/delete/restorewere the only actions that could materialize. The whole trace a sign-in left behind was one unattributedupdate sys_userrow (user_idnull) diffinglast_login_at: a compliance ledger recording that somebody, unknown, had signed in.What changed
plugin-audit registers the
auditservice — the ledger's write ingress for events that are not CRUD (src/auth-event-audit.ts). The row shape stays owned by plugin-audit, which is wherepackages/spec/src/system/index.tsrecords that it belongs ("the LIVE audit path (plugin-audit) … defines its own sys_audit_log row shape"). The caller hands over an event, not a row, andactionis a closedlogin | logoutunion — the only structural protection available on an object where every field isreadonlyandvalidateRecordskips readonly/system fields on both branches (#8203), so the declared enum validates nothing in either direction.plugin-auth emits from better-auth's session lifecycle hooks (
src/auth-session-audit.ts), resolving the sink lazily through a locally-declared structural surface — the mirror of theMessagingEmitSurfacepattern plugin-audit already uses. Neither package depends on the other; a stack without the audit plugin writes no auth rows, exactly as before.session.create.after⇒login. Wired to the session hook rather than the/sign-in/emailmiddleware, so it covers every way a session is minted: email sign-in, sign-up auto-sign-in, SSO, OAuth callback, magic link, email OTP, passkey. On a real deployment most sign-ins are federated, and an endpoint-scoped writer would have audited the minority.session.delete.afterunder/sign-out⇒logout. Scoped deliberately: revokes, bans, user erasure and better-auth's own collection of expired rows all reach the same hook, and recording those aslogoutwould name an action the subject never took. Those revocations already carry their cause on the ADR-0069 D4 tombstone —session-tombstone.tsleft this exact question to audit-log-browser: 4 of 10 declared sys_audit_log actions have no writer anywhere (login/logout/permission_change/config_change/export/import never materialize) #7675.ctxargument, not fromcurrentAuthEndpointPath():delete.afterruns insidequeueAfterTransactionHook, so the ambient AsyncLocalStorage may no longer answer by then.user_idand names the admin onactor, so it cannot be misread as a self-service login.stampLastLoginnow carriesattributedUserId(#4586), so thelast_login_atdiff row names the person who signed in. Attribution only — the write still authorizes as the system. Attributed rather than excluded (the ruling allowed either): suppressing it would mean addinglast_login_at/last_login_ipto the CRUD writer's repo-wideNOISE_FIELDS, deleting thelast_login_iptrail for every object and deployment, and a login from a new address is exactly what a compliance ledger is read for.Verification
Every assertion reads the row back. On this object "no error was thrown" is evidence of nothing — a misspelled action is accepted silently and a writer that never ran throws nothing either.
packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts— audit-log-browser: 4 of 10 declared sys_audit_log actions have no writer anywhere (login/logout/permission_change/config_change/export/import never materialize) #7675's own reproduction, inverted: real sign-up + sign-in through better-auth, thenGET /api/v1/data/sys_audit_log?$filter={"action":"login"}as admin returns the event with actor and tenant (the tenant checked against thesys_memberrow the platform wrote, not against anything the writer passed in). Plus: theauth_eventsview stops being empty (its filter read from the running registry, so refactor(plugin-audit)!: retireexport/permission_changefrom the sys_audit_log action enum (#8147) #8200's narrowing cannot silently strand it), sign-out writeslogout, a revoke does not, and thelast_login_atrow is attributed. 5/5.total 0, literally audit-log-browser: 4 of 10 declared sys_audit_log actions have no writer anywhere (login/logout/permission_change/config_change/export/import never materialize) #7675's symptom — with logout, revoke-is-not-logout and thelast_login_atattribution staying green. Plain red, no inversion.scripts/check-durability-degradation-log-level.mjsgainspersistAuthEventAuditRow, so the new seam's failure report cannot be walked back down towarn.Generated by Claude Code
Generated by Claude Code