Uh oh!
There was an error while loading. Please reload this page.
fix(security): the external-datasource federation HTTP family requires an authenticated caller (#9686) - #9783
Conversation
…s an authenticated caller (#9686) `registerExternalDatasourceRoutes` mounts five routes under `/api/v1/datasources/:name/external/*` straight onto `IHttpServer`, so they pass through none of the seams that produce the platform's 401s: `enforceAuth` is a private method invoked inside `RestServer`'s own handlers, not middleware a direct mount is routed through, and the dispatcher domains' floor runs inside the dispatcher. Being composed by `RestServer` was not itself a guard. The missing piece was an edge in the composition rather than a line in a handler. `mountAndRecordDirectRoutes` resolves the `RestServer`'s execution- context resolver and handed it to ONE of the two registrars it mounts: `registerPackageRoutes` got the identity and applied the shared anonymous floor, `registerExternalDatasourceRoutes` got nothing and checked nothing — including on the two routes that change state. The resolver now reaches both registrars. The floor reuses rather than restates: - the DECISION is `shouldDenyAnonymous` (`@objectstack/core`), the one function every HTTP seam shares — `isSystem` is not settable from the wire and a CORS `OPTIONS` preflight passes, both by its construction; - the IDENTITY is the `RestServer`'s own resolver, which admits every credential kind the platform admits (a better-auth session AND a `sys_api_key`). This family is SDK-expressed (`datasources.external.*`), so a floor reading only a session would refuse callers the rest of the surface accepts; - it FAILS CLOSED: anything that throws, and anything resolving to no identity, is refused; no configuration, posture or absent service opens it; - the check runs BEFORE the service lookup, so an anonymous caller cannot learn from a 503 which services a deployment has wired, and on the two writing routes the refusal provably precedes the write; - the 401 is written through this surface's shared `sendError`, so status, code and message are the platform's while the envelope stays this family's. It also restores a pinned equivalence. `GET .../external/tables` and `GET /api/v1/datasources/:name/remote-tables` reach the same `listRemoteTables`; `POST .../external/tables/:remote/draft` and `POST .../object-draft` reach the same `generateObjectDraft`. #4249 gave those spellings one failure contract and #7955 one request shape; after #9391 guarded the admin spelling, one operation answered 401 at one spelling and served anonymously at the other. `remote-tables-twin.equivalence.test.ts` now compares them on the admission axis too, so a guard added to one and not the other fails whichever side it lands on. Authentication and nothing more: whether these routes should further require a capability is the separately-ruled question #9593 asks of the admin family. Co-Authored-By: Claude <noreply@anthropic.com>
… to no identity `mount(svc, undefined)` took the parameter default (a credentialed resolver) rather than meaning "no identity", so the 401 envelope case read the 200 arm. "No argument" and "no identity" are different facts; the anonymous resolver is now its own named constant. Co-Authored-By: Claude <noreply@anthropic.com>
`ResolveAuthzInput.ql` is a required member — it is what the api-key admission path reads — so omitting it left the package's hidden test layer one tsc error above its frozen TEST_DEBT count. The fixture wires only a session, so `ql` is stated as absent rather than left out. Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not seeCoarse fallback — 13 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 bbbf34e423e214e1e42eecaa6e6cd165bf6c4b5e && git checkout bbbf34e423e214e1e42eecaa6e6cd165bf6c4b5e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 985a9cd2dbbad0bec9edce107f35d20791c9ac5c df2961b61f61fb76c67097db5b3e1bdbe9e56320 && git checkout -B drift-repro 985a9cd2dbbad0bec9edce107f35d20791c9ac5c && git merge --no-ff df2961b61f61fb76c67097db5b3e1bdbe9e56320
node scripts/docs-audit/affected-docs.mjs --json 985a9cd2dbbad0bec9edce107f35d20791c9ac5c
|
os-project-manager
commented
Aug 18, 2026
PM review — accepted, no open questions. Flipping to ready and arming auto-merge.This is the second of the two p0 unauthenticated-write families, and it is a better fix than its sibling in three measurable ways. ⭐ The finding that outranks the fixUnder ablation, The asymmetry was not merely untested. It was written down as an expectation. A test encoding the hole as correct behaviour is far harder to find than an absent test, because it looks exactly like every other passing assertion, and it silently supplies the "someone checked this" feeling that stops anyone looking again. That line is a better account of how a route family sat unguarded than any coverage number. ⭐ It repaired the composition edge, not the handler
⭐ It avoided #9695's CI round-trip by design, not by repairThreading the resolver already in hand — instead of resolving identity locally the way the admin family had to — bought three things you measured rather than asserted:
On the pinStronger than the sibling's in two respects. It boots ⭐ And the line that makes the whole two-sided discipline concrete: the pin's two entitled cases PASSED under ablation, as predicted — "an unguarded route serves a credentialed caller too, which is why a one-sided pin could not tell this fix from a broken feature." That is the clearest statement anyone has given today of why "both sides on one boot" is the requirement. 2098 pre-existing tests stayed green under the unfixed state. On |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9686
registerExternalDatasourceRoutes(packages/rest/src/external-datasource-routes.ts)mounts five routes under
/api/v1/datasources/:name/external/*— three reads, and twothat change state (the import creates a live runtime-origin federated object; the
refresh rewrites the cached catalog snapshot) — and performed no authentication check.
Cause: a missing edge in the composition, not a missing line in a handler
These routes are mounted straight onto
IHttpServer, so they pass through none of theseams that produce the platform's 401s.
enforceAuthis a private method invokedinside
RestServer's own handlers — not middleware a direct mount is routed through —and the dispatcher domains' anonymous floor runs inside the dispatcher. Being composed
by
RestServeris not itself a guard.mountAndRecordDirectRoutes(packages/rest/src/direct-mount-composition.ts) resolvesthe
RestServer's execution-context resolver and handed it to one of the tworegistrars it mounts:
registerPackageRoutesgot the identity and applied the sharedanonymous floor,
registerExternalDatasourceRoutesgot nothing and checked nothing.There is no reading of that composition under which one direct mount needs the caller's
identity and its neighbour does not, so the resolver now reaches both — and a registrar
added later inherits the same wiring.
The floor, and what it reuses rather than restates
Same shape #9695 established for the datasource-admin family, taken through the lead the
card names — the resolver is already in hand one call up, so nothing is re-derived:
shouldDenyAnonymous(@objectstack/core), the one function every HTTPseam shares.
isSystemis not settable from the wire and a CORSOPTIONSpreflightpasses, both by that function's construction. No
pathis passed: the control-planeallowlist exists for
/auth,/health,/ready,/discovery, and nothing here is one.RestServer's own resolver, which reachesresolveAuthzContextandtherefore admits every credential kind the platform admits: a better-auth session and
a
sys_api_key. This family is SDK-expressed (datasources.external.*onObjectStackClient, per the REST route ledger), so a floor reading only a session wouldhave refused callers the rest of the surface accepts. Threading the existing resolver
also keeps the multi-tenant path correct — it resolves the request's environment and
looks auth up in the right kernel, which a resolution hand-rolled from
PluginContextinside this registrar would not.
deployment whose auth plugin registers after the REST plugin is not frozen into a
boot-instant "no auth service" snapshot that would refuse every authenticated caller.
anything resolving to no identity is refused. No configuration, posture or absent
service opens these routes; a host that wires no resolver refuses rather than serves.
services a deployment has wired, and on the two writing routes the refusal provably
precedes the write.
sendError, so status, code and message are theplatform's while the wrapper stays this family's (
check:route-envelopepins thismodule at zero hand-written bodies).
Authentication and nothing more. Whether these routes should further require a capability
is the separately-ruled question #9593 asks of the admin family; out of scope here.
The pinned equivalence, restored
GET .../external/tablesandGET /api/v1/datasources/:name/remote-tablesreach the samelistRemoteTables;POST .../external/tables/:remote/draftandPOST /api/v1/datasources/:name/object-draftreach the samegenerateObjectDraft. #4249gave those spellings one failure contract and #7955 one request shape. Since #9695 landed
the admin family's guard, one operation answered 401 at one spelling and served
anonymously at the other — a pinned equivalence that was false on
main.remote-tables-twin.equivalence.test.tsnow compares the twins on the admission axisas well as the request-shape axis: both spellings driven with no credential, with a
credential the deployment does not admit, and with one it does. A guard added to one
spelling and not the other fails there, whichever side it is added to.
Verification
Reverse verification. Prediction recorded before running: ablate the two
implementation files back to
origin/main, keep every test — the anonymous halves go red,the entitled halves stay green, 7 tests across 4 files. Observed exactly that:
So 2098 pre-existing tests stayed green under the unfixed state — the measurement of
how this shipped. The 7: three anonymous cases in the new pin, the two new twin-admission
cases, the 401-envelope case, and the discovery probe in
direct-mount-base-follows-apipath.test.ts, which asserted 200 for an anonymous calleron a real plugin boot two lines below the package route's 401 — the asymmetry itself,
written down as an expectation. The pin's two entitled cases passed under ablation, as
predicted: an unguarded route serves a credentialed caller too, which is why a one-sided
pin could not have told this fix from a broken feature.
Restored from the commit (
git restore --source=HEAD), tree byte-identical, and the samesuite is green.
Local gates, union re-derived from the actual changed paths off
git merge-baseafterthe final commit, at
HEAD=df2961b61:pnpm --filter @objectstack/rest test— 2105 passed (129 files)pnpm --filter @objectstack/rest typecheck— cleanpnpm check:route-envelope,check:nul-bytes,check:cross-package-test-inputs,check:dispatcher-error-vocabulary,check:changeset-gate-self-tests,check:objectui-changeset,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:type-check-debt— all passnode scripts/check-adr-0087-registration.mjs,check-changeset-no-major.mjs,check-empty-changeset.mjs,scripts/docs-audit/check-affected-docs.mjs— all passpnpm check:slot-lookup— run explicitly, since the deriver does not know it exists(
scripts/pm/dispatch-gates.mjsdoes not knowcheck:slot-lookupexists, so no dev brief ever derives it — it cost a p0 a CI round-trip today #9721). Holds, none new: threading the resolver that already exists adds noservice lookup to this registrar, which is the other reason it beat resolving identity
inside the registrar.
check:type-check-debtis worth a line, because it did not pass first time.packages/rest's tsconfig excludes*.test.ts, so the package's owntypecheckneverreads its tests and the ratchet is the only thing that does: it measured
@objectstack/restat 156 against a frozen 155. The remedy is the errors, never theledger — the one new error was mine (
ResolveAuthzInput.qlis a required member, and thetwin fixture's resolver omitted it). With
qlstated as absent, the entry measures 155again, and the gate reports
surplus: none — every entry sits exactly at its measurement,so that +1 would have been a CI red.
A changeset is included (
@objectstack/rest, patch). It narrows the accept set andrestores a declared contract, so there is no ADR-0087 conversion to register.
Backlinks: #9391 and PR #9695 (the sibling family, guarded first — this PR follows its
shape), #7744 (added this area's route ledgers without a guard), #9593 (the capability
question for the admin family, deliberately not folded in).
Generated by Claude Code