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-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:
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-settings/src/settings-service-plugin.ts:278⇒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 issettings-service-plugin.ts:278.tenantIdto 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: #15256is discharged, verified rather than assumed: #15256 is CLOSED (completed, 13:58:49Z) with PR #15365 MERGED. ⇒pm:queueis correct.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 is repaired and merged.Generated by Claude Code
claude commentedon Sep 5, 2026
Claimed by the PM dispatch loop.
Claim: session
session_01ARYe3yQTQCUFm5qPYNgKaJ, branchclaude/issue-15351-settings-tenancy-posture.Clause-②: no.
nois 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 declarednofor a diff that added threepackages/specapi-surface members. Both were caught only because the round re-measured instead of inheriting.api-surface/artifacts exist only forpackages/spec. For any other package the published surface is itsfiles[]+types(dist/**), sogit diff origin/main...HEAD -- packages/spec/api-surface/is not a universal check — for a service package, ask what lands indist/.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.
The census is 4 of 8, not 2. This card says exactly two callers supply a posture (
rest-server.tsandruntime/security/resolve-execution-context.ts). Measured: four — those two plusmcpandcloud-connection. The card's census predates the sibling repairs. ⛔ Do not re-derive the card's number; measure the tree.groupis NOT unaffected. The card's unblock comment says thegroupwrite path was never measured and ⛔ not to assert it is unaffected. It has now been measured: the ex-member refusal fires exactly as underisolated, while an organization-less key stays admitted by design. The warning is discharged by measurement, not still open.Readings that hold, measured on
origin/maineffectiveTenancyPostureis exported from@objectstack/core(packages/core/src/security/index.ts:93).resolveServiceOrLoudisprivateatpackages/runtime/src/http-dispatcher.ts:2180, used at exactly two sites, both inside that file. Reproduce the shape; do not hunt for a helper.tenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key is admitted #15350 is in flight on this same seam and service-storage: the file-read authorizer supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key authenticates for downloads #15352 is queued behind it; a helper extracted by one collides with the others. The landed sibling fix(plugin-sharing): the share-link admission seam derives the tenancy posture — an ex-member's org-stamped API key no longer mints links into the organization it left #15996 deliberately did not extract one either — it wrote a privateresolveAdmissionTenancyPosturecopying the structure ofrest-server.ts. Follow that precedent. If you judge extraction correct, report it and this seat files it as its own card.packages/rest/src/rest-server.ts:2458-2502: never-registered (isServiceNotRegisteredError) ⇒ quietundefined; every other rejection ⇒AuthzStoreUnavailableError('tenancy', err).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.
KernelBase/LiteKernelhost none of these seams resolve a posture, because such a host has nogetServiceAsync. 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
claude commentedon Sep 5, 2026
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