Uh oh!
There was an error while loading. Please reload this page.
security(rest): require a capability on the external-datasource federation family - #10254
Conversation
… known-gap row Refs #9901
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse 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 5d50dfb4918acc571ee273fdb0b380683d50535e && git checkout 5d50dfb4918acc571ee273fdb0b380683d50535e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 318f96ae5cc25d04a2650a6ef6d4c2b930619b96 f83ec2cff8b022274e7956e7dd301ee63bad470a && git checkout -B drift-repro 318f96ae5cc25d04a2650a6ef6d4c2b930619b96 && git merge --no-ff f83ec2cff8b022274e7956e7dd301ee63bad470a
node scripts/docs-audit/affected-docs.mjs --json 318f96ae5cc25d04a2650a6ef6d4c2b930619b96
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9901
Maintainer ruling, 2026-08-20 (issue comment
5353919655), verbatim: 「其他接受你的建议。」 — the external-datasource federation family is not deliberately the lower-privilege door, and converges with its declared admin twin on the capability axis.What changed
packages/rest/src/external-datasource-routes.tsgated on authentication alone (#9686, which ruled the capability question out of its own scope and pointed it here). It now applies a capability above that floor, from the same singleresolveExecutionContextcall:GET /:name/external/tablesdatasources.external.listTablesmanage_platform_settingsPOST /:name/external/tables/:remote/draftdatasources.external.draftmanage_platform_settingsPOST /:name/external/tables/:remote/importdatasources.external.importmanage_metadataPOST /:name/external/refresh-catalogdatasources.external.refreshCatalogmanage_metadataPOST /:name/external/validatedatasources.external.validateOne resolution, two decisions, anonymous first — the shape the declared admin twin took, for the same reason: two resolutions of one request can disagree, because the second is a fresh set of
sys_*reads.The refusal is
403 PERMISSION_DENIED, not theFORBIDDENthe siblingpackage-routes.tsemits.FORBIDDENis a grandfathered pre-gate synonym under ADR-0112 D3'sSTANDARD_SYNONYM_WAIVERS, whose own schema says it "keeps a WIRE VALUE registered; it does not endorse the spelling for new code" — andPERMISSION_DENIEDis what the admin twin answers, which the twin-equivalence suite now compares directly rather than checking each side against a literal.isSystemis deliberately not a capability bypass: it is read for the anonymous decision only (shouldDenyAnonymousreads it), exactly as the admin twin does. SinceisSystemis never resolved from inbound HTTP, anisSystemarm would be a second policy no wire caller could take — unfalsifiable divergence from the twin.The known-gap case was folded back, not deleted
remote-tables-twin.equivalence.test.tscarried a case added by #9887 recording the divergence this ruling settles, with a block-header instruction to fold the row back into the agreement when the federation spelling grew its gate. That is what happened: the middle row of the admission axis (authenticated but UNENTITLED) is now asserted as an equivalence — both spellings'statusandcodecompared to each other — alongside the three rows that already agreed. It keeps its own case rather than merging into the anonymous one, because401and403are different facts about a caller and a single "both refuse" case would be satisfied by either.A second case was added there: a caller holding a real but irrelevant permission set (
manage_org_users, a declared capability from the spec catalog). Without it, "unentitled" in that file only ever meant "holds no grant at all", and a gate asking merely whether the caller holds anything would pass every case.POST /external/validate— named, not silently includedThe ruling enumerates four routes.
validateis the fifth: no admin twin, creates no metadata, and not one of the four. It keeps the #9686 authentication floor and says so through its own explicit'authenticated'kind rather than inheriting a neighbour's gate — an un-ruled route quietly acquiring a capability is a change nobody decided. Pinned as such, and filed separately for triage rather than decided here.Pin sweep (whole repo, one pass)
Changed — all assert the substance of the new semantics (unentitled ⇒ refused, asserting
codeandstatus; entitled ⇒ still served):external-datasource-routes-auth-guard.test.ts— five new cases: unentitled refused403 PERMISSION_DENIEDon all four ruled routes with the service never reached (so the refusal provably precedes the two writes); both directions of the read/write split (manage_platform_settingsalone clears reads and is refused writes, and the reverse — only the crossed pair can tell "two capabilities" from "one capability spelled twice");validatestill served to a caller holding nothing; an unentitled api-key holder refused, so the gate cannot be read off key scopes instead ofsystemPermissions. The fixture engine grewsys_user_permission_set/sys_permission_setrows so grants come off the real resolution path.remote-tables-twin.equivalence.test.ts— the fold-back described above.external-datasource-envelope.conformance.test.ts— itsCREDENTIALEDstub now holds both capabilities. Its subject is the envelope of the success/400/503 arms; without the grants every case would silently become a reading of the 403 body.discovery-advertised-direct-mounts.parity.test.ts—manage_platform_settingsjoins the stub's set for the same reason.Found and deliberately not changed:
direct-mount-base-follows-apipath.test.ts— drives…/external/tableswith no credential and expects401. The anonymous floor is unchanged, so this pin is still true and still measures base placement. Kept verbatim — it is a refusal assertion for a genuinely illegal shape.external-datasource-routes-auth-guard.test.ts— likewise kept verbatim.direct-mount-introspection.test.ts— compares the returned route array against the registration calls; never drives a handler.packages/client/src/client.test.ts(datasources.external.*URL pins) andservice-route-ledger-coverage.test.ts— mockfetchand assert the URL the SDK builds. The client is unchanged; a server-side gate is invisible to them.packages/rest/src/rest-route-ledger.tsandservice-datasource/src/datasource-route-ledger.ts— record route/family/disposition, carry no capability column. (rest-route-ledger.tsis also under the serial fence for in-flight fix(rest,client): the /meta FSM state route is singular — retire the plural registration (#10077) #10177 and was not touched.)packages/cli/src/commands/datasource/{list-tables,introspect,validate}.ts— rawfetchcallers that surface whatever the server answers; behaviour changes for unentitled operators, no code does.scripts/check-route-envelope.mjs— its ratchet for this module is{ responses: 0, ok: 0, err: 0 }and stays at zero: the new refusal goes through the sharedsendError, so the module still builds no body itself.packages/services/service-datasource/src/admin-routes.tsand its pins — the admin half already measured this in security(service-datasource): datasource-admin routes require a platform-settings capability beyond authentication #9593; this card converges the other spelling onto it and leaves it alone.Published-surface note
datasources.external.*is SDK surface, so this is a behaviour change for existing credentialed callers. The changeset states the requirement plainly, per route, with the migration (grant the capability the route needs;admin_full_accessalready carries both).⛔ Not for the merge queue
This card carries
needs:contract-review(Clause-② yes — the ruling says so in as many words). Draft only; not ready, no auto-merge, label untouched. Green CI does not change that.Verification
Gates were run at
f83ec2cf(final commit; working tree clean,git status --porcelainempty). Ablation legs and their rebuild reasoning are in the dev report on #9901.Generated by Claude Code