fix(core): the auth-gate allow-list matches at a mount boundary - #17284
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
`isAuthGateAllowlisted` decides which paths are exempt from the ADR-0069
authentication-policy gate — the gate that holds a session with an expired
password or a required MFA enrollment away from protected resources. It
matched with two UNANCHORED tests: `path.includes('/auth/')` at any position,
and an `endsWith` test over five suffixes at any depth. A path segment whose
VALUE merely spelled one of those tokens therefore carried the exemption, and
object names and record ids are tenant-controlled.
Both seams hand the predicate a data-plane path directly — the dispatcher
passes `cleanPath`, REST passes `req.path` — so these were reachable requests.
Measured on the built package before the repair: `/data/auth/123`,
`/meta/auth/objects`, `/data/x/health` and `/data/xyz/me/apps` were exempt,
with `/auth/me` exempt and `/data/contacts/1` gated as controls.
The path is read as segments now, and every test is anchored to a mount base
(`/api/v1`, `/api`, or the empty base the dispatcher sees) plus at most one
environment scope immediately after it — the scoped spelling reaches the
predicate because the dispatcher evaluates the gate before its scoped-URL
strip. `/auth/…` at that position stays exempt; the five bootstrap reads are
exact routes there instead of suffixes.
Only exemptions are removed: over a generated corpus of 111,152 paths, newly
exempt = 0 and 25,979 stopped being exempt. That subset check is kept as a
test with the pre-anchoring predicate transcribed beside it, so a later
widening cannot arrive quietly.
The platform-checklist citation that named the deleted `ALLOW_SUFFIXES` is
re-authored onto the successor symbol rather than dropped, and the REST
fault-window measurement pin that recorded the over-broad answer is re-aimed
in place with its superseded text quoted, per that file's own instruction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…nstants The comment named `ALLOW_PREFIXES` and `ALLOW_SUFFIXES` as the constants its path shapes were read off. Anchoring the allow-list replaced both, so the reference dangled; the shapes themselves are unchanged and §0 still drives that they are allow-listed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 25 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 99d010ad934c5b6e6323f7abcc9abc13c2d5eb96 && git checkout 99d010ad934c5b6e6323f7abcc9abc13c2d5eb96
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d64bcb63772b850b3ca8ae39779d492e46d3a0a6 d00421281b3aeb2a48b728dbb8dc42bf5743e4cd && git checkout -B drift-repro d64bcb63772b850b3ca8ae39779d492e46d3a0a6 && git merge --no-ff d00421281b3aeb2a48b728dbb8dc42bf5743e4cd
node scripts/docs-audit/affected-docs.mjs --json d64bcb63772b850b3ca8ae39779d492e46d3a0a6
|
Fixes #16839
Clause-②: no
(Declared by the claiming seat, not by the implementer — the declaration is the seat's judgement (
check-clause2-carriers.mjs: ⛔ do not fill the line in on the claiming seat's behalf), and it matches theClause-②: noon this card's claim comment. This PR anchors an allow-list: after it the auth-gate exempts strictly FEWER paths than before. Nothing is newly accepted, no export is added, and there is no new schema key, closed-set member or registry entry — so the C5 widening-tell limb has nothing to collide with. Written here pre-emptively: theCheck ChangesetLEVEL AXIS refuses «clause-② undeclared + every movedpackages/**/src/**package gradedpatch», which is this diff's shape, and it refused PR #17255 for exactly that an hour ago. The remedy is the declaration, never a regrade.)isAuthGateAllowlisteddecides which paths are exempt from the ADR-0069 authentication-policy gate — the gate that holds a session with an expired password or a required MFA enrollment away from protected resources. It matched with two UNANCHORED tests, so a path segment whose VALUE merely spelled an allow-listed token carried the exemption. Object names and record ids are tenant-controlled.Measured on the built package, before the repair
packages/corebuilt atcf6e0a193b, read throughdist/index.js(the published entry), reproducing the card's table exactly:Reachability, read rather than assumed. Both seams hand this predicate a data-plane path directly:
HttpDispatcher.enforceAuthGate(context, cleanPath)is called athttp-dispatcher.ts:2546— before the scoped-URL strip at:2593, so the environment-scoped spelling reaches it too — andRestServer.enforceAuthpassesreq.path(rest-server.ts:1882). At the dispatcher,cleanPathis the hono adapter'ssubPath(c.req.path.substring(prefix.length)), i.e. already prefix-stripped. This repo's own tree already records the REST half: the#15021fault-window measurement notes that/api/v1/data/:objectwithobject=healthmaterializes/api/v1/data/health, and pinned that it answeredtrue.The repair
The path is read as segments, and every test is anchored to a mount boundary:
/api/v1,/api, or the empty base the dispatcher sees. These are the same three bases the pre-anchoringALLOW_PREFIXESalready enumerated; the file's existing position was that the bases are enumerable, and this keeps it./environments/ENV_ID, or ADR-0006's superseded/projects/ENV_ID), because the dispatcher evaluates the gate before its scoped strip. Recognising the scope only immediately after a base is what keeps/data/environments/x/healthfrom reading as a scoped/health./auth/…at that position stays exempt — the anchored replacement forpath.includes('/auth/'). BareBASE/authstays exempt only unscoped, which is exactly what the oldALLOW_PREFIXESequality branch admitted.A rule that accepts an arbitrary base cannot be written.
/rest/v2/healthand/data/xyz/healthare the same shape, so base-agnosticism is the defect. Enumerating is the only correct answer, and it costs nothing at either live seam: the dispatcher's path arrives base-stripped (matched by the empty base), and REST registers its control-plane routes withoutenforceAuthat all (rest-server.ts:1891). The changeset carries the note for anyone gating a custom mount through this predicate.The semver grade, and the measurement that picked it
@objectstack/core:patch.packages/core/package.jsonmapsexports["."]→./dist/index.js/./dist/index.d.tswithfiles: ["dist", …];dist/index.d.tsdeclaresisAuthGateAllowlistedand names it in the entry'sexport { … }list, andtypeof (await import('./dist/index.js')).isAuthGateAllowlisted === 'function'. ⇒ consumers see this behaviour change on a published surface, so notskip-changeset.(rawPath: string | undefined | null) => booleanbefore and after, byte-identical indist/index.d.ts. ⇒ not theminor+ BREAKING +adr-0087:rung.ALLOW_PREFIXES/ALLOW_SUFFIXES→MOUNT_BASES/ALLOW_ROUTES) were and remain module-private..changeset/auth-domain-claim-segment-boundary.md(PR fix(runtime): stop the/authdomain claiming every path that merely starts withauth#16265, released, now inCHANGELOG) graded exactly this shape — one unanchored path predicate anchored to a segment boundary in a published package, behaviour narrows, no type moves — aspatch. That card is the/authmember of the very sweep this one belongs to. AGENTS.md § Post-Task Checklist step 3 says the same thing independently: "A bug fix in a released package takes apatchchangeset."minor, and it is not the precedent to follow: it also moved a published default (DomainRoute.matchfrom'prefix'to'segment'), which is a config surface authors write. Nothing here moves a default or a declared surface.Clause ② — measured, not asserted
The dispatch declared clause ②
no("nothing is newly accepted; the allowlist shrinks") and asked me to stop and report if any path became newly exempt. None does. Over a generated corpus of 111,152 paths (every path up to four segments over the vocabulary the two spellings can disagree on), against the pre-anchoring predicate transcribed verbatim:That check is kept as a test, with the superseded predicate beside it, so a later widening cannot arrive quietly.
Tests — failing before, passing after, both directions pinned
New block
[#16839] a tenant-controlled segment cannot buy the exemptioninpackages/core/src/security/auth-gate.test.ts. It pins the DECISION per path, not the spelling of the predicate.Ablation (the fail-before leg). Fix committed first, then
packages/core/src/security/auth-gate.tsrestored tocf6e0a193band proven on disk by blob hash (git hash-object=8ffa74f2…= the BASE blob; anchored markerMOUNT_BASEScount 0, unanchoredpath.includes('/auth/')count 1), run, then restored viagit checkout HEAD -- PATHwithgit diff HEADempty as the restore proof. Both legs under atrap … EXIT INT TERMwith absolute paths.The four control legs (
/auth/meexempt,/data/contacts/1gated, and the twenty-one genuinely-exempt route shapes) pass in both runs — which is the point: they are the direction the repair must not move.Suites.
pnpm --filter @objectstack/core buildcheck-dts-emitted: 2/2 declaration file(s) presentpnpm --filter @objectstack/core testTest Files 51 passed (51)·Tests 1261 passed (1261)pnpm --filter @objectstack/core typecheckcheck:test-typecheckovertsconfig.test.json)pnpm --filter @objectstack/rest testTest Files 186 passed (186)·Tests 3111 passed (3111)pnpm --filter @objectstack/rest typecheckpnpm lint(repo-wide union,eslint . --no-inline-config)d00421281bGates. 41 of the 43 derived families that were still outstanding ran clean, plus 18 run earlier —
check:nul-bytes,check:platform-checklist,check:changeset-no-major,check:adr-0087-registration,check:empty-changeset,check:objectui-changeset,check:changeset-gate-self-tests,check:closing-keyword-parity,check:pm-changeset-deadline-census,check:cross-package-test-inputs,check:test-source-alias,check:undeclared-dep-importsamong them. Exit codes captured by redirect-then-$?, never across a pipe.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt. Both refuse without a whole-repodist/(turbo run buildacross every package), which is a repo-wide build this environment reserves for CI. Neither reads anything this diff touches outsidepackages/core, which is built here — but that is a reading, not a run, and CI owns the verdict.Two compelled edits outside
packages/coreBoth are consequences of this diff, not scope creep:
packages/rest/src/auth-gate-allowlist-fault-window.measurement.test.ts§3 pinnedisAuthGateAllowlisted('/api/v1/data/health') === true— the exact over-broad answer this card removes. That file's own header instructs: "If a repair lands, INVERT THESE PINS IN PLACE … re-aim the assertion here and quote the superseded text beside it … ⛔ Do not delete the leg." Done exactly that, with the superseded assertion and its reason quoted verbatim, the leg's reading unchanged, and a positive control added so the flip cannot read as "the predicate started refusing everything".docs/qa/platform-checklist/areas/access-security.jsoncitedauth-gate.ts#ALLOW_SUFFIXES. Deleting that symbol dropped the file's resolvable-anchor count from 44 to 43 andpnpm check:platform-checklistwent red — the gate's own remedy is "re-author the citation with a symbol this gate can resolve" (lowering the baseline floor is the maintainer-only path and was not taken). Re-pointed at the successorALLOW_ROUTESwith the change recorded in the citation text. Gate green:symbol anchors: 631/631 resolved … 17 file floors held.Acceptance notes
Out of scope, observed, not filed:
HttpDispatcher.enforceAuthGatefails open on any exception (catch { return null; // fail-open — never break dispatch on a gate hiccup },http-dispatcher.ts). Declared behaviour with a stated rationale, not a defect, and changing a fail-open/fail-closed posture is runtime authorization behaviour reserved to the maintainer. Successor: whoever next revisits ADR-0069's posture — the same seat that owns#15021's fail-closed window, which is about the sibling half of this door.enforceAuthis only registered on data/meta routes, and the#15021census measured that the sole allow-listed mounted REST route pattern is/discovery. Successor:#15021, whose §3 already records the census.BASE/environments/ENV_ID/authwith nothing under it is not exempt while the unscopedBASE/authis — an asymmetry inherited from the oldALLOW_PREFIXESequality branch and preserved deliberately, because removing it would make a path newly exempt and clause ② is the seat's to change. Successor: none; recorded here so a later reader does not read it as an oversight.🤖 Generated with Claude Code
https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Generated by Claude Code