Skip to content

The RLS isEmptyMembershipFilter deny guard is polarity-blind: an emptied membership under a supported not in compiles to allow-all instead of the deny sentinel #13552

Description

@claude

Filed unassigned by the #13496 dev. This surfaced while measuring #13496's Zone-2 convergence hypothesis and is evidence for the decision that card is escalating — triage may prefer to fold it into #13496 rather than run it separately. It is filed on its own because it is reachable with no null anywhere, so it survives whatever #13496 is ruled. Recording only — no severity asserted, routing is triage's.

Measured on 50cf2940b9 (helper text read on 090f2302ec, unchanged)

packages/plugins/plugin-security/src/rls-compiler.ts carries a purpose-built guard so that a pre-resolved membership set that RESOLVES EMPTY denies rather than emitting a permissive filter. Its own docblock:

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: [].

compileExpression calls it and returns null (policy drops, RLS_DENY_FILTER upstream) when it fires.

It is shape-matched to positive polarity only. Running the helper verbatim:

{"owner":{"$in":[]}}                                        => guard fires: true
{"$not":{"owner":{"$in":[]}}}                               => guard fires: false
{"$or":[{"$not":{"owner":{"$in":[]}}},{"owner":"u_me"}]}    => guard fires: false

It requires keys.length === 1 and the single inner key to be $in. Under a $not the single top-level key is $not, so the guard returns false and the filter flows through.

Why that matters — the negated shape is authored, supported and pinned

not in is a first-class member of the pushdown subset. cel-to-filter.ts's docblock: "not in is !(x in y). Negation wraps in $not." It is pinned in cel-to-filter.test.ts ("not in → !(x in y) → $not wrapping $in"), and the authoring gate accepts it — measured:

isPushdownableCel("!(id in current_user.org_user_ids)") => {"ok":true}

So an author can write using: '!(owner in current_user.org_user_ids)', it passes the enforceability lint, and when the membership set resolves EMPTY the compiler emits {"$not":{"owner":{"$in":[]}}} — which the guard does not catch.

What that filter admits

$in: [] matches nothing on every backend, which is exactly why the positive case is safe. Under $not that inverts. Measured on the two backends the module's own docblock names ("one AST, two backends"), without touching driver-memory / driver-sql / driver-sqlite-wasm:

  • @objectstack/formula's matchesFilterCondition — executed against a 5-row fixture: 5 of 5 rows.
  • service-analytics/read-scope-sql.ts$in: [] lowers to FALSE_CLAUSE (1 = 0, its comment: "IN () matches nothing — safe"); $not lowers to a null-safe negation whose own comment states it is built so this compiler "admits the same rows driver-sql / driver-memory / formula admit". NOT (1 = 0) is TRUE for every row.

So the policy the guard exists to turn into a DENY becomes an ALLOW-ALL instead, on the read scope.

The general shape, which is the useful part

"An emptied membership is safe because $in: [] matches nothing" is a polarity-dependent claim, and the pushdown subset contains negation. The same inference appears as a bare — safe comment in read-scope-sql.ts one arm away from the $not that inverts it. Note that the hand-written Layer 0 path does NOT rely on it: tenant-layer.ts returns an explicit RLS_DENY_FILTER when its membership set is empty rather than an empty $in.

What this does NOT claim

I did not demonstrate a deployed policy authored in the negated form. I demonstrated that the platform accepts one at authoring time, compiles it, and that the deny guard standing in front of the emptied-membership case does not fire on it. I also do not assert which row set $in: [null] should select — that is #13357, unruled.

Related

#13496 (the card this was measured under; its Zone-2 decision turns on the same polarity blind spot) · #13357 (what $in: [null] selects — unruled) · #5146 / #5298 (no-value semantics on read scopes) · ADR-0055 / ADR-0058 (the pushdown contract)


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions