Skip to content

Commit ea62933

Browse files
feat(plugin-auth, plugin-audit): a sign-in the IP allow-list refuses writes one login_failed row, bounded per address (#31249)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XFicVTXXKLtFurhMN9bwcxw)_ domain:server#6 · os-project-manager · session_01FEkQ5pfyn4wSGLdTuVZctK Fixes #30924 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:因为 IP 不在允许范围而被拒的登录,现在每次在审计日志里写一条 `login_failed`,带上 `reason: IP_NOT_ALLOWED`、`rule: ip_allow_list`、客户端地址、能认出的账号,以及组织(组织自己的名单拒绝时)。部署级名单和组织级名单两道门都记。 - **为什么改**:企业版「谁、从哪里、因为哪条规则被拦」的报表(cloud#3668)要读这份数据;之前部署级名单拒绝时什么都不写。这是本卡两个 PR 的第二个,第一个(MFA 挂起)已合并。 - **风险与回滚**:扫描器狂刷时有上限:同一地址 10 分钟内只在第 1、10、100 次各写一行,全局每 10 分钟最多 1000 行,超出只打一行告警。部署级名单的写入不阻塞拒绝响应,响应内容和耗时与账号是否存在无关。回滚就是 revert。 - **先看**:① `ip-allow-list-refusal-audit.ts`(限流规则和行的形状);② `auth-plugin.ts` 的 `auditAllowListRefusal`(部署级名单);③ `auth-manager-database-hooks.ts` 的 `refuseUnadmittedScoping`(组织级名单,切换组织不记)。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-auth exec vitest run src/ip-allow-list-refusal-audit.test.ts src/ip-allow-list-refusal-audit-bound.test.ts`,预期 17 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes **A sign-in refused by the ADR-0069 D5 IP allow-list now writes one `login_failed` row**, at both doors that refuse a sign-in by address. The row follows the incumbent shape of a refused sign-in (`signInFailureEventFor`, `auth-session-audit.ts`): `action: 'login_failed'` with `metadata.endpoint` and `metadata.reason`. It adds `metadata.rule: 'ip_allow_list'`, the way an MFA-held `login` row names `mfa_required` (PR #30950), and `metadata.attempts` (the bound, below). The client address and user agent come from `signInClientFingerprint`, the rule every `login` row's address is read by. The lane kept the incumbent `login_failed` action rather than a dedicated one: a reader counting refused sign-ins already reads it, and `rule` separates the policy refusals from the password ones. - **The deployment's list** (`auth-plugin.ts`, the auth-route middleware, new private `auditAllowListRefusal`). It refuses before better-auth resolves any account. The row names the account when the request's JSON `email` resolves to one, through the new public forwarder `AuthManager.resolveRefusedSignInAccountId(email)` (open question 1, option A: the password sign-in's own `findSignInAccount` question). Otherwise the row is written with no subject (ruling 1A). Only `/sign-in/*` endpoints write a row (ruling 4); a refused `get-session` or `sign-out` does not. - **The answer does not change.** The bound is judged synchronously. The body read, the account lookup and the write run detached from the response (`recordDeploymentListRefusal`), so the `403 IP_NOT_ALLOWED` status, body and timing do not depend on whether the identifier names an account. That keeps the refusal from becoming an account-existence oracle for a client outside the ranges. The body read is capped at 16 KiB; a larger or unparsable body writes the row with no subject. - **An organization's own list** (`auth-manager-database-hooks.ts`, `refuseUnadmittedScoping`, the `SESSION_SCOPING_REFUSAL` site). The credential check has passed there, so the row names the account and the organization (`tenant_id`), and `impersonated_by` on an impersonation session. Only the session's creation writes the row. A refused switch of the active organization (`session.update.before`) is not a sign-in and writes none (open question 2, option A). - **No second row.** On `main` the password sign-in's outcome already wrote a `login_failed` for this refusal (reason `IP_NOT_ALLOWED`, no rule, no organization, unbounded), because better-auth hands the after-hook the refusal the session hook threw. `signInFailureEventFor` now answers `null` for `IP_NOT_ALLOWED` (`REFUSALS_RECORDED_AT_THEIR_DOOR`), so the door's own row is the only one. That code reaches the after-hook only from this door; the deployment gate refuses before better-auth runs. - **The bound** (`IpRefusalAuditBound`, ruling 5). Per client address, a 10-minute window from its first refusal: rows at the 1st, 10th and 100th refusal, each carrying `attempts`. Across all addresses, at most 1000 rows per 10-minute window, and one `warn` line per window once the cap drops a row. An undetermined address counts as one address. The tracked addresses are capped at 10,000 (expired first, then the stalest), so rotating addresses cannot grow memory. Both doors share one bound per audit sink. - **Across replicas:** the bound lives in process memory, so N server processes write up to N times as many rows. No shared store is used, and none was ruled. - **plugin-audit** (`auth-event-audit.ts`): `AuthSessionAuditEvent.userId` is optional. A `login_failed` with no subject is written with `user_id`, `record_id` and `actor` null. It is a row about no record, read under the ledger's own grant (`audit-log-read-visibility.ts`); with no organization it is served to platform administrators only. Every other event with no subject still writes nothing. - **plugin-auth's `AuthEventAuditSurface`**: the event's `userId` is optional to match. `Clause-②: yes (widening)`. - ⛔ **No row carries the credential.** The identifier is read only to find the account and is never recorded; the password is never read. - **Docs:** `content/docs/kernel/runtime-services/audit-service.mdx` now shows the optional `userId`, `reason`/`rule` on an MFA-held `login` row and on an IP refusal, and the bound. - **Changeset:** `@objectstack/plugin-auth` and `@objectstack/plugin-audit`, `patch`. **For the cloud#3668 consumer:** read IP refusals as `action = 'login_failed'` with `metadata.rule = 'ip_allow_list'`. Use `user_id` (may be null), `tenant_id` (set when an organization's list refused), `ip_address`, `user_agent`, `created_at` and `metadata.attempts`. A row with `attempts = 100` means at least a hundred refusals from that address in its window. ## Why #30924: plugin-auth wrote no audit row when policy refused a sign-in, so the Enterprise enforcement report (objectstack-ai/cloud#3668) had nothing to read. PR 1 (#30950) recorded the MFA hold on the held sign-in's `login` row. This PR 2 carries the IP allow-list rows, after #30922 (PR #31013) added the organization's own list. ## Tests - `ip-allow-list-refusal-audit.test.ts` (new). The deployment half mounts the real `AuthPlugin.registerAuthRoutes` on a real Hono app in front of a real `AuthManager`. The organization half goes through `AuthManager.handleRequest` with the transport peer. - deployment, existing account: `403 IP_NOT_ALLOWED`; one event with the account id, the address, the user agent, `{endpoint, reason, rule, attempts: 1}`; neither the password nor the email appears. - deployment, unknown identifier: one event, no `userId`. - `get-session` / `sign-out` refused: no event; `/sign-in/magic-link` refused: an event. - control: an admitted sign-in writes no refusal event. - flood: 120 refusals from one address give rows at attempts 1, 10, 100; a second address starts at 1. - the answer does not change: same status and body with and without the sink, and the response arrives before a 150 ms account lookup settles. - no audit plugin: the forwarder is never called. - organization, refused at creation: one event with the account, `organizationId`, the address and the rule (one, not two). - control: an admitted sign-in writes only its `login`. - a refused switch of the active organization: `403 IP_NOT_ALLOWED`, no event. - 12 organization refusals from one address: attempts 1 and 10. - `ip-allow-list-refusal-audit-bound.test.ts` (new): the ruled constants; the per-address window restart; the undetermined address; the global cap with one `warn` per window; the tracked-address cap; the event shape. - `plugin-audit/src/auth-event-audit.test.ts`: a subject-less `login_failed` lands about no record, credits nobody, and keeps the address and the rule. Control: `login`, `logout` and `account_locked` with no subject write nothing. - Dogfood `auth-session-audit-trail.dogfood.test.ts`, new describe on a booted showcase with `AuditPlugin`. `auth.allowed_ip_ranges` is set through the settings service. Twelve `/auth/sign-in/email` requests answer `403 IP_NOT_ALLOWED` and leave exactly two `sys_audit_log` rows for the account (attempts 1 and 10), each about `sys_user`, with the rule, the reason and the user agent, and no credential. ## Acceptance notes - **Found while measuring, not fixed here (reported to the seat):** on `main`, a sign-in refused by an organization's own IP list counts toward the ADR-0069 D2 lockout, although the password was right. Measured with `lockoutThreshold: 3`: three `/sign-in/email` attempts with the correct password from outside Alpha's list answer `403 IP_NOT_ALLOWED`, and the fourth, from inside the list, answers `403 ACCOUNT_LOCKED` (`failed_login_count` 3). The cause is the password after-hook (`auth-manager-after-hook.ts`) reading every returned error as a failed credential. That file is outside this card's surface, so it goes to the seat to file. - **Across replicas:** the bound is per process (module note in `ip-allow-list-refusal-audit.ts`). N replicas write up to N times as many rows; no shared store was ruled. - **Detached write:** the deployment gate's row is written after the `403` is returned. A runtime that ends the process right after the response could lose that row. The Node server this ships on does not. - **Which sign-ins name an account at the deployment gate:** only a JSON body with an `email` (`/sign-in/email`, `/sign-in/magic-link`, `/sign-in/email-otp`). Social, SSO, passkey and username sign-ins are written with no subject. All refusals whose address could not be established count as one address in the bound, and their rows carry no `ip_address`. - **A published function changes behaviour:** `signInFailureEventFor` (exported from `auth-session-audit.ts`) now answers `null` for `reason: 'IP_NOT_ALLOWED'`. The door that refused writes that row. - **File surface:** the new module `ip-allow-list-refusal-audit.ts` and its two test files sit in the claimed `packages/plugins/plugin-auth/src/`, package-internal (not re-exported). `organization-access-policy.ts` is not touched; the refusal site edited is in `auth-manager-database-hooks.ts`. - **Merged `origin/main` once** (`2aeb9e05`): #31194 (SSO refusal codes) touched `auth-manager.ts` in a different region and the merge was clean. The plugin-auth suite was run again on the merged head. ## Verification Readings on head `2aeb9e05` (the implementation `148bac5f` plus one merge of `origin/main` at `065bba72`) unless named, in this checkout. <details><summary>Red first, on origin/main 658d84f (production sources restored, tests kept)</summary> - `ip-allow-list-refusal-audit.test.ts`: 8 failed | 3 passed. The deployment cases fail with `expected [] to have a length of 1` (the sink receives nothing), and the flood case with `expected [] to have a length of 3`. The forwarder cases fail with `resolveRefusedSignInAccountId is not defined`. The organization case receives the password outcome's row, `{endpoint, reason: IP_NOT_ALLOWED}`, with no `rule`, no `organizationId` and no `attempts`. Twelve organization refusals give twelve rows. The three controls pass: an admitted sign-in, the refused switch, and the bound file not loaded. - `plugin-audit` `auth-event-audit.test.ts`: 2 failed | 17 passed (the subject-less row is dropped). </details> <details><summary>Ablations (scripts/ablation-replace.mjs, each restored: blob == HEAD, git diff HEAD empty)</summary> | # | Mutation | Red | |---|---|---| | A1 | the gate's `auditAllowListRefusal` call deleted | 5 deployment cases | | A2 | the per-address marks ignored | 4 (flood, organization bound, 2 bound unit cases) | | A3 | the switch hands `creating` in | the switch case (negative pin) | | A4 | the `IP_NOT_ALLOWED` suppression in `signInFailureEventFor` deleted | 2 (two rows instead of one) | | A5 | `isSignInEndpoint` admits every path | the `get-session` / `sign-out` case (negative pin) | | A6 | the write awaited before the 403 | the answer-does-not-wait case (negative pin) | | B1 | plugin-audit refuses a subject-less `login_failed` | 2 | | B2 | plugin-audit admits any subject-less event | 2 (negative pin, incl. the #8144 case) | - A6's first attempt was a no-op: an unbalanced replacement failed to parse, the integration file did not load, and the reading was discarded. It was re-run as three held mutations (`async` method, `await` call, `await` at the gate) and read red. - Dogfood, on `dist/`: A1 rebuilt. The DTS step failed TS6133 (the method was left unused) after tsup wrote the mutated JS. `ablation-dist-preflight.mjs --absent` showed the marker gone from all 14 built files, and the new dogfood case failed (1 failed | 6 skipped). Restored and rebuilt: marker present in 2 built files, dogfood 7 passed (7). </details> <details><summary>Suites, types, lint, gates</summary> - `@objectstack/plugin-auth` full suite on `2aeb9e05`: 236 of 237 files pass, 4199 passed | 1 failed | 10 skipped. The one red is `organization-access-policy.test.ts` "the absolute limit is judged too…", the known exact-60-minute boundary flake that #31175 carries. Re-run alone: 19 passed (19), three times out of three. On `148bac5f` before the merge: 236 files, 4185 passed | 10 skipped. - `@objectstack/plugin-audit`: full suite 1203 passed (1203) on `148bac5f`; `auth-event-audit.test.ts` 19 passed (19) on `2aeb9e05`. - Dogfood `auth-session-audit-trail.dogfood.test.ts`: 7 passed (7) on `2aeb9e05`. - Typecheck (source + tests): `@objectstack/plugin-auth` exit 0, `@objectstack/plugin-audit` exit 0 on `2aeb9e05`; `@objectstack/dogfood` exit 0 on `148bac5f`. - `eslint --no-inline-config` on the 10 changed `.ts` files: exit 0. - Build: `turbo run build` under `os-verify-lock`, 120 successful, 120 total (VERDICT command-exit 0). - `scripts/pm/dispatch-gates.mjs --ran` on `2aeb9e05`: 99 derived, 99 run, 0 NOT-MEASURED, all 99 exit 0. Three families first answered exit 3 (PREREQUISITE) and were re-run after their prerequisites: `check-plugin-teardown-shape --self-test` after fetching its fixture commit; `check:skill-examples` and `check:dual-build-cjs-loads` after the full build. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `eslint --no-inline-config` passes on the changed files. (CI owns the repo-wide lint run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request (PR 2 of the claimed two-PR series). - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pfyn4wSGLdTuVZctK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent abc7a94 commit ea62933

12 files changed

Lines changed: 1082 additions & 25 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/plugin-audit': patch
4+
---
5+
6+
feat(plugin-auth, plugin-audit): a sign-in the IP allow-list refuses leaves one `login_failed` row in the audit log, bounded per client address
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A sign-in refused by the ADR-0069 D5 IP allow-list used to leave nothing in `sys_audit_log`: the deployment's list answered `403 IP_NOT_ALLOWED` with only a once-per-process operator line, and an organization's own list refused the session silently. Both now write a `login_failed` row with `metadata.reason: "IP_NOT_ALLOWED"`, `metadata.rule: "ip_allow_list"`, `metadata.endpoint` and `metadata.attempts`, and the client address and user agent in `ip_address` / `user_agent`:
12+
13+
- **The deployment's list** refuses before any account is resolved. Its row names the account when the request's `email` resolves to one (the password sign-in's own account lookup), and is written with no account otherwise. Only `/sign-in/*` endpoints are recorded; a refused session read or sign-out is not a sign-in. The row is written off the response path, so the refusal's status, body and timing do not depend on whether the address names an account.
14+
- **An organization's own list** refuses when the session is created. Its row names the account and the organization (`tenant_id`). A refused switch of the active organization is not a sign-in and writes no row.
15+
16+
The rows are bounded so a scanner cannot flood the table: per client address, in a 10-minute window from its first refusal, only the 1st, 10th and 100th refusal write a row (`attempts` 1, 10, 100); across all addresses, at most 1000 rows per 10-minute window, with one operator `warn` line in a window once the cap drops a row. The bound is held in each server process, so N replicas write up to N times as many rows. No row carries the submitted identifier or the password.
17+
18+
`@objectstack/plugin-audit`'s `AuthSessionAuditEvent.userId` and `@objectstack/plugin-auth`'s `AuthEventAuditSurface` event `userId` are now optional: a `login_failed` with no `userId` is written with `user_id` and `record_id` null (a row about no record, read under the ledger's own grant, platform administrators only when it names no organization). Any other event without a `userId` still writes nothing. `AuthManager.resolveRefusedSignInAccountId(email)` is new: the account a refused sign-in names, used only for that row. A sink registered on the `audit` service slot now receives `login_failed` events with no `userId`, so a custom sink must accept one. The exported `signInFailureEventFor` now answers `null` for `reason: 'IP_NOT_ALLOWED'`, because the door that refused the sign-in writes that row itself.

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

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The slot's entire surface is one method:
2929
```ts
3030
services.audit.recordAuthEvent(event: {
3131
action: 'login' | 'logout' | 'login_failed' | 'account_locked';
32-
userId: string;
32+
userId?: string;
3333
sessionId?: string;
3434
organizationId?: string;
3535
ipAddress?: string;
@@ -46,11 +46,17 @@ services.audit.recordAuthEvent(event: {
4646
hands over an **event** (what happened) rather than a row (what to store).
4747
- `login` / `logout` are **session events**: a sign-in that minted a session, and a
4848
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
49+
- `login_failed` / `account_locked` are **account events**: a refused sign-in — a
50+
password sign-in that named an existing account, or any `/sign-in/*` request the IP
51+
allow-list refused (ADR-0069 D5) — and the refused sign-in that stamped the account
5152
lockout (`lockoutThreshold`, ADR-0069 D2). They exist without any session.
52-
- **`userId`** is required and must be a real `sys_user` id — it lands on the strict
53-
`user_id` lookup. On an account event it is the account the sign-in named.
53+
- **`userId`** must be a real `sys_user` id when given — it lands on the strict `user_id`
54+
lookup. On an account event it is the account the sign-in named. Every event names one
55+
except a `login_failed` refused before any account could be resolved: the deployment's
56+
IP allow-list refuses ahead of better-auth, and the identifier the request carried may
57+
name no account. That row is written with `user_id` and `record_id` null — a row about
58+
no record, read under the ledger's own grant — and still carries the client address and
59+
the rule. Any other event without a `userId` writes nothing.
5460
- **`sessionId`** lands on `record_id`, with `object_name` set to `sys_session`, so the
5561
ledger row is navigable to the session it describes. An account event names its account
5662
instead — `object_name` `sys_user` and `record_id` the account's id — and does not read
@@ -67,9 +73,23 @@ services.audit.recordAuthEvent(event: {
6773
because a refused attempt authenticated nobody.
6874
- **`context`** is free-form and is serialized into `metadata`, e.g. the endpoint that
6975
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.
76+
endpoint, the refusal's machine code (`reason`, e.g. `INVALID_EMAIL_OR_PASSWORD`,
77+
`ACCOUNT_LOCKED` or `IP_NOT_ALLOWED`) and, for `account_locked`, the lock's `locked_until`
78+
and `threshold`. It never carries the attempted credential.
79+
- A sign-in an authentication **policy** holds or refuses also names the policy, in
80+
`rule`. A sign-in held by enforced MFA (ADR-0069 D3) is not refused — the session is
81+
minted — so its own `login` row carries `reason: 'MFA_REQUIRED'` and
82+
`rule: 'mfa_required'`. A sign-in the IP allow-list refuses (ADR-0069 D5), by the
83+
deployment's list or by the organization's own list when the session is created, is a
84+
`login_failed` with `reason: 'IP_NOT_ALLOWED'`, `rule: 'ip_allow_list'` and
85+
`attempts`, below; the organization's refusal also names the organization. A refused
86+
switch of the active organization is not a sign-in and writes no row.
87+
- IP allow-list rows are **bounded** so a scanner cannot flood the ledger: per client
88+
address, in a 10-minute window from its first refusal, only the 1st, 10th and 100th
89+
refusal write a row, carrying that count as `attempts`; across all addresses, at most
90+
1000 rows per 10-minute window, with one operator `warn` line in a window once the cap
91+
drops a row. The bound is held in each server process, so N replicas write up to N
92+
times as many rows.
7393

7494
## Notes
7595

@@ -80,8 +100,9 @@ services.audit.recordAuthEvent(event: {
80100
reference from the moment the plugin initializes.
81101
- `@objectstack/plugin-auth` is the only in-repo consumer. It resolves the slot lazily
82102
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.
103+
and from the sign-in outcomes, so neither package depends on the other. A password
104+
sign-in whose identifier resolves to no account produces no call; an IP allow-list
105+
refusal produces one with no `userId`.
85106

86107
## Typical Errors
87108

@@ -98,8 +119,9 @@ caller's side:
98119
`error` line, once. The sign-in was answered exactly as it would have been without the
99120
ledger, so the shipped `auth_events` list view and the system-overview widgets keep
100121
showing an empty, healthy-looking screen.
101-
- The call **silently no-ops** when no data engine resolves, or when `userId` is absent.
102-
A caller that passes an event without a subject records nothing and is told nothing.
122+
- The call **silently no-ops** when no data engine resolves, or when `userId` is absent on
123+
any event but `login_failed`. A caller that passes such an event records nothing and is
124+
told nothing.
103125
- The most common cause of a failing insert is a datasource split rather than a broken
104126
write: `sys_audit_log` carries an ADR-0057 `audit` lifecycle class, which routes it to a
105127
dedicated `telemetry` datasource whenever one is registered — `os dev` provisions one by

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,3 +620,54 @@ describe('auth-event audit — reported once per CAUSE, not once per process (#1
620620
expect(warns[1].message).toMatch(/ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/);
621621
});
622622
});
623+
624+
/**
625+
* [#30924] A sign-in the IP allow-list refuses is refused before any account
626+
* is resolved, and the identifier it carried may name no account at all. Its
627+
* `login_failed` row is still worth writing — the address and the rule are
628+
* the record an enforcement report reads — so a `login_failed` is the one
629+
* event that may name no subject. Every other event still needs one.
630+
*/
631+
describe('[#30924] a login_failed with no resolvable account is still a row', () => {
632+
it('a subject-less login_failed lands about no record, credits nobody, and keeps the address and the rule', async () => {
633+
const { engine } = await boot();
634+
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });
635+
636+
await sink.recordAuthEvent({
637+
action: 'login_failed',
638+
ipAddress: '198.51.100.9',
639+
userAgent: 'scanner/1.0',
640+
context: { endpoint: '/sign-in/email', reason: 'IP_NOT_ALLOWED', rule: 'ip_allow_list', attempts: 1 },
641+
});
642+
643+
const rows = await ledgerRows(engine, { action: 'login_failed' });
644+
expect(rows).toHaveLength(1);
645+
const [row] = rows;
646+
expect(row.user_id ?? null).toBeNull();
647+
expect(row.object_name).toBe('sys_user');
648+
expect(row.record_id ?? null).toBeNull();
649+
expect(row.actor ?? null).toBeNull();
650+
expect(row.tenant_id ?? null).toBeNull();
651+
expect(row.ip_address).toBe('198.51.100.9');
652+
expect(row.user_agent).toBe('scanner/1.0');
653+
expect(JSON.parse(String(row.metadata))).toEqual({
654+
endpoint: '/sign-in/email',
655+
reason: 'IP_NOT_ALLOWED',
656+
rule: 'ip_allow_list',
657+
attempts: 1,
658+
});
659+
});
660+
661+
it('every other event still needs its subject: no row', async () => {
662+
const { engine } = await boot();
663+
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });
664+
665+
await sink.recordAuthEvent({ action: 'login', sessionId: 'ses_1' });
666+
await sink.recordAuthEvent({ action: 'logout', sessionId: 'ses_1' });
667+
await sink.recordAuthEvent({ action: 'account_locked', context: { threshold: 3 } });
668+
await sink.recordAuthEvent({ action: 'login_failed', userId: '' });
669+
670+
expect(await ledgerRows(engine)).toHaveLength(1);
671+
expect((await ledgerRows(engine, { action: 'login_failed' }))[0].user_id ?? null).toBeNull();
672+
});
673+
});

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@
7575
* credited with it, and recording the account holder there would claim
7676
* they made the attempt.
7777
*
78+
* A `login_failed` whose sign-in was refused before any account resolved
79+
* (the IP allow-list, #30924) names no account: `user_id` and `record_id` are
80+
* null, so it is a row about no record, read under the ledger's own grant, and
81+
* still carries the client address and the refusing rule.
82+
*
7883
* ⛔ An account event never carries the attempted credential, in any form.
7984
* Its `context` is the endpoint, the refusal's machine code and, for a
8085
* lockout, the lock's expiry and threshold — nothing the caller typed.
@@ -130,8 +135,14 @@ export interface AuthSessionAuditEvent {
130135
* The event's subject — a real `sys_user` id (the `user_id` lookup): the
131136
* session's owner for a session event, the account the sign-in named for an
132137
* account event.
138+
*
139+
* [#30924] Optional for one event only: a `login_failed` whose sign-in was
140+
* refused before any account could be resolved (the IP allow-list refuses
141+
* ahead of better-auth, and the identifier may name no account) is still
142+
* written, about no record and attributed to nobody. Every other event
143+
* without a subject writes no row.
133144
*/
134-
userId: string;
145+
userId?: string;
135146
/**
136147
* The `sys_session` row a SESSION event is about, when known. An account
137148
* event names its account instead (module note), so this is not read for
@@ -206,6 +217,15 @@ export interface AuthEventAuditSinkOptions {
206217
logger?: AuthEventAuditLogger;
207218
}
208219

220+
/**
221+
* [#30924] Whether an event of `action` is written with no subject: only a
222+
* refused sign-in (`login_failed`), whose refusal may come before any account
223+
* is resolved. A session needs its owner and a lockout its account.
224+
*/
225+
function mayNameNoSubject(action: unknown): boolean {
226+
return action === 'login_failed';
227+
}
228+
209229
/** The object a session event's row names as its target. */
210230
const SESSION_OBJECT = 'sys_session';
211231

@@ -354,7 +374,9 @@ export function createAuthEventAuditSink(opts: AuthEventAuditSinkOptions): AuthE
354374
return {
355375
async recordAuthEvent(event: AuthSessionAuditEvent): Promise<void> {
356376
const engine = getEngine();
357-
if (!engine || !event?.userId) return;
377+
if (!engine || !event) return;
378+
const subject = typeof event.userId === 'string' && event.userId.length > 0 ? event.userId : undefined;
379+
if (!subject && !mayNameNoSubject(event.action)) return;
358380
if (probe === undefined || probedEngine !== engine) {
359381
probe = createFieldPresenceProbe(engine);
360382
probedEngine = engine;
@@ -367,13 +389,14 @@ export function createAuthEventAuditSink(opts: AuthEventAuditSinkOptions): AuthE
367389
action: event.action,
368390
// The whole point of the card: the event names its actor. `user_id` is
369391
// a strict `sys_user` lookup, and a session always has a real subject.
370-
// On an account event it is the account the sign-in named.
371-
user_id: event.userId,
392+
// On an account event it is the account the sign-in named, and null
393+
// for a refused sign-in that named none (#30924).
394+
user_id: subject ?? null,
372395
// The record this event is about, so the row is navigable from the
373396
// ledger to it (and back) rather than being a bare verb: the session
374397
// for a session event, the account for an account event.
375398
object_name: targetObjectFor(event.action),
376-
record_id: accountEvent ? event.userId : (event.sessionId ?? null),
399+
record_id: accountEvent ? (subject ?? null) : (event.sessionId ?? null),
377400
// No before/after state: an auth event is not a field diff. Recording
378401
// `{}` here would be a claim about a record that never changed.
379402
old_value: null,
@@ -393,7 +416,7 @@ export function createAuthEventAuditSink(opts: AuthEventAuditSinkOptions): AuthE
393416
// is, and recording the impersonated user as the sole principal there
394417
// would be a WRONG record rather than a vague one. An account event
395418
// authenticated nobody, so it credits no principal (module note).
396-
row.actor = event.actor ?? (accountEvent ? null : event.userId);
419+
row.actor = event.actor ?? (accountEvent ? null : subject);
397420
}
398421

399422
try {

‎packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ import {
5454
recordUnlinkTombstone,
5555
} from './implicit-account-linking.js';
5656
import { withSystemReadContext } from './objectql-adapter.js';
57-
import { emitAuthSessionAuditEvent, loginEventFor, logoutEventFor } from './auth-session-audit.js';
57+
import { emitAuthSessionAuditEvent, loginEventFor, logoutEventFor, signInClientFingerprint } from './auth-session-audit.js';
58+
import { ipAllowListRefusalEventFor, ipRefusalAuditBoundFor } from './ip-allow-list-refusal-audit.js';
5859
import { sessionPolicyHoldFor, withSessionPolicyHold } from './session-policy-hold.js';
5960
import { holdPrincipalSessionExpiry } from './principal-session-mint.js';
6061
import { canonicalClientAddress } from './client-address-canonical.js';
@@ -314,11 +315,32 @@ export function composeDatabaseHooks(
314315
// established address is in the request state, exactly the address the
315316
// deployment's list judged at that door. A programmatic `auth.api.*` call
316317
// carries no request and is not judged, as the door never judged it.
317-
const refuseUnadmittedScoping = async (userId: unknown, organizationId: unknown, ctx: any) => {
318+
//
319+
// [#30924] A refused CREATION is a refused sign-in, and leaves its
320+
// `login_failed` row (`ip-allow-list-refusal-audit.ts`), counted by the same
321+
// per-address bound as the deployment gate's rows. A refused SWITCH is not a
322+
// sign-in and writes none: only the creation hands `creating` in.
323+
const refuseUnadmittedScoping = async (userId: unknown, organizationId: unknown, ctx: any, creating?: any) => {
318324
if (!ctx?.request || typeof userId !== 'string' || !userId) return;
319325
if (typeof organizationId !== 'string' || !organizationId) return;
320-
const verdict = await this.judgeSessionScoping(userId, organizationId, await readRequestClientAddress());
326+
const address = await readRequestClientAddress();
327+
const verdict = await this.judgeSessionScoping(userId, organizationId, address);
321328
if (verdict === 'admitted') return;
329+
const sink = creating ? this.config.getAuditSink?.() : undefined;
330+
if (sink) {
331+
const fingerprint = signInClientFingerprint(ctx, address);
332+
const attempts = ipRefusalAuditBoundFor(sink).admit(fingerprint.ipAddress, (line) => this.config.logger?.warn?.(line));
333+
if (attempts !== undefined) {
334+
await emitAuthSessionAuditEvent(sink, ipAllowListRefusalEventFor({
335+
path: typeof ctx?.path === 'string' ? ctx.path : '',
336+
attempts,
337+
userId,
338+
organizationId,
339+
impersonatedBy: typeof creating?.impersonatedBy === 'string' ? creating.impersonatedBy : undefined,
340+
fingerprint,
341+
}));
342+
}
343+
}
322344
const { APIError } = await import('better-auth/api');
323345
throw new APIError('FORBIDDEN', { code: SESSION_SCOPING_REFUSAL.code, message: SESSION_SCOPING_REFUSAL.message });
324346
};
@@ -329,7 +351,7 @@ export function composeDatabaseHooks(
329351
const final = result && typeof result === 'object' && 'data' in result
330352
? { ...captured, ...(result as any).data }
331353
: captured;
332-
await refuseUnadmittedScoping(final?.userId, final?.activeOrganizationId, ctx);
354+
await refuseUnadmittedScoping(final?.userId, final?.activeOrganizationId, ctx, final);
333355
if (result && typeof result === 'object' && 'data' in result) return { data: final };
334356
return captured === session ? result : { data: captured };
335357
};

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6165,6 +6165,22 @@ export class AuthManager {
61656165
return resolveSignInAccountId.call(host, email);
61666166
}
61676167

6168+
/**
6169+
* [#30924] The id of the account a sign-in refused by the ADR-0069 D5 IP
6170+
* allow-list names, for its `login_failed` row only
6171+
* (`ip-allow-list-refusal-audit.ts`), or `undefined` — the password
6172+
* sign-in's own account question ({@link findSignInAccount}). The gate
6173+
* refuses before better-auth resolves any account, so this is the only way
6174+
* that row can name one. It decides nothing: the refusal is already
6175+
* answered. Throws what the engine throws; the caller writes the row with no
6176+
* subject then.
6177+
*/
6178+
public async resolveRefusedSignInAccountId(email: string): Promise<string | undefined> {
6179+
const engine = this.getDataEngine();
6180+
if (!engine) return undefined;
6181+
return (await this.findSignInAccount(engine, email, []))?.id;
6182+
}
6183+
61686184
/**
61696185
* ADR-0069 D2 — the ONE answer to "which account does this password sign-in
61706186
* name", read by the lock gate ({@link assertAccountNotLocked}), the counter

0 commit comments

Comments
 (0)