Repository navigation
fix(plugin-auth)!: clearing the allowed IP ranges applies at once, and a console save that leaves out the saving address is refused - #30309
Conversation
…that excludes the caller is refused A cleared or restored-default `allowed_ip_ranges` resolves with source `default`, which the settings binding skipped, so the last stored list stayed in force until a restart. It now hands the gate back to the deployment's own declaration in the same pass. A console save of a list that leaves out the saving request's own address (resolved by the gate's own rule) is refused, naming the address and the `OS_AUTH_ALLOWED_IP_RANGES` recovery. The settings routes pass the request's transport to write predicates for it. Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01Utp6Ca3N2EsTAqg7jhabeL
…lowed_ip_ranges Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01Utp6Ca3N2EsTAqg7jhabeL
…usal Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01Utp6Ca3N2EsTAqg7jhabeL
…tions, not on the caller's context The door hands the service the resolver's very context; the request a write arrived on rides SettingsWriteOptions.transport to the predicate. Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01Utp6Ca3N2EsTAqg7jhabeL
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
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 f13607b3d76935a7595fbeeefa033155b4a4b8b1 && git checkout f13607b3d76935a7595fbeeefa033155b4a4b8b1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c1240a38ac2c23c8d526e476a20edeabbbefd6f6 8767d6aa81725636037589b29b3ad59a9b4a37e9 && git checkout -B drift-repro c1240a38ac2c23c8d526e476a20edeabbbefd6f6 && git merge --no-ff 8767d6aa81725636037589b29b3ad59a9b4a37e9
node scripts/docs-audit/affected-docs.mjs --json c1240a38ac2c23c8d526e476a20edeabbbefd6f6
|
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentJudged on the tree, against the sign-in gate and the settings service as cited.
Check-runs on Findings
Implemented-by: VERDICT: FAIL Record taken 2026-11-05T05:57Z. |
…quest headers it names; the refusal says no entry matches Contract review 6650936040: the route handed every predicate the whole header table. registerWritePredicate now takes requestHeaders, and the allow-list predicate names the forwarded-address headers alone. The refusal says no entry matches the address (an IPv6 CIDR entry matches nothing today), and the settings slot's predicate input is unknown. Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01Utp6Ca3N2EsTAqg7jhabeL
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentJudged on the tree, trying to break
Check-runs on Findings
Implemented-by: VERDICT: PASS Record taken 2026-11-05T07:05Z. |
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL 维护者速读
席位核对 — ACCEPT · domain:server#2 · head
|
Requested by Bill · project thread
domain:server#2 · os-bill · session_01Utp6Ca3N2EsTAqg7jhabeL
Fixes #30203
Clause-②: yes (narrowing) — a settings console save (
PUT /api/settings/auth) of anallowed_ip_rangeslist none of whose entries matches the saving request's own address is refused, where it was stored before; and@objectstack/service-settingswidens its published surface: three new exported types (SettingsWriteOptions,SettingsRequestTransport,SettingsWritePredicateOptions),setMany's fourth parameter acceptsSettingsWriteOptions,registerWritePredicatetakes an optional fourthoptions, andSettingsWritePredicateInputgains an optionaltransport.Metadata-protocol: none
Skills-docs: none —
skills/**teaches noallowed_ip_ranges;content/docs/permissions/authentication.mdxdescribes the setting and does not yet state the save refusal or the recovery, which ship here in the refusal text and this entry (the page is outside this change's surface and is named for a follow-up).维护者速读
OS_AUTH_ALLOWED_IP_RANGES设为空值后重启)。server.trustProxy规则)。回滚即撤本 PR。packages/plugins/plugin-auth/src/allowed-ip-ranges.ts(保存时的判定与四种语言的拒绝文案);packages/plugins/plugin-auth/src/auth-plugin.ts的allowed_ip_ranges分支(清空时交回部署自己声明的列表);packages/services/service-settings/src/settings-routes.ts的保存路由(把请求的连接交给写谓词,每个谓词只拿到它注册时点名的请求头)。pnpm --filter @objectstack/plugin-auth exec vitest run src/allowed-ip-ranges.pin.test.ts,预期 1 个文件、9 条全绿。What changes
The rule: an allowed-IP list cannot lock out the administrator who saves it, and clearing it applies at once.
@objectstack/plugin-auth). The console sends an emptied field asnull. The settings cascade then resolves the key with sourcedefault, andapplySettingsskipped every default-sourced key, so the runningallowedIpRangeskept the last stored list until a restart. Measured on the realSettingsServicewith the shipped auth manifest: after anullsave and afterresetNamespace('auth'),allowed_ip_rangesreads{ value: null, source: 'default' }. Now a default-sourced value hands the gate back to the deployment's own declaration in the same pass: no list when it declared none, theallowedIpRangesthe plugin options or anauth:configurehandler declared otherwise (captured before the manager is built). The reset is applied only when the running list came from a stored setting, so a deployment that never stored one sees no extra config patch.isExplicititself is unchanged.@objectstack/plugin-auth). A settings write predicate onauth.allowed_ip_ranges(allowed-ip-ranges.ts) refuses a list none of whose entries matches the saving request's address:400 SETTINGS_VALIDATION,invalid_valueat the key,constraint: { clientAddress }, and a message in the caller's language (en, zh-CN, ja-JP, es-ES, the settings bundle's locales) that names the address and the recovery. The address is resolved by the auth manager's ownresolveClientAddress(peer address, or the forwarded one underserver.trustProxy) and matched by the gate's ownipMatchesRange, so the save judges the address the gate later judges. When the address cannot be established, every non-empty list is refused, because the gate refuses that request too. A clear is never refused; an in-process write carries no request and is not judged. The textarea parsing moved intoparseAllowedIpRanges, shared by the binding and the predicate.@objectstack/service-settings).setMany's options becomeSettingsWriteOptions(SettingsDisplayOptionsplus an optionaltransport: the transport's peer address and the request headers), and a write predicate's input gains the same optionaltransport. ThePUT /api/settings/:namespaceroute sets it. It rides the options rather than the caller's context, so the service still receives the resolver's very context (the two door pins that assert identity stay as they are). A predicate sees only the headers it names at registration:registerWritePredicate(namespace, key, predicate, { requestHeaders })(SettingsWritePredicateOptions), narrowed per predicate in the service, socookie,authorizationor an API key never reaches one that did not ask for it. The allow-list predicate names the two forwarded-address headers the address rule reads (FORWARDED_CLIENT_ADDRESS_HEADERS, the rule's own list). Nopackages/specchange.OS_AUTH_ALLOWED_IP_RANGESset to an empty value is in force (source: 'env'), wins over the stored list, and opens the gate on the next boot. The refusal text and the changeset name it. No new env var.Tests
packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts(9 cases), on the realAuthPlugin, the realSettingsServicewith the shipped auth manifest, and the real settings routes:null) opens the gate in the same pass; restoring the namespace defaults does too; a clear returns to the deployment's own declared list, not to none;code, fieldcode,constraint.clientAddress) and nothing is stored; a list that includes it saves and is enforced (preservation);trustProxythe forwarded address is judged, and without it the peer address, both ways;OS_AUTH_ALLOWED_IP_RANGES=''opens the gate over a stored list on the next boot.packages/services/service-settings/src/settings-write-predicate-transport.test.ts(3 cases), on the real service and routes: a predicate that named the forwarded-address headers gets exactly those plus the peer address and nocookie/authorization/x-api-key; one that named none gets the peer address and no header; an in-process write carries notransport.Suites and typecheck (head 8767d6a)
pnpm --filter @objectstack/plugin-auth test: Test Files 221 passed (221), Tests 3885 passed | 10 skipped (3895).pnpm --filter @objectstack/service-settings test: Test Files 68 passed (68), Tests 1162 passed (1162).pnpm --filter @objectstack/plugin-auth typecheck: exit 0.pnpm --filter @objectstack/service-settings typecheck: exit 0.settings-routes.anonymous-floor.pin.test.ts,settings-routes.auth-gate.pin.test.ts: "the service sees that very context",toBeidentity) while the transport rode the context; moving it onto the write options turned both green with the pins unchanged.Ablations (each put the forbidden behaviour back through `scripts/ablation-replace.mjs`, saw red, restored; the test resolves both packages from source, so no build sits between)
} else if (false /* ABLATION-30203-A */ && …): 3 failed | 6 passed — the three clearing cases.transportrenamed away: 3 failed | 6 passed — the same three.transportto the predicate deleted: 3 failed | 6 passed — the same three.git diff HEADempty; each restore's blob equals the HEAD blob.requestHeaders: []: 1 failed | 8 passed — the trustProxy case (the forwarded address no longer reaches the predicate).narrowTransportpassing every header: insettings-write-predicate-transport.test.ts, 2 failed | 1 passed — both header cases. After E and F:git diff HEADempty; each restore's blob equals the HEAD blob.Acceptance notes
if (isExplicit(key))branch inapplySettingswithout a reset keeps the last stored value after a clear or a restore-default. Measured on the real composition:lockout_threshold3 andmax_concurrent_sessions_per_user2 stay in force after both are savednull. A general fix needs each key's "deployment declared" value restored, across the refusableemailAndPasswordpieces that Organization require-MFA can be turned on with two-factor off, and after the grace period everyone is locked out with no way to enroll #30201 and Authentication settings: turning off email and password with no other sign-in method saves without warning, and everyone is locked out #30202 are editing, so this card fixesallowed_ip_rangesonly and names the family for the seat.constraint.clientAddress) for the console to show. That is thedomain:uihalf, named for the seat.ipMatchesRangematches IPv4 CIDR and exact addresses only, so an IPv6 CIDR entry matches nothing at either door; the refusal therefore says no entry matches the address rather than that the list leaves it out. The gap is the seat's to file.content/docs/permissions/authentication.mdxdoes not state the save refusal or the recovery override; outside this card's surface, named for the seat.Verification
Gates and lint on head
8767d6aa(the contract-review round,origin/mainmerged in at89ff3aa9); the dogfood reading is from89ff3aa9.Derived gates: 65 derived, 64 exit 0, 1 NOT MEASURED
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 65 commands for the 9 changed paths; each ran with its exit code captured before any pipe.--ranreconciliation: "65 derived famil(ies) accounted for — 64 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".pnpm check:dual-build-cjs-loadsexits 3 (PREREQUISITE NOT MET: 35 packages withoutdist/; it needs a whole-workspace build). CI runs it.node scripts/check-plugin-teardown-shape.mjs --self-test: exit 0 ("48 cases pass"; on the first round it neededgit fetch origin 621a4876…on the shallow checkout).pnpm lint(eslint . --no-inline-config): exit 0.Full dogfood (owed by the (narrowing) arm, head 89ff3aa): 1 red, from main, not from this change
pnpm --filter @objectstack/dogfood testunder the verify lock:VERDICT command-exit 1, Test Files 1 failed | 418 passed | 3 skipped (422), Tests 1 failed | 3491 passed | 36 skipped (3528).test/permission-set-clone-door.dogfood.test.ts› "a platform set (the lock-and-clone rule's commonest source) copies into its shipping package's pair" answers422 INVALID_METADATA(member_defaultclone: "grants on object 'sys_comment' / 'sys_activity' which is not defined in objects") where it expects 200.mainwith fix(plugin-security): every member reads and posts comments, and reads the activity history, of the records they can read #30222 (490d136f2,member_defaultgainssys_commentandsys_activity), and this test runs only inmain's push battery, so it is first seen there. Tracked as main red: Dogfood Regression Gate (2/3) fails permission-set-clone-door after PR #30222 (member_default grants sys_comment) #30255 (fix(plugin-security): every member reads and posts comments, and reads the activity history, of the records they can read #30222 reverted by revert: PR #30222 (main red #30255) #30262; forward fix in fix(runtime, metadata-protocol): the permission-set clone door copies a set that grants on an object this deployment does not install, and member_default's feed grants re-land #30310). The contract-review round changes only which headers reach a predicate and the refusal wording, so the dogfood was not re-run on8767d6aa.Human Minimum
packages/specholds no runtime logic (untouched).pnpm lintpasses.main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6Ca3N2EsTAqg7jhabeL