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
9 changes: 9 additions & 0 deletions .changeset/13082-rls-reference-passes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/app-shell': patch
---

Studio's RLS policy editor now asks `@objectstack/lint`'s RLS rule about every `USING` or `CHECK` clause that parses, not only about one the runtime cannot lower (objectui#13082). A predicate that lowers but reads something the rule refuses, such as `current_user.nope == owner_id`, was silent before: the draft autosaved, and publish then refused it as `rls-predicate-unknown-user-variable`. It is now the rule's error, in the rule's words, and it holds Save and autosave.

The same holds for the rule's other verdicts that read the clause alone: a `current_user` value of the wrong type for its position (`owner_id == current_user.org_user_ids`) and a refused `null` comparand (`status in ['a', null]`), both `rls-predicate-unenforceable`. A valid predicate such as `owner_id == current_user.id` stays clean.

The verdicts that read more than the clause stay at publish: an unknown field (the editor's field list is read from the published object and misses fields in a pending object draft, so a near miss stays a non-blocking "did you mean" warning), a comparison between fields of incompatible types (the editor holds no field types), and the server engine's own filter check.
12 changes: 8 additions & 4 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -722,10 +722,14 @@ server** instead of maintaining a second grammar:

- **Inline lint** (`CelPredicateField`) — `validateExpression` flags parse
faults inline (blocking Save) and unknown-field near-misses as non-blocking
"did-you-mean" warnings; a `USING` or `CHECK` predicate the runtime cannot
lower (`isSupportedRlsExpression`) is an error that blocks Save, carrying
`@objectstack/lint`'s `rls-predicate-*` message as written — the verdict
publish and `os build` give.
"did-you-mean" warnings; a `USING` or `CHECK` predicate `@objectstack/lint`'s
`rls-predicate-*` rule refuses is an error that blocks Save, carrying the
rule's message as written — the verdict publish and `os build` give. The
editor asks the rule about the clause alone: a predicate the runtime cannot
lower, and, for one that lowers, a `current_user` member nothing resolves, a
`current_user` value of the wrong type for its position or a refused `null`
comparand. The verdicts that read the object's field map or field types, or
the server's engine, stay at publish.
- **Field autocomplete** — `introspectScope` supplies the target object's
fields plus scope vars (`current_user`, `record`, …) and stdlib functions as
you type, so an identifier that would silently never match is caught early.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
* author-time safeties, all backed by the framework's canonical CEL engine
* (see {@link file://./celAuthoring.ts}):
*
* - inline LINT — parse faults and, on an RLS clause, a predicate the
* runtime cannot lower (both blocking), and typo / wrong-layer advisories,
* surfaced under the field as you type (debounced);
* - inline LINT — parse faults and, on an RLS clause, a predicate
* `@objectstack/lint`'s RLS rule refuses (both blocking), and typo /
* wrong-layer advisories, surfaced under the field as you type (debounced);
* - as-you-type AUTOCOMPLETE — the target object's fields, the scope roots
* (`current_user`, `record`, …) and the stdlib functions, so a mistyped
* identifier is caught before it silently never matches;
Expand Down Expand Up @@ -82,7 +82,7 @@ export interface CelPredicateFieldProps {
objectName?: string;
/** Known field names of {@link objectName}. */
fieldNames?: string[];
/** Which RLS clause this is (turns on the blocking lowerability verdict). */
/** Which RLS clause this is (turns on the blocking verdicts of `@objectstack/lint`'s RLS rule). */
clause?: 'using' | 'check';
/**
* Evaluation scope of the authoring site (objectui#1582). `'flattened'`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,36 @@ describe('PermissionAdvancedFacets · CEL authoring safety (objectui#2413)', ()
expect(onCelErrorsChange).toHaveBeenLastCalledWith(0);
});

// objectui#13082 — a clause that lowers but reads something the rule refuses
// holds Save the same way: the card's predicate in USING, in CHECK and in
// both, and its control.
const ownerPolicy = (using: string, check: string) => ({
draft: { rowLevelSecurity: [{ name: 'p1', object: 'account', operation: 'all', using, check, enabled: true }] },
loadObjectFields: async () => ['organization_id', 'owner_id'],
});
for (const [label, using, check, expected] of [
['an unknown `current_user` variable in USING', 'current_user.nope == owner_id', '', 1],
['an unknown `current_user` variable in CHECK', 'owner_id == current_user.id', 'current_user.nope == owner_id', 1],
['an unknown `current_user` variable in both clauses', 'current_user.nope == owner_id', 'current_user.nope == owner_id', 2],
] as const) {
it(`reports ${label} up as blocking (objectui#13082)`, async () => {
const onCelErrorsChange = vi.fn();
const user = userEvent.setup();
renderFacets({ ...ownerPolicy(using, check), onCelErrorsChange });
await user.click(screen.getByText('perm.rls.title'));
await waitFor(() => expect(onCelErrorsChange).toHaveBeenLastCalledWith(expected), { timeout: 3000 });
});
}

it('CONTROL — `owner_id == current_user.id` on both clauses leaves Save open (objectui#13082)', async () => {
const onCelErrorsChange = vi.fn();
const user = userEvent.setup();
renderFacets({ ...ownerPolicy('owner_id == current_user.id', 'owner_id == current_user.id'), onCelErrorsChange });
await user.click(screen.getByText('perm.rls.title'));
await waitFor(() => expect(screen.getAllByText('perm.cel.valid')).toHaveLength(2), { timeout: 3000 });
expect(onCelErrorsChange).toHaveBeenLastCalledWith(0);
});

it('opens the test-run dialog for a policy', async () => {
const user = userEvent.setup();
renderFacets();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#13082 — the RLS editor asks `@objectstack/lint`'s
* `validateRlsPredicateEnforceability` for EVERY clause that parses, not only
* for one the runtime cannot lower, so a lowerable predicate the publish door
* refuses on what it POINTS AT is refused here too: at the rule's severity, in
* the rule's words.
*
* Every expected finding below is read from the published rule at call time
* (a one-clause stack, the same input the editor builds); no message is written
* in this file. Which passes the editor carries and which stay at the publish
* door, and why, is `rlsPredicateVerdict`'s docblock in `celAuthoring.ts`.
*/

import { describe, it, expect } from 'vitest';
import { lintCelPredicate } from './celAuthoring';
import { isSupportedRlsExpression } from '@objectstack/formula';
import {
validateRlsPredicateEnforceability,
RLS_PREDICATE_UNENFORCEABLE,
RLS_PREDICATE_UNKNOWN_FIELD,
RLS_PREDICATE_UNKNOWN_USER_VARIABLE,
} from '@objectstack/lint';

const HINT = { objectName: 'account', fields: ['organization_id', 'owner_id', 'status', 'amount'] };
const CLAUSES = ['using', 'check'] as const;
type Clause = (typeof CLAUSES)[number];

/** The rule's verdict on the editor's input: one clause, no object. */
const ruleOn = (clause: Clause, source: string) =>
validateRlsPredicateEnforceability({ permissions: [{ rowLevelSecurity: [{ [clause]: source }] }] });

describe('celAuthoring · the RLS rule judges a lowerable clause too (objectui#13082)', () => {
// The card's predicate: it lowers, so the shape pass has nothing to say,
// and the reference pass refuses it.
for (const clause of CLAUSES) {
it(`rates ${clause.toUpperCase()} \`current_user.nope == owner_id\` the rule's unknown-user-variable error`, async () => {
const source = 'current_user.nope == owner_id';
expect(isSupportedRlsExpression(source)).toBe(true);
const findings = ruleOn(clause, source);
expect(findings.map((f) => [f.rule, f.severity])).toEqual([[RLS_PREDICATE_UNKNOWN_USER_VARIABLE, 'error']]);
expect(await lintCelPredicate(source, { ...HINT, clause })).toEqual([
{ severity: 'error', message: findings[0].message },
]);
});

it(`CONTROL — ${clause.toUpperCase()} \`owner_id == current_user.id\` stays clean`, async () => {
expect(ruleOn(clause, 'owner_id == current_user.id')).toEqual([]);
expect(await lintCelPredicate('owner_id == current_user.id', { ...HINT, clause })).toEqual([]);
});
}

// The rule's other verdicts on a lowerable clause that read the clause and
// nothing else: a `current_user` value of the wrong type for its position,
// and a comparand the shared filter faces refuse.
for (const [label, source] of [
['a membership set where one value belongs', 'owner_id == current_user.org_user_ids'],
['one value where a set belongs', 'owner_id in current_user.id'],
['the whole caller context where a set belongs', 'status in current_user'],
['a null list member', 'status in [null]'],
] as const) {
for (const clause of CLAUSES) {
it(`rates ${clause.toUpperCase()} with ${label} the rule's unenforceable error`, async () => {
expect(isSupportedRlsExpression(source)).toBe(true);
const findings = ruleOn(clause, source);
expect(findings.map((f) => [f.rule, f.severity])).toEqual([[RLS_PREDICATE_UNENFORCEABLE, 'error']]);
expect(await lintCelPredicate(source, { ...HINT, clause })).toEqual([
{ severity: 'error', message: findings[0].message },
]);
});
}
}

// The same faces refuse a `null` beside a typed member, or a `null`
// ordering bound; the engine's type check refuses those first, as a
// blocking parse fault, and the rule is asked only once the predicate parses.
for (const source of ["status in ['a', null]", 'amount > null']) {
it(`\`${source}\` is already the engine's blocking error, and only that one`, async () => {
expect(isSupportedRlsExpression(source)).toBe(true);
expect(ruleOn('using', source).map((f) => f.rule)).toEqual([RLS_PREDICATE_UNENFORCEABLE]);
const issues = await lintCelPredicate(source, { ...HINT, clause: 'using' });
const errors = issues.filter((i) => i.severity === 'error');
expect(errors).toHaveLength(1);
expect(errors[0].message).not.toBe(ruleOn('using', source)[0].message);
});
}

it("keeps the rule's silence on an app-staged membership key (`owner_id in current_user.team_ids`)", async () => {
for (const clause of CLAUSES) {
expect(ruleOn(clause, 'owner_id in current_user.team_ids')).toEqual([]);
expect(await lintCelPredicate('owner_id in current_user.team_ids', { ...HINT, clause })).toEqual([]);
}
});

it('leaves the shape verdict of #13061 as it was: a clause that cannot lower carries the shape error alone', async () => {
const source = 'upper(status) == "OPEN"';
expect(isSupportedRlsExpression(source)).toBe(false);
for (const clause of CLAUSES) {
const findings = ruleOn(clause, source);
expect(findings).toHaveLength(1);
expect(await lintCelPredicate(source, { ...HINT, clause })).toEqual([
{ severity: 'error', message: findings[0].message },
]);
}
});
});

describe('celAuthoring · the passes that stay at the publish door give no editor error (objectui#13082)', () => {
/** The rule's verdict with the object declared, as the publish door's universe holds it. */
const ruleWithObject = (source: string, fields: Record<string, { type?: string }>) =>
validateRlsPredicateEnforceability({
objects: [{ name: 'account', fields }],
permissions: [{ rowLevelSecurity: [{ object: 'account', operation: 'all', using: source }] }],
});

// The editor's field list is a name list read from the PUBLISHED object; a
// field the author added in a pending object draft is not on it, and the
// package publish folds that draft in. An unknown field therefore gets at
// most the engine's non-blocking near-miss advisory here, never the rule's
// error.
it('a field missing from the editor list holds no Save, though the rule refuses it against that list', async () => {
const source = "region == 'EU'";
const listed = Object.fromEntries(HINT.fields.map((name) => [name, {}]));
expect(ruleWithObject(source, listed).map((f) => f.rule)).toEqual([RLS_PREDICATE_UNKNOWN_FIELD]);
expect(ruleWithObject(source, { ...listed, region: { type: 'text' } })).toEqual([]);
const issues = await lintCelPredicate(source, { ...HINT, clause: 'using' });
expect(issues.filter((i) => i.severity === 'error')).toEqual([]);
});

it('absent field metadata gives no error', async () => {
for (const fields of [undefined, []]) {
for (const clause of CLAUSES) {
const issues = await lintCelPredicate('nope_field == current_user.id', { objectName: 'account', fields, clause });
expect(issues.filter((i) => i.severity === 'error')).toEqual([]);
}
}
});

// Field TYPES decide these two arms; the editor holds names only.
for (const [label, source, fields] of [
['a text field compared with a number field', 'status != amount', { status: { type: 'text' }, amount: { type: 'number' } }],
['a field compared with a list-holding field', 'status != tags', { status: { type: 'text' }, tags: { type: 'json' } }],
] as const) {
it(`${label}: refused by the rule over declared types, no error in the editor`, async () => {
expect(ruleWithObject(source, fields).map((f) => f.rule)).toEqual([RLS_PREDICATE_UNENFORCEABLE]);
const issues = await lintCelPredicate(source, { objectName: 'account', fields: Object.keys(fields), clause: 'using' });
expect(issues.filter((i) => i.severity === 'error')).toEqual([]);
});
}
});
Loading
Loading