Skip to content

Commit 3465ad4

Browse files
fix(plugin-audit,plugin-auth): a refused sign-in and an account lockout each write an audit row (#24797)
Security fix (maintainer-ordered; no public card; detail withheld under the disclosure ruling). Clause-②: no ## The invariant this restores A refused sign-in and an account lockout each write a `sys_audit_log` row, as a successful sign-in already writes `login`. Each row records the account, the outcome, the time and the client IP. The rows show under Setup → Audit Log's *Auth* preset, and `sys_audit_log.action` declares their actions. ## What changed **`@objectstack/plugin-audit`** - `sys_audit_log.action` gains `login_failed` and `account_locked`, writer-first. The `auth_events` list view (the *Auth* preset) now filters on `login`, `logout`, `login_failed` and `account_locked`. The writer inventory in `sys-audit-log-retired-actions.test.ts` names their writer. - The `audit` service sink (`createAuthEventAuditSink`) takes two event classes: - **Session events** (`login`, `logout`) are unchanged. They name `sys_session` and the session id, and `actor` is the subject. - **Account events** (`login_failed`, `account_locked`) name the account: `user_id` is the account, and the target is `sys_user` and its id. `actor` is null, because a refused attempt authenticated nobody. The ledger's record read gate judges these rows by that `sys_user` record. The lost-row report no longer says a refused sign-in succeeded. - New exported types: `AuthEventAuditAction` and `AuthAccountAuditAction`. `AuthSessionAuditEvent.action` widens to `AuthEventAuditAction`. - Translation bundles carry the two new options. The README action and view tables and `content/docs/kernel/runtime-services/audit-service.mdx` describe the account events. The README table also gains the `platform_admin_standing_change` row it was missing, so its value count is correct. **`@objectstack/plugin-auth`**, at the `/sign-in/email` lockout seam (ADR-0069 D2): - A refused sign-in that names an existing account emits `login_failed`. Its `reason` is the refusal's machine code (`INVALID_EMAIL_OR_PASSWORD`, …), taken only when it is shaped as an error code. This happens whether or not `lockoutThreshold` is set. - The refused sign-in that stamps the lock also emits `account_locked`, with `locked_until` and `threshold`. `recordSignInOutcome` now returns the lock it actually wrote, so a lock write that did not land is never reported as a lock. - A sign-in refused by the lock gate emits `login_failed` with `reason: ACCOUNT_LOCKED`. - The client fingerprint is read by better-auth's own rule, `getIP(headers, options)` plus `user-agent`. That is the rule `internalAdapter.createSession` uses to stamp the session row a `login` row copies. The IP source is unchanged; no new IP rule is introduced. - The account is resolved the way the credential check resolves it. The submitted identifier is never recorded, and an identifier that resolves to no account emits nothing. The password is never read. A successful sign-in is unchanged: the session hook records `login`, and no account event is emitted. ## Tests - `packages/plugins/plugin-auth/src/auth-account-event-audit.test.ts` (new). It runs a real `AuthManager` and better-auth pipeline through `handleRequest`, with a recording audit sink, and checks: - one refused sign-in yields one `login_failed` naming the account, the code and the client, with no password material in any event; - its IP and user agent equal those of the `login` event for the same client; - it is recorded with lockout off; - it names the account the credential check resolved; - an unresolvable identifier yields nothing; - reaching the threshold yields `login_failed` ×3, then `account_locked`, whose `locked_until` equals the stored lock; - a sign-in refused while locked yields `login_failed` with `ACCOUNT_LOCKED`, and no second lock; - refusals below the threshold yield no lock; - a successful sign-in yields `login` and no account event. - `packages/plugins/plugin-audit/src/auth-event-audit.test.ts`: account events land as rows about the account, with `actor` null and the outcome and client recorded. Session events still name their session. - `sys-audit-log-retired-actions.test.ts`: the declared actions are exactly the actions with a writer, set equality in both directions. **Ablation**, run once on the committed implementation with `scripts/ablation-replace.mjs` in wrap mode. Every leg was restored, with blob equal to HEAD and an empty `git diff HEAD`. | Leg | Mutation | Result | |---|---|---| | A | after-hook `login_failed` emission disabled | 6 cases red | | B | `account_locked` emission disabled | 1 case red, the threshold case | | C | lock-gate `login_failed` emission disabled | 1 case red, the locked-refusal case | | D | sink credits the subject as `actor` on an account event | 2 cases red | ## Gates (local, at `789c68ff9`, after merging `origin/main` at `74f78159a`) - **Build.** The dependency closure was built with `turbo run build --filter=@objectstack/plugin-auth^... --filter=@objectstack/plugin-audit^...`. After the merge, `@objectstack/cli...` was built too, since `check:i18n` runs the built CLI. Both exited 0. - **Typecheck.** Both exited 0: - `pnpm --filter @objectstack/plugin-audit typecheck`; - `pnpm --filter @objectstack/plugin-auth typecheck`, which includes `tsconfig.examples.json` and `check:test-typecheck`. - **Tests.** All exited 0: - `@objectstack/plugin-audit`: 59 files, 966 tests, re-run after the merge; - `@objectstack/plugin-auth`: 166 files, 3187 passed and 10 skipped before the merge; the three touched suites (`auth-account-event-audit`, `auth-session-audit`, `auth-manager`, 332 tests) re-run after it. `auth-account-event-audit` was re-run again after the last commit. - **Gate families.** `node scripts/pm/dispatch-gates.mjs --commands` derived 101 families for this change set. All were run at the final head, plus `pnpm --filter @objectstack/spec check:generated` and `node scripts/check-nul-bytes.mjs`. `--ran` reconciliation: 101 derived, 100 run with exit 0, 1 NOT MEASURED, 0 unrun. - NOT MEASURED: `node scripts/check-plugin-teardown-shape.mjs --self-test` exited 3 (PREREQUISITE NOT MET). Its positive-control fixture is pinned to a commit this shallow checkout cannot reach. CI runs it on a full checkout. - The first battery round caught one real finding: `check:query-options-erasure` reported the new account lookup erasing its query type. That is fixed in `789c68ff9`, and the ratchet holds at 64 sites. - CI check-runs on this PR are the authority for every other family. --- _Generated by [Claude Code](https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6b22246 commit 3465ad4

17 files changed

Lines changed: 833 additions & 72 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-audit': minor
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
fix(plugin-audit,plugin-auth): a refused sign-in and an account lockout each write a `sys_audit_log` row, as a successful sign-in already does
7+
8+
Clause-②: no
9+
10+
- **Two new ledger actions, each with its writer.** `sys_audit_log.action` gains `login_failed` and `account_locked`. A refused `/sign-in/email` that names an existing account writes one `login_failed` row. The refused sign-in that stamps the account lockout (`lockoutThreshold`, ADR-0069 D2) also writes one `account_locked` row. A sign-in refused while the account is locked writes `login_failed` with the reason `ACCOUNT_LOCKED`. A successful sign-in still writes `login`, unchanged.
11+
- **What each row records.** `user_id` is the account the sign-in named, and the row names `sys_user` and that account's id as its target. The outcome is the action, plus the refusal's machine code in `metadata` (`INVALID_EMAIL_OR_PASSWORD`, `ACCOUNT_LOCKED`, …), and the lock's `locked_until` and `threshold` on an `account_locked` row. `created_at` is the time. `ip_address` and `user_agent` are resolved by the rule better-auth applies to a session row, which is where a `login` row's values come from. `actor` is null, because a refused attempt authenticated nobody. A row never carries the attempted credential, and an identifier that resolves to no account writes no row.
12+
- **Where they show.** Setup → Audit Log's *Auth* preset (the `auth_events` list view) now lists `login`, `logout`, `login_failed` and `account_locked`. Each row is about the account it names, so the ledger's record read gate judges it by that `sys_user` record, as for any row about a user. It carries no `tenant_id`: a refused attempt has no session to take an organization from, so it is a deployment-level row under the ledger's organization scope.
13+
- **Recorded with or without a lockout.** A refused sign-in writes its row whether or not `lockoutThreshold` is set.
14+
- **`@objectstack/plugin-audit` types.** `AuthSessionAuditEvent.action` widens to `AuthEventAuditAction` (`'login' | 'logout' | 'login_failed' | 'account_locked'`). `AuthEventAuditAction` and `AuthAccountAuditAction` are new exported types, and `AuthSessionAuditAction` is unchanged.

‎content/docs/kernel/runtime-services/audit-service.mdx‎

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: services.audit — the audit log service API
33
navTitle: services.audit
4-
description: Write ingress for audit events that are not CRUD — today, auth session events.
4+
description: Write ingress for audit events that are not CRUD — today, auth session events and sign-in outcome events on an account.
55
---
66

77
- **Stability:** `experimental`
@@ -10,7 +10,8 @@ description: Write ingress for audit events that are not CRUD — today, auth se
1010

1111
`services.audit` is the kernel service slot that `@objectstack/plugin-audit` registers
1212
during `init()`. It is the **write ingress for audit events that are not CRUD** — today,
13-
auth session events — and every call appends one row to `sys_audit_log`.
13+
auth session events and sign-in outcome events on an account — and every call appends one
14+
row to `sys_audit_log`.
1415

1516
Record-level `create` / `update` / `delete` rows do **not** travel through this slot. The
1617
same plugin writes those from ObjectQL lifecycle hooks, with no service call involved, so
@@ -27,7 +28,7 @@ The slot's entire surface is one method:
2728

2829
```ts
2930
services.audit.recordAuthEvent(event: {
30-
action: 'login' | 'logout';
31+
action: 'login' | 'logout' | 'login_failed' | 'account_locked';
3132
userId: string;
3233
sessionId?: string;
3334
organizationId?: string;
@@ -43,18 +44,32 @@ services.audit.recordAuthEvent(event: {
4344
validates nothing in either direction — a misspelled action would be accepted silently.
4445
This union is the only structural protection an author gets, which is why the caller
4546
hands over an **event** (what happened) rather than a row (what to store).
47+
- `login` / `logout` are **session events**: a sign-in that minted a session, and a
48+
sign-out.
49+
- `login_failed` / `account_locked` are **account events**: a refused password sign-in
50+
that named an existing account, and the refused sign-in that stamped the account
51+
lockout (`lockoutThreshold`, ADR-0069 D2). They exist without any session.
4652
- **`userId`** is required and must be a real `sys_user` id — it lands on the strict
47-
`user_id` lookup.
48-
- **`sessionId`** lands on `record_id`, with `object_name` fixed to `sys_session`, so the
49-
ledger row is navigable to the session it describes.
53+
`user_id` lookup. On an account event it is the account the sign-in named.
54+
- **`sessionId`** lands on `record_id`, with `object_name` set to `sys_session`, so the
55+
ledger row is navigable to the session it describes. An account event names its account
56+
instead — `object_name` `sys_user` and `record_id` the account's id — and does not read
57+
`sessionId`.
5058
- **`organizationId`** is the session's active organization. It stamps the row's tenant
5159
columns, and an unstamped row is one that non-administrator members can never see
5260
through the security layer's predicate.
61+
- **`ipAddress`** / **`userAgent`** are the client fingerprint by better-auth's own rule:
62+
the session row's values for a session event, and the same resolution applied to the
63+
sign-in request for an account event.
5364
- **`actor`** is the principal that *caused* the event when that is not the subject — an
5465
administrator's user id on an impersonation session. Omitted, the subject is recorded as
55-
having acted for themselves.
66+
having acted for themselves on a session event; an account event records no principal,
67+
because a refused attempt authenticated nobody.
5668
- **`context`** is free-form and is serialized into `metadata`, e.g. the endpoint that
57-
produced the event (`/sign-in/email`, `/sign-out`).
69+
produced the event (`/sign-in/email`, `/sign-out`). On an account event it is the
70+
endpoint, the refusal's machine code (`reason`, e.g. `INVALID_EMAIL_OR_PASSWORD` or
71+
`ACCOUNT_LOCKED`) and, for `account_locked`, the lock's `locked_until` and `threshold`.
72+
It never carries the attempted credential.
5873

5974
## Notes
6075

@@ -64,8 +79,9 @@ services.audit.recordAuthEvent(event: {
6479
`kernel:ready`. The sink therefore resolves its engine per call, so it is safe to hold a
6580
reference from the moment the plugin initializes.
6681
- `@objectstack/plugin-auth` is the only in-repo consumer. It resolves the slot lazily
67-
through a locally-declared structural surface and calls it from the session lifecycle,
68-
so neither package depends on the other.
82+
through a locally-declared structural surface and calls it from the session lifecycle
83+
and from the password sign-in outcome, so neither package depends on the other. A
84+
sign-in identifier that resolves to no account produces no call.
6985

7086
## Typical Errors
7187

@@ -76,11 +92,12 @@ caller's side:
7692

7793
- A failed ledger insert is reported at `error` level **once per cause** — a cause is the
7894
error's `code` plus the object the row is about, so every uncoded failure collapses into
79-
one bucket and the object is always `sys_session` on this seam — and the row is simply
80-
lost. Nothing retries it. A repeat of an **already-reported** cause drops to `debug`; a
81-
**new** cause gets its own `error` line, once. The sign-in itself succeeded and returned
82-
200, so the shipped `auth_events` list view and the system-overview widgets keep showing
83-
an empty, healthy-looking screen.
95+
one bucket per object, and the object is `sys_session` for a session event and
96+
`sys_user` for an account event — and the row is simply lost. Nothing retries it. A
97+
repeat of an **already-reported** cause drops to `debug`; a **new** cause gets its own
98+
`error` line, once. The sign-in was answered exactly as it would have been without the
99+
ledger, so the shipped `auth_events` list view and the system-overview widgets keep
100+
showing an empty, healthy-looking screen.
84101
- The call **silently no-ops** when no data engine resolves, or when `userId` is absent.
85102
A caller that passes an event without a subject records nothing and is told nothing.
86103
- The most common cause of a failing insert is a datasource split rather than a broken

‎packages/plugins/plugin-audit/README.md‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,25 @@ these are the only values that are ever written:
108108
| `delete` | `installAuditWriters` (this package) | `afterDelete` |
109109
| `login` | `createAuthEventAuditSink` (this package), called by `@objectstack/plugin-auth` | session start |
110110
| `logout` | `createAuthEventAuditSink` (this package), called by `@objectstack/plugin-auth` | session end |
111+
| `login_failed` | `createAuthEventAuditSink` (this package), called by `@objectstack/plugin-auth` | a refused password sign-in (`/sign-in/email`) that names an existing account |
112+
| `account_locked` | `createAuthEventAuditSink` (this package), called by `@objectstack/plugin-auth` | the refused sign-in that stamps the account lockout (`lockoutThreshold`, ADR-0069 D2) |
111113
| `config_change` | `@objectstack/service-settings` | every successful settings write |
112114
| `import` | `@objectstack/plugin-auth` admin user import | one run-level row, with `record_id: null` |
115+
| `platform_admin_standing_change` | `@objectstack/plugin-security` | a change of who holds platform-administrator standing, plus a first-boot baseline |
113116

114117
Action values are snake_case. There is no namespaced `domain:action` spelling — the enum
115118
above is the complete accepted set, and a value outside it is not a form this object takes.
116119

120+
**Session events and account events.** A `login` / `logout` row is about a session: it names
121+
`sys_session` and the session id, and `actor` is the subject (or the administrator who
122+
started an impersonation session). A `login_failed` / `account_locked` row is about an
123+
account and exists without any session: `user_id` is the account the sign-in named, the row
124+
names `sys_user` and that account's id, and `actor` is null, because a refused attempt
125+
authenticated nobody. Its `metadata` carries the endpoint and the refusal's machine code
126+
(`INVALID_EMAIL_OR_PASSWORD`, `ACCOUNT_LOCKED`, …), plus the lock's expiry and threshold on
127+
an `account_locked` row. It never carries the attempted credential, and an identifier that
128+
resolves to no account writes no row.
129+
117130
## `sys_audit_log` fields
118131

119132
These are the columns the object declares. Every field is `readonly: true`; rows are
@@ -123,7 +136,7 @@ written only by internal system hooks running under `sudo()`, never through UI f
123136
|---|---|---|
124137
| `id` | text | Audit log entry id |
125138
| `created_at` | datetime | When the action occurred (`NOW()` default) |
126-
| `action` | select | One of the eight values above |
139+
| `action` | select | One of the eleven values above |
127140
| `user_id` | lookup → `sys_user` | Null for non-user / service actions |
128141
| `actor` | text | Principal label: a user id, `svc:<name>`, or null. Attributes service-token writes that `user_id` structurally cannot hold |
129142
| `object_name` | text | Target object, e.g. `sys_user` |
@@ -144,7 +157,9 @@ without the secret itself reaching the ledger.
144157
**`ip_address` / `user_agent` are populated on auth events only.** Neither the
145158
record-level writer nor the record-view writer stamps them: a `create` / `update` /
146159
`delete` / `read` row records who and what, not from where. Do not read a null client
147-
fingerprint on such a row as "the request had none".
160+
fingerprint on such a row as "the request had none". On every auth event they are
161+
resolved by better-auth's own rule: a session event copies the session row's values, and
162+
an account event applies the same resolution to the sign-in request.
148163

149164
**`old_value` / `new_value` are null on every `read` row**, deliberately and not as an
150165
omission — see [Record-view auditing](#record-view-auditing--the-read-action).
@@ -336,7 +351,7 @@ the standard object API or `services.data`, and through the shipped list views:
336351
|---|---|
337352
| `recent` | Everything, newest first |
338353
| `writes_only` | `create` / `update` / `delete` |
339-
| `auth_events` | `login` / `logout` |
354+
| `auth_events` | `login` / `logout` / `login_failed` / `account_locked` |
340355
| `record_views` | `read` — who opened which record |
341356
| `config_changes` | `config_change` / `import` |
342357
| `all_events` | Everything, larger page size |
@@ -355,9 +370,10 @@ interface AuthEventAuditSink {
355370
}
356371
```
357372

358-
`AuthSessionAuditEvent.action` is the closed union `'login' | 'logout'`. `@objectstack/plugin-auth`
359-
resolves this slot lazily and calls it from the session lifecycle. It is not a query API:
360-
to read audit history, query the `sys_audit_log` object.
373+
`AuthSessionAuditEvent.action` is the closed union `'login' | 'logout' | 'login_failed' |
374+
'account_locked'` (`AuthEventAuditAction`). `@objectstack/plugin-auth` resolves this slot
375+
lazily and calls it from the session lifecycle and from the password sign-in outcome. It is
376+
not a query API: to read audit history, query the `sys_audit_log` object.
361377

362378
## Retention and archival
363379

@@ -430,6 +446,7 @@ export type {
430446
// Auth-event ingress (the `audit` service slot)
431447
export { createAuthEventAuditSink };
432448
export type {
449+
AuthAccountAuditAction, AuthEventAuditAction,
433450
AuthEventAuditLogger, AuthEventAuditSink, AuthEventAuditSinkOptions,
434451
AuthSessionAuditAction, AuthSessionAuditEvent,
435452
};

‎packages/plugins/plugin-audit/src/audit-log-field-redaction.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@
4848
* - A record-write row's snapshot that is not a JSON object cannot be judged
4949
* key by key, so it is dropped whole (fail closed).
5050
* - A row of any other action is served as written. Measured per writer on
51-
* the tree this landed on: `read`, `login`, `logout` and `import` rows
52-
* write no snapshot; a `config_change` row's snapshot is a DIGEST of the
51+
* the tree this landed on: `read`, `login`, `logout`, `login_failed`,
52+
* `account_locked` and `import` rows write no snapshot; a `config_change` row's snapshot is a DIGEST of the
5353
* setting, never its value (`service-settings`' `config-change-audit.ts`);
5454
* a `platform_admin_standing_change` row's is the deployment's
5555
* administrator roster (`plugin-security`). None is a parent record's field

‎packages/plugins/plugin-audit/src/audit-log-read-visibility.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
* - **Rows about a record** — they name `object_name` + `record_id`: the CRUD
3434
* mirror's `create` / `update` / `delete` (`audit-writers.ts`), record-view
3535
* `read` rows (`read-audit.ts`), plugin-auth's administrative `create` /
36-
* `update` on a `sys_user`, and the auth-event sink's `login` / `logout`,
37-
* which name the session (`auth-event-audit.ts`). Judged by the record gate;
36+
* `update` on a `sys_user`, the auth-event sink's `login` / `logout`,
37+
* which name the session, and its `login_failed` / `account_locked`, which
38+
* name the account's `sys_user` row (`auth-event-audit.ts`). Judged by the
39+
* record gate;
3840
* the session is in the audit writer's `AUDIT_EXCLUDED_OBJECTS`, so the gate
3941
* names it ({@link LEDGER_NAMED_PARENTS}) to keep judging it.
4042
* A record that no longer exists is read by no caller, so its rows are

‎packages/plugins/plugin-audit/src/auth-event-audit.test.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,87 @@ describe('[#8144] createAuthEventAuditSink writes a login row that names its act
365365
* The helpers are `audit-writers.ts`'s, imported rather than re-spelled: a
366366
* second copy of this key is how the defect got here in the first place.
367367
*/
368+
describe('a refused sign-in and a lockout each land as a ledger row about the account', () => {
369+
it('login_failed names the account as user and target, credits no principal, and keeps outcome and client', async () => {
370+
const { engine } = await boot();
371+
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });
372+
373+
await sink.recordAuthEvent({
374+
action: 'login_failed',
375+
userId: 'usr_1',
376+
ipAddress: '203.0.113.7',
377+
userAgent: 'Mozilla/5.0 (probe)',
378+
context: { endpoint: '/sign-in/email', reason: 'INVALID_EMAIL_OR_PASSWORD' },
379+
});
380+
381+
const rows = await ledgerRows(engine, { action: 'login_failed' });
382+
expect(rows).toHaveLength(1);
383+
const [row] = rows;
384+
// The account attempted: the subject lookup, and the record the row is about.
385+
expect(row.user_id).toBe('usr_1');
386+
expect(row.object_name).toBe('sys_user');
387+
expect(row.record_id).toBe('usr_1');
388+
// A refused attempt authenticated nobody, so no principal is credited with
389+
// it — and in particular not the account holder.
390+
expect(row.actor ?? null).toBeNull();
391+
// The outcome and the client.
392+
expect(JSON.parse(String(row.metadata))).toEqual({
393+
endpoint: '/sign-in/email',
394+
reason: 'INVALID_EMAIL_OR_PASSWORD',
395+
});
396+
expect(row.ip_address).toBe('203.0.113.7');
397+
expect(row.user_agent).toBe('Mozilla/5.0 (probe)');
398+
// No session, no organization context, no field diff.
399+
expect(row.tenant_id ?? null).toBeNull();
400+
expect(row.old_value ?? null).toBeNull();
401+
expect(row.new_value ?? null).toBeNull();
402+
});
403+
404+
it('account_locked is its own row about the account, carrying the lock', async () => {
405+
const { engine } = await boot();
406+
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });
407+
408+
await sink.recordAuthEvent({
409+
action: 'login_failed',
410+
userId: 'usr_1',
411+
context: { endpoint: '/sign-in/email', reason: 'INVALID_EMAIL_OR_PASSWORD' },
412+
});
413+
await sink.recordAuthEvent({
414+
action: 'account_locked',
415+
userId: 'usr_1',
416+
ipAddress: '203.0.113.7',
417+
context: { endpoint: '/sign-in/email', locked_until: '2026-10-20T12:15:00.000Z', threshold: 3 },
418+
});
419+
420+
expect(await ledgerRows(engine, { action: 'login_failed' })).toHaveLength(1);
421+
const locks = await ledgerRows(engine, { action: 'account_locked' });
422+
expect(locks).toHaveLength(1);
423+
expect(locks[0].user_id).toBe('usr_1');
424+
expect(locks[0].object_name).toBe('sys_user');
425+
expect(locks[0].record_id).toBe('usr_1');
426+
expect(locks[0].actor ?? null).toBeNull();
427+
expect(locks[0].ip_address).toBe('203.0.113.7');
428+
expect(JSON.parse(String(locks[0].metadata))).toEqual({
429+
endpoint: '/sign-in/email',
430+
locked_until: '2026-10-20T12:15:00.000Z',
431+
threshold: 3,
432+
});
433+
});
434+
435+
it('a session event still names its session and credits its subject', async () => {
436+
// The account target must not leak onto the session class.
437+
const { engine } = await boot();
438+
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });
439+
440+
await sink.recordAuthEvent({ action: 'login', userId: 'usr_1', sessionId: 'ses_1' });
441+
442+
const [row] = await ledgerRows(engine, { action: 'login' });
443+
expect(row.object_name).toBe('sys_session');
444+
expect(row.record_id).toBe('ses_1');
445+
expect(row.actor).toBe('usr_1');
446+
});
447+
});
448+
368449
describe('auth-event audit — reported once per CAUSE, not once per process (#17452)', () => {
369450
interface LogLine {
370451
level: string;

0 commit comments

Comments
 (0)