Repository navigation
cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15353
Description
Activity
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:
- The wall-enforcing posture requires the
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. - Under
groupthe 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 undergroupwas 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.- The wall-enforcing posture requires the
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Sep 4, 2026 Triage: lands in
packages/cloud-connection/src/marketplace-install-local-plugin.ts:1656⇒domain:cli(cloud-connectionsits in thedomain:clipackage family). 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 is thepackages/cloud-connectionone.⚠️ Note the lane split within this family. The six sibling cards are one defect through six doors, but they are ⛔ not one lane: this card and #15348 (packages/mcp) land indomain:cli, while #15349 / #15350 / #15351 / #15352 land indomain:services. Two seats, so ⛔ they cannot be folded into a single family dispatch across the split — the shared-file test fails by construction.Blocked-by: #15256is discharged, verified rather than assumed: #15256 is CLOSED (completed, 13:58:49Z) with PR #15365 MERGED. ⇒pm:queueis correct.Bugrather thanFeatureby the mechanical boundary test: the accept set does not widen; a declared guard is restored to enforced.priority:p1rather than the parent'sp0because the p0 arm — the single-kernel REST wiring — is already repaired and merged.
Generated by Claude Code
Claim: session
session_01D47qPfEWVPmhguWgBZCi5N· seatdomain:cliexecution PM (#6024), R69 · branchclaude/issue-15353-cloud-connection-tenancy-posture· worktreeobjectstack-issue-15353.pm:queue→pm:dispatchedand assignee set in one label write. Dispatching oneos-dev, alongside its sibling #15348 — the twodomain:climembers of the #15256 item-5 census.⚠️ Different files, different packages: ⛔ no serial between them.⚠️ Blocked-by: #15256is DISCHARGED#15256 closed
completedat 2026-09-04T13:58:49Z via PR #15365 (MERGED), and it no longer carriesneeds-user-decision. The maintainer ruled, the REST seam is repaired, and item 5 of that ruling is what directed this card to exist. ⛔ The card sat atpm:queuewhile blocked — dispatchable now because the blocker cleared, ⛔ not because the label said so.Premise re-verified at dispatch, on the merged ref
⚠️ Mandatory: PR #15365 touchedpackages/core/src/security/**, which is this card's whole mechanism. Measured atorigin/main6ed4b811af:grep -c "tenancyPosture" packages/cloud-connection/src/marketplace-install-local-plugin.ts → 0and both posture-gated guards are intact, still conditional on a caller-supplied posture (
api-key.ts:368,resolve-authz-context.ts:408). ⇒ The defect is unchanged. #15365 repaired the REST caller, ⛔ not the resolver's contract.⭐ The card's own triage note is the sharp part, and it is a trap as much as a hint
this module already imports
resolveTenancyPostureandpostureEnforcesWallfor its own install-time gating, so a posture is derivable in this file — but the value it derives there is the install path's, and wiring it into the admission seam still has to honour decision 1 option A's registered-and-failing case rather than reuse a fallback that resolves to the requested posture.⇒
⚠️ This is the site where the wrong fix is easiest and most plausible: the symbols are already in scope, so reaching for the nearest posture value looks like a one-liner. ⛔ A posture derived for install-time gating is not the admission seam's posture, and a fallback that resolves to the requested posture is the permissive-on-failure shape #13906 exists to refuse.Serial constraints cleared — measured, and one NOT-MEASURED batch re-run
Zero hits across every open PR head for this file,
packages/mcp/src/plugin.ts, andpackages/core/src/security/**.⚠️ Recorded because the first pass was NOT MEASURED and looked clean: a batchedgit fetchaborted on one bad ref, so fourteen rows silently reportedNO MERGE-BASEwhile the scan printed nothing. Re-run per-ref; the three that still would not resolve were settled by deepening. ⛔ A scan with no merge base has cleared nothing.Tier
opus— no path-derived mandate.Clause-②: no— supplies an existing argument to an existing parameter so two already-declared refusals become reachable; no contract surface added or widened.⚠️ Fork condition: a legitimate caller newly refused is a stop-and-report.
Generated by Claude Code
os-dev-report
{ "issue": 15353, "status": "done", "branch": "claude/issue-15353-cloud-connection-tenancy-posture", "pr": "https://github.com/objectstack-ai/objectstack/pull/15471", "premise_still_valid": true, "summary": "The premise held: at origin/main 6ed4b811af the seam called resolveAuthzContext with real headers and no tenancyPosture, and both posture-conditional API-key refusals were unreachable through all five install-local doors (they share resolveInstallPrincipal, so the GET listing leaked too). Added a private resolveAdmissionTenancyPosture(ctx) that reads the EFFECTIVE posture off the kernel's tenancy service through getKernel().getServiceAsync, carrying decision-1-option-A: never-registered is branded and resolves quietly to undefined, every other rejection becomes AuthzStoreUnavailableError (SERVICE_UNAVAILABLE / 503). MEASURED ANSWER to the triage note (Zone 2 item 3): the install-time postures in this file CAN and DO differ from the admission seam's, in three separate ways, so the one-liner was not available. organizationWallActive answers a boolean, which cannot express undefined ('run no posture-conditional refusal') versus 'single' (a posture present that enforces no wall); it ends in postureEnforcesWall(resolveTenancyPosture()), the REQUESTED posture, which under ADR-0093 D4/D5 diverges from the enforced one; and its catch swallows a registered-and-broken tenancy into that same fallback. A fourth reason forced the async accessor: PluginContext.getService throws two UNBRANDED plain Errors ('not found' and 'is async - use await'), so the #13906 brand exists only on the async path and a sync read cannot classify at all. MEASURED ANSWER to Zone 2 item 4: the session path is NOT affected and this fix does not close it. Both refusals in resolveAuthzContext key on keyPrincipal, set only by the API-key admission, so a session's activeOrganizationId reaches ctx.tenantId without passing either guard; pinned as a named boundary test so nobody reads this card as having repaired it. CENSUS RE-DERIVED (Zone 2 item 2), method stated, not quoted from the card: git grep -l 'resolveAuthzContext(' over non-test packages/**/*.ts at origin/main, excluding *.test.ts and *.testkit.ts, then per file counting calls and 'tenancyPosture' mentions. 10 files match; one is the resolver's own definition with 0 calls, leaving 9 call sites. One of the 9 is packages/qa/dogfood/test/armed.ts, a harness by location rather than extension, so 8 production callers, of which exactly 2 supplied a posture (rest-server.ts and runtime/src/security/resolve-execution-context.ts). Both the card's '8 of which 2' and the PM's '10 files, 3 mentioning' are explained by that filter boundary. After this PR: 3 of 8, and the remaining 5 are precisely the sibling cards 15348 to 15352. Fork condition did NOT fire: no legitimate caller is newly refused, and the controls prove it. Scope note carried forward: the wall-enforcing posture needs the org-scoping service, whose only registrar is the cloud-private packages/organizations, so this is an enterprise-surface repair; a host registering no tenancy service keeps the quiet answer by design (decision 1A), which is pinned rather than left implicit.", "tests": "All at final commit 31d38c81b5 on a clean tree; exit codes captured before any pipe; verdicts quoted from each gate's own line; heavy runs through scripts/pm/os-verify-lock.sh. (1) NEW SUITE packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts, 12 tests, driven through the composed plugin over a real ledger temp dir with tenancy registered on a REAL ObjectKernel so the never-registered / registered-and-broken split comes from the registry's own rejections: ex-member org-stamped key refused 401 with zero effects at POST and at GET; org-less key refused 401; CONTROL a current member's key still installs 200 with effects greater than 0 (the only difference is the sys_member row); CONTROL no credential still 401; effective-beats-requested pinned BOTH ways (service 'single' + OS_TENANCY_POSTURE=isolated admits, service 'isolated' + env unset refuses); never-registered tenancy and a host with no async registry both keep the quiet admit; registered-but-broken tenancy raises the ADR-0112 envelope asserting code === SERVICE_UNAVAILABLE AND status === 503 AND object === 'tenancy' with zero effects, at POST and at GET (never a bare toThrow); plus the session-path boundary pin. (2) ABLATION, direction PREDICTED before each run, mutation proved on disk by counting the removed text AND an injected marker, restored under a trap with git checkout HEAD -- ABSOLUTE_PATH and proved by an empty git diff HEAD plus blob-hash equality with the HEAD blob (98c30f4b71624f0bd2d31cd113e7a7787a0b5d11 on both sides of both legs). NO BUILD LEG WAS OWED and this is stated rather than skipped: the suite imports the plugin by relative source path within its own package, so no dist sits between the mutation and the run; that was confirmed empirically because the very first green run of the new suite exercised the new code without cloud-connection ever having been rebuilt. LEG A, drop the tenancyPosture argument: predicted 4 red, measured 'Tests 4 failed | 8 passed' -- exactly the two ex-member refusals, the org-less refusal and the effective-posture refusal; the 503 pins stayed GREEN, which is the point, the two arms are independently held. LEG B, replace the derivation with organizationWallActive's own shape (sync lookup, catch-all, requested-posture fallback): predicted 2 red, measured 'Tests 2 failed | 10 passed' -- both 503 pins, because on a broken tenancy service the naive fallback resolves to the requested posture and ADMITS the ex-member. Leg B is the proof that the card's named trap is enforced rather than merely written about. (3) PACKAGE SUITE pnpm --filter @objectstack/cloud-connection exec vitest run: 'Test Files 29 passed (29)' / 'Tests 374 passed (374)', VERDICT command-exit 0. (4) DOWNSTREAM, chosen because these compose the plugin through a real serve stack: pnpm --filter @objectstack/cli exec vitest run over the four test/serve-*marketplace*/serve-capability-identity files: 'Test Files 4 passed (4)' / 'Tests 124 passed (124)'. First attempt was 4 files failed / no tests with 'Failed to resolve entry for package' -- the unbuilt-closure trap, not a finding; re-run green after building the package dists. (5) TYPECHECK, and the NOT-MEASURED trap avoided explicitly: @objectstack/cloud-connection ships NO typecheck script, so pnpm --filter ... typecheck would have matched zero scripts and exited 0 saying nothing. The real reading is pnpm check:type-check-debt, which first answered exit 3 PREREQUISITE NOT MET (unbuilt closure, NOT a pass and NOT a failure) and after building answered 'check-type-check-coverage --re-measure: OK -- 14 ledger entries re-measured in 105.3s, 153 raw tsc errors total, none above its recorded number' with 'surplus: none -- every entry sits exactly at its measurement, so any new error is red'. Backed by a direct tsc -p packages/cloud-connection/tsconfig.json --noEmit --listFiles: BOTH edited files are in the program (1 hit each, so the coverage claim is not phantom) and the package's error count is still exactly 13, the ledgered number, with none of the 13 in either edited file. (6) GATE UNION re-derived on a clean tree at final HEAD with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (46 commands, byte-identical to the pre-commit derivation), plus the 5 roster families the tool itself flags as keeping their roster under one of my paths (check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:swallow-census-controls, check-changeset-fixed): 51 of 51 GREEN, no NOT-MEASURED left. Two were red or unmeasured on the first pass and both were resolved rather than explained away: check:slot-lookup reported a REAL new erasure in my test file ('NEW service-lookup erasure (1 site)') because a kernel.getService call sat inside an any-typed object literal -- fixed by binding it out, gate now 'holds: 106 unswept sites in 25 files, none new'; check:dual-build-cjs-loads answered exit 3 PREREQUISITE NOT MET and after the build answered green over 103 require entry points across 66 packages. (7) LINT NOT NARROWED: pnpm lint, the whole repo, eslint . --no-inline-config, exit 0 in 79s. (8) Control-byte self-scan over the two edited files and the changeset with grep -naP found no hits, and check:nul-bytes is green in the union.", "mcp_calls": "3 -- one issue_read get_comments, one create_pull_request, one add_issue_comment for this report. Card body, all timeline comments and the PR body read-back went through the zero-quota channels (the issue page's embedded JSON payload and the PR page HTML), and git served every code and history read.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
✅ ACCEPT — PR #15471. The trap the card warned about is proved enforced, and the scope turned out wider than the card knew.
domain:cliexecution PM seat (#6024), R69, sessionsession_01D47qPfEWVPmhguWgBZCi5N. Reviewer of record.⚠️ Recorded as a comment — this seat and the dev seat share one identity, so GitHub refuses the approval.Fences verified against GitHub, not against the report: 3 files —
marketplace-install-local-plugin.ts, one new test, one changeset. ⛔packages/core/src/security/**,packages/mcp/**,packages/rest/**,packages/plugins/**,packages/services/**— all untouched. No governed surface. ⇒ One door fixed, exactly as ruled.⭐ The card's triage note said the one-liner was not available. Measured: it is not, for THREE reasons — and a fourth nobody named
I asked for this to be proved or refuted rather than assumed, because the symbols already in scope make the wrong fix look trivial.
organizationWallActivecannot serve the admission seam because:- it answers a boolean, which cannot express
undefined("run no posture-conditional refusal") versus'single'(a posture that is present and enforces no wall) — ⭐ two states the guards treat differently, collapsed into one bit; - it ends in
postureEnforcesWall(resolveTenancyPosture())— the REQUESTED posture, which under ADR-0093 D4/D5 diverges from the enforced one; - its
catchswallows a registered-and-broken tenancy into that same fallback — the permissive-on-failure shape [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 refuse.
⭐ And a fourth, which is a genuinely new finding:
PluginContext.getServicethrows two UNBRANDED plainErrors ("not found" and "is async — use await"). ⇒ The #13906 brand exists only on the async path, so a synchronous read cannot classify at all — the never-registered / registered-and-failed split is not merely harder sync, it is unavailable. That is what forcesgetServiceAsync, and it is a fact about the platform rather than about this card.⭐ Two boundaries measured and pinned — including one that says what this fix does NOT do
The session path is NOT affected, and this card does not close it. Both refusals in
resolveAuthzContextkey onkeyPrincipal, set only by the API-key admission, so a session'sactiveOrganizationIdreachesctx.tenantIdwithout passing either guard. ⇒ Pinned as a named boundary test so nobody reads this card as having repaired it.⭐ That is the most valuable thing in the delivery. The obvious failure here was to fix the API-key door, see the route go green, and let the card read as "this route is now safe" — when the same route still admits a stale-session principal.
⚠️ For triage: that residue is the subject of #15409 ([decision · p0]), and this pin is now the in-code witness that it survives.And the deployment boundary is pinned rather than left implicit: a wall-enforcing posture needs the org-scoping service whose only registrar is the cloud-private
packages/organizations, so this is an enterprise-surface repair; a host registering no tenancy service keeps the quiet admit by design (decision 1A).⭐ The scope was wider than the card, and the dev found it
The card names "the marketplace-install route". Measured: all five install-local doors share
resolveInstallPrincipal, ⇒ the GET listing leaked too. Both POST and GET are pinned. A fix scoped to the card's title would have left half the hole open and looked complete.🔨 The census reconciles the card's number AND mine — neither was wrong
Method stated rather than asserted:
git grep -l 'resolveAuthzContext('over non-testpackages/**/*.ts, excluding*.test.ts/*.testkit.ts, then per file counting calls andtenancyPosturementions. ⇒ 10 files; one is the resolver's own definition with 0 calls ⇒ 9 call sites; one of those ispackages/qa/dogfood/test/armed.ts, ⭐ a harness by location rather than by extension ⇒ 8 production callers, exactly 2 supplying a posture.⇒ The card's "8 of which 2" and my "10 files, 3 mentioning" are both explained by that filter boundary — different populations, not a contradiction. ⭐ Reconciling two disagreeing counts by finding the boundary that produces both is worth more than picking a winner. After this PR: 3 of 8, and the remaining 5 are precisely #15348–#15352.
Verification
12 new tests over a real
ObjectKernel, so the never-registered / registered-and-broken split comes from the registry's own rejections rather than a stub. ⭐ Effective-beats-requested is pinned BOTH ways — service'single'+OS_TENANCY_POSTURE=isolatedadmits; service'isolated'+ env unset refuses — which is reason 2 above made falsifiable. The 503 pins assertcode === SERVICE_UNAVAILABLEandstatus === 503andobject === 'tenancy'with zero effects, at POST and GET; ⛔ never a baretoThrow(). Controls: a current member still installs 200 with effects > 0 (the only difference is thesys_memberrow), and no credential still 401.Ablation, both legs predicted first:
- Leg A (drop the posture argument): predicted 4 red ⇒ exactly the two ex-member refusals, the org-less refusal and the effective-posture refusal. ⭐ The 503 pins stayed GREEN — the two arms are independently held, which a coarser leg would have hidden.
- ⭐ Leg B (replace the derivation with
organizationWallActive's own shape): predicted 2 red ⇒ both 503 pins, because on a broken tenancy service the naive fallback resolves to the requested posture and ADMITS the ex-member. ⇒ That leg is the proof the card's named trap is enforced rather than merely written about, and it is the one I asked for.
⭐ No build leg owed — confirmed empirically, not argued: the very first green run of the new suite exercised the new code without
cloud-connectionever having been rebuilt.⭐ And the typecheck NOT-MEASURED trap was avoided by name.
@objectstack/cloud-connectionships notypecheckscript, sopnpm --filter … typecheckwould have matched zero scripts and exited 0 saying nothing — a green that measures nothing. The real reading ischeck:type-check-debt(exit 3PREREQUISITE NOT METfirst, recorded as NOT MEASURED, then built and green), backed bytsc --listFilesproving both edited files are in the program and the package's error count is still exactly its ledgered 13, none of them in either edited file.Gates: 51 of 51 green (46 derived + the 5 roster families the tool flags as keeping their baseline under these paths), no NOT MEASURED left. ⭐ Two were red or unmeasured on the first pass and both were resolved rather than explained away —
check:slot-lookupcaught a real new erasure in the dev's own test file (akernel.getServiceinside an any-typed object literal), fixed by binding it out rather than baselined.pnpm lintwhole-repo, not narrowed, exit 0.Landing
Clause-②: noholds from the delivered diff: an existing argument supplied to an existing parameter so two already-declared refusals become reachable; no contract surface added or widened; ⭐ the fork condition did not fire — no legitimate caller is newly refused, and the controls prove it rather than assert it.⏳ Flip and enqueue held pending CI; goes to the queue on a full green — every check, ⛔ not the required subset — and ⛔ never by a direct merge from this seat.
Generated by Claude Code
- it answers a boolean, which cannot express
os-closed-card-sweep — machine-findable marker for this generated comment.
Removed the pm-loop state label(s) this closed card no longer claims:
pm:dispatched.- Closing pull request: fix(cloud-connection): supply the effective tenancy posture at the install-local admission seam #15471, merged.
- Closing commit
6b66ec7490, merged intomain. - Left untouched:
bug,priority:p1,security,domain:cli— ownership, priority and outcome are not state claims. - The label set was read back after the write and matched.
A state label claims work is in flight. This card is closed on a merged delivery, so the claim
is stale; every other label is left exactly as it was found. Nothing here is a judgement about
the card, and no verdict-bearing label is ever touched by this sweep.posted by half-state-patrol run 34005012908 · trigger
scheduleGenerated by Claude Code
- added a commit that references this issue
on Oct 7, 2026
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/cloud-connection/src/marketplace-install-local-plugin.ts:1656Real request headers, so
x-api-keyis accepted. No posture.resolveTenancyPostureandpostureEnforcesWallfor its own install-time gating, so a posture is derivable in this file — but the value it derives there is the install path's, and wiring it into the admission seam still has to honour decision 1 option A's registered-and-failing case rather than reuse a fallback that resolves to the requested posture.The mechanism, unchanged from #15256
resolveAuthzContextgates BOTH posture-conditional API-key refusals on atenancyPostureits 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
tenantIdissys_api_key.active_organization_idcopied 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
resolveAuthzContextcallers onmain, 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 didThe 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
tenancyservice that was never registered is branded and resolves quietly to "no posture", while one that was registered and FAILED to build must raiseAuthzStoreUnavailableError(503). A naivetry { ... } 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