Skip to content

service-datasource: the admin routes supply no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15350

Closed

Description

@hotlong

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:

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

  1. added
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Sep 4, 2026
  2. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    Unblocked: pm:blocked → pm:queue. The upstream this card names, #15256, closed 2026-09-04 — PR #15365 merged as a72704375, 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 no tenancyPosture, and the mechanism that made the REST hole exploitable is unchanged here: both posture-conditional guards (organization_required at admission, organization_membership_ended after grants) are gated on a posture being supplied, so a door that supplies none runs neither, and under isolated Layer 0 then compares against the API key's own unvetted active_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:

    • The wall-enforcing posture requires the org-scoping service. Nothing in this repository registers it — only consumers gate on it (ctx.getService('org-scoping')); its sole registrar is the cloud-private packages/organizations. So this is an enterprise-surface exposure, not something an open-source install can reach, and no npm release is its vehicle.
    • Under group the exposure differs: Layer 0 takes the real membership union (organization_id IN accessible_org_ids), so the read leak does not arise there. ⚠️ The write path under group was never measured — the stamp comes from the same unvetted tenantId — so ⛔ do not assert group is 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.

  3. added
    pm:queueReady for the PM dispatch loop
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Sep 4, 2026
  4. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Sep 4, 2026
  5. added theissue type on Sep 4, 2026
  6. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    Triage: lands in packages/services/service-datasource/src/admin-routes.ts:387 ⇒ domain:services. Graded Bug · priority:p1.

    Landing point read on origin/main at 2026-09-04T14:24Z, not inherited from the title: git grep -n "resolveAuthzContext(" origin/main over non-test packages/**/*.ts returns 10 sites, and this card's is service-datasource/src/admin-routes.ts:387.

    Blocked-by: #15256 is discharged, verified rather than assumed: #15256 is CLOSED (completed, 13:58:49Z) with PR #15365 MERGED. ⇒ pm:queue is correct; no pm:blocked repair needed.

    Bug by the mechanical boundary test — the accept set does not widen, a declared guard returns to enforced. priority:p1 rather than the parent's p0: the p0 arm (the single-kernel REST wiring) is already repaired and merged, so what remains are the non-default transports.

    ⚠️ Four cards of this family land in domain:services (#15349 #15350 #15351 #15352) and two in domain: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

  7. claude commented on Sep 5, 2026

    @claude
    Contributor

    Claimed by the PM dispatch loop.

    Claim: session session_01ARYe3yQTQCUFm5qPYNgKaJ, branch claude/issue-15350-datasource-tenancy-posture.
    Clause-②: no.

    ⚠️ That no is 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/spec api-surface members, an exported type, a published payload's shape), declare yes and 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 declared no for 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 no as 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:

    1. effectiveTenancyPosture is exported from @objectstack/core (packages/core/src/security/index.ts:93).

    2. ⭐ There is no importable posture-resolver. resolveServiceOrLoud is declared private at packages/runtime/src/http-dispatcher.ts:2180 and used at exactly two sites, both inside that one file. The classification shape has to be reproduced, not imported.

    3. ⭐ ⛔ Do not extract a shared helper in this PR. Sibling cards plugin-sharing: the exec-context seam supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key keeps its claim #15349 (dispatched, in flight), service-settings: the manifest gate supplies no tenancyPosture to resolveAuthzContext — and returns the unvetted tenantId to its callers #15351 and service-storage: the file-read authorizer supplies no tenancyPosture to resolveAuthzContext — 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.

    4. The structure to copy is packages/rest/src/rest-server.ts:2458-2502: never-registered (isServiceNotRegisteredError) ⇒ quiet undefined; 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.

    ⚠️ The group posture's WRITE path was never measured (hotlong, 5541719825) — the stamp comes from the same unvetted tenantId. ⛔ Do not assert group is unaffected without measuring it.


    Generated by Claude Code

  8. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 5, 2026
  9. claude commented on Sep 5, 2026

    @claude
    Contributor

    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

  10. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p1High: required for production / M2security

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions