Skip to content

Commit 6b66ec7

Browse files
os-litantclaude
andauthored
fix(cloud-connection): supply the effective tenancy posture at the install-local admission seam (#15471)
* fix(cloud-connection): derive the effective tenancy posture at the install-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 * test(cloud-connection): pin the install-local admission posture, and 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f85e4d commit 6b66ec7

3 files changed

Lines changed: 539 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
---
4+
5+
Fix: the marketplace install-local routes now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply at these doors.
6+
7+
Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran here, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership.
8+
9+
The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage and answers 503 rather than admitting the caller.

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,20 @@
4646
*/
4747

4848
import type { Plugin, PluginContext } from '@objectstack/core';
49-
import { resolveAuthzContext, isAuthzStoreUnavailableError } from '@objectstack/core';
49+
import {
50+
resolveAuthzContext,
51+
isAuthzStoreUnavailableError,
52+
// [#15353] The three symbols the ADMISSION seam's posture derivation needs
53+
// — the same set `packages/rest`'s repaired seam imports, for the same
54+
// reason. `effectiveTenancyPosture` reads the posture IN FORCE off the
55+
// kernel's `tenancy` service; the other two are decision-1-option-A's
56+
// classification (#13906): never-registered is branded and quiet, every
57+
// other rejection is the outage it is.
58+
effectiveTenancyPosture,
59+
isServiceNotRegisteredError,
60+
AuthzStoreUnavailableError,
61+
type TenancyPostureSource,
62+
} from '@objectstack/core';
5063
import {
5164
resolveTenancyPosture,
5265
collectGlobalUniques,
@@ -1620,6 +1633,88 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
16201633
* embedding could not have held a permission set to be judged on anyway, and
16211634
* could not have run `syncSchemas()` either.
16221635
*/
1636+
/**
1637+
* [#15353] The ADMISSION seam's EFFECTIVE tenancy posture — derived here,
1638+
* and ⛔ never borrowed from this file's install-time posture.
1639+
*
1640+
* ## What was open
1641+
*
1642+
* `resolveAuthzContext` gates BOTH posture-conditional API-key refusals on a
1643+
* posture its CALLER supplies — `organization_required` inside
1644+
* `resolveApiKeyAdmission`, and `organization_membership_ended` after the
1645+
* grants are aggregated. This door supplied none, so neither guard ran, and
1646+
* an API key's `tenantId` is `sys_api_key.active_organization_id` copied
1647+
* verbatim: the caller's own stored claim, never vetted against current
1648+
* membership. Under a wall-enforcing posture a key stamped with an
1649+
* organization its owner has LEFT was admitted carrying that organization as
1650+
* its tenant — through all five install-local doors, since they share
1651+
* {@link resolveInstallPrincipal}.
1652+
*
1653+
* ## ⛔ Why the two postures already in this file are the WRONG value
1654+
*
1655+
* They are install-time seeding decisions, and both differ from what an
1656+
* admission decision needs — measured, not assumed:
1657+
*
1658+
* - {@link organizationWallActive} answers a BOOLEAN ("will the per-org
1659+
* replay own this seeding?"), which cannot express the distinction the
1660+
* resolver keys on: `undefined` means "run no posture-conditional refusal
1661+
* at all", while `'single'` is a posture that is present and simply
1662+
* enforces no wall. It also ends in `postureEnforcesWall(resolveTenancyPosture())`
1663+
* — the REQUESTED posture, read from `OS_TENANCY_POSTURE`. Under ADR-0093
1664+
* D4/D5 a deployment that requests `isolated` without the enterprise
1665+
* organizations runtime is `single` in force, so the requested value would
1666+
* refuse legitimate automation over a wall that is not there.
1667+
* - `evaluateGlobalUniqueGate` reads `resolveTenancyPosture()` outright, the
1668+
* same requested value.
1669+
*
1670+
* And its `catch` swallows EVERY failure into that requested fallback — the
1671+
* permissive-on-failure shape #13906 exists to refuse, where a `tenancy`
1672+
* service that was wired and BROKE reads as "this check does not apply".
1673+
* ⛔ Do not wire either of them into this seam, and ⛔ do not add a
1674+
* `catch { undefined }` here.
1675+
*
1676+
* ## The classification, decision 1 option A (#13906)
1677+
*
1678+
* - **Never registered** ⇒ branded (`isServiceNotRegisteredError`), quiet
1679+
* `undefined`. A lean embedding with no `plugin-auth` is a SUPPORTED
1680+
* composition, and behaviour there is exactly what it was.
1681+
* - **Registered and unable to answer** ⇒ `AuthzStoreUnavailableError`
1682+
* (ADR-0112 `SERVICE_UNAVAILABLE` / 503). Admission was never DECIDED, so
1683+
* it must not be answered. {@link resolveInstallPrincipal}'s `catch`
1684+
* already re-raises this brand rather than collapsing it to `null` (401).
1685+
*
1686+
* ⚠️ The brand exists only on the ASYNC resolution path: `PluginContext.getService`
1687+
* throws two UNBRANDED plain `Error`s (`… not found` and `… is async - use
1688+
* await`), so a synchronous read cannot tell the two facts apart and would
1689+
* have to guess. Hence `getServiceAsync`, reached through `getKernel()`.
1690+
*
1691+
* ⚠️ The ASYNC ACCESSOR's presence is part of the wiring fact, exactly as
1692+
* on the REST seam: a `KernelBase`-shaped host (`LiteKernel`) exposes
1693+
* `getKernel()` but has no `getServiceAsync` at all, so dereferencing it
1694+
* would raise an unbranded — therefore LOUD — `TypeError`, turning "this
1695+
* host shape has no async registry" into an outage. Such a host has no
1696+
* service factories either, so absence is the only fault it could report.
1697+
* It keeps the quiet answer.
1698+
*/
1699+
private resolveAdmissionTenancyPosture = async (
1700+
ctx: PluginContext,
1701+
): Promise<TenancyPosture | undefined> => {
1702+
const kernel = ctx.getKernel?.() as
1703+
| { getServiceAsync?: <T>(name: string, scopeId?: string) => Promise<T> }
1704+
| undefined;
1705+
if (!kernel || typeof kernel.getServiceAsync !== 'function') return undefined;
1706+
try {
1707+
return effectiveTenancyPosture(
1708+
await kernel.getServiceAsync<TenancyPostureSource>('tenancy'),
1709+
);
1710+
} catch (err) {
1711+
if (!isServiceNotRegisteredError(err)) {
1712+
throw new AuthzStoreUnavailableError('tenancy', err);
1713+
}
1714+
return undefined;
1715+
}
1716+
};
1717+
16231718
private resolveInstallPrincipal = async (
16241719
c: any,
16251720
ctx: PluginContext,
@@ -1653,7 +1748,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
16531748
}
16541749
};
16551750

1656-
const authz = await resolveAuthzContext({ ql, headers, getSession });
1751+
// [#15353] The posture is an authorization INPUT, resolved for
1752+
// THIS door before the resolver runs. Omitting it is what left
1753+
// both posture-conditional API-key refusals unreachable here.
1754+
const tenancyPosture = await this.resolveAdmissionTenancyPosture(ctx);
1755+
const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture });
16571756
if (!authz.userId) return null;
16581757
return {
16591758
userId: String(authz.userId),

0 commit comments

Comments
 (0)