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
11 changes: 11 additions & 0 deletions .changeset/orange-planets-sniff.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-security': minor
---

`OrgScopingEntitlement` grows two per-deployment wall-shaping keys, both declared by the mounted `org-scoping` runtime and consumed by plugin-security when arming the Layer 0 organization wall, both fail-closed (absent ⇒ byte-identical behaviour):

- `platformGlobalObjects?: readonly string[]` — objects THIS deployment declares platform-global; Layer 0 does not wall them here (read filtering, the ADR-0123 D2 no-active-org write refusal, the forge guard, and the Layer 1 wildcard-`organization_id` policy drop all follow, because they read the same per-object security meta). Exact machine names only; a junk shape is refused loudly and exempts nothing.
- `suppressUnboundedOrgAdminGrant?: boolean` — the walled-posture `organization_admin` auto-grant hands out `organization_admin_no_bypass` (no unbounded `viewAllRecords`/`modifyAllRecords`) instead; the superseded-variant reconcile converges standing grants in both directions.

New spec exports: `PlatformGlobalObjectsSchema`, `PlatformGlobalObjects`, `OrgScopingEntitlementSchema`.
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1584 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1586 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand DownExpand Up@@ -28,12 +28,12 @@ counts are sums of the rows they head. Regenerate with
| [Integration Protocol](/docs/references/integration) | 1 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
| [Kernel Protocol](/docs/references/kernel) | 31 | 171 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
| [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. |
| [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. |
| [Security Protocol](/docs/references/security) | 5 | 29 | Permission sets, row-level security, sharing rules, tenancy posture. |
| [Shared Protocol](/docs/references/shared) | 8 | 32 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 288 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 152 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1584** | 14 protocol modules |
| **Total** | **199** | **1586** | 14 protocol modules |

---

Expand DownExpand Up@@ -269,14 +269,14 @@ Declarative test suites — scenarios, steps, actions and assertions.

## Security Protocol

**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 27 schemas**
**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 29 schemas**

Permission sets, row-level security, sharing rules, tenancy posture.

| File | Schemas |
| :--- | :--- |
| [`explain.zod.ts`](/docs/references/security/explain) | `AccessMatrix`, `AccessMatrixEntry`, `AuthzPosture`, `ExplainDecision`, `ExplainLayer`, `ExplainMatchedRule`, `ExplainOperation`, `ExplainRecordAttribution`, `ExplainRequest` |
| [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `TenancyPosture` |
| [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture` |
| [`permission.zod.ts`](/docs/references/security/permission) | `AdminScope`, `EffectiveObjectPermission`, `FieldPermission`, `ObjectAccessScope`, `ObjectPermission`, `PermissionSet` |
| [`rls.zod.ts`](/docs/references/security/rls) | `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` |
| [`sharing.zod.ts`](/docs/references/security/sharing) | `CriteriaSharingRule`, `OWDModel`, `ShareRecipientType`, `SharingLevel`, `SharingRule`, `SharingRuleType` |
Expand Down
24 changes: 22 additions & 2 deletions content/docs/references/security/misc.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,8 +8,8 @@ description: Misc protocol schemas
## TypeScript Usage

```typescript
import { CapabilityDeclarationSchema, TenancyPostureSchema } from '@objectstack/spec/security';
import type { CapabilityDeclaration, TenancyPosture } from '@objectstack/spec/security';
import { CapabilityDeclarationSchema, OrgScopingEntitlementSchema, PlatformGlobalObjectsSchema, TenancyPostureSchema } from '@objectstack/spec/security';
import type { CapabilityDeclaration, OrgScopingEntitlement, PlatformGlobalObjects, TenancyPosture } from '@objectstack/spec/security';

// Validate data
const result = CapabilityDeclarationSchema.parse(data);
Expand DownExpand Up@@ -37,6 +37,26 @@ const result = CapabilityDeclarationSchema.parse(data);
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |


---

## OrgScopingEntitlement

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **supportedPostures** | `Enum<'single' \| 'group' \| 'isolated'>[]` | optional | |
| **platformGlobalObjects** | `string[]` | optional | |
| **suppressUnboundedOrgAdminGrant** | `boolean` | optional | |


---

## PlatformGlobalObjects

**Type:** `string[]`


---

## TenancyPosture
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -387,6 +387,100 @@ describe('[ADR-0105 D4] posture selects the org-admin variant', () => {
});
});

// ---------------------------------------------------------------------------
// [#12699] Deployment-declared suppression of the unbounded walled grant.
//
// D4's "the wall bounds the superbits" rationale stops holding on a deployment
// that carves platform-global objects OUT of the wall
// (`OrgScopingEntitlement.platformGlobalObjects`), so the same entitlement may
// declare `suppressUnboundedOrgAdminGrant: true` and the walled auto-grant
// hands out the de-VAMA'd variant there too. Fail closed: absent ⇒ D4's
// posture-keyed behaviour byte-identical (the block above IS that pin).
// ---------------------------------------------------------------------------
describe('[#12699] suppressUnboundedOrgAdminGrant', () => {
const seedBoth = () =>
makeStub({
sys_permission_set: [ORG_ADMIN_SET, ORG_ADMIN_NO_BYPASS_SET],
sys_member: [{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' }],
sys_user_permission_set: [],
});

it('suppression ON: `isolated` grants the de-VAMA\'d variant', async () => {
const stub = seedBoth();
const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', {
posture: 'isolated',
suppressUnboundedOrgAdminGrant: true,
});
expect(res.action).toBe('granted');
expect(stub.tables.sys_user_permission_set).toHaveLength(1);
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin_nb');
});

it('suppression ON: `group` grants the de-VAMA\'d variant too', async () => {
const stub = seedBoth();
await reconcileOrgAdminGrant(stub, 'u1', 'o1', {
posture: 'group',
suppressUnboundedOrgAdminGrant: true,
});
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin_nb');
});

it('suppression OFF (explicit false) is byte-identical to today: `isolated` grants the full set', async () => {
const stub = seedBoth();
await reconcileOrgAdminGrant(stub, 'u1', 'o1', {
posture: 'isolated',
suppressUnboundedOrgAdminGrant: false,
});
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin');
});

it('turning suppression on REVOKES a standing unbounded grant (superseded-variant convergence)', async () => {
const stub = seedBoth();
await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'isolated' });
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin');

await reconcileOrgAdminGrant(stub, 'u1', 'o1', {
posture: 'isolated',
suppressUnboundedOrgAdminGrant: true,
});
expect(stub.tables.sys_user_permission_set).toHaveLength(1);
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin_nb');

// ...and a deployment that withdraws the declaration converges back —
// the fail-closed default protects any deployment RELYING on the auto-grant.
await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'isolated' });
expect(stub.tables.sys_user_permission_set).toHaveLength(1);
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin');
});

it('backfill threads the suppression to every pair AND the orphan sweep', async () => {
const stub = makeStub({
sys_permission_set: [ORG_ADMIN_SET, ORG_ADMIN_NO_BYPASS_SET],
sys_member: [
{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' },
{ id: 'm2', user_id: 'u2', organization_id: 'o1', role: 'admin' },
],
// Pre-existing unbounded grants from a pre-suppression walled boot, plus
// one orphan (no membership row) that only the sweep can reach.
sys_user_permission_set: [
{ id: 'ups1', user_id: 'u1', organization_id: 'o1', permission_set_id: 'ps_org_admin' },
{ id: 'ups2', user_id: 'u2', organization_id: 'o1', permission_set_id: 'ps_org_admin' },
{ id: 'ups3', user_id: 'u9', organization_id: 'o1', permission_set_id: 'ps_org_admin' },
],
});

await backfillOrgAdminGrants(stub, {
posture: 'isolated',
suppressUnboundedOrgAdminGrant: true,
});

const grants = stub.tables.sys_user_permission_set;
expect(grants).toHaveLength(2);
expect(grants.every((g) => g.permission_set_id === 'ps_org_admin_nb')).toBe(true);
expect(grants.some((g) => g.user_id === 'u9')).toBe(false);
});
});

// ---------------------------------------------------------------------------
// [#4586] Hop 2 of the elevation chain stops discarding provenance.
//
Expand Down
68 changes: 56 additions & 12 deletions packages/plugins/plugin-security/src/auto-org-admin-grant.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,14 +52,32 @@ const SYSTEM_CTX = { isSystem: true } as const;
* there instead. Deliberate blanket visibility remains available through
* `admin_full_access` or an explicitly authored set; it just stops being a side
* effect of a better-auth membership role.
*
* [#12699] `suppressUnbounded` is the deployment's own veto on the walled
* branch (`OrgScopingEntitlement.suppressUnboundedOrgAdminGrant`): D4's "Layer
* 0 bounds it" rationale stops holding on a deployment that carves
* platform-global objects OUT of the wall, so such a deployment declares that
* arming a walled posture must NOT auto-grant the unbounded superbits — the
* de-VAMA'd variant is granted on walled postures too. Fail closed: `false`/
* absent keeps today's posture-keyed behaviour exactly.
*/
export function orgAdminSetNameForPosture(posture: TenancyPosture): string {
return postureEnforcesWall(posture) ? ORGANIZATION_ADMIN : ORGANIZATION_ADMIN_NO_BYPASS;
export function orgAdminSetNameForPosture(
posture: TenancyPosture,
suppressUnbounded = false,
): string {
return postureEnforcesWall(posture) && !suppressUnbounded
? ORGANIZATION_ADMIN
: ORGANIZATION_ADMIN_NO_BYPASS;
}

/** The variant NOT granted under `posture` — reconciled away so a posture change converges. */
function supersededOrgAdminSetName(posture: TenancyPosture): string {
return postureEnforcesWall(posture) ? ORGANIZATION_ADMIN_NO_BYPASS : ORGANIZATION_ADMIN;
/**
* The variant NOT granted under `posture` — reconciled away so a posture (or
* [#12699] suppression) change converges on exactly one org-admin grant.
*/
function supersededOrgAdminSetName(posture: TenancyPosture, suppressUnbounded = false): string {
return orgAdminSetNameForPosture(posture, suppressUnbounded) === ORGANIZATION_ADMIN
? ORGANIZATION_ADMIN_NO_BYPASS
: ORGANIZATION_ADMIN;
}

interface MaybeLogger {
Expand DownExpand Up@@ -259,6 +277,12 @@ export async function reconcileOrgAdminGrant(
* did this" (ADR-0118 D1).
*/
attributedUserId?: string;
/**
* [#12699] The deployment's `OrgScopingEntitlement.suppressUnboundedOrgAdminGrant`
* declaration, threaded by the caller (SecurityPlugin reads it live off the
* `org-scoping` service). Default `false` — today's behaviour exactly.
*/
suppressUnboundedOrgAdminGrant?: boolean;
} = {},
): Promise<{
action: 'granted' | 'revoked' | 'noop' | 'skipped';
Expand All@@ -276,8 +300,9 @@ export async function reconcileOrgAdminGrant(
// `single` (the wall-less, conservative choice) when a caller does not supply
// one: an unknown posture must not hand out unbounded superuser bits.
const posture: TenancyPosture = options.posture ?? 'single';
const grantSetName = orgAdminSetNameForPosture(posture);
const supersededSetName = supersededOrgAdminSetName(posture);
const suppressUnbounded = options.suppressUnboundedOrgAdminGrant === true;
const grantSetName = orgAdminSetNameForPosture(posture, suppressUnbounded);
const supersededSetName = supersededOrgAdminSetName(posture, suppressUnbounded);

const permSetId = await resolvePermissionSetId(ql, grantSetName, logger);
if (!permSetId) {
Expand DownExpand Up@@ -414,15 +439,26 @@ export async function reconcileOrgAdminGrant(
*/
export async function backfillOrgAdminGrants(
ql: any,
options: { logger?: MaybeLogger; limit?: number; posture?: TenancyPosture } = {},
options: {
logger?: MaybeLogger;
limit?: number;
posture?: TenancyPosture;
/** [#12699] See {@link reconcileOrgAdminGrant}'s option of the same name. */
suppressUnboundedOrgAdminGrant?: boolean;
} = {},
): Promise<{ scanned: number; granted: number; revoked: number; skipped: number }> {
const logger = options.logger;
const limit = options.limit ?? 5000;
const posture: TenancyPosture = options.posture ?? 'single';
const suppressUnbounded = options.suppressUnboundedOrgAdminGrant === true;
const summary = { scanned: 0, granted: 0, revoked: 0, skipped: 0 };
if (!ql || typeof ql.find !== 'function') return summary;

const permSetId = await resolvePermissionSetId(ql, orgAdminSetNameForPosture(posture), logger);
const permSetId = await resolvePermissionSetId(
ql,
orgAdminSetNameForPosture(posture, suppressUnbounded),
logger,
);
if (!permSetId) {
logger?.debug?.('[security] org-admin backfill skipped — permission set missing');
return summary;
Expand All@@ -432,7 +468,7 @@ export async function backfillOrgAdminGrants(
// exactly the rows whose bits must stop applying.
const supersededId = await resolvePermissionSetId(
ql,
supersededOrgAdminSetName(posture),
supersededOrgAdminSetName(posture, suppressUnbounded),
logger,
);

Expand All@@ -448,7 +484,11 @@ export async function backfillOrgAdminGrants(
if (seen.has(key)) continue;
seen.add(key);
summary.scanned += 1;
const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger, posture });
const res = await reconcileOrgAdminGrant(ql, userId, orgId, {
logger,
posture,
suppressUnboundedOrgAdminGrant: suppressUnbounded,
});
if (res.action === 'granted') summary.granted += 1;
else if (res.action === 'revoked') summary.revoked += 1;
else if (res.action === 'skipped') summary.skipped += 1;
Expand All@@ -471,7 +511,11 @@ export async function backfillOrgAdminGrants(
if (!userId || !orgId) continue;
const key = `${userId}|${orgId}`;
if (seen.has(key)) continue;
const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger, posture });
const res = await reconcileOrgAdminGrant(ql, userId, orgId, {
logger,
posture,
suppressUnboundedOrgAdminGrant: suppressUnbounded,
});
if (res.action === 'revoked') summary.revoked += 1;
}

Expand Down
Loading
Loading