Skip to content

Commit 2a40c10

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20230-overlay-owner-hidden-retired
2 parents cbc81c5 + 17bd318 commit 2a40c10

16 files changed

Lines changed: 1136 additions & 46 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/plugin-sharing": minor
5+
"@objectstack/lint": minor
6+
"@objectstack/objectql": minor
7+
"@objectstack/spec": patch
8+
---
9+
10+
A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886).
11+
12+
**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`.
13+
14+
Clause-②: no (narrowing)
15+
16+
**Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory:
17+
18+
- `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory.
19+
- `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver.
20+
- `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler.
21+
- `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write.
22+
23+
What changes:
24+
25+
- `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value.
26+
- `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing.
27+
- `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded.
28+
- `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass.
29+
- `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before.
30+
- `@objectstack/spec`: the migration registry carries the entry.
31+
32+
The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused.
33+
34+
**What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator.
35+
36+
Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference.
37+
38+
<!-- adr-0087: registered cel-predicate-one-value-comparand-refused -->
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed` name the shapes their TSDoc promises instead of being `unknown` (#19920)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for TypeScript code that annotates with `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema, no parse and no export changes, and neither does the declared type of any schema.
10+
11+
Four published type aliases were derived from a schema whose own static type erases to `unknown`, so any value type-checked against them. Each is now derived from the member schema the parse actually runs:
12+
13+
- `InlineAction`: FROM `z.input<typeof InlineActionSchema>` (`unknown`, because the schema is a `z.preprocess` whose input is the preprocess function's `unknown` parameter) TO `z.input<(typeof InlineActionSchema)['out']>`, the input type of the picked action object.
14+
- `ViewMetadataParsed`: FROM `z.infer<typeof ViewMetadataSchema>` (`unknown`, because the union's members are cast to `z.ZodTypeAny` where it is built) TO the union of the OUTPUT types of `VIEW_METADATA_MEMBERS`, the same record `ViewMetadata` reads its input types from. `diagnoseViewMetadata` keeps returning the schema's own parse output as `data`; only that value's static type changes.
15+
- `AssembledViewArtifact` / `AssembledViewArtifactParsed`: FROM `z.input` / `z.infer` of `AssembledViewArtifactSchema` (`unknown`, the same cast) TO the input / output union of the three non-container `VIEW_METADATA_MEMBERS`, the members that schema's union is mapped from. A container body is now a compile error here, as it always was at the schema.
16+
17+
**If your code stops compiling.** A value you annotated with one of these names is not the shape the name describes: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. For `InlineAction`, the legacy `type: 'navigation'` and `to` spellings are refused by the type while `InlineActionSchema` still folds them onto `url` / `target`: write `type: 'url'` and `target`.
18+
19+
The types are the members' declared shapes, not the schemas' verdicts. Each schema still accepts some bodies its type refuses (the preprocess folds and strips) and still refuses some bodies its type admits (refinements are not types), so the schema remains the only judge.
20+
21+
`JoinedReportBlock` is not changed by this change, and still resolves to `unknown`.
22+
23+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves — no spec key, no export and no stored row changes and every runtime accept set is unchanged, so `objectstack migrate meta` has nothing to reach — and only TypeScript annotations narrow, whose channel is the consumer's compiler. -->

‎.changeset/view-metadata-type-not-unknown.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ judge.
3030
member shapes. Correct the body, or type a value that is still unvalidated as `unknown` and let
3131
`ViewMetadataSchema.safeParse` decide.
3232

33-
`ViewMetadataParsed` is not changed by this release: it is still `unknown`.
33+
`ViewMetadataParsed` is not changed by this change. It is re-derived from the same members, as their output types, by its own entry (#19920).
3434

3535
The `@objectstack/metadata` changelog entry for #19852 gives `ViewMetadata` being `unknown` as the
3636
reason a saved `view` file is written with no annotation; that reason is superseded here, and the
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19886 stage 2d] Every comparison the pushdown compiler lowers compares ONE
5+
* value, and an `in` list holds one value per member. Stage 2c refused a list
6+
* under `==` / `!=` opposite a field; the same fault stayed open one position
7+
* over, each measured admitting and storing the writes a row-level `check` was
8+
* written to refuse (real `SecurityPlugin` + ObjectQL, driver-sql and
9+
* driver-memory):
10+
*
11+
* | predicate | lowered to (before) | write-check evaluator (before) |
12+
* |---------------------------------------------|----------------------------------------------|--------------------------------|
13+
* | `!(record.status in [['closed', 'archived']])` | `$not { status: { $in: [[…]] } }` | every write admitted |
14+
* | `record.status > ['m']` | `{ status: { $gt: ['m'] } }` | compared as the string `'m'` |
15+
* | `current_user.org_user_ids != 'x'` | `{}` — "no restriction" | every write admitted |
16+
* | `current_user.org_user_ids > 'a'` | `{}` — folded on the coerced string | every write admitted |
17+
* | `record.reviewer_id > current_user` | `{ reviewer_id: { $gt: <context object> } }` | compared as `[object Object]` |
18+
*
19+
* Each is now `unsupported`, so every consumer fails closed on the path it
20+
* already has: the RLS compiler drops the policy (`RLS_DENY_FILTER` when
21+
* nothing else applies), the sharing seeder skips the rule, and the authoring
22+
* gate reports what the SOURCE shows (a list literal, the variable root). A
23+
* variable's value exists per request, so a resolved list is refused at
24+
* request time with the shape check still passing the source.
25+
*
26+
* NOT here, because the lowering cannot see it: a field compared with another
27+
* field whose column holds a list (`record.status != record.tags`, `tags` a
28+
* json or multiple field). The compiler knows the predicate's text, not the
29+
* object's field types; that one is refused by the write-check evaluator
30+
* (`matches-filter-array-comparand.test.ts`).
31+
*/
32+
33+
import { describe, expect, it } from 'vitest';
34+
35+
import { compileCelToFilter, isPushdownableCel } from './cel-to-filter';
36+
import { isSupportedRlsExpression } from './rls-predicate';
37+
38+
const VARS = {
39+
current_user: {
40+
id: 'u_me',
41+
email: 'me@example.test',
42+
org_user_ids: ['u_me', 'u_peer'],
43+
// A membership set a host staged with a list member — the ExecutionContext
44+
// contract declares string members, so only a host violating it supplies one.
45+
nested_set: [['acc_secret_a', 'acc_secret_b']],
46+
profile: { tier: 'gold' },
47+
},
48+
};
49+
50+
const refusedEverywhere = (source: string) => {
51+
const compiled = compileCelToFilter(source, { variables: VARS });
52+
expect(compiled.ok).toBe(false);
53+
expect(compiled.ok ? undefined : compiled.reason).toBe('unsupported');
54+
return compiled.ok ? '' : compiled.detail;
55+
};
56+
57+
/** Refused per request AND by the authoring shape check: the source shows the fault. */
58+
const REFUSED_BY_SHAPE: Array<[string, string]> = [
59+
// (b) a nested list under `in`
60+
['(b) not-in with a nested list', "!(record.status in [['closed', 'archived']])"],
61+
['(b) in with a nested list', "record.status in [['closed', 'archived']]"],
62+
['(b) a nested list beside a scalar member', "record.status in ['open', ['closed']]"],
63+
['(b) an empty nested list', 'record.status in [[]]'],
64+
['(b) under && / ||', "record.owner_id == current_user.id || !(record.status in [['closed']])"],
65+
// (c) an ordering operator against a list literal, either side, negated, constant
66+
['(c) > a list literal', "record.status > ['m']"],
67+
['(c) >= an empty list', 'record.amount >= []'],
68+
['(c) <= a list literal', 'record.amount <= [10, 20]'],
69+
['(c) negated <', "!(record.status < ['m'])"],
70+
['(c) the list on the left', "['m'] < record.status"],
71+
['(c) a constant ordering against a list literal', "['a'] > 'b'"],
72+
// (e) a list literal on the constant branch
73+
['(e) a constant != against a list literal', "['a'] != 'x'"],
74+
// (d) the variable root under an ordering operator
75+
['(d) > the variable root', 'record.reviewer_id > current_user'],
76+
['(d) the root on the left of <=', 'current_user <= record.reviewer_id'],
77+
['(d) a constant ordering of the root', "current_user > 'a'"],
78+
];
79+
80+
describe('[#19886 stage 2d] a comparand that is not one value is refused — visible to the authoring gate', () => {
81+
for (const [name, source] of REFUSED_BY_SHAPE) {
82+
it(`${name}: ${source}`, () => {
83+
refusedEverywhere(source);
84+
expect(isPushdownableCel(source).ok).toBe(false);
85+
expect(isSupportedRlsExpression(source)).toBe(false);
86+
});
87+
}
88+
});
89+
90+
/** Refused per request; the source passes the shape check because the value is per request. */
91+
const REFUSED_PER_REQUEST: Array<[string, string, RegExp]> = [
92+
['(b) a membership set with a list member', '!(record.account in current_user.nested_set)', /current_user\.nested_set/],
93+
['(c) > a membership set', 'record.reviewer_id > current_user.org_user_ids', /current_user\.org_user_ids/],
94+
['(c) a membership set on the left of <=', 'current_user.org_user_ids <= record.reviewer_id', /current_user\.org_user_ids/],
95+
['(c) a constant ordering of a membership set', "current_user.org_user_ids > 'a'", /current_user\.org_user_ids/],
96+
['(e) a constant != of a membership set', "current_user.org_user_ids != 'x'", /current_user\.org_user_ids/],
97+
['(e) a constant == of a membership set', "current_user.org_user_ids == 'u_me'", /current_user\.org_user_ids/],
98+
['(d) > a key that resolves to an object', 'record.tier > current_user.profile', /current_user\.profile/],
99+
];
100+
101+
describe('[#19886 stage 2d] a resolved comparand that is not one value is refused at request time', () => {
102+
for (const [name, source, names] of REFUSED_PER_REQUEST) {
103+
it(`${name}: ${source} — naming the variable, withholding its value`, () => {
104+
const detail = refusedEverywhere(source);
105+
expect(detail).toMatch(names);
106+
for (const secret of ['u_me', 'u_peer', 'acc_secret_a', 'acc_secret_b', 'gold']) {
107+
expect(detail).not.toContain(secret);
108+
}
109+
expect(isPushdownableCel(source).ok).toBe(true);
110+
expect(isSupportedRlsExpression(source)).toBe(true);
111+
});
112+
}
113+
});
114+
115+
describe('[#19886 stage 2d] each refusal carries its own remedy', () => {
116+
it('an ordering operator is told to take one bound, a range, or `in`', () => {
117+
const detail = refusedEverywhere("record.status > ['m']");
118+
expect(detail).toContain('`>` orders against one value');
119+
expect(detail).toContain("record.f > 'm'");
120+
expect(detail).toContain('&&');
121+
});
122+
123+
it('a nested list is told to flatten, naming the member', () => {
124+
const detail = refusedEverywhere("!(record.status in ['open', ['closed']])");
125+
expect(detail).toContain('member 1 of the list literal is itself a list');
126+
expect(detail).toContain("record.f in ['a', 'b']");
127+
});
128+
129+
it('`==` / `!=` keep the stage 2c remedy', () => {
130+
const detail = refusedEverywhere("current_user.org_user_ids != 'x'");
131+
expect(detail).toContain('`!=` compares one value');
132+
expect(detail).toContain('!(record.f in current_user.org_user_ids)');
133+
});
134+
});
135+
136+
describe('[#19886 stage 2d] every neighbouring comparison lowers exactly as before', () => {
137+
const ok = (source: string) => {
138+
const r = compileCelToFilter(source, { variables: VARS });
139+
if (!r.ok) throw new Error(`expected "${source}" to lower, got ${r.reason}: ${r.detail}`);
140+
return r.filter;
141+
};
142+
143+
it('an ordering operator against one literal, one scalar key, and another field', () => {
144+
expect(ok("record.status > 'm'")).toEqual({ status: { $gt: 'm' } });
145+
expect(ok('record.amount <= 10')).toEqual({ amount: { $lte: 10 } });
146+
expect(ok('record.owner_id >= current_user.id')).toEqual({ owner_id: { $gte: 'u_me' } });
147+
expect(ok('record.a > record.b')).toEqual({ a: { $gt: { $field: 'b' } } });
148+
});
149+
150+
it('`in` / `not in` against a flat list and a membership set', () => {
151+
expect(ok("record.status in ['open', 'pending']")).toEqual({ status: { $in: ['open', 'pending'] } });
152+
expect(ok("!(record.status in ['closed'])")).toEqual({ $not: { status: { $in: ['closed'] } } });
153+
expect(ok('record.owner_id in current_user.org_user_ids')).toEqual({ owner_id: { $in: ['u_me', 'u_peer'] } });
154+
expect(ok('record.status in []')).toEqual({ status: { $in: [] } });
155+
});
156+
157+
it('constant comparisons over scalars still fold', () => {
158+
expect(ok('1 == 1')).toEqual({});
159+
expect(ok("current_user.id != 'guest'")).toEqual({});
160+
expect(ok("current_user.id > 'a'")).toEqual({});
161+
});
162+
163+
it('field-to-field `==` / `!=` still lower — the column TYPE is not the lowering\'s to judge', () => {
164+
expect(ok('record.status != record.tags')).toEqual({ status: { $ne: { $field: 'tags' } } });
165+
expect(ok('!(record.status == record.tags)')).toEqual({ $not: { status: { $eq: { $field: 'tags' } } } });
166+
});
167+
});

0 commit comments

Comments
 (0)