Skip to content

security(service-datasource): require manage_platform_settings on the datasource-admin routes - #9887

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-9593-datasource-admin-capability
Aug 19, 2026
Merged

security(service-datasource): require manage_platform_settings on the datasource-admin routes#9887
os-warren merged 2 commits into
mainfrom
claude/issue-9593-datasource-admin-capability

Conversation

@os-warren

@os-warrenos-warren commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

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.

⚠️ Cross-package pin sweep — read this before the rest

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, the
listRemoteTables twin-equivalence suite (#4249 / #7955 / #9686). Its fixture caller was
authenticated 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:

  • The fixture is entitled through the platform's own RBAC chain — one fake data
    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. 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.
  • The finding: the external-datasource federation HTTP family mounts outside the enforceAuth seam with no guard of its own — read and write #9686 "WHO may ask" refusal cases are unchanged and still pass — anonymous and
    unrecognised-credential callers are still refused identically on both spellings, because
    the capability check runs after the anonymous decision.
  • A new case pins what is now true rather than deleting the old one: an authenticated
    but unentitled caller is refused 403 PERMISSION_DENIED at the admin spelling and
    served at the federation spelling, which gates on authentication alone. Both halves
    are asserted in full.
  • No packages/rest runtime code is touched. Whether the federation family should
    require 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: registerDatasourceAdminRoutes callers
repo-wide (the twin file, plus packages/cli/src/commands/serve.ts, which is production
wiring and not a pin), and every *.test.ts outside service-datasource matching the
admin family's route literals (api/v1/datasources, datasources/drivers,
remote-tables, object-draft, migrate-credential) — nine files. Eight of the nine are
untouched by this change and were verified as such rather than assumed: two assert a
metadata action's declared target string 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 envelope
pins (a different registrar), one calls the datasource-adminservice method directly
rather than the route (runtime/src/datasource-visibility.test.ts), and
client/src/client.test.ts asserts SDK URL construction with no server. The twin file is
the 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:

  1. The sibling settings namespaces.@objectstack/service-settings's manifests fall
    into two cohorts. The tenant-facing, cosmetic ones — branding, company,
    localization, feature-flags — declare readPermission: 'setup.access' /
    writePermission: 'setup.write'. The platform-infrastructure ones carrying connection
    configuration and credentials — mail, storage, sms, auth, ai, knowledge,
    and packages/objectql's lifecycle namespace — declare manage_platform_settings for
    both. A datasource is a driver plus a DSN plus a bound sys_secret; it is in the
    second cohort by construction.
  2. This service's own Setup nav entry.datasource-admin-plugin.ts contributes
    nav_datasources with requiredPermissions: ['manage_platform_settings']. The console
    door 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.
  3. The capability's own definition.PLATFORM_CAPABILITIES describes 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_access carries
manage_platform_settings in its systemPermissions, so every platform admin already
holds 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/:name returns stored connection
configuration — host, port, database, user, redactedConfigKeys, the hasSecret handle
flag — and GET /:name/remote-tables returns a live introspection of a remote schema. A
lower read capability would publish a deployment's connection topology to every
authenticated tenant user, which is most of what the write gate protects.
GET /drivers is static and could stand lower; it is held at the same line for the reason
the authentication floor gave — a family whose floor has one hole has to be read route by
route.

The refusal code

403PERMISSION_DENIED, through the shared sendError, so
check:route-envelope's zero-hand-written-bodies pin on this module is unchanged.

Deliberately notFORBIDDEN, which the closest sibling registrar
(@objectstack/rest's package-routes.ts) emits: that spelling is a grandfathered
pre-gate synonym under ADR-0112 D3's STANDARD_SYNONYM_WAIVERS, waived for the three
packages 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_DENIED is the standard-catalog member HttpStatusErrorCodeMap names for 403
and needs no per-package ledger registration — the same reason SERVICE_UNAVAILABLE and
RESOURCE_NOT_FOUND already 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 resolveAuthzContext call.
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 a
third posture rather than gaining a parallel file. All eleven routes are now asserted
three times against the same mounted app: anonymous → 401 UNAUTHENTICATED, authenticated
but unentitled → 403 PERMISSION_DENIED (status and code and the capability named
in 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_set row binding the user to a sys_permission_set whose
system_permissions names the capability, aggregated by the platform's own resolver off
the objectql engine. The set is deliberately notadmin_full_access: that set
carries 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:

  • service-datasource pin11 failed | 22 passed (33):
    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.
  • rest twin pin1 failed | 8 passed (9): only the new divergence case goes red
    (expected 200 to be 403), which is what proves that case is driven by the gate rather
    than 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 -- on
the file; git hash-object reads back 164d3b00f9700a106548c11328ee1d674ebfe9cb,
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.jssrc/),
and packages/rest/vitest.config.tsaliases @objectstack/service-datasource to its
src/index.ts
— a deliberate alias whose own comment says a dist there "would report
the 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.ts plus the pinned
both-sides test, as dispatched. Everything else is a consequence of the semantic flip, not
a choice of scope:

  • src/__tests__/admin-routes.test.ts and src/__tests__/envelope.conformance.test.ts
    each 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 entitled
    datasource-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 sweep
    above. Test-only; no runtime code in that package is touched.

No producer-side seam turned up outside packages/services/**: this registrar mounts
straight onto IHttpServer from a plugin init(), so — exactly as #9391 found for the
401 — there is no @objectstack/rest seam in front of it to fix instead.

Verification

At 667c14711:

  • pnpm --filter @objectstack/service-datasource test22 files, 513 tests passed;
    typecheck clean
  • pnpm --filter @objectstack/rest test129 files, 2106 tests passed; typecheck
    clean
  • 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"
  • Union re-derived with node scripts/pm/dispatch-gates.mjs against 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, and
    check:type-check-debt — the last matters here because @objectstack/rest carries a
    TEST_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 on
    an unbuilt one). Plus check-adr-0087-registration / check-changeset-no-major /
    check-empty-changeset.
  • @objectstack/cli build: the CI job log showed an ELIFECYCLE from cli:build
    while the turbo summary named only @objectstack/rest#test as failed. Confirmed rather
    than assumed — on a fresh worktree cli:build fails with TS2307 Cannot find module
    for @objectstack/runtime, service-settings, service-storage and driver-turso,
    the standard unbuilt-closure signature; after pnpm --filter '@objectstack/cli^...' build it exits 0. Not reproduced by this patch and unrelated to it.

Generated by Claude Code

… 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
@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-datasource, touching 7 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via /datasources/:name (route))
  • content/docs/kernel/runtime-services/sharing-service.mdx(via manage_platform_settings (literal))
  • content/docs/permissions/permission-sets.mdx(via setup.access (literal))
  • content/docs/permissions/sharing-rules.mdx(via manage_platform_settings (literal))
  • content/docs/protocol/kernel/config-resolution.mdx(via setup.access (literal), setup.write (literal))
  • content/docs/ui/audience-based-interfaces.mdx(via setup.access (literal))
  • content/docs/ui/setup-app.mdx(via manage_platform_settings (literal), setup.access (literal))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via setup.write (literal))
  • content/docs/releases/v15.mdx(via manage_platform_settings (literal))
  • content/docs/releases/v16.mdx(via manage_platform_settings (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2fb2e3fa40af0d7dc1e5d6f4eb969323dfea9a53packageMentionDocs.

Which tree this was computed on

This run read content/docs from 72c11b25fbb69147c00e139412371bb3651400c8 — the merge of head 667c14711f390123012f1f4ca89deecaa1e125ba into base 2fb2e3fa40af0d7dc1e5d6f4eb969323dfea9a53, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2fb2e3fa40af0d7dc1e5d6f4eb969323dfea9a53 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(service-datasource): datasource-admin routes require a platform-settings capability beyond authentication

2 participants

@os-warren@claude