Repository navigation
feat(plugin-auth, plugin-audit): a sign-in the IP allow-list refuses writes one login_failed row, bounded per address - #31249
Conversation
…writes one login_failed row, bounded per address Both doors that refuse a sign-in by client address (ADR-0069 D5) now record it: the deployment's list in the auth-route middleware, written detached from the 403 so the answer never depends on the account lookup, and an organization's own list at session creation, naming the organization. The row is the incumbent login_failed shape with reason IP_NOT_ALLOWED, rule ip_allow_list and attempts. Rows are bounded per address (1st, 10th and 100th refusal in a 10-minute window) and globally (1000 per window, one warn line). A refused switch of the active organization writes no row, and the password outcome no longer writes a second row for the organization's refusal. plugin-audit admits a subject-less login_failed (user_id and record_id null); every other event still needs a subject. AuthManager gains one public forwarder, resolveRefusedSignInAccountId, used only for that row. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pfyn4wSGLdTuVZctK
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pfyn4wSGLdTuVZctK
📓 Docs Drift CheckThis PR changes 2 package(s): 37 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5a6fcc3f437d60e891c01188b01c74e89b4b3053 && git checkout 5a6fcc3f437d60e891c01188b01c74e89b4b3053
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d0c377beb988b990e77aa4763fd5e4b429d8de4 5c0974f9e7ad8b9bef9b1a7261e1d65ed4e814c9 && git checkout -B drift-repro 0d0c377beb988b990e77aa4763fd5e4b429d8de4 && git merge --no-ff 5c0974f9e7ad8b9bef9b1a7261e1d65ed4e814c9
node scripts/docs-audit/affected-docs.mjs --json 0d0c377beb988b990e77aa4763fd5e4b429d8de4
|
|
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ Contract reviewServed-tier: Face: declaration. The committed tree touches no Check-runs on ① Derived judgmentsPublished reach: both packages export only
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Should-fix S1 (relayed to the dev): the changeset should also say that the exported |
…sal rows Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pfyn4wSGLdTuVZctK
|
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ 席位核对 · ACCEPT · 2026-11-07T20:22Z · head
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42410962891 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ Queue triage · 2026-11-07T20:49Z The merge queue removed this PR at 2026-11-07T20:46Z on a red |
Requested by Leehom · project thread
domain:server#6 · os-project-manager · session_01FEkQ5pfyn4wSGLdTuVZctK
Fixes #30924
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
login_failed,带上reason: IP_NOT_ALLOWED、rule: ip_allow_list、客户端地址、能认出的账号,以及组织(组织自己的名单拒绝时)。部署级名单和组织级名单两道门都记。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_failedrow, 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'withmetadata.endpointandmetadata.reason. It addsmetadata.rule: 'ip_allow_list', the way an MFA-heldloginrow namesmfa_required(PR #30950), andmetadata.attempts(the bound, below). The client address and user agent come fromsignInClientFingerprint, the rule everyloginrow's address is read by. The lane kept the incumbentlogin_failedaction rather than a dedicated one: a reader counting refused sign-ins already reads it, andruleseparates the policy refusals from the password ones.auth-plugin.ts, the auth-route middleware, new privateauditAllowListRefusal). It refuses before better-auth resolves any account. The row names the account when the request's JSONemailresolves to one, through the new public forwarderAuthManager.resolveRefusedSignInAccountId(email)(open question 1, option A: the password sign-in's ownfindSignInAccountquestion). Otherwise the row is written with no subject (ruling 1A). Only/sign-in/*endpoints write a row (ruling 4); a refusedget-sessionorsign-outdoes not.recordDeploymentListRefusal), so the403 IP_NOT_ALLOWEDstatus, 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.auth-manager-database-hooks.ts,refuseUnadmittedScoping, theSESSION_SCOPING_REFUSALsite). The credential check has passed there, so the row names the account and the organization (tenant_id), andimpersonated_byon 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).mainthe password sign-in's outcome already wrote alogin_failedfor this refusal (reasonIP_NOT_ALLOWED, no rule, no organization, unbounded), because better-auth hands the after-hook the refusal the session hook threw.signInFailureEventFornow answersnullforIP_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.IpRefusalAuditBound, ruling 5). Per client address, a 10-minute window from its first refusal: rows at the 1st, 10th and 100th refusal, each carryingattempts. Across all addresses, at most 1000 rows per 10-minute window, and onewarnline 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.auth-event-audit.ts):AuthSessionAuditEvent.userIdis optional. Alogin_failedwith no subject is written withuser_id,record_idandactornull. 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.AuthEventAuditSurface: the event'suserIdis optional to match.Clause-②: yes (widening).content/docs/kernel/runtime-services/audit-service.mdxnow shows the optionaluserId,reason/ruleon an MFA-heldloginrow and on an IP refusal, and the bound.@objectstack/plugin-authand@objectstack/plugin-audit,patch.For the cloud#3668 consumer: read IP refusals as
action = 'login_failed'withmetadata.rule = 'ip_allow_list'. Useuser_id(may be null),tenant_id(set when an organization's list refused),ip_address,user_agent,created_atandmetadata.attempts. A row withattempts = 100means 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
loginrow. 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 realAuthPlugin.registerAuthRouteson a real Hono app in front of a realAuthManager. The organization half goes throughAuthManager.handleRequestwith the transport peer.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.userId.get-session/sign-outrefused: no event;/sign-in/magic-linkrefused: an event.organizationId, the address and the rule (one, not two).login.403 IP_NOT_ALLOWED, no event.ip-allow-list-refusal-audit-bound.test.ts(new): the ruled constants; the per-address window restart; the undetermined address; the global cap with onewarnper window; the tracked-address cap; the event shape.plugin-audit/src/auth-event-audit.test.ts: a subject-lesslogin_failedlands about no record, credits nobody, and keeps the address and the rule. Control:login,logoutandaccount_lockedwith no subject write nothing.auth-session-audit-trail.dogfood.test.ts, new describe on a booted showcase withAuditPlugin.auth.allowed_ip_rangesis set through the settings service. Twelve/auth/sign-in/emailrequests answer403 IP_NOT_ALLOWEDand leave exactly twosys_audit_logrows for the account (attempts 1 and 10), each aboutsys_user, with the rule, the reason and the user agent, and no credential.Acceptance notes
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 withlockoutThreshold: 3: three/sign-in/emailattempts with the correct password from outside Alpha's list answer403 IP_NOT_ALLOWED, and the fourth, from inside the list, answers403 ACCOUNT_LOCKED(failed_login_count3). 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.ip-allow-list-refusal-audit.ts). N replicas write up to N times as many rows; no shared store was ruled.403is returned. A runtime that ends the process right after the response could lose that row. The Node server this ships on does not.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 noip_address.signInFailureEventFor(exported fromauth-session-audit.ts) now answersnullforreason: 'IP_NOT_ALLOWED'. The door that refused writes that row.ip-allow-list-refusal-audit.tsand its two test files sit in the claimedpackages/plugins/plugin-auth/src/, package-internal (not re-exported).organization-access-policy.tsis not touched; the refusal site edited is inauth-manager-database-hooks.ts.origin/mainonce (2aeb9e05): fix(plugin-auth): a refused enterprise OIDC sign-in names its cause #31194 (SSO refusal codes) touchedauth-manager.tsin 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 implementation148bac5fplus one merge oforigin/mainat065bba72) unless named, in this checkout.Red first, on origin/main 658d84f (production sources restored, tests kept)
ip-allow-list-refusal-audit.test.ts: 8 failed | 3 passed. The deployment cases fail withexpected [] to have a length of 1(the sink receives nothing), and the flood case withexpected [] to have a length of 3. The forwarder cases fail withresolveRefusedSignInAccountId is not defined. The organization case receives the password outcome's row,{endpoint, reason: IP_NOT_ALLOWED}, with norule, noorganizationIdand noattempts. Twelve organization refusals give twelve rows. The three controls pass: an admitted sign-in, the refused switch, and the bound file not loaded.plugin-auditauth-event-audit.test.ts: 2 failed | 17 passed (the subject-less row is dropped).Ablations (scripts/ablation-replace.mjs, each restored: blob == HEAD, git diff HEAD empty)
auditAllowListRefusalcall deletedcreatinginIP_NOT_ALLOWEDsuppression insignInFailureEventFordeletedisSignInEndpointadmits every pathget-session/sign-outcase (negative pin)login_failedasyncmethod,awaitcall,awaitat the gate) and read red.dist/: A1 rebuilt. The DTS step failed TS6133 (the method was left unused) after tsup wrote the mutated JS.ablation-dist-preflight.mjs --absentshowed 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).Suites, types, lint, gates
@objectstack/plugin-authfull suite on2aeb9e05: 236 of 237 files pass, 4199 passed | 1 failed | 10 skipped. The one red isorganization-access-policy.test.ts"the absolute limit is judged too…", the known exact-60-minute boundary flake that plugin-auth test:organization-access-policy.test.tssets a session exactly 60 minutes old against a one-hour limit and expectsadmitted, so it goes red whenever a millisecond passes (main red on 56baf78e) #31175 carries. Re-run alone: 19 passed (19), three times out of three. On148bac5fbefore the merge: 236 files, 4185 passed | 10 skipped.@objectstack/plugin-audit: full suite 1203 passed (1203) on148bac5f;auth-event-audit.test.ts19 passed (19) on2aeb9e05.auth-session-audit-trail.dogfood.test.ts: 7 passed (7) on2aeb9e05.@objectstack/plugin-authexit 0,@objectstack/plugin-auditexit 0 on2aeb9e05;@objectstack/dogfoodexit 0 on148bac5f.eslint --no-inline-configon the 10 changed.tsfiles: exit 0.turbo run buildunderos-verify-lock, 120 successful, 120 total (VERDICT command-exit 0).scripts/pm/dispatch-gates.mjs --ranon2aeb9e05: 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-testafter fetching its fixture commit;check:skill-examplesandcheck:dual-build-cjs-loadsafter the full build.Human Minimum
packages/specholds no runtime logic. (No spec change.)eslint --no-inline-configpasses on the changed files. (CI owns the repo-wide lint run.)main, one pull request (PR 2 of the claimed two-PR series).packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code