From 7db591f5e28e05230c70e03263fc025e884211d2 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:04:06 +0800 Subject: [PATCH 1/3] feat(spec,security): OrgScopingEntitlement platform-global exemption + unbounded-admin suppression Part of objectstack-ai/cloud#1653 (Phase 1). Co-Authored-By: Claude Fable 5 --- .../src/auto-org-admin-grant.test.ts | 94 +++++++ .../src/auto-org-admin-grant.ts | 68 ++++- .../src/deployment-org-scoping-entitlement.ts | 141 ++++++++++ ...ployment-platform-global-exemption.test.ts | 253 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 88 +++++- .../spec/src/security/tenancy-posture.test.ts | 108 ++++++++ packages/spec/src/security/tenancy-posture.ts | 77 ++++++ 7 files changed, 815 insertions(+), 14 deletions(-) create mode 100644 packages/plugins/plugin-security/src/deployment-org-scoping-entitlement.ts create mode 100644 packages/plugins/plugin-security/src/deployment-platform-global-exemption.test.ts create mode 100644 packages/spec/src/security/tenancy-posture.test.ts diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts index c7ae4aff14..8c14774639 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts @@ -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. // diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts index bb3e8943b0..3c5adbab56 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts @@ -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 { @@ -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'; @@ -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) { @@ -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; @@ -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, ); @@ -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; @@ -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; } diff --git a/packages/plugins/plugin-security/src/deployment-org-scoping-entitlement.ts b/packages/plugins/plugin-security/src/deployment-org-scoping-entitlement.ts new file mode 100644 index 0000000000..d8a9caa448 --- /dev/null +++ b/packages/plugins/plugin-security/src/deployment-org-scoping-entitlement.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12699 / cloud#1653] Read the per-deployment wall-shaping keys off the + * mounted `org-scoping` service (`OrgScopingEntitlement`, + * `@objectstack/spec/security`). + * + * ## Why a reader, and why it fails closed per key + * + * The `org-scoping` service is the enterprise runtime's own object — a live + * plugin instance, not a parsed document — so its declaration arrives through + * an untyped boundary exactly like the `auth.membership_policy` setting did. + * The `MembershipPolicy` precedent (`@objectstack/plugin-auth`, + * reconcile-membership.ts) governs what happens to a value the type would have + * rejected: it is REFUSED loudly with a verdict naming the offending value, + * never coerced onto a permissive branch. Here the non-permissive branch is + * "the key was never declared": + * + * - `platformGlobalObjects` junk ⇒ NO object is exempted (everything walls + * exactly as its own declaration says); + * - `suppressUnboundedOrgAdminGrant` junk ⇒ the auto-grant keeps today's + * posture-keyed behaviour. + * + * Each key is validated INDEPENDENTLY: junk in one must not silently void the + * other's valid declaration (all-or-nothing refusal would turn one typo into + * two behaviour changes, only one of which the log line explains). + * + * ⛔ Partial honouring is refusal's other failure mode: one junk ENTRY voids + * the whole `platformGlobalObjects` key rather than dropping the entry. The + * declarer is first-party runtime code; half-honouring a malformed list hides + * the bug behind mostly-working behaviour, while a whole-key refusal walls + * every named object — loud on the first smoke test, and safe. + * + * ## Read timing + * + * The reader itself is pure and cheap; validation is memoized per service + * instance (WeakMap), so callers may read it live — the same pattern as the + * seam's existing consumer (`plugin-auth`'s `probeEntitledPostures`, which + * resolves `getService('org-scoping')` per call precisely because the provider + * registers after the reader's own init). A re-registered service is a new + * instance and re-validates; in-place mutation of a declaration is outside the + * contract (the interface is readonly, and every declared key is expected to + * be constant for the kernel's life). + */ + +import { + PlatformGlobalObjectsSchema, + type OrgScopingEntitlement, +} from '@objectstack/spec/security'; + +/** One refused key, for the caller's loud warn (the caller owns logging). */ +export interface RefusedEntitlementKey { + readonly key: 'platformGlobalObjects' | 'suppressUnboundedOrgAdminGrant'; + /** Human-readable shape complaint, stable enough to warn-once on. */ + readonly problem: string; + /** The offending declared value, for the log line. */ + readonly value: unknown; +} + +/** The validated, fail-closed reading of the deployment's declaration. */ +export interface DeploymentOrgScopingEntitlementReading { + /** + * Objects this deployment declares platform-global (Layer 0 must not wall + * them here). Empty when the key is absent, junk, or no service is mounted. + */ + readonly platformGlobalObjects: ReadonlySet; + /** + * Whether the walled-posture `organization_admin` auto-grant must hand out + * the de-VAMA'd variant. `false` when absent, junk, or no service is mounted. + */ + readonly suppressUnboundedOrgAdminGrant: boolean; + /** Keys whose declared value was refused — non-empty ⇒ the caller warns. */ + readonly refused: readonly RefusedEntitlementKey[]; +} + +const ABSENT: DeploymentOrgScopingEntitlementReading = Object.freeze({ + platformGlobalObjects: new Set(), + suppressUnboundedOrgAdminGrant: false, + refused: [], +}); + +/** Validation memo, keyed on the service instance (declarations are readonly). */ +const readingMemo = new WeakMap(); + +/** + * Validate the deployment's `OrgScopingEntitlement` wall-shaping keys. + * + * `service` is whatever `getService('org-scoping')` returned — `undefined`/ + * non-object resolves to the fail-closed ABSENT reading. Never throws. + */ +export function readDeploymentOrgScopingEntitlement( + service: unknown, +): DeploymentOrgScopingEntitlementReading { + if (service === null || typeof service !== 'object') return ABSENT; + const memoized = readingMemo.get(service); + if (memoized) return memoized; + + const declared = service as Partial & Record; + const refused: RefusedEntitlementKey[] = []; + + let platformGlobalObjects: ReadonlySet = ABSENT.platformGlobalObjects; + const rawObjects = declared.platformGlobalObjects; + if (rawObjects !== undefined) { + const parsed = PlatformGlobalObjectsSchema.safeParse(rawObjects); + if (parsed.success) { + platformGlobalObjects = new Set(parsed.data); + } else { + refused.push({ + key: 'platformGlobalObjects', + problem: + 'must be an array of exact object machine names (^[a-z_][a-z0-9_]*$ — no wildcards, no empty strings); ' + + 'the whole key is refused and NO object is exempted (fail closed)', + value: rawObjects, + }); + } + } + + let suppressUnboundedOrgAdminGrant = false; + const rawSuppress = declared.suppressUnboundedOrgAdminGrant; + if (rawSuppress !== undefined) { + if (typeof rawSuppress === 'boolean') { + suppressUnboundedOrgAdminGrant = rawSuppress; + } else { + refused.push({ + key: 'suppressUnboundedOrgAdminGrant', + problem: + 'must be a boolean; the key is refused and the walled-posture organization_admin ' + + 'auto-grant keeps today\'s behaviour (fail closed)', + value: rawSuppress, + }); + } + } + + const reading: DeploymentOrgScopingEntitlementReading = Object.freeze({ + platformGlobalObjects, + suppressUnboundedOrgAdminGrant, + refused, + }); + readingMemo.set(service, reading); + return reading; +} diff --git a/packages/plugins/plugin-security/src/deployment-platform-global-exemption.test.ts b/packages/plugins/plugin-security/src/deployment-platform-global-exemption.test.ts new file mode 100644 index 0000000000..50afb1681a --- /dev/null +++ b/packages/plugins/plugin-security/src/deployment-platform-global-exemption.test.ts @@ -0,0 +1,253 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12699 / cloud#1653] Deployment-declared platform-global exemption. + * + * The mounted `org-scoping` service may declare + * `OrgScopingEntitlement.platformGlobalObjects` — objects THIS deployment + * owns platform-globally, which Layer 0 must not wall HERE even though the + * same objects genuinely wall on tenant runtimes (which is why the per-object + * `tenancy: { enabled: false }` authoring channel cannot express the fact: + * that declaration travels with the object into every deployment). + * + * These cases pin the four properties the contract promises: + * + * 1. an exempted object is not walled on this deployment — on the read path + * AND on the ADR-0123 D2 write-refusal path, because both are the same + * `computeLayeredRlsFilter().layer0` (the single choke point); + * 2. a non-exempted object walls exactly as before (the exemption is a + * carve-out, never a widening); + * 3. an ABSENT declaration is byte-identical to today (fail closed) — and so + * is a JUNK one, refused loudly per the `MembershipPolicy` precedent; + * 4. the exemption composes with, never replaces, the object-level + * `tenancy: { enabled: false }` channel. + * + * Harness pattern: `federated-tenant-layer0.test.ts` — a SecurityPlugin over a + * fake ObjectQL, asserting the composed FilterCondition before any driver sees + * it. The caller is a plain member (no policies), so the only thing + * `getReadFilter` can return is Layer 0 — the layer under test. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { RLS_DENY_FILTER } from './rls-compiler.js'; + +const PLAIN_MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + +/** An ordinary member of `org-1`: no superuser bit, no positions. */ +const MEMBER_CTX = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }; +/** The same member with NO active organization — the ADR-0123 D2 write case. */ +const NO_ORG_CTX = { userId: 'u1', positions: [], permissions: [] }; + +/** Two ordinary local tenant objects — identical shapes, different names. */ +const localSchema = (name: string, extra: Record = {}) => ({ + name, + fields: { + organization_id: { type: 'text', label: 'Organization' }, + title: { type: 'text', label: 'Title' }, + }, + ...extra, +}); + +/** + * Boot a SecurityPlugin over per-name schemas, with the `org-scoping` service + * carrying `entitlement` (the deployment's declaration). Returns the plugin and + * the fake logger so cases can assert the loud-refusal channel. + */ +async function boot( + schemas: Record>, + opts: { entitlement?: Record; tenancy?: { posture: string } } = {}, +) { + const getSchema = (name: string) => schemas[name]; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { registerMiddleware: vi.fn(), getSchema, findOne: vi.fn(async () => null) }, + metadata: { + get: async (_type: string, name: string) => schemas[name], + list: async () => [PLAIN_MEMBER], + }, + 'org-scoping': { name: 'com.objectstack.org-scoping', ...(opts.entitlement ?? {}) }, + }; + if (opts.tenancy) services['tenancy'] = opts.tenancy; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const ctx: Record = { + logger, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.init(ctx as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.start(ctx as any); + return { plugin, logger }; +} + +const TWO_OBJECTS = { + sys_widget_registry: localSchema('sys_widget_registry'), + crm_task: localSchema('crm_task'), +}; + +describe('[#12699] platformGlobalObjects — the deployment carve-out', () => { + it('an exempted object is NOT walled on this deployment (`isolated`)', async () => { + const { plugin } = await boot(TWO_OBJECTS, { + entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter = await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX); + expect(filter).toBeUndefined(); + }); + + it('a NON-exempted object still walls exactly as before', async () => { + const { plugin } = await boot(TWO_OBJECTS, { + entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter = await (plugin as any).getReadFilter('crm_task', MEMBER_CTX); + expect(filter).toEqual({ organization_id: 'org-1' }); + }); + + it('`group` posture: the exemption holds and the union wall stays on the sibling', async () => { + const { plugin } = await boot(TWO_OBJECTS, { + entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, + tenancy: { posture: 'group' }, + }); + const groupCtx = { ...MEMBER_CTX, accessible_org_ids: ['org-1', 'org-2'] }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('sys_widget_registry', groupCtx)).toBeUndefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('crm_task', groupCtx)).toEqual({ + organization_id: { $in: ['org-1', 'org-2'] }, + }); + }); + + it('the ADR-0123 D2 write wall derives from the SAME choke point: an exempted object escapes the no-active-org refusal, a sibling does not', async () => { + const { plugin } = await boot(TWO_OBJECTS, { + entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, + }); + // computeWriteTenantCheckFilter IS computeLayeredRlsFilter().layer0 — the + // derivation the middleware's refusal reads. Null = Layer 0 contributes + // nothing (the write may land); the deny sentinel = refusal. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const exempted = await (plugin as any).computeWriteTenantCheckFilter( + [PLAIN_MEMBER], 'sys_widget_registry', 'insert', NO_ORG_CTX, + ); + expect(exempted).toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const walled = await (plugin as any).computeWriteTenantCheckFilter( + [PLAIN_MEMBER], 'crm_task', 'insert', NO_ORG_CTX, + ); + expect(walled).toEqual({ ...RLS_DENY_FILTER }); + }); + + it('REGRESSION PIN — no declaration ⇒ byte-identical to today: both objects wall', async () => { + const { plugin, logger } = await boot(TWO_OBJECTS); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toEqual({ + organization_id: 'org-1', + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('crm_task', MEMBER_CTX)).toEqual({ + organization_id: 'org-1', + }); + // And nothing to refuse means nothing to warn about. + const warned = logger.warn.mock.calls.map((c) => String(c[0])); + expect(warned.filter((m) => m.includes('#12699'))).toEqual([]); + }); + + it('composes with, never replaces, the object-level channel: `tenancy.enabled:false` stays exempt with no deployment declaration', async () => { + const { plugin } = await boot({ + sys_catalog: localSchema('sys_catalog', { tenancy: { enabled: false } }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('sys_catalog', MEMBER_CTX)).toBeUndefined(); + }); + + it('`single` posture: the declaration decides nothing (Layer 0 is inert either way)', async () => { + const { plugin } = await boot(TWO_OBJECTS, { + entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, + tenancy: { posture: 'single' }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toBeUndefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('crm_task', MEMBER_CTX)).toBeUndefined(); + }); +}); + +describe('[#12699] junk declarations are REFUSED loudly, never coerced (MembershipPolicy precedent)', () => { + it('a bare string is refused: warn names the key, and the named object STILL walls', async () => { + const { plugin, logger } = await boot(TWO_OBJECTS, { + entitlement: { platformGlobalObjects: 'sys_widget_registry' }, + }); + const warned = logger.warn.mock.calls.map((c) => String(c[0])); + expect(warned.some((m) => m.includes("'platformGlobalObjects' REFUSED"))).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toEqual({ + organization_id: 'org-1', + }); + }); + + it('one junk ENTRY voids the whole key (no partial honouring): a wildcard poisons the list', async () => { + const { plugin, logger } = await boot(TWO_OBJECTS, { + entitlement: { platformGlobalObjects: ['sys_widget_registry', '*'] }, + }); + const warned = logger.warn.mock.calls.map((c) => String(c[0])); + expect(warned.some((m) => m.includes("'platformGlobalObjects' REFUSED"))).toBe(true); + // The well-formed entry is NOT honoured — refusal is whole-key, fail closed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toEqual({ + organization_id: 'org-1', + }); + }); + + it('junk in one key does not void the other: a bad suppress flag leaves a valid exemption standing', async () => { + const { plugin, logger } = await boot(TWO_OBJECTS, { + entitlement: { + platformGlobalObjects: ['sys_widget_registry'], + suppressUnboundedOrgAdminGrant: 'yes', + }, + }); + const warned = logger.warn.mock.calls.map((c) => String(c[0])); + expect(warned.some((m) => m.includes("'suppressUnboundedOrgAdminGrant' REFUSED"))).toBe(true); + expect(warned.some((m) => m.includes("'platformGlobalObjects' REFUSED"))).toBe(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toBeUndefined(); + }); + + it('the refusal is warned ONCE per boot, not once per read', async () => { + const { plugin, logger } = await boot(TWO_OBJECTS, { + entitlement: { platformGlobalObjects: 42 }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (plugin as any).getReadFilter('crm_task', MEMBER_CTX); + const refusals = logger.warn.mock.calls + .map((c) => String(c[0])) + .filter((m) => m.includes("'platformGlobalObjects' REFUSED")); + expect(refusals).toHaveLength(1); + }); +}); + +describe('[#12699] the arming log surfaces the declaration', () => { + it('a walled boot with exemptions logs the carve-out (count + names)', async () => { + const { logger } = await boot(TWO_OBJECTS, { + entitlement: { + platformGlobalObjects: ['sys_widget_registry'], + suppressUnboundedOrgAdminGrant: true, + }, + }); + const infos = logger.info.mock.calls.map((c) => String(c[0])); + expect(infos.some((m) => m.includes('1 platform-global'))).toBe(true); + expect(infos.some((m) => m.includes('suppresses the unbounded organization_admin auto-grant'))).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index ffc599bc27..e129cc4664 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -113,6 +113,10 @@ import { extractMemberPairs, reconcileOrgAdminGrant, } from './auto-org-admin-grant.js'; +import { + readDeploymentOrgScopingEntitlement, + type DeploymentOrgScopingEntitlementReading, +} from './deployment-org-scoping-entitlement.js'; import { SysPositionDetailPage } from '@objectstack/platform-objects/pages'; import { securityObjects, @@ -772,6 +776,38 @@ export class SecurityPlugin implements Plugin { private get orgScopingEnabled(): boolean { return postureEnforcesWall(this.tenancyPosture); } + /** + * [#12699] `problem` strings already warned about, so a junk + * `OrgScopingEntitlement` key is explained once per boot rather than on + * every security-meta fill (the `warnedAuthoredTenantPolicies` pattern). + */ + private readonly warnedEntitlementRefusals = new Set(); + /** + * [#12699] The deployment's wall-shaping declaration, read LIVE off the + * mounted `org-scoping` service — the same read pattern as the seam's + * existing consumer (plugin-auth's `probeEntitledPostures`): resolve the + * service per call, fail closed to "absent" on any miss. Live resolution is + * ordering-robust by construction: a deployment where the wall is ARMED had + * `org-scoping` registered before this plugin's `start()` captured the + * posture, so the declaration is present from the first read; a deployment + * where it registered too late resolves `single` and Layer 0 is inert, so + * the exemption decides nothing. Validation is memoized per service + * instance inside the reader; refusals are warned once per boot here. + */ + private deploymentOrgScopingEntitlement(): DeploymentOrgScopingEntitlementReading { + const reading = readDeploymentOrgScopingEntitlement( + this.resolveKernelService?.('org-scoping'), + ); + for (const refusal of reading.refused) { + if (this.warnedEntitlementRefusals.has(refusal.problem)) continue; + this.warnedEntitlementRefusals.add(refusal.problem); + this.logger?.warn?.( + `[security/#12699] org-scoping entitlement key '${refusal.key}' REFUSED — ${refusal.problem}`, + { key: refusal.key, declared: refusal.value }, + ); + } + return reading; + } /** * [ADR-0105 D3] `object|policy` keys already reported by * {@link warnAuthoredTenantPolicyOnce}, so a retained authored tenant policy @@ -1142,6 +1178,25 @@ export class SecurityPlugin implements Plugin { ? 'organization_id IN accessible_org_ids — union access across the caller\'s memberships' : 'organization_id = active organization'})`, ); + // [#12699] Surface the deployment's wall-shaping declaration at arming + // time: the carve-out list is a security-relevant deployment fact, and + // this is also the boot-time seam where a junk declaration gets its + // warn-once (the accessor validates as a side effect), instead of + // surfacing on the first request. + const entitlement = this.deploymentOrgScopingEntitlement(); + if (entitlement.platformGlobalObjects.size > 0) { + ctx.logger.info( + `[security/#12699] deployment declares ${entitlement.platformGlobalObjects.size} platform-global ` + + `object(s) — Layer 0 does not wall them on THIS deployment`, + { objects: [...entitlement.platformGlobalObjects].sort() }, + ); + } + if (entitlement.suppressUnboundedOrgAdminGrant) { + ctx.logger.info( + '[security/#12699] deployment suppresses the unbounded organization_admin auto-grant — ' + + 'membership-driven grants hand out organization_admin_no_bypass under this walled posture', + ); + } } else { ctx.logger.info( "[security] tenancy posture 'single' — Layer 0 is inert; the platform's own tenant-scoped RLS policies are stripped (app-authored ones are retained and fail closed, ADR-0105 D3)", @@ -3528,6 +3583,12 @@ export class SecurityPlugin implements Plugin { await reconcileOrgAdminGrant(ql, userId, orgId, { logger: ctx.logger, posture: this.tenancyPosture, + // [#12699] The deployment's declared suppression of the unbounded + // walled-posture grant — read live, like the posture is cached-at- + // start: both are deployment facts, but the entitlement's declarer + // may register between init and this middleware's first fire. + suppressUnboundedOrgAdminGrant: + this.deploymentOrgScopingEntitlement().suppressUnboundedOrgAdminGrant, ...(attributedUserId ? { attributedUserId } : {}), }); } catch (e) { @@ -3545,7 +3606,13 @@ export class SecurityPlugin implements Plugin { // missing rows and revokes orphaned ones, never duplicates. const runOrgAdminBackfill = async () => { try { - await backfillOrgAdminGrants(ql, { logger: ctx.logger, posture: this.tenancyPosture }); + await backfillOrgAdminGrants(ql, { + logger: ctx.logger, + posture: this.tenancyPosture, + // [#12699] Same live read as the middleware call site above. + suppressUnboundedOrgAdminGrant: + this.deploymentOrgScopingEntitlement().suppressUnboundedOrgAdminGrant, + }); } catch (e) { ctx.logger.warn?.('[security] organization_admin backfill failed', { error: (e as Error).message, @@ -6379,8 +6446,25 @@ export class SecurityPlugin implements Plugin { } const meta = { isPrivate: (obj as any)?.access?.default === 'private', + // [#12699] The deployment's `platformGlobalObjects` declaration folds in + // HERE, and only here — this meta is the single source every Layer 0 + // consumer reads (the read wall and the Layer 1 wildcard-`organization_id` + // drop via the 'tenancyDisabled' merge in computeLayeredRlsFilter; the + // ADR-0123 D2 write refusal and the forge guard via + // computeWriteTenantCheckFilter, which IS that same layer0; the + // platform-admin `posturePermits` gate and the write-check bypass via + // `meta.tenancyDisabled` directly) — so a deployment-exempted object + // behaves exactly as if it had declared `tenancy: { enabled: false }` + // itself, on every one of those paths at once, on THIS deployment only. + // Gated on the armed wall so the `single` posture stays byte-identical + // (there the exemption could only perturb Layer 1 bypasses on a wall + // that does not exist). Fail closed: absent/junk declaration ⇒ the + // object's own two clauses decide, exactly as today. tenancyDisabled: - (obj as any)?.tenancy?.enabled === false || (obj as any)?.systemFields?.tenant === false, + (obj as any)?.tenancy?.enabled === false || + (obj as any)?.systemFields?.tenant === false || + (this.orgScopingEnabled && + this.deploymentOrgScopingEntitlement().platformGlobalObjects.has(object)), // Identity-infrastructure tables managed by the auth library // (`managedBy: 'better-auth'`: sys_user, sys_account, sys_session, // sys_oauth_application, sys_sso_provider, …). Their rows are written by diff --git a/packages/spec/src/security/tenancy-posture.test.ts b/packages/spec/src/security/tenancy-posture.test.ts new file mode 100644 index 0000000000..937b9c88e9 --- /dev/null +++ b/packages/spec/src/security/tenancy-posture.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12699] `OrgScopingEntitlement` — the per-deployment wall-shaping contract. + * + * The interface is consumed structurally off a LIVE service object + * (`getService('org-scoping')`), so these cases pin the runtime twin the + * consumers validate with: what a well-formed declaration looks like, what + * junk must be refused, and that a service instance's unrelated machinery + * (name/version/init/...) never disqualifies its declaration. + */ + +import { describe, it, expect } from 'vitest'; +import { + OrgScopingEntitlementSchema, + PlatformGlobalObjectsSchema, + normalizeTenancyPosture, + type OrgScopingEntitlement, +} from './tenancy-posture'; + +describe('[#12699] PlatformGlobalObjectsSchema', () => { + it('accepts exact object machine names', () => { + const parsed = PlatformGlobalObjectsSchema.safeParse([ + 'sys_setting', + 'sys_job_queue', + 'cloud_capability', + '_private_ledger', + ]); + expect(parsed.success).toBe(true); + }); + + it('accepts the empty list (an explicit "no exemptions")', () => { + expect(PlatformGlobalObjectsSchema.safeParse([]).success).toBe(true); + }); + + it.each([ + ['a bare string', 'sys_setting'], + ['a wildcard entry', ['sys_setting', '*']], + ['a glob-shaped entry', ['sys_*']], + ['an empty-string entry', ['']], + ['a non-string entry', ['sys_setting', 42]], + ['an uppercase name', ['SysSetting']], + ['a number', 42], + ['an object', { objects: ['sys_setting'] }], + ])('refuses %s', (_label, value) => { + expect(PlatformGlobalObjectsSchema.safeParse(value).success).toBe(false); + }); +}); + +describe('[#12699] OrgScopingEntitlementSchema', () => { + it('accepts a full declaration', () => { + const declaration: OrgScopingEntitlement = { + supportedPostures: ['isolated'], + platformGlobalObjects: ['sys_setting', 'sys_job'], + suppressUnboundedOrgAdminGrant: true, + }; + expect(OrgScopingEntitlementSchema.safeParse(declaration).success).toBe(true); + }); + + it('accepts the empty declaration (every key optional — the pre-seam runtime)', () => { + expect(OrgScopingEntitlementSchema.safeParse({}).success).toBe(true); + }); + + it('tolerates a live service object carrying unrelated machinery (non-strict by design)', () => { + const serviceShaped = { + name: 'com.example.org-scoping', + version: '1.0.0', + init: () => undefined, + supportedPostures: ['group', 'isolated'], + platformGlobalObjects: ['sys_setting'], + }; + const parsed = OrgScopingEntitlementSchema.safeParse(serviceShaped); + expect(parsed.success).toBe(true); + // ...and the parse yields only the declaration, machinery stripped. + expect(parsed.success && parsed.data).toEqual({ + supportedPostures: ['group', 'isolated'], + platformGlobalObjects: ['sys_setting'], + }); + }); + + it.each([ + ['junk platformGlobalObjects', { platformGlobalObjects: 'sys_setting' }], + ['junk suppress flag', { suppressUnboundedOrgAdminGrant: 'yes' }], + ['junk posture entry', { supportedPostures: ['isolated', 'multi'] }], + ])('refuses %s', (_label, value) => { + expect(OrgScopingEntitlementSchema.safeParse(value).success).toBe(false); + }); + + it('the schema and the interface agree (compile-time parity witness)', () => { + // Assignability both ways, checked by tsc when this file typechecks. + const fromSchema = OrgScopingEntitlementSchema.parse({ + supportedPostures: ['isolated'], + platformGlobalObjects: ['sys_setting'], + suppressUnboundedOrgAdminGrant: true, + }); + const asInterface: OrgScopingEntitlement = fromSchema; + expect(asInterface.suppressUnboundedOrgAdminGrant).toBe(true); + }); +}); + +describe('normalizeTenancyPosture (pre-existing seam, exercised alongside)', () => { + it('maps the legacy `multi` spelling to `isolated`', () => { + expect(normalizeTenancyPosture('multi')).toBe('isolated'); + }); + it('returns undefined for junk rather than a weaker posture', () => { + expect(normalizeTenancyPosture('everything')).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/security/tenancy-posture.ts b/packages/spec/src/security/tenancy-posture.ts index a421bd0872..82893b2b64 100644 --- a/packages/spec/src/security/tenancy-posture.ts +++ b/packages/spec/src/security/tenancy-posture.ts @@ -93,11 +93,88 @@ export function postureUsesUnionScope(posture: TenancyPosture): boolean { * * Omitting `supportedPostures` entitles every walled posture, which is what * every runtime predating this seam did. + * + * ## Per-deployment wall shaping (#12699, cloud#1653 ruling 2026-08-26) + * + * The two keys below extend the same seam in the same direction: they are + * DEPLOYMENT facts declared by the mounted org-scoping runtime — never + * authorable app metadata — and both FAIL CLOSED: an absent (or unparseable) + * declaration leaves behaviour byte-identical to a runtime predating the key. + * + * They exist because the alternative seams are dead. Host self-declaration of + * the boundary is a paywall bypass (only a mounted enterprise runtime may + * declare anything here, which is what keeps the org-create gate intact), and + * carrying `tenancy` through `objectExtensions` silently drops it in the merge + * (objectstack#12680). Nor can the per-object authoring channel + * (`tenancy: { enabled: false }`) express either fact: that declaration travels + * with the OBJECT into every deployment, while these are facts about ONE + * deployment — the same object that is platform-global on the platform's own + * control plane genuinely walls on tenant runtimes. */ export interface OrgScopingEntitlement { readonly supportedPostures?: readonly TenancyPosture[]; + /** + * Objects THIS deployment declares platform-global: Layer 0 must not wall + * them here, exactly as if the object had declared + * `tenancy: { enabled: false }` — but only on this deployment. Consumed by + * plugin-security when arming the Layer 0 organization wall; it composes + * with (never replaces) the object-level authoring channel. + * + * Entries are exact object machine names ({@link PlatformGlobalObjectsSchema} + * — no wildcards: a pattern would let one declaration unwall an open-ended + * set, and the whole point of the seam is an explicit, auditable carve-out). + * + * Fail closed: absent ⇒ every object walls exactly as its own declaration + * says; a junk shape is refused loudly at the consuming seam (the + * `MembershipPolicy` precedent — never coerced), which also resolves to + * "absent". + */ + readonly platformGlobalObjects?: readonly string[]; + /** + * When `true`, arming a walled posture must NOT auto-grant the + * `organization_admin` role's unbounded `viewAllRecords`/`modifyAllRecords` + * superbits: the membership-driven auto-grant hands out + * `organization_admin_no_bypass` (the de-VAMA'd variant) instead, on walled + * postures too. The ADR-0105 D4 rationale for granting the unbounded set + * under a wall — "Layer 0 bounds it" — stops holding on a deployment that + * carves platform-global objects OUT of the wall with + * {@link platformGlobalObjects}, so the same runtime that declares the + * carve-out declares this suppression. + * + * Fail closed: absent or `false` ⇒ today's posture-keyed grant; junk is + * refused loudly and resolves to "absent". + */ + readonly suppressUnboundedOrgAdminGrant?: boolean; } +/** + * [#12699] `platformGlobalObjects` value shape: exact object machine names + * (the `ObjectSchema.name` grammar), at least one character, no wildcards. + * + * A junk shape — a bare string, non-string entries, `''`, `'*'` — must be + * REFUSED at the consuming seam, never coerced or partially honoured: the + * declarer is first-party runtime code, so a malformed declaration is a bug to + * surface, and refusing resolves to the fail-closed default (everything walls). + */ +export const PlatformGlobalObjectsSchema = z + .array(z.string().regex(/^[a-z_][a-z0-9_]*$/)) + .readonly(); + +/** + * [ADR-0105 D12 / #12699] Runtime twin of {@link OrgScopingEntitlement} for the + * keys with structural shape requirements. Deliberately a plain (non-strict) + * object schema: the `org-scoping` service is usually a live plugin instance + * carrying service machinery alongside the declaration, and unknown keys are + * not junk. Consumers validate PER KEY (each key fails closed independently) + * rather than all-or-nothing — see plugin-security's + * `readDeploymentOrgScopingEntitlement`. + */ +export const OrgScopingEntitlementSchema = z.object({ + supportedPostures: z.array(TenancyPostureSchema).readonly().optional(), + platformGlobalObjects: PlatformGlobalObjectsSchema.optional(), + suppressUnboundedOrgAdminGrant: z.boolean().optional(), +}); + /** * Normalize a stored/env-supplied posture value, accepting the legacy `multi` * spelling as `isolated` (ADR-0093 → ADR-0105 rename). Returns `undefined` for From 1e61a0de545c7f1f1aca3b1ad7d1bfaab0026b09 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:35:04 +0800 Subject: [PATCH 2/3] chore(spec): regenerate security-shard artifacts + PlatformGlobalObjects type alias Co-Authored-By: Claude Fable 5 --- content/docs/references/index.mdx | 10 ++++---- content/docs/references/security/misc.mdx | 24 +++++++++++++++++-- packages/spec/api-surface/security.json | 3 +++ .../spec/authorable-surface/security.json | 3 +++ packages/spec/export-origins/security.json | 3 +++ .../spec/json-schema.manifest/security.json | 2 ++ packages/spec/src/security/tenancy-posture.ts | 1 + 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index e23fc15613..ada56d08bf 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -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/. */} @@ -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 | --- @@ -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` | diff --git a/content/docs/references/security/misc.mdx b/content/docs/references/security/misc.mdx index 4b3d45add2..a0c7004888 100644 --- a/content/docs/references/security/misc.mdx +++ b/content/docs/references/security/misc.mdx @@ -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); @@ -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 diff --git a/packages/spec/api-surface/security.json b/packages/spec/api-surface/security.json index fec216823c..d0cba42252 100644 --- a/packages/spec/api-surface/security.json +++ b/packages/spec/api-surface/security.json @@ -46,6 +46,7 @@ "ObjectPermissionParsed (type)", "ObjectPermissionSchema (const)", "OrgScopingEntitlement (interface)", + "OrgScopingEntitlementSchema (const)", "PLATFORM_CAPABILITIES (const)", "PLATFORM_CAPABILITY_NAMES (const)", "PUBLIC_FORM_SERVER_MANAGED_FIELDS (const)", @@ -53,6 +54,8 @@ "PermissionSetParsed (type)", "PermissionSetSchema (const)", "PlatformCapability (interface)", + "PlatformGlobalObjects (type)", + "PlatformGlobalObjectsSchema (const)", "RLS (const)", "RLSEvaluationResult (type)", "RLSEvaluationResultSchema (const)", diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index 4a9e3187f7..b65465e7fb 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -106,6 +106,9 @@ "security/ObjectPermission:readScope", "security/ObjectPermission:viewAllRecords", "security/ObjectPermission:writeScope", + "security/OrgScopingEntitlement:platformGlobalObjects", + "security/OrgScopingEntitlement:supportedPostures", + "security/OrgScopingEntitlement:suppressUnboundedOrgAdminGrant", "security/PermissionSet:_lock", "security/PermissionSet:_lockDocsUrl", "security/PermissionSet:_lockReason", diff --git a/packages/spec/export-origins/security.json b/packages/spec/export-origins/security.json index 15c542c16d..dee36798f4 100644 --- a/packages/spec/export-origins/security.json +++ b/packages/spec/export-origins/security.json @@ -46,6 +46,7 @@ "ObjectPermissionParsed": "src/security/permission.zod.ts#ObjectPermissionParsed (type)", "ObjectPermissionSchema": "src/security/permission.zod.ts#ObjectPermissionSchema (const)", "OrgScopingEntitlement": "src/security/tenancy-posture.ts#OrgScopingEntitlement (interface)", + "OrgScopingEntitlementSchema": "src/security/tenancy-posture.ts#OrgScopingEntitlementSchema (const)", "PLATFORM_CAPABILITIES": "src/security/capabilities.ts#PLATFORM_CAPABILITIES (const)", "PLATFORM_CAPABILITY_NAMES": "src/security/capabilities.ts#PLATFORM_CAPABILITY_NAMES (const)", "PUBLIC_FORM_SERVER_MANAGED_FIELDS": "src/security/public-form.ts#PUBLIC_FORM_SERVER_MANAGED_FIELDS (const)", @@ -53,6 +54,8 @@ "PermissionSetParsed": "src/security/permission.zod.ts#PermissionSetParsed (type)", "PermissionSetSchema": "src/security/permission.zod.ts#PermissionSetSchema (const)", "PlatformCapability": "src/security/capabilities.ts#PlatformCapability (interface)", + "PlatformGlobalObjects": "src/security/tenancy-posture.ts#PlatformGlobalObjects (type)", + "PlatformGlobalObjectsSchema": "src/security/tenancy-posture.ts#PlatformGlobalObjectsSchema (const)", "RLS": "src/security/rls.zod.ts#RLS (const)", "RLSEvaluationResult": "src/security/rls.zod.ts#RLSEvaluationResult (type)", "RLSEvaluationResultSchema": "src/security/rls.zod.ts#RLSEvaluationResultSchema (const)", diff --git a/packages/spec/json-schema.manifest/security.json b/packages/spec/json-schema.manifest/security.json index abb0e3565d..8961a21047 100644 --- a/packages/spec/json-schema.manifest/security.json +++ b/packages/spec/json-schema.manifest/security.json @@ -19,7 +19,9 @@ "security/OWDModel", "security/ObjectAccessScope", "security/ObjectPermission", + "security/OrgScopingEntitlement", "security/PermissionSet", + "security/PlatformGlobalObjects", "security/RLSEvaluationResult", "security/RLSOperation", "security/RLSUserContext", diff --git a/packages/spec/src/security/tenancy-posture.ts b/packages/spec/src/security/tenancy-posture.ts index 82893b2b64..62f502e14e 100644 --- a/packages/spec/src/security/tenancy-posture.ts +++ b/packages/spec/src/security/tenancy-posture.ts @@ -159,6 +159,7 @@ export interface OrgScopingEntitlement { export const PlatformGlobalObjectsSchema = z .array(z.string().regex(/^[a-z_][a-z0-9_]*$/)) .readonly(); +export type PlatformGlobalObjects = z.infer; /** * [ADR-0105 D12 / #12699] Runtime twin of {@link OrgScopingEntitlement} for the From b49ebc290c0161fe48e0a7940603620af85a70ad Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:12:25 +0800 Subject: [PATCH 3/3] chore: changeset for OrgScopingEntitlement wall-shaping keys Co-Authored-By: Claude Fable 5 --- .changeset/orange-planets-sniff.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/orange-planets-sniff.md diff --git a/.changeset/orange-planets-sniff.md b/.changeset/orange-planets-sniff.md new file mode 100644 index 0000000000..716b608f6c --- /dev/null +++ b/.changeset/orange-planets-sniff.md @@ -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`.