Skip to content

Commit 06c721c

Browse files
committed
feat(security): ADR-0099 P1 — Layer 0 exemption reads the carried posture rung (#3211 M2)
The Layer 0 cross-tenant exemption gate in computeLayeredRlsFilter now reads the carried ctx.posture rung (#2956) as authoritative: crossing the tenant wall requires PLATFORM_ADMIN. The hasPlatformAdminPosture capability probe demotes to a fallback for resolver-less contexts (delegated-admin bridge, sharing service, getReadFilter consumers), where behavior is byte-for-byte unchanged. Read and write (insert/update post-image) tenant checks share the one decision, so they cannot drift. A probe/rung disagreement logs an [authz/ADR-0099] defect breadcrumb and enforces the narrower rung verdict. Retires the stale 'posture not plumbed' comment. Security narrowing (multi-org / @objectstack/organizations only, the G1- adjudicated deltas): a scoped admin_full_access grant (a) and a piecemeal platform-capability grant (b) resolve below PLATFORM_ADMIN and are now walled to their own org on private / platform-global / better-auth objects, where the posture-blind probe used to exempt them. Fail-safe (rung is a strict subset of the probe, ADR-0099 I3); recover with an unscoped admin_full_access grant. Single-org / env-per-database unaffected. authz-matrix-gate.test.ts: new ADR-0099 P1 describe (narrowing read/write, PLATFORM_ADMIN stays exempt, TENANT_ADMIN stays walled per I1, resolver-less fallback preserved, defect-breadcrumb on disagreement / silence on agreement). dogfood multi-tenant-exemption-posture ledger note updated. Docs: authorization.mdx records the unscoped-grant rule. Verified: plugin-security 503 passed, dogfood 296 passed, build 71/71, tsc + check:role-word + check:doc-authoring clean. Refs #3211 · ADR-0099 / #3109 · #2946 Finding 2 · #2956 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DAHp4K7FvyMPBY1DPNkmRu
1 parent 50dfa4f commit 06c721c

5 files changed

Lines changed: 157 additions & 39 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
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.
6+
7+
**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).
8+
9+
**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.

content/docs/permissions/authorization.mdx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,15 @@ implementation detail:
7979
Keys never collide across packages because object api names are
8080
package-namespaced.
8181
3. **RLS: OR within an object, AND with tenant-global.** Multiple row policies
82-
for the same object/operation OR-combine; the wildcard tenant-isolation
83-
policy ANDs on top. `viewAllRecords` / `modifyAllRecords` (super-user
84-
bypass, posture-gated) short-circuit RLS for the object.
82+
for the same object/operation OR-combine; the tenant wall (Layer 0, ADR-0095
83+
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
84+
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
85+
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
86+
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives
87+
only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or
88+
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
89+
Studio/admin *functions* but never widen the tenant data boundary. A tenant
90+
`organization_admin` never crosses it (invariant I1).
8591
4. **Explicit deny — reserved.** There is no deny layer yet; the only implicit
8692
denies are the AND-gates in (1) and fail-closed defaults. Permission-set
8793
groups + subtractive *muting* (Salesforce-style) are the planned step 4

packages/plugins/plugin-security/src/authz-matrix-gate.test.ts

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -574,26 +574,6 @@ describe('ADR-0099 P0 — probe vs carried-rung equivalence (#3211 M1)', () => {
574574
}
575575
});
576576

577-
// ── [I4 staging — the P1 flip target, pinned] ─────────────────────────────
578-
// TODAY the Layer 0 exemption is POSTURE-BLIND: a carried MEMBER rung on the
579-
// ExecutionContext does not wall a scoped-grant holder (the probe path never
580-
// consults it), and a carried PLATFORM_ADMIN rung is not required by a true
581-
// admin. Both cells pin the PRE-FLIP behavior verbatim; under P1 the first
582-
// cell FLIPS to the walled filter ({organization_id:'org-1'}) as the
583-
// adjudicated narrowing, and the second MUST NOT change (rung authoritative).
584-
it('[I4 staging / P1 flip target] carried MEMBER rung does NOT yet wall a scoped-grant holder (posture-blind today)', async () => {
585-
const scopedHolder = {
586-
userId: 'scoped-admin', tenantId: 'org-1',
587-
positions: ['org_member'], permissions: ['admin_full_access'],
588-
posture: 'MEMBER', // carried rung (what resolve-authz-context derives for a SCOPED grant)
589-
};
590-
expect(await readFilter(OBJECTS.private_obj, scopedHolder)).toBeNull(); // ← flips to {organization_id:'org-1'} at P1
591-
});
592-
it('[I4 staging / P1 invariant] a true platform admin with the carried PLATFORM_ADMIN rung stays exempt', async () => {
593-
const carriedAdmin = { ...ROLES.platform_admin, posture: 'PLATFORM_ADMIN' };
594-
expect(await readFilter(OBJECTS.private_obj, carriedAdmin)).toBeNull(); // ← must NOT change at P1
595-
});
596-
597577
// ── [D3 dead branch] capability evidence can never derive EXTERNAL ────────
598578
// The EXTERNAL rung activates only from `audience:'external'` (ADR-0090 D10)
599579
// 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)', () => {
607587
}
608588
});
609589
});
590+
591+
// ═══════════════════════════════════════════════════════════════════════════
592+
// ADR-0099 P1 — Layer 0 exemption reads the carried rung (#3211 M2)
593+
// ═══════════════════════════════════════════════════════════════════════════
594+
//
595+
// The flip: `computeLayeredRlsFilter`'s Layer 0 cross-tenant exemption now reads
596+
// the CARRIED `ctx.posture` rung as authoritative (#2956 plumbs it), with the
597+
// capability probe demoted to a fallback for contexts that carry no rung. These
598+
// cells assert the flipped behavior for the divergence class the P0 gate pinned:
599+
// - carried MEMBER rung (a scoped `admin_full_access` holder — G1 delta (a)) is
600+
// now WALLED to its org, where the posture-blind probe used to exempt it;
601+
// - a carried PLATFORM_ADMIN rung stays exempt (rung authority, unchanged);
602+
// - a carried TENANT_ADMIN rung stays walled (invariant I1);
603+
// - a resolver-less context (no rung) still uses the probe — the internal
604+
// paths (delegated-admin, sharing service, getReadFilter) are byte-for-byte
605+
// unchanged until ADR-0096 D3 eliminates hand-built contexts;
606+
// - a probe/rung disagreement logs a defect breadcrumb (I4) and enforces the
607+
// narrower rung verdict.
608+
describe('ADR-0099 P1 — Layer 0 exemption reads the carried rung (#3211 M2)', () => {
609+
// G1 delta (a): a SCOPED admin_full_access grant. Its set contents make the
610+
// probe say platform-admin, but the resolver derives MEMBER (#2949), and that
611+
// carried rung now governs — the holder is walled to its own org (the narrowing).
612+
const scopedGrantHolder = {
613+
userId: 'scoped-admin', tenantId: 'org-1',
614+
positions: ['org_member'], permissions: ['admin_full_access'],
615+
posture: 'MEMBER',
616+
};
617+
618+
it('[P1 narrowing / delta (a)] carried MEMBER rung WALLS a scoped-grant holder (read)', async () => {
619+
expect(await readFilter(OBJECTS.private_obj, scopedGrantHolder)).toEqual({ organization_id: 'org-1' });
620+
});
621+
it('[P1 narrowing / delta (a)] carried MEMBER rung WALLS a scoped-grant holder (write pre-image)', async () => {
622+
expect(await writeFilter(OBJECTS.private_obj, scopedGrantHolder)).toEqual([{ organization_id: 'org-1' }]);
623+
});
624+
625+
it('[P1 invariant] a true platform admin carrying PLATFORM_ADMIN stays exempt (read null)', async () => {
626+
const carriedAdmin = { ...ROLES.platform_admin, posture: 'PLATFORM_ADMIN' };
627+
expect(await readFilter(OBJECTS.private_obj, carriedAdmin)).toBeNull();
628+
});
629+
630+
it('[P1 / I1] org_admin carrying TENANT_ADMIN stays walled (TENANT_ADMIN never crosses Layer 0)', async () => {
631+
const tenantAdmin = { ...ROLES.org_admin, posture: 'TENANT_ADMIN' };
632+
expect(await readFilter(OBJECTS.private_obj, tenantAdmin)).toEqual({ organization_id: 'org-1' });
633+
});
634+
635+
// Fallback: NO carried rung (a delegated-admin / sharing-service / getReadFilter
636+
// context built without the resolver) → the probe governs, exactly as pre-P1.
637+
// The same scoped holder, sans `posture`, is exempt via the probe — behavior
638+
// preserved on the internal paths.
639+
it('[P1 fallback] a resolver-less context (no rung) uses the probe — behavior preserved', async () => {
640+
const { posture: _drop, ...scopedNoRung } = scopedGrantHolder;
641+
expect(await readFilter(OBJECTS.private_obj, scopedNoRung)).toBeNull();
642+
});
643+
644+
// I4: enforcement and the probe disagree only on the divergence class; the gate
645+
// logs a defect breadcrumb and enforces the (narrower) carried rung.
646+
it('[P1 / I4] a probe↔rung disagreement logs a defect breadcrumb; carried rung wins', async () => {
647+
const plugin = new SecurityPlugin();
648+
const h = makeHarness({ ...OBJECTS.private_obj, orgScoping: true });
649+
await plugin.init(h.ctx); await plugin.start(h.ctx);
650+
const opCtx: any = {
651+
object: OBJECTS.private_obj.objectName, operation: 'find',
652+
ast: { where: undefined }, context: scopedGrantHolder,
653+
};
654+
await h.run(opCtx);
655+
expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' }); // narrower rung enforced
656+
expect(h.ctx.logger.warn).toHaveBeenCalledWith(
657+
expect.stringContaining('[authz/ADR-0099]'),
658+
expect.objectContaining({ carriedPosture: 'MEMBER', probePlatformAdmin: true }),
659+
);
660+
});
661+
662+
// Agreement path: a true platform admin carrying PLATFORM_ADMIN — probe and rung
663+
// agree, so NO defect breadcrumb is logged.
664+
it('[P1 / I4] no breadcrumb when probe and rung agree (true platform admin)', async () => {
665+
const plugin = new SecurityPlugin();
666+
const h = makeHarness({ ...OBJECTS.private_obj, orgScoping: true });
667+
await plugin.init(h.ctx); await plugin.start(h.ctx);
668+
const opCtx: any = {
669+
object: OBJECTS.private_obj.objectName, operation: 'find',
670+
ast: { where: undefined }, context: { ...ROLES.platform_admin, posture: 'PLATFORM_ADMIN' },
671+
};
672+
await h.run(opCtx);
673+
const authzWarn = (h.ctx.logger.warn as any).mock.calls.filter(
674+
(c: any[]) => typeof c[0] === 'string' && c[0].includes('[authz/ADR-0099]'),
675+
);
676+
expect(authzWarn).toHaveLength(0);
677+
});
678+
});

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { Plugin, PluginContext } from '@objectstack/core';
3+
import { Plugin, PluginContext, POSTURE_LADDER } from '@objectstack/core';
44
import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security';
55
import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security';
66
import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai';
@@ -68,13 +68,16 @@ import {
6868
* `setup.write` are EXCLUDED on purpose: org admins hold them (they are the Setup
6969
* app shell + tenant-settings-write caps, not platform powers).
7070
*
71-
* NOTE: the fully-correct signal is the `PLATFORM_ADMIN` posture already derived
72-
* in `resolve-authz-context.ts` (`ctx.posture`), but that field is NOT plumbed
73-
* into the ExecutionContext the enforcement middleware receives (the REST/runtime
74-
* transports drop it), so consuming it here would silently no-op. This capability
75-
* probe reads the SAME resolved permission sets enforcement already uses, works on
76-
* every entry point, and — being a strict subset of the old superuser-bit gate —
77-
* can only NARROW the exemption, never widen it (fail-safe).
71+
* [ADR-0099 D1 / P1] Since #2956 the resolver-derived `PLATFORM_ADMIN` rung
72+
* rides the ExecutionContext (`ctx.posture`), so the Layer 0 exemption gate now
73+
* reads the CARRIED rung as authoritative (see {@link isCarriedPosture} at the
74+
* decision site in `computeLayeredRlsFilter`). This capability probe DEMOTES to
75+
* a fallback: it applies only to contexts that never passed the shared resolver
76+
* (delegated-admin bridge, sharing service, `getReadFilter` consumers — the
77+
* hand-built-context population ADR-0096 D3 is eliminating). The probe stays a
78+
* strict subset of the carried rung's derivation (the unscoped `admin_full_access`
79+
* grant carries every capability below), so a disagreement can only NARROW the
80+
* exemption, never widen it (fail-safe; ADR-0099 I3).
7881
*/
7982
const PLATFORM_ADMIN_ONLY_CAPABILITIES: readonly string[] = [
8083
'manage_metadata',
@@ -83,6 +86,16 @@ const PLATFORM_ADMIN_ONLY_CAPABILITIES: readonly string[] = [
8386
'manage_users',
8487
];
8588

89+
/**
90+
* [ADR-0099 D1] Is `v` a carried posture rung (one of the ladder's values)?
91+
* Present ⇒ the value is authoritative for the Layer 0 tier decision; absent ⇒
92+
* fall back to the capability probe. Sourced from core's `POSTURE_LADDER` so the
93+
* enforcement gate and the resolver's derivation can never drift on the enum.
94+
*/
95+
function isCarriedPosture(v: unknown): boolean {
96+
return typeof v === 'string' && (POSTURE_LADDER as readonly string[]).includes(v);
97+
}
98+
8699
/**
87100
* [ADR-0099 P0] Pure form of the platform-admin capability probe: does the held
88101
* capability set contain any platform-EXCLUSIVE capability? Exported so the
@@ -2428,12 +2441,33 @@ export class SecurityPlugin implements Plugin {
24282441
? this.permissionEvaluator.hasSuperuserWriteBypass(object, permissionSets, { isPrivate: meta.isPrivate })
24292442
: this.permissionEvaluator.hasSuperuserReadBypass(object, permissionSets, { isPrivate: meta.isPrivate }))
24302443
: false;
2431-
// [Finding 2 / #2937] The Layer 0 cross-tenant EXEMPTION is stricter: it
2432-
// requires a TRUE PLATFORM_ADMIN (the superuser bit AND a platform-exclusive
2433-
// capability), never merely the superuser bit an `organization_admin` also
2434-
// holds. This is the ONLY place the platform-admin posture gates crossing the
2435-
// tenant wall; a tenant org admin stays org-scoped even on private objects.
2436-
const isPlatformAdmin = superuserBypass && this.hasPlatformAdminPosture(permissionSets);
2444+
// [Finding 2 / #2937 / ADR-0099 D1 (P1)] The Layer 0 cross-tenant EXEMPTION
2445+
// is stricter than the superuser bit: it requires a TRUE PLATFORM_ADMIN, so a
2446+
// tenant `organization_admin` (which also holds the superuser bit via its `'*'`
2447+
// wildcard) stays org-scoped even on private objects (invariant I1). The tier
2448+
// signal is the CARRIED rung when the context passed the resolver (#2956); the
2449+
// capability probe is the fallback for hand-built contexts that carry no rung.
2450+
// Both are computed so a disagreement (only possible on the fallback-eligible
2451+
// divergence class — scoped `admin_full_access` / piecemeal platform caps) is
2452+
// logged as a defect (ADR-0099 I4) and the NARROWER rung verdict is enforced.
2453+
const carriedPosture = context?.posture;
2454+
const probePlatformAdmin = this.hasPlatformAdminPosture(permissionSets);
2455+
let platformPosture: boolean;
2456+
if (isCarriedPosture(carriedPosture)) {
2457+
platformPosture = carriedPosture === 'PLATFORM_ADMIN';
2458+
if (platformPosture !== probePlatformAdmin) {
2459+
// rung ⊆ probe (I3), so this only fires as probe=true / rung≠PLATFORM_ADMIN
2460+
// — the adjudicated narrowing (#3211 G1). A breadcrumb, never a throw.
2461+
this.logger.warn?.(
2462+
'[authz/ADR-0099] Layer 0 exemption: carried posture rung and capability probe disagree; ' +
2463+
'enforcing the (narrower) carried rung',
2464+
{ object, operation, carriedPosture, probePlatformAdmin, userId: context?.userId },
2465+
);
2466+
}
2467+
} else {
2468+
platformPosture = probePlatformAdmin;
2469+
}
2470+
const isPlatformAdmin = superuserBypass && platformPosture;
24372471

24382472
// Field set drives BOTH the Layer 1 field-existence net and the Layer 0
24392473
// "is this a tenant object?" check.

packages/qa/dogfood/test/authz-conformance.matrix.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
5353
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)',
5454
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.' },
5555
{ 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',
56-
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',
57-
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).' },
56+
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',
57+
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").' },
5858
{ id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced',
5959
enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' },
6060
// ── #2567 — the anonymous-deny posture is UNIFORM across HTTP surfaces, not

0 commit comments

Comments
 (0)