Skip to content

fix(cloud-connection): supply the effective tenancy posture at the install-local admission seam - #15471

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-15353-cloud-connection-tenancy-posture
Sep 4, 2026
Merged

fix(cloud-connection): supply the effective tenancy posture at the install-local admission seam#15471
os-litant merged 2 commits into
mainfrom
claude/issue-15353-cloud-connection-tenancy-posture

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15353

packages/cloud-connection/src/marketplace-install-local-plugin.ts called resolveAuthzContext with real request headers and no tenancyPosture. Both posture-conditional API-key refusals are gated on a posture the caller supplies — organization_required in resolveApiKeyAdmission, organization_membership_ended after the grants are aggregated — so neither guard ran here. An API key's tenantId is sys_api_key.active_organization_id copied 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 share resolveInstallPrincipal).

The fix

A new private resolveAdmissionTenancyPosture(ctx) reads the posture IN FORCE off the kernel's tenancy service and carries decision-1-option-A's classification (#13906):

  • never registered → branded (isServiceNotRegisteredError), quiet undefined, no posture-conditional refusal. A lean embedding without plugin-auth is a supported composition and is byte-for-byte unchanged.
  • registered and unable to answerAuthzStoreUnavailableError (SERVICE_UNAVAILABLE / 503). Admission was never decided, so it is not answered. resolveInstallPrincipal's existing catch already re-raises that brand rather than collapsing it to null (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:

The classification also cannot be read synchronously: PluginContext.getService throws two unbranded plain Errors (… not found and … is async - use await), so the brand exists only on the async path. Hence getServiceAsync, reached through getKernel(), with the same typeof … === 'function' guard the REST seam uses for KernelBase-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, tenancy registered on a real ObjectKernel so 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, tenancy reporting isolated:

door before after
POST /install-local (registry + syncSchemas + ledger) 200, all three effects fired 401, zero effects
GET /install-local (the listing) 200, install list served 401
an org-LESS key, same posture 200 401
a CURRENT member's key (control) 200 200 — unchanged
no credential (control) 401 401 — unchanged

Also pinned: the posture is the effective one in both directions (service single + OS_TENANCY_POSTURE=isolated admits; service isolated + env unset refuses); a never-registered tenancy and a host with no async registry both keep the quiet answer; a registered-but-broken tenancy raises the 503 envelope (asserting code and status, never a bare toThrow()) with zero effects.

One boundary is pinned deliberately: a session whose activeOrganizationId names an org the caller does not belong to is not refused. Both refusals in resolveAuthzContext key on keyPrincipal, 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_PATH under a trap, proved by an empty git diff HEAD plus blob-hash equality with the HEAD blob (98c30f4b71624f0bd2d31cd113e7a7787a0b5d11 both sides, both legs). No build leg: the suite imports the plugin by relative source path, so no dist sits between the mutation and the run.

  • Leg (a) — drop the tenancyPosture argument. 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.
  • Leg (b) — replace the derivation with organizationWallActive's own shape (sync lookup, catch-all, requested-posture fallback). Predicted 2 red; measured 2 failed / 10 passed: on a broken tenancy service 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 run29 files, 374 tests passed.
  • Downstream consumers that compose this plugin: 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).
  • Typecheck: @objectstack/cloud-connection has no typecheck script and carries a 13-error DEBT entry. pnpm check:type-check-debtOK — 14 ledger entries re-measured in 105.3s, 153 raw tsc errors total, none above its recorded number, with surplus: none. A direct tsc -p … --noEmit --listFiles confirms both edited files are in the program and the package's error count is still exactly 13, none of them in either edited file.
  • Gate union re-derived on a clean tree at final HEAD with 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. Includes check: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 its VERDICT line.

Changeset: patch for @objectstack/cloud-connection. Per the Check Changeset step's WHICH LEVEL rule, minor is 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 the fix( stays patch.


🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cloud-connection, touching 4 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via MarketplaceInstallLocalPlugin (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c3b6da4bca58e7d275ee0f81e5bd58c2591df093packageMentionDocs.

Which tree this was computed on

This run read content/docs from 62b4f0f6484c42b5ed69336ca6d52328fefa0d8b — the merge of head 31d38c81b5fb266a6811cdfe0f14ca5a75a5a38d into base c3b6da4bca58e7d275ee0f81e5bd58c2591df093, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c3b6da4bca58e7d275ee0f81e5bd58c2591df093 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted

2 participants