Repository navigation
Commit 1207baf
feat(lint,metadata-protocol,cli)!: RLS read scopes are admitted by the engine judge when authored, at the save door and at os validate / build / lint (#20265)
Fixes #20158
Clause-②: yes (narrowing)
`yes` because two published `@objectstack/lint` signatures gain an
optional `judgeFilter` input, so `@objectstack/lint` is `minor`.
`(narrowing)` because the metadata save door now refuses RLS policies it
accepted before, so the release is BREAKING and the changeset carries
the ADR-0087 marker. Route A, as ruled in seat answer 5856289438 on the
card, following triage 5855733474 and ruling C on #19995 (comment
5852158605).
## What changed
A row-level-security policy is permission-set metadata
(`rowLevelSecurity[]`). Its read-scope `using` could lower cleanly and
still be a filter the engine refuses to run: a text operator on a number
field, a date field compared against a value its storage cannot read, a
filter on a formula field, or a `{…}` placeholder string. Nothing
refused any of these at authoring time. The metadata save door (Studio,
REST `/meta`, MCP) also did not run the RLS predicate rule at all.
- **The rule takes the engine's judge as an input.**
`validateRlsPredicateEnforceability(stack, { judgeFilter })` judges the
lowered `using` of every `select` / `all` policy with
`IObjectQLEngine.judgeFilter`. It runs only when every existing pass
left the clause clean, so one defect produces one finding. The finding
keeps the existing id `rls-predicate-unenforceable` and quotes the
engine's code, status and sentence verbatim. `packages/lint` holds no
model of the engine's checks. Without the input, the pass is skipped and
the rule answers exactly as before.
- **The input is threaded through the run signatures:**
`AuthoringRuleContext.judgeFilter` (so `AuthoringRuleRun.judgeFilter`
for `runAuthoringRules`) and the `judgeFilter` argument of
`runRuntimeAuthoringRules`. The registry entry passes `ctx.judgeFilter`
to the rule.
- **Runtime door.** `assertRuntimeAuthoringRules` (`protocol.ts`) probes
`typeof this.engine.judgeFilter === 'function'`. It passes the method,
bound to the engine, into `evaluateRuntimeAuthoringGate`, which is
internal to the package and stays pure (a new `judgeFilter` argument,
the same pattern as `orgWallEnforced`), and from there to
`runRuntimeAuthoringRules`.
- **CLI door.** The new
`packages/cli/src/utils/authoring-filter-judge.ts` builds an `ObjectQL`
with no driver and no kernel, registers the stack's objects through its
own registry (`applySystemFields` included), and returns its bound
`judgeFilter`. It is built on the first judgement only, and objects are
copied before registration so the rules that run later see the author's
stack untouched. All five `runAuthoringRules` call sites pass it:
`commands/validate.ts`, `commands/compile.ts`, `commands/lint.ts`,
`utils/artifact-packages.ts` (the per-package pass, handed the union's
judge) and `utils/scaffold-validate.ts`.
- **The surfaces flip.** The registry entry is now `surfaces:
CLI_AND_RUNTIME`, `runtimeTypes: ['permission']`. The per-write snapshot
has carried `permissions` and `objects` since #8309, and #8310 closed
with no ruling that kept this rule CLI-only (0 matches for rls / RLS /
rowLevelSecurity / validateRlsPredicate / CLI_ONLY across its body and 2
comments; control: the body names `validateSecurityPosture`).
- No spec change, no new error code, no new rule id.
### The refusal text (new, quoted)
The message the rule builds for an engine refusal. Both doors carry it
byte for byte:
```text
RLS using `SOURCE` lowers, but the engine refuses to run the lowered filter on "OBJECT" (CODE / STATUS): ENGINE_MESSAGE This clause is part of the object's row-level READ SCOPE, and the analytics face hands the read scope to this same engine admission before it runs a query: every analytics query over the object that this policy scopes is refused.
```
The hint, chosen by the engine's `code` (never by its prose):
- `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED`: "A `{…}`
placeholder is saved-filter syntax, and an RLS predicate does not use
it: the predicate is CEL, and the caller's values are the `current_user`
keys (… the kernel-resolved keys …). Write `owner == current_user.id`,
not `owner == '{current_user_id}'`."
- `INVALID_FIELD`: "A row filter reaches only the stored columns of its
own object. Denormalise the value the engine names onto this object (a
stored field, written when the source changes) and test that column."
- any other code: "Rewrite the comparison the engine names so it can
run: a text operator (`startsWith` / `endsWith` / `contains`) only on a
field that holds a string, and a date or datetime field only against a
value its storage can read." followed by the rule's existing
lowerable-subset sentence.
At the save door the refusal is the gate's existing envelope, `422
INVALID_METADATA`, whose `issues[]` entry carries the same `rule`,
`message` and `hint`. The path is keyed by the set's name
(`permissions.sales.rowLevelSecurity[0].using`). The text is the
author's own, so nothing is withheld at authoring time. The runtime
read-scope withhold (#5367) is untouched.
### The judge-pass binding (as ruled)
The judge sees the reference pass's probe with one change: a
`current_user.*` key the kernel does NOT resolve (an app-staged §7.3.1
membership set) is bound to `[]` for the judge pass only. Kernel keys
keep their runtime-typed probe (a user id or an email is never a date,
so the probe stands in faithfully). An app-staged set holds app data
this rule cannot know. The measured artefact: `close_date in
current_user.holidays` lowered with the probe string reaches the engine
as `{ close_date: { $in: ['__objectstack_lint_probe__'] } }`, and the
temporal door refuses it (`INVALID_FILTER`). Lowered with `[]`, the
engine admits it. Pinned at both doors: that clause is accepted, and
`close_date in current_user.holidays && amount.startsWith('5')` is still
refused (the binding does not switch the judge off). At the lint layer,
a recording judge shows it is handed `{ close_date: { $in: [] } }` for
the app key and `{ owner: { $in: ['__objectstack_lint_probe__'] } }` for
`current_user.org_user_ids`.
### The fail-open edge (named, not closed)
At the runtime door, the judge reads the live engine's registry, not the
per-write snapshot. An object that exists only in the same publish batch
(`publishPackageDrafts` pending declarations), or only in an
organization overlay row, is one that registry does not hold, so it gets
the engine's unknown-object answer: the field-map doors (text operator,
temporal, formula, dotted) answer nothing, while the schema-free doors
(placeholders, comparand shape and type) still judge. The runtime
read-scope withhold (#5367) stays the backstop there. As ruled, no
snapshot-based second registry is added to close it. The CLI door gives
the same answer for an object the stack does not define (another
package's, or a platform object), and for an object the registry itself
refuses to register (refused at boot by that same registry).
### What the judge does not see
It judges only `using` clauses that belong to the read scope: `select`
and `all`, the two `getApplicablePolicies` matches for a read. A `check`
is matched in memory against a post-image and never reaches the engine's
`where` admission. A `using` on an `insert` / `update` / `delete`-only
policy is not part of the read scope either. Both are pinned (a
recording judge counts 0 calls for them).
## The fifteen classes, both doors
(`packages/cli/test/rls-policy-authoring-admission.test.ts`)
One table. Each row runs `os validate`'s own composition in process
(`normalizeStackInput` → `lowerCallables` →
`ObjectStackDefinitionSchema` → union fold →
`runAuthoringRules('validate', …)` with `stackFilterJudge`) and
`saveMetaItem({ type: 'permission' })` over a real `ObjectQL` host
(in-memory driver, the real `sys_metadata` objects from
`@objectstack/metadata-core`) holding the same objects. It asserts one
verdict and one sentence (`issues[0].message === cliFinding.message`),
plus the envelope `INVALID_METADATA` / `422` and the path.
| # | Class (#19995) | Family | CEL as written | Rule at both doors |
Engine code quoted | Before (`805af4f2`): `os validate` / save |
|---|---|---|---|---|---|---|
| 1 | list in the implicit equality slot | comparand shape | `region ==
['emea', 'apac']` | `rls-predicate-unenforceable` | — | refused /
accepted |
| 2 | list under `$eq` | comparand shape | `region ==
current_user.org_user_ids` | `rls-predicate-unenforceable` | — | refused
/ accepted |
| 3 | scalar under `$in` | comparand shape | `region in 'emea'` |
`rls-predicate-unenforceable` | — | refused / accepted |
| 4 | scalar under `$nin` | comparand shape | `!(region in 'emea')` |
`rls-predicate-unenforceable` | — | refused / accepted |
| 5 | one-bound `$between` (no CEL spelling; nearest: a range with a
null bound) | comparand shape | `amount >= 1 && amount <= null` |
`rls-predicate-unenforceable` | — | refused / accepted |
| 6 | null member in `$in` | comparand shape | `region in ['emea',
null]` | `rls-predicate-unenforceable` | — | refused / accepted |
| 7 | plain-object member in `$in` | comparand type | `region in [{'a':
1}]` | `rls-predicate-unenforceable` | — | refused / accepted |
| 8 | plain-object comparand under `$eq` | comparand type | `region ==
{'a': 1}` | `rls-predicate-unenforceable` | — | refused / accepted |
| 9 | `undefined` comparand (no CEL spelling; nearest: a key nothing
resolves) | comparand type | `region == current_user.nope` |
`rls-predicate-unknown-user-variable` | — | refused / accepted |
| 10 | unknown placeholder | placeholder | `owner == '{current_usr_id}'`
| `rls-predicate-unenforceable` | `FILTER_TOKEN_UNKNOWN` | accepted /
accepted |
| 11 | known placeholder, no context | placeholder | `owner ==
'{current_user_id}'` | `rls-predicate-unenforceable` |
`FILTER_TOKEN_UNRESOLVED` | accepted / accepted |
| 12 | text operator on a non-text field | schema door |
`amount.startsWith('5')` | `rls-predicate-unenforceable` |
`INVALID_FILTER` | accepted / accepted |
| 13 | temporal comparand the platform cannot read | schema door |
`close_date > 'soon'` | `rls-predicate-unenforceable` | `INVALID_FILTER`
| accepted / accepted |
| 14 | filter on a virtual (formula) field | schema door | `is_open ==
true` | `rls-predicate-unenforceable` | `INVALID_FIELD` | accepted /
accepted |
| 15 | dotted path through a lookup | schema door | `account.region ==
'emea'` | `rls-predicate-unenforceable` | — | refused / accepted |
Controls, accepted at both doors: `owner == current_user.id`, `owner in
current_user.org_user_ids` (the pre-resolved `IN` form) and
`name.startsWith('A')` (a text operator on a text field).
The "Before" column was measured on `805af4f2` with a scratch probe (not
committed). The rule was run directly for `os validate`, and
`saveMetaItem` for a permission write on a stub host. Rows 5 and 9 were
not in that probe. Their `os validate` reading was taken at this head
with no judge passed, which runs exactly the passes `805af4f2` ran: the
rule's diff only adds the judge pass, and `packages/formula` changed no
production file between the two. Rows 1–4, 7, 8 and 15 are CEL shapes
the pushdown compiler refuses, so the existing shape pass already
reports them. Rows 5 and 6 are the existing shared-face pass, and row 9
is the existing unknown-variable pass. The five rows the engine alone
refuses (10–14) were accepted at both doors. Every row was accepted at
the save door, because the rule did not run there.
Placeholder semantics are as `5855081099` records them: a known
placeholder judged with no context answers `FILTER_TOKEN_UNRESOLVED`, an
unknown one `FILTER_TOKEN_UNKNOWN`. No authored policy in the census
uses a `{…}` token, so no mode change is needed and none is added.
## Measurements
- **Producer census, with the real rule and a real judge** (scratch
probe, not committed; tree `cb223b1e`). A driverless `ObjectQL` held
every object definition the tree ships
(`packages/**/src/**/*.object.ts`: 75 files, 75 objects, 0 imports
failed, 0 refused by the registry). The run was
`validateRlsPredicateEnforceability(stack, { judgeFilter })`:
- `plugin-security` `defaultPermissionSets`: 107 clauses, 104 read-scope
`using`, 104 judge calls, **0 findings**. 28 of its 29 policy objects
are known to that engine; the 29th is the `'*'` wildcard, which gets the
unknown-object answer.
- `packages/metadata` hotcrm fixture: 4 clauses, 2 read-scope `using`, 2
judge calls, **0 findings**.
- Positive control, same run: a planted
`user_id.startsWith('{current_user_id}')` on `sys_session` produced 1
finding (`FILTER_TOKEN_UNRESOLVED`).
- `examples/app-showcase` (`allPermissionSets` over its 24 objects,
judged by an engine holding those objects, 0 refused by the registry): 3
clauses, 2 read-scope `using`, 2 judge calls, **0 findings**.
- **Placeholder spelling:** in `examples/**` and `packages/**`
(non-test), 63 of 88 `using:` / `check:` lines name `current_user.` (CEL
variables), and 0 carry a `{token}`. cloud `origin/main` `96eb092` has 0
authored policies (control: `rowLevelSecurity` 1 comment hit). objectui
at pin `f8a9d0fb` has 0 authored policies (control: `rowLevelSecurity`
10 hits, the Studio RLS editor `PermissionAdvancedFacets.tsx`, a real
producer of save-door writes).
## Tests
All heavy runs went through `scripts/pm/os-verify-lock.sh`. Each verdict
below is the run's own tally.
Final head `a8102979` (`origin/main` `443b2f4f` merged; the post-merge
re-verification ran after `pnpm install --frozen-lockfile` and a
`@objectstack/cli...` closure build, `Tasks: 56 successful, 56 total`):
- **The two-door pin**
(`packages/cli/test/rls-policy-authoring-admission.test.ts`; the tier
file places it in `integration`, run locally because this diff adds it):
`Tests 21 passed (21)`.
- **Runtime door**
(`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
the new `#20158` block on the file's existing pinned stub host): `Tests
39 passed (39)`.
- **Lint contract with the judge**
(`packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts`,
11 cases) runs inside the lint suite below.
- **Consumer suites** (each package's whole suite):
- `pnpm --filter @objectstack/lint test`: `Test Files 111 passed (111)`,
`Tests 4273 passed (4273)`.
- `pnpm --filter @objectstack/cli exec vitest run --project unit`: `Test
Files 229 passed (229)`, `Tests 3250 passed (3250)`. The integration
tier is left to CI, except the new pin above.
- `pnpm --filter @objectstack/plugin-security test` (read-only
consumer): `Test Files 139 passed (139)`, `Tests 2839 passed (2839)`.
- `pnpm --filter @objectstack/metadata-protocol test` at `cb223b1e`:
`Test Files 189 passed | 3 skipped (192)`, `Tests 2707 passed | 19
skipped (2726)`. The second merge brought no `metadata-protocol` source
change (one new test file on `main`), and the gate file was re-run at
the final head (above).
- **Typecheck at the final head:** `pnpm --filter @objectstack/lint
typecheck`, `@objectstack/metadata-protocol` and `@objectstack/cli` all
exit 0.
- **An earlier red, fixed in this PR:** the first CLI unit run
(`9df0a6f5`) failed `test/validate-build-gate-parity.test.ts` with
`compile.ts / validate.ts call 1 name(s) this file has not classified:
stackFilterJudge`. The name is now classified in that file's
`NOT_A_GATE` ledger as an input to the registry. Two
`published-subpath-*` files failed in the same run only because the CLI
itself was not built; after the build they passed (`Tests 29 passed
(29)`).
### Reverse verification (ablations)
The pin reads `@objectstack/lint` and `@objectstack/metadata-protocol`
through `exports`, which resolve to `dist/`, and no alias points them at
source. So every leg was: `scripts/ablation-replace.mjs` in wrap mode
(the anchor hits exactly once, the blob changes, the restore is proven
blob == HEAD with an empty `git diff HEAD`); rebuild the package;
`scripts/ablation-dist-preflight.mjs` shows the marker in `dist/`; run
the pin; then rebuild after the restore and preflight `--absent`.
| Leg | Mutation | Marker in `dist/` | Pin under mutation | Failing
cases | Restore |
|---|---|---|---|---|---|
| A1B | `protocol.ts` passes the gate NO judge (`… &&
('ABLATION_20158_A1B' in globalThis) ? { judgeFilter } : {}`) | present
in 2 built files | `6 failed \| 15 passed (21)` | rows 10–14 and the
binding-refusal case: `expected true to be false` (the save door
ACCEPTED; the CLI door still refused) | rebuilt, marker absent, pin `21
passed` |
| A2B | the rule never asks the judge (`const refused =
('ABLATION_20158_A2B' in globalThis) && …`) | present | `6 failed \| 15
passed (21)` | the same six, failing first at the CLI door: `expected []
to deeply equal [ { severity: 'error', … } ]` | rebuilt, marker absent,
pin `21 passed` |
Direction as predicted: red, and red only where the engine alone
refuses. The ten rows owned by earlier passes and the three controls
stayed green under both legs.
1 parent 17bd318 commit 1207baf
18 files changed
Lines changed: 1025 additions & 18 deletions
File tree
- .changeset
- packages
- cli
- src
- commands
- utils
- test
- lint/src
- metadata-protocol/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
20 | 21 | | |
21 | 22 | | |
22 | 23 | | |
| |||
417 | 418 | | |
418 | 419 | | |
419 | 420 | | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
420 | 426 | | |
421 | 427 | | |
422 | | - | |
| 428 | + | |
423 | 429 | | |
| 430 | + | |
424 | 431 | | |
425 | 432 | | |
426 | 433 | | |
| |||
520 | 527 | | |
521 | 528 | | |
522 | 529 | | |
| 530 | + | |
523 | 531 | | |
524 | 532 | | |
525 | 533 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| |||
670 | 671 | | |
671 | 672 | | |
672 | 673 | | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
673 | 679 | | |
674 | 680 | | |
675 | 681 | | |
676 | 682 | | |
| 683 | + | |
677 | 684 | | |
678 | 685 | | |
679 | 686 | | |
| |||
743 | 750 | | |
744 | 751 | | |
745 | 752 | | |
| 753 | + | |
746 | 754 | | |
747 | 755 | | |
748 | 756 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
21 | 22 | | |
22 | 23 | | |
23 | 24 | | |
| |||
374 | 375 | | |
375 | 376 | | |
376 | 377 | | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
377 | 384 | | |
378 | 385 | | |
379 | | - | |
| 386 | + | |
380 | 387 | | |
381 | 388 | | |
382 | 389 | | |
383 | 390 | | |
| 391 | + | |
384 | 392 | | |
385 | 393 | | |
386 | 394 | | |
| |||
466 | 474 | | |
467 | 475 | | |
468 | 476 | | |
| 477 | + | |
469 | 478 | | |
470 | 479 | | |
471 | 480 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| 47 | + | |
47 | 48 | | |
48 | 49 | | |
49 | 50 | | |
| |||
267 | 268 | | |
268 | 269 | | |
269 | 270 | | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
270 | 277 | | |
271 | 278 | | |
272 | 279 | | |
| |||
303 | 310 | | |
304 | 311 | | |
305 | 312 | | |
| 313 | + | |
306 | 314 | | |
307 | 315 | | |
308 | 316 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
| 43 | + | |
43 | 44 | | |
44 | 45 | | |
45 | 46 | | |
| |||
98 | 99 | | |
99 | 100 | | |
100 | 101 | | |
| 102 | + | |
| 103 | + | |
101 | 104 | | |
102 | 105 | | |
103 | 106 | | |
| |||
0 commit comments