Skip to content

Commit 4035b31

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(authz): ADR-0066 Phase 1 — secure-by-default object posture + requiredPermissions (D2/D3) (#2235)
* docs(adr): refine ADR-0066 — posture-gated superuser RLS bypass, precedence order, roadmap - ① 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 <noreply@anthropic.com> * feat(spec): ADR-0066 D2/D3 — object access posture + requiredPermissions - 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 <noreply@anthropic.com> * feat(plugin-security): ADR-0066 D2/D3 — enforce private posture, requiredPermissions, posture-gated RLS bypass - 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * chore(spec): refresh api-surface snapshot for ObjectAccessConfig (ADR-0066 D2) Non-breaking: +ObjectAccessConfig (type) +ObjectAccessConfigSchema (const). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(runtime): platform-scoped (null-org) permission grants resolve under an active org 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 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f2063f3 commit 4035b31

10 files changed

Lines changed: 525 additions & 21 deletions

File tree

docs/adr/0066-unified-authorization-model.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,12 @@ Promote capabilities from bare strings to **first-class records** (`sys_permissi
4545
### D2 — Secure-by-default object/field posture [new] (data-model posture, NOT a permission)
4646
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).
4747

48+
**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.
49+
50+
**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.
51+
4852
### D3 — Resource→capability requirement [existing concept, new placement]
49-
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']`.
53+
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']`.
5054

5155
### D4 — Dual-surface action gates [new]
5256
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
7680
3. **D1** — capability registry (string→record), back-compat seeded.
7781
4. **D3 (field) + D5** — field-level requirements + package secure-default seeding; delegated admin (#9).
7882

83+
## Precedence / combination semantics (②)
84+
85+
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):
86+
87+
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.
88+
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).
89+
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.
90+
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).
91+
7992
## Open-core boundary
8093
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.
8194

8295
## Consequences
8396
- **+** 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.
8497
- **** 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).
85-
- **** Combination edge cases (explicit deny vs union) need a defined precedence; specify deny-overrides only where a resource is `private`.
98+
- **** 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.
99+
100+
101+
## Future refinements (beyond the phased plan)
102+
103+
Captured for the record; **out of scope for Phases 1–4 above**. Each is anchored to a mainstream-platform precedent.
104+
105+
- **④ 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.
106+
- **⑤ 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.
107+
- **⑥ 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.
108+
- **⑦ 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).

packages/plugins/plugin-security/src/permission-evaluator.ts

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,30 @@ const OPERATION_TO_PERMISSION: Record<string, keyof ObjectPermission> = {
2626
*/
2727
const DESTRUCTIVE_OPERATIONS = new Set<string>(['transfer', 'restore', 'purge']);
2828

29+
/**
30+
* [ADR-0066 D2] Resolve the object permission a permission set contributes for
31+
* `objectName`, honouring the secure-by-default posture:
32+
*
33+
* - an EXPLICIT per-object grant (`ps.objects[objectName]`) always applies;
34+
* - the `'*'` wildcard applies to a `public` object (today's allow-by-default);
35+
* - for a `private` object the `'*'` wildcard applies ONLY when it carries the
36+
* super-user bypass bits (`viewAllRecords`/`modifyAllRecords` — the Salesforce
37+
* "View/Modify All Data" power). A plain `'*': {allowRead:true}` does NOT cover
38+
* a private object; access then requires an explicit per-object grant.
39+
*/
40+
function resolveObjectPermission(
41+
ps: PermissionSet,
42+
objectName: string,
43+
isPrivate: boolean,
44+
): ObjectPermission | undefined {
45+
const explicit = ps.objects?.[objectName];
46+
if (explicit) return explicit;
47+
const wild = ps.objects?.['*'];
48+
if (!wild) return undefined;
49+
if (!isPrivate) return wild;
50+
return wild.viewAllRecords || wild.modifyAllRecords ? wild : undefined;
51+
}
52+
2953
/**
3054
* PermissionEvaluator
3155
*
@@ -40,7 +64,9 @@ export class PermissionEvaluator {
4064
checkObjectPermission(
4165
operation: string,
4266
objectName: string,
43-
permissionSets: PermissionSet[]
67+
permissionSets: PermissionSet[],
68+
/** [ADR-0066 D2] When the object is `private`, the `'*'` wildcard only covers it if it is a super-user grant. */
69+
opts: { isPrivate?: boolean } = {},
4470
): boolean {
4571
const permKey = OPERATION_TO_PERMISSION[operation];
4672
if (!permKey) {
@@ -51,10 +77,10 @@ export class PermissionEvaluator {
5177
}
5278

5379
for (const ps of permissionSets) {
54-
// Honour the `'*'` wildcard sentinel — admin permission sets typically
55-
// grant blanket access via a single `objects: { '*': … }` entry rather
56-
// than enumerating every system object.
57-
const objPerm = ps.objects?.[objectName] ?? ps.objects?.['*'];
80+
// [ADR-0066 D2] Honour the `'*'` wildcard sentinel — admin permission
81+
// sets grant blanket access via a single `objects: { '*': … }` entry
82+
// but a `private` object is excluded from a non-super-user wildcard.
83+
const objPerm = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false);
5884
if (objPerm) {
5985
// Check if modifyAllRecords is set (super-user bypass for write ops)
6086
if (['allowEdit', 'allowDelete'].includes(permKey) && objPerm.modifyAllRecords) {
@@ -87,13 +113,14 @@ export class PermissionEvaluator {
87113
opClass: 'read' | 'write',
88114
objectName: string,
89115
permissionSets: PermissionSet[],
116+
opts: { isPrivate?: boolean } = {},
90117
): 'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' {
91118
const RANK = { own: 0, own_and_reports: 1, unit: 2, unit_and_below: 3, org: 4 } as const;
92119
const ORDER = ['own', 'own_and_reports', 'unit', 'unit_and_below', 'org'] as const;
93120
let widest = -1;
94121
let matched = false;
95122
for (const ps of permissionSets) {
96-
const op: any = ps.objects?.[objectName] ?? ps.objects?.['*'];
123+
const op: any = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false);
97124
if (!op) continue;
98125
matched = true;
99126
if (opClass === 'read' && (op.viewAllRecords || op.modifyAllRecords)) return 'org';
@@ -106,6 +133,51 @@ export class PermissionEvaluator {
106133
return ORDER[widest < 0 ? 0 : widest];
107134
}
108135

136+
/**
137+
* [ADR-0066 D3] Union of `systemPermissions` (capabilities) the caller holds
138+
* across the resolved permission sets — used to enforce a resource's
139+
* `requiredPermissions` AND-gate.
140+
*/
141+
getSystemPermissions(permissionSets: PermissionSet[]): Set<string> {
142+
const out = new Set<string>();
143+
for (const ps of permissionSets) {
144+
for (const cap of ps.systemPermissions ?? []) out.add(cap);
145+
}
146+
return out;
147+
}
148+
149+
/**
150+
* [ADR-0066 D2 / ①] Does any resolved set grant the super-user READ bypass
151+
* (`viewAllRecords`/`modifyAllRecords`, the "View All Data" power) for the
152+
* object? Honours the private posture (see {@link resolveObjectPermission}).
153+
* The security plugin uses this to skip wildcard RLS on private/platform-global
154+
* objects so a platform admin sees all rows.
155+
*/
156+
hasSuperuserReadBypass(
157+
objectName: string,
158+
permissionSets: PermissionSet[],
159+
opts: { isPrivate?: boolean } = {},
160+
): boolean {
161+
for (const ps of permissionSets) {
162+
const op = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false);
163+
if (op && (op.viewAllRecords || op.modifyAllRecords)) return true;
164+
}
165+
return false;
166+
}
167+
168+
/** [ADR-0066 D2 / ①] Super-user WRITE bypass (`modifyAllRecords`) for the object. */
169+
hasSuperuserWriteBypass(
170+
objectName: string,
171+
permissionSets: PermissionSet[],
172+
opts: { isPrivate?: boolean } = {},
173+
): boolean {
174+
for (const ps of permissionSets) {
175+
const op = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false);
176+
if (op && op.modifyAllRecords) return true;
177+
}
178+
return false;
179+
}
180+
109181
/**
110182
* Get the merged field permissions for an object.
111183
* Returns a map of field names to their effective permissions.

0 commit comments

Comments
 (0)