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
43 changes: 43 additions & 0 deletions .changeset/cel-pushdown-membership-null-member-fail-closed.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/formula": patch
---

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496)

`compileCelToFilter` already fails closed when a `current_user.*` variable
resolves to `undefined`/`null` — the module's docblock calls it "the no active
org fail-closed path" and it is pinned for the SCALAR case. `lowerMembership`
did not apply the same discipline one level in: it checked only
`Array.isArray(value)` and emitted the list verbatim, so a null MEMBER of a
resolved membership array went straight into a security `$in`. The one shape
that IS a permission predicate was the one shape that did not fail closed.

`lowerMembership` now refuses a `null`/`undefined` member of a **variable-resolved**
membership array with the same `unresolved-variable` reason the scalar path
uses, which the RLS path already turns into the deny sentinel.

Maintainer ruling, 2026-08-31 (quoted unchanged): 「membership 数组中的 null
**成员**触发与 null 标量同款处置——`unresolved-variable` / deny sentinel,⛔ 不
strip、不静默清洗。」

**Why refuse rather than strip.** Stripping the unresolved member is safe in
POSITIVE polarity only. `not in` is a supported, pinned member of the pushdown
subset (`!(x in y)` lowers to `$not` wrapping `$in`), and `$in: []` matches
nothing on every backend — so `$not { $in: [] }` matches the WHOLE table.
Stripping therefore inverts into fail-OPEN exactly where the predicate is a
blocklist, and it silently deletes a blocklist entry in the mixed
`['u1', null]` case. Refusing needs no polarity awareness at all: it throws
before any `$not` wrapper is built. Both polarities are pinned.

**No shipped behaviour changes.** No first-party provider puts a null into a
membership array — `resolve-authz-context.ts` filters non-strings out of
`org_user_ids`, and the kernel spec declares `org_user_ids: z.array(z.string())`
— so the refused shape was never a declared-valid input. This makes the
implementation match the declaration rather than narrowing it. A fully resolved
list, an empty list (`$in: []`, a legitimate declared predicate) and the
authoring-time `isPushdownableCel` shape gate are all unchanged and pinned so.

Out of scope, deliberately: an AUTHORED literal null inside a list
(`record.status in ['lost', null]`) is a declared predicate, not an unresolved
variable, and what such a filter should select is a separate open question. It
is untouched, with a pin recording that.
103 changes: 103 additions & 0 deletions packages/formula/src/cel-to-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -216,3 +216,106 @@ describe('compileCelToFilter — input shapes', () => {
expect(r.ok && r.filter).toEqual({ dept: 'sales' });
});
});

/**
* A null/undefined MEMBER of a resolved membership array fails closed, like the
* null SCALAR variable already pinned above (maintainer ruling, 2026-08-31).
*
* BOTH POLARITIES are pinned, and that is the point of the suite rather than a
* completeness flourish. The alternative repair — stripping the unresolved member
* — is safe in POSITIVE polarity (`$in` over the surviving members never grants
* more than those members grant) and INVERTS under the supported `not in` form:
* `!(x in y)` lowers to `$not` wrapping `$in`, and `$in: []` matching nothing makes
* `$not { $in: [] }` match the whole table. A positive-only suite is green for both
* repairs and therefore pins nothing about the one that was ruled on.
*/
describe('compileCelToFilter — a null MEMBER of a membership array fails closed', () => {
const vars = (org_user_ids: unknown[]) => ({ current_user: { id: 'u_me', org_user_ids } });
/**
* `ok` above pins its second argument to the exact shape of the module-level
* `VARS`, so it cannot take these partial contexts. Same assertion, same throw
* on an unexpected refusal, widened only where this suite needs it.
*/
const filterOf = (src: string, v: Record<string, unknown>) => {
const r = compileCelToFilter(src, { variables: v });
if (!r.ok) throw new Error(`expected ok for "${src}" but got ${r.reason}: ${r.detail}`);
return r.filter;
};
const expectUnresolved = (r: ReturnType<typeof compileCelToFilter>, path = 'current_user.org_user_ids') => {
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toBe('unresolved-variable');
expect(r.detail).toContain(path);
expect(r.detail).toContain('unresolved member');
};

// ---- POSITIVE polarity: `x in y` -> $in ---------------------------------
it('positive: null among resolved members → unresolved-variable (no $in emitted)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', null]) }));
});
it('positive: a lone null member → unresolved-variable', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) }));
});
it('positive: an undefined member fails closed too (the scalar path refuses both)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', undefined]) }));
});

// ---- NEGATIVE polarity: `!(x in y)` -> $not wrapping $in ----------------
// This is where stripping inverted into allow-all; refusing must reach the SAME
// result here as in positive polarity, with no polarity threading in the lowerer.
it('negated `not in`: null among resolved members → unresolved-variable (no $not{$in} emitted)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars(['u_me', null]) }));
});
it('negated `not in`: a lone null member → unresolved-variable, NOT $not{$in:[]} (whole table)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) }));
});
it('negated inside a disjunction: the surviving disjunct does not rescue the compile', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) || owner == current_user.id", {
variables: vars([null]),
}),
);
});
it('negated inside a conjunction fails closed as well', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) && owner == current_user.id", {
variables: vars(['u_me', null]),
}),
);
});

// ---- the two polarities agree, which is the ruling's "same treatment" ----
it('both polarities and the null SCALAR variable yield the identical reason', () => {
const scalar = compileCelToFilter('record.organization_id == current_user.organization_id', {
variables: { current_user: { organization_id: null } },
});
const positive = compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) });
const negated = compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) });
const reasons = [scalar, positive, negated].map((r) => (r.ok ? 'ok' : r.reason));
expect(reasons).toEqual(['unresolved-variable', 'unresolved-variable', 'unresolved-variable']);
});

// ---- shapes this guard must NOT move --------------------------------------
it('a fully resolved membership array still compiles, in both polarities', () => {
expect(filterOf('id in current_user.org_user_ids', vars(['u_me', 'u_peer']))).toEqual({
id: { $in: ['u_me', 'u_peer'] },
});
expect(filterOf('!(id in current_user.org_user_ids)', vars(['u_me', 'u_peer']))).toEqual({
$not: { id: { $in: ['u_me', 'u_peer'] } },
});
});
it('an EMPTY membership array still compiles to $in:[] in both polarities (a declared predicate, unchanged here)', () => {
expect(filterOf('id in current_user.org_user_ids', vars([]))).toEqual({ id: { $in: [] } });
expect(filterOf('!(id in current_user.org_user_ids)', vars([]))).toEqual({ $not: { id: { $in: [] } } });
});
it('an AUTHORED literal null in a list is not an unresolved variable — out of this guard scope', () => {
// A declared predicate, the same way `record.x == null` lowers to `$null` rather
// than failing closed. What such a filter SELECTS is a separate open question;
// this pin records only that the compiler still lowers it, unchanged.
expect(ok("record.status in ['lost', null]")).toEqual({ status: { $in: ['lost', null] } });
});
it('isPushdownableCel is untouched: the authoring gate resolves no variables', () => {
expect(isPushdownableCel('id in current_user.org_user_ids').ok).toBe(true);
expect(isPushdownableCel('!(id in current_user.org_user_ids)').ok).toBe(true);
});
});
34 changes: 33 additions & 1 deletion packages/formula/src/cel-to-filter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,7 +39,10 @@
* `current_user.org_user_ids` → a pre-resolved membership array for `$in`
* (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits
* a subquery). A variable that resolves to `undefined`/`null` yields
* `unresolved-variable` (the "no active org" fail-closed path).
* `unresolved-variable` (the "no active org" fail-closed path) — and so does a
* null/undefined MEMBER of a resolved membership array, which is the same
* unresolved value one level in. See {@link lowerMembership} for why the member
* is refused rather than dropped.
*/

import type { ASTNode } from '@marcbachmann/cel-js';
Expand DownExpand Up@@ -366,6 +369,35 @@ function lowerMembership(elemNode: ASTNode, containerNode: ASTNode, ctx: Ctx): F
if (value !== SHAPE_VALUE && !Array.isArray(value)) {
throw new CompileError('unsupported', `\`in\` requires an array/list on the right`);
}
// A null/undefined MEMBER of a RESOLVED membership variable fails closed, exactly
// as the scalar `resolveValue` path does one level up: the same unresolved value,
// the same `unresolved-variable` reason, the same deny sentinel downstream. Until
// this guard the member was emitted verbatim into a security `$in`, so the one
// shape that IS a permission predicate was the one shape that did not fail closed.
//
// Refused, never dropped. Stripping the member was measured to INVERT under the
// supported `not in` form (`!(x in y)` → `$not` wrapping `$in`): `$in: []` matches
// nothing, so `$not { $in: [] }` matches the WHOLE table. "Matches nothing" is
// fail-closed in POSITIVE polarity only, which makes stripping fail-OPEN precisely
// where the predicate is a blocklist. Refusing here needs no polarity awareness at
// all — it throws before any `$not` wrapper is built, so every enclosing shape
// (`!`, `&&`, `||`) collapses to the single `unresolved-variable` result.
//
// Deliberately NOT this guard's business: an AUTHORED literal null inside a list
// (`record.status in ['lost', null]`). That is a declared predicate rather than an
// unresolved variable — the same distinction `== null` already draws, where a
// literal null lowers to `$null` instead of failing closed — and what such a
// filter should SELECT is a separate open question this compiler does not answer.
if (container.kind === 'var' && Array.isArray(value)) {
const idx = value.findIndex((member) => member === null || member === undefined);
if (idx !== -1) {
throw new CompileError(
'unresolved-variable',
`variable "${container.path.join('.')}" has an unresolved member at index ${idx} ` +
`(${String(value[idx])}); a membership array must resolve every member`,
);
}
}
return { [(elem as { path: string }).path]: { $in: value } } as FilterCondition;
}

Expand Down
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(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) by claude[bot] · Pull Request #13630 · 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
43 changes: 43 additions & 0 deletions .changeset/cel-pushdown-membership-null-member-fail-closed.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/formula": patch
---

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496)

`compileCelToFilter` already fails closed when a `current_user.*` variable
resolves to `undefined`/`null` — the module's docblock calls it "the no active
org fail-closed path" and it is pinned for the SCALAR case. `lowerMembership`
did not apply the same discipline one level in: it checked only
`Array.isArray(value)` and emitted the list verbatim, so a null MEMBER of a
resolved membership array went straight into a security `$in`. The one shape
that IS a permission predicate was the one shape that did not fail closed.

`lowerMembership` now refuses a `null`/`undefined` member of a **variable-resolved**
membership array with the same `unresolved-variable` reason the scalar path
uses, which the RLS path already turns into the deny sentinel.

Maintainer ruling, 2026-08-31 (quoted unchanged): 「membership 数组中的 null
**成员**触发与 null 标量同款处置——`unresolved-variable` / deny sentinel,⛔ 不
strip、不静默清洗。」

**Why refuse rather than strip.** Stripping the unresolved member is safe in
POSITIVE polarity only. `not in` is a supported, pinned member of the pushdown
subset (`!(x in y)` lowers to `$not` wrapping `$in`), and `$in: []` matches
nothing on every backend — so `$not { $in: [] }` matches the WHOLE table.
Stripping therefore inverts into fail-OPEN exactly where the predicate is a
blocklist, and it silently deletes a blocklist entry in the mixed
`['u1', null]` case. Refusing needs no polarity awareness at all: it throws
before any `$not` wrapper is built. Both polarities are pinned.

**No shipped behaviour changes.** No first-party provider puts a null into a
membership array — `resolve-authz-context.ts` filters non-strings out of
`org_user_ids`, and the kernel spec declares `org_user_ids: z.array(z.string())`
— so the refused shape was never a declared-valid input. This makes the
implementation match the declaration rather than narrowing it. A fully resolved
list, an empty list (`$in: []`, a legitimate declared predicate) and the
authoring-time `isPushdownableCel` shape gate are all unchanged and pinned so.

Out of scope, deliberately: an AUTHORED literal null inside a list
(`record.status in ['lost', null]`) is a declared predicate, not an unresolved
variable, and what such a filter should select is a separate open question. It
is untouched, with a pin recording that.
103 changes: 103 additions & 0 deletions packages/formula/src/cel-to-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -216,3 +216,106 @@ describe('compileCelToFilter — input shapes', () => {
expect(r.ok && r.filter).toEqual({ dept: 'sales' });
});
});

/**
* A null/undefined MEMBER of a resolved membership array fails closed, like the
* null SCALAR variable already pinned above (maintainer ruling, 2026-08-31).
*
* BOTH POLARITIES are pinned, and that is the point of the suite rather than a
* completeness flourish. The alternative repair — stripping the unresolved member
* — is safe in POSITIVE polarity (`$in` over the surviving members never grants
* more than those members grant) and INVERTS under the supported `not in` form:
* `!(x in y)` lowers to `$not` wrapping `$in`, and `$in: []` matching nothing makes
* `$not { $in: [] }` match the whole table. A positive-only suite is green for both
* repairs and therefore pins nothing about the one that was ruled on.
*/
describe('compileCelToFilter — a null MEMBER of a membership array fails closed', () => {
const vars = (org_user_ids: unknown[]) => ({ current_user: { id: 'u_me', org_user_ids } });
/**
* `ok` above pins its second argument to the exact shape of the module-level
* `VARS`, so it cannot take these partial contexts. Same assertion, same throw
* on an unexpected refusal, widened only where this suite needs it.
*/
const filterOf = (src: string, v: Record<string, unknown>) => {
const r = compileCelToFilter(src, { variables: v });
if (!r.ok) throw new Error(`expected ok for "${src}" but got ${r.reason}: ${r.detail}`);
return r.filter;
};
const expectUnresolved = (r: ReturnType<typeof compileCelToFilter>, path = 'current_user.org_user_ids') => {
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toBe('unresolved-variable');
expect(r.detail).toContain(path);
expect(r.detail).toContain('unresolved member');
};

// ---- POSITIVE polarity: `x in y` -> $in ---------------------------------
it('positive: null among resolved members → unresolved-variable (no $in emitted)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', null]) }));
});
it('positive: a lone null member → unresolved-variable', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) }));
});
it('positive: an undefined member fails closed too (the scalar path refuses both)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', undefined]) }));
});

// ---- NEGATIVE polarity: `!(x in y)` -> $not wrapping $in ----------------
// This is where stripping inverted into allow-all; refusing must reach the SAME
// result here as in positive polarity, with no polarity threading in the lowerer.
it('negated `not in`: null among resolved members → unresolved-variable (no $not{$in} emitted)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars(['u_me', null]) }));
});
it('negated `not in`: a lone null member → unresolved-variable, NOT $not{$in:[]} (whole table)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) }));
});
it('negated inside a disjunction: the surviving disjunct does not rescue the compile', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) || owner == current_user.id", {
variables: vars([null]),
}),
);
});
it('negated inside a conjunction fails closed as well', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) && owner == current_user.id", {
variables: vars(['u_me', null]),
}),
);
});

// ---- the two polarities agree, which is the ruling's "same treatment" ----
it('both polarities and the null SCALAR variable yield the identical reason', () => {
const scalar = compileCelToFilter('record.organization_id == current_user.organization_id', {
variables: { current_user: { organization_id: null } },
});
const positive = compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) });
const negated = compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) });
const reasons = [scalar, positive, negated].map((r) => (r.ok ? 'ok' : r.reason));
expect(reasons).toEqual(['unresolved-variable', 'unresolved-variable', 'unresolved-variable']);
});

// ---- shapes this guard must NOT move --------------------------------------
it('a fully resolved membership array still compiles, in both polarities', () => {
expect(filterOf('id in current_user.org_user_ids', vars(['u_me', 'u_peer']))).toEqual({
id: { $in: ['u_me', 'u_peer'] },
});
expect(filterOf('!(id in current_user.org_user_ids)', vars(['u_me', 'u_peer']))).toEqual({
$not: { id: { $in: ['u_me', 'u_peer'] } },
});
});
it('an EMPTY membership array still compiles to $in:[] in both polarities (a declared predicate, unchanged here)', () => {
expect(filterOf('id in current_user.org_user_ids', vars([]))).toEqual({ id: { $in: [] } });
expect(filterOf('!(id in current_user.org_user_ids)', vars([]))).toEqual({ $not: { id: { $in: [] } } });
});
it('an AUTHORED literal null in a list is not an unresolved variable — out of this guard scope', () => {
// A declared predicate, the same way `record.x == null` lowers to `$null` rather
// than failing closed. What such a filter SELECTS is a separate open question;
// this pin records only that the compiler still lowers it, unchanged.
expect(ok("record.status in ['lost', null]")).toEqual({ status: { $in: ['lost', null] } });
});
it('isPushdownableCel is untouched: the authoring gate resolves no variables', () => {
expect(isPushdownableCel('id in current_user.org_user_ids').ok).toBe(true);
expect(isPushdownableCel('!(id in current_user.org_user_ids)').ok).toBe(true);
});
});
34 changes: 33 additions & 1 deletion packages/formula/src/cel-to-filter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,7 +39,10 @@
* `current_user.org_user_ids` → a pre-resolved membership array for `$in`
* (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits
* a subquery). A variable that resolves to `undefined`/`null` yields
* `unresolved-variable` (the "no active org" fail-closed path).
* `unresolved-variable` (the "no active org" fail-closed path) — and so does a
* null/undefined MEMBER of a resolved membership array, which is the same
* unresolved value one level in. See {@link lowerMembership} for why the member
* is refused rather than dropped.
*/

import type { ASTNode } from '@marcbachmann/cel-js';
Expand DownExpand Up@@ -366,6 +369,35 @@ function lowerMembership(elemNode: ASTNode, containerNode: ASTNode, ctx: Ctx): F
if (value !== SHAPE_VALUE && !Array.isArray(value)) {
throw new CompileError('unsupported', `\`in\` requires an array/list on the right`);
}
// A null/undefined MEMBER of a RESOLVED membership variable fails closed, exactly
// as the scalar `resolveValue` path does one level up: the same unresolved value,
// the same `unresolved-variable` reason, the same deny sentinel downstream. Until
// this guard the member was emitted verbatim into a security `$in`, so the one
// shape that IS a permission predicate was the one shape that did not fail closed.
//
// Refused, never dropped. Stripping the member was measured to INVERT under the
// supported `not in` form (`!(x in y)` → `$not` wrapping `$in`): `$in: []` matches
// nothing, so `$not { $in: [] }` matches the WHOLE table. "Matches nothing" is
// fail-closed in POSITIVE polarity only, which makes stripping fail-OPEN precisely
// where the predicate is a blocklist. Refusing here needs no polarity awareness at
// all — it throws before any `$not` wrapper is built, so every enclosing shape
// (`!`, `&&`, `||`) collapses to the single `unresolved-variable` result.
//
// Deliberately NOT this guard's business: an AUTHORED literal null inside a list
// (`record.status in ['lost', null]`). That is a declared predicate rather than an
// unresolved variable — the same distinction `== null` already draws, where a
// literal null lowers to `$null` instead of failing closed — and what such a
// filter should SELECT is a separate open question this compiler does not answer.
if (container.kind === 'var' && Array.isArray(value)) {
const idx = value.findIndex((member) => member === null || member === undefined);
if (idx !== -1) {
throw new CompileError(
'unresolved-variable',
`variable "${container.path.join('.')}" has an unresolved member at index ${idx} ` +
`(${String(value[idx])}); a membership array must resolve every member`,
);
}
}
return { [(elem as { path: string }).path]: { $in: value } } as FilterCondition;
}

Expand Down
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(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) by claude[bot] · Pull Request #13630 · 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
43 changes: 43 additions & 0 deletions .changeset/cel-pushdown-membership-null-member-fail-closed.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/formula": patch
---

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496)

`compileCelToFilter` already fails closed when a `current_user.*` variable
resolves to `undefined`/`null` — the module's docblock calls it "the no active
org fail-closed path" and it is pinned for the SCALAR case. `lowerMembership`
did not apply the same discipline one level in: it checked only
`Array.isArray(value)` and emitted the list verbatim, so a null MEMBER of a
resolved membership array went straight into a security `$in`. The one shape
that IS a permission predicate was the one shape that did not fail closed.

`lowerMembership` now refuses a `null`/`undefined` member of a **variable-resolved**
membership array with the same `unresolved-variable` reason the scalar path
uses, which the RLS path already turns into the deny sentinel.

Maintainer ruling, 2026-08-31 (quoted unchanged): 「membership 数组中的 null
**成员**触发与 null 标量同款处置——`unresolved-variable` / deny sentinel,⛔ 不
strip、不静默清洗。」

**Why refuse rather than strip.** Stripping the unresolved member is safe in
POSITIVE polarity only. `not in` is a supported, pinned member of the pushdown
subset (`!(x in y)` lowers to `$not` wrapping `$in`), and `$in: []` matches
nothing on every backend — so `$not { $in: [] }` matches the WHOLE table.
Stripping therefore inverts into fail-OPEN exactly where the predicate is a
blocklist, and it silently deletes a blocklist entry in the mixed
`['u1', null]` case. Refusing needs no polarity awareness at all: it throws
before any `$not` wrapper is built. Both polarities are pinned.

**No shipped behaviour changes.** No first-party provider puts a null into a
membership array — `resolve-authz-context.ts` filters non-strings out of
`org_user_ids`, and the kernel spec declares `org_user_ids: z.array(z.string())`
— so the refused shape was never a declared-valid input. This makes the
implementation match the declaration rather than narrowing it. A fully resolved
list, an empty list (`$in: []`, a legitimate declared predicate) and the
authoring-time `isPushdownableCel` shape gate are all unchanged and pinned so.

Out of scope, deliberately: an AUTHORED literal null inside a list
(`record.status in ['lost', null]`) is a declared predicate, not an unresolved
variable, and what such a filter should select is a separate open question. It
is untouched, with a pin recording that.
103 changes: 103 additions & 0 deletions packages/formula/src/cel-to-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -216,3 +216,106 @@ describe('compileCelToFilter — input shapes', () => {
expect(r.ok && r.filter).toEqual({ dept: 'sales' });
});
});

/**
* A null/undefined MEMBER of a resolved membership array fails closed, like the
* null SCALAR variable already pinned above (maintainer ruling, 2026-08-31).
*
* BOTH POLARITIES are pinned, and that is the point of the suite rather than a
* completeness flourish. The alternative repair — stripping the unresolved member
* — is safe in POSITIVE polarity (`$in` over the surviving members never grants
* more than those members grant) and INVERTS under the supported `not in` form:
* `!(x in y)` lowers to `$not` wrapping `$in`, and `$in: []` matching nothing makes
* `$not { $in: [] }` match the whole table. A positive-only suite is green for both
* repairs and therefore pins nothing about the one that was ruled on.
*/
describe('compileCelToFilter — a null MEMBER of a membership array fails closed', () => {
const vars = (org_user_ids: unknown[]) => ({ current_user: { id: 'u_me', org_user_ids } });
/**
* `ok` above pins its second argument to the exact shape of the module-level
* `VARS`, so it cannot take these partial contexts. Same assertion, same throw
* on an unexpected refusal, widened only where this suite needs it.
*/
const filterOf = (src: string, v: Record<string, unknown>) => {
const r = compileCelToFilter(src, { variables: v });
if (!r.ok) throw new Error(`expected ok for "${src}" but got ${r.reason}: ${r.detail}`);
return r.filter;
};
const expectUnresolved = (r: ReturnType<typeof compileCelToFilter>, path = 'current_user.org_user_ids') => {
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toBe('unresolved-variable');
expect(r.detail).toContain(path);
expect(r.detail).toContain('unresolved member');
};

// ---- POSITIVE polarity: `x in y` -> $in ---------------------------------
it('positive: null among resolved members → unresolved-variable (no $in emitted)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', null]) }));
});
it('positive: a lone null member → unresolved-variable', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) }));
});
it('positive: an undefined member fails closed too (the scalar path refuses both)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', undefined]) }));
});

// ---- NEGATIVE polarity: `!(x in y)` -> $not wrapping $in ----------------
// This is where stripping inverted into allow-all; refusing must reach the SAME
// result here as in positive polarity, with no polarity threading in the lowerer.
it('negated `not in`: null among resolved members → unresolved-variable (no $not{$in} emitted)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars(['u_me', null]) }));
});
it('negated `not in`: a lone null member → unresolved-variable, NOT $not{$in:[]} (whole table)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) }));
});
it('negated inside a disjunction: the surviving disjunct does not rescue the compile', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) || owner == current_user.id", {
variables: vars([null]),
}),
);
});
it('negated inside a conjunction fails closed as well', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) && owner == current_user.id", {
variables: vars(['u_me', null]),
}),
);
});

// ---- the two polarities agree, which is the ruling's "same treatment" ----
it('both polarities and the null SCALAR variable yield the identical reason', () => {
const scalar = compileCelToFilter('record.organization_id == current_user.organization_id', {
variables: { current_user: { organization_id: null } },
});
const positive = compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) });
const negated = compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) });
const reasons = [scalar, positive, negated].map((r) => (r.ok ? 'ok' : r.reason));
expect(reasons).toEqual(['unresolved-variable', 'unresolved-variable', 'unresolved-variable']);
});

// ---- shapes this guard must NOT move --------------------------------------
it('a fully resolved membership array still compiles, in both polarities', () => {
expect(filterOf('id in current_user.org_user_ids', vars(['u_me', 'u_peer']))).toEqual({
id: { $in: ['u_me', 'u_peer'] },
});
expect(filterOf('!(id in current_user.org_user_ids)', vars(['u_me', 'u_peer']))).toEqual({
$not: { id: { $in: ['u_me', 'u_peer'] } },
});
});
it('an EMPTY membership array still compiles to $in:[] in both polarities (a declared predicate, unchanged here)', () => {
expect(filterOf('id in current_user.org_user_ids', vars([]))).toEqual({ id: { $in: [] } });
expect(filterOf('!(id in current_user.org_user_ids)', vars([]))).toEqual({ $not: { id: { $in: [] } } });
});
it('an AUTHORED literal null in a list is not an unresolved variable — out of this guard scope', () => {
// A declared predicate, the same way `record.x == null` lowers to `$null` rather
// than failing closed. What such a filter SELECTS is a separate open question;
// this pin records only that the compiler still lowers it, unchanged.
expect(ok("record.status in ['lost', null]")).toEqual({ status: { $in: ['lost', null] } });
});
it('isPushdownableCel is untouched: the authoring gate resolves no variables', () => {
expect(isPushdownableCel('id in current_user.org_user_ids').ok).toBe(true);
expect(isPushdownableCel('!(id in current_user.org_user_ids)').ok).toBe(true);
});
});
34 changes: 33 additions & 1 deletion packages/formula/src/cel-to-filter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,7 +39,10 @@
* `current_user.org_user_ids` → a pre-resolved membership array for `$in`
* (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits
* a subquery). A variable that resolves to `undefined`/`null` yields
* `unresolved-variable` (the "no active org" fail-closed path).
* `unresolved-variable` (the "no active org" fail-closed path) — and so does a
* null/undefined MEMBER of a resolved membership array, which is the same
* unresolved value one level in. See {@link lowerMembership} for why the member
* is refused rather than dropped.
*/

import type { ASTNode } from '@marcbachmann/cel-js';
Expand DownExpand Up@@ -366,6 +369,35 @@ function lowerMembership(elemNode: ASTNode, containerNode: ASTNode, ctx: Ctx): F
if (value !== SHAPE_VALUE && !Array.isArray(value)) {
throw new CompileError('unsupported', `\`in\` requires an array/list on the right`);
}
// A null/undefined MEMBER of a RESOLVED membership variable fails closed, exactly
// as the scalar `resolveValue` path does one level up: the same unresolved value,
// the same `unresolved-variable` reason, the same deny sentinel downstream. Until
// this guard the member was emitted verbatim into a security `$in`, so the one
// shape that IS a permission predicate was the one shape that did not fail closed.
//
// Refused, never dropped. Stripping the member was measured to INVERT under the
// supported `not in` form (`!(x in y)` → `$not` wrapping `$in`): `$in: []` matches
// nothing, so `$not { $in: [] }` matches the WHOLE table. "Matches nothing" is
// fail-closed in POSITIVE polarity only, which makes stripping fail-OPEN precisely
// where the predicate is a blocklist. Refusing here needs no polarity awareness at
// all — it throws before any `$not` wrapper is built, so every enclosing shape
// (`!`, `&&`, `||`) collapses to the single `unresolved-variable` result.
//
// Deliberately NOT this guard's business: an AUTHORED literal null inside a list
// (`record.status in ['lost', null]`). That is a declared predicate rather than an
// unresolved variable — the same distinction `== null` already draws, where a
// literal null lowers to `$null` instead of failing closed — and what such a
// filter should SELECT is a separate open question this compiler does not answer.
if (container.kind === 'var' && Array.isArray(value)) {
const idx = value.findIndex((member) => member === null || member === undefined);
if (idx !== -1) {
throw new CompileError(
'unresolved-variable',
`variable "${container.path.join('.')}" has an unresolved member at index ${idx} ` +
`(${String(value[idx])}); a membership array must resolve every member`,
);
}
}
return { [(elem as { path: string }).path]: { $in: value } } as FilterCondition;
}

Expand Down
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(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) by claude[bot] · Pull Request #13630 · 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
43 changes: 43 additions & 0 deletions .changeset/cel-pushdown-membership-null-member-fail-closed.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/formula": patch
---

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496)

`compileCelToFilter` already fails closed when a `current_user.*` variable
resolves to `undefined`/`null` — the module's docblock calls it "the no active
org fail-closed path" and it is pinned for the SCALAR case. `lowerMembership`
did not apply the same discipline one level in: it checked only
`Array.isArray(value)` and emitted the list verbatim, so a null MEMBER of a
resolved membership array went straight into a security `$in`. The one shape
that IS a permission predicate was the one shape that did not fail closed.

`lowerMembership` now refuses a `null`/`undefined` member of a **variable-resolved**
membership array with the same `unresolved-variable` reason the scalar path
uses, which the RLS path already turns into the deny sentinel.

Maintainer ruling, 2026-08-31 (quoted unchanged): 「membership 数组中的 null
**成员**触发与 null 标量同款处置——`unresolved-variable` / deny sentinel,⛔ 不
strip、不静默清洗。」

**Why refuse rather than strip.** Stripping the unresolved member is safe in
POSITIVE polarity only. `not in` is a supported, pinned member of the pushdown
subset (`!(x in y)` lowers to `$not` wrapping `$in`), and `$in: []` matches
nothing on every backend — so `$not { $in: [] }` matches the WHOLE table.
Stripping therefore inverts into fail-OPEN exactly where the predicate is a
blocklist, and it silently deletes a blocklist entry in the mixed
`['u1', null]` case. Refusing needs no polarity awareness at all: it throws
before any `$not` wrapper is built. Both polarities are pinned.

**No shipped behaviour changes.** No first-party provider puts a null into a
membership array — `resolve-authz-context.ts` filters non-strings out of
`org_user_ids`, and the kernel spec declares `org_user_ids: z.array(z.string())`
— so the refused shape was never a declared-valid input. This makes the
implementation match the declaration rather than narrowing it. A fully resolved
list, an empty list (`$in: []`, a legitimate declared predicate) and the
authoring-time `isPushdownableCel` shape gate are all unchanged and pinned so.

Out of scope, deliberately: an AUTHORED literal null inside a list
(`record.status in ['lost', null]`) is a declared predicate, not an unresolved
variable, and what such a filter should select is a separate open question. It
is untouched, with a pin recording that.
103 changes: 103 additions & 0 deletions packages/formula/src/cel-to-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -216,3 +216,106 @@ describe('compileCelToFilter — input shapes', () => {
expect(r.ok && r.filter).toEqual({ dept: 'sales' });
});
});

/**
* A null/undefined MEMBER of a resolved membership array fails closed, like the
* null SCALAR variable already pinned above (maintainer ruling, 2026-08-31).
*
* BOTH POLARITIES are pinned, and that is the point of the suite rather than a
* completeness flourish. The alternative repair — stripping the unresolved member
* — is safe in POSITIVE polarity (`$in` over the surviving members never grants
* more than those members grant) and INVERTS under the supported `not in` form:
* `!(x in y)` lowers to `$not` wrapping `$in`, and `$in: []` matching nothing makes
* `$not { $in: [] }` match the whole table. A positive-only suite is green for both
* repairs and therefore pins nothing about the one that was ruled on.
*/
describe('compileCelToFilter — a null MEMBER of a membership array fails closed', () => {
const vars = (org_user_ids: unknown[]) => ({ current_user: { id: 'u_me', org_user_ids } });
/**
* `ok` above pins its second argument to the exact shape of the module-level
* `VARS`, so it cannot take these partial contexts. Same assertion, same throw
* on an unexpected refusal, widened only where this suite needs it.
*/
const filterOf = (src: string, v: Record<string, unknown>) => {
const r = compileCelToFilter(src, { variables: v });
if (!r.ok) throw new Error(`expected ok for "${src}" but got ${r.reason}: ${r.detail}`);
return r.filter;
};
const expectUnresolved = (r: ReturnType<typeof compileCelToFilter>, path = 'current_user.org_user_ids') => {
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toBe('unresolved-variable');
expect(r.detail).toContain(path);
expect(r.detail).toContain('unresolved member');
};

// ---- POSITIVE polarity: `x in y` -> $in ---------------------------------
it('positive: null among resolved members → unresolved-variable (no $in emitted)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', null]) }));
});
it('positive: a lone null member → unresolved-variable', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) }));
});
it('positive: an undefined member fails closed too (the scalar path refuses both)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', undefined]) }));
});

// ---- NEGATIVE polarity: `!(x in y)` -> $not wrapping $in ----------------
// This is where stripping inverted into allow-all; refusing must reach the SAME
// result here as in positive polarity, with no polarity threading in the lowerer.
it('negated `not in`: null among resolved members → unresolved-variable (no $not{$in} emitted)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars(['u_me', null]) }));
});
it('negated `not in`: a lone null member → unresolved-variable, NOT $not{$in:[]} (whole table)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) }));
});
it('negated inside a disjunction: the surviving disjunct does not rescue the compile', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) || owner == current_user.id", {
variables: vars([null]),
}),
);
});
it('negated inside a conjunction fails closed as well', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) && owner == current_user.id", {
variables: vars(['u_me', null]),
}),
);
});

// ---- the two polarities agree, which is the ruling's "same treatment" ----
it('both polarities and the null SCALAR variable yield the identical reason', () => {
const scalar = compileCelToFilter('record.organization_id == current_user.organization_id', {
variables: { current_user: { organization_id: null } },
});
const positive = compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) });
const negated = compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) });
const reasons = [scalar, positive, negated].map((r) => (r.ok ? 'ok' : r.reason));
expect(reasons).toEqual(['unresolved-variable', 'unresolved-variable', 'unresolved-variable']);
});

// ---- shapes this guard must NOT move --------------------------------------
it('a fully resolved membership array still compiles, in both polarities', () => {
expect(filterOf('id in current_user.org_user_ids', vars(['u_me', 'u_peer']))).toEqual({
id: { $in: ['u_me', 'u_peer'] },
});
expect(filterOf('!(id in current_user.org_user_ids)', vars(['u_me', 'u_peer']))).toEqual({
$not: { id: { $in: ['u_me', 'u_peer'] } },
});
});
it('an EMPTY membership array still compiles to $in:[] in both polarities (a declared predicate, unchanged here)', () => {
expect(filterOf('id in current_user.org_user_ids', vars([]))).toEqual({ id: { $in: [] } });
expect(filterOf('!(id in current_user.org_user_ids)', vars([]))).toEqual({ $not: { id: { $in: [] } } });
});
it('an AUTHORED literal null in a list is not an unresolved variable — out of this guard scope', () => {
// A declared predicate, the same way `record.x == null` lowers to `$null` rather
// than failing closed. What such a filter SELECTS is a separate open question;
// this pin records only that the compiler still lowers it, unchanged.
expect(ok("record.status in ['lost', null]")).toEqual({ status: { $in: ['lost', null] } });
});
it('isPushdownableCel is untouched: the authoring gate resolves no variables', () => {
expect(isPushdownableCel('id in current_user.org_user_ids').ok).toBe(true);
expect(isPushdownableCel('!(id in current_user.org_user_ids)').ok).toBe(true);
});
});
34 changes: 33 additions & 1 deletion packages/formula/src/cel-to-filter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,7 +39,10 @@
* `current_user.org_user_ids` → a pre-resolved membership array for `$in`
* (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits
* a subquery). A variable that resolves to `undefined`/`null` yields
* `unresolved-variable` (the "no active org" fail-closed path).
* `unresolved-variable` (the "no active org" fail-closed path) — and so does a
* null/undefined MEMBER of a resolved membership array, which is the same
* unresolved value one level in. See {@link lowerMembership} for why the member
* is refused rather than dropped.
*/

import type { ASTNode } from '@marcbachmann/cel-js';
Expand DownExpand Up@@ -366,6 +369,35 @@ function lowerMembership(elemNode: ASTNode, containerNode: ASTNode, ctx: Ctx): F
if (value !== SHAPE_VALUE && !Array.isArray(value)) {
throw new CompileError('unsupported', `\`in\` requires an array/list on the right`);
}
// A null/undefined MEMBER of a RESOLVED membership variable fails closed, exactly
// as the scalar `resolveValue` path does one level up: the same unresolved value,
// the same `unresolved-variable` reason, the same deny sentinel downstream. Until
// this guard the member was emitted verbatim into a security `$in`, so the one
// shape that IS a permission predicate was the one shape that did not fail closed.
//
// Refused, never dropped. Stripping the member was measured to INVERT under the
// supported `not in` form (`!(x in y)` → `$not` wrapping `$in`): `$in: []` matches
// nothing, so `$not { $in: [] }` matches the WHOLE table. "Matches nothing" is
// fail-closed in POSITIVE polarity only, which makes stripping fail-OPEN precisely
// where the predicate is a blocklist. Refusing here needs no polarity awareness at
// all — it throws before any `$not` wrapper is built, so every enclosing shape
// (`!`, `&&`, `||`) collapses to the single `unresolved-variable` result.
//
// Deliberately NOT this guard's business: an AUTHORED literal null inside a list
// (`record.status in ['lost', null]`). That is a declared predicate rather than an
// unresolved variable — the same distinction `== null` already draws, where a
// literal null lowers to `$null` instead of failing closed — and what such a
// filter should SELECT is a separate open question this compiler does not answer.
if (container.kind === 'var' && Array.isArray(value)) {
const idx = value.findIndex((member) => member === null || member === undefined);
if (idx !== -1) {
throw new CompileError(
'unresolved-variable',
`variable "${container.path.join('.')}" has an unresolved member at index ${idx} ` +
`(${String(value[idx])}); a membership array must resolve every member`,
);
}
}
return { [(elem as { path: string }).path]: { $in: value } } as FilterCondition;
}

Expand Down
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(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) by claude[bot] · Pull Request #13630 · 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
43 changes: 43 additions & 0 deletions .changeset/cel-pushdown-membership-null-member-fail-closed.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/formula": patch
---

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496)

`compileCelToFilter` already fails closed when a `current_user.*` variable
resolves to `undefined`/`null` — the module's docblock calls it "the no active
org fail-closed path" and it is pinned for the SCALAR case. `lowerMembership`
did not apply the same discipline one level in: it checked only
`Array.isArray(value)` and emitted the list verbatim, so a null MEMBER of a
resolved membership array went straight into a security `$in`. The one shape
that IS a permission predicate was the one shape that did not fail closed.

`lowerMembership` now refuses a `null`/`undefined` member of a **variable-resolved**
membership array with the same `unresolved-variable` reason the scalar path
uses, which the RLS path already turns into the deny sentinel.

Maintainer ruling, 2026-08-31 (quoted unchanged): 「membership 数组中的 null
**成员**触发与 null 标量同款处置——`unresolved-variable` / deny sentinel,⛔ 不
strip、不静默清洗。」

**Why refuse rather than strip.** Stripping the unresolved member is safe in
POSITIVE polarity only. `not in` is a supported, pinned member of the pushdown
subset (`!(x in y)` lowers to `$not` wrapping `$in`), and `$in: []` matches
nothing on every backend — so `$not { $in: [] }` matches the WHOLE table.
Stripping therefore inverts into fail-OPEN exactly where the predicate is a
blocklist, and it silently deletes a blocklist entry in the mixed
`['u1', null]` case. Refusing needs no polarity awareness at all: it throws
before any `$not` wrapper is built. Both polarities are pinned.

**No shipped behaviour changes.** No first-party provider puts a null into a
membership array — `resolve-authz-context.ts` filters non-strings out of
`org_user_ids`, and the kernel spec declares `org_user_ids: z.array(z.string())`
— so the refused shape was never a declared-valid input. This makes the
implementation match the declaration rather than narrowing it. A fully resolved
list, an empty list (`$in: []`, a legitimate declared predicate) and the
authoring-time `isPushdownableCel` shape gate are all unchanged and pinned so.

Out of scope, deliberately: an AUTHORED literal null inside a list
(`record.status in ['lost', null]`) is a declared predicate, not an unresolved
variable, and what such a filter should select is a separate open question. It
is untouched, with a pin recording that.
103 changes: 103 additions & 0 deletions packages/formula/src/cel-to-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -216,3 +216,106 @@ describe('compileCelToFilter — input shapes', () => {
expect(r.ok && r.filter).toEqual({ dept: 'sales' });
});
});

/**
* A null/undefined MEMBER of a resolved membership array fails closed, like the
* null SCALAR variable already pinned above (maintainer ruling, 2026-08-31).
*
* BOTH POLARITIES are pinned, and that is the point of the suite rather than a
* completeness flourish. The alternative repair — stripping the unresolved member
* — is safe in POSITIVE polarity (`$in` over the surviving members never grants
* more than those members grant) and INVERTS under the supported `not in` form:
* `!(x in y)` lowers to `$not` wrapping `$in`, and `$in: []` matching nothing makes
* `$not { $in: [] }` match the whole table. A positive-only suite is green for both
* repairs and therefore pins nothing about the one that was ruled on.
*/
describe('compileCelToFilter — a null MEMBER of a membership array fails closed', () => {
const vars = (org_user_ids: unknown[]) => ({ current_user: { id: 'u_me', org_user_ids } });
/**
* `ok` above pins its second argument to the exact shape of the module-level
* `VARS`, so it cannot take these partial contexts. Same assertion, same throw
* on an unexpected refusal, widened only where this suite needs it.
*/
const filterOf = (src: string, v: Record<string, unknown>) => {
const r = compileCelToFilter(src, { variables: v });
if (!r.ok) throw new Error(`expected ok for "${src}" but got ${r.reason}: ${r.detail}`);
return r.filter;
};
const expectUnresolved = (r: ReturnType<typeof compileCelToFilter>, path = 'current_user.org_user_ids') => {
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toBe('unresolved-variable');
expect(r.detail).toContain(path);
expect(r.detail).toContain('unresolved member');
};

// ---- POSITIVE polarity: `x in y` -> $in ---------------------------------
it('positive: null among resolved members → unresolved-variable (no $in emitted)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', null]) }));
});
it('positive: a lone null member → unresolved-variable', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) }));
});
it('positive: an undefined member fails closed too (the scalar path refuses both)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', undefined]) }));
});

// ---- NEGATIVE polarity: `!(x in y)` -> $not wrapping $in ----------------
// This is where stripping inverted into allow-all; refusing must reach the SAME
// result here as in positive polarity, with no polarity threading in the lowerer.
it('negated `not in`: null among resolved members → unresolved-variable (no $not{$in} emitted)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars(['u_me', null]) }));
});
it('negated `not in`: a lone null member → unresolved-variable, NOT $not{$in:[]} (whole table)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) }));
});
it('negated inside a disjunction: the surviving disjunct does not rescue the compile', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) || owner == current_user.id", {
variables: vars([null]),
}),
);
});
it('negated inside a conjunction fails closed as well', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) && owner == current_user.id", {
variables: vars(['u_me', null]),
}),
);
});

// ---- the two polarities agree, which is the ruling's "same treatment" ----
it('both polarities and the null SCALAR variable yield the identical reason', () => {
const scalar = compileCelToFilter('record.organization_id == current_user.organization_id', {
variables: { current_user: { organization_id: null } },
});
const positive = compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) });
const negated = compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) });
const reasons = [scalar, positive, negated].map((r) => (r.ok ? 'ok' : r.reason));
expect(reasons).toEqual(['unresolved-variable', 'unresolved-variable', 'unresolved-variable']);
});

// ---- shapes this guard must NOT move --------------------------------------
it('a fully resolved membership array still compiles, in both polarities', () => {
expect(filterOf('id in current_user.org_user_ids', vars(['u_me', 'u_peer']))).toEqual({
id: { $in: ['u_me', 'u_peer'] },
});
expect(filterOf('!(id in current_user.org_user_ids)', vars(['u_me', 'u_peer']))).toEqual({
$not: { id: { $in: ['u_me', 'u_peer'] } },
});
});
it('an EMPTY membership array still compiles to $in:[] in both polarities (a declared predicate, unchanged here)', () => {
expect(filterOf('id in current_user.org_user_ids', vars([]))).toEqual({ id: { $in: [] } });
expect(filterOf('!(id in current_user.org_user_ids)', vars([]))).toEqual({ $not: { id: { $in: [] } } });
});
it('an AUTHORED literal null in a list is not an unresolved variable — out of this guard scope', () => {
// A declared predicate, the same way `record.x == null` lowers to `$null` rather
// than failing closed. What such a filter SELECTS is a separate open question;
// this pin records only that the compiler still lowers it, unchanged.
expect(ok("record.status in ['lost', null]")).toEqual({ status: { $in: ['lost', null] } });
});
it('isPushdownableCel is untouched: the authoring gate resolves no variables', () => {
expect(isPushdownableCel('id in current_user.org_user_ids').ok).toBe(true);
expect(isPushdownableCel('!(id in current_user.org_user_ids)').ok).toBe(true);
});
});
34 changes: 33 additions & 1 deletion packages/formula/src/cel-to-filter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,7 +39,10 @@
* `current_user.org_user_ids` → a pre-resolved membership array for `$in`
* (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits
* a subquery). A variable that resolves to `undefined`/`null` yields
* `unresolved-variable` (the "no active org" fail-closed path).
* `unresolved-variable` (the "no active org" fail-closed path) — and so does a
* null/undefined MEMBER of a resolved membership array, which is the same
* unresolved value one level in. See {@link lowerMembership} for why the member
* is refused rather than dropped.
*/

import type { ASTNode } from '@marcbachmann/cel-js';
Expand DownExpand Up@@ -366,6 +369,35 @@ function lowerMembership(elemNode: ASTNode, containerNode: ASTNode, ctx: Ctx): F
if (value !== SHAPE_VALUE && !Array.isArray(value)) {
throw new CompileError('unsupported', `\`in\` requires an array/list on the right`);
}
// A null/undefined MEMBER of a RESOLVED membership variable fails closed, exactly
// as the scalar `resolveValue` path does one level up: the same unresolved value,
// the same `unresolved-variable` reason, the same deny sentinel downstream. Until
// this guard the member was emitted verbatim into a security `$in`, so the one
// shape that IS a permission predicate was the one shape that did not fail closed.
//
// Refused, never dropped. Stripping the member was measured to INVERT under the
// supported `not in` form (`!(x in y)` → `$not` wrapping `$in`): `$in: []` matches
// nothing, so `$not { $in: [] }` matches the WHOLE table. "Matches nothing" is
// fail-closed in POSITIVE polarity only, which makes stripping fail-OPEN precisely
// where the predicate is a blocklist. Refusing here needs no polarity awareness at
// all — it throws before any `$not` wrapper is built, so every enclosing shape
// (`!`, `&&`, `||`) collapses to the single `unresolved-variable` result.
//
// Deliberately NOT this guard's business: an AUTHORED literal null inside a list
// (`record.status in ['lost', null]`). That is a declared predicate rather than an
// unresolved variable — the same distinction `== null` already draws, where a
// literal null lowers to `$null` instead of failing closed — and what such a
// filter should SELECT is a separate open question this compiler does not answer.
if (container.kind === 'var' && Array.isArray(value)) {
const idx = value.findIndex((member) => member === null || member === undefined);
if (idx !== -1) {
throw new CompileError(
'unresolved-variable',
`variable "${container.path.join('.')}" has an unresolved member at index ${idx} ` +
`(${String(value[idx])}); a membership array must resolve every member`,
);
}
}
return { [(elem as { path: string }).path]: { $in: value } } as FilterCondition;
}

Expand Down
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(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) by claude[bot] · Pull Request #13630 · 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
43 changes: 43 additions & 0 deletions .changeset/cel-pushdown-membership-null-member-fail-closed.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/formula": patch
---

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496)

`compileCelToFilter` already fails closed when a `current_user.*` variable
resolves to `undefined`/`null` — the module's docblock calls it "the no active
org fail-closed path" and it is pinned for the SCALAR case. `lowerMembership`
did not apply the same discipline one level in: it checked only
`Array.isArray(value)` and emitted the list verbatim, so a null MEMBER of a
resolved membership array went straight into a security `$in`. The one shape
that IS a permission predicate was the one shape that did not fail closed.

`lowerMembership` now refuses a `null`/`undefined` member of a **variable-resolved**
membership array with the same `unresolved-variable` reason the scalar path
uses, which the RLS path already turns into the deny sentinel.

Maintainer ruling, 2026-08-31 (quoted unchanged): 「membership 数组中的 null
**成员**触发与 null 标量同款处置——`unresolved-variable` / deny sentinel,⛔ 不
strip、不静默清洗。」

**Why refuse rather than strip.** Stripping the unresolved member is safe in
POSITIVE polarity only. `not in` is a supported, pinned member of the pushdown
subset (`!(x in y)` lowers to `$not` wrapping `$in`), and `$in: []` matches
nothing on every backend — so `$not { $in: [] }` matches the WHOLE table.
Stripping therefore inverts into fail-OPEN exactly where the predicate is a
blocklist, and it silently deletes a blocklist entry in the mixed
`['u1', null]` case. Refusing needs no polarity awareness at all: it throws
before any `$not` wrapper is built. Both polarities are pinned.

**No shipped behaviour changes.** No first-party provider puts a null into a
membership array — `resolve-authz-context.ts` filters non-strings out of
`org_user_ids`, and the kernel spec declares `org_user_ids: z.array(z.string())`
— so the refused shape was never a declared-valid input. This makes the
implementation match the declaration rather than narrowing it. A fully resolved
list, an empty list (`$in: []`, a legitimate declared predicate) and the
authoring-time `isPushdownableCel` shape gate are all unchanged and pinned so.

Out of scope, deliberately: an AUTHORED literal null inside a list
(`record.status in ['lost', null]`) is a declared predicate, not an unresolved
variable, and what such a filter should select is a separate open question. It
is untouched, with a pin recording that.
103 changes: 103 additions & 0 deletions packages/formula/src/cel-to-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -216,3 +216,106 @@ describe('compileCelToFilter — input shapes', () => {
expect(r.ok && r.filter).toEqual({ dept: 'sales' });
});
});

/**
* A null/undefined MEMBER of a resolved membership array fails closed, like the
* null SCALAR variable already pinned above (maintainer ruling, 2026-08-31).
*
* BOTH POLARITIES are pinned, and that is the point of the suite rather than a
* completeness flourish. The alternative repair — stripping the unresolved member
* — is safe in POSITIVE polarity (`$in` over the surviving members never grants
* more than those members grant) and INVERTS under the supported `not in` form:
* `!(x in y)` lowers to `$not` wrapping `$in`, and `$in: []` matching nothing makes
* `$not { $in: [] }` match the whole table. A positive-only suite is green for both
* repairs and therefore pins nothing about the one that was ruled on.
*/
describe('compileCelToFilter — a null MEMBER of a membership array fails closed', () => {
const vars = (org_user_ids: unknown[]) => ({ current_user: { id: 'u_me', org_user_ids } });
/**
* `ok` above pins its second argument to the exact shape of the module-level
* `VARS`, so it cannot take these partial contexts. Same assertion, same throw
* on an unexpected refusal, widened only where this suite needs it.
*/
const filterOf = (src: string, v: Record<string, unknown>) => {
const r = compileCelToFilter(src, { variables: v });
if (!r.ok) throw new Error(`expected ok for "${src}" but got ${r.reason}: ${r.detail}`);
return r.filter;
};
const expectUnresolved = (r: ReturnType<typeof compileCelToFilter>, path = 'current_user.org_user_ids') => {
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toBe('unresolved-variable');
expect(r.detail).toContain(path);
expect(r.detail).toContain('unresolved member');
};

// ---- POSITIVE polarity: `x in y` -> $in ---------------------------------
it('positive: null among resolved members → unresolved-variable (no $in emitted)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', null]) }));
});
it('positive: a lone null member → unresolved-variable', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) }));
});
it('positive: an undefined member fails closed too (the scalar path refuses both)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', undefined]) }));
});

// ---- NEGATIVE polarity: `!(x in y)` -> $not wrapping $in ----------------
// This is where stripping inverted into allow-all; refusing must reach the SAME
// result here as in positive polarity, with no polarity threading in the lowerer.
it('negated `not in`: null among resolved members → unresolved-variable (no $not{$in} emitted)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars(['u_me', null]) }));
});
it('negated `not in`: a lone null member → unresolved-variable, NOT $not{$in:[]} (whole table)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) }));
});
it('negated inside a disjunction: the surviving disjunct does not rescue the compile', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) || owner == current_user.id", {
variables: vars([null]),
}),
);
});
it('negated inside a conjunction fails closed as well', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) && owner == current_user.id", {
variables: vars(['u_me', null]),
}),
);
});

// ---- the two polarities agree, which is the ruling's "same treatment" ----
it('both polarities and the null SCALAR variable yield the identical reason', () => {
const scalar = compileCelToFilter('record.organization_id == current_user.organization_id', {
variables: { current_user: { organization_id: null } },
});
const positive = compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) });
const negated = compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) });
const reasons = [scalar, positive, negated].map((r) => (r.ok ? 'ok' : r.reason));
expect(reasons).toEqual(['unresolved-variable', 'unresolved-variable', 'unresolved-variable']);
});

// ---- shapes this guard must NOT move --------------------------------------
it('a fully resolved membership array still compiles, in both polarities', () => {
expect(filterOf('id in current_user.org_user_ids', vars(['u_me', 'u_peer']))).toEqual({
id: { $in: ['u_me', 'u_peer'] },
});
expect(filterOf('!(id in current_user.org_user_ids)', vars(['u_me', 'u_peer']))).toEqual({
$not: { id: { $in: ['u_me', 'u_peer'] } },
});
});
it('an EMPTY membership array still compiles to $in:[] in both polarities (a declared predicate, unchanged here)', () => {
expect(filterOf('id in current_user.org_user_ids', vars([]))).toEqual({ id: { $in: [] } });
expect(filterOf('!(id in current_user.org_user_ids)', vars([]))).toEqual({ $not: { id: { $in: [] } } });
});
it('an AUTHORED literal null in a list is not an unresolved variable — out of this guard scope', () => {
// A declared predicate, the same way `record.x == null` lowers to `$null` rather
// than failing closed. What such a filter SELECTS is a separate open question;
// this pin records only that the compiler still lowers it, unchanged.
expect(ok("record.status in ['lost', null]")).toEqual({ status: { $in: ['lost', null] } });
});
it('isPushdownableCel is untouched: the authoring gate resolves no variables', () => {
expect(isPushdownableCel('id in current_user.org_user_ids').ok).toBe(true);
expect(isPushdownableCel('!(id in current_user.org_user_ids)').ok).toBe(true);
});
});
34 changes: 33 additions & 1 deletion packages/formula/src/cel-to-filter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,7 +39,10 @@
* `current_user.org_user_ids` → a pre-resolved membership array for `$in`
* (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits
* a subquery). A variable that resolves to `undefined`/`null` yields
* `unresolved-variable` (the "no active org" fail-closed path).
* `unresolved-variable` (the "no active org" fail-closed path) — and so does a
* null/undefined MEMBER of a resolved membership array, which is the same
* unresolved value one level in. See {@link lowerMembership} for why the member
* is refused rather than dropped.
*/

import type { ASTNode } from '@marcbachmann/cel-js';
Expand DownExpand Up@@ -366,6 +369,35 @@ function lowerMembership(elemNode: ASTNode, containerNode: ASTNode, ctx: Ctx): F
if (value !== SHAPE_VALUE && !Array.isArray(value)) {
throw new CompileError('unsupported', `\`in\` requires an array/list on the right`);
}
// A null/undefined MEMBER of a RESOLVED membership variable fails closed, exactly
// as the scalar `resolveValue` path does one level up: the same unresolved value,
// the same `unresolved-variable` reason, the same deny sentinel downstream. Until
// this guard the member was emitted verbatim into a security `$in`, so the one
// shape that IS a permission predicate was the one shape that did not fail closed.
//
// Refused, never dropped. Stripping the member was measured to INVERT under the
// supported `not in` form (`!(x in y)` → `$not` wrapping `$in`): `$in: []` matches
// nothing, so `$not { $in: [] }` matches the WHOLE table. "Matches nothing" is
// fail-closed in POSITIVE polarity only, which makes stripping fail-OPEN precisely
// where the predicate is a blocklist. Refusing here needs no polarity awareness at
// all — it throws before any `$not` wrapper is built, so every enclosing shape
// (`!`, `&&`, `||`) collapses to the single `unresolved-variable` result.
//
// Deliberately NOT this guard's business: an AUTHORED literal null inside a list
// (`record.status in ['lost', null]`). That is a declared predicate rather than an
// unresolved variable — the same distinction `== null` already draws, where a
// literal null lowers to `$null` instead of failing closed — and what such a
// filter should SELECT is a separate open question this compiler does not answer.
if (container.kind === 'var' && Array.isArray(value)) {
const idx = value.findIndex((member) => member === null || member === undefined);
if (idx !== -1) {
throw new CompileError(
'unresolved-variable',
`variable "${container.path.join('.')}" has an unresolved member at index ${idx} ` +
`(${String(value[idx])}); a membership array must resolve every member`,
);
}
}
return { [(elem as { path: string }).path]: { $in: value } } as FilterCondition;
}

Expand Down
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(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) by claude[bot] · Pull Request #13630 · 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
43 changes: 43 additions & 0 deletions .changeset/cel-pushdown-membership-null-member-fail-closed.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/formula": patch
---

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496)

`compileCelToFilter` already fails closed when a `current_user.*` variable
resolves to `undefined`/`null` — the module's docblock calls it "the no active
org fail-closed path" and it is pinned for the SCALAR case. `lowerMembership`
did not apply the same discipline one level in: it checked only
`Array.isArray(value)` and emitted the list verbatim, so a null MEMBER of a
resolved membership array went straight into a security `$in`. The one shape
that IS a permission predicate was the one shape that did not fail closed.

`lowerMembership` now refuses a `null`/`undefined` member of a **variable-resolved**
membership array with the same `unresolved-variable` reason the scalar path
uses, which the RLS path already turns into the deny sentinel.

Maintainer ruling, 2026-08-31 (quoted unchanged): 「membership 数组中的 null
**成员**触发与 null 标量同款处置——`unresolved-variable` / deny sentinel,⛔ 不
strip、不静默清洗。」

**Why refuse rather than strip.** Stripping the unresolved member is safe in
POSITIVE polarity only. `not in` is a supported, pinned member of the pushdown
subset (`!(x in y)` lowers to `$not` wrapping `$in`), and `$in: []` matches
nothing on every backend — so `$not { $in: [] }` matches the WHOLE table.
Stripping therefore inverts into fail-OPEN exactly where the predicate is a
blocklist, and it silently deletes a blocklist entry in the mixed
`['u1', null]` case. Refusing needs no polarity awareness at all: it throws
before any `$not` wrapper is built. Both polarities are pinned.

**No shipped behaviour changes.** No first-party provider puts a null into a
membership array — `resolve-authz-context.ts` filters non-strings out of
`org_user_ids`, and the kernel spec declares `org_user_ids: z.array(z.string())`
— so the refused shape was never a declared-valid input. This makes the
implementation match the declaration rather than narrowing it. A fully resolved
list, an empty list (`$in: []`, a legitimate declared predicate) and the
authoring-time `isPushdownableCel` shape gate are all unchanged and pinned so.

Out of scope, deliberately: an AUTHORED literal null inside a list
(`record.status in ['lost', null]`) is a declared predicate, not an unresolved
variable, and what such a filter should select is a separate open question. It
is untouched, with a pin recording that.
103 changes: 103 additions & 0 deletions packages/formula/src/cel-to-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -216,3 +216,106 @@ describe('compileCelToFilter — input shapes', () => {
expect(r.ok && r.filter).toEqual({ dept: 'sales' });
});
});

/**
* A null/undefined MEMBER of a resolved membership array fails closed, like the
* null SCALAR variable already pinned above (maintainer ruling, 2026-08-31).
*
* BOTH POLARITIES are pinned, and that is the point of the suite rather than a
* completeness flourish. The alternative repair — stripping the unresolved member
* — is safe in POSITIVE polarity (`$in` over the surviving members never grants
* more than those members grant) and INVERTS under the supported `not in` form:
* `!(x in y)` lowers to `$not` wrapping `$in`, and `$in: []` matching nothing makes
* `$not { $in: [] }` match the whole table. A positive-only suite is green for both
* repairs and therefore pins nothing about the one that was ruled on.
*/
describe('compileCelToFilter — a null MEMBER of a membership array fails closed', () => {
const vars = (org_user_ids: unknown[]) => ({ current_user: { id: 'u_me', org_user_ids } });
/**
* `ok` above pins its second argument to the exact shape of the module-level
* `VARS`, so it cannot take these partial contexts. Same assertion, same throw
* on an unexpected refusal, widened only where this suite needs it.
*/
const filterOf = (src: string, v: Record<string, unknown>) => {
const r = compileCelToFilter(src, { variables: v });
if (!r.ok) throw new Error(`expected ok for "${src}" but got ${r.reason}: ${r.detail}`);
return r.filter;
};
const expectUnresolved = (r: ReturnType<typeof compileCelToFilter>, path = 'current_user.org_user_ids') => {
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toBe('unresolved-variable');
expect(r.detail).toContain(path);
expect(r.detail).toContain('unresolved member');
};

// ---- POSITIVE polarity: `x in y` -> $in ---------------------------------
it('positive: null among resolved members → unresolved-variable (no $in emitted)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', null]) }));
});
it('positive: a lone null member → unresolved-variable', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) }));
});
it('positive: an undefined member fails closed too (the scalar path refuses both)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', undefined]) }));
});

// ---- NEGATIVE polarity: `!(x in y)` -> $not wrapping $in ----------------
// This is where stripping inverted into allow-all; refusing must reach the SAME
// result here as in positive polarity, with no polarity threading in the lowerer.
it('negated `not in`: null among resolved members → unresolved-variable (no $not{$in} emitted)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars(['u_me', null]) }));
});
it('negated `not in`: a lone null member → unresolved-variable, NOT $not{$in:[]} (whole table)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) }));
});
it('negated inside a disjunction: the surviving disjunct does not rescue the compile', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) || owner == current_user.id", {
variables: vars([null]),
}),
);
});
it('negated inside a conjunction fails closed as well', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) && owner == current_user.id", {
variables: vars(['u_me', null]),
}),
);
});

// ---- the two polarities agree, which is the ruling's "same treatment" ----
it('both polarities and the null SCALAR variable yield the identical reason', () => {
const scalar = compileCelToFilter('record.organization_id == current_user.organization_id', {
variables: { current_user: { organization_id: null } },
});
const positive = compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) });
const negated = compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) });
const reasons = [scalar, positive, negated].map((r) => (r.ok ? 'ok' : r.reason));
expect(reasons).toEqual(['unresolved-variable', 'unresolved-variable', 'unresolved-variable']);
});

// ---- shapes this guard must NOT move --------------------------------------
it('a fully resolved membership array still compiles, in both polarities', () => {
expect(filterOf('id in current_user.org_user_ids', vars(['u_me', 'u_peer']))).toEqual({
id: { $in: ['u_me', 'u_peer'] },
});
expect(filterOf('!(id in current_user.org_user_ids)', vars(['u_me', 'u_peer']))).toEqual({
$not: { id: { $in: ['u_me', 'u_peer'] } },
});
});
it('an EMPTY membership array still compiles to $in:[] in both polarities (a declared predicate, unchanged here)', () => {
expect(filterOf('id in current_user.org_user_ids', vars([]))).toEqual({ id: { $in: [] } });
expect(filterOf('!(id in current_user.org_user_ids)', vars([]))).toEqual({ $not: { id: { $in: [] } } });
});
it('an AUTHORED literal null in a list is not an unresolved variable — out of this guard scope', () => {
// A declared predicate, the same way `record.x == null` lowers to `$null` rather
// than failing closed. What such a filter SELECTS is a separate open question;
// this pin records only that the compiler still lowers it, unchanged.
expect(ok("record.status in ['lost', null]")).toEqual({ status: { $in: ['lost', null] } });
});
it('isPushdownableCel is untouched: the authoring gate resolves no variables', () => {
expect(isPushdownableCel('id in current_user.org_user_ids').ok).toBe(true);
expect(isPushdownableCel('!(id in current_user.org_user_ids)').ok).toBe(true);
});
});
34 changes: 33 additions & 1 deletion packages/formula/src/cel-to-filter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,7 +39,10 @@
* `current_user.org_user_ids` → a pre-resolved membership array for `$in`
* (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits
* a subquery). A variable that resolves to `undefined`/`null` yields
* `unresolved-variable` (the "no active org" fail-closed path).
* `unresolved-variable` (the "no active org" fail-closed path) — and so does a
* null/undefined MEMBER of a resolved membership array, which is the same
* unresolved value one level in. See {@link lowerMembership} for why the member
* is refused rather than dropped.
*/

import type { ASTNode } from '@marcbachmann/cel-js';
Expand DownExpand Up@@ -366,6 +369,35 @@ function lowerMembership(elemNode: ASTNode, containerNode: ASTNode, ctx: Ctx): F
if (value !== SHAPE_VALUE && !Array.isArray(value)) {
throw new CompileError('unsupported', `\`in\` requires an array/list on the right`);
}
// A null/undefined MEMBER of a RESOLVED membership variable fails closed, exactly
// as the scalar `resolveValue` path does one level up: the same unresolved value,
// the same `unresolved-variable` reason, the same deny sentinel downstream. Until
// this guard the member was emitted verbatim into a security `$in`, so the one
// shape that IS a permission predicate was the one shape that did not fail closed.
//
// Refused, never dropped. Stripping the member was measured to INVERT under the
// supported `not in` form (`!(x in y)` → `$not` wrapping `$in`): `$in: []` matches
// nothing, so `$not { $in: [] }` matches the WHOLE table. "Matches nothing" is
// fail-closed in POSITIVE polarity only, which makes stripping fail-OPEN precisely
// where the predicate is a blocklist. Refusing here needs no polarity awareness at
// all — it throws before any `$not` wrapper is built, so every enclosing shape
// (`!`, `&&`, `||`) collapses to the single `unresolved-variable` result.
//
// Deliberately NOT this guard's business: an AUTHORED literal null inside a list
// (`record.status in ['lost', null]`). That is a declared predicate rather than an
// unresolved variable — the same distinction `== null` already draws, where a
// literal null lowers to `$null` instead of failing closed — and what such a
// filter should SELECT is a separate open question this compiler does not answer.
if (container.kind === 'var' && Array.isArray(value)) {
const idx = value.findIndex((member) => member === null || member === undefined);
if (idx !== -1) {
throw new CompileError(
'unresolved-variable',
`variable "${container.path.join('.')}" has an unresolved member at index ${idx} ` +
`(${String(value[idx])}); a membership array must resolve every member`,
);
}
}
return { [(elem as { path: string }).path]: { $in: value } } as FilterCondition;
}

Expand Down
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(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) by claude[bot] · Pull Request #13630 · 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
43 changes: 43 additions & 0 deletions .changeset/cel-pushdown-membership-null-member-fail-closed.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/formula": patch
---

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496)

`compileCelToFilter` already fails closed when a `current_user.*` variable
resolves to `undefined`/`null` — the module's docblock calls it "the no active
org fail-closed path" and it is pinned for the SCALAR case. `lowerMembership`
did not apply the same discipline one level in: it checked only
`Array.isArray(value)` and emitted the list verbatim, so a null MEMBER of a
resolved membership array went straight into a security `$in`. The one shape
that IS a permission predicate was the one shape that did not fail closed.

`lowerMembership` now refuses a `null`/`undefined` member of a **variable-resolved**
membership array with the same `unresolved-variable` reason the scalar path
uses, which the RLS path already turns into the deny sentinel.

Maintainer ruling, 2026-08-31 (quoted unchanged): 「membership 数组中的 null
**成员**触发与 null 标量同款处置——`unresolved-variable` / deny sentinel,⛔ 不
strip、不静默清洗。」

**Why refuse rather than strip.** Stripping the unresolved member is safe in
POSITIVE polarity only. `not in` is a supported, pinned member of the pushdown
subset (`!(x in y)` lowers to `$not` wrapping `$in`), and `$in: []` matches
nothing on every backend — so `$not { $in: [] }` matches the WHOLE table.
Stripping therefore inverts into fail-OPEN exactly where the predicate is a
blocklist, and it silently deletes a blocklist entry in the mixed
`['u1', null]` case. Refusing needs no polarity awareness at all: it throws
before any `$not` wrapper is built. Both polarities are pinned.

**No shipped behaviour changes.** No first-party provider puts a null into a
membership array — `resolve-authz-context.ts` filters non-strings out of
`org_user_ids`, and the kernel spec declares `org_user_ids: z.array(z.string())`
— so the refused shape was never a declared-valid input. This makes the
implementation match the declaration rather than narrowing it. A fully resolved
list, an empty list (`$in: []`, a legitimate declared predicate) and the
authoring-time `isPushdownableCel` shape gate are all unchanged and pinned so.

Out of scope, deliberately: an AUTHORED literal null inside a list
(`record.status in ['lost', null]`) is a declared predicate, not an unresolved
variable, and what such a filter should select is a separate open question. It
is untouched, with a pin recording that.
103 changes: 103 additions & 0 deletions packages/formula/src/cel-to-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -216,3 +216,106 @@ describe('compileCelToFilter — input shapes', () => {
expect(r.ok && r.filter).toEqual({ dept: 'sales' });
});
});

/**
* A null/undefined MEMBER of a resolved membership array fails closed, like the
* null SCALAR variable already pinned above (maintainer ruling, 2026-08-31).
*
* BOTH POLARITIES are pinned, and that is the point of the suite rather than a
* completeness flourish. The alternative repair — stripping the unresolved member
* — is safe in POSITIVE polarity (`$in` over the surviving members never grants
* more than those members grant) and INVERTS under the supported `not in` form:
* `!(x in y)` lowers to `$not` wrapping `$in`, and `$in: []` matching nothing makes
* `$not { $in: [] }` match the whole table. A positive-only suite is green for both
* repairs and therefore pins nothing about the one that was ruled on.
*/
describe('compileCelToFilter — a null MEMBER of a membership array fails closed', () => {
const vars = (org_user_ids: unknown[]) => ({ current_user: { id: 'u_me', org_user_ids } });
/**
* `ok` above pins its second argument to the exact shape of the module-level
* `VARS`, so it cannot take these partial contexts. Same assertion, same throw
* on an unexpected refusal, widened only where this suite needs it.
*/
const filterOf = (src: string, v: Record<string, unknown>) => {
const r = compileCelToFilter(src, { variables: v });
if (!r.ok) throw new Error(`expected ok for "${src}" but got ${r.reason}: ${r.detail}`);
return r.filter;
};
const expectUnresolved = (r: ReturnType<typeof compileCelToFilter>, path = 'current_user.org_user_ids') => {
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toBe('unresolved-variable');
expect(r.detail).toContain(path);
expect(r.detail).toContain('unresolved member');
};

// ---- POSITIVE polarity: `x in y` -> $in ---------------------------------
it('positive: null among resolved members → unresolved-variable (no $in emitted)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', null]) }));
});
it('positive: a lone null member → unresolved-variable', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) }));
});
it('positive: an undefined member fails closed too (the scalar path refuses both)', () => {
expectUnresolved(compileCelToFilter('id in current_user.org_user_ids', { variables: vars(['u_me', undefined]) }));
});

// ---- NEGATIVE polarity: `!(x in y)` -> $not wrapping $in ----------------
// This is where stripping inverted into allow-all; refusing must reach the SAME
// result here as in positive polarity, with no polarity threading in the lowerer.
it('negated `not in`: null among resolved members → unresolved-variable (no $not{$in} emitted)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars(['u_me', null]) }));
});
it('negated `not in`: a lone null member → unresolved-variable, NOT $not{$in:[]} (whole table)', () => {
expectUnresolved(compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) }));
});
it('negated inside a disjunction: the surviving disjunct does not rescue the compile', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) || owner == current_user.id", {
variables: vars([null]),
}),
);
});
it('negated inside a conjunction fails closed as well', () => {
expectUnresolved(
compileCelToFilter("!(id in current_user.org_user_ids) && owner == current_user.id", {
variables: vars(['u_me', null]),
}),
);
});

// ---- the two polarities agree, which is the ruling's "same treatment" ----
it('both polarities and the null SCALAR variable yield the identical reason', () => {
const scalar = compileCelToFilter('record.organization_id == current_user.organization_id', {
variables: { current_user: { organization_id: null } },
});
const positive = compileCelToFilter('id in current_user.org_user_ids', { variables: vars([null]) });
const negated = compileCelToFilter('!(id in current_user.org_user_ids)', { variables: vars([null]) });
const reasons = [scalar, positive, negated].map((r) => (r.ok ? 'ok' : r.reason));
expect(reasons).toEqual(['unresolved-variable', 'unresolved-variable', 'unresolved-variable']);
});

// ---- shapes this guard must NOT move --------------------------------------
it('a fully resolved membership array still compiles, in both polarities', () => {
expect(filterOf('id in current_user.org_user_ids', vars(['u_me', 'u_peer']))).toEqual({
id: { $in: ['u_me', 'u_peer'] },
});
expect(filterOf('!(id in current_user.org_user_ids)', vars(['u_me', 'u_peer']))).toEqual({
$not: { id: { $in: ['u_me', 'u_peer'] } },
});
});
it('an EMPTY membership array still compiles to $in:[] in both polarities (a declared predicate, unchanged here)', () => {
expect(filterOf('id in current_user.org_user_ids', vars([]))).toEqual({ id: { $in: [] } });
expect(filterOf('!(id in current_user.org_user_ids)', vars([]))).toEqual({ $not: { id: { $in: [] } } });
});
it('an AUTHORED literal null in a list is not an unresolved variable — out of this guard scope', () => {
// A declared predicate, the same way `record.x == null` lowers to `$null` rather
// than failing closed. What such a filter SELECTS is a separate open question;
// this pin records only that the compiler still lowers it, unchanged.
expect(ok("record.status in ['lost', null]")).toEqual({ status: { $in: ['lost', null] } });
});
it('isPushdownableCel is untouched: the authoring gate resolves no variables', () => {
expect(isPushdownableCel('id in current_user.org_user_ids').ok).toBe(true);
expect(isPushdownableCel('!(id in current_user.org_user_ids)').ok).toBe(true);
});
});
34 changes: 33 additions & 1 deletion packages/formula/src/cel-to-filter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,7 +39,10 @@
* `current_user.org_user_ids` → a pre-resolved membership array for `$in`
* (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits
* a subquery). A variable that resolves to `undefined`/`null` yields
* `unresolved-variable` (the "no active org" fail-closed path).
* `unresolved-variable` (the "no active org" fail-closed path) — and so does a
* null/undefined MEMBER of a resolved membership array, which is the same
* unresolved value one level in. See {@link lowerMembership} for why the member
* is refused rather than dropped.
*/

import type { ASTNode } from '@marcbachmann/cel-js';
Expand DownExpand Up@@ -366,6 +369,35 @@ function lowerMembership(elemNode: ASTNode, containerNode: ASTNode, ctx: Ctx): F
if (value !== SHAPE_VALUE && !Array.isArray(value)) {
throw new CompileError('unsupported', `\`in\` requires an array/list on the right`);
}
// A null/undefined MEMBER of a RESOLVED membership variable fails closed, exactly
// as the scalar `resolveValue` path does one level up: the same unresolved value,
// the same `unresolved-variable` reason, the same deny sentinel downstream. Until
// this guard the member was emitted verbatim into a security `$in`, so the one
// shape that IS a permission predicate was the one shape that did not fail closed.
//
// Refused, never dropped. Stripping the member was measured to INVERT under the
// supported `not in` form (`!(x in y)` → `$not` wrapping `$in`): `$in: []` matches
// nothing, so `$not { $in: [] }` matches the WHOLE table. "Matches nothing" is
// fail-closed in POSITIVE polarity only, which makes stripping fail-OPEN precisely
// where the predicate is a blocklist. Refusing here needs no polarity awareness at
// all — it throws before any `$not` wrapper is built, so every enclosing shape
// (`!`, `&&`, `||`) collapses to the single `unresolved-variable` result.
//
// Deliberately NOT this guard's business: an AUTHORED literal null inside a list
// (`record.status in ['lost', null]`). That is a declared predicate rather than an
// unresolved variable — the same distinction `== null` already draws, where a
// literal null lowers to `$null` instead of failing closed — and what such a
// filter should SELECT is a separate open question this compiler does not answer.
if (container.kind === 'var' && Array.isArray(value)) {
const idx = value.findIndex((member) => member === null || member === undefined);
if (idx !== -1) {
throw new CompileError(
'unresolved-variable',
`variable "${container.path.join('.')}" has an unresolved member at index ${idx} ` +
`(${String(value[idx])}); a membership array must resolve every member`,
);
}
}
return { [(elem as { path: string }).path]: { $in: value } } as FilterCondition;
}

Expand Down
Loading