Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/adr-0099-p1-layer0-reads-rung.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 9 additions & 3 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 89 additions & 20 deletions packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
});
});
62 changes: 48 additions & 14 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down