|
46 | 46 | */ |
47 | 47 |
|
48 | 48 | 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'; |
50 | 63 | import { |
51 | 64 | resolveTenancyPosture, |
52 | 65 | collectGlobalUniques, |
@@ -1620,6 +1633,88 @@ export class MarketplaceInstallLocalPlugin implements Plugin { |
1620 | 1633 | * embedding could not have held a permission set to be judged on anyway, and |
1621 | 1634 | * could not have run `syncSchemas()` either. |
1622 | 1635 | */ |
| 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 | + |
1623 | 1718 | private resolveInstallPrincipal = async ( |
1624 | 1719 | c: any, |
1625 | 1720 | ctx: PluginContext, |
@@ -1653,7 +1748,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { |
1653 | 1748 | } |
1654 | 1749 | }; |
1655 | 1750 |
|
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 }); |
1657 | 1756 | if (!authz.userId) return null; |
1658 | 1757 | return { |
1659 | 1758 | userId: String(authz.userId), |
|
0 commit comments