Skip to content

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

Description

@os-support-ai

Blocked-by: #9391

Commissioned by maintainer ruling (2026-08-18, live PM chat with the triage seat, session session_01EUZUjvnLsqwHVkXkTv8TKQ; verbatim: 「其他接受你的建议」, applying the recommendation on #9391's flagged judgement call). This is the capability half #9391's own body deliberately split out so it would not block the p0 authentication guard.

Scope

Once #9391's authentication guard lands, tighten the datasource-admin HTTP family (/api/v1/datasources… — list, read, driver catalog, remote-table introspection, create, patch, remove) from "any authenticated user" to a platform-configuration capability (manage_platform_settings or the nearest existing capability the sibling Setup-admin families already use — the implementer measures which capability the adjacent admin families gate on and matches it rather than minting a new one). Creating, patching, deleting and introspecting datasources are platform-configuration actions; per-tenant users have no business on them in a multi-user deployment.

The write-vs-read split (whether read-only datasource listing stays at a lower capability) is the implementer's measurement against the sibling families' precedent — match, don't invent.

Pinning

Extend #9391's both-sides-on-one-boot test: entitled caller succeeds, authenticated-but-unentitled caller is refused with the standard capability refusal, anonymous stays refused. ⛔ Same disclosure discipline as #9391: no reproduction recipes in this card, the PR body, or commit messages.

Sequencing (why blocked)

#9391's guard is release-blocking (target:v17) and must not wait on this; this card re-verifies its own premise against the landed guard's shape before dispatch.

Refs

#9391 (the p0 authentication half and its "judgement call" paragraph) · #7744 (route-ledger history).

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    CollaboratorAuthor

    Unlock scan (triage seat, session session_01NYoiJGd1MxXdcvM4EGAMrS): upstream #9391 closed 16:49Z via merged PR #9695 — pm:blocked → pm:queue.

    Premise re-verified on the merged ref, not the card's text: origin/main:packages/services/service-datasource/src/admin-routes.ts now carries requireAuthenticated on every handler (lines 269, 389, 404, …), and no capability check exists in the file — so this card's scope (tighten from "any authenticated user" to the sibling Setup-admin families' platform-configuration capability, measured not minted) is intact. The claiming seat still owes the standard claim-time re-read: PR #9695 rewrote this exact file, so derive line anchors fresh, and extend its both-sides-on-one-boot pin rather than writing a parallel one. Disclosure discipline from #9391 carries over: no reproduction recipes anywhere public.


    Generated by Claude Code

  2. self-assigned this
    on Aug 19, 2026
  3. os-warren commented on Aug 19, 2026

    @os-warren
    Collaborator

    Claim: PM loop round 1
    Session: session_01PnJHU45vPJj5UQrxe946Bx
    Branch: claude/issue-9593-datasource-admin-capability
    Worktree: objectstack-issue-9593
    Domain: domain:services
    File surface: packages/services/service-datasource/src/admin-routes.ts + the #9391/#9695 both-sides boot test (extended, not paralleled); possibly a shared capability-guard helper within service-datasource; stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus — tier per this round's node scripts/pm/dispatch-gates.mjs --tier packages/services/service-datasource/src/admin-routes.ts output: "no path-derived mandate — the tier stays the PM's per-card judgment call"; PM sets default judgment tier (opus): matching the sibling admin families' capability precedent is a measurement-plus-judgment task on a security boundary.
    Clause-②: no — narrows a runtime authorization surface back to the platform-configuration posture the maintainer ruled for (2026-08-18, applying #9391's flagged recommendation); no spec contract accept/reject change, nothing widened.
    Serial constraints cleared: upstream #9391 CLOSED via merged PR #9695 (unlock scan re-verified premise on the merged ref 2026-08-18 17:24Z); admin-routes.ts has no open claim or in-flight PR. Same-day churn: #9695 rewrote this exact file — dispatch prompt orders fresh line anchors from current origin/main.


    Generated by Claude Code

  4. os-warren commented on Aug 19, 2026

    @os-warren
    Collaborator
    {
      "issue": 9593,
      "status": "done",
      "branch": "claude/issue-9593-datasource-admin-capability",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9887",
      "premise_still_valid": true,
      "summary": "Re-verified on origin/main at merge base 55d29935b: admin-routes.ts carried requireAuthenticated on all eleven handlers and no capability check, with an explicit comment deferring this card. All eleven routes now also require `manage_platform_settings`, refusing an authenticated-but-unentitled caller with 403 PERMISSION_DENIED through the shared sendError, resolved from the same single resolveAuthzContext call that already makes the anonymous decision. Capability matched, not minted: service-settings' platform-infrastructure manifests (mail/storage/sms/auth/ai/knowledge) plus objectql's lifecycle namespace declare it for reads AND writes, this plugin's own Setup nav entry already declared requiredPermissions: ['manage_platform_settings'] on the console door in front of these routes, and the PLATFORM_CAPABILITIES description names this class. No read/write split: the cohort that splits setup.access/setup.write is the tenant-cosmetic one (branding/company/localization/feature-flags), and datasource reads return stored connection config plus live remote-schema introspection. Code is the standard-catalog PERMISSION_DENIED rather than FORBIDDEN, whose ADR-0112 D3 waiver covers three other packages and states it does not endorse the spelling for new code.",
      "tests": "All at HEAD a1c1cc615 (tree clean, dispatch-gates confirmed 'committed 6, working tree 0, untracked 0'). Package: `pnpm --filter @objectstack/service-datasource test` -> 'Test Files 22 passed (22) / Tests 513 passed (513)'; `typecheck` -> tsc --noEmit clean (exit captured by redirect, not through a pipe). Pin alone: 'Test Files 1 passed (1) / Tests 33 passed (33)' = 11 routes x 3 postures. ABLATION (fix committed first; no rebuild on its resolution path and none needed - the pin imports ../admin-routes.js relatively into src/, and this package's vitest config already aliases @objectstack/core to source, so vitest reads the mutated source directly; the red itself demonstrates the mutation reached the runtime): capability arm removed -> 'Tests 11 failed | 22 passed (33)' with 'AssertionError: expected 200 to be 403', 'expected 201 to be 403', 'expected 204 to be 403', anonymous and entitled postures staying green - the predicted signature for removing only the capability half. Restored via `git checkout HEAD --` on the file; git hash-object reads back 164d3b00f9700a106548c11328ee1d674ebfe9cb, identical to the pre-ablation blob, porcelain clean, marker absent, pin back to 33 passed. PM-named gates, all exit 0: check:route-envelope ('11 module(s) audited, 161 hand-built body/bodies ... 8 conformant, 0 ratcheted, 3 exempt'), check:test-source-alias ('OK - 72 packages with tests scanned'), check:type-source-resolution ('OK - 76 packages with a tsconfig.json scanned'), scripts/docs-audit/check-affected-docs.mjs ('affected-docs self-test: 242 cases pass'). Union re-derived with `node scripts/pm/dispatch-gates.mjs` (no paths passed; it took 6 paths from the merge base itself) and everything it added over the dispatched list was run, all exit 0: check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure ('ratchet holds ... none new'), check:engine-double-contract ('OK - 321 pinned, 133 in the DEBT ledger, 2 exempt'), check:where-matcher ('258 matcher(s) ... 0 silently-wrong ... none new'), check:type-check-coverage ('OK - 64/77 workspace packages type-checked'), check:type-check-debt ('33 ledger entr(ies) re-measured in 356.0s, 1926 raw tsc error(s) total, none above its recorded number') - the last refused first with 'cannot run: 37 workspace dependenc(ies) ... have no built type entry point on disk', so the closure was built (`turbo run build --filter=./packages/* --filter=./packages/*/*`, 70/70 successful) and it was re-run to a real measurement rather than recorded as skipped; plus check-adr-0087-registration, check-changeset-no-major, check-empty-changeset. Heavy steps ran under flock on /tmp/os-heavy-verify.lock; one queue timeout (exit 99) was retried in-turn, never waited on across a turn. CI on the PR itself is in_progress - not waited on, per the draft-PR delivery rule.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Deviations and notes for the PM

    • File-surface expansion, inside service-datasource, forced not chosen. Two sibling
      suites in the same package mount this family with an authenticated caller as their
      premise — src/__tests__/admin-routes.test.ts (routing + failure attribution) and
      src/__tests__/envelope.conformance.test.ts (envelope conformance). Both would answer
      403 on every case after this change and would have started measuring the new guard
      instead of what they exist to measure. Their fixtures are entitled and nothing else in
      them is touched. The entitlement itself is a new file,
      src/__tests__/entitled-caller.fixture.ts, holding one definition of "an entitled
      datasource-admin caller" for the pin and both premise suites, rather than three copies
      of a four-table RBAC chain that would drift apart on the next resolver change. No file
      outside packages/services/service-datasource/** is touched.
    • No producer-side seam to fix instead. The file-surface clause asked whether the real
      gating producer lives elsewhere (an @objectstack/rest seam). It does not: this
      registrar mounts straight onto IHttpServer from a plugin init(), which is the same
      reason The datasource-admin HTTP family is reachable unauthenticated, read and write — mounts outside the enforceAuth seam #9391 had to put the 401 here. Nothing consumer-side was widened or worked around.
    • Guard renamed requireAuthenticated → requireDatasourceAdmin at all eleven call
      sites — a guard that also requires a capability cannot keep the narrower name.
    • @objectstack/service-datasource is publishable (private not set), so the
      user-visible change carries a patch changeset. The skip-changeset label is therefore
      not in play for this PR.
    • Route ledger left alone. datasource-route-ledger.ts's header says every row is
      "reachable only by an authenticated caller" — still true after this change (authentication
      remains necessary, just no longer sufficient), so it is an understatement rather than a
      falsehood and did not warrant crossing into a third file. Flagging it in case the PM
      wants the prose sharpened in a follow-up.

    Generated by Claude Code


    Generated by Claude Code

  5. os-warren commented on Aug 19, 2026

    @os-warren
    Collaborator

    ACCEPT — reviewer of record: PM session session_01PnJHU45vPJj5UQrxe946Bx, round 1. PR: #9887 (draft, CI converging — will flip ready + enqueue on green gates; readings below are my own against GitHub, not the report's self-description).

    Checklist conclusions:

    • PR form: draft ✓, base main ✓, first line Fixes #9593 ✓; 6 changed files, all inside packages/services/service-datasource/** + one patch changeset — file-surface expansion (two premise suites + shared entitled-caller.fixture.ts) is forced by the guard change and declared in both PR body and report ✓.
    • Ruling compliance: capability matched (manage_platform_settings), not minted — three independent measurements converge (sibling platform-infrastructure settings namespaces, this plugin's own Setup nav requiredPermissions, PLATFORM_CAPABILITIES registry description). No read/write split, with the measured rationale (credential-bearing cohort splits nowhere; datasource reads expose connection topology + live remote-schema introspection). Spot-checked the diff: guard runs before resolve() and any handler body on all 11 routes; one resolveAuthzContext resolution, two decisions, anonymous-first ordering.
    • Rejection pins: status AND code (403 PERMISSION_DENIED, standard catalog — deliberately not the ADR-0112 D3-waived FORBIDDEN) AND the capability named in the message; dispatch-not-called asserted. Entitled fixture deliberately not admin_full_access — a posture-keyed gate cannot pass the suite. ✓
    • Ablation (lane standing clause): differential signature 11 failed | 22 passed with anonymous+entitled staying green — distinguishable from whole-guard removal; restore verified by git hash-object blob identity. ✓
    • Disclosure discipline: no reproduction recipes in PR body, commits, or test names. ✓
    • Path fork: no governed paths, no packages/spec/**; Clause-② declared no and path limb not hit ⇒ normal ready→queue path once green.

    Deviations accepted: guard rename requireAuthenticated → requireDatasourceAdmin (necessary); route-ledger prose now an understatement — noted, not worth a third-file rider (may be folded into any future ledger touch).

    Landing plan: verify the gate-family jobs read completed: success by name on head a1c1cc615, then flip ready and enqueue via the merge queue.


    Generated by Claude Code

  6. os-warren commented on Aug 19, 2026

    @os-warren
    Collaborator

    Supersedes the report above (patch round: CI Test Core (3/3) red on a1c1cc615 — our coupling, now fixed at 667c14711).

    {
      "issue": 9593,
      "status": "done",
      "branch": "claude/issue-9593-datasource-admin-capability",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9887",
      "premise_still_valid": true,
      "summary": "Unchanged from the first round in substance: all eleven datasource-admin routes now require `manage_platform_settings` (measured against the sibling Setup-admin families, not minted; no read/write split, matching the credential-bearing cohort), refusing an authenticated-but-unentitled caller 403 PERMISSION_DENIED. The patch round adds the cross-package pin sweep the semantic flip obliged: `packages/rest/src/remote-tables-twin.equivalence.test.ts` was the one consumer pin asserting the old 'authenticated is enough' admission, and its unentitled fixture made five request-shape cases compare a 200 against the new 403. Its fixture is now entitled through the platform's own RBAC chain, with ONE fake engine wired into both spellings so the twins still read one identity and now one grant aggregation. The #9686 refusal cases are unchanged and still pass; a new case pins what is now true — admin spelling refuses 403 PERMISSION_DENIED, federation spelling serves — because measurement shows the federation family gates on authentication alone (its own guard doc defers the capability question to this card). No packages/rest runtime code touched: gating that family is a separate decision, filed as #9901.",
      "tests": "All at HEAD 667c14711 (tree clean). Suites: `pnpm --filter @objectstack/rest test` -> 'Test Files 129 passed (129) / Tests 2106 passed (2106)'; `pnpm --filter @objectstack/service-datasource test` -> 'Test Files 22 passed (22) / Tests 513 passed (513)'; both packages' `typecheck` clean (exits captured by redirect, never through a pipe). Twin pin alone: 'Test Files 1 passed (1) / Tests 9 passed (9)'. ABLATION, BOTH LEGS, from the committed state, neither needing a rebuild and both resolution paths checked rather than assumed (service-datasource pin imports ../admin-routes.js relatively into src/; packages/rest/vitest.config.ts ALIASES @objectstack/service-datasource to its src/index.ts, an alias whose own comment says a dist there 'would report the pre-fix admin route as agreeing'): capability arm removed -> service-datasource pin 'Tests 11 failed | 22 passed (33)' ('expected 200 to be 403', 'expected 201 to be 403', 'expected 204 to be 403'), rest twin pin 'Tests 1 failed | 8 passed (9)' ('expected 200 to be 403') with ONLY the new divergence case red — which is what proves that case is driven by the gate and not by twin-fixture wiring. Restored via `git checkout HEAD --` on the file; git hash-object reads back 164d3b00f9700a106548c11328ee1d674ebfe9cb, identical to the pre-ablation blob; both pins back to green. GATES re-derived with `node scripts/pm/dispatch-gates.mjs` against the EXPANDED diff (no paths passed; it took 7 paths from the merge base). The rest file pulled in two gates the first derivation never named, both run and green: check:cross-package-test-inputs ('12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob') and check:dispatcher-error-vocabulary ('290 registered codes (240 ledger + 50 standard); 17 unregistered code-stamping site(s) found; 17 classified'). Re-run and green: check:route-envelope, check:test-source-alias, check:type-source-resolution, check-affected-docs, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset. check:type-check-debt matters specifically this round because @objectstack/rest CARRIES A TEST_DEBT LEDGER ENTRY (155 errors, scripts/check-type-check-coverage.mjs:703), so a test edit there 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 (70/70 successful), since it refuses outright on an unbuilt one. CLI BUILD confirmed rather than assumed: on a fresh worktree `pnpm --filter @objectstack/cli build` fails with TS2307 'Cannot find module' for @objectstack/runtime, service-settings, service-storage and driver-turso — the unbuilt-closure signature; after `pnpm --filter '@objectstack/cli^...' build` it exits 0. Not reproduced by this patch. Heavy steps under flock on /tmp/os-heavy-verify.lock, queue timeouts (99) retried in-turn. CI on the PR is in_progress and not waited on.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9901: the external-datasource federation family (packages/rest) still admits any authenticated caller while its declared admin twin now requires a capability — same operation, same service slot, two doors, one gate; includes three routes with no admin twin, two of which write"
      ]
    }

    Patch-round notes for the PM


    Generated by Claude Code


    Generated by Claude Code

  7. os-warren commented on Aug 19, 2026

    @os-warren
    Collaborator

    ACCEPT — patch round verified (supplements the ACCEPT above; reviewer session session_01PnJHU45vPJj5UQrxe946Bx). Head moved a1c1cc615 → 667c14711; all gate readings for the ready-flip will be taken on the new head.

    What the patch round was for: Test Core (3/3) red — the cross-package pin sweep the semantic flip obliged (my dispatch omitted the sweep clause; recorded as a PM miss). Verified against the actual diff:

    • Expanded file set is exactly +1 file: packages/rest/src/remote-tables-twin.equivalence.test.ts (+154/−11) — zero packages/rest runtime code, as instructed. The twin fixture is now entitled through the platform's own RBAC chain with one shared grants engine wired into both spellings.
    • The finding: the external-datasource federation HTTP family mounts outside the enforceAuth seam with no guard of its own — read and write #9686 anonymous-refusal cases are untouched; a new divergence case pins what is now true (admin spelling 403 PERMISSION_DENIED, federation spelling serves), explicitly labelled a record of a known gap with a fold-back instruction for the fix.
    • Measurement behind it: the federation registrar applies shouldDenyAnonymous and nothing else, by its own documented deferral to this card. The governance asymmetry — same operation, two doors, one gate, plus three federation-only routes (two of them writes) on authentication-only footing — 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, unassigned/unqueued for triage, with the The datasource-admin HTTP family is reachable unauthenticated, read and write — mounts outside the enforceAuth seam #9391 disclosure posture inherited and the three genuinely-open questions (which capability; published datasources.external.* SDK callers; whether the spellings should converge at all) stated rather than answered. Correctly a decision card, not a mechanical copy.
    • Ablation re-run from the committed state, both legs, with the differential signature (service-datasource pin 11/33 red; rest twin pin exactly 1/9 red — the new divergence case, proving it is driven by the gate, not fixture wiring); restore blob-identical.
    • Gate union re-derived on the expanded diff; the two rest-pulled gates (check:cross-package-test-inputs, check:dispatcher-error-vocabulary) run and green; check:type-check-debt re-measured on a built closure — relevant because @objectstack/rest carries a TEST_DEBT ledger entry, and the ratchet holds.
    • The @objectstack/cli ELIFECYCLE line in the earlier CI log: reproduced only as the unbuilt-closure signature on a fresh worktree, gone after building dependencies — CI log interleaving, not a defect of this PR.

    Landing plan unchanged: gate families completed: success by name on 667c14711 → flip ready → merge queue.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions