diff --git a/.changeset/adr-0099-p1-layer0-reads-rung.md b/.changeset/adr-0099-p1-layer0-reads-rung.md new file mode 100644 index 0000000000..034044e273 --- /dev/null +++ b/.changeset/adr-0099-p1-layer0-reads-rung.md @@ -0,0 +1,9 @@ +--- +'@objectstack/plugin-security': patch +--- + +ADR-0099 P1 (#3211 M2): the Layer 0 cross-tenant exemption gate now reads the carried `ctx.posture` rung (#2956) as authoritative, with the platform-admin capability probe demoted to a fallback for resolver-less contexts (delegated-admin bridge, sharing service, `getReadFilter`). The read and write (insert/update post-image) tenant checks share one decision (`computeLayeredRlsFilter`), so they cannot drift. A probe↔rung disagreement logs a defect breadcrumb and enforces the narrower rung verdict. + +**Behavior change (security narrowing, multi-org / `@objectstack/organizations` only):** a principal whose carried rung is not `PLATFORM_ADMIN` no longer crosses the tenant wall on private / platform-global / better-auth-managed objects, even when its resolved permission sets carry a platform-exclusive capability. Two shapes are affected: (a) a **scoped** `admin_full_access` grant (`sys_user_permission_set.organization_id` non-null), and (b) a custom set granting a platform capability (e.g. `studio.access`) piecemeal alongside a superuser bit. Both are now walled to their own org — the fail-safe direction (the carried rung is a strict subset of the probe). Single-org / env-per-database deployments are unaffected (Layer 0 is inert). + +**Upgrade check:** before upgrading, scan `sys_user_permission_set` for `admin_full_access` rows with a non-null `organization_id`, and custom permission sets whose `systemPermissions` intersect `{manage_metadata, manage_platform_settings, studio.access, manage_users}`. To restore cross-tenant operator access for such a principal, grant the **unscoped** `admin_full_access` instead. The `[authz/ADR-0099]` warn log names any principal hitting the divergence at runtime. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 25c3810033..7537df0a20 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -79,9 +79,15 @@ implementation detail: Keys never collide across packages because object api names are package-namespaced. 3. **RLS: OR within an object, AND with tenant-global.** Multiple row policies - for the same object/operation OR-combine; the wildcard tenant-isolation - policy ANDs on top. `viewAllRecords` / `modifyAllRecords` (super-user - bypass, posture-gated) short-circuit RLS for the object. + for the same object/operation OR-combine; the tenant wall (Layer 0, ADR-0095 + D1) is a separate always-first AND conjunct, not an OR-mergeable policy. + `viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated) + short-circuit the object's *business* RLS. Crossing the **tenant wall**, + though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives + only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or + piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant + Studio/admin *functions* but never widen the tenant data boundary. A tenant + `organization_admin` never crosses it (invariant I1). 4. **Explicit deny — reserved.** There is no deny layer yet; the only implicit denies are the AND-gates in (1) and fail-closed defaults. Permission-set groups + subtractive *muting* (Salesforce-style) are the planned step 4 diff --git a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts index 7dc3ad6c4c..6fa831d7e4 100644 --- a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts +++ b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts @@ -574,26 +574,6 @@ describe('ADR-0099 P0 — probe vs carried-rung equivalence (#3211 M1)', () => { } }); - // ── [I4 staging — the P1 flip target, pinned] ───────────────────────────── - // TODAY the Layer 0 exemption is POSTURE-BLIND: a carried MEMBER rung on the - // ExecutionContext does not wall a scoped-grant holder (the probe path never - // consults it), and a carried PLATFORM_ADMIN rung is not required by a true - // admin. Both cells pin the PRE-FLIP behavior verbatim; under P1 the first - // cell FLIPS to the walled filter ({organization_id:'org-1'}) as the - // adjudicated narrowing, and the second MUST NOT change (rung authoritative). - it('[I4 staging / P1 flip target] carried MEMBER rung does NOT yet wall a scoped-grant holder (posture-blind today)', async () => { - const scopedHolder = { - userId: 'scoped-admin', tenantId: 'org-1', - positions: ['org_member'], permissions: ['admin_full_access'], - posture: 'MEMBER', // carried rung (what resolve-authz-context derives for a SCOPED grant) - }; - expect(await readFilter(OBJECTS.private_obj, scopedHolder)).toBeNull(); // ← flips to {organization_id:'org-1'} at P1 - }); - it('[I4 staging / P1 invariant] a true platform admin with the carried PLATFORM_ADMIN rung stays exempt', async () => { - const carriedAdmin = { ...ROLES.platform_admin, posture: 'PLATFORM_ADMIN' }; - expect(await readFilter(OBJECTS.private_obj, carriedAdmin)).toBeNull(); // ← must NOT change at P1 - }); - // ── [D3 dead branch] capability evidence can never derive EXTERNAL ──────── // The EXTERNAL rung activates only from `audience:'external'` (ADR-0090 D10) // when the portal principal type ships; no combination of capability-grant @@ -607,3 +587,92 @@ describe('ADR-0099 P0 — probe vs carried-rung equivalence (#3211 M1)', () => { } }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// ADR-0099 P1 — Layer 0 exemption reads the carried rung (#3211 M2) +// ═══════════════════════════════════════════════════════════════════════════ +// +// The flip: `computeLayeredRlsFilter`'s Layer 0 cross-tenant exemption now reads +// the CARRIED `ctx.posture` rung as authoritative (#2956 plumbs it), with the +// capability probe demoted to a fallback for contexts that carry no rung. These +// cells assert the flipped behavior for the divergence class the P0 gate pinned: +// - carried MEMBER rung (a scoped `admin_full_access` holder — G1 delta (a)) is +// now WALLED to its org, where the posture-blind probe used to exempt it; +// - a carried PLATFORM_ADMIN rung stays exempt (rung authority, unchanged); +// - a carried TENANT_ADMIN rung stays walled (invariant I1); +// - a resolver-less context (no rung) still uses the probe — the internal +// paths (delegated-admin, sharing service, getReadFilter) are byte-for-byte +// unchanged until ADR-0096 D3 eliminates hand-built contexts; +// - a probe/rung disagreement logs a defect breadcrumb (I4) and enforces the +// narrower rung verdict. +describe('ADR-0099 P1 — Layer 0 exemption reads the carried rung (#3211 M2)', () => { + // G1 delta (a): a SCOPED admin_full_access grant. Its set contents make the + // probe say platform-admin, but the resolver derives MEMBER (#2949), and that + // carried rung now governs — the holder is walled to its own org (the narrowing). + const scopedGrantHolder = { + userId: 'scoped-admin', tenantId: 'org-1', + positions: ['org_member'], permissions: ['admin_full_access'], + posture: 'MEMBER', + }; + + it('[P1 narrowing / delta (a)] carried MEMBER rung WALLS a scoped-grant holder (read)', async () => { + expect(await readFilter(OBJECTS.private_obj, scopedGrantHolder)).toEqual({ organization_id: 'org-1' }); + }); + it('[P1 narrowing / delta (a)] carried MEMBER rung WALLS a scoped-grant holder (write pre-image)', async () => { + expect(await writeFilter(OBJECTS.private_obj, scopedGrantHolder)).toEqual([{ organization_id: 'org-1' }]); + }); + + it('[P1 invariant] a true platform admin carrying PLATFORM_ADMIN stays exempt (read null)', async () => { + const carriedAdmin = { ...ROLES.platform_admin, posture: 'PLATFORM_ADMIN' }; + expect(await readFilter(OBJECTS.private_obj, carriedAdmin)).toBeNull(); + }); + + it('[P1 / I1] org_admin carrying TENANT_ADMIN stays walled (TENANT_ADMIN never crosses Layer 0)', async () => { + const tenantAdmin = { ...ROLES.org_admin, posture: 'TENANT_ADMIN' }; + expect(await readFilter(OBJECTS.private_obj, tenantAdmin)).toEqual({ organization_id: 'org-1' }); + }); + + // Fallback: NO carried rung (a delegated-admin / sharing-service / getReadFilter + // context built without the resolver) → the probe governs, exactly as pre-P1. + // The same scoped holder, sans `posture`, is exempt via the probe — behavior + // preserved on the internal paths. + it('[P1 fallback] a resolver-less context (no rung) uses the probe — behavior preserved', async () => { + const { posture: _drop, ...scopedNoRung } = scopedGrantHolder; + expect(await readFilter(OBJECTS.private_obj, scopedNoRung)).toBeNull(); + }); + + // I4: enforcement and the probe disagree only on the divergence class; the gate + // logs a defect breadcrumb and enforces the (narrower) carried rung. + it('[P1 / I4] a probe↔rung disagreement logs a defect breadcrumb; carried rung wins', async () => { + const plugin = new SecurityPlugin(); + const h = makeHarness({ ...OBJECTS.private_obj, orgScoping: true }); + await plugin.init(h.ctx); await plugin.start(h.ctx); + const opCtx: any = { + object: OBJECTS.private_obj.objectName, operation: 'find', + ast: { where: undefined }, context: scopedGrantHolder, + }; + await h.run(opCtx); + expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' }); // narrower rung enforced + expect(h.ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[authz/ADR-0099]'), + expect.objectContaining({ carriedPosture: 'MEMBER', probePlatformAdmin: true }), + ); + }); + + // Agreement path: a true platform admin carrying PLATFORM_ADMIN — probe and rung + // agree, so NO defect breadcrumb is logged. + it('[P1 / I4] no breadcrumb when probe and rung agree (true platform admin)', async () => { + const plugin = new SecurityPlugin(); + const h = makeHarness({ ...OBJECTS.private_obj, orgScoping: true }); + await plugin.init(h.ctx); await plugin.start(h.ctx); + const opCtx: any = { + object: OBJECTS.private_obj.objectName, operation: 'find', + ast: { where: undefined }, context: { ...ROLES.platform_admin, posture: 'PLATFORM_ADMIN' }, + }; + await h.run(opCtx); + const authzWarn = (h.ctx.logger.warn as any).mock.calls.filter( + (c: any[]) => typeof c[0] === 'string' && c[0].includes('[authz/ADR-0099]'), + ); + expect(authzWarn).toHaveLength(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 96bc13393a..dedfa8d507 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext } from '@objectstack/core'; +import { Plugin, PluginContext, POSTURE_LADDER } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; @@ -68,13 +68,16 @@ import { * `setup.write` are EXCLUDED on purpose: org admins hold them (they are the Setup * app shell + tenant-settings-write caps, not platform powers). * - * NOTE: the fully-correct signal is the `PLATFORM_ADMIN` posture already derived - * in `resolve-authz-context.ts` (`ctx.posture`), but that field is NOT plumbed - * into the ExecutionContext the enforcement middleware receives (the REST/runtime - * transports drop it), so consuming it here would silently no-op. This capability - * probe reads the SAME resolved permission sets enforcement already uses, works on - * every entry point, and — being a strict subset of the old superuser-bit gate — - * can only NARROW the exemption, never widen it (fail-safe). + * [ADR-0099 D1 / P1] Since #2956 the resolver-derived `PLATFORM_ADMIN` rung + * rides the ExecutionContext (`ctx.posture`), so the Layer 0 exemption gate now + * reads the CARRIED rung as authoritative (see {@link isCarriedPosture} at the + * decision site in `computeLayeredRlsFilter`). This capability probe DEMOTES to + * a fallback: it applies only to contexts that never passed the shared resolver + * (delegated-admin bridge, sharing service, `getReadFilter` consumers — the + * hand-built-context population ADR-0096 D3 is eliminating). The probe stays a + * strict subset of the carried rung's derivation (the unscoped `admin_full_access` + * grant carries every capability below), so a disagreement can only NARROW the + * exemption, never widen it (fail-safe; ADR-0099 I3). */ const PLATFORM_ADMIN_ONLY_CAPABILITIES: readonly string[] = [ 'manage_metadata', @@ -83,6 +86,16 @@ const PLATFORM_ADMIN_ONLY_CAPABILITIES: readonly string[] = [ 'manage_users', ]; +/** + * [ADR-0099 D1] Is `v` a carried posture rung (one of the ladder's values)? + * Present ⇒ the value is authoritative for the Layer 0 tier decision; absent ⇒ + * fall back to the capability probe. Sourced from core's `POSTURE_LADDER` so the + * enforcement gate and the resolver's derivation can never drift on the enum. + */ +function isCarriedPosture(v: unknown): boolean { + return typeof v === 'string' && (POSTURE_LADDER as readonly string[]).includes(v); +} + /** * [ADR-0099 P0] Pure form of the platform-admin capability probe: does the held * capability set contain any platform-EXCLUSIVE capability? Exported so the @@ -2428,12 +2441,33 @@ export class SecurityPlugin implements Plugin { ? this.permissionEvaluator.hasSuperuserWriteBypass(object, permissionSets, { isPrivate: meta.isPrivate }) : this.permissionEvaluator.hasSuperuserReadBypass(object, permissionSets, { isPrivate: meta.isPrivate })) : false; - // [Finding 2 / #2937] The Layer 0 cross-tenant EXEMPTION is stricter: it - // requires a TRUE PLATFORM_ADMIN (the superuser bit AND a platform-exclusive - // capability), never merely the superuser bit an `organization_admin` also - // holds. This is the ONLY place the platform-admin posture gates crossing the - // tenant wall; a tenant org admin stays org-scoped even on private objects. - const isPlatformAdmin = superuserBypass && this.hasPlatformAdminPosture(permissionSets); + // [Finding 2 / #2937 / ADR-0099 D1 (P1)] The Layer 0 cross-tenant EXEMPTION + // is stricter than the superuser bit: it requires a TRUE PLATFORM_ADMIN, so a + // tenant `organization_admin` (which also holds the superuser bit via its `'*'` + // wildcard) stays org-scoped even on private objects (invariant I1). The tier + // signal is the CARRIED rung when the context passed the resolver (#2956); the + // capability probe is the fallback for hand-built contexts that carry no rung. + // Both are computed so a disagreement (only possible on the fallback-eligible + // divergence class — scoped `admin_full_access` / piecemeal platform caps) is + // logged as a defect (ADR-0099 I4) and the NARROWER rung verdict is enforced. + const carriedPosture = context?.posture; + const probePlatformAdmin = this.hasPlatformAdminPosture(permissionSets); + let platformPosture: boolean; + if (isCarriedPosture(carriedPosture)) { + platformPosture = carriedPosture === 'PLATFORM_ADMIN'; + if (platformPosture !== probePlatformAdmin) { + // rung ⊆ probe (I3), so this only fires as probe=true / rung≠PLATFORM_ADMIN + // — the adjudicated narrowing (#3211 G1). A breadcrumb, never a throw. + this.logger.warn?.( + '[authz/ADR-0099] Layer 0 exemption: carried posture rung and capability probe disagree; ' + + 'enforcing the (narrower) carried rung', + { object, operation, carriedPosture, probePlatformAdmin, userId: context?.userId }, + ); + } + } else { + platformPosture = probePlatformAdmin; + } + const isPlatformAdmin = superuserBypass && platformPosture; // Field set drives BOTH the Layer 1 field-existence net and the Layer 0 // "is this a tenant object?" check. diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index e2f058c6f0..b2d4615615 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -53,8 +53,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ enforcement: 'plugin-security/security-plugin.ts step 3.7 — computeWriteTenantCheckFilter (reuses computeLayeredRlsFilter\'s Layer 0) matched against the write post-image (fail-closed) for BOTH insert and update; enterprise auto-stamp authoritatively overwrites a user-context organization_id (@objectstack/organizations Middleware A)', note: 'INSERT has no pre-image and UPDATE\'s pre-image (step 2.7) validates only the OLD organization_id, so the AND-composed Layer 0 wall never inspected the NEW value: a member could INSERT a forged cross-tenant organization_id (#2937) or UPDATE a row to RE-POINT it into a victim tenant (Finding 1, BLOCKER). A supplied cross-tenant organization_id is now DENIED on both paths — organization_id is effectively immutable in non-platform user contexts (platform-admin posture on a posture-permitting object + single-mode exempt, same rule as the read side). Unit-proven in plugin-security/authz-matrix-gate.test.ts ([#2937] insert + [Finding 1 / #2937] update post-image tenant guard). Multi-org is enterprise-only so it is not in the open-core dogfood boot; see ADR-0095 D1.' }, { id: 'multi-tenant-exemption-posture', summary: 'Layer 0 cross-tenant exemption requires the PLATFORM_ADMIN posture (Finding 2 — org_admin does not cross the wall)', state: 'enforced', - enforcement: 'plugin-security/security-plugin.ts hasPlatformAdminPosture (platform-exclusive systemPermissions) gates the tenant-layer.ts Layer 0 exemption; the superuser bit (viewAllRecords/modifyAllRecords) governs only the Layer 1 business-RLS short-circuit', - note: 'An organization_admin holds the superuser bit via its `*` wildcard, so it used to also get the Layer 0 exemption and read/write EVERY tenant\'s rows on private tenant objects. The exemption now requires a platform-exclusive capability (manage_metadata/manage_platform_settings/studio.access/manage_users), which org_admin deliberately lacks — a SECURITY NARROWING: org admin is walled to its own org, a true platform admin still crosses, the better-auth carve-out is untouched. Unit-proven in plugin-security/authz-matrix-gate.test.ts ([Finding 2 / #2937] Layer 0 cross-tenant exemption requires the platform posture).' }, + enforcement: 'plugin-security/security-plugin.ts computeLayeredRlsFilter reads the carried ctx.posture rung (ADR-0099 D1 / #2956) to gate the tenant-layer.ts Layer 0 exemption — PLATFORM_ADMIN crosses, everything below is walled; the hasPlatformAdminPosture capability probe is the resolver-less fallback; the superuser bit (viewAllRecords/modifyAllRecords) governs only the Layer 1 business-RLS short-circuit', + note: 'An organization_admin holds the superuser bit via its `*` wildcard, so it used to also get the Layer 0 exemption and read/write EVERY tenant\'s rows on private tenant objects. Crossing now requires the carried PLATFORM_ADMIN rung (ADR-0099 P1), which derives only from an unscoped admin_full_access grant — org_admin resolves to TENANT_ADMIN and a scoped grant / piecemeal platform capability to MEMBER, so all are walled to their own org (SECURITY NARROWING; a true platform admin still crosses, the better-auth carve-out is untouched). Before P1 the gate keyed on a platform-exclusive capability probe, which a scoped admin_full_access grant or a piecemeal studio.access grant could satisfy — the divergence class the equivalence gate pinned and P1 closed (invariant I1: TENANT_ADMIN never crosses). Unit-proven in plugin-security/authz-matrix-gate.test.ts ([Finding 2 / #2937] platform-posture exemption + "ADR-0099 P1 — Layer 0 exemption reads the carried rung").' }, { id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced', enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' }, // ── #2567 — the anonymous-deny posture is UNIFORM across HTTP surfaces, not