|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { |
| 4 | + postureEnforcesWall, |
| 5 | + postureUsesUnionScope, |
| 6 | + type TenancyPosture, |
| 7 | + type TenantLayer0Verdict, |
| 8 | +} from '@objectstack/spec/security'; |
| 9 | + |
| 10 | +/** |
| 11 | + * ── The ONE tenant read predicate (ADR-0131 D8 「一道谓词,算一次」) ────────── |
| 12 | + * |
| 13 | + * What the organization wall decides for one operation, computed by ONE |
| 14 | + * function for both of its enforcers: |
| 15 | + * |
| 16 | + * - **Layer 0** (`plugin-security`'s `tenant-layer.ts`, ADR-0095 D1) projects |
| 17 | + * the verdict onto the `where` predicate it AND-composes first, and records |
| 18 | + * it on the operation (`OperationContext.tenantLayer0Verdict`, #15813); |
| 19 | + * - **the engine** (`@objectstack/objectql`, `buildDriverOptions`) projects the |
| 20 | + * same verdict onto `DriverOptions.tenantId` / `tenantIds`, the driver's |
| 21 | + * native read scope — the recorded verdict where the wall ran, this function |
| 22 | + * over the engine's own facts where it did not (a system context, a by-id |
| 23 | + * write, a composition with no security layer). |
| 24 | + * |
| 25 | + * Before this module the engine derived the driver's scope on its own, from |
| 26 | + * the active organization alone: a scope under `single`, where the wall is |
| 27 | + * inert, and a scope for the exempt platform admin, whom the wall lets |
| 28 | + * through — two walls, two answers. Here, so `@objectstack/objectql` and |
| 29 | + * `@objectstack/plugin-security` (both already depend on this package) read |
| 30 | + * one decision rather than two copies of it. |
| 31 | + */ |
| 32 | + |
| 33 | +/** Inputs the Layer 0 decision needs — all cheap, already-loaded facts. */ |
| 34 | +export interface TenantLayer0Input { |
| 35 | + /** |
| 36 | + * [ADR-0105 D1] The tenancy posture actually IN FORCE (`tenancy.posture`) — |
| 37 | + * the single fact that decides what this layer enforces: |
| 38 | + * |
| 39 | + * - `single` → inert (no wall) |
| 40 | + * - `group` → `organization_id IN accessibleOrgIds` (union / MOAC) |
| 41 | + * - `isolated` → `organization_id = organizationId` (the hard wall) |
| 42 | + * |
| 43 | + * A degraded deployment (a wall requested but not enforceable) already |
| 44 | + * resolves to `single` at the service, so this layer never sees a posture it |
| 45 | + * cannot honour. |
| 46 | + */ |
| 47 | + tenancyPosture: TenancyPosture; |
| 48 | + /** The resolved authz context's active organization id (`ExecutionContext.tenantId`). */ |
| 49 | + organizationId?: string; |
| 50 | + /** |
| 51 | + * [ADR-0105 D2] The caller's org access set |
| 52 | + * (`ExecutionContext.accessible_org_ids`) — every organization they hold a |
| 53 | + * currently-valid membership in. Read ONLY under the `group` posture, where |
| 54 | + * it is the wall. An empty/absent set there denies (fail closed): a principal |
| 55 | + * with no membership has no group data reach. |
| 56 | + */ |
| 57 | + accessibleOrgIds?: readonly string[]; |
| 58 | + /** |
| 59 | + * Whether the object carries an `organization_id` column. `undefined` = the |
| 60 | + * schema/field-set could not be resolved yet (boot); treated as "assume tenant |
| 61 | + * object" so a not-yet-registered object still fails safe toward isolation |
| 62 | + * (parity with the pre-extraction path, which kept the wildcard tenant policy |
| 63 | + * when the field set was unavailable). |
| 64 | + */ |
| 65 | + objectHasOrgIdField?: boolean; |
| 66 | + /** |
| 67 | + * Whether the object opted out of tenancy (`tenancy.enabled === false` or |
| 68 | + * `systemFields.tenant === false`) — a platform-global object. Such objects |
| 69 | + * are NOT tenant objects; Layer 0 contributes nothing. |
| 70 | + */ |
| 71 | + tenancyDisabled: boolean; |
| 72 | + /** |
| 73 | + * Whether the object's POSTURE permits a platform admin to cross the tenant |
| 74 | + * wall — `private`, platform-global (`tenancy.enabled:false`), or |
| 75 | + * better-auth-managed (exactly the ADR-0066 ① gate). Business (public) tenant |
| 76 | + * objects do NOT permit it, so a platform admin stays org-scoped on them. |
| 77 | + */ |
| 78 | + posturePermitsCrossTenant: boolean; |
| 79 | + /** |
| 80 | + * TRUE-platform-admin evidence (ADR-0095 D3 `PLATFORM_ADMIN` rung) for THIS |
| 81 | + * operation side. This is the ONLY gate that lets a caller cross the tenant |
| 82 | + * wall, so it MUST distinguish a PLATFORM operator from a TENANT `org_admin`. |
| 83 | + * |
| 84 | + * It is NOT merely the superuser bit (`viewAllRecords`/`modifyAllRecords`): |
| 85 | + * `organization_admin` holds that bit via its `'*'` wildcard grant |
| 86 | + * (`default-permission-sets.ts`), so gating the exemption on the superuser bit |
| 87 | + * alone let an org admin cross the wall on a `private` (or platform-global / |
| 88 | + * better-auth) TENANT object — the D1 "B2 posture stand-in" gap this field's |
| 89 | + * former semantics carried. The caller now requires the superuser bit AND a |
| 90 | + * platform-EXCLUSIVE capability (`manage_metadata`/`manage_platform_settings`/ |
| 91 | + * `studio.access`/`manage_users` — the caps `admin_full_access` carries and |
| 92 | + * `organization_admin` deliberately withholds), i.e. the `PLATFORM_ADMIN` |
| 93 | + * posture. It is NEVER the better-auth role. A caller holding no permission |
| 94 | + * sets (the engine, over its own facts) passes `false`. |
| 95 | + */ |
| 96 | + isPlatformAdmin: boolean; |
| 97 | +} |
| 98 | + |
| 99 | +/** The two verdicts that carry no organization — shared, frozen, safe to hand out. */ |
| 100 | +const LAYER0_NONE: TenantLayer0Verdict = Object.freeze({ kind: 'none' as const }); |
| 101 | +const LAYER0_DENY: TenantLayer0Verdict = Object.freeze({ kind: 'deny' as const }); |
| 102 | + |
| 103 | +/** |
| 104 | + * [#15813 / ADR-0131 D8] Compute the Layer 0 VERDICT — what the wall decides |
| 105 | + * for this operation, as a value another package can read without re-deriving |
| 106 | + * it (`TenantLayer0VerdictSchema`, `@objectstack/spec/security`). |
| 107 | + * `computeTenantLayer0Filter` (plugin-security) is this verdict PROJECTED onto |
| 108 | + * a `where` predicate; the engine's driver scope is the same verdict projected |
| 109 | + * onto `DriverOptions`. Neither can disagree with the other, because both are |
| 110 | + * derived from this verdict, never computed beside it. |
| 111 | + * |
| 112 | + * The decision ladder is the wall's own, in the wall's own order: every exit |
| 113 | + * that answers `null` as a filter answers `none` here, every fail-closed exit |
| 114 | + * answers `deny`, and the two walled shapes answer the organization(s) they |
| 115 | + * name. The `group` set is DEDUPLICATED here (`accessible_org_ids` is a |
| 116 | + * membership list; a repeated membership names one organization once), so a |
| 117 | + * reader may test `organizationIds.length === 1` and read no further. |
| 118 | + * |
| 119 | + * - `none` → the wall contributes nothing (`single` posture; non-tenant object; |
| 120 | + * or an exempt platform admin). |
| 121 | + * - `organization` → the `isolated` wall, `organization_id = <id>`. |
| 122 | + * - `organizations` → the `group` union wall, `organization_id IN <set>` |
| 123 | + * (ADR-0105 D2). |
| 124 | + * - `deny` → a walled posture on a tenant object, but the context carries no |
| 125 | + * organization scope to enforce with (no active org under `isolated`, empty |
| 126 | + * access set under `group`) → fail closed (zero rows / write denied). |
| 127 | + */ |
| 128 | +export function computeTenantLayer0Verdict(input: TenantLayer0Input): TenantLayer0Verdict { |
| 129 | + // `single` posture (incl. a degraded deployment, which resolves to `single`) |
| 130 | + // → parity with today's policy stripping. |
| 131 | + if (!postureEnforcesWall(input.tenancyPosture)) return LAYER0_NONE; |
| 132 | + |
| 133 | + // Not a tenant object: platform-global (tenancy disabled) or simply carries no |
| 134 | + // `organization_id` column (e.g. better-auth identity tables like `sys_user`). |
| 135 | + // Layer 0 contributes nothing — the object's own business RLS (Layer 1, e.g. |
| 136 | + // `_self` carve-outs) is its only scoping. |
| 137 | + if (input.tenancyDisabled) return LAYER0_NONE; |
| 138 | + if (input.objectHasOrgIdField === false) return LAYER0_NONE; |
| 139 | + |
| 140 | + // Exemption is a Layer 0 rule (W2 fix): only a TRUE PLATFORM_ADMIN caller on an |
| 141 | + // object whose posture permits it crosses the wall — NOT a tenant `org_admin` |
| 142 | + // that merely holds the superuser bit (Finding 2 / #2937). Layer 1's superuser |
| 143 | + // bypass no longer implies Layer 0's. |
| 144 | + if (input.isPlatformAdmin && input.posturePermitsCrossTenant) return LAYER0_NONE; |
| 145 | + |
| 146 | + // [ADR-0105 D2] `group`: union access over the caller's memberships (MOAC). |
| 147 | + // The ACTIVE organization no longer bounds reads here — membership does — so |
| 148 | + // a group-HQ analyst sees every plant they belong to on one screen without |
| 149 | + // context switching, and a plant admin still sees only their own plants. |
| 150 | + // An empty access set is the fail-closed case: no membership, no reach. |
| 151 | + if (postureUsesUnionScope(input.tenancyPosture)) { |
| 152 | + const orgIds = input.accessibleOrgIds; |
| 153 | + if (!orgIds || orgIds.length === 0) return LAYER0_DENY; |
| 154 | + return { kind: 'organizations', organizationIds: [...new Set(orgIds)] }; |
| 155 | + } |
| 156 | + |
| 157 | + // `isolated`: the hard wall. Missing active org → fail closed. |
| 158 | + if (!input.organizationId) return LAYER0_DENY; |
| 159 | + return { kind: 'organization', organizationId: input.organizationId }; |
| 160 | +} |
0 commit comments