Repository navigation
feat(plugin-auth): a platform admin can read the address the allowed IP ranges save check will judge (#30398) - #30483
Conversation
…ges save check judges Claude-Session: https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 Co-authored-by: Claude <[email protected]>
…-i4mjun Claude-Session: https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 Co-authored-by: Claude <[email protected]>
… sweeps Claude-Session: https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 Co-authored-by: Claude <[email protected]>
…-i4mjun Claude-Session: https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 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 f590bbae1201a05d40e4020c85a1e75612a761b8 && git checkout f590bbae1201a05d40e4020c85a1e75612a761b8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a1d0b1ee9e428402399f91808923c508c84e0957 843cf4fecd534b83ea05e6f58e1f96aaa0bd525a && git checkout -B drift-repro a1d0b1ee9e428402399f91808923c508c84e0957 && git merge --no-ff 843cf4fecd534b83ea05e6f58e1f96aaa0bd525a
node scripts/docs-audit/affected-docs.mjs --json a1d0b1ee9e428402399f91808923c508c84e0957
|
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL 维护者速读
席位核对 — ACCEPT · domain:server#2 · head
|
Requested by Bill · project thread
domain:server#2 · os-bill · session_01Utp6CZbTJpa9PNbpp3fdB7
Fixes #30398
Clause-②: yes (widening) — a new authenticated HTTP read,
GET /api/v1/auth/admin/client-address, ledgeredserver-onlyin the plugin's auth route ledger; no spec schema, SDK method or package export moves.Metadata-protocol: none
维护者速读
GET /api/v1/auth/admin/client-address,平台管理员在保存「允许的 IP 段」之前,就能看到服务器会拿哪个地址去判(clientAddress),以及是否信任代理转发头(trustProxy)。/config不变;响应带no-store。保存校验和登录闸门的判法一字未变。回滚即撤本 PR。packages/plugins/plugin-auth/src/allowed-ip-ranges.ts的judgedClientAddress(两扇门共用的取址);client-address-read.ts(接口本体);allowed-ip-ranges.pin.test.ts末尾#30398用例(同一连接分别问接口和保存,结果逐字相等);两个 dogfood 的/admin/路由清点各加一行归类。pnpm --filter @objectstack/plugin-auth exec vitest run src/allowed-ip-ranges.pin.test.ts,预期 1 个文件、21 条全绿。What changes
The rule: the address shown to an administrator before a save is the address that save is judged by, because both come from one call.
judgedClientAddress(allowed-ip-ranges.ts). The save check's inline call toAuthManager.resolveClientAddressmoves into an exported helper: the request's peer address plus only the forwarded-address headers the rule reads (FORWARDED_CLIENT_ADDRESS_HEADERS, the narrowing the settings service already applies to the save check's input, so on that path it is idempotent),server.trustProxyread inside the manager as before. The save check calls it with the same input it had; its refusal copy is untouched. The transport type it takes, formerly the file-privateWriteTransport, is exported asRequestTransport(package-internal:allowed-ip-ranges.tsis not on the package'sexports).GET /api/v1/auth/admin/client-address(client-address-read.ts, mounted inauth-plugin.ts). Answers200 { success: true, data: { clientAddress, trustProxy } }withCache-Control: no-store.clientAddressisjudgedClientAddresson this request's headers and transport peer, in the rule's own text (an IPv4-mapped peer reads::ffff:127.0.0.1, as the save refusal'sconstraint.clientAddressnames it),nullwhen no address can be established.trustProxyisAuthManager.trustsForwardedHeaders(). Closed, empty query set: any parameter is refused400 VALIDATION_ERRORbefore the address is read (AGENTS.md route rule 5). No body is read.gateAdminevery ObjectStack/admin/*auth mount calls (ADR-0068 D2 judge,platform-admin-gate.ts): no session401 UNAUTHENTICATED, a held session (ADR-0069 D3)403with the posture's code, any other non-platform-admin403 PERMISSION_DENIED, each before the read runs. Not on the anonymousGET /api/v1/auth/config.objectstack-mountrow,server-only, inauth-route-ledger.ts, and the conformance pin's hand-written objectstack set gains the route.pnpm check:auth-mount-ledger: 25 mounts, all accounted for./admin/route sweeps (dogfood). A mounted/admin/route is in scope of both at once, so each classifies it:admin-platform-admin-standing.dogfood.test.tslists it inADMITTED(the platform admin is not refused),admin-route-nonadmin-refusal.dogfood.test.tsasobjectstack-gate(anonymous401, member403, admin not refused). No payload is needed: nothing is read before the gate.GET /api/v1/auth/admin/client-address(same-origin, session cookie) on the Authentication settings page and showdata.clientAddressbeside the Allowed IP ranges field;nullmeans the server cannot establish the address (any non-empty list would then be refused). A403 IP_NOT_ALLOWEDfrom it means the caller's address is already outside the list in force (the D5 gate answers it, see Acceptance notes).resolveClientAddress,service-settings,packages/spec, the SDK.Why
#30398: the save check from #30309 refuses a list that leaves out the saving request's address and names it, but nothing served that address before a save, so an administrator learned it only by being refused, and the console had no correct way to show it (triage 6655147452: the served address must come from the function the save check judges with; the client never guesses its own address).
Verification
Measured first, at
6fa842af: no auth route, no/api/v1/auth/configfield and noGET /api/settings/authfield answersresolveClientAddress; the save check (allowed-ip-ranges.ts) is its only settings-side caller.get-sessionserves the session row'sip_address, which is a different fact (see Acceptance notes).Tests
allowed-ip-ranges.pin.test.ts›#30398(the shared harness: realAuthPlugin, realSettingsServicewith the shipped auth manifest, real settings routes for the save, and now the plugin's real auth routes on a real Hono app for the read, the transport peer supplied the way@hono/node-serversupplies it). Each address case asks the read and then saves192.0.2.0/24from the same connection, and asserts the read'sclientAddressequals the refusal'sconstraint.clientAddress:203.0.113.9; IPv4-mapped peer::ffff:127.0.0.1(not rewritten to dotted);trustProxydeclared withX-Forwarded-For: 198.51.100.7, 10.0.0.2(leftmost entry);trustProxynot declared with forgedX-Forwarded-ForandX-Real-IP(the peer10.0.0.1at both doors); no peer at all (nullat both doors).Cache-Control: no-store.trustsForwardedHeadersnever called, nodata): no session401 UNAUTHENTICATED; a signed-in non-admin403 PERMISSION_DENIED; a held platform admin403 MFA_REQUIRED; an unknown query parameter400 VALIDATION_ERROR.auth-route-ledger.conformance.test.ts: the objectstack-sourced set includes the route, and better-auth does not serve it.pnpm --filter @objectstack/plugin-auth teston the merged head889cbec8: Test Files 224 passed (224), Tests 3961 passed | 10 skipped (3971); again after mergingmainf7afab86(tree of5db8d66e, closure rebuilt): Test Files 224 passed (224), Tests 3964 passed | 10 skipped (3974).pnpm --filter @objectstack/plugin-auth typecheck: exit 0 (check:test-typecheckOK, debt ledger unchanged).Dogfood: the full suite under the verify lock on the tree of
5db8d66e(closure built, no shard) went red on exactly the two/admin/sweeps, each namingGET /api/v1/auth/admin/client-addressas unclassified (Test Files 2 failed | 417 passed | 3 skipped (422), Tests 2 failed | 3490 passed | 36 skipped (3528)). That run is the red leg for the two classifications above; with them, both files pass (Test Files 2 passed (2), Tests 15 passed (15)). On the final head843cf4fe(mainf273c9femerged, closure rebuilt):pnpm --filter @objectstack/plugin-auth testTest Files 224 passed (224), Tests 3964 passed | 10 skipped (3974); full dogfood under the verify lock (pnpm --filter @objectstack/dogfood test, no shard), lock verdictcommand-exit 0: Test Files 419 passed | 3 skipped (422), Tests 3492 passed | 36 skipped (3528).Ablations (scripts/ablation-replace.mjs; the suite imports src directly, no build between legs)
clientAddress: null::ffff:(the session row's canonical text)trustProxyX-Forwarded-ForwithouttrustProxy0.0.0.0for an undetermined addressCache-Controlheader droppedgateAdminverdictEach mutation landed (anchor 1 to 0, blob changed) and each restore leg reported blob equal to HEAD and
git diff HEADempty. A7's first attempt was refused by the tool before running anything (its replacement was a substring of its anchor, so the replacement count could not rise); it was re-run with a replacement that lands, and only that run is in the table.Gates (head 843cf4f)
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 68 families at510dffb6; each ran with its exit code written to a file before any pipe, after a build of every package;--ranwith:: exit <code>on every line:68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3). All 68 exit 0.pnpm lintexit 0.mainf273c9fewas merged (head843cf4fe, no conflict, nothing underplugin-author the two dogfood sweeps). The gate list re-derived there is byte-identical (68); the two gates whose inputs the merge touched (scripts/cross-package-test-inputs.mjsreads the changed codemod) re-ran, exit 0:check:cross-package-test-inputs,check:ci-filter-parity.889cbec8three families first exited 3 on an unmet prerequisite and passed on re-run (check-plugin-teardown-shape.mjs --self-testafter fetching its fixture commit621a4876;check:dual-build-cjs-loadsafter a whole-closure build;check:type-check-debtafter a lock queue timeout behind this card's own build). In the510dffb6round all three exited 0 first time.Acceptance notes
/admin/*family's one judge (platform admin), not a freshmanage_platform_settingscheck through the settings door's composition. With the shipped permission sets the two populations are the same callers: platform-admin standing is derived from holdingadmin_full_access(resolveAuthzContext), which carriesmanage_platform_settings, theauthnamespace's read and write capability, andorganization_adminwithholds that capability. They part only at two edges: a custom permission set grantingmanage_platform_settingswithoutadmin_full_accessmay save but is refused the read (it loses only the hint; the save check still names the address), and the judge's legacyrole === 'admin'fallback may read its own address without being able to save. Judging the settings capability exactly would mean re-composing the settings door's resolver call, held-session and outage relays inside plugin-auth, a second copy of a door; not done here.get-sessionservessession.ipAddress, stamped at sign-in in its canonical text (client-address-canonical.ts: an IPv4-mapped peer is stored dotted). It can differ from the current connection (the address changed since sign-in) and from the judged text, so it was not reused; ablation A2 pins the text difference./configand/bootstrap-status. A caller whose address is already outside the list in force gets403 IP_NOT_ALLOWEDfrom the read; no exemption is added to the gate.content/docs/permissions/authentication.mdx› Saving, clearing and lockout recovery does not mention the read yet; it is outside this card's declared file surface. A one-line addition fits there once the ui half shows the address. Carrier: the seat.allowed-ip-ranges.ts's refusal copy (COPY,copyFor) is byte-untouched, for this seat's Allowed IP ranges: the documented env recovery cannot clear the stored list, so removing OS_AUTH_ALLOWED_IP_RANGES locks the admin out again at the next restart #30385.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_01Utp6CZbTJpa9PNbpp3fdB7