|
| 1 | +--- |
| 2 | +"@objectstack/formula": minor |
| 3 | +"@objectstack/lint": minor |
| 4 | +"@objectstack/plugin-security": patch |
| 5 | +--- |
| 6 | + |
| 7 | +feat(formula,lint): wire ADR-0056 D4's RLS authoring gate, from the runtime's own predicate (#4983) |
| 8 | + |
| 9 | +`isSupportedRlsExpression` has carried the same docblock since ADR-0056 D4: |
| 10 | +"exposed so an authoring-time gate (`objectstack compile`) can REJECT a |
| 11 | +predicate the runtime would silently drop … A `false` here means 'this |
| 12 | +predicate will never enforce'." It had **no non-test consumer anywhere** — the |
| 13 | +function written to fix declared-but-never-read was itself declared and never |
| 14 | +read. This lands the consumer, in two steps that had to happen in this order. |
| 15 | + |
| 16 | +**1. `sqlPredicateToCel` and `isSupportedRlsExpression` move FROM |
| 17 | +`@objectstack/plugin-security` (`src/rls-compiler.ts`) TO `@objectstack/formula` |
| 18 | +(`src/rls-predicate.ts`), and are exported from its root.** Executable code |
| 19 | +unchanged — a change of address, not of behaviour; `plugin-security` now imports |
| 20 | +them from `@objectstack/formula` and keeps no copy, so there is still exactly |
| 21 | +one definition. No import path outside the two packages changes: neither symbol |
| 22 | +was ever exported from `@objectstack/plugin-security`'s entry point. The move is |
| 23 | +what makes step 2 possible at all — `@objectstack/lint` may depend on |
| 24 | +`@objectstack/spec` and never on a runtime, so with the predicate living in a |
| 25 | +runtime the gate's only other door was copying the SQL→CEL bridge, whose |
| 26 | +boundary conditions (quoted literals are never rewritten; canonical CEL passes |
| 27 | +through unchanged) *are* the gate's red/green line. A fork drifting by one |
| 28 | +character rejects policies the runtime executes correctly — the false-positive |
| 29 | +direction, which is worse than the gap. ADR-0058 D1 asks for a single canonical |
| 30 | +shape gate; the bridge is part of that gate. |
| 31 | + |
| 32 | +**2. New `@objectstack/lint` rule `validateRlsPredicateEnforceability`, |
| 33 | +`error`, on all three authoring commands**, over |
| 34 | +`permissions[].rowLevelSecurity[].using` and `.check`: |
| 35 | + |
| 36 | +- **`rls-predicate-unenforceable`** — parses as CEL, outside the pushdown |
| 37 | + subset: a function call (`size(...)`, `has(...)`), arithmetic, a ternary, a |
| 38 | + cross-object path (`record.account.region`). |
| 39 | +- **`rls-predicate-unparseable`** — does not parse as CEL even after the legacy |
| 40 | + SQL bridge (`=` → `==`, `IN` → `in`): SQL `AND` / `OR` / `LIKE`, a subquery. |
| 41 | + Its own id because the fix is different — write CEL (`&&`, `||`), not a |
| 42 | + different shape. |
| 43 | + |
| 44 | +What the gate prevents, measured through `plugin-security` rather than inferred: |
| 45 | +`RLSCompiler` drops the policy and logs one request-time WARN. On the read path, |
| 46 | +when it is the only applicable policy, `compileFilter` returns the |
| 47 | +`RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause — so |
| 48 | +every select / update / delete on the object matches **zero rows**. On the |
| 49 | +ADR-0058 D4 write path the post-image `check` becomes that same sentinel, which |
| 50 | +no record satisfies, so every insert / update fails with `PermissionDeniedError`. |
| 51 | +The runtime fails closed, which is why this was survivable: the result is not a |
| 52 | +hole but a policy that reads as an authorization and behaves as a blanket |
| 53 | +refusal, with nothing at authoring time pointing at the line that caused it. |
| 54 | + |
| 55 | +Fix a flagged predicate by rewriting it inside the lowerable subset — `==` `!=` |
| 56 | +`>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and |
| 57 | +`startsWith` / `endsWith` / `contains` over single-column field paths (ADR-0058 |
| 58 | +D2), against a literal or a `current_user.*` value. Two specific migrations: |
| 59 | +`has(x)` / `size(x) > 0` → `x != null` (a function call is correct in an object |
| 60 | +*validation* rule, which is interpreted, and wrong here, where the predicate is |
| 61 | +compiled to a filter); and a related record's field → denormalise it onto this |
| 62 | +object (formula/rollup) and test that column, since RLS cannot join (ADR-0055). |
| 63 | + |
| 64 | +Same construction as the sharing-rule gate (#4698): the rule does not model the |
| 65 | +consumer or grep for it — it calls `isSupportedRlsExpression`, the exact |
| 66 | +function `RLSCompiler.compileFilter` consults to decide whether a dropped policy |
| 67 | +earns its warning, so the two verdicts are one boolean by construction, pinned |
| 68 | +in both directions over a shared corpus. Measured before shipping: every RLS |
| 69 | +predicate declared anywhere in this repo — the `plugin-security` platform seeds, |
| 70 | +the examples, the dogfood fixtures, the authoring skill — is supported, so the |
| 71 | +gate turns nothing red that works today. Unlike the sharing-rule gate, CEL |
| 72 | +*syntax* is reported here rather than deferred to `expression-invalid`: |
| 73 | +`validateStackExpressions` does not walk `rowLevelSecurity` at all, and could not |
| 74 | +judge this field correctly if it did, because `owner_id = current_user.id` is a |
| 75 | +CEL syntax error and a working RLS predicate at the same time. |
0 commit comments