Skip to content

service-settings: the manifest gate supplies no tenancyPosture to resolveAuthzContext — and returns the unvetted tenantId to its callers #15351

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-settings/src/settings-service-plugin.ts:278

const authz = await resolveAuthzContext({ ql, headers, getSession });
return { userId: authz.userId, tenantId: authz.tenantId, permissions: [...], enforced: true };

Real request headers, so x-api-key is accepted. No posture — and this site does not merely admit the principal, it returns authz.tenantId onward as the resolved tenant for the settings read/write path. That is the ex-member's own unvetted claim being handed to a consumer as a verdict.

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-settings/src/settings-service-plugin.ts:278 ⇒ 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 settings-service-plugin.ts:278.

    ⚠️ This card carries a second claim its five siblings do not — that the seam returns the unvetted tenantId to its callers. That makes its blast radius a superset of the plain admission hole, and the repair has a second half (what the callers do with the returned value). ⛔ Triage has not measured the caller set; it is named here so the dispatch does not scope to the admission line alone.

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

    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 is repaired and merged.


    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-15351-settings-tenancy-posture.
    Clause-②: no.

    ⚠️ That no is this seat's reading and is NOT binding on the implementing round. Re-derive the tier from your own diff and re-declare it. This seat got a tier call wrong in BOTH directions today — on #14552 it predicted a park that measurement retired, and on #14646 it declared no for a diff that added three packages/spec api-surface members. Both were caught only because the round re-measured instead of inheriting.
    ⚠️ And note the instrument's limit, learned the hard way on #15996: api-surface/ artifacts exist only for packages/spec. For any other package the published surface is its files[] + types (dist/**), so git diff origin/main...HEAD -- packages/spec/api-surface/ is not a universal check — for a service package, ask what lands in dist/.

    No fresh ruling is needed: #15256's ruling (2026-09-04, item 5) already directed one card per site, fix-inline only where it is one line per site.

    ⚠️ Two statements in this card's body are now FALSE. Measured on the #15349 round (PR #15996), verified by this seat.

    1. The census is 4 of 8, not 2. This card says exactly two callers supply a posture (rest-server.ts and runtime/security/resolve-execution-context.ts). Measured: four — those two plus mcp and cloud-connection. The card's census predates the sibling repairs. ⛔ Do not re-derive the card's number; measure the tree.

    2. group is NOT unaffected. The card's unblock comment says the group write path was never measured and ⛔ not to assert it is unaffected. It has now been measured: the ex-member refusal fires exactly as under isolated, while an organization-less key stays admitted by design. The warning is discharged by measurement, not still open.

    Readings that hold, measured on origin/main

    Acceptance shape is #15365's, which #15996 followed: 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.

    ⚠️ One residual already filed, so you do not re-file it: #15997 — on a KernelBase/LiteKernel host none of these seams resolve a posture, because such a host has no getServiceAsync. That is the ruled behaviour (#15256) and is a family-wide question, ⛔ not yours to fix here. Mention it if you touch it; do not deviate.


    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": 15351,
      "status": "done",
      "branch": "claude/issue-15351-settings-tenancy-posture",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16015",
      "premise_still_valid": true,
      "summary": "The seam the card names is real and still at packages/services/service-settings/src/settings-service-plugin.ts:278 (verifiedContextFromRequest, the manifest gate behind every /api/settings door). It now derives the EFFECTIVE tenancy posture through the kernel's async registry and supplies it to resolveAuthzContext, carrying decision-1-option-A: never registered => quiet undefined, registered-and-failed => AuthzStoreUnavailableError. Structure copied from rest-server.ts:2458-2502 as a PRIVATE resolveAdmissionTenancyPosture, following the #15996 precedent; no shared helper extracted. The card's second claim (this seam returns the unvetted tenantId onward) is repaired too and pinned separately: with the posture supplied, a browser session whose stored activeOrganizationId is no longer backed by a sys_member row has that claim DROPPED here (#15409's ruled option B), so the settings tenant is undefined instead of an organization the caller left. Zone 2's corrections both re-measured and confirmed on this tree: four of the eight non-test callers supplied a posture before this PR (rest-server, runtime/resolve-execution-context, mcp/plugin.ts, cloud-connection/marketplace-install-local-plugin), five after; the three still open are plugin-sharing/sharing-plugin.ts:847, service-datasource/admin-routes.ts:387 and service-storage/storage-service-plugin.ts:841. #15997 was not touched; its ruled KernelBase/LiteKernel behaviour is pinned as one of the two permanent ablation shapes.",
      "tests": "All at the final commit d49ec41f7 (union re-run after it). [1] pnpm --filter '@objectstack/service-settings^...' build - EXIT=0 (dependency closure built BEFORE anything was judged). [2] pnpm --filter @objectstack/service-settings typecheck - EXIT=0. It demonstrably covers the new test file rather than excluding it: an earlier run failed with 'src/settings-admission-tenancy-posture.test.ts(395,87): error TS2339' before that line was fixed. [3] pnpm --filter @objectstack/service-settings test - EXIT=0, 'Test Files 31 passed (31) / Tests 551 passed (551)', including the new 18-case suite src/settings-admission-tenancy-posture.test.ts. [4] Derived gate family: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; it derived 3 paths vs merge base f7db8f4fd) named 54 families; the list was identical before and after the final commit. Re-run at d49ec41f7: 52 EXIT=0. Every exit code captured by redirect-then-read, never after a pipe. check:objectql-double-limit found the NEW fixture driver twice - first 'BLIND', then 'WRONG -- a row-touching stage runs BEFORE the bound' - and both were repaired in the fixture rather than baselined ('The baseline never grows'); its verdict line now reads '0 touch rows outside the bound ... none new'. check:where-matcher: '357 matcher(s) ... 0 silently-wrong and 0 unjudged ... none new'. Also ran the four artifact-roster gates whose roster sits under my paths (check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check-changeset-fixed) plus check:tenant-chokepoint and check:error-status-conformance - all EXIT=0. [5] NOT MEASURED, declared: check:dual-build-cjs-loads and check:type-check-debt both EXIT=3, each printing its own 'PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured' - they read built output and 54 packages have no dist/ in this worktree. I did not run a full workspace build for them (CI does it before both steps); neither can plausibly move on this diff - no package.json, no exports, no ledgered-package boundary is touched, and check:type-check-coverage (which counts hidden tests) is EXIT=0. This is a declared narrowing, not a silent skip. [6] ABLATION x2. No dist is involved: the suite imports the seam through a relative source specifier, so there is no built artifact to go stale; each ran in ONE shell with an absolute-path EXIT/INT/TERM trap, and in each the mutation was proven on disk BEFORE any measurement (git hash-object vs the HEAD blob, plus injected/removed marker counts) and the restore proven after (git diff HEAD empty AND restored hash == HEAD blob 0946b2f9b51c0884d4e44a6977e76cc4c4923da4). (a) Remove the posture from the call site: on-disk 'injected=1 removed-text=0 hash=a788fc10...', then 'Tests 7 failed | 11 passed (18)' - exactly the six refusal cases plus the session claim-drop. The controls, BOTH re-admitting ablation shapes and the 503 arm stayed green, which is what makes the refusal attributable to the supply alone. (b) Replace the classification with the forbidden catch { undefined }: on-disk 'injected=1 removed-text=0 hash=3438fbe7...', then 'Tests 3 failed | 15 passed (18)' - exactly the three ADR-0112 outage cases. Refusal cases assert the ENVELOPE, never a bare toThrow: code === AUTHZ_STORE_UNAVAILABLE_CODE, status === AUTHZ_STORE_UNAVAILABLE_STATUS, object === 'tenancy'. [7] Acceptance shape (#15365 / #15996): the wiring ablation is held PERMANENTLY in the file (three mount() shapes differing in nothing but whether the seam can resolve a posture; 'no-async-registry' and 'unregistered' re-admit the very caller 'isolated' refuses); writes are read back FROM THE STORE (sys_setting / sys_setting_audit rows out of the driver's own Maps), never off a response body; controls run in both directions (a CURRENT member's key writes and reads 200 under the same posture, so a door that authenticates nobody cannot 'pass'; no credential is unchanged at 403), plus the effective-vs-requested discriminator in both directions with OS_TENANCY_POSTURE saved and restored per test. [8] Published-surface measurement (the tier re-derivation, not an inheritance): built dist/index.d.ts on the branch, then swapped ONLY settings-service-plugin.ts back to f7db8f4fd (swap proven on disk, marker count 0) and rebuilt. Ignoring doc comments the delta is exactly one line, '+    private resolveAdmissionTenancyPosture;'. Restore proven (git diff HEAD empty).",
      "mcp_calls": "1 - one targeted mcp__github__search_issues for the out-of-scope dedupe. Everything else went through container curl REST (repo-scoped probe green: HTTP 200) or git: the card body, its comments, the sibling #15999 body, the comment POST, the comment read-back, the PR create and the PR body read-back. CHANNEL SWITCH DECLARED: REST /search/issues is 403 in this session ('sessions are bound to their configured repositories. Use repository-scoped endpoints'), so the dedupe went to one targeted MCP semantic search instead. Its result was a positive hit (8 results, #15999 first), so no separate control word was needed.",
      "open_questions": [],
      "out_of_scope_findings": [
        "MEASURED and contributed to the EXISTING open card #15999 rather than re-filed (comment 5554071696): the settings route layer flattens the branded AuthzStoreUnavailableError into 500 INTERNAL_ERROR. #15999 already lists settings-service-plugin.ts:292 in its scope question but explicitly says only the datasource site was driven; I drove this one, so the honest disposition was to add the measurement, not to open a duplicate. It also changes that card's own scope question: the two measured sites lose the envelope in DIFFERENT places (datasource escapes the handler and the adapter renders it; settings is caught by each route's own try and re-encoded), so 'per-family catch relays err.status/err.code' is not one edit repeated. Pre-existing (#13279's re-raise already reached the same else branch), pinned as MEASURED-not-endorsed in the new test.",
        "NOT filed, reported for the PM to file if it wants the card: I do judge extraction correct on the merits - five doors now carry structurally identical private posture resolvers (rest-server.ts, resolve-execution-context.ts, mcp/plugin.ts, cloud-connection, and now service-settings), and a shared resolveAdmissionTenancyPosture(ctx) in @objectstack/core would collapse them. I did NOT extract one, per the dispatch's explicit instruction and the #15996 precedent, because #15350 is in flight and #15352 is queued on this same seam. Worth filing once those two land, not before."
      ]
    }

    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