fix(service-datasource): the admin routes derive the tenancy posture — an ex-member's org-stamped API key is no longer admitted - #16011
Conversation
…es admission seam The datasource ADMIN routes accept real request headers, so `x-api-key` is admitted there — but `requireDatasourceAdmin` called `resolveAuthzContext` with no `tenancyPosture`, and both posture-conditional API-key refusals are gated on the caller supplying one. Neither ran, so under a wall-enforcing posture an API key stamped with an organization its owner has left was admitted and the family then gated it on `authz.systemPermissions` alone. The posture is classified per #13906 decision 1 option A: a `tenancy` service that was NEVER REGISTERED is branded and resolves quietly to "no posture"; one that was registered and FAILED to build raises `AuthzStoreUnavailableError`, which `requireDatasourceAdmin`'s existing catch re-raises rather than laundering into a denial (#13279). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…its permanent wiring ablation and the two option-A arms
29 cases on one registrar: controls in both directions (a current member reads
and writes, an anonymous caller is refused, an entitled-less key gets the
capability refusal), the ex-member and organization-less subject rows under
`isolated`, and a permanently-held ablation whose handle is the wiring this
package shipped before this card — a PluginContext with no `getKernel`.
Writes are read back FROM THE DATASOURCE STORE, never from the response body.
`group` is MEASURED rather than assumed: the ex-member is refused there too
(`postureEnforcesWall('group')`), while the organization-less key is admitted
(union scope).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ge class rather than its digits Sized as #15365 was: a fail-closed bug fix, no accept set widens. The outage arm now asserts the outage CLASS. Measured on this tree, the family answers 500 INTERNAL_ERROR rather than the 503 SERVICE_UNAVAILABLE the brand carries — a pre-existing relay gap this card did not open (the same escape has existed for the `ql` permission-store outage since #13279) and does not repair. Asserting the class keeps the pin on the security property. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…accessor
`tsc --noEmit` reds on a hand-written `{ fetch: (req: Request) => Promise<Response> }`:
Hono's `fetch` takes an env and an execution context after the request, so the
narrower signature is a type error rather than a simplification.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 1 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 27b620d914dc77cef15b4dd517be1c8092ef935b && git checkout 27b620d914dc77cef15b4dd517be1c8092ef935b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7db8f4fd268a86a08c62ae4894cf7417720f8c9 42d89418d6552e7130917c43f6c4e687276fd9a9 && git checkout -B drift-repro f7db8f4fd268a86a08c62ae4894cf7417720f8c9 && git merge --no-ff 42d89418d6552e7130917c43f6c4e687276fd9a9
node scripts/docs-audit/affected-docs.mjs --json f7db8f4fd268a86a08c62ae4894cf7417720f8c9 |
|
Standing down on the red
Mechanism, measured (and my earlier summary of it was wrong — corrected on #15978/#15998): the launcher does not fail to resolve. Not tree-dependent, and the cleanest proof is on Severity bound: false-RED only, never false-green — a store-poisoned run makes every artifact look Fix status: PR #16002 (from the Nothing to port into this PR before #16002 lands. ⛔ No test skipped, disabled or quarantined; ⛔ no empty commit. Spending this PR's one permitted re-run, which is unspent. Generated by Claude Code |
Fixes #15350
The hole
packages/services/service-datasource/src/admin-routes.tsresolved every request withresolveAuthzContext({ ql, headers, getSession })and supplied notenancyPosture. Both posture-conditional API-key refusals are gated on the caller supplying one:organization_required—packages/core/src/security/api-key.ts,if (!tenantId && tenancyPosture)organization_membership_ended—packages/core/src/security/resolve-authz-context.ts,if (keyPrincipal?.tenantId && input.tenancyPosture)These are real request headers on the datasource ADMIN routes, so
x-api-keyis accepted there. A key'stenantIdissys_api_key.active_organization_idcopied verbatim — the caller's own stored claim, never vetted against current membership — so under a wall-enforcing posture a key stamped with an organization its owner had left was admitted, and the family then gated it onauthz.systemPermissionsalone.Severity, stated as the card states it. This family gates on system capabilities rather than on organization-scoped rows, so the measured consequence is an admitted principal — an ex-member whose platform grants outlive their membership creates, patches and deletes this deployment's datasources — not a cross-organization row read. Less severe than #15256's data door. Not correct either.
The fix
One local resolver,
resolveAdmissionTenancyPosture, reading thetenancyservice off the kernel's async accessor and classifying the result per #13906 decision 1 option A:isServiceNotRegisteredError) — quietundefined. An embedding with noplugin-authis a supported composition; its behaviour is unchanged.AuthzStoreUnavailableError('tenancy', err). The posture is an authorization INPUT, so admission was never decided and must not be answered. A quietcatchhere would re-introduce exactly the permissive-on-failure defect [finding] two morecomputeExecCtxseams read "failed" and "not wired" as one value, and both feed authorization inputs — tenancy posture and the ADR-0069 auth gate #13906 exists to repair.The throw is raised inside
requireDatasourceAdmin's owntry, so it takes the relay that block already runs for the identical fault one seam over (isAuthzStoreUnavailableError(err)re-raises rather than laundering an outage into a denial, #13279). No new relay.The async accessor is used because the brand exists only on that path:
PluginContext.getServicethrows two unbranded plainErrors, so the synchronous read this file uses forobjectqlcannot tell the two facts apart. Its absence stays quiet, as on the two sibling seams — aKernelBase-shaped host exposesgetKernel()but has nogetServiceAsync, and dereferencing it would turn "this host shape has no async registry" into an outage.No shared helper was extracted, deliberately: #15349 is in flight and #15351 / #15352 are queued on this same seam in other packages, so a helper extracted by one of the four collides with the other three. The extraction looks correct to do once, as its own card, after they land — noted in the report for the PM rather than done here.
Acceptance — #15365's shape, copied
src/__tests__/admin-routes-tenancy-posture-admission.test.ts, 29 cases on one registrar:PERMISSION_DENIED. A door that authenticates nobody cannot "pass" here by refusing for the wrong reason.isolated. The ex-member's stamped key and the organization-less key are 401 on list, create and delete; the refusal names its reason on the server side and never the credential.PluginContextwith nogetKernelat all, which is byte-identically what the other suites here still mount. Ablated, the ex-member reads again (200), writes again (201, row lands) and deletes again (204, row gone), while the member and capability controls are unchanged.ObjectKernelcarries the wiring fact, because the classification under measurement is the registry's and a double imitating both brands would be asserting about itself.groupwas MEASURED, not assumedThe card records the
groupwrite path as never measured and forbids asserting it unaffected. Measured here, both directions:isolatedgroupsingleorganization_membership_endedkeys onpostureEnforcesWall, whichgroupsatisfies, so the ex-member is repaired undergrouptoo.organization_requiredadditionally requires!postureUsesUnionScope, whichgroupfails, so an organization-less key stays admitted there by design — it already reads the union of its owner's organizations. Both rows are pinned so the asymmetry is a recorded decision rather than an accident.One thing this PR measured and did NOT repair
The outage arm asserts the outage class — status in the set 500 or 503, never 200 and never 403 — rather than the digits. Measured on this tree, the family answers
500 INTERNAL_ERROR("No response from handler"), not the503 SERVICE_UNAVAILABLEthe brand declares:requireDatasourceAdminre-raises out of route handlers that have nocatch, and the Hono adapter renders any escaped throw as a bare 500. That escape is pre-existing — the samecatchhas re-raised the identical brand for theqlpermission-store outage since #13279 — so this PR does not open it and does not repair it. Filed separately as #15999. Pinning the class keeps this suite measuring the security property, so repairing the status later does not have to redden a security pin.Tier
Clause-② re-derived from this diff rather than inherited, and it is no, on two measurements:
git diff origin/main...HEAD -- packages/spec/api-surface/is empty (0 lines), and the diff adds and removes noexportline.dist/index.d.tsof@objectstack/service-datasourceis byte-identical with and without the change — sha25615a4858494…, 109773 bytes both ways, compared by building the package, checking outorigin/main's copy of the one changed source file, rebuilding, and diffing (restored byte-exact afterwards, bloba978a47f, and the restore leg rebuilt).The change is closure-local code plus type-only imports inside an existing function. No published contract face moves; a runtime authorization decision does.
Verification
Measured on
42d89418d, on a fully built tree.pnpm --filter @objectstack/service-datasource test— 32 files, 672 tests passedpnpm --filter @objectstack/service-datasource typecheck— clean, andtsc --noEmit --listFilesconfirms the new test file is in the program (1 hit), so that green really covers itscripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderives for this diff — all 55 exit 0, exit codes captured before any pipe. Two of them (check:dual-build-cjs-loads,check:type-check-debt) first answeredPREREQUISITE NOT MET(exit 3, NOT MEASURED); the full package closure was built and both were then measured green.Generated by Claude Code