Repository navigation
Description
Censused under #15256 (maintainer ruling 2026-09-04, item 5). That card repaired the REST single-kernel seam; this is the same hole through another door.
This site
packages/services/service-datasource/src/admin-routes.ts:387
const authz = await resolveAuthzContext({ ql, headers, getSession });
Real request headers on the datasource ADMIN routes, so x-api-key is accepted. No posture, so an ex-member's stamped key authenticates and the route then gates on authz.systemPermissions.
Severity note, honestly: this family gates on system capabilities rather than on org-scoped rows, so the immediate consequence is an admitted principal rather than a cross-organization row read. That makes it less severe than #15256's data door — it does not make the admission correct.
The mechanism, unchanged from #15256
resolveAuthzContext gates BOTH posture-conditional API-key refusals on a tenancyPosture its caller supplies:
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)
No posture supplied means neither guard runs. The key's tenantId is sys_api_key.active_organization_id copied verbatim — the caller's own stored claim, never vetted against current membership. So under a wall-enforcing posture (isolated), an API key stamped with an organization its owner has left is admitted, carrying that organization as its tenant.
The census this came from
Of the eight non-test resolveAuthzContext callers on main, exactly two supply a posture:
packages/rest/src/rest-server.ts— both wirings, after [decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring underisolated— the wall compares against the caller's own unvetted claim #15256packages/runtime/src/security/resolve-execution-context.ts— already did
The other six do not. This issue is one of them; the ruling directed one issue per site.
Why #15256's PR did not fix it inline
Ruling item 5 says fix in that PR if it is one line per site. It is not. A correct derivation has to carry decision-1-option-A's classification (#13906): a tenancy service that was never registered is branded and resolves quietly to "no posture", while one that was registered and FAILED to build must raise AuthzStoreUnavailableError (503). A naive try { ... } catch { undefined } at this seam re-introduces precisely the permissive-on-failure defect #13906 was filed to repair — a failure reading as "this check does not apply".
Refs: #15256 (the ruling and the repaired REST seam) · #15163 (the framework measurement) · #13906 (decision 1 option A) · ADR-0105 D2/D3 · ADR-0123 D2.
Blocked-by: #15256
Activity
hotlong commentedon Sep 4, 2026
Unblocked:
pm:blocked→pm:queue. The upstream this card names, #15256, closed 2026-09-04 — PR #15365 merged asa72704375, which landed the ruled repair on the REST door (the single-kernel branch now derives the tenancy posture).What that does and does not do for this card. #15365 repaired one caller of
resolveAuthzContext— the REST transport. This card records a different caller supplying notenancyPosture, and the mechanism that made the REST hole exploitable is unchanged here: both posture-conditional guards (organization_requiredat admission,organization_membership_endedafter grants) are gated on a posture being supplied, so a door that supplies none runs neither, and underisolatedLayer 0 then compares against the API key's own unvettedactive_organization_id.Scoping context, measured after this card was filed — it narrows who is exposed and should shape the fix's urgency, not its correctness:
org-scopingservice. Nothing in this repository registers it — only consumers gate on it (ctx.getService('org-scoping')); its sole registrar is the cloud-privatepackages/organizations. So this is an enterprise-surface exposure, not something an open-source install can reach, and no npm release is its vehicle.groupthe exposure differs: Layer 0 takes the real membership union (organization_id IN accessible_org_ids), so the read leak does not arise there.groupwas never measured — the stamp comes from the same unvettedtenantId— so ⛔ do not assertgroupis unaffected without measuring it.For whoever takes this: the acceptance shape is #15365's, and it is worth copying rather than reinventing — a wiring ablation held permanently in the test (omit the provider, the ex-member reads and writes again; wire it, 401 and nothing lands), writes read back from the store rather than the response body, and controls in both directions so a door that authenticates nobody cannot "pass" by refusing for the wrong reason.
Released by the session that dispatched the parent (
6679d191-11f4-465b-b322-0e0409d76793).domain:*and priority remain triage's.os-zhuang commentedon Sep 4, 2026
Triage: lands in
packages/services/service-datasource/src/admin-routes.ts:387⇒domain:services. GradedBug·priority:p1.Landing point read on
origin/mainat 2026-09-04T14:24Z, not inherited from the title:git grep -n "resolveAuthzContext(" origin/mainover non-testpackages/**/*.tsreturns 10 sites, and this card's isservice-datasource/src/admin-routes.ts:387.Blocked-by: #15256is discharged, verified rather than assumed: #15256 is CLOSED (completed, 13:58:49Z) with PR #15365 MERGED. ⇒pm:queueis correct; nopm:blockedrepair needed.Bugby the mechanical boundary test — the accept set does not widen, a declared guard returns to enforced.priority:p1rather than the parent'sp0: the p0 arm (the single-kernel REST wiring) is already repaired and merged, so what remains are the non-default transports.domain:services(#15349 #15350 #15351 #15352) and two indomain:cli(#15348 #15353). Within the services four, the shared-region test for a folded family dispatch is worth running before dispatching them one at a time — ⛔ triage has not run it, and the fold-or-serial answer is the executing seat's.Generated by Claude Code
claude commentedon Sep 5, 2026
Claimed by the PM dispatch loop.
Claim: session
session_01ARYe3yQTQCUFm5qPYNgKaJ, branchclaude/issue-15350-datasource-tenancy-posture.Clause-②: no.
nois this seat's reading, and it is NOT binding on the implementing round. Re-derive the tier from your own diff and re-declare it — if your change adds or widens anything on a published contract face (packages/specapi-surface members, an exported type, a published payload's shape), declareyesand PARK, whatever this line says. This seat got a tier call wrong in each direction today: on #14552 it wrongly predicted a park, and on #14646 it declarednofor a diff that added three api-surface members. Both were caught by the round re-measuring rather than inheriting. Do that here.The basis for the
noas a starting reading: the 2026-08-28 negative boundary — 「运行时权限/安全行为变更不是条款② …… 条款②只指已发布契约面」. Supplying an existing argument to an existing function changes a runtime authorization decision, not a published face. Falsify it against your actual diff.No fresh ruling is needed to dispatch: #15256's ruling (2026-09-04, item 5) already directed one card per site, and fix-inline only where it is one line per site — which it is not, for the reason the card body gives.
Readings measured on
origin/main, which the card does not carry:effectiveTenancyPostureis exported from@objectstack/core(packages/core/src/security/index.ts:93).⭐ There is no importable posture-resolver.
resolveServiceOrLoudis declaredprivateatpackages/runtime/src/http-dispatcher.ts:2180and used at exactly two sites, both inside that one file. The classification shape has to be reproduced, not imported.⭐ ⛔ Do not extract a shared helper in this PR. Sibling cards plugin-sharing: the exec-context seam supplies no
tenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key keeps its claim #15349 (dispatched, in flight), service-settings: the manifest gate supplies notenancyPosturetoresolveAuthzContext— and returns the unvettedtenantIdto its callers #15351 and service-storage: the file-read authorizer supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key authenticates for downloads #15352 are live on this same seam in other packages; a helper extracted by one collides with the other three. If you judge extraction correct, say so in your report and this seat files it as its own card.The structure to copy is
packages/rest/src/rest-server.ts:2458-2502: never-registered (isServiceNotRegisteredError) ⇒ quietundefined; every other rejection ⇒AuthzStoreUnavailableError('tenancy', err).Acceptance shape is #15365's, which the card says to copy rather than reinvent: a wiring ablation held permanently in the test (omit the provider ⇒ the ex-member is admitted again; wire it ⇒ refused and nothing lands), writes read back from the store rather than the response body, and controls in both directions so a door that authenticates nobody cannot "pass" by refusing for the wrong reason.
groupposture's WRITE path was never measured (hotlong,5541719825) — the stamp comes from the same unvettedtenantId. ⛔ Do not assertgroupis unaffected without measuring it.Generated by Claude Code
claude commentedon Sep 5, 2026
os-dev-report
{ "issue": 15350, "status": "done", "branch": "claude/issue-15350-datasource-tenancy-posture", "pr": "https://github.com/objectstack-ai/objectstack/pull/16011", "premise_still_valid": true, "summary": "The card's premise verified on origin/main: admin-routes.ts:387 called resolveAuthzContext with no tenancyPosture, so neither organization_required nor organization_membership_ended ran on this door. Added a local resolveAdmissionTenancyPosture that reads the tenancy service off the kernel's async accessor and classifies per #13906 decision 1A (never-registered = branded = quiet undefined; registered-and-failed = AuthzStoreUnavailableError), raised inside requireDatasourceAdmin's existing try so it takes the #13279 relay already there rather than a new one. NO shared helper was extracted, per the dispatch: #15349 is in flight and #15351/#15352 are queued on this same seam. Clause-2 RE-DERIVED, not inherited, and it is NO on two measurements: the packages/spec/api-surface delta is empty (0 lines) and no export line moved; and, decisively, the built dist/index.d.ts of @objectstack/service-datasource is byte-identical with and without the change (sha256 15a4858494..., 109773 bytes both ways) — measured by building, checking out origin/main's copy of the one changed source file, rebuilding and diffing, then restoring byte-exact (blob a978a47f) and rebuilding the restore leg. Two half-states worth naming: the card arrived with an EMPTY assignee field (PM-side; opened anyway per contract), and the group posture's write path, which the card recorded as never measured, is now measured — see tests.", "tests": "All measured on final head 42d89418d, tree clean, on a fully built package closure. Exit codes captured BEFORE any pipe (cmd > file 2>&1; E=$?).\n\n1) pnpm --filter @objectstack/service-datasource test — EXIT=0 — 'Test Files 32 passed (32) / Tests 672 passed (672)'. The new suite is 29 of those.\n2) pnpm --filter @objectstack/service-datasource typecheck — EXIT=0, script name echoed ('tsc --noEmit'), so not a zero-match no-op. NOT-MEASURED trap closed explicitly: tsc --noEmit --listFiles greps the new test file 1 time, so this green really covers it. This mattered: an earlier typecheck ran BEFORE the test file existed and was green; re-running on the final head found a real TS2322 in the harness type, which is fixed in 42d89418d.\n3) Gate union: scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived 55 families from the real change set (3 paths, three-dot vs merge base 1375344b6; no path list fed to it). ALL 55 EXIT 0 on head 42d89418d, re-run in full after that commit. Two of them first answered 'PREREQUISITE NOT MET' (exit 3 = NOT MEASURED, not red): check:dual-build-cjs-loads and check:type-check-debt. The full closure was built (turbo build over packages, 71/71 tasks) and both were then measured GREEN — type-check-debt: '12 ledger entries re-measured in 95.2s, 140 raw tsc errors total, none above its recorded number'.\n\nABLATION — held PERMANENTLY in the test, not a throwaway. Handle: a PluginContext with NO getKernel, which is byte-identically the wiring this package shipped before this card and what its other suites still mount. Ablated arm measured: ex-member's org-stamped key reads again (GET 200), writes again (POST 201, row LANDS — counted in the datasource store, not read off the response body) and deletes again (DELETE 204, row GONE from the store); organization-less key admitted again; zero refusal lines; and the member/anonymous/capability controls unchanged, so the provider is what moved and not the fixture. Rebuild discipline: the subject resolves through relative source (../admin-routes.js), and @objectstack/core and @objectstack/plugin-hono-server resolve through their dist, which the full build refreshed before every reading above.\n\nMUTATION CONFIRMED ON DISK for both temporary measurements, never inferred from an editor exit code. (a) The outage-status probe: anchor grep counted 1 before, 0 old and 2 injected after; restored under a trap with absolute paths; restore proven by git status clean AND git hash-object == git rev-parse HEAD:path (blob bf566b8f). (b) The d.ts tier measurement: after checkout the posture-symbol count in the source read 0 (expect 0), and after restore it read 3, with worktree blob == HEAD blob (a978a47f); the RESTORE LEG was then rebuilt and dist re-confirmed to carry the marker (2 occurrences of effectiveTenancyPosture in dist/index.js).\n\nGROUP MEASURED, not assumed — the card forbids asserting group unaffected. Pinned in both directions: under group the ex-member's stamped key is REFUSED on read and on write (401, nothing lands), because organization_membership_ended keys on postureEnforcesWall which group satisfies; the organization-less key stays ADMITTED under group (200 / 201, row lands), because organization_required additionally requires NOT postureUsesUnionScope, which group fails. So this card repairs group as well as isolated on the ex-member row, and deliberately does not on the org-less row. Under single nothing changes.\n\nOUTAGE ARM measured rather than assumed: registered-and-failed answers 500 INTERNAL_ERROR ('No response from handler'), NOT the 503 SERVICE_UNAVAILABLE the brand declares. Pre-existing (the same catch has re-raised this brand for the ql store outage since #13279, out of handlers with no catch, and the Hono adapter renders an escaped throw as a bare 500) — not opened and not repaired here; filed as #15999. The pin asserts the outage CLASS (status in 500 or 503, never 200, never 403) so repairing the status later need not redden a security pin.", "mcp_calls": "4 — issue_read(get), issue_read(get_comments), issue_write(create #15999), create_pull_request. Nothing else went through MCP. The report comment itself was posted via repo-scoped REST (POST repos/objectstack-ai/objectstack/issues/15350/comments, HTTP 201), and the duplicate search went through repo-scoped REST plus local grep: the REST probe on repos/objectstack-ai/objectstack returned 200, but /search/issues returned 403 ('sessions are bound to their configured repositories'), so the sanctioned fallback was the REST list endpoint (562 open issues) with a local grep carrying a positive control — 'tenancy' fired 9 hits including this card and its three siblings, and the duplicate keywords fired none.", "open_questions": [ { "question": "Extract the tenancy-posture admission resolver into one shared helper, once the four sibling cards land? The dispatch forbade doing it in this PR and asked for the judgement.", "options": [ "A. File it as its own card after #15349/#15351/#15352 land, and have it fold all four call sites plus rest-server.ts's two branches onto one exported helper in @objectstack/core beside effectiveTenancyPosture", "B. Leave the copies. Four near-identical bodies, each about ten lines, each carrying its own seam's wiring caveats", "C. Extract now and let the three in-flight cards rebase onto it" ], "recommendation": "A. The bodies really are the same shape and the classification is the part nobody may get wrong — a quiet catch at any one of them re-opens #13906 — so one tested helper is worth more than four correct copies. But C is the wrong sequencing for exactly the reason the dispatch gave: a helper extracted by one of four in-flight cards collides with the other three. Worth noting for whoever writes it: the four seams are NOT byte-identical. rest-server.ts branches on kernel-vs-provider, this one and plugin-sharing read ctx.getKernel(), and each carries a different reason for why the async accessor's absence must stay quiet. A helper that erases those differences would be worse than the copies; the extractable part is the try/catch classification, not the resolution." }, { "question": "Does the 500-instead-of-503 relay gap (#15999) extend to the other four direct-mount registrars that re-raise this brand?", "options": [ "A. Measure all five, then decide per-family relay versus one shared render of an escaped ADR-0112 envelope", "B. Repair only the datasource family, in its own PR", "C. Leave it: 500 is at least not 403, so the #13279 property survives" ], "recommendation": "A, but as its own card — #15999 records the census honestly as UNMEASURED for the other four. I measured only this family (git grep finds six re-raise sites, five of them direct-mount registrars); whether each renders the declared 503 depends on its own transport, and I drove none of them. Please do not read that list as five confirmed defects." } ], "out_of_scope_findings": [ "filed as #15999: service-datasource — the AuthzStoreUnavailableError requireDatasourceAdmin re-raises escapes route handlers that have no catch, so the Hono adapter renders it as 500 INTERNAL_ERROR ('No response from handler') instead of the 503 SERVICE_UNAVAILABLE the brand declares. Pre-existing since #13279 for the ql store outage; this card adds a second source of the same brand at the same seam but does not open the escape. Filed unassigned, no labels, for triage to grade. Searched first: REST list of 562 open issues plus local grep with a positive control ('tenancy' fired 9, including #15349/#15350/#15351/#15352); the duplicate keywords fired zero. Nearest neighbour is #15900 (dispatcher domain gates read the posture through a collapsing resolveService probe) — same family, different site and different defect, not a duplicate." ] }Generated by Claude Code