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
75 changes: 75 additions & 0 deletions .changeset/rls-predicate-authoring-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
"@objectstack/formula": minor
"@objectstack/lint": minor
"@objectstack/plugin-security": patch
---

feat(formula,lint): wire ADR-0056 D4's RLS authoring gate, from the runtime's own predicate (#4983)

`isSupportedRlsExpression` has carried the same docblock since ADR-0056 D4:
"exposed so an authoring-time gate (`objectstack compile`) can REJECT a
predicate the runtime would silently drop … A `false` here means 'this
predicate will never enforce'." It had **no non-test consumer anywhere** — the
function written to fix declared-but-never-read was itself declared and never
read. This lands the consumer, in two steps that had to happen in this order.

**1. `sqlPredicateToCel` and `isSupportedRlsExpression` move FROM
`@objectstack/plugin-security` (`src/rls-compiler.ts`) TO `@objectstack/formula`
(`src/rls-predicate.ts`), and are exported from its root.** Executable code
unchanged — a change of address, not of behaviour; `plugin-security` now imports
them from `@objectstack/formula` and keeps no copy, so there is still exactly
one definition. No import path outside the two packages changes: neither symbol
was ever exported from `@objectstack/plugin-security`'s entry point. The move is
what makes step 2 possible at all — `@objectstack/lint` may depend on
`@objectstack/spec` and never on a runtime, so with the predicate living in a
runtime the gate's only other door was copying the SQL→CEL bridge, whose
boundary conditions (quoted literals are never rewritten; canonical CEL passes
through unchanged) *are* the gate's red/green line. A fork drifting by one
character rejects policies the runtime executes correctly — the false-positive
direction, which is worse than the gap. ADR-0058 D1 asks for a single canonical
shape gate; the bridge is part of that gate.

**2. New `@objectstack/lint` rule `validateRlsPredicateEnforceability`,
`error`, on all three authoring commands**, over
`permissions[].rowLevelSecurity[].using` and `.check`:

- **`rls-predicate-unenforceable`** — parses as CEL, outside the pushdown
subset: a function call (`size(...)`, `has(...)`), arithmetic, a ternary, a
cross-object path (`record.account.region`).
- **`rls-predicate-unparseable`** — does not parse as CEL even after the legacy
SQL bridge (`=` → `==`, `IN` → `in`): SQL `AND` / `OR` / `LIKE`, a subquery.
Its own id because the fix is different — write CEL (`&&`, `||`), not a
different shape.

What the gate prevents, measured through `plugin-security` rather than inferred:
`RLSCompiler` drops the policy and logs one request-time WARN. On the read path,
when it is the only applicable policy, `compileFilter` returns the
`RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause — so
every select / update / delete on the object matches **zero rows**. On the
ADR-0058 D4 write path the post-image `check` becomes that same sentinel, which
no record satisfies, so every insert / update fails with `PermissionDeniedError`.
The runtime fails closed, which is why this was survivable: the result is not a
hole but a policy that reads as an authorization and behaves as a blanket
refusal, with nothing at authoring time pointing at the line that caused it.

Fix a flagged predicate by rewriting it inside the lowerable subset — `==` `!=`
`>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and
`startsWith` / `endsWith` / `contains` over single-column field paths (ADR-0058
D2), against a literal or a `current_user.*` value. Two specific migrations:
`has(x)` / `size(x) > 0` → `x != null` (a function call is correct in an object
*validation* rule, which is interpreted, and wrong here, where the predicate is
compiled to a filter); and a related record's field → denormalise it onto this
object (formula/rollup) and test that column, since RLS cannot join (ADR-0055).

Same construction as the sharing-rule gate (#4698): the rule does not model the
consumer or grep for it — it calls `isSupportedRlsExpression`, the exact
function `RLSCompiler.compileFilter` consults to decide whether a dropped policy
earns its warning, so the two verdicts are one boolean by construction, pinned
in both directions over a shared corpus. Measured before shipping: every RLS
predicate declared anywhere in this repo — the `plugin-security` platform seeds,
the examples, the dogfood fixtures, the authoring skill — is supported, so the
gate turns nothing red that works today. Unlike the sharing-rule gate, CEL
*syntax* is reported here rather than deferred to `expression-invalid`:
`validateStackExpressions` does not walk `rowLevelSecurity` at all, and could not
judge this field correctly if it did, because `owner_id = current_user.id` is a
CEL syntax error and a working RLS predicate at the same time.
7 changes: 7 additions & 0 deletions packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export { normalizeExpression, normalizeExpressionTree } from './normalize';
// and plugin-sharing; honours ADR-0055 (no subquery / no cross-object traversal).
export { compileCelToFilter, isPushdownableCel, lowerCelAst } from './cel-to-filter';
export type { CelFilterCompileResult, CelFilterCompileOptions, CelFilterFailReason } from './cel-to-filter';
// ADR-0056 D4 / ADR-0058 D1 — the RLS predicate shape gate and its legacy
// SQL→CEL bridge. Hoisted out of plugin-security in #4983 so the runtime that
// enforces the predicate and the authoring gate that rejects it share ONE
// definition: `@objectstack/lint` may depend on this package and never on a
// runtime, so the alternative was forking the bridge, whose `=`/`IN` boundary
// conditions ARE the red/green line.
export { isSupportedRlsExpression, sqlPredicateToCel } from './rls-predicate';
export { matchesFilterCondition } from './matches-filter';
// ADR-0032 — shared validator + introspection (one validator for build,
// registration, and the agent-callable validate_expression tool).
Expand Down
143 changes: 143 additions & 0 deletions packages/formula/src/rls-predicate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The unit tests that travelled with `isSupportedRlsExpression` /
* `sqlPredicateToCel` when #4983 hoisted them out of
* `@objectstack/plugin-security` (`security-plugin.test.ts`, describe block
* "RLSCompiler D4 — uncompilable predicates are surfaced"). The two shape cases
* are reproduced VERBATIM below: the hoist is a change of address, so a moved
* test that also changes its assertions would hide the one thing the move has
* to prove. The consumer-side half — that `RLSCompiler` still warns, still
* fails closed, and still agrees with this predicate — stayed in
* plugin-security, where the consumer is.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { isSupportedRlsExpression, sqlPredicateToCel } from './rls-predicate';
import { isPushdownableCel } from './cel-to-filter';

// ---------------------------------------------------------------------------
// ADR-0056 D4 — RLS predicates that won't compile must not vanish in silence
// (moved verbatim from plugin-security/src/security-plugin.test.ts, #4983)
// ---------------------------------------------------------------------------
describe('isSupportedRlsExpression — the ADR-0056 D4 shape gate', () => {
it('isSupportedRlsExpression accepts the compilable shapes', () => {
// Legacy SQL-ish subset (bridged `=`/`IN`).
expect(isSupportedRlsExpression('owner_id = current_user.id')).toBe(true);
expect(isSupportedRlsExpression('owner = current_user.email')).toBe(true);
expect(isSupportedRlsExpression("status = 'published'")).toBe(true);
expect(isSupportedRlsExpression('id IN (current_user.org_user_ids)')).toBe(true);
expect(isSupportedRlsExpression('1 = 1')).toBe(true);
// ADR-0058: the canonical compiler lowers a broader pushdown subset, so the
// shape gate now (correctly) reports these as enforceable — `==`/`!=`,
// comparisons, and CEL compound predicates all compile to a FilterCondition.
expect(isSupportedRlsExpression('owner == current_user.id')).toBe(true); // `==`
expect(isSupportedRlsExpression('amount > 100')).toBe(true); // comparison
expect(isSupportedRlsExpression('region != null')).toBe(true); // null check
expect(isSupportedRlsExpression('a == 1 && b == 2')).toBe(true); // CEL compound
});

it('isSupportedRlsExpression rejects genuinely non-pushdownable shapes', () => {
// These cannot lower to a FilterCondition for ANY input, so the gate must
// reject them (ADR-0055 / ADR-0056 D4) — they fail closed at runtime.
expect(isSupportedRlsExpression('a = current_user.id AND b = 1')).toBe(false); // SQL AND ≠ CEL && (unparseable)
expect(isSupportedRlsExpression('amount + 1 > 2')).toBe(false); // arithmetic
expect(isSupportedRlsExpression('id IN (SELECT id FROM users)')).toBe(false); // subquery
expect(isSupportedRlsExpression('record.a.b == 1')).toBe(false); // cross-object traversal
expect(isSupportedRlsExpression('')).toBe(false);
});
});

// ---------------------------------------------------------------------------
// The bridge's boundary conditions — the reason a COPY of it was unacceptable
// ---------------------------------------------------------------------------
//
// `sqlPredicateToCel` is a regex rewrite, and its edge cases are precisely the
// red/green line of the authoring gate built on it (#4983). A second
// implementation drifting by one character would make `os validate` reject
// policies the runtime executes correctly — the false-positive direction, which
// is worse than the gap. Pinning them here is what makes ONE definition worth
// insisting on.

describe('sqlPredicateToCel — the legacy bridge, pinned at its boundaries', () => {
it('rewrites the historically-supported SQL subset', () => {
expect(sqlPredicateToCel('owner_id = current_user.id')).toBe('owner_id == current_user.id');
expect(sqlPredicateToCel('id IN (current_user.org_user_ids)')).toBe('id in (current_user.org_user_ids)');
expect(sqlPredicateToCel('1 = 1')).toBe('1 == 1');
});

it('never rewrites inside a quoted string literal', () => {
expect(sqlPredicateToCel("status = 'a = b'")).toBe("status == 'a = b'");
expect(sqlPredicateToCel("note = 'IN transit'")).toBe("note == 'IN transit'");
});

it('is IDEMPOTENT on canonical CEL — an authored predicate passes through unchanged', () => {
for (const cel of [
'owner_id == current_user.id',
'id in current_user.org_user_ids',
'amount >= 100',
'amount <= 100',
'region != null',
"a == 1 && b == 'x'",
]) {
expect(sqlPredicateToCel(cel)).toBe(cel);
expect(sqlPredicateToCel(sqlPredicateToCel(cel))).toBe(cel);
}
});

it('leaves comparison operators containing `=` alone', () => {
// The lookbehind/lookahead exist for these: `>=`, `<=`, `!=`, `==`.
expect(sqlPredicateToCel('a >= 1')).toBe('a >= 1');
expect(sqlPredicateToCel('a <= 1')).toBe('a <= 1');
expect(sqlPredicateToCel('a != 1')).toBe('a != 1');
});
});

// ---------------------------------------------------------------------------
// The composition the gate depends on
// ---------------------------------------------------------------------------

describe('isSupportedRlsExpression — composition and dependency direction', () => {
it('is exactly `isPushdownableCel(sqlPredicateToCel(x)).ok` for a non-blank predicate', () => {
const corpus = [
'owner_id = current_user.id',
"status = 'published'",
'id IN (current_user.org_user_ids)',
'amount > 100',
'a == 1 && b == 2',
'amount + 1 > 2',
'size(record.tags) > 0',
"record.account.region == 'EU'",
'a = current_user.id AND b = 1',
];
for (const source of corpus) {
expect({ source, ok: isSupportedRlsExpression(source) })
.toEqual({ source, ok: isPushdownableCel(sqlPredicateToCel(source)).ok });
}
});

/**
* #4983's hard constraint: the direction is `plugin-security` → `formula` and
* `lint` → `formula`, NEVER the reverse. `@objectstack/formula` depends on
* `@objectstack/spec` alone (see its package.json), and this module may not
* quietly acquire a runtime import — that would put the hoisted predicate back
* out of `@objectstack/lint`'s reach ("Depends on @objectstack/spec; never on
* a runtime") and undo the whole move. Asserted against the source, because
* a dependency that is only wrong at build time produces no failing assertion.
*/
it('never imports a runtime — the hoist direction is pinned, not just intended', () => {
const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(here, 'rls-predicate.ts'), 'utf8');
const specifiers = [...source.matchAll(/from\s+'([^']+)'/g)].map((m) => m[1]);
expect(specifiers).toEqual(['./cel-to-filter']);

const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as {
dependencies?: Record<string, string>;
};
expect(Object.keys(pkg.dependencies ?? {}).sort()).toEqual(['@marcbachmann/cel-js', '@objectstack/spec']);
});
});
79 changes: 79 additions & 0 deletions packages/formula/src/rls-predicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The RLS predicate shape gate, and the legacy SQL→CEL bridge it stands on.
*
* **Hoisted here from `@objectstack/plugin-security` (`src/rls-compiler.ts`) in
* #4983 — executable code unchanged, address changed.** Both functions were
* already pure `(string) => …` over `isPushdownableCel`; neither ever read a
* runtime service, an `ExecutionContext` or a policy record, so nothing about
* them needed a runtime to live in. What their old address DID do was put the
* one decision procedure for "will this RLS predicate ever enforce?" behind a
* package `@objectstack/lint` is forbidden to import ("Depends on
* @objectstack/spec; never on a runtime"), which left the ADR-0056 D4
* authoring gate two impossible doors: import a runtime, or fork the bridge.
* A forked bridge is the worse one — `sqlPredicateToCel`'s `=` / `IN` boundary
* conditions (quoted literals are never rewritten; CEL input passes through
* unchanged) ARE the red/green line, so one drifting character makes the linter
* reject policies the runtime executes correctly. Hoisting keeps ONE definition
* and lets the gate call the consumer's own verdict (ADR-0058 D1: a single
* canonical shape gate).
*
* `plugin-security` imports both from here; `@objectstack/lint` imports
* {@link isSupportedRlsExpression} for the authoring gate. The dependency
* direction is security → formula and lint → formula, never the reverse —
* pinned by `rls-predicate.test.ts`'s import-graph assertion.
*/

import { isPushdownableCel } from './cel-to-filter';

/**
* Recognize whether an RLS `using` / `check` expression matches one of the SHAPES
* the compiler can compile (equality against a `current_user.*` var, equality
* against a string literal, set-membership against a `current_user.*` array, or
* the `1 = 1` allow-all). This is SHAPE-only — it does not check whether the
* referenced context variable is populated at runtime.
*
* ADR-0056 D4: exposed so an authoring-time gate (`objectstack compile`) can REJECT
* a predicate the runtime would silently drop — the class of bug where
* `owner == current_user.name` (`==`, unsupported) compiled to nothing and left an
* object unprotected. A `false` here means "this predicate will never enforce".
*
* That gate exists as of #4983: `validateRlsPredicateEnforceability` in
* `@objectstack/lint` calls THIS function on every
* `permissions[].rowLevelSecurity[].using` / `.check`, so the sentence above is
* no longer aspirational. Until then the function had no non-test consumer
* anywhere — a declared-but-never-read helper written to fix
* declared-but-never-read.
*/
export function isSupportedRlsExpression(expression: string): boolean {
if (!expression || !expression.trim()) return false;
// ADR-0058 D1: a single canonical shape gate. We bridge the legacy SQL-ish
// subset (`=`, `IN`) to canonical CEL, then ask the ONE pushdown compiler
// whether the shape lowers to a FilterCondition at all. This is broader than
// the historical 4 forms — comparisons (`amount > 100`) and `==` now ENFORCE
// (the compiler lowers them), so the gate correctly reports them supported.
// It is SHAPE-only: whether a referenced `current_user.*` variable is exposed
// at runtime is a separate availability concern (an unexposed var fails closed
// at resolution — see RLSCompiler.compileExpression).
return isPushdownableCel(sqlPredicateToCel(expression)).ok;
}

/**
* @deprecated Transitional bridge (ADR-0058 D1). Canonical RLS predicates are
* CEL; this exists ONLY so stored/legacy SQL-ish `using`/`check` keeps compiling
* until it is migrated. Bridge the legacy SQL subset to canonical CEL so it flows
* through the ONE compiler: `=` → `==`, `IN` → `in`. Quoted string literals are
* left untouched. It is IDEMPOTENT on CEL input (a `==`/`in` predicate is
* unchanged), so authored-CEL seeds pass through as no-ops (no deprecation warn). Only this historically-supported subset is bridged — compound
* predicates should be authored in canonical CEL (`&&` / `||`); anything outside
* the subset (subqueries, SQL `AND`/`OR`, `LIKE`) stays unparseable and so fails
* closed, exactly as before.
*/
export function sqlPredicateToCel(expression: string): string {
return expression.replace(/'[^']*'|\bIN\b|(?<![<>=!])=(?!=)/gi, (m) => {
if (m[0] === "'") return m; // quoted literal — never rewrite its contents
if (m === '=') return '==';
return 'in'; // IN / in / In → CEL membership operator
});
}
Loading
Loading