Uh oh!
There was an error while loading. Please reload this page.
security(service-datasource): require manage_platform_settings on the datasource-admin routes - #9887
Conversation
… datasource-admin routes The datasource-admin HTTP family took any authenticated caller once #9391 landed its anonymous floor. Datasource create/patch/remove/introspect are platform-configuration actions, so all eleven routes now also require the capability the adjacent Setup-admin families gate on. The capability is measured, not minted: service-settings' platform-infrastructure namespaces (mail, storage, sms, auth, ai, knowledge) and objectql's lifecycle namespace all declare manage_platform_settings for reads AND writes, while the cohort that splits setup.access/setup.write is the tenant-cosmetic one. This service's own Setup nav entry already declared requiredPermissions: ['manage_platform_settings'] on the console door in front of these routes, so the change makes declared equal enforced. Refusal is the standard-catalog 403 PERMISSION_DENIED through the shared sendError, not the grandfathered FORBIDDEN synonym whose ADR-0112 waiver covers three other packages and does not endorse the spelling for new code. The both-sides-on-one-boot pin grows a third posture (entitled succeeds, authenticated-but-unentitled is refused, anonymous stays refused), and the two suites for which an entitled caller is the premise share its fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 0 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 72c11b25fbb69147c00e139412371bb3651400c8 && git checkout 72c11b25fbb69147c00e139412371bb3651400c8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2fb2e3fa40af0d7dc1e5d6f4eb969323dfea9a53 667c14711f390123012f1f4ca89deecaa1e125ba && git checkout -B drift-repro 2fb2e3fa40af0d7dc1e5d6f4eb969323dfea9a53 && git merge --no-ff 667c14711f390123012f1f4ca89deecaa1e125ba
node scripts/docs-audit/affected-docs.mjs --json 2fb2e3fa40af0d7dc1e5d6f4eb969323dfea9a53
|
… capability divergence The datasource-admin capability gate flips a public admission semantic, and the twin-equivalence suite one package over was the consumer pin asserting the old one: its fixture caller was authenticated but unentitled, so five request-shape cases compared a 200 against the new 403 and read an admission difference as a request-shape divergence. The fixture now resolves grants through the platform's own RBAC chain — one fake engine wired into BOTH spellings, the admin registrar's `objectql` lookup and the resolveAuthzContext call behind the federation registrar's resolveExecutionContext, so the twins still read one identity and now one grant aggregation. The #9686 "WHO may ask" block keeps both refusal cases unchanged (anonymous and unrecognised credential still refuse identically) and gains a case pinning what is now true: an authenticated but unentitled caller is refused 403 PERMISSION_DENIED at the admin spelling and served at the federation spelling. That asymmetry is filed as #9901, not accepted; the case is labelled a record of a known gap and is expected to fail when the gap closes. No packages/rest runtime code is touched — gating the federation family is a separate decision and a different lane. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9593
Tightens the datasource-admin HTTP family from "any authenticated user" to the
platform-configuration capability the adjacent Setup-admin families already gate on.
The authentication floor #9391 landed is unchanged; this adds the capability half its
body deliberately split out.
This PR changes a public admission semantic, so the obligation that follows is
flipping every pin that asserted the old one. One consumer pin existed and it is in
another package:
packages/rest/src/remote-tables-twin.equivalence.test.ts, thelistRemoteTablestwin-equivalence suite (#4249 / #7955 / #9686). Its fixture caller wasauthenticated but unentitled, so after the gate landed five request-shape cases were
comparing a 200 against the new 403 — reading an admission difference as a request
shape divergence, which is the one thing that file exists not to confuse. CI caught it
as
Test Core (3/3)red; it is our coupling, not flake.What changed there, and what deliberately did not:
engine wired into both spellings (the admin registrar's
objectqllookup and theresolveAuthzContextcall behind the federation registrar'sresolveExecutionContext),so the twins still read one identity and now one grant aggregation. A fixture that
hand-rolled a second notion of "entitled" on one side could make the twins agree by
construction, which that file must never do.
unrecognised-credential callers are still refused identically on both spellings, because
the capability check runs after the anonymous decision.
but unentitled caller is refused
403 PERMISSION_DENIEDat the admin spelling andserved at the federation spelling, which gates on authentication alone. Both halves
are asserted in full.
packages/restruntime code is touched. Whether the federation family shouldrequire a capability is a separate decision in a different lane — measurement at this
head shows it deliberately does not, and its own guard doc says so. That asymmetry is
filed as security(rest): the external-datasource federation family still admits any authenticated caller, while its declared admin twin now requires a capability #9901, not accepted, and the new case is labelled a record of a known gap
that is expected to fail when the gap closes.
Sweep scope, so the negative result is checkable:
registerDatasourceAdminRoutescallersrepo-wide (the twin file, plus
packages/cli/src/commands/serve.ts, which is productionwiring and not a pin), and every
*.test.tsoutsideservice-datasourcematching theadmin family's route literals (
api/v1/datasources,datasources/drivers,remote-tables,object-draft,migrate-credential) — nine files. Eight of the nine areuntouched by this change and were verified as such rather than assumed: two assert a
metadata action's declared
targetstring with no HTTP driven(
metadata-service.test.ts,metadata-type-actions.test.ts), three assert discovery /route-table entries for the federation spelling
(
direct-mount-base-follows-apipath,discovery-advertised-direct-mounts.parity,direct-mount-introspection), two are the federation family's own guard and envelopepins (a different registrar), one calls the
datasource-adminservice method directlyrather than the route (
runtime/src/datasource-visibility.test.ts), andclient/src/client.test.tsasserts SDK URL construction with no server. The twin file isthe only consumer pin.
Which capability, and why it was measured rather than chosen
manage_platform_settings, from three independent readings of the tree that agree:@objectstack/service-settings's manifests fallinto two cohorts. The tenant-facing, cosmetic ones —
branding,company,localization,feature-flags— declarereadPermission: 'setup.access'/writePermission: 'setup.write'. The platform-infrastructure ones carrying connectionconfiguration and credentials —
mail,storage,sms,auth,ai,knowledge,and
packages/objectql's lifecycle namespace — declaremanage_platform_settingsforboth. A datasource is a driver plus a DSN plus a bound
sys_secret; it is in thesecond cohort by construction.
datasource-admin-plugin.tscontributesnav_datasourceswithrequiredPermissions: ['manage_platform_settings']. The consoledoor was already gated at this capability while the HTTP door behind it took any
authenticated caller — declared-but-unenforced (Prime Directive chore: version packages #10), with the
declaration and the gap in one package. This makes declared equal enforced, and takes
away no console anyone could already reach.
PLATFORM_CAPABILITIESdescribes it as"Configure global platform settings (mail, storage, AI, licensing, …) and platform-only
Setup pages" — the class this family is in, named in the registry rather than inferred.
Nothing new is minted, and there is no platform-admin arm:
admin_full_accesscarriesmanage_platform_settingsin itssystemPermissions, so every platform admin alreadyholds it, and a deployment stays free to grant it to an operator who is not a full admin.
No read/write split, and that is also a measurement
The card delegated the split to measurement and the measurement says no. The cohort that
splits is the cosmetic one; the credential-bearing cohort does not, and its reads are the
reason. A read here is not a name list:
GET /datasources/:namereturns stored connectionconfiguration — host, port, database, user,
redactedConfigKeys, thehasSecrethandleflag — and
GET /:name/remote-tablesreturns a live introspection of a remote schema. Alower read capability would publish a deployment's connection topology to every
authenticated tenant user, which is most of what the write gate protects.
GET /driversis static and could stand lower; it is held at the same line for the reasonthe authentication floor gave — a family whose floor has one hole has to be read route by
route.
The refusal code
403PERMISSION_DENIED, through the sharedsendError, socheck:route-envelope's zero-hand-written-bodies pin on this module is unchanged.Deliberately not
FORBIDDEN, which the closest sibling registrar(
@objectstack/rest'spackage-routes.ts) emits: that spelling is a grandfatheredpre-gate synonym under ADR-0112 D3's
STANDARD_SYNONYM_WAIVERS, waived for the threepackages already putting it on the wire, and the waiver schema's own words are that it
"keeps a WIRE VALUE registered; it does not endorse the spelling for new code".
PERMISSION_DENIEDis the standard-catalog memberHttpStatusErrorCodeMapnames for 403and needs no per-package ledger registration — the same reason
SERVICE_UNAVAILABLEandRESOURCE_NOT_FOUNDalready appear in this module while@objectstack/service-datasource's ledger entry lists only its own two registered codes.One guard, one resolution
The identity and the held capabilities come out of the same
resolveAuthzContextcall.Splitting this into an authentication guard followed by a capability guard would resolve
one request twice, and two resolutions can disagree — the second is a fresh set of
sys_*reads against a store another request may have written in between. Order is fixed:
anonymous first, so a caller with no identity is told it has no identity rather than that
its empty capability set is insufficient.
Pinning
admin-routes-auth-guard.test.ts— the #9391/#9695 both-sides-on-one-boot pin — grows athird posture rather than gaining a parallel file. All eleven routes are now asserted
three times against the same mounted app: anonymous →
401 UNAUTHENTICATED, authenticatedbut unentitled →
403 PERMISSION_DENIED(status and code and the capability namedin the message, per the ADR-0112 rejection-case rule), entitled → the route's own success
status. 33 cases, all green.
The entitlement is delivered the way a deployment delivers it — a
sys_user_permission_setrow binding the user to asys_permission_setwhosesystem_permissionsnames the capability, aggregated by the platform's own resolver offthe
objectqlengine. The set is deliberately notadmin_full_access: that setcarries the capability among six others, so granting it would leave a gate keyed on
platform-admin posture passing this suite unchanged. The twin fixture one package over
makes the same choice for the same reason.
Ablation, both legs
Removed the capability arm from the guard (fix committed first, so restoration had a real
restore point) and re-ran both pins:
AssertionError: expected 200 to be 403,expected 201 to be 403,expected 204 to be 403, one failure per route, with the anonymous and entitled postures staying green.(
expected 200 to be 403), which is what proves that case is driven by the gate ratherthan by some accident of the twin fixture's wiring.
Both signatures are the predicted ones for removing only the capability half, and both
are distinguishable from removing the whole guard. Restored with
git checkout HEAD --onthe file;
git hash-objectreads back164d3b00f9700a106548c11328ee1d674ebfe9cb,identical to the pre-ablation blob, and both pins return to green.
Neither leg needed a rebuild, and both resolution paths were checked rather than assumed:
the service-datasource pin imports the subject relatively (
../admin-routes.js→src/),and
packages/rest/vitest.config.tsaliases@objectstack/service-datasourceto itssrc/index.ts— a deliberate alias whose own comment says adistthere "would reportthe pre-fix admin route as agreeing". Both mutations reaching the runtime is demonstrated
by the reds themselves.
Declared file surface, and its expansions
Landing is
packages/services/service-datasource/src/admin-routes.tsplus the pinnedboth-sides test, as dispatched. Everything else is a consequence of the semantic flip, not
a choice of scope:
src/__tests__/admin-routes.test.tsandsrc/__tests__/envelope.conformance.test.tseach mount the family with an authenticated caller as their premise — routing and
failure-attribution in one, envelope conformance in the other. Both would answer 403 on
every case after this change, measuring the new guard instead of what they exist to
measure. Their fixtures are entitled, and nothing else in them is touched.
src/__tests__/entitled-caller.fixture.ts(new) holds the one definition of "an entitleddatasource-admin caller" that the pin and those two suites share, rather than three
copies of a four-table RBAC chain that would drift apart on the next resolver change.
packages/rest/src/remote-tables-twin.equivalence.test.ts— the cross-package pin sweepabove. Test-only; no runtime code in that package is touched.
No producer-side seam turned up outside
packages/services/**: this registrar mountsstraight onto
IHttpServerfrom a plugininit(), so — exactly as #9391 found for the401 — there is no
@objectstack/restseam in front of it to fix instead.Verification
At
667c14711:pnpm --filter @objectstack/service-datasource test— 22 files, 513 tests passed;typecheckcleanpnpm --filter @objectstack/rest test— 129 files, 2106 tests passed;typecheckclean
pnpm check:route-envelope— "11 module(s) audited … 8 conformant, 0 ratcheted, 3 exempt"pnpm check:test-source-alias— "OK — 72 packages with tests scanned"pnpm check:type-source-resolution— "OK — 76 packages with a tsconfig.json scanned"node scripts/docs-audit/check-affected-docs.mjs— "self-test: 242 cases pass"node scripts/pm/dispatch-gates.mjsagainst the expanded diff.The rest file pulled in two gates the first derivation never named, and both were run:
check:cross-package-test-inputs("12 package(s) read outside themselves, all declared")and
check:dispatcher-error-vocabulary("290 registered codes … 17 classified").Also green:
check:changeset-gate-self-tests,check:objectui-changeset,check:query-options-erasure,check:engine-double-contract,check:where-matcher(both re-run because the diff adds test code),
check:type-check-coverage, andcheck:type-check-debt— the last matters here because@objectstack/restcarries aTEST_DEBT ledger entry, so a test edit in that package can move the ratchet: "33 ledger
entr(ies) re-measured in 362.4s, 1926 raw tsc error(s) total, none above its recorded
number", run on a closure built with
turbo run build(the ratchet refuses outright onan unbuilt one). Plus
check-adr-0087-registration/check-changeset-no-major/check-empty-changeset.@objectstack/clibuild: the CI job log showed anELIFECYCLEfromcli:buildwhile the turbo summary named only
@objectstack/rest#testas failed. Confirmed ratherthan assumed — on a fresh worktree
cli:buildfails withTS2307 Cannot find modulefor
@objectstack/runtime,service-settings,service-storageanddriver-turso,the standard unbuilt-closure signature; after
pnpm --filter '@objectstack/cli^...' buildit exits 0. Not reproduced by this patch and unrelated to it.Generated by Claude Code