Skip to content

feat(audit): write login/logout rows to sys_audit_log and attribute the last_login_at update - #8244

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-8144-audit-login-logout-writers
Aug 13, 2026
Merged

feat(audit): write login/logout rows to sys_audit_log and attribute the last_login_at update#8244
os-zhuang merged 5 commits into
mainfrom
claude/issue-8144-audit-login-logout-writers

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #8144

Sub-issue A of #7675, carrying the maintainer ruling of 2026-08-12 (comment 5261744983 on #7675), quoted verbatim and untranslated:

补 writer(3 个):login / logout(auth 事件已有钩点,顺带解决那条 user_id null 的未归因 last_login_at diff 行)…

The defect

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 in the platform 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 one unattributed update sys_user row (user_id null) diffing last_login_at: a compliance ledger recording that somebody, unknown, had signed in.

What changed

plugin-audit registers the audit service — 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 where packages/spec/src/system/index.ts records 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, and action is a closed login | logout union — the only structural protection available on an object where every field is readonly and validateRecord skips 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 the MessagingEmitSurface pattern 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.afterlogin. Wired to the session hook rather than the /sign-in/email middleware, 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.after under /sign-outlogout. Scoped deliberately: revokes, bans, user erasure and better-auth's own collection of expired rows all reach the same hook, and recording those as logout would name an action the subject never took. Those revocations already carry their cause on the ADR-0069 D4 tombstone — session-tombstone.ts left 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.
  • The endpoint path comes from the hook's captured ctx argument, not from currentAuthEndpointPath(): delete.after runs inside queueAfterTransactionHook, so the ambient AsyncLocalStorage may no longer answer by then.
  • An impersonation session keeps the subject on user_id and names the admin on actor, so it cannot be misread as a self-service login.

stampLastLogin now carries attributedUserId (#4586), so the last_login_at diff 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 adding last_login_at/last_login_ip to the CRUD writer's repo-wide NOISE_FIELDS, deleting the last_login_ip trail 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.

scripts/check-durability-degradation-log-level.mjs gains persistAuthEventAuditRow, so the new seam's failure report cannot be walked back down to warn.

Generated by Claude Code


Generated by Claude Code

claude added 3 commits August 12, 2026 21:43
…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
…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
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 13, 2026 3:46am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-audit, @objectstack/plugin-auth.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-audit, @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-audit, @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-audit, @objectstack/plugin-auth)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-audit, @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

claude added 2 commits August 13, 2026 03:25
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

audit-log (A): add login/logout writers on the auth session hooks, and attribute the unattributed last_login_at update row

2 participants