From 0cb83e06509f6789bfa22c897534760102858ad4 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:23:54 +0800 Subject: [PATCH 1/6] =?UTF-8?q?docs(adr):=20refine=20ADR-0066=20=E2=80=94?= =?UTF-8?q?=20posture-gated=20superuser=20RLS=20bypass,=20precedence=20ord?= =?UTF-8?q?er,=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ① Generalize the private RLS exemption into one explainable rule: viewAllRecords bypasses read RLS and modifyAllRecords bypasses write RLS, gated by the object's platform-global/private posture (Salesforce View All Data / Dataverse Org level). Covers the write path too; replaces the narrow "private skips tenant_isolation". - ② Add an explicit "Precedence / combination semantics" section (AND-gates → union grants → RLS OR-within/AND-with-global → deny overrides), closing the ADR's own open edge-case; fix the vague Consequences bullet. - ③ Author guidance for private (posture) vs requiredPermissions (capability contract); clarify requiredPermissions is an AND-gate. - ④–⑦ "Future refinements": deny-by-default target, per-operation requiredPermissions, capabilities in the CEL surface, permission-set-group muting. Co-Authored-By: Claude Opus 4.8 --- docs/adr/0066-unified-authorization-model.md | 27 ++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/adr/0066-unified-authorization-model.md b/docs/adr/0066-unified-authorization-model.md index 8ed47f5f4d..31d4174e06 100644 --- a/docs/adr/0066-unified-authorization-model.md +++ b/docs/adr/0066-unified-authorization-model.md @@ -45,8 +45,12 @@ Promote capabilities from bare strings to **first-class records** (`sys_permissi ### D2 — Secure-by-default object/field posture [new] (data-model posture, NOT a permission) Add an object (and field) flag that opts it **out of blanket wildcard grants** — e.g. `access: { default: 'private' }` (vs the implicit `'public'`). A `private` object is **not** covered by `'*': {allowRead:true}`; access requires an **explicit** permission-set grant. Mirrors Salesforce "new object = no access until granted." This is a posture like `tenancy`, declared on the object — it is **not** an assignment and names no principal. `admin_full_access` (the superuser `'*'` grant) still covers private objects unless it too is excluded (rare). +**Enforcement — RLS exemption via the superuser bypass (revised ①).** A `private` (or `tenancy.enabled:false`, i.e. platform-global) object must also be exempt from the wildcard RLS policies (`tenant_isolation`, owner scoping) so a platform admin — *including one who is also an org admin*, whose `organization_admin` set contributes a narrowing `tenant_isolation` policy that the OR-union would otherwise apply — sees **all** rows. The general principle (Salesforce *View All Data* / Dataverse *Organization* access level): **`viewAllRecords` bypasses read-side RLS and `modifyAllRecords` bypasses write-side RLS for that object** — but *only* when the object's posture permits it (platform-global or `private`). The posture gates the bypass so that in a shared multi-tenant DB a platform admin is **not** silently granted cross-tenant visibility on ordinary *tenant business* objects; the bypass applies to control-plane / global / private objects, which is exactly where it is wanted. This replaces the original narrower "a `private` object skips the wildcard `tenant_isolation`" wording: same outcome for `sys_license`, but one explainable rule that also covers the write path. + +**When `private` vs `requiredPermissions` (D3) — author guidance (③).** `private` is a *data-model posture* — "no ambient grant; needs an explicit grant" — use it when the default answer should be *nobody*. `requiredPermissions` (D3) is a *capability contract* — "needs a named capability" — use it when the answer is *whoever holds capability X*. Either one alone secures a sensitive object; using both (as `sys_license` does) is defence-in-depth, not a requirement. + ### D3 — Resource→capability requirement [existing concept, new placement] -Extend `requiredPermissions` (today only on `App`/nav, **[existing]**) to **Object**, **Field**, and **Action**. A resource references the capability (D1) needed to access/invoke it — a contract, not an assignment. The security engine enforces it alongside permission-set grants. sys_license becomes: `access:{default:'private'}` + `requiredPermissions:['manage_licenses']`. +Extend `requiredPermissions` (today only on `App`/nav, **[existing]**) to **Object**, **Field**, and **Action**. A resource references the capability (D1) needed to access/invoke it — a contract, not an assignment. The security engine enforces it as an **AND-gate** — checked *in addition to* (not instead of) the permission-set CRUD grant; see *Precedence / combination semantics*. sys_license becomes: `access:{default:'private'}` + `requiredPermissions:['manage_licenses']`. ### D4 — Dual-surface action gates [new] An action declaring `requiredPermissions` is enforced in **one place, two surfaces**: the ActionRunner hides/disables it in the UI **and** the server rejects the call when the caller lacks the capability. Removes the "UI-gated but server-open" footgun (and the inverse). Server enforcement is the source of truth; UI gating is derived from the same declaration. @@ -76,10 +80,29 @@ Cloud seeds (D5): `manage_licenses` capability + an `admin_full_access` grant. R 3. **D1** — capability registry (string→record), back-compat seeded. 4. **D3 (field) + D5** — field-level requirements + package secure-default seeding; delegated admin (#9). +## Precedence / combination semantics (②) + +Authorization resolves in a fixed order, adopted from shapes proven elsewhere — ServiceNow ACLs (required-role **AND** condition), Odoo record rules (global-**AND**, group-**OR**), Salesforce (union grants): + +1. **AND-gates (hard prerequisites).** A resource's `requiredPermissions` (D3) and its `private` posture (D2) are prerequisites, not grants. The caller must clear every gate *before* any grant is consulted: missing a required capability, or lacking an explicit grant on a `private` object, **denies** regardless of how permissive the rest of the configuration is. +2. **Grants union (most-permissive).** Within the gates, object-CRUD and field grants combine most-permissively across the caller's permission sets — any set that allows wins (the existing semantics). +3. **RLS: OR within an object, AND with tenant-global.** Multiple row policies for the same object/operation are OR-combined (any matching policy admits the row); the wildcard tenant-isolation policy AND-s on top as a global scope. The **superuser bypass** (D2: `viewAllRecords`/`modifyAllRecords`, gated by posture) short-circuits RLS for the object. +4. **Explicit deny overrides (when introduced).** If/when a per-resource deny is added (Salesforce permission-set-group *muting*; see Future refinements) it sits at the top and overrides any union grant. Until then there is no implicit deny except the gates in (1) and fail-closed defaults (an applicable-but-uncompilable RLS policy denies). + ## Open-core boundary All of this is **open mechanism** (framework `spec` + `plugin-security`): schema fields, the registry, the enforcement engine. The *policies* (which capabilities, which grants) are **data** — shipped by distributions/packages and maintained by admins. No commercial policy is encoded in the framework. ## Consequences - **+** Security becomes declarative metadata co-located with the resource (single source of truth); generalizes to every object + third-party app; capabilities are admin-extensible records; sensitive resources are secure-by-default. - **−** Migration: string capabilities → records (seeded, back-compat); a `private` default flips the implicit allow-by-default for objects that adopt it (opt-in, no forced migration). -- **−** Combination edge cases (explicit deny vs union) need a defined precedence; specify deny-overrides only where a resource is `private`. +- **−** Combination/precedence is now **explicitly specified** (see *Precedence / combination semantics*) rather than left as an open edge case; explicit deny (muting) is deferred to Future refinements. + + +## Future refinements (beyond the phased plan) + +Captured for the record; **out of scope for Phases 1–4 above**. Each is anchored to a mainstream-platform precedent. + +- **④ Deny-by-default target for sensitive objects.** Salesforce / Dataverse / ServiceNow / SAP are all deny-by-default; ObjectStack stays allow-by-default for *tenant business* objects (low-code ergonomics, à la Airtable/Notion within a workspace) but should make **system / control-plane / sensitive** objects `private` by default, ship genuine reference data (countries, currencies, picklists) as explicit `public`, and surface each object's posture visibly in Studio. The `access` flag (D2) is the primitive; this is a defaults + visibility call, staged per object — no forced migration. +- **⑤ Per-operation `requiredPermissions`.** Today object-level `requiredPermissions` gates all of CRUD. ERP routinely needs "read-open / write-gated" (Salesforce & Dataverse separate capability by operation). Allow `requiredPermissions` to be either `string[]` (all operations) or a per-operation map `{ read, create, update, delete }`. Field-level (D3) and action-level (D4) requirements already give finer control; this closes the object-level gap. +- **⑥ Capabilities in the expression surface.** Salesforce *Custom Permissions* are referenceable in formulas / validation / flows (`$Permission.X`). Expose the caller's held capabilities to the CEL/predicate surface (ADR-0058) so `visible` / validation / sharing predicates can branch on a capability. High-leverage once D1 makes capabilities first-class. +- **⑦ Permission-set groups + subtractive *muting*.** Pure union does not scale governance ("permission-set explosion"); Salesforce added permission-set-group *muting* precisely to allow taking access away. Roles→permission-sets already bundle; a subtractive/deny layer (precedence step 4) is the missing piece for large-org administration. Pairs with delegated admin (#9). From e7e1ab936faeccfea6bf1b9ea55628ce0689cdf1 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:27:48 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(spec):=20ADR-0066=20D2/D3=20=E2=80=94?= =?UTF-8?q?=20object=20access=20posture=20+=20requiredPermissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - access: { default: 'public' | 'private' } — secure-by-default object posture (ObjectAccessConfigSchema), a data-model posture like tenancy. private opts the object out of wildcard '*' grants and (via the engine) out of wildcard RLS. - requiredPermissions: string[] on Object — capability contract (mirrors App.requiredPermissions), enforced by the engine as an AND-gate. - Both optional; absent ⇒ public / no gate (no migration for existing objects). - Tests + full-DTS build green. Co-Authored-By: Claude Opus 4.8 --- packages/spec/src/data/object.test.ts | 38 +++++++++++++++++++++- packages/spec/src/data/object.zod.ts | 46 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index c42b1c4b60..8dbbd902d0 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, type ServiceObject } from './object.zod'; +import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, type ServiceObject } from './object.zod'; describe('ObjectCapabilities', () => { it('should apply default values correctly', () => { @@ -953,3 +953,39 @@ describe('ObjectSchema.fieldGroups', () => { }); }); }); + + +describe('ADR-0066 — object access posture (D2) + requiredPermissions (D3)', () => { + it('ObjectAccessConfigSchema defaults to public', () => { + expect(ObjectAccessConfigSchema.parse({}).default).toBe('public'); + }); + + it('accepts an explicit private posture', () => { + expect(ObjectAccessConfigSchema.parse({ default: 'private' }).default).toBe('private'); + }); + + it('rejects an unknown posture value', () => { + expect(() => ObjectAccessConfigSchema.parse({ default: 'secret' })).toThrow(); + }); + + it('round-trips access + requiredPermissions on an object', () => { + const obj = ObjectSchema.create({ + name: 'sys_license', + tenancy: { enabled: false, strategy: 'shared' }, + access: { default: 'private' }, + requiredPermissions: ['manage_licenses'], + fields: { signed_token: { type: 'text' } }, + }); + expect(obj.access?.default).toBe('private'); + expect(obj.requiredPermissions).toEqual(['manage_licenses']); + }); + + it('leaves access undefined (public by convention) when omitted', () => { + const obj = ObjectSchema.create({ + name: 'crm_account', + fields: { name: { type: 'text' } }, + }); + expect(obj.access).toBeUndefined(); + expect(obj.requiredPermissions).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 2fe5d84b03..5576c10ab5 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -135,6 +135,32 @@ export const TenancyConfigSchema = lazySchema(() => z.object({ crossTenantAccess: z.boolean().default(false).describe('Allow cross-tenant data access (with explicit permission)'), })); +/** + * [ADR-0066 D2] Secure-by-default object posture. + * + * Declares whether the object participates in blanket wildcard permission + * grants — a data-model posture like {@link TenancyConfigSchema}, NOT an + * assignment (it names no principal). + * + * - `public` (default) — covered by a permission set's `'*'` wildcard object + * grant; today's allow-by-default behaviour. + * - `private` — NOT covered by the `'*'` wildcard grant; access requires an + * EXPLICIT per-object grant (Salesforce "new object = no access until + * granted"). A `private` object is ALSO exempt from wildcard RLS + * (`tenant_isolation`, owner scoping): the posture-gated superuser bypass + * (`viewAllRecords`/`modifyAllRecords`) short-circuits RLS, so a platform + * admin — incl. one who is also an org admin whose `tenant_isolation` would + * otherwise narrow the result — sees all rows, while non-admins without an + * explicit grant see none. + * + * Pair with the object's `requiredPermissions` (D3) to additionally gate access + * on holding a capability. + */ +export const ObjectAccessConfigSchema = lazySchema(() => z.object({ + default: z.enum(['public', 'private']).default('public') + .describe('Default exposure posture: public (covered by wildcard grants) | private (needs explicit grant; exempt from wildcard RLS).'), +})); + /** * Soft Delete Configuration Schema * Implements recycle bin / trash functionality @@ -487,6 +513,25 @@ const ObjectSchemaBase = z.object({ // Multi-tenancy configuration tenancy: TenancyConfigSchema.optional().describe('Multi-tenancy configuration for SaaS applications'), + + /** + * [ADR-0066 D2] Secure-by-default object posture. `access.default: 'private'` + * opts the object OUT of blanket wildcard (`'*'`) permission grants (access + * then needs an explicit per-object grant) and exempts it from wildcard RLS + * via the posture-gated superuser bypass. Absent ⇒ `public` (today's + * allow-by-default behaviour; no migration for existing objects). + */ + access: ObjectAccessConfigSchema.optional().describe('[ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default).'), + + /** + * [ADR-0066 D3] Capability contract — capability name(s) (permission-set + * `systemPermissions`; D1 records) a caller MUST hold to access this object at + * all. Mirrors `App.requiredPermissions`. Enforced by plugin-security as an + * AND-gate: checked IN ADDITION to permission-set CRUD grants — a caller + * missing any listed capability is denied regardless of grants. Absent/empty + * ⇒ no capability gate. + */ + requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D3] Capabilities required to access this object (AND-gate, checked alongside CRUD grants).'), // Soft delete configuration softDelete: SoftDeleteConfigSchema.optional().describe('Soft delete (trash/recycle bin) configuration'), @@ -867,6 +912,7 @@ export type ServiceObjectInput = z.input; export type ObjectCapabilities = z.infer; export type ObjectIndex = z.infer; export type TenancyConfig = z.infer; +export type ObjectAccessConfig = z.infer; export type SoftDeleteConfig = z.infer; export type VersioningConfig = z.infer; export type PartitioningConfig = z.infer; From b80c554376dc44aa8369b1fe905b6f20d890a53e Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:39:18 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(plugin-security):=20ADR-0066=20D2/D3?= =?UTF-8?q?=20=E2=80=94=20enforce=20private=20posture,=20requiredPermissio?= =?UTF-8?q?ns,=20posture-gated=20RLS=20bypass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D2 CRUD: a `private` object is not covered by a non-super-user '*' wildcard grant (resolveObjectPermission); access needs an explicit per-object grant OR the View/Modify All Data super-user wildcard. Explicit grants always honored. - D3 capability AND-gate: object `requiredPermissions` checked before the CRUD grant (caller's systemPermissions union); missing capability → deny. - ① posture-gated super-user RLS bypass: on a private/platform-global object, viewAllRecords bypasses read RLS and modifyAllRecords bypasses write RLS (incl. pre-image + post-image check) — so a platform admin who is also an org admin sees all rows, while the bypass never leaks cross-tenant data on ordinary tenant business objects. - New getObjectSecurityMeta cache (private/tenancy/requiredPermissions), invalidated on metadata change. Evaluator: getSystemPermissions, hasSuperuserRead/WriteBypass; checkObjectPermission/getEffectiveScope take an isPrivate option (backward-compatible). - 10 new tests (evaluator units + full-middleware integration). Full DTS green. Co-Authored-By: Claude Opus 4.8 --- .../src/permission-evaluator.ts | 84 +++++++++- .../src/security-plugin.test.ts | 153 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 98 ++++++++++- 3 files changed, 326 insertions(+), 9 deletions(-) diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts index 4c629eafdc..f2a248a743 100644 --- a/packages/plugins/plugin-security/src/permission-evaluator.ts +++ b/packages/plugins/plugin-security/src/permission-evaluator.ts @@ -26,6 +26,30 @@ const OPERATION_TO_PERMISSION: Record = { */ const DESTRUCTIVE_OPERATIONS = new Set(['transfer', 'restore', 'purge']); +/** + * [ADR-0066 D2] Resolve the object permission a permission set contributes for + * `objectName`, honouring the secure-by-default posture: + * + * - an EXPLICIT per-object grant (`ps.objects[objectName]`) always applies; + * - the `'*'` wildcard applies to a `public` object (today's allow-by-default); + * - for a `private` object the `'*'` wildcard applies ONLY when it carries the + * super-user bypass bits (`viewAllRecords`/`modifyAllRecords` — the Salesforce + * "View/Modify All Data" power). A plain `'*': {allowRead:true}` does NOT cover + * a private object; access then requires an explicit per-object grant. + */ +function resolveObjectPermission( + ps: PermissionSet, + objectName: string, + isPrivate: boolean, +): ObjectPermission | undefined { + const explicit = ps.objects?.[objectName]; + if (explicit) return explicit; + const wild = ps.objects?.['*']; + if (!wild) return undefined; + if (!isPrivate) return wild; + return wild.viewAllRecords || wild.modifyAllRecords ? wild : undefined; +} + /** * PermissionEvaluator * @@ -40,7 +64,9 @@ export class PermissionEvaluator { checkObjectPermission( operation: string, objectName: string, - permissionSets: PermissionSet[] + permissionSets: PermissionSet[], + /** [ADR-0066 D2] When the object is `private`, the `'*'` wildcard only covers it if it is a super-user grant. */ + opts: { isPrivate?: boolean } = {}, ): boolean { const permKey = OPERATION_TO_PERMISSION[operation]; if (!permKey) { @@ -51,10 +77,10 @@ export class PermissionEvaluator { } for (const ps of permissionSets) { - // Honour the `'*'` wildcard sentinel — admin permission sets typically - // grant blanket access via a single `objects: { '*': … }` entry rather - // than enumerating every system object. - const objPerm = ps.objects?.[objectName] ?? ps.objects?.['*']; + // [ADR-0066 D2] Honour the `'*'` wildcard sentinel — admin permission + // sets grant blanket access via a single `objects: { '*': … }` entry — + // but a `private` object is excluded from a non-super-user wildcard. + const objPerm = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false); if (objPerm) { // Check if modifyAllRecords is set (super-user bypass for write ops) if (['allowEdit', 'allowDelete'].includes(permKey) && objPerm.modifyAllRecords) { @@ -87,13 +113,14 @@ export class PermissionEvaluator { opClass: 'read' | 'write', objectName: string, permissionSets: PermissionSet[], + opts: { isPrivate?: boolean } = {}, ): 'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' { const RANK = { own: 0, own_and_reports: 1, unit: 2, unit_and_below: 3, org: 4 } as const; const ORDER = ['own', 'own_and_reports', 'unit', 'unit_and_below', 'org'] as const; let widest = -1; let matched = false; for (const ps of permissionSets) { - const op: any = ps.objects?.[objectName] ?? ps.objects?.['*']; + const op: any = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false); if (!op) continue; matched = true; if (opClass === 'read' && (op.viewAllRecords || op.modifyAllRecords)) return 'org'; @@ -106,6 +133,51 @@ export class PermissionEvaluator { return ORDER[widest < 0 ? 0 : widest]; } + /** + * [ADR-0066 D3] Union of `systemPermissions` (capabilities) the caller holds + * across the resolved permission sets — used to enforce a resource's + * `requiredPermissions` AND-gate. + */ + getSystemPermissions(permissionSets: PermissionSet[]): Set { + const out = new Set(); + for (const ps of permissionSets) { + for (const cap of ps.systemPermissions ?? []) out.add(cap); + } + return out; + } + + /** + * [ADR-0066 D2 / ①] Does any resolved set grant the super-user READ bypass + * (`viewAllRecords`/`modifyAllRecords`, the "View All Data" power) for the + * object? Honours the private posture (see {@link resolveObjectPermission}). + * The security plugin uses this to skip wildcard RLS on private/platform-global + * objects so a platform admin sees all rows. + */ + hasSuperuserReadBypass( + objectName: string, + permissionSets: PermissionSet[], + opts: { isPrivate?: boolean } = {}, + ): boolean { + for (const ps of permissionSets) { + const op = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false); + if (op && (op.viewAllRecords || op.modifyAllRecords)) return true; + } + return false; + } + + /** [ADR-0066 D2 / ①] Super-user WRITE bypass (`modifyAllRecords`) for the object. */ + hasSuperuserWriteBypass( + objectName: string, + permissionSets: PermissionSet[], + opts: { isPrivate?: boolean } = {}, + ): boolean { + for (const ps of permissionSets) { + const op = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false); + if (op && op.modifyAllRecords) return true; + } + return false; + } + /** * Get the merged field permissions for an object. * Returns a map of field names to their effective permissions. diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index d857f05fb6..d0091fda7b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -389,6 +389,102 @@ describe('SecurityPlugin', () => { expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' }); }); + // ------------------------------------------------------------------------- + // ADR-0066 D2/D3 — private posture + requiredPermissions (full middleware) + // ------------------------------------------------------------------------- + describe('ADR-0066 private posture + requiredPermissions (middleware)', () => { + const memberSet: PermissionSet = { + name: 'member_default', label: 'Member', isProfile: true, + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + } as any; + // Platform super-admin: super-user wildcard + the capability + a tenant_isolation + // RLS policy (to prove the posture-gated bypass actually short-circuits it). + const adminSet: PermissionSet = { + name: 'admin_full_access', label: 'Admin', isProfile: true, + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true } }, + systemPermissions: ['manage_platform_settings'], + rowLevelSecurity: [ + { name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' }, + ], + } as any; + + it('DENIES a non-admin (plain wildcard) on a private object — wildcard does not cover it', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [memberSet], + objectFields: ['id', 'organization_id', 'signed_token'], + schemaExtra: { access: { default: 'private' } }, + orgScoping: true, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'find', ast: { where: undefined }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + }; + await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + + it('DENIES a caller missing the required capability (D3 AND-gate)', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [memberSet], + objectFields: ['id', 'organization_id', 'signed_token'], + schemaExtra: { requiredPermissions: ['manage_platform_settings'] }, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'find', ast: { where: undefined }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + }; + await expect(harness.run(opCtx)).rejects.toMatchObject({ + name: 'PermissionDeniedError', + message: expect.stringContaining('requires capability'), + }); + }); + + it('ALLOWS the platform admin and BYPASSES wildcard RLS on a private object (read)', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); + const harness = makeMiddlewareCtx({ + permissionSets: [adminSet], + objectFields: ['id', 'organization_id', 'signed_token'], + schemaExtra: { access: { default: 'private' }, requiredPermissions: ['manage_platform_settings'] }, + orgScoping: true, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'find', ast: { where: undefined }, + context: { userId: 'admin', tenantId: 'org-1', roles: ['admin_full_access'], permissions: [] }, + }; + await expect(harness.run(opCtx)).resolves.toBeDefined(); + // Without the bypass this would be { organization_id: 'org-1' } and the + // platform admin would miss null-/cross-org rows (the sys_license bug). + expect(opCtx.ast.where).toBeUndefined(); + }); + + it('BYPASSES the write pre-image check for a modifyAll admin on a private object', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); + const harness = makeMiddlewareCtx({ + permissionSets: [adminSet], + objectFields: ['id', 'organization_id', 'signed_token'], + schemaExtra: { access: { default: 'private' }, requiredPermissions: ['manage_platform_settings'] }, + orgScoping: true, + findOneImpl: () => null, // would DENY if the pre-image check ran + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'update', + data: { id: 'r1', signed_token: 'x' }, options: { where: { id: 'r1' } }, + context: { userId: 'admin', tenantId: 'org-1', roles: ['admin_full_access'], permissions: [] }, + }; + await expect(harness.run(opCtx)).resolves.toBeDefined(); + expect(harness.findOne).not.toHaveBeenCalled(); + }); + }); + // ------------------------------------------------------------------------- // getReadFilter service (ADR-0021 D-C) — the reusable READ scope the // analytics raw-SQL path bridges to. Must produce the SAME FilterCondition @@ -891,6 +987,63 @@ describe('PermissionEvaluator', () => { }); }); +// --------------------------------------------------------------------------- +// PermissionEvaluator — ADR-0066 D2 (private posture) + D3 (capabilities) +// --------------------------------------------------------------------------- +describe('PermissionEvaluator — ADR-0066 posture + capabilities', () => { + const ps = (name: string, objects: PermissionSet['objects'] = {}, systemPermissions?: string[]): PermissionSet => + ({ name, objects, ...(systemPermissions ? { systemPermissions } : {}) }); + const plainWildcard = ps('member', { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }); + const superWildcard = ps('admin', { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true } }); + const explicitGrant = ps('license_reader', { sys_license: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } }); + + it('private object is NOT covered by a plain (non-superuser) wildcard', () => { + const ev = new PermissionEvaluator(); + expect(ev.checkObjectPermission('find', 'sys_license', [plainWildcard], { isPrivate: true })).toBe(false); + // ...but a public object still is (today's allow-by-default). + expect(ev.checkObjectPermission('find', 'crm_account', [plainWildcard])).toBe(true); + expect(ev.checkObjectPermission('find', 'crm_account', [plainWildcard], { isPrivate: false })).toBe(true); + }); + + it('private object IS covered by a super-user wildcard (View/Modify All Data)', () => { + const ev = new PermissionEvaluator(); + expect(ev.checkObjectPermission('find', 'sys_license', [superWildcard], { isPrivate: true })).toBe(true); + expect(ev.checkObjectPermission('insert', 'sys_license', [superWildcard], { isPrivate: true })).toBe(true); + expect(ev.checkObjectPermission('update', 'sys_license', [superWildcard], { isPrivate: true })).toBe(true); + }); + + it('private object IS covered by an explicit per-object grant (no superuser bit needed)', () => { + const ev = new PermissionEvaluator(); + expect(ev.checkObjectPermission('find', 'sys_license', [explicitGrant], { isPrivate: true })).toBe(true); + expect(ev.checkObjectPermission('update', 'sys_license', [explicitGrant], { isPrivate: true })).toBe(false); + }); + + it('getSystemPermissions unions capabilities across sets', () => { + const ev = new PermissionEvaluator(); + const a = ps('a', {}, ['manage_users', 'export_data']); + const b = ps('b', {}, ['export_data', 'manage_platform_settings']); + expect([...ev.getSystemPermissions([a, b])].sort()).toEqual(['export_data', 'manage_platform_settings', 'manage_users']); + expect([...ev.getSystemPermissions([plainWildcard])]).toEqual([]); + }); + + it('hasSuperuserReadBypass honours the private posture', () => { + const ev = new PermissionEvaluator(); + // plain wildcard: bypasses on a public object, NOT on a private one. + expect(ev.hasSuperuserReadBypass('crm_account', [plainWildcard], { isPrivate: false })).toBe(false); // no viewAll bit + expect(ev.hasSuperuserReadBypass('sys_license', [superWildcard], { isPrivate: true })).toBe(true); + expect(ev.hasSuperuserReadBypass('sys_license', [plainWildcard], { isPrivate: true })).toBe(false); + // explicit grant without viewAll → no read bypass. + expect(ev.hasSuperuserReadBypass('sys_license', [explicitGrant], { isPrivate: true })).toBe(false); + }); + + it('hasSuperuserWriteBypass requires modifyAllRecords', () => { + const ev = new PermissionEvaluator(); + const viewOnly = ps('vo', { '*': { allowRead: true, viewAllRecords: true } }); + expect(ev.hasSuperuserWriteBypass('sys_license', [superWildcard], { isPrivate: true })).toBe(true); + expect(ev.hasSuperuserWriteBypass('sys_license', [viewOnly], { isPrivate: true })).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // FieldMasker // --------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index ad9b7277e0..0d19c91fac 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -112,6 +112,13 @@ export class SecurityPlugin implements Plugin { private metadataWatch: { unsubscribe: () => void } | null = null; /** ADR-0055: cache the resolved master-detail relation per controlled_by_parent object. */ private cbpRelCache = new Map(); + /** + * [ADR-0066 D2/D3] Per-object security posture cache: `private` flag + * (access.default), platform-global flag (tenancy disabled), and the object's + * `requiredPermissions` capability contract. Populated lazily from the schema; + * cleared on metadata change alongside the other schema-derived caches. + */ + private readonly objectSecurityMetaCache = new Map(); private dbLoader?: (names: string[]) => Promise; private logger: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void } = {}; @@ -224,6 +231,7 @@ export class SecurityPlugin implements Plugin { this.fieldNamesCache.clear(); this.tenancyDisabledCache.clear(); this.cbpRelCache.clear(); + this.objectSecurityMetaCache.clear(); }); } @@ -366,12 +374,43 @@ export class SecurityPlugin implements Plugin { ); } + // [ADR-0066 D2/D3] Resolve the object's security posture (private flag, + // platform-global flag, capability contract) once for the checks below. + const secMeta = + permissionSets.length > 0 + ? await this.getObjectSecurityMeta(opCtx.object) + : { isPrivate: false, tenancyDisabled: false, requiredPermissions: [] as string[] }; + + // 1.5. [ADR-0066 D3] requiredPermissions AND-gate — a capability + // prerequisite checked BEFORE the CRUD grant (ADR §Precedence): a + // caller missing any required capability is denied regardless of how + // permissive their grants are. + if (permissionSets.length > 0 && secMeta.requiredPermissions.length > 0) { + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + const missing = secMeta.requiredPermissions.filter((cap) => !held.has(cap)); + if (missing.length > 0) { + throw new PermissionDeniedError( + `[Security] Access denied: '${opCtx.object}' requires capability ` + + `[${secMeta.requiredPermissions.join(', ')}] — caller is missing [${missing.join(', ')}]`, + { + operation: opCtx.operation, + object: opCtx.object, + roles, + permissionSets: explicitPermissionSets, + requiredPermissions: secMeta.requiredPermissions, + missingPermissions: missing, + }, + ); + } + } + // 2. CRUD permission check if (permissionSets.length > 0) { const allowed = this.permissionEvaluator.checkObjectPermission( opCtx.operation, opCtx.object, - permissionSets + permissionSets, + { isPrivate: secMeta.isPrivate }, ); if (!allowed) { @@ -390,9 +429,9 @@ export class SecurityPlugin implements Plugin { if (permissionSets.length > 0) { const sc: any = opCtx.context; if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) { - sc.__readScope = this.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets); + sc.__readScope = this.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate }); } else if (opCtx.operation === 'update' || opCtx.operation === 'delete') { - sc.__writeScope = this.permissionEvaluator.getEffectiveScope('write', opCtx.object, permissionSets); + sc.__writeScope = this.permissionEvaluator.getEffectiveScope('write', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate }); } } @@ -913,6 +952,21 @@ export class SecurityPlugin implements Plugin { operation: string, context: any, ): Promise | null> { + // [ADR-0066 ①] Posture-gated super-user RLS bypass. On a `private` or + // platform-global object, a caller with the super-user bypass bit + // (viewAllRecords for reads, modifyAllRecords for writes) skips wildcard RLS + // entirely — so a platform admin (incl. one who is also an org admin whose + // tenant_isolation would otherwise narrow the result) sees all rows. The + // posture gate ensures this never grants cross-tenant visibility on ordinary + // tenant business objects. + const meta = await this.getObjectSecurityMeta(object); + if (meta.isPrivate || meta.tenancyDisabled) { + const isWrite = operation === 'insert' || operation === 'update' || operation === 'delete'; + const bypass = isWrite + ? this.permissionEvaluator.hasSuperuserWriteBypass(object, permissionSets, { isPrivate: meta.isPrivate }) + : this.permissionEvaluator.hasSuperuserReadBypass(object, permissionSets, { isPrivate: meta.isPrivate }); + if (bypass) return null; + } const allRlsPolicies = this.collectRLSPolicies(permissionSets, object, operation); if (allRlsPolicies.length === 0) return null; // Field-existence safety: wildcard policies (`object: '*'`) target fields @@ -957,6 +1011,15 @@ export class SecurityPlugin implements Plugin { operation: string, context: any, ): Promise | null> { + // [ADR-0066 ①] modifyAllRecords bypasses write-side RLS (incl. the post-image + // check) on private/platform-global objects. + const meta = await this.getObjectSecurityMeta(object); + if ( + (meta.isPrivate || meta.tenancyDisabled) && + this.permissionEvaluator.hasSuperuserWriteBypass(object, permissionSets, { isPrivate: meta.isPrivate }) + ) { + return null; + } const withCheck = this.collectRLSPolicies(permissionSets, object, operation).filter( (p) => typeof (p as { check?: string }).check === 'string' && (p as { check?: string }).check!.trim() !== '', ); @@ -1150,6 +1213,35 @@ export class SecurityPlugin implements Plugin { return this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies); } + /** + * [ADR-0066 D2/D3] Resolve and cache the object's security posture: whether it + * is `private` (access.default), platform-global (tenancy disabled), and its + * `requiredPermissions` capability contract. Prefers the live ObjectQL schema + * (reflects registry-time augmentation) and falls back to the metadata service. + * Returns the permissive default when the schema can't be resolved yet (boot) — + * the CRUD/RLS checks then behave as pre-0066 and the miss is retried next call. + */ + private async getObjectSecurityMeta( + object: string, + ): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[] }> { + const cached = this.objectSecurityMetaCache.get(object); + if (cached) return cached; + let obj: any = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null; + if (!obj) { + try { obj = await this.metadata?.get?.('object', object); } catch { obj = null; } + } + const meta = { + isPrivate: (obj as any)?.access?.default === 'private', + tenancyDisabled: + (obj as any)?.tenancy?.enabled === false || (obj as any)?.systemFields?.tenant === false, + requiredPermissions: Array.isArray((obj as any)?.requiredPermissions) + ? (obj as any).requiredPermissions.map(String) + : [], + }; + if (obj) this.objectSecurityMetaCache.set(object, meta); + return meta; + } + /** * Resolve the column-name set for an object (lowercased). Returns * `null` if the schema can't be loaded — caller should fail-closed. From c0b04f3c18c3a95216beeffefddad4efea4a4385 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:52:07 +0800 Subject: [PATCH 4/6] chore(spec): classify ADR-0066 object.access + object.requiredPermissions in liveness ledger ADR-0054 spec-property liveness gate requires every governed-type property to be classified. Mark both new object props `live`, citing their plugin-security enforcement sites. Not in BOUND_PROOF_PATHS, so no dogfood proof is required; unit + full-middleware coverage lives in plugin-security/security-plugin.test.ts. Co-Authored-By: Claude Opus 4.8 --- packages/spec/liveness/object.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 75107debc0..d1fa2a8553 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -87,6 +87,16 @@ "evidence": "packages/objectql/src/registry.ts:208", "note": "default perms; ui crudAffordances." }, + "access": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", + "note": "ADR-0066 D2 secure-by-default posture. access.default:'private' → resolveObjectPermission excludes the object from non-super-user '*' wildcard grants (needs an explicit grant or View/Modify All Data), and security-plugin computeRlsFilter/computeWriteCheckFilter apply the posture-gated super-user RLS bypass. Unit + full-middleware proven in plugin-security/security-plugin.test.ts." + }, + "requiredPermissions": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/security-plugin.ts", + "note": "ADR-0066 D3 object capability contract — the security middleware denies unless the caller's systemPermissions union covers it (AND-gate, before the CRUD grant). Mirrors App.requiredPermissions. Unit + full-middleware proven in plugin-security/security-plugin.test.ts." + }, "userActions": { "status": "live", "note": "objectui ObjectGrid per-object CRUD." From 7ca86a878d57d2dbf7b7e48347825f3d18d67508 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:40:55 +0800 Subject: [PATCH 5/6] chore(spec): refresh api-surface snapshot for ObjectAccessConfig (ADR-0066 D2) Non-breaking: +ObjectAccessConfig (type) +ObjectAccessConfigSchema (const). Co-Authored-By: Claude Opus 4.8 --- packages/spec/api-surface.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index e7e3b85083..43d8ce822c 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -315,6 +315,8 @@ "NormalizedFilter (type)", "NormalizedFilterSchema (const)", "NotificationChannel (type)", + "ObjectAccessConfig (type)", + "ObjectAccessConfigSchema (const)", "ObjectCapabilities (type)", "ObjectDependencyGraph (type)", "ObjectDependencyGraphSchema (const)", From 9e50def9cc179810d055c8d3840bb6f7f3c73b0e Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:40:55 +0800 Subject: [PATCH 6/6] fix(runtime): platform-scoped (null-org) permission grants resolve under an active org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveExecutionContext queried sys_user_permission_set with `organization_id = tenantId`, which DROPPED a platform-scoped (organization_id IS NULL) grant the moment a user had an active org. A platform admin who also owns an org therefore silently lost `admin_full_access` (and its systemPermissions) — and with ADR-0066 that locked them out of a requiredPermissions-gated control-plane object (sys_license). Mirror the role-binding logic (null org = global, cross-org): fetch the user's grants and keep null-org + active-org ones, dropping only grants scoped to a DIFFERENT org. +2 regression tests. Co-Authored-By: Claude Opus 4.8 --- .../resolve-execution-context.test.ts | 61 +++++++++++++++++++ .../src/security/resolve-execution-context.ts | 27 +++++--- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index ddb06eaf04..381ef4c32a 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -263,3 +263,64 @@ describe('resolveExecutionContext — localization (timezone + locale)', () => { expect(ctx.locale).toBeUndefined(); }); }); + + +// --------------------------------------------------------------------------- +// ADR-0066 — platform-scoped (null-org) permission-set grants are GLOBAL and +// must resolve even when the caller has an active org. Regression for the gap +// where `organization_id = tenantId` dropped a platform admin's null-org +// admin_full_access grant (and its systemPermissions) the moment they owned an +// org — which then locked them out of a requiredPermissions-gated object. +// --------------------------------------------------------------------------- +describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-0066)', () => { + const RAW = 'osk_admin'; + function makeAuthQl(extraGrants = []) { + const tables = { + sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', organization_id: 'orgA', expires_at: FUTURE }], + sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'owner' }], + sys_user_permission_set: [ + { id: 'ups_global', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }, + ...extraGrants, + ], + sys_permission_set: [ + { id: 'ps_admin', name: 'admin_full_access', system_permissions: '["manage_platform_settings","manage_users"]', object_permissions: '{}' }, + { id: 'ps_other', name: 'other_org_set', system_permissions: '["should_not_appear"]', object_permissions: '{}' }, + ], + sys_role: [], + sys_role_permission_set: [], + sys_user_role: [], + }; + return { + async find(object, opts) { + const rows = tables[object] ?? []; + const where = opts?.where ?? {}; + return rows.filter((row) => { + for (const [k, v] of Object.entries(where)) { + if (v !== null && typeof v === 'object') { + if (Array.isArray(v.$in) && !v.$in.includes(row[k])) return false; + continue; + } + if ((v ?? null) !== (row[k] ?? null)) return false; + } + return true; + }); + }, + }; + } + const opts = (ql) => ({ getService: async () => undefined, getQl: async () => ql, request: { headers: { 'x-api-key': RAW } } }); + + it('resolves a null-org admin grant + its systemPermissions even with an active org', async () => { + const ctx = await resolveExecutionContext(opts(makeAuthQl())); + expect(ctx.tenantId).toBe('orgA'); + expect(ctx.permissions).toContain('admin_full_access'); + expect(ctx.systemPermissions).toContain('manage_platform_settings'); + }); + + it('still drops a grant scoped to a DIFFERENT org', async () => { + const ql = makeAuthQl([{ id: 'ups_otherorg', user_id: 'u1', permission_set_id: 'ps_other', organization_id: 'orgB' }]); + const ctx = await resolveExecutionContext(opts(ql)); + expect(ctx.permissions).toContain('admin_full_access'); // global grant kept + expect(ctx.permissions).not.toContain('other_org_set'); // foreign-org grant dropped + expect(ctx.systemPermissions).not.toContain('should_not_appear'); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index f0f024424f..4a1a0dee81 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -287,16 +287,25 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise( - upsRows.map((r) => r.permission_set_id ?? r.permissionSetId).filter(Boolean), + upsRows + .filter((r) => { + const org = (r.organization_id ?? r.organizationId) ?? null; + return !(org && tenantId && org !== tenantId); + }) + .map((r) => r.permission_set_id ?? r.permissionSetId) + .filter(Boolean), ); // Resolve role-bound permission sets.