Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/rls-enabled-enforced-security-audit.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
49 changes: 49 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
23 changes: 15 additions & 8 deletions packages/spec/liveness/object.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand All @@ -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": {
Expand Down Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
Loading
Loading