From 151f5977b687aacb41699b9c5a1f7096b80e8875 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:50:24 +0000 Subject: [PATCH] fix(security)!: a disabled RLS policy no longer grants (#3896 follow-up security-subset audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RowLevelSecurityPolicySchema.enabled promises, verbatim: "Disabled policies are not evaluated." Nothing read it — not the collection site, not the projection round-trip, not the compiler. Because applicable policies OR-combine (any match allows access), a policy an admin switched off kept contributing its grant: disabling a too-permissive policy silently changed nothing. The #3896 shape one layer up — a documented security control whose real behaviour is wider than its contract. getApplicablePolicies now excludes enabled === false before any matching, at the single choke point both the find and analytics paths flow through — the same place, and the same ADR-0049 enforce-or-remove resolution, as the formerly-unenforced positions domain. Exact === false on purpose: the schema defaults enabled to true and projection rows may omit the key, so absent stays active. Four tests pin both directions. Access-narrowing only; nothing in-repo authors enabled:false. Found by re-verifying the liveness ledger's security subset: all 44 entries (permission 33, position 4, object sharing/access 7) call-graph-closed by hand and stamped verifiedAt — the subset's first-ever re-verification. Other corrections: - rowLevelSecurity.priority → dead + authorWarn: under OR-combination the promised "conflict resolution" semantics cannot exist. REMOVE candidate (#3715/#3950 precedent) while the v17 breaking window is open. - rowLevelSecurity.label/description/tags → dead (benign display, no consumer in either repo, not authorWarn'd). - tabPermissions was UNDERSTATED ("only 'hidden' is read" → the rank merge reads all four values; the me-apps dogfood test exercises it). Evidence upgraded; proof-binding candidate. - allowExport re-verified TRUE against the projection-only suspicion: the export route carries its own caller-level 403 gate, fail-closed. - allowTransfer/Restore/Purge notes re-confirmed (M2 still unshipped). - object.ownership evidence line had rotted — refreshed; 6 more object security entries re-cited and stamped. plugin-security: 32 files / 677 tests green. tsc clean, eslint clean, check:liveness green (48 dated entries, all paths resolve). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- .../rls-enabled-enforced-security-audit.md | 49 ++++++++ .../plugin-security/src/rls-compiler.ts | 11 ++ .../src/security-plugin.test.ts | 49 ++++++++ packages/spec/liveness/README.md | 4 +- packages/spec/liveness/object.json | 23 ++-- packages/spec/liveness/permission.json | 107 ++++++++++++------ packages/spec/liveness/position.json | 10 +- 7 files changed, 206 insertions(+), 47 deletions(-) create mode 100644 .changeset/rls-enabled-enforced-security-audit.md diff --git a/.changeset/rls-enabled-enforced-security-audit.md b/.changeset/rls-enabled-enforced-security-audit.md new file mode 100644 index 0000000000..4fbca87101 --- /dev/null +++ b/.changeset/rls-enabled-enforced-security-audit.md @@ -0,0 +1,49 @@ +--- +"@objectstack/plugin-security": patch +"@objectstack/spec": patch +--- + +fix(security)!: a disabled RLS policy no longer grants — found by re-verifying the ledger's security subset (#3896 follow-up) + +**The fix.** `RowLevelSecurityPolicySchema.enabled` promises, verbatim: *"Disabled +policies are not evaluated."* Nothing read it — not the collection site, not the +projection round-trip, not the compiler. Because applicable policies OR-combine +(any match allows access), a policy an admin switched off **kept contributing its +grant**: disabling a too-permissive policy silently changed nothing. That is the +#3896 shape — a documented security control whose real behaviour is wider than +its contract — one layer up, on RLS instead of sharing rules. + +`getApplicablePolicies` now excludes `enabled === false` before any matching, at +the single choke point both the find path and the analytics path flow through — +the same place, and the same ADR-0049 enforce-or-remove resolution, as the +formerly-unenforced `positions` domain. Exact `=== false` on purpose: the schema +defaults `enabled` to true and projection rows may omit the key, so absent stays +active. Four tests pin both directions. Access-narrowing only: no policy grants +MORE after this change, and nothing in-repo authors `enabled: false`. + +**The audit that found it.** All 44 entries of the liveness ledger's security +subset (`permission` 33, `position` 4, `object` sharing/access 7) were +call-graph-closed by hand and stamped `verifiedAt: 2026-07-30` — the subset's +first-ever re-verification (previously 4 dated entries repo-wide, and the last +sweep that cited preview renderers went 10-for-13 wrong). Beyond `enabled`: + +- `rowLevelSecurity.priority` → **dead + authorWarn**. Not merely unimplemented: + policies OR-combine (the schema's own describe says most-permissive-wins), so + the promised "conflict resolution" semantics cannot exist. A REMOVE candidate + per the #3715/#3950 precedent while the v17 breaking window is open. +- `rowLevelSecurity.label` / `description` / `tags` → dead (benign display — + no consumer in either repo; deliberately not authorWarn'd). +- `tabPermissions` was UNDERSTATED: the note said only `'hidden'` is read, but + hono's rank merge reads all four visibility values across resolved sets, and + the `me-apps-and-everyone-baseline` dogfood test exercises it. Evidence + upgraded; noted as a proof-binding candidate. +- `allowExport` re-verified TRUE against the suspicion that it was + projection-only: the export route carries its own caller-level 403 gate + (`enforceExportPermission`), fail-closed when the security service cannot + answer, separate from the object-level 405. +- `allowTransfer/Restore/Purge` notes re-confirmed accurate (M2 operations still + unshipped; the RBAC gates are pre-mapped fail-closed). +- `object.ownership` evidence had rotted (line drift) — refreshed; six other + object-level security entries re-cited and stamped. + +No other runtime behaviour changes. diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index 4f17989c65..a4d821836b 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -285,6 +285,17 @@ export class RLSCompiler { const rlsOp = this.mapOperationToRLS(operation); return allPolicies.filter(policy => { + // A policy switched off is not evaluated — the schema's exact promise + // ("Disabled policies are not evaluated", RowLevelSecurityPolicySchema). + // Formerly unread ANYWHERE: because applicable policies OR-combine (any + // match allows access), a disabled policy kept contributing its grant — + // an admin who switched off a too-permissive policy kept sharing the + // rows. Same enforce-or-remove shape as `positions` below (ADR-0049). + // Exact `=== false` on purpose: the schema defaults `enabled` to true, + // and rows round-tripped through sys_permission_set may omit the key — + // absent means active, only an explicit false disables. + if ((policy as { enabled?: boolean }).enabled === false) return false; + // Check object match if (policy.object !== objectName && policy.object !== '*') return false; diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 90b4105b2d..68f42adbff 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -2760,6 +2760,55 @@ describe('RLSCompiler', () => { expect(contactFind).toHaveLength(2); // contact all + * all }); + // `enabled` — "Disabled policies are not evaluated" (the schema's promise). + // Found unread by the 2026-07 security-subset liveness re-verification: + // applicable policies OR-combine (any match ALLOWS access), so a disabled + // policy kept granting the rows an admin believed they had switched off. + // These pin the gate in both directions. + it('excludes a policy explicitly disabled with enabled:false', () => { + const compiler = new RLSCompiler(); + const policies: any[] = [ + { object: 'task', operation: 'select', using: 'owner_id = current_user.id' }, + { object: 'task', operation: 'select', using: "visibility = 'public'", enabled: false }, + ]; + const applicable = compiler.getApplicablePolicies('task', 'find', policies); + expect(applicable).toHaveLength(1); + expect(applicable[0]!.using).toBe('owner_id = current_user.id'); + }); + + it('treats enabled:true and an ABSENT enabled key as active (schema default)', () => { + // Rows round-tripped through sys_permission_set may omit the key entirely — + // absent must mean active, or every legacy row would silently stop enforcing. + const compiler = new RLSCompiler(); + const policies: any[] = [ + { object: 'task', operation: 'select', using: 'owner_id = current_user.id', enabled: true }, + { object: 'task', operation: 'select', using: "status = 'open'" }, + ]; + expect(compiler.getApplicablePolicies('task', 'find', policies)).toHaveLength(2); + }); + + it('a disabled policy no longer contributes its OR-branch to the compiled filter', () => { + // The over-share this closes: owner-only policy + a disabled public-read + // policy must compile to owner-only, not owner-OR-public. + const compiler = new RLSCompiler(); + const policies: any[] = [ + { object: 'task', operation: 'select', using: 'owner_id = current_user.id' }, + { object: 'task', operation: 'select', using: "visibility = 'public'", enabled: false }, + ]; + const ctx: any = { userId: 'user-42', positions: [] }; + const applicable = compiler.getApplicablePolicies('task', 'find', policies); + const filter = compiler.compileFilter(applicable, ctx); + expect(filter).toEqual({ owner_id: 'user-42' }); + }); + + it('disabling the ONLY policy leaves no applicable policies (caller decides posture, not a silent grant)', () => { + const compiler = new RLSCompiler(); + const policies: any[] = [ + { object: 'task', operation: 'select', using: "visibility = 'public'", enabled: false }, + ]; + expect(compiler.getApplicablePolicies('task', 'find', policies)).toHaveLength(0); + }); + // §7.3.1 dynamic membership — arbitrary pre-resolved sets in rlsMembership it('should resolve IN against a §7.3.1 pre-resolved rlsMembership set', () => { // Manager hierarchy: the runtime resolved the manager's reports into diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index b88a4d7d59..50d7987985 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -467,8 +467,8 @@ EOF | flow | 26 | – | 5 | – | dead = description/template/nodes.outputSchema/errorHandling.fallbackNodeId (engine uses fault edges) + `active` CORRECTED to dead 2026-07 (deprecated no-op — `status` is what gates binding/execution since 497bda853; the file `_note` claiming otherwise is fixed) | | action | 34 | 0 | 2 | – | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live for real since objectui#2863 (six surfaces); `shortcut` + `bulkEnabled` CORRECTED to dead 2026-07 — registered into ActionEngine but their accessors have no non-test caller (#3686 sweep); `undoable` CORRECTED to live 2026-07 — understated, two objectui readers gate the toast's Undo and the record restore (#3714) | | hook | 11 | – | 2 | – | model-healthy; only label/description dead (benign) | -| permission | 32 | – | 0 | – | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets) | -| position | 4 | – | – | – | (role's ADR-0090 successor) fully live | +| permission | 29 | – | 4 | – | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection) | +| position | 4 | – | – | – | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | | agent | 13 | 5 | 1 | – | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377) — only `planning.maxIterations` live; autonomy tier experimental; `knowledge` CORRECTED to dead 2026-07 — `search_knowledge` takes `sourceIds` from the LLM's tool-call args, never from the agent record (#1878 §3 recheck) | | tool | 5 | 1 | 4 | – | the whole authoring surface is inert: `permissions` (not permission-gated), plus `category`/`active`/`builtIn` CORRECTED to dead 2026-07 (#3686 sweep). `requiresConfirmation` REMOVED (#3715, ADR-0033 §2) — SAFETY-shaped and unenforced on every path, so it was false compliance, not merely dead; ToolSchema is now `.strict()` so the retired key REJECTS with the FROM → TO prescription instead of being silently stripped | | skill | 8 | – | 1 | – | `permissions` REMOVED 2026-07 (never gated anything — owner call was prune, #3704); `triggerPhrases` CORRECTED to dead — phrases are never matched against user messages; activation is `triggerConditions` + the agent's `skills[]` + explicit /skill-name pinning (#3686 sweep) | diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index e232160289..23ebd20925 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -102,8 +102,9 @@ }, "ownership": { "status": "live", - "evidence": "packages/objectql/src/registry.ts:272", - "note": "#3175 record-ownership model. applySystemFields injects the reassignable owner_id lookup by default (ownership:'user'); 'org'|'none' opt out (Dataverse-style catalog/junction tables). Proven in objectql/src/registry.test.ts." + "verifiedAt": "2026-07-30", + "evidence": "packages/objectql/src/registry.ts:292 (applySystemFields reads schema.ownership)", + "note": "#3175 record-ownership model. applySystemFields injects the reassignable owner_id lookup by default (ownership:'user'); 'org'|'none' opt out (Dataverse-style catalog/junction tables). Proven in objectql/src/registry.test.ts. Evidence line refreshed 2026-07-30 (was :272, drifted)." }, "access": { "status": "live", @@ -112,7 +113,8 @@ }, "requiredPermissions": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/security-plugin.ts", + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/security-plugin.ts:132 (NormalizedRequiredPermissions — per-CRUD buckets) + packages/plugins/plugin-security/src/permission-evaluator.ts (crudBucketForOperation keeps the buckets in lockstep with OPERATION_TO_PERMISSION)", "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": { @@ -121,18 +123,21 @@ }, "systemFields": { "status": "live", - "evidence": "packages/objectql/src/registry.ts", + "verifiedAt": "2026-07-30", + "evidence": "packages/objectql/src/registry.ts (column injection) + packages/plugins/plugin-security/src/security-plugin.ts:3281 (systemFields.tenant === false read as the tenancy opt-out)", "note": "organization_id auto-inject gate." }, "sharingModel": { "status": "live", - "evidence": "packages/plugins/plugin-sharing/src/sharing-service.ts:54", + "verifiedAt": "2026-07-30", "proof": "packages/qa/dogfood/test/controlled-by-parent.dogfood.test.ts#cbp-controlled-by-parent", + "evidence": "packages/plugins/plugin-sharing/src/sharing-service.ts:54", "note": "ADR-0055 high-risk class (sharing): the `controlled_by_parent` value derives a detail object's access from its master — the security layer injects `masterFK IN (accessible master ids)` on reads and requires master edit-access on by-id writes. The proof asserts a member who cannot read the master can neither read nor by-id-write the detail, and is not over-blocked on a master they own." }, "publicSharing": { "status": "live", - "evidence": "packages/plugins/plugin-sharing/src/share-link-service.ts:56" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-sharing/src/share-link-service.ts:48 (policy extraction) — share-link creation on an object without publicSharing.enabled=true is rejected 422 (share-link-service.ts:170)" }, "tenancy": { "children": { @@ -191,7 +196,8 @@ }, "isSystem": { "status": "live", - "evidence": "packages/plugins/plugin-sharing/src/sharing-service.ts:74", + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-sharing/src/sharing-service.ts:75", "note": "effectiveSharingModel: an object with no sharingModel and isSystem===true defaults to 'public' (else 'private') — fail-closed org-wide-default posture; ORed with the sys_ name-prefix fallback. Also read by lint/src/validate-security-posture.ts:98 (isSystemObject exempts system objects from the master-detail CRUD-grant lint) and mirrored in metadata-protocol/src/protocol.ts:106. The 2026-06 audit mis-classified as dead (sharing/lint readers not re-verified)." }, "searchableFields": { @@ -200,8 +206,9 @@ }, "externalSharingModel": { "status": "planned", + "verifiedAt": "2026-07-30", "authorWarn": true, - "note": "[ADR-0090 D11] P1 lands the SPEC SHAPE only (validated external<=internal at authoring; Studio surfaces the Ext badge). Runtime consumption (audience-aware evaluator branch substituting the external dial for external principals) is scheduled with the principal-taxonomy semantics phase; tracked on #2696. `planned` + authorWarn per enforce-or-mark: authors get told the dial does not evaluate yet. Was a bespoke 'authorable' status outside the documented vocabulary." + "note": "[ADR-0090 D11] P1 lands the SPEC SHAPE only (validated external<=internal at authoring; Studio surfaces the Ext badge). Runtime consumption (audience-aware evaluator branch substituting the external dial for external principals) is scheduled with the principal-taxonomy semantics phase; tracked on #2696. `planned` + authorWarn per enforce-or-mark: authors get told the dial does not evaluate yet. Was a bespoke 'authorable' status outside the documented vocabulary. Re-verified 2026-07-30: still no runtime consumer (authoring-time external<=internal validation in lint/src/validate-security-posture.ts:175 only, plus the ADR-0090 D4 retired-alias value check); nothing external-principal-shaped evaluates it, matching planned." }, "fileAccessDelegate": { "status": "live", diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 0dd64eee80..0ecb5e39cb 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -1,33 +1,40 @@ { "type": "permission", - "_note": "PermissionSetSchema (the `profile` alias and isProfile flag were removed by ADR-0090 D2). Seeded from docs/audits/2026-06-security-identity-property-liveness.md. CRUD/FLS/RLS enforced via plugin-security. allowTransfer/Restore/Purge are RBAC-mapped in the evaluator (#1883) — deny unless granted; the ObjectQL operations themselves are pending M2.", + "_note": "PermissionSetSchema (the `profile` alias and isProfile flag were removed by ADR-0090 D2). Seeded from docs/audits/2026-06-security-identity-property-liveness.md. CRUD/FLS/RLS enforced via plugin-security. allowTransfer/Restore/Purge are RBAC-mapped in the evaluator (#1883) — deny unless granted; the ObjectQL operations themselves are pending M2. 2026-07-30 security-subset re-verification (#3896 follow-up): every entry call-graph-closed by hand; TWO corrections — rowLevelSecurity.enabled was live-with-wrong-evidence and UNREAD anywhere (a disabled policy kept granting; enforced in rls-compiler the same day), rowLevelSecurity.priority is semantically void under OR-combination → dead+authorWarn. tabPermissions was UNDERSTATED (all four rank values read, not just 'hidden').", "props": { "name": { "status": "live", + "verifiedAt": "2026-07-30", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", "note": "registration/assignment key." }, "label": { "status": "live", + "verifiedAt": "2026-07-30", + "evidence": "rendered as the record title of sys_permission_set in Setup's generic object views (objectui app-shell list/detail rendering via displayNameField)", "note": "display metadata." }, "packageId": { "status": "live", + "verifiedAt": "2026-07-30", "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (author-declared fallback for the registry `_packageId` provenance; persisted to sys_permission_set.package_id)", "note": "ADR-0086 D3 — owning package for a package-shipped set; proven by packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts." }, "managedBy": { "status": "live", + "verifiedAt": "2026-07-30", "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (persisted per record as sys_permission_set.managed_by; 'package' rows re-seeded, platform/user rows never clobbered)", "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user)." }, "adminScope": { "status": "live", + "verifiedAt": "2026-07-30", "evidence": "packages/plugins/plugin-security/src/delegated-admin-gate.ts — non-tenant-admin writes to sys_user_position / sys_position_permission_set / sys_user_permission_set / sys_permission_set require a covering adminScope (BU-subtree boundary + assignable-set allowlist + strict-containment for scope grants).", "note": "ADR-0090 D12 — delegated administration; proven by packages/plugins/plugin-security/src/delegated-admin-gate.test.ts." }, "isDefault": { "status": "live", + "verifiedAt": "2026-07-30", "evidence": "plugin-security: packages/plugins/plugin-security/src/security-plugin.ts (constructor) — when no explicit fallbackPermissionSet is configured, the SecurityPlugin resolves the fallback from the permission set marked isDefault:true (ADR-0056 D7).", "note": "LIVE — app-declared default profile; proven by packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts." }, @@ -35,59 +42,71 @@ "children": { "allowCreate": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION insert→allowCreate)" }, "allowRead": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION find/findOne/count/aggregate→allowRead)" }, "allowEdit": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION update→allowEdit)" }, "allowDelete": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION delete→allowDelete)" }, "allowExport": { "status": "live", - "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts (annotateEffectiveApiOperations: userExportAllowed = allowExport !== false) + packages/spec/src/data/api-derivation.ts (export derives from list ∧ userExportAllowed)", - "note": "#3544 — user-level export axis over read. Enforced end-to-end: the bit drives userExportAllowed, which removes `export` from the object's effective apiOperations on /me/permissions; the frontend hides Export and the REST export gate 405s. Optional/no-default = backward-compatible opt-out (unset inherits read); `false` denies export while keeping read." + "verifiedAt": "2026-07-30", + "evidence": "packages/rest/src/rest-server.ts (enforceExportPermission — caller-level 403 gate on the bulk-egress route, fail-closed when the security service cannot answer) + packages/plugins/plugin-security/src/security-plugin.ts (canExport → checkObjectPermission('export'), posture-unresolvable → deny) + packages/plugins/plugin-hono-server/src/hono-plugin.ts (annotateEffectiveApiOperations, the /me/permissions projection the frontend renders)", + "note": "#3544 — user-level export axis over read. Re-verified 2026-07-30: enforcement is SERVER-side, not only the projection — the export route calls enforceExportPermission (403), separate from the object-level 405; the annotate path is the display half. Optional/no-default = backward-compatible opt-out (unset inherits read); `false` denies export while keeping read." }, "allowTransfer": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (OPERATION_TO_PERMISSION transfer→allowTransfer + modifyAllRecords bypass)", - "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `transfer` ObjectQL operation is pending M2, so granting delivers nothing until it ships." + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION transfer→allowTransfer; DESTRUCTIVE_OPERATIONS fail-closed backstop) + modifyAllRecords bypass via MODIFY_ALL_WRITE_KEYS", + "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `transfer` ObjectQL operation is pending M2, so granting delivers nothing until it ships. Re-verified 2026-07-30: M2 still unshipped (no transfer/restore/purge operations in packages/objectql), the gate mapping stands." }, "allowRestore": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (OPERATION_TO_PERMISSION restore→allowRestore + modifyAllRecords bypass)", - "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `restore` ObjectQL operation is pending M2." + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION restore→allowRestore; DESTRUCTIVE_OPERATIONS fail-closed backstop)", + "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `restore` ObjectQL operation is pending M2. Re-verified 2026-07-30: M2 still unshipped." }, "allowPurge": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (OPERATION_TO_PERMISSION purge→allowPurge + modifyAllRecords bypass)", - "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `purge` ObjectQL operation is pending M2." + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION purge→allowPurge; DESTRUCTIVE_OPERATIONS fail-closed backstop)", + "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `purge` ObjectQL operation is pending M2. Re-verified 2026-07-30: M2 still unshipped." }, "viewAllRecords": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:64" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (read bypass at the allowRead check; getEffectiveScope read→'org'; hasViewAllData)" }, "modifyAllRecords": { "status": "live", + "verifiedAt": "2026-07-30", "proof": "packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts#owner-anchor-and-bulk-writes", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:60" + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (MODIFY_ALL_WRITE_KEYS bypass over edit/delete + destructive class; getEffectiveScope write→'org')" }, "readScope": { "status": "live", + "verifiedAt": "2026-07-30", "proof": "packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts#showcase-scope-depth", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (getEffectiveScope) + packages/plugins/plugin-sharing/src/sharing-service.ts (owner-match widened by readScope; hierarchy values delegated to IHierarchyScopeResolver)", "note": "ADR-0057 D1 — read access DEPTH (own/own_and_reports/unit/unit_and_below/org). own/org enforced in open edition; hierarchy values via the enterprise hierarchy-scope-resolver (fail-closed to owner-only when absent; defineStack requires 'hierarchy-security'). Proven: packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts." }, "writeScope": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (getEffectiveScope) + packages/plugins/plugin-sharing/src/sharing-service.ts (canEdit owner-set widened by writeScope; hierarchy values delegated)", - "note": "ADR-0057 D1 — write access DEPTH (same enum as readScope)." + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:224 (getEffectiveScope write branch) + packages/plugins/plugin-sharing/src/sharing-service.ts:230 (canEdit owner-set widened by __writeScope; 'org' short-circuits to unrestricted)", + "note": "ADR-0057 D1 — write access DEPTH (same enum as readScope). Re-verified 2026-07-30: both cited reads confirmed. Sibling of proofed readScope with no dogfood proof of its own — a proof-binding candidate (ADR-0054)." } } }, @@ -95,75 +114,93 @@ "children": { "readable": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:301", "note": "FLS read-mask." }, "editable": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:302", "note": "FLS write-deny." } } }, "systemPermissions": { "status": "live", - "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts", + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts:1222", "note": "PARTIAL — app-entry/nav visibility only, not a general capability gate." }, "tabPermissions": { "status": "live", - "note": "PARTIAL — only 'hidden' is read." + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts:1200 (tabRank merges all four values — hidden/default_off/default_on/visible — across resolved sets, most-visible wins; app hidden ⇒ dropped from /me/apps) + packages/runtime/src/security/resolve-execution-context.ts:205 + packages/rest/src/rest-server.ts:1551 (projection)", + "note": "Re-verified 2026-07-30 — the previous note ('only hidden is read') UNDERSTATED it: the rank merge reads every value. Exercised by packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts (registered in proof-registry.mts, unbound there because the binding was recorded against the ungoverned `app` type — this governed entry could carry it instead)." }, "rowLevelSecurity": { "children": { "name": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts:203 (skip-diagnostics name the operator sees)" }, "label": { - "status": "live", - "note": "display." + "status": "dead", + "verifiedAt": "2026-07-30", + "note": "CORRECTED 2026-07-30 (was live with no evidence): no consumer in either repo — objectui's PermissionAdvancedFacets edits policies and PermissionPreview counts them, and an authoring surface echoing input is not a runtime consumer (see README §preview-renderer). Benign display metadata — deliberately NOT authorWarn'd." }, "description": { - "status": "live", - "note": "display." + "status": "dead", + "verifiedAt": "2026-07-30", + "note": "CORRECTED 2026-07-30 (was live with no evidence): same closure as label. Benign — not authorWarn'd." }, "object": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts:289 (applicability: exact object or '*')" }, "operation": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts:304 (applicability: 'all' or the mapped RLS op)" }, "using": { "status": "live", + "verifiedAt": "2026-07-30", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "proof": "packages/qa/dogfood/test/rls-fixture.dogfood.test.ts#rls-by-id-write", "note": "compiled into find + analytics SQL. ADR-0054 high-risk class (RLS): the proof boots an owner-isolated fixture so a fresh member cannot read an admin-created row, then asserts the runner's verdict in both directions — `rls-consistent` when the owner predicate also gates the by-id write (#1994 pre-image check) and `rls-hole` when it doesn't. Guards read AND by-id-write enforcement, not just the read predicate." }, "check": { "status": "live", + "verifiedAt": "2026-07-30", "proof": "packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts#showcase-d3-d4-capabilities", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, "positions": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts:297 (applicability domain vs caller's held positions)", "note": "flat match — no subordinate rollup (ADR-0090 D3 rename)." }, "enabled": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" + "verifiedAt": "2026-07-30", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts (getApplicablePolicies: `enabled === false` excluded before object/positions/operation matching; absent = active per the schema default) — pinned by the enabled-gate cases in packages/plugins/plugin-security/src/security-plugin.test.ts", + "note": "ENFORCED 2026-07-30, by the security-subset re-verification this stamp records. Until then the entry claimed live citing rls-compiler.ts, and NOTHING read the property: policies OR-combine (any match allows access), so a policy an admin disabled kept CONTRIBUTING ITS GRANT — the schema's own describe ('Disabled policies are not evaluated') was false in the over-share direction, the #3896 shape. Same enforce-or-remove resolution as `positions` (ADR-0049)." }, "priority": { - "status": "live", - "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", - "note": "ordering." + "status": "dead", + "verifiedAt": "2026-07-30", + "authorWarn": true, + "authorHint": "priority has no effect and CANNOT have one: applicable policies OR-combine (most permissive wins, per the schema's own describe), so there is no conflict to order and no evaluation-order effect on the outcome. Remove it from the policy.", + "note": "CORRECTED 2026-07-30 (was live citing rls-compiler.ts, which never reads it — confirmed across the load site, the projection round-trip and the compiler). Not merely unimplemented: under OR-combination the promised 'conflict resolution' semantics cannot exist, so this is a REMOVE candidate (#3715/#3950 precedent, v17 window) rather than an implement candidate." }, "tags": { - "status": "live", - "note": "organizational." + "status": "dead", + "verifiedAt": "2026-07-30", + "note": "CORRECTED 2026-07-30 (was live with no evidence): no reader in either repo — governance/compliance reporting never consumes policy tags. Benign organizational metadata — not authorWarn'd." } } } diff --git a/packages/spec/liveness/position.json b/packages/spec/liveness/position.json index ed3757928f..20c60adfd6 100644 --- a/packages/spec/liveness/position.json +++ b/packages/spec/liveness/position.json @@ -1,22 +1,28 @@ { "type": "position", - "_note": "PositionSchema (ADR-0090 D3; formerly RoleSchema). Flat capability-distribution group.", + "_note": "PositionSchema (ADR-0090 D3; formerly RoleSchema). Flat capability-distribution group. 2026-07-30 security-subset re-verification: all four entries call-graph-closed, no corrections.", "props": { "name": { "status": "live", - "evidence": "packages/core/src/security/resolve-authz-context.ts:210", + "verifiedAt": "2026-07-30", + "evidence": "packages/core/src/security/resolve-authz-context.ts (resolveUserAuthzGrants — sys_user_position rows resolve by position name into ctx.positions, which keys position-bound permission-set resolution and the RLS positions domain)", "note": "sys_position.name keys position-bound permission-set resolution and ctx.positions." }, "label": { "status": "live", + "verifiedAt": "2026-07-30", + "evidence": "rendered as the record title of sys_position in Setup's generic object views (objectui app-shell list/detail rendering)", "note": "display (admin nav/forms, security-plugin.ts)." }, "description": { "status": "live", + "verifiedAt": "2026-07-30", + "evidence": "rendered on the sys_position record detail in Setup's generic object views", "note": "display." }, "delegatable": { "status": "live", + "verifiedAt": "2026-07-30", "evidence": "packages/plugins/plugin-security/src/delegated-admin-gate.ts:positionIsDelegatable", "proof": "packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts#delegation-of-duty", "note": "ADR-0091 D3: gates the self-service delegation branch — a non-admin may delegate this position only when delegatable=true, time-boxed and reasoned (enforced fail-closed in the D12 gate). ADR-0054 high-risk class (delegation): the proof drives a gated delegation write over HTTP and asserts the delegate's grant resolves in-window and dies at valid_until."