Skip to content

Commit a65db76

Browse files
os-zhuangclaude
andauthored
feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming (#12704)
* feat(spec,security): OrgScopingEntitlement platform-global exemption + unbounded-admin suppression Part of objectstack-ai/cloud#1653 (Phase 1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(spec): regenerate security-shard artifacts + PlatformGlobalObjects type alias Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: changeset for OrgScopingEntitlement wall-shaping keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d29e42f commit a65db76

14 files changed

Lines changed: 865 additions & 21 deletions

File tree

‎.changeset/orange-planets-sniff.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
`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):
7+
8+
- `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.
9+
- `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.
10+
11+
New spec exports: `PlatformGlobalObjectsSchema`, `PlatformGlobalObjects`, `OrgScopingEntitlementSchema`.

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Protocol Reference
3-
description: Every schema published by @objectstack/spec — 1584 schemas across 14 protocol modules
3+
description: Every schema published by @objectstack/spec — 1586 schemas across 14 protocol modules
44
---
55

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

3838
---
3939

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

270270
## Security Protocol
271271

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

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

276276
| File | Schemas |
277277
| :--- | :--- |
278278
| [`explain.zod.ts`](/docs/references/security/explain) | `AccessMatrix`, `AccessMatrixEntry`, `AuthzPosture`, `ExplainDecision`, `ExplainLayer`, `ExplainMatchedRule`, `ExplainOperation`, `ExplainRecordAttribution`, `ExplainRequest` |
279-
| [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `TenancyPosture` |
279+
| [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture` |
280280
| [`permission.zod.ts`](/docs/references/security/permission) | `AdminScope`, `EffectiveObjectPermission`, `FieldPermission`, `ObjectAccessScope`, `ObjectPermission`, `PermissionSet` |
281281
| [`rls.zod.ts`](/docs/references/security/rls) | `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` |
282282
| [`sharing.zod.ts`](/docs/references/security/sharing) | `CriteriaSharingRule`, `OWDModel`, `ShareRecipientType`, `SharingLevel`, `SharingRule`, `SharingRuleType` |

‎content/docs/references/security/misc.mdx‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ description: Misc protocol schemas
88
## TypeScript Usage
99

1010
```typescript
11-
import { CapabilityDeclarationSchema, TenancyPostureSchema } from '@objectstack/spec/security';
12-
import type { CapabilityDeclaration, TenancyPosture } from '@objectstack/spec/security';
11+
import { CapabilityDeclarationSchema, OrgScopingEntitlementSchema, PlatformGlobalObjectsSchema, TenancyPostureSchema } from '@objectstack/spec/security';
12+
import type { CapabilityDeclaration, OrgScopingEntitlement, PlatformGlobalObjects, TenancyPosture } from '@objectstack/spec/security';
1313

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

3939

40+
---
41+
42+
## OrgScopingEntitlement
43+
44+
### Properties
45+
46+
| Property | Type | Required | Description |
47+
| :--- | :--- | :--- | :--- |
48+
| **supportedPostures** | `Enum<'single' \| 'group' \| 'isolated'>[]` | optional | |
49+
| **platformGlobalObjects** | `string[]` | optional | |
50+
| **suppressUnboundedOrgAdminGrant** | `boolean` | optional | |
51+
52+
53+
---
54+
55+
## PlatformGlobalObjects
56+
57+
**Type:** `string[]`
58+
59+
4060
---
4161

4262
## TenancyPosture

‎packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,100 @@ describe('[ADR-0105 D4] posture selects the org-admin variant', () => {
387387
});
388388
});
389389

390+
// ---------------------------------------------------------------------------
391+
// [#12699] Deployment-declared suppression of the unbounded walled grant.
392+
//
393+
// D4's "the wall bounds the superbits" rationale stops holding on a deployment
394+
// that carves platform-global objects OUT of the wall
395+
// (`OrgScopingEntitlement.platformGlobalObjects`), so the same entitlement may
396+
// declare `suppressUnboundedOrgAdminGrant: true` and the walled auto-grant
397+
// hands out the de-VAMA'd variant there too. Fail closed: absent ⇒ D4's
398+
// posture-keyed behaviour byte-identical (the block above IS that pin).
399+
// ---------------------------------------------------------------------------
400+
describe('[#12699] suppressUnboundedOrgAdminGrant', () => {
401+
const seedBoth = () =>
402+
makeStub({
403+
sys_permission_set: [ORG_ADMIN_SET, ORG_ADMIN_NO_BYPASS_SET],
404+
sys_member: [{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' }],
405+
sys_user_permission_set: [],
406+
});
407+
408+
it('suppression ON: `isolated` grants the de-VAMA\'d variant', async () => {
409+
const stub = seedBoth();
410+
const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', {
411+
posture: 'isolated',
412+
suppressUnboundedOrgAdminGrant: true,
413+
});
414+
expect(res.action).toBe('granted');
415+
expect(stub.tables.sys_user_permission_set).toHaveLength(1);
416+
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin_nb');
417+
});
418+
419+
it('suppression ON: `group` grants the de-VAMA\'d variant too', async () => {
420+
const stub = seedBoth();
421+
await reconcileOrgAdminGrant(stub, 'u1', 'o1', {
422+
posture: 'group',
423+
suppressUnboundedOrgAdminGrant: true,
424+
});
425+
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin_nb');
426+
});
427+
428+
it('suppression OFF (explicit false) is byte-identical to today: `isolated` grants the full set', async () => {
429+
const stub = seedBoth();
430+
await reconcileOrgAdminGrant(stub, 'u1', 'o1', {
431+
posture: 'isolated',
432+
suppressUnboundedOrgAdminGrant: false,
433+
});
434+
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin');
435+
});
436+
437+
it('turning suppression on REVOKES a standing unbounded grant (superseded-variant convergence)', async () => {
438+
const stub = seedBoth();
439+
await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'isolated' });
440+
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin');
441+
442+
await reconcileOrgAdminGrant(stub, 'u1', 'o1', {
443+
posture: 'isolated',
444+
suppressUnboundedOrgAdminGrant: true,
445+
});
446+
expect(stub.tables.sys_user_permission_set).toHaveLength(1);
447+
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin_nb');
448+
449+
// ...and a deployment that withdraws the declaration converges back —
450+
// the fail-closed default protects any deployment RELYING on the auto-grant.
451+
await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'isolated' });
452+
expect(stub.tables.sys_user_permission_set).toHaveLength(1);
453+
expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin');
454+
});
455+
456+
it('backfill threads the suppression to every pair AND the orphan sweep', async () => {
457+
const stub = makeStub({
458+
sys_permission_set: [ORG_ADMIN_SET, ORG_ADMIN_NO_BYPASS_SET],
459+
sys_member: [
460+
{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' },
461+
{ id: 'm2', user_id: 'u2', organization_id: 'o1', role: 'admin' },
462+
],
463+
// Pre-existing unbounded grants from a pre-suppression walled boot, plus
464+
// one orphan (no membership row) that only the sweep can reach.
465+
sys_user_permission_set: [
466+
{ id: 'ups1', user_id: 'u1', organization_id: 'o1', permission_set_id: 'ps_org_admin' },
467+
{ id: 'ups2', user_id: 'u2', organization_id: 'o1', permission_set_id: 'ps_org_admin' },
468+
{ id: 'ups3', user_id: 'u9', organization_id: 'o1', permission_set_id: 'ps_org_admin' },
469+
],
470+
});
471+
472+
await backfillOrgAdminGrants(stub, {
473+
posture: 'isolated',
474+
suppressUnboundedOrgAdminGrant: true,
475+
});
476+
477+
const grants = stub.tables.sys_user_permission_set;
478+
expect(grants).toHaveLength(2);
479+
expect(grants.every((g) => g.permission_set_id === 'ps_org_admin_nb')).toBe(true);
480+
expect(grants.some((g) => g.user_id === 'u9')).toBe(false);
481+
});
482+
});
483+
390484
// ---------------------------------------------------------------------------
391485
// [#4586] Hop 2 of the elevation chain stops discarding provenance.
392486
//

‎packages/plugins/plugin-security/src/auto-org-admin-grant.ts‎

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,32 @@ const SYSTEM_CTX = { isSystem: true } as const;
5252
* there instead. Deliberate blanket visibility remains available through
5353
* `admin_full_access` or an explicitly authored set; it just stops being a side
5454
* effect of a better-auth membership role.
55+
*
56+
* [#12699] `suppressUnbounded` is the deployment's own veto on the walled
57+
* branch (`OrgScopingEntitlement.suppressUnboundedOrgAdminGrant`): D4's "Layer
58+
* 0 bounds it" rationale stops holding on a deployment that carves
59+
* platform-global objects OUT of the wall, so such a deployment declares that
60+
* arming a walled posture must NOT auto-grant the unbounded superbits — the
61+
* de-VAMA'd variant is granted on walled postures too. Fail closed: `false`/
62+
* absent keeps today's posture-keyed behaviour exactly.
5563
*/
56-
export function orgAdminSetNameForPosture(posture: TenancyPosture): string {
57-
return postureEnforcesWall(posture) ? ORGANIZATION_ADMIN : ORGANIZATION_ADMIN_NO_BYPASS;
64+
export function orgAdminSetNameForPosture(
65+
posture: TenancyPosture,
66+
suppressUnbounded = false,
67+
): string {
68+
return postureEnforcesWall(posture) && !suppressUnbounded
69+
? ORGANIZATION_ADMIN
70+
: ORGANIZATION_ADMIN_NO_BYPASS;
5871
}
5972

60-
/** The variant NOT granted under `posture` — reconciled away so a posture change converges. */
61-
function supersededOrgAdminSetName(posture: TenancyPosture): string {
62-
return postureEnforcesWall(posture) ? ORGANIZATION_ADMIN_NO_BYPASS : ORGANIZATION_ADMIN;
73+
/**
74+
* The variant NOT granted under `posture` — reconciled away so a posture (or
75+
* [#12699] suppression) change converges on exactly one org-admin grant.
76+
*/
77+
function supersededOrgAdminSetName(posture: TenancyPosture, suppressUnbounded = false): string {
78+
return orgAdminSetNameForPosture(posture, suppressUnbounded) === ORGANIZATION_ADMIN
79+
? ORGANIZATION_ADMIN_NO_BYPASS
80+
: ORGANIZATION_ADMIN;
6381
}
6482

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

282307
const permSetId = await resolvePermissionSetId(ql, grantSetName, logger);
283308
if (!permSetId) {
@@ -414,15 +439,26 @@ export async function reconcileOrgAdminGrant(
414439
*/
415440
export async function backfillOrgAdminGrants(
416441
ql: any,
417-
options: { logger?: MaybeLogger; limit?: number; posture?: TenancyPosture } = {},
442+
options: {
443+
logger?: MaybeLogger;
444+
limit?: number;
445+
posture?: TenancyPosture;
446+
/** [#12699] See {@link reconcileOrgAdminGrant}'s option of the same name. */
447+
suppressUnboundedOrgAdminGrant?: boolean;
448+
} = {},
418449
): Promise<{ scanned: number; granted: number; revoked: number; skipped: number }> {
419450
const logger = options.logger;
420451
const limit = options.limit ?? 5000;
421452
const posture: TenancyPosture = options.posture ?? 'single';
453+
const suppressUnbounded = options.suppressUnboundedOrgAdminGrant === true;
422454
const summary = { scanned: 0, granted: 0, revoked: 0, skipped: 0 };
423455
if (!ql || typeof ql.find !== 'function') return summary;
424456

425-
const permSetId = await resolvePermissionSetId(ql, orgAdminSetNameForPosture(posture), logger);
457+
const permSetId = await resolvePermissionSetId(
458+
ql,
459+
orgAdminSetNameForPosture(posture, suppressUnbounded),
460+
logger,
461+
);
426462
if (!permSetId) {
427463
logger?.debug?.('[security] org-admin backfill skipped — permission set missing');
428464
return summary;
@@ -432,7 +468,7 @@ export async function backfillOrgAdminGrants(
432468
// exactly the rows whose bits must stop applying.
433469
const supersededId = await resolvePermissionSetId(
434470
ql,
435-
supersededOrgAdminSetName(posture),
471+
supersededOrgAdminSetName(posture, suppressUnbounded),
436472
logger,
437473
);
438474

@@ -448,7 +484,11 @@ export async function backfillOrgAdminGrants(
448484
if (seen.has(key)) continue;
449485
seen.add(key);
450486
summary.scanned += 1;
451-
const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger, posture });
487+
const res = await reconcileOrgAdminGrant(ql, userId, orgId, {
488+
logger,
489+
posture,
490+
suppressUnboundedOrgAdminGrant: suppressUnbounded,
491+
});
452492
if (res.action === 'granted') summary.granted += 1;
453493
else if (res.action === 'revoked') summary.revoked += 1;
454494
else if (res.action === 'skipped') summary.skipped += 1;
@@ -471,7 +511,11 @@ export async function backfillOrgAdminGrants(
471511
if (!userId || !orgId) continue;
472512
const key = `${userId}|${orgId}`;
473513
if (seen.has(key)) continue;
474-
const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger, posture });
514+
const res = await reconcileOrgAdminGrant(ql, userId, orgId, {
515+
logger,
516+
posture,
517+
suppressUnboundedOrgAdminGrant: suppressUnbounded,
518+
});
475519
if (res.action === 'revoked') summary.revoked += 1;
476520
}
477521

0 commit comments

Comments
 (0)