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
7 changes: 7 additions & 0 deletions .changeset/rls-empty-membership-polarity-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/plugin-security': patch
---

Security fix (fail-closed tightening, #13552): the RLS emptied-membership deny guard is now polarity-aware. A policy whose pre-resolved membership set resolves EMPTY under a negated membership test (`not in` — e.g. `using: '!(owner in current_user.org_user_ids)'`) now compiles to the deny sentinel (zero rows) instead of flowing through. Before this fix `$in: []` under `$not` inverted to a constant-TRUE clause (`NOT (1 = 0)` on the SQL read-scope lowering), so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads. The guard now fires at any composition depth: `$not` wrapping the membership directly, `$not` arms nested inside `$or`/`$and`, `$not` over a composite containing the membership, and multi-level `$not` (odd polarity anywhere; the bare positive case is unchanged).

Blast radius, in plain terms: callers that were relying on that allow-all stop seeing rows. If a negated-membership policy was the only applicable policy and its membership set resolves empty (no active organization; an empty team/territory/blocked set), reads that previously returned EVERY row now return ZERO rows. The prior behaviour was a defect — an over-permissive read on a row-level-security scope — not a contract. If own-rows access must survive an emptied membership set, author it as a separate OR'd policy (e.g. `owner == current_user.id`): each policy's grant is compiled independently, and a sibling policy dropping does not take it down. A deliberate allow-all remains authorable as a literal `true` predicate. Unchanged: a NON-empty membership set under `not in` compiles and enforces exactly as before, and an emptied POSITIVE membership nested in `$or` (e.g. `owner in current_user.team_ids || owner == current_user.id`) still preserves the other arm's grant.
120 changes: 106 additions & 14 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,19 +66,105 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
});

/**
* Does this filter consist solely of an empty membership (`{ field: { $in: [] } }`)?
* Used to preserve the legacy "empty pre-resolved set drops the policy" semantics
* so the single-policy path fails closed via the deny sentinel rather than an
* always-false `$in: []`.
* Is this field constraint an emptied membership, and what CONSTANT does it
* evaluate to? `{ $in: [] }` is constant FALSE on every backend ("IN ()
* matches nothing"); `{ $nin: [] }` is constant TRUE ("NOT IN () excludes
* nothing"). Returns that constant, or `null` when the spec is not an emptied
* membership. The CEL pushdown compiler only ever emits `$in` (negation wraps
* in `$not` — cel-to-filter.ts), but this guard's contract is over the
* FilterCondition shape, so the intrinsically-negated `$nin` spelling is
* recognised too rather than left to fail open should a future lowering emit it.
*/
function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
const keys = Object.keys(filter);
if (keys.length !== 1) return false;
const inner = filter[keys[0]];
if (!inner || typeof inner !== 'object') return false;
const innerKeys = Object.keys(inner as Record<string, unknown>);
return innerKeys.length === 1 && Array.isArray((inner as Record<string, unknown>).$in)
&& ((inner as Record<string, unknown>).$in as unknown[]).length === 0;
function emptyMembershipConstantTruth(spec: unknown): boolean | null {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
const rec = spec as Record<string, unknown>;
if (Object.keys(rec).length !== 1) return null;
if (Array.isArray(rec.$in) && (rec.$in as unknown[]).length === 0) return false;
if (Array.isArray(rec.$nin) && (rec.$nin as unknown[]).length === 0) return true;
return null;
}

/**
* Does this compiled filter LEAN ON an emptied membership in a way that must
* drop the policy (→ the deny sentinel upstream)? Preserves the legacy "empty
* pre-resolved set drops the policy" semantics so the single-policy path fails
* closed via the deny sentinel rather than an always-false `$in: []`.
*
* [#13552] POLARITY-AWARE. "An emptied membership is safe because `$in: []`
* matches nothing" is a polarity-DEPENDENT claim, and the pushdown subset
* contains negation (`not in` is first-class: `!(x in y)` → `$not` wrapping
* `$in`). The pre-#13552 guard shape-matched the bare positive form only, so
* `{ $not: { f: { $in: [] } } }` — constant TRUE for every row — flowed
* through and compiled to ALLOW-ALL on the read scope. Two rules now hold:
*
* 1. An emptied membership whose EFFECTIVE polarity is inverted (odd number of
* enclosing `$not`s for `$in: []`; zero/even for `$nin: []`) is a
* constant-TRUE clause. Anywhere in the tree — wrapping directly, as an arm
* of `$or`/`$and` (nested to any depth), inside a multi-key implicit AND,
* or under multi-level `$not` — it means the membership restriction the
* author wrote has evaporated: as an `$or` arm the whole filter is
* allow-all; as an `$and` arm the restriction silently vanishes. Either way
* the policy is degenerate → drop it (fail closed), exactly as the emptied
* POSITIVE single-policy case already does.
* 2. The legacy positive case, generalised through double negation: a filter
* that consists solely of an emptied `$in` membership under an even
* (incl. zero) number of `$not` wrappers is constant FALSE as a whole —
* prefer the deny sentinel over an always-false filter (same zero rows,
* one recognisable shape).
*
* What deliberately does NOT fire, in both cases matching pre-#13552
* behaviour: a NON-empty membership under `$not` (the working `not in`
* feature), and an emptied POSITIVE membership nested in a composite — as an
* `$or` arm it is inert (`owner in <empty> || owner == me` must keep granting
* own rows), as an `$and` arm the filter is already constant FALSE (denies by
* itself). A deliberate allow-all stays authorable as literal `true` (compiles
* to `{}`), which never involves a membership set.
*/
export function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
// (Exported for direct shape tests — rls-empty-membership-polarity.test.ts;
// not part of the package surface: index.ts deliberately does not re-export it.)
return containsTautologicalEmptyMembership(filter, false) || isSolelyEmptyMembership(filter);
}

/** Rule 1 above: an emptied membership that is constant TRUE in effective polarity. */
function containsTautologicalEmptyMembership(node: unknown, negated: boolean): boolean {
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
const rec = node as Record<string, unknown>;
// A BARE operator object (an emptied membership with no field key) is not a
// shape the CEL lowering emits, but the evaluator answers it fail-closed
// (constant FALSE — unknown top-level operator), which a wrapping `$not`
// inverts to constant TRUE. The pre-#13552 guard already fired on
// `{ $not: { $in: [] } }`; never be weaker than the predecessor on any shape.
if (emptyMembershipConstantTruth(rec) !== null) return negated;
for (const [key, value] of Object.entries(rec)) {
if (key === '$not') {
if (containsTautologicalEmptyMembership(value, !negated)) return true;
} else if (key === '$and' || key === '$or') {
if (Array.isArray(value) && value.some((arm) => containsTautologicalEmptyMembership(arm, negated))) return true;
} else if (!key.startsWith('$')) {
const truth = emptyMembershipConstantTruth(value);
if (truth !== null && (negated ? !truth : truth)) return true;
}
}
return false;
}

/** Rule 2 above: solely an emptied `$in` membership under even (incl. zero) `$not`s. */
function isSolelyEmptyMembership(filter: Record<string, unknown>): boolean {
let node: Record<string, unknown> = filter;
let negations = 0;
for (;;) {
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0] !== '$not') break;
const inner = node.$not;
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return false;
node = inner as Record<string, unknown>;
negations++;
}
if (negations % 2 !== 0) return false; // odd polarity → rule 1's walk owns it
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0].startsWith('$')) return false;
return emptyMembershipConstantTruth(node[keys[0]]) === false;
}

/**
Expand DownExpand Up@@ -228,7 +314,10 @@ export class RLSCompiler {
* - an unresolved/absent `current_user.*` variable → `null` → fail closed
* (the "no active organization" path);
* - an empty pre-resolved membership set → `null` so the single-policy case
* yields the deny sentinel upstream rather than a permissive `$in: []`.
* yields the deny sentinel upstream rather than a permissive `$in: []` —
* in EITHER polarity ([#13552]): under a supported `not in` the emptied
* set would otherwise compile to a constant-TRUE `$not`/`$in: []` clause,
* i.e. allow-all, the exact fail-open this guard exists to prevent.
*/
compileExpression(
expression: string,
Expand All@@ -255,7 +344,10 @@ export class RLSCompiler {
// Parity: an empty pre-resolved membership (`field in current_user.<empty>`)
// compiles to `{ field: { $in: [] } }`. The legacy compiler dropped the
// policy in this case; preserve that so the deny sentinel (not a literal
// empty-IN) is what the single-policy path returns.
// empty-IN) is what the single-policy path returns. [#13552] The guard is
// polarity-aware: the same emptied set under a supported `not in`
// (`$not` wrapping, at any composition depth) is dropped too — otherwise
// it inverts to a constant-TRUE clause and the policy compiles ALLOW-ALL.
if (isEmptyMembershipFilter(result.filter as Record<string, unknown>)) return null;
return result.filter as Record<string, unknown>;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13552] The RLS emptied-membership deny guard must be POLARITY-AWARE.
*
* `isEmptyMembershipFilter` exists so a pre-resolved membership set that
* RESOLVES EMPTY drops the policy and the single-policy path fails closed via
* `RLS_DENY_FILTER`. Before #13552 it shape-matched the bare positive form
* (`{ f: { $in: [] } }`) only — but `not in` is a first-class pushdown shape
* (`!(x in y)` → `$not` wrapping `$in`, cel-to-filter.ts), and under `$not` an
* empty `$in: []` INVERTS from constant FALSE to constant TRUE: the policy the
* guard exists to turn into a DENY compiled to ALLOW-ALL on the read scope.
*
* This suite is the triage-mandated enumeration (issue #13552, grading
* comment): every negation/composition shape the guard must fire under, the
* shapes it must NOT fire under (the working `not in` feature; the legitimate
* positive-composite cases), and row-level evidence via the formula evaluator
* that the pre-fix filter really admitted everything while the deny sentinel
* admits nothing.
*/

import { describe, it, expect } from 'vitest';
import { isPushdownableCel, matchesFilterCondition } from '@objectstack/formula';
import { RLSCompiler, RLS_DENY_FILTER, isEmptyMembershipFilter } from './rls-compiler.js';

/** Five-row fixture: distinct owners, one null — mirrors the issue's measurement. */
const ROWS: Record<string, unknown>[] = [
{ id: 'r1', owner: 'u_me', status: 'open' },
{ id: 'r2', owner: 'u_other', status: 'open' },
{ id: 'r3', owner: 'u_third', status: 'closed' },
{ id: 'r4', owner: null, status: 'open' },
{ id: 'r5', owner: 'u_fourth', status: 'closed' },
];

const admitted = (filter: Record<string, unknown>): number =>
ROWS.filter((row) => matchesFilterCondition(row, filter as any)).length;

const policy = (using: string): any => ({ object: 'task', operation: 'select', using });

/** Context whose membership sets all RESOLVE EMPTY (the degenerate context). */
const EMPTY_CTX: any = {
userId: 'u_me',
tenantId: 'org-1',
positions: [],
org_user_ids: [],
rlsMembership: { team_ids: [], blocked_ids: [] },
};

describe('[#13552] emptied membership under negation — the guard must fire (deny sentinel)', () => {
const compiler = new RLSCompiler();

// ── The decisive control first: the DANGER is real at the evaluator ──────
it('evaluator control: `$not` over an empty `$in` is constant TRUE — 5 of 5 rows', () => {
// Independent of the guard: this pins WHY the guard must fire. The same
// inversion holds at the analytics lowering (`read-scope-sql.ts`:
// `$in: []` → `1 = 0`, and `NOT (1 = 0)` is TRUE for every row).
expect(admitted({ $not: { owner: { $in: [] } } })).toBe(5);
// …and the deny sentinel admits nothing.
expect(admitted(RLS_DENY_FILTER as Record<string, unknown>)).toBe(0);
});

// ── Enumeration: shapes the guard fires under, driven through authored CEL ──
const MUST_DENY: Array<[label: string, cel: string]> = [
['direct `$not` wrap — `not in` on an emptied set',
'!(owner in current_user.org_user_ids)'],
['`$not` nested inside `$or`',
'!(owner in current_user.team_ids) || owner == current_user.id'],
['`$not` nested inside `$and`',
'!(owner in current_user.blocked_ids) && status == "open"'],
['`$not` over a composite containing the emptied membership ($and)',
'!(owner in current_user.team_ids && status == "open")'],
['`$not` over a composite containing the emptied membership ($or)',
'!(owner in current_user.team_ids || status == "archived")'],
['multi-level `$not`, odd (triple)',
'!(!(!(owner in current_user.org_user_ids)))'],
['multi-level `$not`, even (double) — constant FALSE, sentinel preferred',
'!(!(owner in current_user.org_user_ids))'],
['bare positive (the pre-#13552 behaviour, preserved)',
'owner in current_user.org_user_ids'],
];

it('every enumerated shape is an AUTHORABLE pushdown shape (isPushdownableCel ok)', () => {
for (const [label, cel] of MUST_DENY) {
expect(isPushdownableCel(cel), `${label}: ${cel}`).toEqual({ ok: true });
}
});

for (const [label, cel] of MUST_DENY) {
it(`${label} → RLS_DENY_FILTER (zero rows)`, () => {
const filter = compiler.compileFilter([policy(cel)], EMPTY_CTX);
expect(filter, `policy: ${cel}`).toEqual(RLS_DENY_FILTER);
// Row-level: the compiled scope admits NOTHING. Before the #13552 fix
// the negated shapes compiled to a constant-TRUE filter admitting 5/5.
expect(admitted(filter as Record<string, unknown>), `policy: ${cel}`).toBe(0);
});
}

// ── Shapes the guard must NOT fire under ─────────────────────────────────
it('NON-empty membership under `$not` keeps working — the `not in` feature', () => {
const ctx: any = { userId: 'u_me', tenantId: 'org-1', positions: [], org_user_ids: ['u_other', 'u_third'] };
const filter = compiler.compileFilter([policy('!(owner in current_user.org_user_ids)')], ctx);
expect(filter).toEqual({ $not: { owner: { $in: ['u_other', 'u_third'] } } });
// r1 (u_me), r4 (null owner — $in over null is false, $not inverts), r5 (u_fourth).
expect(admitted(filter as Record<string, unknown>)).toBe(3);
});

it('emptied POSITIVE membership as an `$or` arm stays inert — own rows keep flowing', () => {
const filter = compiler.compileFilter(
[policy('owner in current_user.team_ids || owner == current_user.id')],
EMPTY_CTX,
);
expect(filter).toEqual({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(admitted(filter as Record<string, unknown>)).toBe(1); // r1 only
});

it('deliberate allow-all stays authorable as literal `true`', () => {
const filter = compiler.compileFilter([policy('true')], EMPTY_CTX);
expect(filter).toEqual({});
expect(admitted(filter as Record<string, unknown>)).toBe(5);
});

it('multi-policy: a dropped negated-empty policy removes only its grant — the sibling still grants', () => {
const filter = compiler.compileFilter(
[policy('!(owner in current_user.org_user_ids)'), policy('owner == current_user.id')],
EMPTY_CTX,
);
// The degenerate policy contributes nothing; the sibling's grant survives.
expect(filter).toEqual({ owner: 'u_me' });
expect(admitted(filter as Record<string, unknown>)).toBe(1);
});
});

describe('[#13552] guard shape tests — FilterCondition forms CEL cannot author', () => {
// The guard's contract is over the compiled FilterCondition, which is wider
// than what cel-to-filter emits today. Direct shape pins so the defensive
// arms are not phantom checks.
const fires = (f: Record<string, unknown>) => isEmptyMembershipFilter(f);

it('multi-key implicit AND under `$not` (constant TRUE by De Morgan) fires', () => {
expect(fires({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(true);
// Evaluator agreement: NOT(FALSE AND …) admits everything.
expect(admitted({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(5);
});

it('bare `{ $not: { $in: [] } }` still fires — pre-#13552 guard parity', () => {
expect(fires({ $not: { $in: [] } })).toBe(true);
});

it('empty `$nin` (intrinsically constant TRUE) fires at positive polarity', () => {
// Not emitted by cel-to-filter today; recognised so a future lowering
// cannot fail open through the same blind spot ($nin: [] → `1 = 1` at the
// read-scope SQL lowering).
expect(fires({ owner: { $nin: [] } })).toBe(true);
expect(fires({ $or: [{ owner: { $nin: [] } }, { status: 'open' }] })).toBe(true);
});

it('non-membership shapes do not fire', () => {
expect(fires({ owner: 'u_me' })).toBe(false);
expect(fires({ $not: { owner: { $in: ['a'] } } })).toBe(false);
expect(fires({ $not: { owner: { $null: true } } })).toBe(false);
expect(fires({ $and: [{ owner: { $in: [] } }, { status: 'open' }] })).toBe(false); // constant FALSE — denies by itself
expect(fires({})).toBe(false);
});

it('even-`$not` emptied membership NESTED in a composite stays inert (constant FALSE arm)', () => {
expect(fires({ $or: [{ $not: { $not: { owner: { $in: [] } } } }, { owner: 'u_me' }] })).toBe(false);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware by os-steve · Pull Request #13570 · objectstack-ai/objectstack · GitHub
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
7 changes: 7 additions & 0 deletions .changeset/rls-empty-membership-polarity-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/plugin-security': patch
---

Security fix (fail-closed tightening, #13552): the RLS emptied-membership deny guard is now polarity-aware. A policy whose pre-resolved membership set resolves EMPTY under a negated membership test (`not in` — e.g. `using: '!(owner in current_user.org_user_ids)'`) now compiles to the deny sentinel (zero rows) instead of flowing through. Before this fix `$in: []` under `$not` inverted to a constant-TRUE clause (`NOT (1 = 0)` on the SQL read-scope lowering), so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads. The guard now fires at any composition depth: `$not` wrapping the membership directly, `$not` arms nested inside `$or`/`$and`, `$not` over a composite containing the membership, and multi-level `$not` (odd polarity anywhere; the bare positive case is unchanged).

Blast radius, in plain terms: callers that were relying on that allow-all stop seeing rows. If a negated-membership policy was the only applicable policy and its membership set resolves empty (no active organization; an empty team/territory/blocked set), reads that previously returned EVERY row now return ZERO rows. The prior behaviour was a defect — an over-permissive read on a row-level-security scope — not a contract. If own-rows access must survive an emptied membership set, author it as a separate OR'd policy (e.g. `owner == current_user.id`): each policy's grant is compiled independently, and a sibling policy dropping does not take it down. A deliberate allow-all remains authorable as a literal `true` predicate. Unchanged: a NON-empty membership set under `not in` compiles and enforces exactly as before, and an emptied POSITIVE membership nested in `$or` (e.g. `owner in current_user.team_ids || owner == current_user.id`) still preserves the other arm's grant.
120 changes: 106 additions & 14 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,19 +66,105 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
});

/**
* Does this filter consist solely of an empty membership (`{ field: { $in: [] } }`)?
* Used to preserve the legacy "empty pre-resolved set drops the policy" semantics
* so the single-policy path fails closed via the deny sentinel rather than an
* always-false `$in: []`.
* Is this field constraint an emptied membership, and what CONSTANT does it
* evaluate to? `{ $in: [] }` is constant FALSE on every backend ("IN ()
* matches nothing"); `{ $nin: [] }` is constant TRUE ("NOT IN () excludes
* nothing"). Returns that constant, or `null` when the spec is not an emptied
* membership. The CEL pushdown compiler only ever emits `$in` (negation wraps
* in `$not` — cel-to-filter.ts), but this guard's contract is over the
* FilterCondition shape, so the intrinsically-negated `$nin` spelling is
* recognised too rather than left to fail open should a future lowering emit it.
*/
function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
const keys = Object.keys(filter);
if (keys.length !== 1) return false;
const inner = filter[keys[0]];
if (!inner || typeof inner !== 'object') return false;
const innerKeys = Object.keys(inner as Record<string, unknown>);
return innerKeys.length === 1 && Array.isArray((inner as Record<string, unknown>).$in)
&& ((inner as Record<string, unknown>).$in as unknown[]).length === 0;
function emptyMembershipConstantTruth(spec: unknown): boolean | null {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
const rec = spec as Record<string, unknown>;
if (Object.keys(rec).length !== 1) return null;
if (Array.isArray(rec.$in) && (rec.$in as unknown[]).length === 0) return false;
if (Array.isArray(rec.$nin) && (rec.$nin as unknown[]).length === 0) return true;
return null;
}

/**
* Does this compiled filter LEAN ON an emptied membership in a way that must
* drop the policy (→ the deny sentinel upstream)? Preserves the legacy "empty
* pre-resolved set drops the policy" semantics so the single-policy path fails
* closed via the deny sentinel rather than an always-false `$in: []`.
*
* [#13552] POLARITY-AWARE. "An emptied membership is safe because `$in: []`
* matches nothing" is a polarity-DEPENDENT claim, and the pushdown subset
* contains negation (`not in` is first-class: `!(x in y)` → `$not` wrapping
* `$in`). The pre-#13552 guard shape-matched the bare positive form only, so
* `{ $not: { f: { $in: [] } } }` — constant TRUE for every row — flowed
* through and compiled to ALLOW-ALL on the read scope. Two rules now hold:
*
* 1. An emptied membership whose EFFECTIVE polarity is inverted (odd number of
* enclosing `$not`s for `$in: []`; zero/even for `$nin: []`) is a
* constant-TRUE clause. Anywhere in the tree — wrapping directly, as an arm
* of `$or`/`$and` (nested to any depth), inside a multi-key implicit AND,
* or under multi-level `$not` — it means the membership restriction the
* author wrote has evaporated: as an `$or` arm the whole filter is
* allow-all; as an `$and` arm the restriction silently vanishes. Either way
* the policy is degenerate → drop it (fail closed), exactly as the emptied
* POSITIVE single-policy case already does.
* 2. The legacy positive case, generalised through double negation: a filter
* that consists solely of an emptied `$in` membership under an even
* (incl. zero) number of `$not` wrappers is constant FALSE as a whole —
* prefer the deny sentinel over an always-false filter (same zero rows,
* one recognisable shape).
*
* What deliberately does NOT fire, in both cases matching pre-#13552
* behaviour: a NON-empty membership under `$not` (the working `not in`
* feature), and an emptied POSITIVE membership nested in a composite — as an
* `$or` arm it is inert (`owner in <empty> || owner == me` must keep granting
* own rows), as an `$and` arm the filter is already constant FALSE (denies by
* itself). A deliberate allow-all stays authorable as literal `true` (compiles
* to `{}`), which never involves a membership set.
*/
export function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
// (Exported for direct shape tests — rls-empty-membership-polarity.test.ts;
// not part of the package surface: index.ts deliberately does not re-export it.)
return containsTautologicalEmptyMembership(filter, false) || isSolelyEmptyMembership(filter);
}

/** Rule 1 above: an emptied membership that is constant TRUE in effective polarity. */
function containsTautologicalEmptyMembership(node: unknown, negated: boolean): boolean {
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
const rec = node as Record<string, unknown>;
// A BARE operator object (an emptied membership with no field key) is not a
// shape the CEL lowering emits, but the evaluator answers it fail-closed
// (constant FALSE — unknown top-level operator), which a wrapping `$not`
// inverts to constant TRUE. The pre-#13552 guard already fired on
// `{ $not: { $in: [] } }`; never be weaker than the predecessor on any shape.
if (emptyMembershipConstantTruth(rec) !== null) return negated;
for (const [key, value] of Object.entries(rec)) {
if (key === '$not') {
if (containsTautologicalEmptyMembership(value, !negated)) return true;
} else if (key === '$and' || key === '$or') {
if (Array.isArray(value) && value.some((arm) => containsTautologicalEmptyMembership(arm, negated))) return true;
} else if (!key.startsWith('$')) {
const truth = emptyMembershipConstantTruth(value);
if (truth !== null && (negated ? !truth : truth)) return true;
}
}
return false;
}

/** Rule 2 above: solely an emptied `$in` membership under even (incl. zero) `$not`s. */
function isSolelyEmptyMembership(filter: Record<string, unknown>): boolean {
let node: Record<string, unknown> = filter;
let negations = 0;
for (;;) {
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0] !== '$not') break;
const inner = node.$not;
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return false;
node = inner as Record<string, unknown>;
negations++;
}
if (negations % 2 !== 0) return false; // odd polarity → rule 1's walk owns it
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0].startsWith('$')) return false;
return emptyMembershipConstantTruth(node[keys[0]]) === false;
}

/**
Expand DownExpand Up@@ -228,7 +314,10 @@ export class RLSCompiler {
* - an unresolved/absent `current_user.*` variable → `null` → fail closed
* (the "no active organization" path);
* - an empty pre-resolved membership set → `null` so the single-policy case
* yields the deny sentinel upstream rather than a permissive `$in: []`.
* yields the deny sentinel upstream rather than a permissive `$in: []` —
* in EITHER polarity ([#13552]): under a supported `not in` the emptied
* set would otherwise compile to a constant-TRUE `$not`/`$in: []` clause,
* i.e. allow-all, the exact fail-open this guard exists to prevent.
*/
compileExpression(
expression: string,
Expand All@@ -255,7 +344,10 @@ export class RLSCompiler {
// Parity: an empty pre-resolved membership (`field in current_user.<empty>`)
// compiles to `{ field: { $in: [] } }`. The legacy compiler dropped the
// policy in this case; preserve that so the deny sentinel (not a literal
// empty-IN) is what the single-policy path returns.
// empty-IN) is what the single-policy path returns. [#13552] The guard is
// polarity-aware: the same emptied set under a supported `not in`
// (`$not` wrapping, at any composition depth) is dropped too — otherwise
// it inverts to a constant-TRUE clause and the policy compiles ALLOW-ALL.
if (isEmptyMembershipFilter(result.filter as Record<string, unknown>)) return null;
return result.filter as Record<string, unknown>;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13552] The RLS emptied-membership deny guard must be POLARITY-AWARE.
*
* `isEmptyMembershipFilter` exists so a pre-resolved membership set that
* RESOLVES EMPTY drops the policy and the single-policy path fails closed via
* `RLS_DENY_FILTER`. Before #13552 it shape-matched the bare positive form
* (`{ f: { $in: [] } }`) only — but `not in` is a first-class pushdown shape
* (`!(x in y)` → `$not` wrapping `$in`, cel-to-filter.ts), and under `$not` an
* empty `$in: []` INVERTS from constant FALSE to constant TRUE: the policy the
* guard exists to turn into a DENY compiled to ALLOW-ALL on the read scope.
*
* This suite is the triage-mandated enumeration (issue #13552, grading
* comment): every negation/composition shape the guard must fire under, the
* shapes it must NOT fire under (the working `not in` feature; the legitimate
* positive-composite cases), and row-level evidence via the formula evaluator
* that the pre-fix filter really admitted everything while the deny sentinel
* admits nothing.
*/

import { describe, it, expect } from 'vitest';
import { isPushdownableCel, matchesFilterCondition } from '@objectstack/formula';
import { RLSCompiler, RLS_DENY_FILTER, isEmptyMembershipFilter } from './rls-compiler.js';

/** Five-row fixture: distinct owners, one null — mirrors the issue's measurement. */
const ROWS: Record<string, unknown>[] = [
{ id: 'r1', owner: 'u_me', status: 'open' },
{ id: 'r2', owner: 'u_other', status: 'open' },
{ id: 'r3', owner: 'u_third', status: 'closed' },
{ id: 'r4', owner: null, status: 'open' },
{ id: 'r5', owner: 'u_fourth', status: 'closed' },
];

const admitted = (filter: Record<string, unknown>): number =>
ROWS.filter((row) => matchesFilterCondition(row, filter as any)).length;

const policy = (using: string): any => ({ object: 'task', operation: 'select', using });

/** Context whose membership sets all RESOLVE EMPTY (the degenerate context). */
const EMPTY_CTX: any = {
userId: 'u_me',
tenantId: 'org-1',
positions: [],
org_user_ids: [],
rlsMembership: { team_ids: [], blocked_ids: [] },
};

describe('[#13552] emptied membership under negation — the guard must fire (deny sentinel)', () => {
const compiler = new RLSCompiler();

// ── The decisive control first: the DANGER is real at the evaluator ──────
it('evaluator control: `$not` over an empty `$in` is constant TRUE — 5 of 5 rows', () => {
// Independent of the guard: this pins WHY the guard must fire. The same
// inversion holds at the analytics lowering (`read-scope-sql.ts`:
// `$in: []` → `1 = 0`, and `NOT (1 = 0)` is TRUE for every row).
expect(admitted({ $not: { owner: { $in: [] } } })).toBe(5);
// …and the deny sentinel admits nothing.
expect(admitted(RLS_DENY_FILTER as Record<string, unknown>)).toBe(0);
});

// ── Enumeration: shapes the guard fires under, driven through authored CEL ──
const MUST_DENY: Array<[label: string, cel: string]> = [
['direct `$not` wrap — `not in` on an emptied set',
'!(owner in current_user.org_user_ids)'],
['`$not` nested inside `$or`',
'!(owner in current_user.team_ids) || owner == current_user.id'],
['`$not` nested inside `$and`',
'!(owner in current_user.blocked_ids) && status == "open"'],
['`$not` over a composite containing the emptied membership ($and)',
'!(owner in current_user.team_ids && status == "open")'],
['`$not` over a composite containing the emptied membership ($or)',
'!(owner in current_user.team_ids || status == "archived")'],
['multi-level `$not`, odd (triple)',
'!(!(!(owner in current_user.org_user_ids)))'],
['multi-level `$not`, even (double) — constant FALSE, sentinel preferred',
'!(!(owner in current_user.org_user_ids))'],
['bare positive (the pre-#13552 behaviour, preserved)',
'owner in current_user.org_user_ids'],
];

it('every enumerated shape is an AUTHORABLE pushdown shape (isPushdownableCel ok)', () => {
for (const [label, cel] of MUST_DENY) {
expect(isPushdownableCel(cel), `${label}: ${cel}`).toEqual({ ok: true });
}
});

for (const [label, cel] of MUST_DENY) {
it(`${label} → RLS_DENY_FILTER (zero rows)`, () => {
const filter = compiler.compileFilter([policy(cel)], EMPTY_CTX);
expect(filter, `policy: ${cel}`).toEqual(RLS_DENY_FILTER);
// Row-level: the compiled scope admits NOTHING. Before the #13552 fix
// the negated shapes compiled to a constant-TRUE filter admitting 5/5.
expect(admitted(filter as Record<string, unknown>), `policy: ${cel}`).toBe(0);
});
}

// ── Shapes the guard must NOT fire under ─────────────────────────────────
it('NON-empty membership under `$not` keeps working — the `not in` feature', () => {
const ctx: any = { userId: 'u_me', tenantId: 'org-1', positions: [], org_user_ids: ['u_other', 'u_third'] };
const filter = compiler.compileFilter([policy('!(owner in current_user.org_user_ids)')], ctx);
expect(filter).toEqual({ $not: { owner: { $in: ['u_other', 'u_third'] } } });
// r1 (u_me), r4 (null owner — $in over null is false, $not inverts), r5 (u_fourth).
expect(admitted(filter as Record<string, unknown>)).toBe(3);
});

it('emptied POSITIVE membership as an `$or` arm stays inert — own rows keep flowing', () => {
const filter = compiler.compileFilter(
[policy('owner in current_user.team_ids || owner == current_user.id')],
EMPTY_CTX,
);
expect(filter).toEqual({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(admitted(filter as Record<string, unknown>)).toBe(1); // r1 only
});

it('deliberate allow-all stays authorable as literal `true`', () => {
const filter = compiler.compileFilter([policy('true')], EMPTY_CTX);
expect(filter).toEqual({});
expect(admitted(filter as Record<string, unknown>)).toBe(5);
});

it('multi-policy: a dropped negated-empty policy removes only its grant — the sibling still grants', () => {
const filter = compiler.compileFilter(
[policy('!(owner in current_user.org_user_ids)'), policy('owner == current_user.id')],
EMPTY_CTX,
);
// The degenerate policy contributes nothing; the sibling's grant survives.
expect(filter).toEqual({ owner: 'u_me' });
expect(admitted(filter as Record<string, unknown>)).toBe(1);
});
});

describe('[#13552] guard shape tests — FilterCondition forms CEL cannot author', () => {
// The guard's contract is over the compiled FilterCondition, which is wider
// than what cel-to-filter emits today. Direct shape pins so the defensive
// arms are not phantom checks.
const fires = (f: Record<string, unknown>) => isEmptyMembershipFilter(f);

it('multi-key implicit AND under `$not` (constant TRUE by De Morgan) fires', () => {
expect(fires({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(true);
// Evaluator agreement: NOT(FALSE AND …) admits everything.
expect(admitted({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(5);
});

it('bare `{ $not: { $in: [] } }` still fires — pre-#13552 guard parity', () => {
expect(fires({ $not: { $in: [] } })).toBe(true);
});

it('empty `$nin` (intrinsically constant TRUE) fires at positive polarity', () => {
// Not emitted by cel-to-filter today; recognised so a future lowering
// cannot fail open through the same blind spot ($nin: [] → `1 = 1` at the
// read-scope SQL lowering).
expect(fires({ owner: { $nin: [] } })).toBe(true);
expect(fires({ $or: [{ owner: { $nin: [] } }, { status: 'open' }] })).toBe(true);
});

it('non-membership shapes do not fire', () => {
expect(fires({ owner: 'u_me' })).toBe(false);
expect(fires({ $not: { owner: { $in: ['a'] } } })).toBe(false);
expect(fires({ $not: { owner: { $null: true } } })).toBe(false);
expect(fires({ $and: [{ owner: { $in: [] } }, { status: 'open' }] })).toBe(false); // constant FALSE — denies by itself
expect(fires({})).toBe(false);
});

it('even-`$not` emptied membership NESTED in a composite stays inert (constant FALSE arm)', () => {
expect(fires({ $or: [{ $not: { $not: { owner: { $in: [] } } } }, { owner: 'u_me' }] })).toBe(false);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware by os-steve · Pull Request #13570 · objectstack-ai/objectstack · GitHub
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
7 changes: 7 additions & 0 deletions .changeset/rls-empty-membership-polarity-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/plugin-security': patch
---

Security fix (fail-closed tightening, #13552): the RLS emptied-membership deny guard is now polarity-aware. A policy whose pre-resolved membership set resolves EMPTY under a negated membership test (`not in` — e.g. `using: '!(owner in current_user.org_user_ids)'`) now compiles to the deny sentinel (zero rows) instead of flowing through. Before this fix `$in: []` under `$not` inverted to a constant-TRUE clause (`NOT (1 = 0)` on the SQL read-scope lowering), so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads. The guard now fires at any composition depth: `$not` wrapping the membership directly, `$not` arms nested inside `$or`/`$and`, `$not` over a composite containing the membership, and multi-level `$not` (odd polarity anywhere; the bare positive case is unchanged).

Blast radius, in plain terms: callers that were relying on that allow-all stop seeing rows. If a negated-membership policy was the only applicable policy and its membership set resolves empty (no active organization; an empty team/territory/blocked set), reads that previously returned EVERY row now return ZERO rows. The prior behaviour was a defect — an over-permissive read on a row-level-security scope — not a contract. If own-rows access must survive an emptied membership set, author it as a separate OR'd policy (e.g. `owner == current_user.id`): each policy's grant is compiled independently, and a sibling policy dropping does not take it down. A deliberate allow-all remains authorable as a literal `true` predicate. Unchanged: a NON-empty membership set under `not in` compiles and enforces exactly as before, and an emptied POSITIVE membership nested in `$or` (e.g. `owner in current_user.team_ids || owner == current_user.id`) still preserves the other arm's grant.
120 changes: 106 additions & 14 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,19 +66,105 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
});

/**
* Does this filter consist solely of an empty membership (`{ field: { $in: [] } }`)?
* Used to preserve the legacy "empty pre-resolved set drops the policy" semantics
* so the single-policy path fails closed via the deny sentinel rather than an
* always-false `$in: []`.
* Is this field constraint an emptied membership, and what CONSTANT does it
* evaluate to? `{ $in: [] }` is constant FALSE on every backend ("IN ()
* matches nothing"); `{ $nin: [] }` is constant TRUE ("NOT IN () excludes
* nothing"). Returns that constant, or `null` when the spec is not an emptied
* membership. The CEL pushdown compiler only ever emits `$in` (negation wraps
* in `$not` — cel-to-filter.ts), but this guard's contract is over the
* FilterCondition shape, so the intrinsically-negated `$nin` spelling is
* recognised too rather than left to fail open should a future lowering emit it.
*/
function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
const keys = Object.keys(filter);
if (keys.length !== 1) return false;
const inner = filter[keys[0]];
if (!inner || typeof inner !== 'object') return false;
const innerKeys = Object.keys(inner as Record<string, unknown>);
return innerKeys.length === 1 && Array.isArray((inner as Record<string, unknown>).$in)
&& ((inner as Record<string, unknown>).$in as unknown[]).length === 0;
function emptyMembershipConstantTruth(spec: unknown): boolean | null {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
const rec = spec as Record<string, unknown>;
if (Object.keys(rec).length !== 1) return null;
if (Array.isArray(rec.$in) && (rec.$in as unknown[]).length === 0) return false;
if (Array.isArray(rec.$nin) && (rec.$nin as unknown[]).length === 0) return true;
return null;
}

/**
* Does this compiled filter LEAN ON an emptied membership in a way that must
* drop the policy (→ the deny sentinel upstream)? Preserves the legacy "empty
* pre-resolved set drops the policy" semantics so the single-policy path fails
* closed via the deny sentinel rather than an always-false `$in: []`.
*
* [#13552] POLARITY-AWARE. "An emptied membership is safe because `$in: []`
* matches nothing" is a polarity-DEPENDENT claim, and the pushdown subset
* contains negation (`not in` is first-class: `!(x in y)` → `$not` wrapping
* `$in`). The pre-#13552 guard shape-matched the bare positive form only, so
* `{ $not: { f: { $in: [] } } }` — constant TRUE for every row — flowed
* through and compiled to ALLOW-ALL on the read scope. Two rules now hold:
*
* 1. An emptied membership whose EFFECTIVE polarity is inverted (odd number of
* enclosing `$not`s for `$in: []`; zero/even for `$nin: []`) is a
* constant-TRUE clause. Anywhere in the tree — wrapping directly, as an arm
* of `$or`/`$and` (nested to any depth), inside a multi-key implicit AND,
* or under multi-level `$not` — it means the membership restriction the
* author wrote has evaporated: as an `$or` arm the whole filter is
* allow-all; as an `$and` arm the restriction silently vanishes. Either way
* the policy is degenerate → drop it (fail closed), exactly as the emptied
* POSITIVE single-policy case already does.
* 2. The legacy positive case, generalised through double negation: a filter
* that consists solely of an emptied `$in` membership under an even
* (incl. zero) number of `$not` wrappers is constant FALSE as a whole —
* prefer the deny sentinel over an always-false filter (same zero rows,
* one recognisable shape).
*
* What deliberately does NOT fire, in both cases matching pre-#13552
* behaviour: a NON-empty membership under `$not` (the working `not in`
* feature), and an emptied POSITIVE membership nested in a composite — as an
* `$or` arm it is inert (`owner in <empty> || owner == me` must keep granting
* own rows), as an `$and` arm the filter is already constant FALSE (denies by
* itself). A deliberate allow-all stays authorable as literal `true` (compiles
* to `{}`), which never involves a membership set.
*/
export function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
// (Exported for direct shape tests — rls-empty-membership-polarity.test.ts;
// not part of the package surface: index.ts deliberately does not re-export it.)
return containsTautologicalEmptyMembership(filter, false) || isSolelyEmptyMembership(filter);
}

/** Rule 1 above: an emptied membership that is constant TRUE in effective polarity. */
function containsTautologicalEmptyMembership(node: unknown, negated: boolean): boolean {
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
const rec = node as Record<string, unknown>;
// A BARE operator object (an emptied membership with no field key) is not a
// shape the CEL lowering emits, but the evaluator answers it fail-closed
// (constant FALSE — unknown top-level operator), which a wrapping `$not`
// inverts to constant TRUE. The pre-#13552 guard already fired on
// `{ $not: { $in: [] } }`; never be weaker than the predecessor on any shape.
if (emptyMembershipConstantTruth(rec) !== null) return negated;
for (const [key, value] of Object.entries(rec)) {
if (key === '$not') {
if (containsTautologicalEmptyMembership(value, !negated)) return true;
} else if (key === '$and' || key === '$or') {
if (Array.isArray(value) && value.some((arm) => containsTautologicalEmptyMembership(arm, negated))) return true;
} else if (!key.startsWith('$')) {
const truth = emptyMembershipConstantTruth(value);
if (truth !== null && (negated ? !truth : truth)) return true;
}
}
return false;
}

/** Rule 2 above: solely an emptied `$in` membership under even (incl. zero) `$not`s. */
function isSolelyEmptyMembership(filter: Record<string, unknown>): boolean {
let node: Record<string, unknown> = filter;
let negations = 0;
for (;;) {
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0] !== '$not') break;
const inner = node.$not;
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return false;
node = inner as Record<string, unknown>;
negations++;
}
if (negations % 2 !== 0) return false; // odd polarity → rule 1's walk owns it
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0].startsWith('$')) return false;
return emptyMembershipConstantTruth(node[keys[0]]) === false;
}

/**
Expand DownExpand Up@@ -228,7 +314,10 @@ export class RLSCompiler {
* - an unresolved/absent `current_user.*` variable → `null` → fail closed
* (the "no active organization" path);
* - an empty pre-resolved membership set → `null` so the single-policy case
* yields the deny sentinel upstream rather than a permissive `$in: []`.
* yields the deny sentinel upstream rather than a permissive `$in: []` —
* in EITHER polarity ([#13552]): under a supported `not in` the emptied
* set would otherwise compile to a constant-TRUE `$not`/`$in: []` clause,
* i.e. allow-all, the exact fail-open this guard exists to prevent.
*/
compileExpression(
expression: string,
Expand All@@ -255,7 +344,10 @@ export class RLSCompiler {
// Parity: an empty pre-resolved membership (`field in current_user.<empty>`)
// compiles to `{ field: { $in: [] } }`. The legacy compiler dropped the
// policy in this case; preserve that so the deny sentinel (not a literal
// empty-IN) is what the single-policy path returns.
// empty-IN) is what the single-policy path returns. [#13552] The guard is
// polarity-aware: the same emptied set under a supported `not in`
// (`$not` wrapping, at any composition depth) is dropped too — otherwise
// it inverts to a constant-TRUE clause and the policy compiles ALLOW-ALL.
if (isEmptyMembershipFilter(result.filter as Record<string, unknown>)) return null;
return result.filter as Record<string, unknown>;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13552] The RLS emptied-membership deny guard must be POLARITY-AWARE.
*
* `isEmptyMembershipFilter` exists so a pre-resolved membership set that
* RESOLVES EMPTY drops the policy and the single-policy path fails closed via
* `RLS_DENY_FILTER`. Before #13552 it shape-matched the bare positive form
* (`{ f: { $in: [] } }`) only — but `not in` is a first-class pushdown shape
* (`!(x in y)` → `$not` wrapping `$in`, cel-to-filter.ts), and under `$not` an
* empty `$in: []` INVERTS from constant FALSE to constant TRUE: the policy the
* guard exists to turn into a DENY compiled to ALLOW-ALL on the read scope.
*
* This suite is the triage-mandated enumeration (issue #13552, grading
* comment): every negation/composition shape the guard must fire under, the
* shapes it must NOT fire under (the working `not in` feature; the legitimate
* positive-composite cases), and row-level evidence via the formula evaluator
* that the pre-fix filter really admitted everything while the deny sentinel
* admits nothing.
*/

import { describe, it, expect } from 'vitest';
import { isPushdownableCel, matchesFilterCondition } from '@objectstack/formula';
import { RLSCompiler, RLS_DENY_FILTER, isEmptyMembershipFilter } from './rls-compiler.js';

/** Five-row fixture: distinct owners, one null — mirrors the issue's measurement. */
const ROWS: Record<string, unknown>[] = [
{ id: 'r1', owner: 'u_me', status: 'open' },
{ id: 'r2', owner: 'u_other', status: 'open' },
{ id: 'r3', owner: 'u_third', status: 'closed' },
{ id: 'r4', owner: null, status: 'open' },
{ id: 'r5', owner: 'u_fourth', status: 'closed' },
];

const admitted = (filter: Record<string, unknown>): number =>
ROWS.filter((row) => matchesFilterCondition(row, filter as any)).length;

const policy = (using: string): any => ({ object: 'task', operation: 'select', using });

/** Context whose membership sets all RESOLVE EMPTY (the degenerate context). */
const EMPTY_CTX: any = {
userId: 'u_me',
tenantId: 'org-1',
positions: [],
org_user_ids: [],
rlsMembership: { team_ids: [], blocked_ids: [] },
};

describe('[#13552] emptied membership under negation — the guard must fire (deny sentinel)', () => {
const compiler = new RLSCompiler();

// ── The decisive control first: the DANGER is real at the evaluator ──────
it('evaluator control: `$not` over an empty `$in` is constant TRUE — 5 of 5 rows', () => {
// Independent of the guard: this pins WHY the guard must fire. The same
// inversion holds at the analytics lowering (`read-scope-sql.ts`:
// `$in: []` → `1 = 0`, and `NOT (1 = 0)` is TRUE for every row).
expect(admitted({ $not: { owner: { $in: [] } } })).toBe(5);
// …and the deny sentinel admits nothing.
expect(admitted(RLS_DENY_FILTER as Record<string, unknown>)).toBe(0);
});

// ── Enumeration: shapes the guard fires under, driven through authored CEL ──
const MUST_DENY: Array<[label: string, cel: string]> = [
['direct `$not` wrap — `not in` on an emptied set',
'!(owner in current_user.org_user_ids)'],
['`$not` nested inside `$or`',
'!(owner in current_user.team_ids) || owner == current_user.id'],
['`$not` nested inside `$and`',
'!(owner in current_user.blocked_ids) && status == "open"'],
['`$not` over a composite containing the emptied membership ($and)',
'!(owner in current_user.team_ids && status == "open")'],
['`$not` over a composite containing the emptied membership ($or)',
'!(owner in current_user.team_ids || status == "archived")'],
['multi-level `$not`, odd (triple)',
'!(!(!(owner in current_user.org_user_ids)))'],
['multi-level `$not`, even (double) — constant FALSE, sentinel preferred',
'!(!(owner in current_user.org_user_ids))'],
['bare positive (the pre-#13552 behaviour, preserved)',
'owner in current_user.org_user_ids'],
];

it('every enumerated shape is an AUTHORABLE pushdown shape (isPushdownableCel ok)', () => {
for (const [label, cel] of MUST_DENY) {
expect(isPushdownableCel(cel), `${label}: ${cel}`).toEqual({ ok: true });
}
});

for (const [label, cel] of MUST_DENY) {
it(`${label} → RLS_DENY_FILTER (zero rows)`, () => {
const filter = compiler.compileFilter([policy(cel)], EMPTY_CTX);
expect(filter, `policy: ${cel}`).toEqual(RLS_DENY_FILTER);
// Row-level: the compiled scope admits NOTHING. Before the #13552 fix
// the negated shapes compiled to a constant-TRUE filter admitting 5/5.
expect(admitted(filter as Record<string, unknown>), `policy: ${cel}`).toBe(0);
});
}

// ── Shapes the guard must NOT fire under ─────────────────────────────────
it('NON-empty membership under `$not` keeps working — the `not in` feature', () => {
const ctx: any = { userId: 'u_me', tenantId: 'org-1', positions: [], org_user_ids: ['u_other', 'u_third'] };
const filter = compiler.compileFilter([policy('!(owner in current_user.org_user_ids)')], ctx);
expect(filter).toEqual({ $not: { owner: { $in: ['u_other', 'u_third'] } } });
// r1 (u_me), r4 (null owner — $in over null is false, $not inverts), r5 (u_fourth).
expect(admitted(filter as Record<string, unknown>)).toBe(3);
});

it('emptied POSITIVE membership as an `$or` arm stays inert — own rows keep flowing', () => {
const filter = compiler.compileFilter(
[policy('owner in current_user.team_ids || owner == current_user.id')],
EMPTY_CTX,
);
expect(filter).toEqual({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(admitted(filter as Record<string, unknown>)).toBe(1); // r1 only
});

it('deliberate allow-all stays authorable as literal `true`', () => {
const filter = compiler.compileFilter([policy('true')], EMPTY_CTX);
expect(filter).toEqual({});
expect(admitted(filter as Record<string, unknown>)).toBe(5);
});

it('multi-policy: a dropped negated-empty policy removes only its grant — the sibling still grants', () => {
const filter = compiler.compileFilter(
[policy('!(owner in current_user.org_user_ids)'), policy('owner == current_user.id')],
EMPTY_CTX,
);
// The degenerate policy contributes nothing; the sibling's grant survives.
expect(filter).toEqual({ owner: 'u_me' });
expect(admitted(filter as Record<string, unknown>)).toBe(1);
});
});

describe('[#13552] guard shape tests — FilterCondition forms CEL cannot author', () => {
// The guard's contract is over the compiled FilterCondition, which is wider
// than what cel-to-filter emits today. Direct shape pins so the defensive
// arms are not phantom checks.
const fires = (f: Record<string, unknown>) => isEmptyMembershipFilter(f);

it('multi-key implicit AND under `$not` (constant TRUE by De Morgan) fires', () => {
expect(fires({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(true);
// Evaluator agreement: NOT(FALSE AND …) admits everything.
expect(admitted({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(5);
});

it('bare `{ $not: { $in: [] } }` still fires — pre-#13552 guard parity', () => {
expect(fires({ $not: { $in: [] } })).toBe(true);
});

it('empty `$nin` (intrinsically constant TRUE) fires at positive polarity', () => {
// Not emitted by cel-to-filter today; recognised so a future lowering
// cannot fail open through the same blind spot ($nin: [] → `1 = 1` at the
// read-scope SQL lowering).
expect(fires({ owner: { $nin: [] } })).toBe(true);
expect(fires({ $or: [{ owner: { $nin: [] } }, { status: 'open' }] })).toBe(true);
});

it('non-membership shapes do not fire', () => {
expect(fires({ owner: 'u_me' })).toBe(false);
expect(fires({ $not: { owner: { $in: ['a'] } } })).toBe(false);
expect(fires({ $not: { owner: { $null: true } } })).toBe(false);
expect(fires({ $and: [{ owner: { $in: [] } }, { status: 'open' }] })).toBe(false); // constant FALSE — denies by itself
expect(fires({})).toBe(false);
});

it('even-`$not` emptied membership NESTED in a composite stays inert (constant FALSE arm)', () => {
expect(fires({ $or: [{ $not: { $not: { owner: { $in: [] } } } }, { owner: 'u_me' }] })).toBe(false);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware by os-steve · Pull Request #13570 · objectstack-ai/objectstack · GitHub
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
7 changes: 7 additions & 0 deletions .changeset/rls-empty-membership-polarity-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/plugin-security': patch
---

Security fix (fail-closed tightening, #13552): the RLS emptied-membership deny guard is now polarity-aware. A policy whose pre-resolved membership set resolves EMPTY under a negated membership test (`not in` — e.g. `using: '!(owner in current_user.org_user_ids)'`) now compiles to the deny sentinel (zero rows) instead of flowing through. Before this fix `$in: []` under `$not` inverted to a constant-TRUE clause (`NOT (1 = 0)` on the SQL read-scope lowering), so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads. The guard now fires at any composition depth: `$not` wrapping the membership directly, `$not` arms nested inside `$or`/`$and`, `$not` over a composite containing the membership, and multi-level `$not` (odd polarity anywhere; the bare positive case is unchanged).

Blast radius, in plain terms: callers that were relying on that allow-all stop seeing rows. If a negated-membership policy was the only applicable policy and its membership set resolves empty (no active organization; an empty team/territory/blocked set), reads that previously returned EVERY row now return ZERO rows. The prior behaviour was a defect — an over-permissive read on a row-level-security scope — not a contract. If own-rows access must survive an emptied membership set, author it as a separate OR'd policy (e.g. `owner == current_user.id`): each policy's grant is compiled independently, and a sibling policy dropping does not take it down. A deliberate allow-all remains authorable as a literal `true` predicate. Unchanged: a NON-empty membership set under `not in` compiles and enforces exactly as before, and an emptied POSITIVE membership nested in `$or` (e.g. `owner in current_user.team_ids || owner == current_user.id`) still preserves the other arm's grant.
120 changes: 106 additions & 14 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,19 +66,105 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
});

/**
* Does this filter consist solely of an empty membership (`{ field: { $in: [] } }`)?
* Used to preserve the legacy "empty pre-resolved set drops the policy" semantics
* so the single-policy path fails closed via the deny sentinel rather than an
* always-false `$in: []`.
* Is this field constraint an emptied membership, and what CONSTANT does it
* evaluate to? `{ $in: [] }` is constant FALSE on every backend ("IN ()
* matches nothing"); `{ $nin: [] }` is constant TRUE ("NOT IN () excludes
* nothing"). Returns that constant, or `null` when the spec is not an emptied
* membership. The CEL pushdown compiler only ever emits `$in` (negation wraps
* in `$not` — cel-to-filter.ts), but this guard's contract is over the
* FilterCondition shape, so the intrinsically-negated `$nin` spelling is
* recognised too rather than left to fail open should a future lowering emit it.
*/
function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
const keys = Object.keys(filter);
if (keys.length !== 1) return false;
const inner = filter[keys[0]];
if (!inner || typeof inner !== 'object') return false;
const innerKeys = Object.keys(inner as Record<string, unknown>);
return innerKeys.length === 1 && Array.isArray((inner as Record<string, unknown>).$in)
&& ((inner as Record<string, unknown>).$in as unknown[]).length === 0;
function emptyMembershipConstantTruth(spec: unknown): boolean | null {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
const rec = spec as Record<string, unknown>;
if (Object.keys(rec).length !== 1) return null;
if (Array.isArray(rec.$in) && (rec.$in as unknown[]).length === 0) return false;
if (Array.isArray(rec.$nin) && (rec.$nin as unknown[]).length === 0) return true;
return null;
}

/**
* Does this compiled filter LEAN ON an emptied membership in a way that must
* drop the policy (→ the deny sentinel upstream)? Preserves the legacy "empty
* pre-resolved set drops the policy" semantics so the single-policy path fails
* closed via the deny sentinel rather than an always-false `$in: []`.
*
* [#13552] POLARITY-AWARE. "An emptied membership is safe because `$in: []`
* matches nothing" is a polarity-DEPENDENT claim, and the pushdown subset
* contains negation (`not in` is first-class: `!(x in y)` → `$not` wrapping
* `$in`). The pre-#13552 guard shape-matched the bare positive form only, so
* `{ $not: { f: { $in: [] } } }` — constant TRUE for every row — flowed
* through and compiled to ALLOW-ALL on the read scope. Two rules now hold:
*
* 1. An emptied membership whose EFFECTIVE polarity is inverted (odd number of
* enclosing `$not`s for `$in: []`; zero/even for `$nin: []`) is a
* constant-TRUE clause. Anywhere in the tree — wrapping directly, as an arm
* of `$or`/`$and` (nested to any depth), inside a multi-key implicit AND,
* or under multi-level `$not` — it means the membership restriction the
* author wrote has evaporated: as an `$or` arm the whole filter is
* allow-all; as an `$and` arm the restriction silently vanishes. Either way
* the policy is degenerate → drop it (fail closed), exactly as the emptied
* POSITIVE single-policy case already does.
* 2. The legacy positive case, generalised through double negation: a filter
* that consists solely of an emptied `$in` membership under an even
* (incl. zero) number of `$not` wrappers is constant FALSE as a whole —
* prefer the deny sentinel over an always-false filter (same zero rows,
* one recognisable shape).
*
* What deliberately does NOT fire, in both cases matching pre-#13552
* behaviour: a NON-empty membership under `$not` (the working `not in`
* feature), and an emptied POSITIVE membership nested in a composite — as an
* `$or` arm it is inert (`owner in <empty> || owner == me` must keep granting
* own rows), as an `$and` arm the filter is already constant FALSE (denies by
* itself). A deliberate allow-all stays authorable as literal `true` (compiles
* to `{}`), which never involves a membership set.
*/
export function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
// (Exported for direct shape tests — rls-empty-membership-polarity.test.ts;
// not part of the package surface: index.ts deliberately does not re-export it.)
return containsTautologicalEmptyMembership(filter, false) || isSolelyEmptyMembership(filter);
}

/** Rule 1 above: an emptied membership that is constant TRUE in effective polarity. */
function containsTautologicalEmptyMembership(node: unknown, negated: boolean): boolean {
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
const rec = node as Record<string, unknown>;
// A BARE operator object (an emptied membership with no field key) is not a
// shape the CEL lowering emits, but the evaluator answers it fail-closed
// (constant FALSE — unknown top-level operator), which a wrapping `$not`
// inverts to constant TRUE. The pre-#13552 guard already fired on
// `{ $not: { $in: [] } }`; never be weaker than the predecessor on any shape.
if (emptyMembershipConstantTruth(rec) !== null) return negated;
for (const [key, value] of Object.entries(rec)) {
if (key === '$not') {
if (containsTautologicalEmptyMembership(value, !negated)) return true;
} else if (key === '$and' || key === '$or') {
if (Array.isArray(value) && value.some((arm) => containsTautologicalEmptyMembership(arm, negated))) return true;
} else if (!key.startsWith('$')) {
const truth = emptyMembershipConstantTruth(value);
if (truth !== null && (negated ? !truth : truth)) return true;
}
}
return false;
}

/** Rule 2 above: solely an emptied `$in` membership under even (incl. zero) `$not`s. */
function isSolelyEmptyMembership(filter: Record<string, unknown>): boolean {
let node: Record<string, unknown> = filter;
let negations = 0;
for (;;) {
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0] !== '$not') break;
const inner = node.$not;
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return false;
node = inner as Record<string, unknown>;
negations++;
}
if (negations % 2 !== 0) return false; // odd polarity → rule 1's walk owns it
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0].startsWith('$')) return false;
return emptyMembershipConstantTruth(node[keys[0]]) === false;
}

/**
Expand DownExpand Up@@ -228,7 +314,10 @@ export class RLSCompiler {
* - an unresolved/absent `current_user.*` variable → `null` → fail closed
* (the "no active organization" path);
* - an empty pre-resolved membership set → `null` so the single-policy case
* yields the deny sentinel upstream rather than a permissive `$in: []`.
* yields the deny sentinel upstream rather than a permissive `$in: []` —
* in EITHER polarity ([#13552]): under a supported `not in` the emptied
* set would otherwise compile to a constant-TRUE `$not`/`$in: []` clause,
* i.e. allow-all, the exact fail-open this guard exists to prevent.
*/
compileExpression(
expression: string,
Expand All@@ -255,7 +344,10 @@ export class RLSCompiler {
// Parity: an empty pre-resolved membership (`field in current_user.<empty>`)
// compiles to `{ field: { $in: [] } }`. The legacy compiler dropped the
// policy in this case; preserve that so the deny sentinel (not a literal
// empty-IN) is what the single-policy path returns.
// empty-IN) is what the single-policy path returns. [#13552] The guard is
// polarity-aware: the same emptied set under a supported `not in`
// (`$not` wrapping, at any composition depth) is dropped too — otherwise
// it inverts to a constant-TRUE clause and the policy compiles ALLOW-ALL.
if (isEmptyMembershipFilter(result.filter as Record<string, unknown>)) return null;
return result.filter as Record<string, unknown>;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13552] The RLS emptied-membership deny guard must be POLARITY-AWARE.
*
* `isEmptyMembershipFilter` exists so a pre-resolved membership set that
* RESOLVES EMPTY drops the policy and the single-policy path fails closed via
* `RLS_DENY_FILTER`. Before #13552 it shape-matched the bare positive form
* (`{ f: { $in: [] } }`) only — but `not in` is a first-class pushdown shape
* (`!(x in y)` → `$not` wrapping `$in`, cel-to-filter.ts), and under `$not` an
* empty `$in: []` INVERTS from constant FALSE to constant TRUE: the policy the
* guard exists to turn into a DENY compiled to ALLOW-ALL on the read scope.
*
* This suite is the triage-mandated enumeration (issue #13552, grading
* comment): every negation/composition shape the guard must fire under, the
* shapes it must NOT fire under (the working `not in` feature; the legitimate
* positive-composite cases), and row-level evidence via the formula evaluator
* that the pre-fix filter really admitted everything while the deny sentinel
* admits nothing.
*/

import { describe, it, expect } from 'vitest';
import { isPushdownableCel, matchesFilterCondition } from '@objectstack/formula';
import { RLSCompiler, RLS_DENY_FILTER, isEmptyMembershipFilter } from './rls-compiler.js';

/** Five-row fixture: distinct owners, one null — mirrors the issue's measurement. */
const ROWS: Record<string, unknown>[] = [
{ id: 'r1', owner: 'u_me', status: 'open' },
{ id: 'r2', owner: 'u_other', status: 'open' },
{ id: 'r3', owner: 'u_third', status: 'closed' },
{ id: 'r4', owner: null, status: 'open' },
{ id: 'r5', owner: 'u_fourth', status: 'closed' },
];

const admitted = (filter: Record<string, unknown>): number =>
ROWS.filter((row) => matchesFilterCondition(row, filter as any)).length;

const policy = (using: string): any => ({ object: 'task', operation: 'select', using });

/** Context whose membership sets all RESOLVE EMPTY (the degenerate context). */
const EMPTY_CTX: any = {
userId: 'u_me',
tenantId: 'org-1',
positions: [],
org_user_ids: [],
rlsMembership: { team_ids: [], blocked_ids: [] },
};

describe('[#13552] emptied membership under negation — the guard must fire (deny sentinel)', () => {
const compiler = new RLSCompiler();

// ── The decisive control first: the DANGER is real at the evaluator ──────
it('evaluator control: `$not` over an empty `$in` is constant TRUE — 5 of 5 rows', () => {
// Independent of the guard: this pins WHY the guard must fire. The same
// inversion holds at the analytics lowering (`read-scope-sql.ts`:
// `$in: []` → `1 = 0`, and `NOT (1 = 0)` is TRUE for every row).
expect(admitted({ $not: { owner: { $in: [] } } })).toBe(5);
// …and the deny sentinel admits nothing.
expect(admitted(RLS_DENY_FILTER as Record<string, unknown>)).toBe(0);
});

// ── Enumeration: shapes the guard fires under, driven through authored CEL ──
const MUST_DENY: Array<[label: string, cel: string]> = [
['direct `$not` wrap — `not in` on an emptied set',
'!(owner in current_user.org_user_ids)'],
['`$not` nested inside `$or`',
'!(owner in current_user.team_ids) || owner == current_user.id'],
['`$not` nested inside `$and`',
'!(owner in current_user.blocked_ids) && status == "open"'],
['`$not` over a composite containing the emptied membership ($and)',
'!(owner in current_user.team_ids && status == "open")'],
['`$not` over a composite containing the emptied membership ($or)',
'!(owner in current_user.team_ids || status == "archived")'],
['multi-level `$not`, odd (triple)',
'!(!(!(owner in current_user.org_user_ids)))'],
['multi-level `$not`, even (double) — constant FALSE, sentinel preferred',
'!(!(owner in current_user.org_user_ids))'],
['bare positive (the pre-#13552 behaviour, preserved)',
'owner in current_user.org_user_ids'],
];

it('every enumerated shape is an AUTHORABLE pushdown shape (isPushdownableCel ok)', () => {
for (const [label, cel] of MUST_DENY) {
expect(isPushdownableCel(cel), `${label}: ${cel}`).toEqual({ ok: true });
}
});

for (const [label, cel] of MUST_DENY) {
it(`${label} → RLS_DENY_FILTER (zero rows)`, () => {
const filter = compiler.compileFilter([policy(cel)], EMPTY_CTX);
expect(filter, `policy: ${cel}`).toEqual(RLS_DENY_FILTER);
// Row-level: the compiled scope admits NOTHING. Before the #13552 fix
// the negated shapes compiled to a constant-TRUE filter admitting 5/5.
expect(admitted(filter as Record<string, unknown>), `policy: ${cel}`).toBe(0);
});
}

// ── Shapes the guard must NOT fire under ─────────────────────────────────
it('NON-empty membership under `$not` keeps working — the `not in` feature', () => {
const ctx: any = { userId: 'u_me', tenantId: 'org-1', positions: [], org_user_ids: ['u_other', 'u_third'] };
const filter = compiler.compileFilter([policy('!(owner in current_user.org_user_ids)')], ctx);
expect(filter).toEqual({ $not: { owner: { $in: ['u_other', 'u_third'] } } });
// r1 (u_me), r4 (null owner — $in over null is false, $not inverts), r5 (u_fourth).
expect(admitted(filter as Record<string, unknown>)).toBe(3);
});

it('emptied POSITIVE membership as an `$or` arm stays inert — own rows keep flowing', () => {
const filter = compiler.compileFilter(
[policy('owner in current_user.team_ids || owner == current_user.id')],
EMPTY_CTX,
);
expect(filter).toEqual({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(admitted(filter as Record<string, unknown>)).toBe(1); // r1 only
});

it('deliberate allow-all stays authorable as literal `true`', () => {
const filter = compiler.compileFilter([policy('true')], EMPTY_CTX);
expect(filter).toEqual({});
expect(admitted(filter as Record<string, unknown>)).toBe(5);
});

it('multi-policy: a dropped negated-empty policy removes only its grant — the sibling still grants', () => {
const filter = compiler.compileFilter(
[policy('!(owner in current_user.org_user_ids)'), policy('owner == current_user.id')],
EMPTY_CTX,
);
// The degenerate policy contributes nothing; the sibling's grant survives.
expect(filter).toEqual({ owner: 'u_me' });
expect(admitted(filter as Record<string, unknown>)).toBe(1);
});
});

describe('[#13552] guard shape tests — FilterCondition forms CEL cannot author', () => {
// The guard's contract is over the compiled FilterCondition, which is wider
// than what cel-to-filter emits today. Direct shape pins so the defensive
// arms are not phantom checks.
const fires = (f: Record<string, unknown>) => isEmptyMembershipFilter(f);

it('multi-key implicit AND under `$not` (constant TRUE by De Morgan) fires', () => {
expect(fires({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(true);
// Evaluator agreement: NOT(FALSE AND …) admits everything.
expect(admitted({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(5);
});

it('bare `{ $not: { $in: [] } }` still fires — pre-#13552 guard parity', () => {
expect(fires({ $not: { $in: [] } })).toBe(true);
});

it('empty `$nin` (intrinsically constant TRUE) fires at positive polarity', () => {
// Not emitted by cel-to-filter today; recognised so a future lowering
// cannot fail open through the same blind spot ($nin: [] → `1 = 1` at the
// read-scope SQL lowering).
expect(fires({ owner: { $nin: [] } })).toBe(true);
expect(fires({ $or: [{ owner: { $nin: [] } }, { status: 'open' }] })).toBe(true);
});

it('non-membership shapes do not fire', () => {
expect(fires({ owner: 'u_me' })).toBe(false);
expect(fires({ $not: { owner: { $in: ['a'] } } })).toBe(false);
expect(fires({ $not: { owner: { $null: true } } })).toBe(false);
expect(fires({ $and: [{ owner: { $in: [] } }, { status: 'open' }] })).toBe(false); // constant FALSE — denies by itself
expect(fires({})).toBe(false);
});

it('even-`$not` emptied membership NESTED in a composite stays inert (constant FALSE arm)', () => {
expect(fires({ $or: [{ $not: { $not: { owner: { $in: [] } } } }, { owner: 'u_me' }] })).toBe(false);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware by os-steve · Pull Request #13570 · objectstack-ai/objectstack · GitHub
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
7 changes: 7 additions & 0 deletions .changeset/rls-empty-membership-polarity-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/plugin-security': patch
---

Security fix (fail-closed tightening, #13552): the RLS emptied-membership deny guard is now polarity-aware. A policy whose pre-resolved membership set resolves EMPTY under a negated membership test (`not in` — e.g. `using: '!(owner in current_user.org_user_ids)'`) now compiles to the deny sentinel (zero rows) instead of flowing through. Before this fix `$in: []` under `$not` inverted to a constant-TRUE clause (`NOT (1 = 0)` on the SQL read-scope lowering), so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads. The guard now fires at any composition depth: `$not` wrapping the membership directly, `$not` arms nested inside `$or`/`$and`, `$not` over a composite containing the membership, and multi-level `$not` (odd polarity anywhere; the bare positive case is unchanged).

Blast radius, in plain terms: callers that were relying on that allow-all stop seeing rows. If a negated-membership policy was the only applicable policy and its membership set resolves empty (no active organization; an empty team/territory/blocked set), reads that previously returned EVERY row now return ZERO rows. The prior behaviour was a defect — an over-permissive read on a row-level-security scope — not a contract. If own-rows access must survive an emptied membership set, author it as a separate OR'd policy (e.g. `owner == current_user.id`): each policy's grant is compiled independently, and a sibling policy dropping does not take it down. A deliberate allow-all remains authorable as a literal `true` predicate. Unchanged: a NON-empty membership set under `not in` compiles and enforces exactly as before, and an emptied POSITIVE membership nested in `$or` (e.g. `owner in current_user.team_ids || owner == current_user.id`) still preserves the other arm's grant.
120 changes: 106 additions & 14 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,19 +66,105 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
});

/**
* Does this filter consist solely of an empty membership (`{ field: { $in: [] } }`)?
* Used to preserve the legacy "empty pre-resolved set drops the policy" semantics
* so the single-policy path fails closed via the deny sentinel rather than an
* always-false `$in: []`.
* Is this field constraint an emptied membership, and what CONSTANT does it
* evaluate to? `{ $in: [] }` is constant FALSE on every backend ("IN ()
* matches nothing"); `{ $nin: [] }` is constant TRUE ("NOT IN () excludes
* nothing"). Returns that constant, or `null` when the spec is not an emptied
* membership. The CEL pushdown compiler only ever emits `$in` (negation wraps
* in `$not` — cel-to-filter.ts), but this guard's contract is over the
* FilterCondition shape, so the intrinsically-negated `$nin` spelling is
* recognised too rather than left to fail open should a future lowering emit it.
*/
function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
const keys = Object.keys(filter);
if (keys.length !== 1) return false;
const inner = filter[keys[0]];
if (!inner || typeof inner !== 'object') return false;
const innerKeys = Object.keys(inner as Record<string, unknown>);
return innerKeys.length === 1 && Array.isArray((inner as Record<string, unknown>).$in)
&& ((inner as Record<string, unknown>).$in as unknown[]).length === 0;
function emptyMembershipConstantTruth(spec: unknown): boolean | null {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
const rec = spec as Record<string, unknown>;
if (Object.keys(rec).length !== 1) return null;
if (Array.isArray(rec.$in) && (rec.$in as unknown[]).length === 0) return false;
if (Array.isArray(rec.$nin) && (rec.$nin as unknown[]).length === 0) return true;
return null;
}

/**
* Does this compiled filter LEAN ON an emptied membership in a way that must
* drop the policy (→ the deny sentinel upstream)? Preserves the legacy "empty
* pre-resolved set drops the policy" semantics so the single-policy path fails
* closed via the deny sentinel rather than an always-false `$in: []`.
*
* [#13552] POLARITY-AWARE. "An emptied membership is safe because `$in: []`
* matches nothing" is a polarity-DEPENDENT claim, and the pushdown subset
* contains negation (`not in` is first-class: `!(x in y)` → `$not` wrapping
* `$in`). The pre-#13552 guard shape-matched the bare positive form only, so
* `{ $not: { f: { $in: [] } } }` — constant TRUE for every row — flowed
* through and compiled to ALLOW-ALL on the read scope. Two rules now hold:
*
* 1. An emptied membership whose EFFECTIVE polarity is inverted (odd number of
* enclosing `$not`s for `$in: []`; zero/even for `$nin: []`) is a
* constant-TRUE clause. Anywhere in the tree — wrapping directly, as an arm
* of `$or`/`$and` (nested to any depth), inside a multi-key implicit AND,
* or under multi-level `$not` — it means the membership restriction the
* author wrote has evaporated: as an `$or` arm the whole filter is
* allow-all; as an `$and` arm the restriction silently vanishes. Either way
* the policy is degenerate → drop it (fail closed), exactly as the emptied
* POSITIVE single-policy case already does.
* 2. The legacy positive case, generalised through double negation: a filter
* that consists solely of an emptied `$in` membership under an even
* (incl. zero) number of `$not` wrappers is constant FALSE as a whole —
* prefer the deny sentinel over an always-false filter (same zero rows,
* one recognisable shape).
*
* What deliberately does NOT fire, in both cases matching pre-#13552
* behaviour: a NON-empty membership under `$not` (the working `not in`
* feature), and an emptied POSITIVE membership nested in a composite — as an
* `$or` arm it is inert (`owner in <empty> || owner == me` must keep granting
* own rows), as an `$and` arm the filter is already constant FALSE (denies by
* itself). A deliberate allow-all stays authorable as literal `true` (compiles
* to `{}`), which never involves a membership set.
*/
export function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
// (Exported for direct shape tests — rls-empty-membership-polarity.test.ts;
// not part of the package surface: index.ts deliberately does not re-export it.)
return containsTautologicalEmptyMembership(filter, false) || isSolelyEmptyMembership(filter);
}

/** Rule 1 above: an emptied membership that is constant TRUE in effective polarity. */
function containsTautologicalEmptyMembership(node: unknown, negated: boolean): boolean {
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
const rec = node as Record<string, unknown>;
// A BARE operator object (an emptied membership with no field key) is not a
// shape the CEL lowering emits, but the evaluator answers it fail-closed
// (constant FALSE — unknown top-level operator), which a wrapping `$not`
// inverts to constant TRUE. The pre-#13552 guard already fired on
// `{ $not: { $in: [] } }`; never be weaker than the predecessor on any shape.
if (emptyMembershipConstantTruth(rec) !== null) return negated;
for (const [key, value] of Object.entries(rec)) {
if (key === '$not') {
if (containsTautologicalEmptyMembership(value, !negated)) return true;
} else if (key === '$and' || key === '$or') {
if (Array.isArray(value) && value.some((arm) => containsTautologicalEmptyMembership(arm, negated))) return true;
} else if (!key.startsWith('$')) {
const truth = emptyMembershipConstantTruth(value);
if (truth !== null && (negated ? !truth : truth)) return true;
}
}
return false;
}

/** Rule 2 above: solely an emptied `$in` membership under even (incl. zero) `$not`s. */
function isSolelyEmptyMembership(filter: Record<string, unknown>): boolean {
let node: Record<string, unknown> = filter;
let negations = 0;
for (;;) {
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0] !== '$not') break;
const inner = node.$not;
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return false;
node = inner as Record<string, unknown>;
negations++;
}
if (negations % 2 !== 0) return false; // odd polarity → rule 1's walk owns it
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0].startsWith('$')) return false;
return emptyMembershipConstantTruth(node[keys[0]]) === false;
}

/**
Expand DownExpand Up@@ -228,7 +314,10 @@ export class RLSCompiler {
* - an unresolved/absent `current_user.*` variable → `null` → fail closed
* (the "no active organization" path);
* - an empty pre-resolved membership set → `null` so the single-policy case
* yields the deny sentinel upstream rather than a permissive `$in: []`.
* yields the deny sentinel upstream rather than a permissive `$in: []` —
* in EITHER polarity ([#13552]): under a supported `not in` the emptied
* set would otherwise compile to a constant-TRUE `$not`/`$in: []` clause,
* i.e. allow-all, the exact fail-open this guard exists to prevent.
*/
compileExpression(
expression: string,
Expand All@@ -255,7 +344,10 @@ export class RLSCompiler {
// Parity: an empty pre-resolved membership (`field in current_user.<empty>`)
// compiles to `{ field: { $in: [] } }`. The legacy compiler dropped the
// policy in this case; preserve that so the deny sentinel (not a literal
// empty-IN) is what the single-policy path returns.
// empty-IN) is what the single-policy path returns. [#13552] The guard is
// polarity-aware: the same emptied set under a supported `not in`
// (`$not` wrapping, at any composition depth) is dropped too — otherwise
// it inverts to a constant-TRUE clause and the policy compiles ALLOW-ALL.
if (isEmptyMembershipFilter(result.filter as Record<string, unknown>)) return null;
return result.filter as Record<string, unknown>;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13552] The RLS emptied-membership deny guard must be POLARITY-AWARE.
*
* `isEmptyMembershipFilter` exists so a pre-resolved membership set that
* RESOLVES EMPTY drops the policy and the single-policy path fails closed via
* `RLS_DENY_FILTER`. Before #13552 it shape-matched the bare positive form
* (`{ f: { $in: [] } }`) only — but `not in` is a first-class pushdown shape
* (`!(x in y)` → `$not` wrapping `$in`, cel-to-filter.ts), and under `$not` an
* empty `$in: []` INVERTS from constant FALSE to constant TRUE: the policy the
* guard exists to turn into a DENY compiled to ALLOW-ALL on the read scope.
*
* This suite is the triage-mandated enumeration (issue #13552, grading
* comment): every negation/composition shape the guard must fire under, the
* shapes it must NOT fire under (the working `not in` feature; the legitimate
* positive-composite cases), and row-level evidence via the formula evaluator
* that the pre-fix filter really admitted everything while the deny sentinel
* admits nothing.
*/

import { describe, it, expect } from 'vitest';
import { isPushdownableCel, matchesFilterCondition } from '@objectstack/formula';
import { RLSCompiler, RLS_DENY_FILTER, isEmptyMembershipFilter } from './rls-compiler.js';

/** Five-row fixture: distinct owners, one null — mirrors the issue's measurement. */
const ROWS: Record<string, unknown>[] = [
{ id: 'r1', owner: 'u_me', status: 'open' },
{ id: 'r2', owner: 'u_other', status: 'open' },
{ id: 'r3', owner: 'u_third', status: 'closed' },
{ id: 'r4', owner: null, status: 'open' },
{ id: 'r5', owner: 'u_fourth', status: 'closed' },
];

const admitted = (filter: Record<string, unknown>): number =>
ROWS.filter((row) => matchesFilterCondition(row, filter as any)).length;

const policy = (using: string): any => ({ object: 'task', operation: 'select', using });

/** Context whose membership sets all RESOLVE EMPTY (the degenerate context). */
const EMPTY_CTX: any = {
userId: 'u_me',
tenantId: 'org-1',
positions: [],
org_user_ids: [],
rlsMembership: { team_ids: [], blocked_ids: [] },
};

describe('[#13552] emptied membership under negation — the guard must fire (deny sentinel)', () => {
const compiler = new RLSCompiler();

// ── The decisive control first: the DANGER is real at the evaluator ──────
it('evaluator control: `$not` over an empty `$in` is constant TRUE — 5 of 5 rows', () => {
// Independent of the guard: this pins WHY the guard must fire. The same
// inversion holds at the analytics lowering (`read-scope-sql.ts`:
// `$in: []` → `1 = 0`, and `NOT (1 = 0)` is TRUE for every row).
expect(admitted({ $not: { owner: { $in: [] } } })).toBe(5);
// …and the deny sentinel admits nothing.
expect(admitted(RLS_DENY_FILTER as Record<string, unknown>)).toBe(0);
});

// ── Enumeration: shapes the guard fires under, driven through authored CEL ──
const MUST_DENY: Array<[label: string, cel: string]> = [
['direct `$not` wrap — `not in` on an emptied set',
'!(owner in current_user.org_user_ids)'],
['`$not` nested inside `$or`',
'!(owner in current_user.team_ids) || owner == current_user.id'],
['`$not` nested inside `$and`',
'!(owner in current_user.blocked_ids) && status == "open"'],
['`$not` over a composite containing the emptied membership ($and)',
'!(owner in current_user.team_ids && status == "open")'],
['`$not` over a composite containing the emptied membership ($or)',
'!(owner in current_user.team_ids || status == "archived")'],
['multi-level `$not`, odd (triple)',
'!(!(!(owner in current_user.org_user_ids)))'],
['multi-level `$not`, even (double) — constant FALSE, sentinel preferred',
'!(!(owner in current_user.org_user_ids))'],
['bare positive (the pre-#13552 behaviour, preserved)',
'owner in current_user.org_user_ids'],
];

it('every enumerated shape is an AUTHORABLE pushdown shape (isPushdownableCel ok)', () => {
for (const [label, cel] of MUST_DENY) {
expect(isPushdownableCel(cel), `${label}: ${cel}`).toEqual({ ok: true });
}
});

for (const [label, cel] of MUST_DENY) {
it(`${label} → RLS_DENY_FILTER (zero rows)`, () => {
const filter = compiler.compileFilter([policy(cel)], EMPTY_CTX);
expect(filter, `policy: ${cel}`).toEqual(RLS_DENY_FILTER);
// Row-level: the compiled scope admits NOTHING. Before the #13552 fix
// the negated shapes compiled to a constant-TRUE filter admitting 5/5.
expect(admitted(filter as Record<string, unknown>), `policy: ${cel}`).toBe(0);
});
}

// ── Shapes the guard must NOT fire under ─────────────────────────────────
it('NON-empty membership under `$not` keeps working — the `not in` feature', () => {
const ctx: any = { userId: 'u_me', tenantId: 'org-1', positions: [], org_user_ids: ['u_other', 'u_third'] };
const filter = compiler.compileFilter([policy('!(owner in current_user.org_user_ids)')], ctx);
expect(filter).toEqual({ $not: { owner: { $in: ['u_other', 'u_third'] } } });
// r1 (u_me), r4 (null owner — $in over null is false, $not inverts), r5 (u_fourth).
expect(admitted(filter as Record<string, unknown>)).toBe(3);
});

it('emptied POSITIVE membership as an `$or` arm stays inert — own rows keep flowing', () => {
const filter = compiler.compileFilter(
[policy('owner in current_user.team_ids || owner == current_user.id')],
EMPTY_CTX,
);
expect(filter).toEqual({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(admitted(filter as Record<string, unknown>)).toBe(1); // r1 only
});

it('deliberate allow-all stays authorable as literal `true`', () => {
const filter = compiler.compileFilter([policy('true')], EMPTY_CTX);
expect(filter).toEqual({});
expect(admitted(filter as Record<string, unknown>)).toBe(5);
});

it('multi-policy: a dropped negated-empty policy removes only its grant — the sibling still grants', () => {
const filter = compiler.compileFilter(
[policy('!(owner in current_user.org_user_ids)'), policy('owner == current_user.id')],
EMPTY_CTX,
);
// The degenerate policy contributes nothing; the sibling's grant survives.
expect(filter).toEqual({ owner: 'u_me' });
expect(admitted(filter as Record<string, unknown>)).toBe(1);
});
});

describe('[#13552] guard shape tests — FilterCondition forms CEL cannot author', () => {
// The guard's contract is over the compiled FilterCondition, which is wider
// than what cel-to-filter emits today. Direct shape pins so the defensive
// arms are not phantom checks.
const fires = (f: Record<string, unknown>) => isEmptyMembershipFilter(f);

it('multi-key implicit AND under `$not` (constant TRUE by De Morgan) fires', () => {
expect(fires({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(true);
// Evaluator agreement: NOT(FALSE AND …) admits everything.
expect(admitted({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(5);
});

it('bare `{ $not: { $in: [] } }` still fires — pre-#13552 guard parity', () => {
expect(fires({ $not: { $in: [] } })).toBe(true);
});

it('empty `$nin` (intrinsically constant TRUE) fires at positive polarity', () => {
// Not emitted by cel-to-filter today; recognised so a future lowering
// cannot fail open through the same blind spot ($nin: [] → `1 = 1` at the
// read-scope SQL lowering).
expect(fires({ owner: { $nin: [] } })).toBe(true);
expect(fires({ $or: [{ owner: { $nin: [] } }, { status: 'open' }] })).toBe(true);
});

it('non-membership shapes do not fire', () => {
expect(fires({ owner: 'u_me' })).toBe(false);
expect(fires({ $not: { owner: { $in: ['a'] } } })).toBe(false);
expect(fires({ $not: { owner: { $null: true } } })).toBe(false);
expect(fires({ $and: [{ owner: { $in: [] } }, { status: 'open' }] })).toBe(false); // constant FALSE — denies by itself
expect(fires({})).toBe(false);
});

it('even-`$not` emptied membership NESTED in a composite stays inert (constant FALSE arm)', () => {
expect(fires({ $or: [{ $not: { $not: { owner: { $in: [] } } } }, { owner: 'u_me' }] })).toBe(false);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware by os-steve · Pull Request #13570 · objectstack-ai/objectstack · GitHub
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
7 changes: 7 additions & 0 deletions .changeset/rls-empty-membership-polarity-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/plugin-security': patch
---

Security fix (fail-closed tightening, #13552): the RLS emptied-membership deny guard is now polarity-aware. A policy whose pre-resolved membership set resolves EMPTY under a negated membership test (`not in` — e.g. `using: '!(owner in current_user.org_user_ids)'`) now compiles to the deny sentinel (zero rows) instead of flowing through. Before this fix `$in: []` under `$not` inverted to a constant-TRUE clause (`NOT (1 = 0)` on the SQL read-scope lowering), so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads. The guard now fires at any composition depth: `$not` wrapping the membership directly, `$not` arms nested inside `$or`/`$and`, `$not` over a composite containing the membership, and multi-level `$not` (odd polarity anywhere; the bare positive case is unchanged).

Blast radius, in plain terms: callers that were relying on that allow-all stop seeing rows. If a negated-membership policy was the only applicable policy and its membership set resolves empty (no active organization; an empty team/territory/blocked set), reads that previously returned EVERY row now return ZERO rows. The prior behaviour was a defect — an over-permissive read on a row-level-security scope — not a contract. If own-rows access must survive an emptied membership set, author it as a separate OR'd policy (e.g. `owner == current_user.id`): each policy's grant is compiled independently, and a sibling policy dropping does not take it down. A deliberate allow-all remains authorable as a literal `true` predicate. Unchanged: a NON-empty membership set under `not in` compiles and enforces exactly as before, and an emptied POSITIVE membership nested in `$or` (e.g. `owner in current_user.team_ids || owner == current_user.id`) still preserves the other arm's grant.
120 changes: 106 additions & 14 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,19 +66,105 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
});

/**
* Does this filter consist solely of an empty membership (`{ field: { $in: [] } }`)?
* Used to preserve the legacy "empty pre-resolved set drops the policy" semantics
* so the single-policy path fails closed via the deny sentinel rather than an
* always-false `$in: []`.
* Is this field constraint an emptied membership, and what CONSTANT does it
* evaluate to? `{ $in: [] }` is constant FALSE on every backend ("IN ()
* matches nothing"); `{ $nin: [] }` is constant TRUE ("NOT IN () excludes
* nothing"). Returns that constant, or `null` when the spec is not an emptied
* membership. The CEL pushdown compiler only ever emits `$in` (negation wraps
* in `$not` — cel-to-filter.ts), but this guard's contract is over the
* FilterCondition shape, so the intrinsically-negated `$nin` spelling is
* recognised too rather than left to fail open should a future lowering emit it.
*/
function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
const keys = Object.keys(filter);
if (keys.length !== 1) return false;
const inner = filter[keys[0]];
if (!inner || typeof inner !== 'object') return false;
const innerKeys = Object.keys(inner as Record<string, unknown>);
return innerKeys.length === 1 && Array.isArray((inner as Record<string, unknown>).$in)
&& ((inner as Record<string, unknown>).$in as unknown[]).length === 0;
function emptyMembershipConstantTruth(spec: unknown): boolean | null {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
const rec = spec as Record<string, unknown>;
if (Object.keys(rec).length !== 1) return null;
if (Array.isArray(rec.$in) && (rec.$in as unknown[]).length === 0) return false;
if (Array.isArray(rec.$nin) && (rec.$nin as unknown[]).length === 0) return true;
return null;
}

/**
* Does this compiled filter LEAN ON an emptied membership in a way that must
* drop the policy (→ the deny sentinel upstream)? Preserves the legacy "empty
* pre-resolved set drops the policy" semantics so the single-policy path fails
* closed via the deny sentinel rather than an always-false `$in: []`.
*
* [#13552] POLARITY-AWARE. "An emptied membership is safe because `$in: []`
* matches nothing" is a polarity-DEPENDENT claim, and the pushdown subset
* contains negation (`not in` is first-class: `!(x in y)` → `$not` wrapping
* `$in`). The pre-#13552 guard shape-matched the bare positive form only, so
* `{ $not: { f: { $in: [] } } }` — constant TRUE for every row — flowed
* through and compiled to ALLOW-ALL on the read scope. Two rules now hold:
*
* 1. An emptied membership whose EFFECTIVE polarity is inverted (odd number of
* enclosing `$not`s for `$in: []`; zero/even for `$nin: []`) is a
* constant-TRUE clause. Anywhere in the tree — wrapping directly, as an arm
* of `$or`/`$and` (nested to any depth), inside a multi-key implicit AND,
* or under multi-level `$not` — it means the membership restriction the
* author wrote has evaporated: as an `$or` arm the whole filter is
* allow-all; as an `$and` arm the restriction silently vanishes. Either way
* the policy is degenerate → drop it (fail closed), exactly as the emptied
* POSITIVE single-policy case already does.
* 2. The legacy positive case, generalised through double negation: a filter
* that consists solely of an emptied `$in` membership under an even
* (incl. zero) number of `$not` wrappers is constant FALSE as a whole —
* prefer the deny sentinel over an always-false filter (same zero rows,
* one recognisable shape).
*
* What deliberately does NOT fire, in both cases matching pre-#13552
* behaviour: a NON-empty membership under `$not` (the working `not in`
* feature), and an emptied POSITIVE membership nested in a composite — as an
* `$or` arm it is inert (`owner in <empty> || owner == me` must keep granting
* own rows), as an `$and` arm the filter is already constant FALSE (denies by
* itself). A deliberate allow-all stays authorable as literal `true` (compiles
* to `{}`), which never involves a membership set.
*/
export function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
// (Exported for direct shape tests — rls-empty-membership-polarity.test.ts;
// not part of the package surface: index.ts deliberately does not re-export it.)
return containsTautologicalEmptyMembership(filter, false) || isSolelyEmptyMembership(filter);
}

/** Rule 1 above: an emptied membership that is constant TRUE in effective polarity. */
function containsTautologicalEmptyMembership(node: unknown, negated: boolean): boolean {
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
const rec = node as Record<string, unknown>;
// A BARE operator object (an emptied membership with no field key) is not a
// shape the CEL lowering emits, but the evaluator answers it fail-closed
// (constant FALSE — unknown top-level operator), which a wrapping `$not`
// inverts to constant TRUE. The pre-#13552 guard already fired on
// `{ $not: { $in: [] } }`; never be weaker than the predecessor on any shape.
if (emptyMembershipConstantTruth(rec) !== null) return negated;
for (const [key, value] of Object.entries(rec)) {
if (key === '$not') {
if (containsTautologicalEmptyMembership(value, !negated)) return true;
} else if (key === '$and' || key === '$or') {
if (Array.isArray(value) && value.some((arm) => containsTautologicalEmptyMembership(arm, negated))) return true;
} else if (!key.startsWith('$')) {
const truth = emptyMembershipConstantTruth(value);
if (truth !== null && (negated ? !truth : truth)) return true;
}
}
return false;
}

/** Rule 2 above: solely an emptied `$in` membership under even (incl. zero) `$not`s. */
function isSolelyEmptyMembership(filter: Record<string, unknown>): boolean {
let node: Record<string, unknown> = filter;
let negations = 0;
for (;;) {
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0] !== '$not') break;
const inner = node.$not;
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return false;
node = inner as Record<string, unknown>;
negations++;
}
if (negations % 2 !== 0) return false; // odd polarity → rule 1's walk owns it
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0].startsWith('$')) return false;
return emptyMembershipConstantTruth(node[keys[0]]) === false;
}

/**
Expand DownExpand Up@@ -228,7 +314,10 @@ export class RLSCompiler {
* - an unresolved/absent `current_user.*` variable → `null` → fail closed
* (the "no active organization" path);
* - an empty pre-resolved membership set → `null` so the single-policy case
* yields the deny sentinel upstream rather than a permissive `$in: []`.
* yields the deny sentinel upstream rather than a permissive `$in: []` —
* in EITHER polarity ([#13552]): under a supported `not in` the emptied
* set would otherwise compile to a constant-TRUE `$not`/`$in: []` clause,
* i.e. allow-all, the exact fail-open this guard exists to prevent.
*/
compileExpression(
expression: string,
Expand All@@ -255,7 +344,10 @@ export class RLSCompiler {
// Parity: an empty pre-resolved membership (`field in current_user.<empty>`)
// compiles to `{ field: { $in: [] } }`. The legacy compiler dropped the
// policy in this case; preserve that so the deny sentinel (not a literal
// empty-IN) is what the single-policy path returns.
// empty-IN) is what the single-policy path returns. [#13552] The guard is
// polarity-aware: the same emptied set under a supported `not in`
// (`$not` wrapping, at any composition depth) is dropped too — otherwise
// it inverts to a constant-TRUE clause and the policy compiles ALLOW-ALL.
if (isEmptyMembershipFilter(result.filter as Record<string, unknown>)) return null;
return result.filter as Record<string, unknown>;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13552] The RLS emptied-membership deny guard must be POLARITY-AWARE.
*
* `isEmptyMembershipFilter` exists so a pre-resolved membership set that
* RESOLVES EMPTY drops the policy and the single-policy path fails closed via
* `RLS_DENY_FILTER`. Before #13552 it shape-matched the bare positive form
* (`{ f: { $in: [] } }`) only — but `not in` is a first-class pushdown shape
* (`!(x in y)` → `$not` wrapping `$in`, cel-to-filter.ts), and under `$not` an
* empty `$in: []` INVERTS from constant FALSE to constant TRUE: the policy the
* guard exists to turn into a DENY compiled to ALLOW-ALL on the read scope.
*
* This suite is the triage-mandated enumeration (issue #13552, grading
* comment): every negation/composition shape the guard must fire under, the
* shapes it must NOT fire under (the working `not in` feature; the legitimate
* positive-composite cases), and row-level evidence via the formula evaluator
* that the pre-fix filter really admitted everything while the deny sentinel
* admits nothing.
*/

import { describe, it, expect } from 'vitest';
import { isPushdownableCel, matchesFilterCondition } from '@objectstack/formula';
import { RLSCompiler, RLS_DENY_FILTER, isEmptyMembershipFilter } from './rls-compiler.js';

/** Five-row fixture: distinct owners, one null — mirrors the issue's measurement. */
const ROWS: Record<string, unknown>[] = [
{ id: 'r1', owner: 'u_me', status: 'open' },
{ id: 'r2', owner: 'u_other', status: 'open' },
{ id: 'r3', owner: 'u_third', status: 'closed' },
{ id: 'r4', owner: null, status: 'open' },
{ id: 'r5', owner: 'u_fourth', status: 'closed' },
];

const admitted = (filter: Record<string, unknown>): number =>
ROWS.filter((row) => matchesFilterCondition(row, filter as any)).length;

const policy = (using: string): any => ({ object: 'task', operation: 'select', using });

/** Context whose membership sets all RESOLVE EMPTY (the degenerate context). */
const EMPTY_CTX: any = {
userId: 'u_me',
tenantId: 'org-1',
positions: [],
org_user_ids: [],
rlsMembership: { team_ids: [], blocked_ids: [] },
};

describe('[#13552] emptied membership under negation — the guard must fire (deny sentinel)', () => {
const compiler = new RLSCompiler();

// ── The decisive control first: the DANGER is real at the evaluator ──────
it('evaluator control: `$not` over an empty `$in` is constant TRUE — 5 of 5 rows', () => {
// Independent of the guard: this pins WHY the guard must fire. The same
// inversion holds at the analytics lowering (`read-scope-sql.ts`:
// `$in: []` → `1 = 0`, and `NOT (1 = 0)` is TRUE for every row).
expect(admitted({ $not: { owner: { $in: [] } } })).toBe(5);
// …and the deny sentinel admits nothing.
expect(admitted(RLS_DENY_FILTER as Record<string, unknown>)).toBe(0);
});

// ── Enumeration: shapes the guard fires under, driven through authored CEL ──
const MUST_DENY: Array<[label: string, cel: string]> = [
['direct `$not` wrap — `not in` on an emptied set',
'!(owner in current_user.org_user_ids)'],
['`$not` nested inside `$or`',
'!(owner in current_user.team_ids) || owner == current_user.id'],
['`$not` nested inside `$and`',
'!(owner in current_user.blocked_ids) && status == "open"'],
['`$not` over a composite containing the emptied membership ($and)',
'!(owner in current_user.team_ids && status == "open")'],
['`$not` over a composite containing the emptied membership ($or)',
'!(owner in current_user.team_ids || status == "archived")'],
['multi-level `$not`, odd (triple)',
'!(!(!(owner in current_user.org_user_ids)))'],
['multi-level `$not`, even (double) — constant FALSE, sentinel preferred',
'!(!(owner in current_user.org_user_ids))'],
['bare positive (the pre-#13552 behaviour, preserved)',
'owner in current_user.org_user_ids'],
];

it('every enumerated shape is an AUTHORABLE pushdown shape (isPushdownableCel ok)', () => {
for (const [label, cel] of MUST_DENY) {
expect(isPushdownableCel(cel), `${label}: ${cel}`).toEqual({ ok: true });
}
});

for (const [label, cel] of MUST_DENY) {
it(`${label} → RLS_DENY_FILTER (zero rows)`, () => {
const filter = compiler.compileFilter([policy(cel)], EMPTY_CTX);
expect(filter, `policy: ${cel}`).toEqual(RLS_DENY_FILTER);
// Row-level: the compiled scope admits NOTHING. Before the #13552 fix
// the negated shapes compiled to a constant-TRUE filter admitting 5/5.
expect(admitted(filter as Record<string, unknown>), `policy: ${cel}`).toBe(0);
});
}

// ── Shapes the guard must NOT fire under ─────────────────────────────────
it('NON-empty membership under `$not` keeps working — the `not in` feature', () => {
const ctx: any = { userId: 'u_me', tenantId: 'org-1', positions: [], org_user_ids: ['u_other', 'u_third'] };
const filter = compiler.compileFilter([policy('!(owner in current_user.org_user_ids)')], ctx);
expect(filter).toEqual({ $not: { owner: { $in: ['u_other', 'u_third'] } } });
// r1 (u_me), r4 (null owner — $in over null is false, $not inverts), r5 (u_fourth).
expect(admitted(filter as Record<string, unknown>)).toBe(3);
});

it('emptied POSITIVE membership as an `$or` arm stays inert — own rows keep flowing', () => {
const filter = compiler.compileFilter(
[policy('owner in current_user.team_ids || owner == current_user.id')],
EMPTY_CTX,
);
expect(filter).toEqual({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(admitted(filter as Record<string, unknown>)).toBe(1); // r1 only
});

it('deliberate allow-all stays authorable as literal `true`', () => {
const filter = compiler.compileFilter([policy('true')], EMPTY_CTX);
expect(filter).toEqual({});
expect(admitted(filter as Record<string, unknown>)).toBe(5);
});

it('multi-policy: a dropped negated-empty policy removes only its grant — the sibling still grants', () => {
const filter = compiler.compileFilter(
[policy('!(owner in current_user.org_user_ids)'), policy('owner == current_user.id')],
EMPTY_CTX,
);
// The degenerate policy contributes nothing; the sibling's grant survives.
expect(filter).toEqual({ owner: 'u_me' });
expect(admitted(filter as Record<string, unknown>)).toBe(1);
});
});

describe('[#13552] guard shape tests — FilterCondition forms CEL cannot author', () => {
// The guard's contract is over the compiled FilterCondition, which is wider
// than what cel-to-filter emits today. Direct shape pins so the defensive
// arms are not phantom checks.
const fires = (f: Record<string, unknown>) => isEmptyMembershipFilter(f);

it('multi-key implicit AND under `$not` (constant TRUE by De Morgan) fires', () => {
expect(fires({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(true);
// Evaluator agreement: NOT(FALSE AND …) admits everything.
expect(admitted({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(5);
});

it('bare `{ $not: { $in: [] } }` still fires — pre-#13552 guard parity', () => {
expect(fires({ $not: { $in: [] } })).toBe(true);
});

it('empty `$nin` (intrinsically constant TRUE) fires at positive polarity', () => {
// Not emitted by cel-to-filter today; recognised so a future lowering
// cannot fail open through the same blind spot ($nin: [] → `1 = 1` at the
// read-scope SQL lowering).
expect(fires({ owner: { $nin: [] } })).toBe(true);
expect(fires({ $or: [{ owner: { $nin: [] } }, { status: 'open' }] })).toBe(true);
});

it('non-membership shapes do not fire', () => {
expect(fires({ owner: 'u_me' })).toBe(false);
expect(fires({ $not: { owner: { $in: ['a'] } } })).toBe(false);
expect(fires({ $not: { owner: { $null: true } } })).toBe(false);
expect(fires({ $and: [{ owner: { $in: [] } }, { status: 'open' }] })).toBe(false); // constant FALSE — denies by itself
expect(fires({})).toBe(false);
});

it('even-`$not` emptied membership NESTED in a composite stays inert (constant FALSE arm)', () => {
expect(fires({ $or: [{ $not: { $not: { owner: { $in: [] } } } }, { owner: 'u_me' }] })).toBe(false);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware by os-steve · Pull Request #13570 · objectstack-ai/objectstack · GitHub
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
7 changes: 7 additions & 0 deletions .changeset/rls-empty-membership-polarity-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/plugin-security': patch
---

Security fix (fail-closed tightening, #13552): the RLS emptied-membership deny guard is now polarity-aware. A policy whose pre-resolved membership set resolves EMPTY under a negated membership test (`not in` — e.g. `using: '!(owner in current_user.org_user_ids)'`) now compiles to the deny sentinel (zero rows) instead of flowing through. Before this fix `$in: []` under `$not` inverted to a constant-TRUE clause (`NOT (1 = 0)` on the SQL read-scope lowering), so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads. The guard now fires at any composition depth: `$not` wrapping the membership directly, `$not` arms nested inside `$or`/`$and`, `$not` over a composite containing the membership, and multi-level `$not` (odd polarity anywhere; the bare positive case is unchanged).

Blast radius, in plain terms: callers that were relying on that allow-all stop seeing rows. If a negated-membership policy was the only applicable policy and its membership set resolves empty (no active organization; an empty team/territory/blocked set), reads that previously returned EVERY row now return ZERO rows. The prior behaviour was a defect — an over-permissive read on a row-level-security scope — not a contract. If own-rows access must survive an emptied membership set, author it as a separate OR'd policy (e.g. `owner == current_user.id`): each policy's grant is compiled independently, and a sibling policy dropping does not take it down. A deliberate allow-all remains authorable as a literal `true` predicate. Unchanged: a NON-empty membership set under `not in` compiles and enforces exactly as before, and an emptied POSITIVE membership nested in `$or` (e.g. `owner in current_user.team_ids || owner == current_user.id`) still preserves the other arm's grant.
120 changes: 106 additions & 14 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,19 +66,105 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
});

/**
* Does this filter consist solely of an empty membership (`{ field: { $in: [] } }`)?
* Used to preserve the legacy "empty pre-resolved set drops the policy" semantics
* so the single-policy path fails closed via the deny sentinel rather than an
* always-false `$in: []`.
* Is this field constraint an emptied membership, and what CONSTANT does it
* evaluate to? `{ $in: [] }` is constant FALSE on every backend ("IN ()
* matches nothing"); `{ $nin: [] }` is constant TRUE ("NOT IN () excludes
* nothing"). Returns that constant, or `null` when the spec is not an emptied
* membership. The CEL pushdown compiler only ever emits `$in` (negation wraps
* in `$not` — cel-to-filter.ts), but this guard's contract is over the
* FilterCondition shape, so the intrinsically-negated `$nin` spelling is
* recognised too rather than left to fail open should a future lowering emit it.
*/
function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
const keys = Object.keys(filter);
if (keys.length !== 1) return false;
const inner = filter[keys[0]];
if (!inner || typeof inner !== 'object') return false;
const innerKeys = Object.keys(inner as Record<string, unknown>);
return innerKeys.length === 1 && Array.isArray((inner as Record<string, unknown>).$in)
&& ((inner as Record<string, unknown>).$in as unknown[]).length === 0;
function emptyMembershipConstantTruth(spec: unknown): boolean | null {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
const rec = spec as Record<string, unknown>;
if (Object.keys(rec).length !== 1) return null;
if (Array.isArray(rec.$in) && (rec.$in as unknown[]).length === 0) return false;
if (Array.isArray(rec.$nin) && (rec.$nin as unknown[]).length === 0) return true;
return null;
}

/**
* Does this compiled filter LEAN ON an emptied membership in a way that must
* drop the policy (→ the deny sentinel upstream)? Preserves the legacy "empty
* pre-resolved set drops the policy" semantics so the single-policy path fails
* closed via the deny sentinel rather than an always-false `$in: []`.
*
* [#13552] POLARITY-AWARE. "An emptied membership is safe because `$in: []`
* matches nothing" is a polarity-DEPENDENT claim, and the pushdown subset
* contains negation (`not in` is first-class: `!(x in y)` → `$not` wrapping
* `$in`). The pre-#13552 guard shape-matched the bare positive form only, so
* `{ $not: { f: { $in: [] } } }` — constant TRUE for every row — flowed
* through and compiled to ALLOW-ALL on the read scope. Two rules now hold:
*
* 1. An emptied membership whose EFFECTIVE polarity is inverted (odd number of
* enclosing `$not`s for `$in: []`; zero/even for `$nin: []`) is a
* constant-TRUE clause. Anywhere in the tree — wrapping directly, as an arm
* of `$or`/`$and` (nested to any depth), inside a multi-key implicit AND,
* or under multi-level `$not` — it means the membership restriction the
* author wrote has evaporated: as an `$or` arm the whole filter is
* allow-all; as an `$and` arm the restriction silently vanishes. Either way
* the policy is degenerate → drop it (fail closed), exactly as the emptied
* POSITIVE single-policy case already does.
* 2. The legacy positive case, generalised through double negation: a filter
* that consists solely of an emptied `$in` membership under an even
* (incl. zero) number of `$not` wrappers is constant FALSE as a whole —
* prefer the deny sentinel over an always-false filter (same zero rows,
* one recognisable shape).
*
* What deliberately does NOT fire, in both cases matching pre-#13552
* behaviour: a NON-empty membership under `$not` (the working `not in`
* feature), and an emptied POSITIVE membership nested in a composite — as an
* `$or` arm it is inert (`owner in <empty> || owner == me` must keep granting
* own rows), as an `$and` arm the filter is already constant FALSE (denies by
* itself). A deliberate allow-all stays authorable as literal `true` (compiles
* to `{}`), which never involves a membership set.
*/
export function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
// (Exported for direct shape tests — rls-empty-membership-polarity.test.ts;
// not part of the package surface: index.ts deliberately does not re-export it.)
return containsTautologicalEmptyMembership(filter, false) || isSolelyEmptyMembership(filter);
}

/** Rule 1 above: an emptied membership that is constant TRUE in effective polarity. */
function containsTautologicalEmptyMembership(node: unknown, negated: boolean): boolean {
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
const rec = node as Record<string, unknown>;
// A BARE operator object (an emptied membership with no field key) is not a
// shape the CEL lowering emits, but the evaluator answers it fail-closed
// (constant FALSE — unknown top-level operator), which a wrapping `$not`
// inverts to constant TRUE. The pre-#13552 guard already fired on
// `{ $not: { $in: [] } }`; never be weaker than the predecessor on any shape.
if (emptyMembershipConstantTruth(rec) !== null) return negated;
for (const [key, value] of Object.entries(rec)) {
if (key === '$not') {
if (containsTautologicalEmptyMembership(value, !negated)) return true;
} else if (key === '$and' || key === '$or') {
if (Array.isArray(value) && value.some((arm) => containsTautologicalEmptyMembership(arm, negated))) return true;
} else if (!key.startsWith('$')) {
const truth = emptyMembershipConstantTruth(value);
if (truth !== null && (negated ? !truth : truth)) return true;
}
}
return false;
}

/** Rule 2 above: solely an emptied `$in` membership under even (incl. zero) `$not`s. */
function isSolelyEmptyMembership(filter: Record<string, unknown>): boolean {
let node: Record<string, unknown> = filter;
let negations = 0;
for (;;) {
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0] !== '$not') break;
const inner = node.$not;
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return false;
node = inner as Record<string, unknown>;
negations++;
}
if (negations % 2 !== 0) return false; // odd polarity → rule 1's walk owns it
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0].startsWith('$')) return false;
return emptyMembershipConstantTruth(node[keys[0]]) === false;
}

/**
Expand DownExpand Up@@ -228,7 +314,10 @@ export class RLSCompiler {
* - an unresolved/absent `current_user.*` variable → `null` → fail closed
* (the "no active organization" path);
* - an empty pre-resolved membership set → `null` so the single-policy case
* yields the deny sentinel upstream rather than a permissive `$in: []`.
* yields the deny sentinel upstream rather than a permissive `$in: []` —
* in EITHER polarity ([#13552]): under a supported `not in` the emptied
* set would otherwise compile to a constant-TRUE `$not`/`$in: []` clause,
* i.e. allow-all, the exact fail-open this guard exists to prevent.
*/
compileExpression(
expression: string,
Expand All@@ -255,7 +344,10 @@ export class RLSCompiler {
// Parity: an empty pre-resolved membership (`field in current_user.<empty>`)
// compiles to `{ field: { $in: [] } }`. The legacy compiler dropped the
// policy in this case; preserve that so the deny sentinel (not a literal
// empty-IN) is what the single-policy path returns.
// empty-IN) is what the single-policy path returns. [#13552] The guard is
// polarity-aware: the same emptied set under a supported `not in`
// (`$not` wrapping, at any composition depth) is dropped too — otherwise
// it inverts to a constant-TRUE clause and the policy compiles ALLOW-ALL.
if (isEmptyMembershipFilter(result.filter as Record<string, unknown>)) return null;
return result.filter as Record<string, unknown>;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13552] The RLS emptied-membership deny guard must be POLARITY-AWARE.
*
* `isEmptyMembershipFilter` exists so a pre-resolved membership set that
* RESOLVES EMPTY drops the policy and the single-policy path fails closed via
* `RLS_DENY_FILTER`. Before #13552 it shape-matched the bare positive form
* (`{ f: { $in: [] } }`) only — but `not in` is a first-class pushdown shape
* (`!(x in y)` → `$not` wrapping `$in`, cel-to-filter.ts), and under `$not` an
* empty `$in: []` INVERTS from constant FALSE to constant TRUE: the policy the
* guard exists to turn into a DENY compiled to ALLOW-ALL on the read scope.
*
* This suite is the triage-mandated enumeration (issue #13552, grading
* comment): every negation/composition shape the guard must fire under, the
* shapes it must NOT fire under (the working `not in` feature; the legitimate
* positive-composite cases), and row-level evidence via the formula evaluator
* that the pre-fix filter really admitted everything while the deny sentinel
* admits nothing.
*/

import { describe, it, expect } from 'vitest';
import { isPushdownableCel, matchesFilterCondition } from '@objectstack/formula';
import { RLSCompiler, RLS_DENY_FILTER, isEmptyMembershipFilter } from './rls-compiler.js';

/** Five-row fixture: distinct owners, one null — mirrors the issue's measurement. */
const ROWS: Record<string, unknown>[] = [
{ id: 'r1', owner: 'u_me', status: 'open' },
{ id: 'r2', owner: 'u_other', status: 'open' },
{ id: 'r3', owner: 'u_third', status: 'closed' },
{ id: 'r4', owner: null, status: 'open' },
{ id: 'r5', owner: 'u_fourth', status: 'closed' },
];

const admitted = (filter: Record<string, unknown>): number =>
ROWS.filter((row) => matchesFilterCondition(row, filter as any)).length;

const policy = (using: string): any => ({ object: 'task', operation: 'select', using });

/** Context whose membership sets all RESOLVE EMPTY (the degenerate context). */
const EMPTY_CTX: any = {
userId: 'u_me',
tenantId: 'org-1',
positions: [],
org_user_ids: [],
rlsMembership: { team_ids: [], blocked_ids: [] },
};

describe('[#13552] emptied membership under negation — the guard must fire (deny sentinel)', () => {
const compiler = new RLSCompiler();

// ── The decisive control first: the DANGER is real at the evaluator ──────
it('evaluator control: `$not` over an empty `$in` is constant TRUE — 5 of 5 rows', () => {
// Independent of the guard: this pins WHY the guard must fire. The same
// inversion holds at the analytics lowering (`read-scope-sql.ts`:
// `$in: []` → `1 = 0`, and `NOT (1 = 0)` is TRUE for every row).
expect(admitted({ $not: { owner: { $in: [] } } })).toBe(5);
// …and the deny sentinel admits nothing.
expect(admitted(RLS_DENY_FILTER as Record<string, unknown>)).toBe(0);
});

// ── Enumeration: shapes the guard fires under, driven through authored CEL ──
const MUST_DENY: Array<[label: string, cel: string]> = [
['direct `$not` wrap — `not in` on an emptied set',
'!(owner in current_user.org_user_ids)'],
['`$not` nested inside `$or`',
'!(owner in current_user.team_ids) || owner == current_user.id'],
['`$not` nested inside `$and`',
'!(owner in current_user.blocked_ids) && status == "open"'],
['`$not` over a composite containing the emptied membership ($and)',
'!(owner in current_user.team_ids && status == "open")'],
['`$not` over a composite containing the emptied membership ($or)',
'!(owner in current_user.team_ids || status == "archived")'],
['multi-level `$not`, odd (triple)',
'!(!(!(owner in current_user.org_user_ids)))'],
['multi-level `$not`, even (double) — constant FALSE, sentinel preferred',
'!(!(owner in current_user.org_user_ids))'],
['bare positive (the pre-#13552 behaviour, preserved)',
'owner in current_user.org_user_ids'],
];

it('every enumerated shape is an AUTHORABLE pushdown shape (isPushdownableCel ok)', () => {
for (const [label, cel] of MUST_DENY) {
expect(isPushdownableCel(cel), `${label}: ${cel}`).toEqual({ ok: true });
}
});

for (const [label, cel] of MUST_DENY) {
it(`${label} → RLS_DENY_FILTER (zero rows)`, () => {
const filter = compiler.compileFilter([policy(cel)], EMPTY_CTX);
expect(filter, `policy: ${cel}`).toEqual(RLS_DENY_FILTER);
// Row-level: the compiled scope admits NOTHING. Before the #13552 fix
// the negated shapes compiled to a constant-TRUE filter admitting 5/5.
expect(admitted(filter as Record<string, unknown>), `policy: ${cel}`).toBe(0);
});
}

// ── Shapes the guard must NOT fire under ─────────────────────────────────
it('NON-empty membership under `$not` keeps working — the `not in` feature', () => {
const ctx: any = { userId: 'u_me', tenantId: 'org-1', positions: [], org_user_ids: ['u_other', 'u_third'] };
const filter = compiler.compileFilter([policy('!(owner in current_user.org_user_ids)')], ctx);
expect(filter).toEqual({ $not: { owner: { $in: ['u_other', 'u_third'] } } });
// r1 (u_me), r4 (null owner — $in over null is false, $not inverts), r5 (u_fourth).
expect(admitted(filter as Record<string, unknown>)).toBe(3);
});

it('emptied POSITIVE membership as an `$or` arm stays inert — own rows keep flowing', () => {
const filter = compiler.compileFilter(
[policy('owner in current_user.team_ids || owner == current_user.id')],
EMPTY_CTX,
);
expect(filter).toEqual({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(admitted(filter as Record<string, unknown>)).toBe(1); // r1 only
});

it('deliberate allow-all stays authorable as literal `true`', () => {
const filter = compiler.compileFilter([policy('true')], EMPTY_CTX);
expect(filter).toEqual({});
expect(admitted(filter as Record<string, unknown>)).toBe(5);
});

it('multi-policy: a dropped negated-empty policy removes only its grant — the sibling still grants', () => {
const filter = compiler.compileFilter(
[policy('!(owner in current_user.org_user_ids)'), policy('owner == current_user.id')],
EMPTY_CTX,
);
// The degenerate policy contributes nothing; the sibling's grant survives.
expect(filter).toEqual({ owner: 'u_me' });
expect(admitted(filter as Record<string, unknown>)).toBe(1);
});
});

describe('[#13552] guard shape tests — FilterCondition forms CEL cannot author', () => {
// The guard's contract is over the compiled FilterCondition, which is wider
// than what cel-to-filter emits today. Direct shape pins so the defensive
// arms are not phantom checks.
const fires = (f: Record<string, unknown>) => isEmptyMembershipFilter(f);

it('multi-key implicit AND under `$not` (constant TRUE by De Morgan) fires', () => {
expect(fires({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(true);
// Evaluator agreement: NOT(FALSE AND …) admits everything.
expect(admitted({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(5);
});

it('bare `{ $not: { $in: [] } }` still fires — pre-#13552 guard parity', () => {
expect(fires({ $not: { $in: [] } })).toBe(true);
});

it('empty `$nin` (intrinsically constant TRUE) fires at positive polarity', () => {
// Not emitted by cel-to-filter today; recognised so a future lowering
// cannot fail open through the same blind spot ($nin: [] → `1 = 1` at the
// read-scope SQL lowering).
expect(fires({ owner: { $nin: [] } })).toBe(true);
expect(fires({ $or: [{ owner: { $nin: [] } }, { status: 'open' }] })).toBe(true);
});

it('non-membership shapes do not fire', () => {
expect(fires({ owner: 'u_me' })).toBe(false);
expect(fires({ $not: { owner: { $in: ['a'] } } })).toBe(false);
expect(fires({ $not: { owner: { $null: true } } })).toBe(false);
expect(fires({ $and: [{ owner: { $in: [] } }, { status: 'open' }] })).toBe(false); // constant FALSE — denies by itself
expect(fires({})).toBe(false);
});

it('even-`$not` emptied membership NESTED in a composite stays inert (constant FALSE arm)', () => {
expect(fires({ $or: [{ $not: { $not: { owner: { $in: [] } } } }, { owner: 'u_me' }] })).toBe(false);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware by os-steve · Pull Request #13570 · objectstack-ai/objectstack · GitHub
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
7 changes: 7 additions & 0 deletions .changeset/rls-empty-membership-polarity-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/plugin-security': patch
---

Security fix (fail-closed tightening, #13552): the RLS emptied-membership deny guard is now polarity-aware. A policy whose pre-resolved membership set resolves EMPTY under a negated membership test (`not in` — e.g. `using: '!(owner in current_user.org_user_ids)'`) now compiles to the deny sentinel (zero rows) instead of flowing through. Before this fix `$in: []` under `$not` inverted to a constant-TRUE clause (`NOT (1 = 0)` on the SQL read-scope lowering), so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads. The guard now fires at any composition depth: `$not` wrapping the membership directly, `$not` arms nested inside `$or`/`$and`, `$not` over a composite containing the membership, and multi-level `$not` (odd polarity anywhere; the bare positive case is unchanged).

Blast radius, in plain terms: callers that were relying on that allow-all stop seeing rows. If a negated-membership policy was the only applicable policy and its membership set resolves empty (no active organization; an empty team/territory/blocked set), reads that previously returned EVERY row now return ZERO rows. The prior behaviour was a defect — an over-permissive read on a row-level-security scope — not a contract. If own-rows access must survive an emptied membership set, author it as a separate OR'd policy (e.g. `owner == current_user.id`): each policy's grant is compiled independently, and a sibling policy dropping does not take it down. A deliberate allow-all remains authorable as a literal `true` predicate. Unchanged: a NON-empty membership set under `not in` compiles and enforces exactly as before, and an emptied POSITIVE membership nested in `$or` (e.g. `owner in current_user.team_ids || owner == current_user.id`) still preserves the other arm's grant.
120 changes: 106 additions & 14 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,19 +66,105 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
});

/**
* Does this filter consist solely of an empty membership (`{ field: { $in: [] } }`)?
* Used to preserve the legacy "empty pre-resolved set drops the policy" semantics
* so the single-policy path fails closed via the deny sentinel rather than an
* always-false `$in: []`.
* Is this field constraint an emptied membership, and what CONSTANT does it
* evaluate to? `{ $in: [] }` is constant FALSE on every backend ("IN ()
* matches nothing"); `{ $nin: [] }` is constant TRUE ("NOT IN () excludes
* nothing"). Returns that constant, or `null` when the spec is not an emptied
* membership. The CEL pushdown compiler only ever emits `$in` (negation wraps
* in `$not` — cel-to-filter.ts), but this guard's contract is over the
* FilterCondition shape, so the intrinsically-negated `$nin` spelling is
* recognised too rather than left to fail open should a future lowering emit it.
*/
function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
const keys = Object.keys(filter);
if (keys.length !== 1) return false;
const inner = filter[keys[0]];
if (!inner || typeof inner !== 'object') return false;
const innerKeys = Object.keys(inner as Record<string, unknown>);
return innerKeys.length === 1 && Array.isArray((inner as Record<string, unknown>).$in)
&& ((inner as Record<string, unknown>).$in as unknown[]).length === 0;
function emptyMembershipConstantTruth(spec: unknown): boolean | null {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
const rec = spec as Record<string, unknown>;
if (Object.keys(rec).length !== 1) return null;
if (Array.isArray(rec.$in) && (rec.$in as unknown[]).length === 0) return false;
if (Array.isArray(rec.$nin) && (rec.$nin as unknown[]).length === 0) return true;
return null;
}

/**
* Does this compiled filter LEAN ON an emptied membership in a way that must
* drop the policy (→ the deny sentinel upstream)? Preserves the legacy "empty
* pre-resolved set drops the policy" semantics so the single-policy path fails
* closed via the deny sentinel rather than an always-false `$in: []`.
*
* [#13552] POLARITY-AWARE. "An emptied membership is safe because `$in: []`
* matches nothing" is a polarity-DEPENDENT claim, and the pushdown subset
* contains negation (`not in` is first-class: `!(x in y)` → `$not` wrapping
* `$in`). The pre-#13552 guard shape-matched the bare positive form only, so
* `{ $not: { f: { $in: [] } } }` — constant TRUE for every row — flowed
* through and compiled to ALLOW-ALL on the read scope. Two rules now hold:
*
* 1. An emptied membership whose EFFECTIVE polarity is inverted (odd number of
* enclosing `$not`s for `$in: []`; zero/even for `$nin: []`) is a
* constant-TRUE clause. Anywhere in the tree — wrapping directly, as an arm
* of `$or`/`$and` (nested to any depth), inside a multi-key implicit AND,
* or under multi-level `$not` — it means the membership restriction the
* author wrote has evaporated: as an `$or` arm the whole filter is
* allow-all; as an `$and` arm the restriction silently vanishes. Either way
* the policy is degenerate → drop it (fail closed), exactly as the emptied
* POSITIVE single-policy case already does.
* 2. The legacy positive case, generalised through double negation: a filter
* that consists solely of an emptied `$in` membership under an even
* (incl. zero) number of `$not` wrappers is constant FALSE as a whole —
* prefer the deny sentinel over an always-false filter (same zero rows,
* one recognisable shape).
*
* What deliberately does NOT fire, in both cases matching pre-#13552
* behaviour: a NON-empty membership under `$not` (the working `not in`
* feature), and an emptied POSITIVE membership nested in a composite — as an
* `$or` arm it is inert (`owner in <empty> || owner == me` must keep granting
* own rows), as an `$and` arm the filter is already constant FALSE (denies by
* itself). A deliberate allow-all stays authorable as literal `true` (compiles
* to `{}`), which never involves a membership set.
*/
export function isEmptyMembershipFilter(filter: Record<string, unknown>): boolean {
// (Exported for direct shape tests — rls-empty-membership-polarity.test.ts;
// not part of the package surface: index.ts deliberately does not re-export it.)
return containsTautologicalEmptyMembership(filter, false) || isSolelyEmptyMembership(filter);
}

/** Rule 1 above: an emptied membership that is constant TRUE in effective polarity. */
function containsTautologicalEmptyMembership(node: unknown, negated: boolean): boolean {
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
const rec = node as Record<string, unknown>;
// A BARE operator object (an emptied membership with no field key) is not a
// shape the CEL lowering emits, but the evaluator answers it fail-closed
// (constant FALSE — unknown top-level operator), which a wrapping `$not`
// inverts to constant TRUE. The pre-#13552 guard already fired on
// `{ $not: { $in: [] } }`; never be weaker than the predecessor on any shape.
if (emptyMembershipConstantTruth(rec) !== null) return negated;
for (const [key, value] of Object.entries(rec)) {
if (key === '$not') {
if (containsTautologicalEmptyMembership(value, !negated)) return true;
} else if (key === '$and' || key === '$or') {
if (Array.isArray(value) && value.some((arm) => containsTautologicalEmptyMembership(arm, negated))) return true;
} else if (!key.startsWith('$')) {
const truth = emptyMembershipConstantTruth(value);
if (truth !== null && (negated ? !truth : truth)) return true;
}
}
return false;
}

/** Rule 2 above: solely an emptied `$in` membership under even (incl. zero) `$not`s. */
function isSolelyEmptyMembership(filter: Record<string, unknown>): boolean {
let node: Record<string, unknown> = filter;
let negations = 0;
for (;;) {
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0] !== '$not') break;
const inner = node.$not;
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return false;
node = inner as Record<string, unknown>;
negations++;
}
if (negations % 2 !== 0) return false; // odd polarity → rule 1's walk owns it
const keys = Object.keys(node);
if (keys.length !== 1 || keys[0].startsWith('$')) return false;
return emptyMembershipConstantTruth(node[keys[0]]) === false;
}

/**
Expand DownExpand Up@@ -228,7 +314,10 @@ export class RLSCompiler {
* - an unresolved/absent `current_user.*` variable → `null` → fail closed
* (the "no active organization" path);
* - an empty pre-resolved membership set → `null` so the single-policy case
* yields the deny sentinel upstream rather than a permissive `$in: []`.
* yields the deny sentinel upstream rather than a permissive `$in: []` —
* in EITHER polarity ([#13552]): under a supported `not in` the emptied
* set would otherwise compile to a constant-TRUE `$not`/`$in: []` clause,
* i.e. allow-all, the exact fail-open this guard exists to prevent.
*/
compileExpression(
expression: string,
Expand All@@ -255,7 +344,10 @@ export class RLSCompiler {
// Parity: an empty pre-resolved membership (`field in current_user.<empty>`)
// compiles to `{ field: { $in: [] } }`. The legacy compiler dropped the
// policy in this case; preserve that so the deny sentinel (not a literal
// empty-IN) is what the single-policy path returns.
// empty-IN) is what the single-policy path returns. [#13552] The guard is
// polarity-aware: the same emptied set under a supported `not in`
// (`$not` wrapping, at any composition depth) is dropped too — otherwise
// it inverts to a constant-TRUE clause and the policy compiles ALLOW-ALL.
if (isEmptyMembershipFilter(result.filter as Record<string, unknown>)) return null;
return result.filter as Record<string, unknown>;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13552] The RLS emptied-membership deny guard must be POLARITY-AWARE.
*
* `isEmptyMembershipFilter` exists so a pre-resolved membership set that
* RESOLVES EMPTY drops the policy and the single-policy path fails closed via
* `RLS_DENY_FILTER`. Before #13552 it shape-matched the bare positive form
* (`{ f: { $in: [] } }`) only — but `not in` is a first-class pushdown shape
* (`!(x in y)` → `$not` wrapping `$in`, cel-to-filter.ts), and under `$not` an
* empty `$in: []` INVERTS from constant FALSE to constant TRUE: the policy the
* guard exists to turn into a DENY compiled to ALLOW-ALL on the read scope.
*
* This suite is the triage-mandated enumeration (issue #13552, grading
* comment): every negation/composition shape the guard must fire under, the
* shapes it must NOT fire under (the working `not in` feature; the legitimate
* positive-composite cases), and row-level evidence via the formula evaluator
* that the pre-fix filter really admitted everything while the deny sentinel
* admits nothing.
*/

import { describe, it, expect } from 'vitest';
import { isPushdownableCel, matchesFilterCondition } from '@objectstack/formula';
import { RLSCompiler, RLS_DENY_FILTER, isEmptyMembershipFilter } from './rls-compiler.js';

/** Five-row fixture: distinct owners, one null — mirrors the issue's measurement. */
const ROWS: Record<string, unknown>[] = [
{ id: 'r1', owner: 'u_me', status: 'open' },
{ id: 'r2', owner: 'u_other', status: 'open' },
{ id: 'r3', owner: 'u_third', status: 'closed' },
{ id: 'r4', owner: null, status: 'open' },
{ id: 'r5', owner: 'u_fourth', status: 'closed' },
];

const admitted = (filter: Record<string, unknown>): number =>
ROWS.filter((row) => matchesFilterCondition(row, filter as any)).length;

const policy = (using: string): any => ({ object: 'task', operation: 'select', using });

/** Context whose membership sets all RESOLVE EMPTY (the degenerate context). */
const EMPTY_CTX: any = {
userId: 'u_me',
tenantId: 'org-1',
positions: [],
org_user_ids: [],
rlsMembership: { team_ids: [], blocked_ids: [] },
};

describe('[#13552] emptied membership under negation — the guard must fire (deny sentinel)', () => {
const compiler = new RLSCompiler();

// ── The decisive control first: the DANGER is real at the evaluator ──────
it('evaluator control: `$not` over an empty `$in` is constant TRUE — 5 of 5 rows', () => {
// Independent of the guard: this pins WHY the guard must fire. The same
// inversion holds at the analytics lowering (`read-scope-sql.ts`:
// `$in: []` → `1 = 0`, and `NOT (1 = 0)` is TRUE for every row).
expect(admitted({ $not: { owner: { $in: [] } } })).toBe(5);
// …and the deny sentinel admits nothing.
expect(admitted(RLS_DENY_FILTER as Record<string, unknown>)).toBe(0);
});

// ── Enumeration: shapes the guard fires under, driven through authored CEL ──
const MUST_DENY: Array<[label: string, cel: string]> = [
['direct `$not` wrap — `not in` on an emptied set',
'!(owner in current_user.org_user_ids)'],
['`$not` nested inside `$or`',
'!(owner in current_user.team_ids) || owner == current_user.id'],
['`$not` nested inside `$and`',
'!(owner in current_user.blocked_ids) && status == "open"'],
['`$not` over a composite containing the emptied membership ($and)',
'!(owner in current_user.team_ids && status == "open")'],
['`$not` over a composite containing the emptied membership ($or)',
'!(owner in current_user.team_ids || status == "archived")'],
['multi-level `$not`, odd (triple)',
'!(!(!(owner in current_user.org_user_ids)))'],
['multi-level `$not`, even (double) — constant FALSE, sentinel preferred',
'!(!(owner in current_user.org_user_ids))'],
['bare positive (the pre-#13552 behaviour, preserved)',
'owner in current_user.org_user_ids'],
];

it('every enumerated shape is an AUTHORABLE pushdown shape (isPushdownableCel ok)', () => {
for (const [label, cel] of MUST_DENY) {
expect(isPushdownableCel(cel), `${label}: ${cel}`).toEqual({ ok: true });
}
});

for (const [label, cel] of MUST_DENY) {
it(`${label} → RLS_DENY_FILTER (zero rows)`, () => {
const filter = compiler.compileFilter([policy(cel)], EMPTY_CTX);
expect(filter, `policy: ${cel}`).toEqual(RLS_DENY_FILTER);
// Row-level: the compiled scope admits NOTHING. Before the #13552 fix
// the negated shapes compiled to a constant-TRUE filter admitting 5/5.
expect(admitted(filter as Record<string, unknown>), `policy: ${cel}`).toBe(0);
});
}

// ── Shapes the guard must NOT fire under ─────────────────────────────────
it('NON-empty membership under `$not` keeps working — the `not in` feature', () => {
const ctx: any = { userId: 'u_me', tenantId: 'org-1', positions: [], org_user_ids: ['u_other', 'u_third'] };
const filter = compiler.compileFilter([policy('!(owner in current_user.org_user_ids)')], ctx);
expect(filter).toEqual({ $not: { owner: { $in: ['u_other', 'u_third'] } } });
// r1 (u_me), r4 (null owner — $in over null is false, $not inverts), r5 (u_fourth).
expect(admitted(filter as Record<string, unknown>)).toBe(3);
});

it('emptied POSITIVE membership as an `$or` arm stays inert — own rows keep flowing', () => {
const filter = compiler.compileFilter(
[policy('owner in current_user.team_ids || owner == current_user.id')],
EMPTY_CTX,
);
expect(filter).toEqual({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(admitted(filter as Record<string, unknown>)).toBe(1); // r1 only
});

it('deliberate allow-all stays authorable as literal `true`', () => {
const filter = compiler.compileFilter([policy('true')], EMPTY_CTX);
expect(filter).toEqual({});
expect(admitted(filter as Record<string, unknown>)).toBe(5);
});

it('multi-policy: a dropped negated-empty policy removes only its grant — the sibling still grants', () => {
const filter = compiler.compileFilter(
[policy('!(owner in current_user.org_user_ids)'), policy('owner == current_user.id')],
EMPTY_CTX,
);
// The degenerate policy contributes nothing; the sibling's grant survives.
expect(filter).toEqual({ owner: 'u_me' });
expect(admitted(filter as Record<string, unknown>)).toBe(1);
});
});

describe('[#13552] guard shape tests — FilterCondition forms CEL cannot author', () => {
// The guard's contract is over the compiled FilterCondition, which is wider
// than what cel-to-filter emits today. Direct shape pins so the defensive
// arms are not phantom checks.
const fires = (f: Record<string, unknown>) => isEmptyMembershipFilter(f);

it('multi-key implicit AND under `$not` (constant TRUE by De Morgan) fires', () => {
expect(fires({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(true);
// Evaluator agreement: NOT(FALSE AND …) admits everything.
expect(admitted({ $not: { owner: { $in: [] }, status: 'open' } })).toBe(5);
});

it('bare `{ $not: { $in: [] } }` still fires — pre-#13552 guard parity', () => {
expect(fires({ $not: { $in: [] } })).toBe(true);
});

it('empty `$nin` (intrinsically constant TRUE) fires at positive polarity', () => {
// Not emitted by cel-to-filter today; recognised so a future lowering
// cannot fail open through the same blind spot ($nin: [] → `1 = 1` at the
// read-scope SQL lowering).
expect(fires({ owner: { $nin: [] } })).toBe(true);
expect(fires({ $or: [{ owner: { $nin: [] } }, { status: 'open' }] })).toBe(true);
});

it('non-membership shapes do not fire', () => {
expect(fires({ owner: 'u_me' })).toBe(false);
expect(fires({ $not: { owner: { $in: ['a'] } } })).toBe(false);
expect(fires({ $not: { owner: { $null: true } } })).toBe(false);
expect(fires({ $and: [{ owner: { $in: [] } }, { status: 'open' }] })).toBe(false); // constant FALSE — denies by itself
expect(fires({})).toBe(false);
});

it('even-`$not` emptied membership NESTED in a composite stays inert (constant FALSE arm)', () => {
expect(fires({ $or: [{ $not: { $not: { owner: { $in: [] } } } }, { owner: 'u_me' }] })).toBe(false);
});
});
Loading