fix(cloud-connection): supply the effective tenancy posture at the install-local admission seam - #15471
Conversation
…stall-local admission seam `resolveInstallPrincipal` called `resolveAuthzContext` with no `tenancyPosture`, so both posture-conditional API-key refusals stayed unreachable through all five install-local doors: an ex-member's org-stamped key and an organization-less key were admitted under a wall-enforcing posture. The posture is derived here through the kernel's async registry, carrying decision-1-option-A's classification: a `tenancy` service that was never registered resolves quietly to "no posture", one that was registered and failed to build raises `AuthzStoreUnavailableError` (503). Neither of this file's install-time postures is reused — both answer the REQUESTED posture and both swallow a build failure into it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…add the changeset Drives the defect through the composed plugin over a real ledger directory with the `tenancy` service on a real kernel: an ex-member's org-stamped key and an org-less key are refused at POST and GET, a current member's key and an anonymous caller are unchanged controls, the effective posture beats the requested one in both directions, a never-registered `tenancy` keeps the quiet answer, and a registered-but-broken one raises the ADR-0112 503 envelope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 62b4f0f6484c42b5ed69336ca6d52328fefa0d8b && git checkout 62b4f0f6484c42b5ed69336ca6d52328fefa0d8b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c3b6da4bca58e7d275ee0f81e5bd58c2591df093 31d38c81b5fb266a6811cdfe0f14ca5a75a5a38d && git checkout -B drift-repro c3b6da4bca58e7d275ee0f81e5bd58c2591df093 && git merge --no-ff 31d38c81b5fb266a6811cdfe0f14ca5a75a5a38d
node scripts/docs-audit/affected-docs.mjs --json c3b6da4bca58e7d275ee0f81e5bd58c2591df093
|
Fixes #15353
packages/cloud-connection/src/marketplace-install-local-plugin.tscalledresolveAuthzContextwith real request headers and notenancyPosture. Both posture-conditional API-key refusals are gated on a posture the caller supplies —organization_requiredinresolveApiKeyAdmission,organization_membership_endedafter the grants are aggregated — so neither guard ran here. An API key'stenantIdissys_api_key.active_organization_idcopied verbatim: the caller's own stored claim, never vetted against current membership. Under a wall-enforcing posture, a key stamped with an organization its owner has left was admitted carrying that organization as its tenant, through all five install-local doors (they shareresolveInstallPrincipal).The fix
A new private
resolveAdmissionTenancyPosture(ctx)reads the posture IN FORCE off the kernel'stenancyservice and carries decision-1-option-A's classification (#13906):isServiceNotRegisteredError), quietundefined, no posture-conditional refusal. A lean embedding withoutplugin-authis a supported composition and is byte-for-byte unchanged.AuthzStoreUnavailableError(SERVICE_UNAVAILABLE/ 503). Admission was never decided, so it is not answered.resolveInstallPrincipal's existingcatchalready re-raises that brand rather than collapsing it tonull(401).The mechanism is copied from the repaired REST seam (
packages/rest/src/rest-server.ts); the branch placement is derived for this door.The two postures already in this file are NOT the value this seam needs
The triage note predicted this, and it measures out:
organizationWallActive()answers a boolean ("will the per-org replay own this seeding?"), which cannot express the distinction the resolver keys on —undefinedmeans run no posture-conditional refusal at all, while'single'is a posture that is present and enforces no wall. It also ends inpostureEnforcesWall(resolveTenancyPosture()), the requested posture, and itscatchswallows every failure into that 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.evaluateGlobalUniqueGate()readsresolveTenancyPosture()outright, the same requested value.The classification also cannot be read synchronously:
PluginContext.getServicethrows two unbranded plainErrors (… not foundand… is async - use await), so the brand exists only on the async path. HencegetServiceAsync, reached throughgetKernel(), with the sametypeof … === 'function'guard the REST seam uses forKernelBase-shaped hosts that have no async registry.Measured, driven through the composed plugin
New suite
marketplace-install-local-tenancy-admission.test.ts— real plugin, real routes, real ledger on a temp dir,tenancyregistered on a realObjectKernelso the never-registered / registered-and-broken distinction comes from the registry's own rejections rather than a hand-branded stub. Ex-member's org-stamped key,tenancyreportingisolated:POST /install-local(registry + syncSchemas + ledger)GET /install-local(the listing)Also pinned: the posture is the effective one in both directions (service
single+OS_TENANCY_POSTURE=isolatedadmits; serviceisolated+ env unset refuses); a never-registeredtenancyand a host with no async registry both keep the quiet answer; a registered-but-brokentenancyraises the 503 envelope (assertingcodeandstatus, never a baretoThrow()) with zero effects.One boundary is pinned deliberately: a session whose
activeOrganizationIdnames an org the caller does not belong to is not refused. Both refusals inresolveAuthzContextkey onkeyPrincipal, set only by the API-key admission — so supplying the posture here changes nothing for a session caller. That is a different mechanism, not a gap in this fix, and the pin exists so nobody reads this card as having closed it.Ablation — predicted first, both legs proved on disk
Mutation confirmed by counting the removed text AND an injected marker; restored with
git checkout HEAD -- ABSOLUTE_PATHunder a trap, proved by an emptygit diff HEADplus blob-hash equality with the HEAD blob (98c30f4b71624f0bd2d31cd113e7a7787a0b5d11both sides, both legs). No build leg: the suite imports the plugin by relative source path, so nodistsits between the mutation and the run.tenancyPostureargument. Predicted 4 red; measured 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.organizationWallActive's own shape (sync lookup, catch-all, requested-posture fallback). Predicted 2 red; measured 2 failed / 10 passed: on a brokentenancyservice the naive fallback resolves to the requested posture and admits the ex-member instead of raising the 503. That is the trap this card names, enforced rather than written.Verification
All at final commit
31d38c81b5, exit codes captured before any pipe, verdicts quoted from each gate's own line.pnpm --filter @objectstack/cloud-connection exec vitest run→ 29 files, 374 tests passed.pnpm --filter @objectstack/cli exec vitest run test/serve-*.test.ts(4 files) → 124 passed (after building the package dists; they fail to resolve entry points without them, independently of this change).@objectstack/cloud-connectionhas notypecheckscript and carries a 13-error DEBT entry.pnpm check:type-check-debt→OK — 14 ledger entries re-measured in 105.3s, 153 raw tsc errors total, none above its recorded number, withsurplus: none. A directtsc -p … --noEmit --listFilesconfirms both edited files are in the program and the package's error count is still exactly 13, none of them in either edited file.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(identical to the first derivation), plus the 5 roster families the tool flags as having their roster under one of these paths: 51/51 green. Includescheck:where-matcher,check:objectql-double-limit,check:slot-lookup,check:cross-package-test-inputs,check:route-envelope,check:dual-build-cjs-loads,check:nul-bytes.pnpm lint(whole repo,eslint . --no-inline-config) → exit 0 in 79s. Not narrowed.Heavy runs went through
bash scripts/pm/os-verify-lock.sh; every verdict above is read from itsVERDICTline.Changeset:
patchfor@objectstack/cloud-connection. Per theCheck Changesetstep's WHICH LEVEL rule,minoris owed by a purely additive widening of a published public surface; this adds no exported symbol and no accepted key or value — it supplies an existing argument to an existing parameter — so thefix(stayspatch.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Generated by Claude Code