Skip to content

Commit 3efe649

Browse files
committed
Merge origin/main (ef96c9e) into the stage 5a branch
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
2 parents 75e33f4 + ef96c9e commit 3efe649

7 files changed

Lines changed: 662 additions & 13 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a row-level `check` judges a `date`, `datetime` or `time` column as the row will be stored, so the write and the read the same policy scopes give one answer for one row (#21109)
6+
7+
Clause-②: no
8+
9+
The write check evaluates the compiled `check` filter in-process against the write's post-image. That image held each value as the caller or a hook wrote it, while the read compares the value the driver stored, in its column's storage form, against a comparand put into the same form. On a temporal column the two forms differ, so one row could get two answers. Measured through `ObjectQL.insert` with `SecurityPlugin` on SQLite, as a member resolving a permission set, with the same predicate as `using` and `check`:
10+
11+
| `check` | written | write, before | stored | read |
12+
|---|---|---|---|---|
13+
| `record.due_on == '2026-01-05'` | `'2026-01-05T15:00:00Z'`, or a `Date` on that day | 403 | `2026-01-05` | shown |
14+
| `record.start_time == '09:00'` | `'09:00:00'` | 403 | `09:00:00` | shown |
15+
| `record.due_at == '2026-01-05T10:00:00Z'` | `'2026-01-05T18:00:00+08:00'` | 403 | `2026-01-05T10:00:00.000Z` | shown |
16+
| `record.due_on > '2026-01-05'` | `'2026-01-05T15:00:00Z'` | admitted | `2026-01-05` | hidden |
17+
18+
Now, before the check is judged, every column the object declares `date`, `datetime` or `time` is put into `@objectstack/core`'s `temporalStorageForm`, the rule the drivers write and compare those columns by. That applies to the post-image's value and to the check's value comparands on the column (`$eq`, `$ne`, the orderings, `$in`, `$nin`, `$between`). The first three rows above are now admitted. The last is now refused, which is the read/write agreement this change buys: the read never showed that row, so a write that stores a day the predicate excludes is no longer admitted on the strength of the time of day it was sent with. Every insert, by-id update and predicate update takes the same step.
19+
20+
Unchanged: a column the object does not declare temporal is judged as written, whatever its value looks like; an object whose schema cannot be loaded is judged as before; a value the storage rule cannot read is judged as written; and refusals keep their code and status (`PERMISSION_DENIED` / 403).
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the `filter-text-operator-declared-type-refused` migration entry's control no longer counts JSON-stored fields, and it names the separate door that refuses text operators there
6+
7+
Clause-②: no
8+
9+
**`filter-text-operator-declared-type-refused` (protocol 18).** The entry's acceptance criteria
10+
named every text-valued field, `multiselect` / `checkboxes` / `tags` and lookup and `user` ids
11+
included, as the control that "must keep answering exactly as before". The declared-type door this
12+
entry registers still leaves every text-valued type alone. A second door, though, judges a field by
13+
how it is STORED: on a column stored as JSON it now refuses `$startsWith`, `$endsWith`,
14+
`$icontains`, `$like` and `$ilike` with `INVALID_FILTER` / `400`, as it already refused the scalar
15+
comparisons there. So a stored filter that uses one of those operators on a multi-valued field
16+
answers that `400` after the upgrade, and the old sentence called it a control.
17+
18+
The criteria now say four things:
19+
20+
- The control is a text-valued field that is NOT stored as a JSON column.
21+
- The JSON-stored population is `multiselect` / `checkboxes` / `tags`, any field declared
22+
`multiple: true` (a multi-valued lookup or `user` among them), and, on a SQL deployment still
23+
inside the ADR-0104 dual-encoding window, a single-value file-class field.
24+
- That door refuses every text operator except the membership pair `$contains` / `$notContains`,
25+
and its refusal names no declared type, so it is outside this entry's repair list.
26+
- The repair on a multi-valued field is membership: `$contains` for one member, an `$or` of
27+
`$contains` for any-of. A single-value file-class field answers text operators again once the
28+
deployment finishes the media-column move (the column step of
29+
`objectstack migrate files-to-references --apply`).
30+
31+
Text only: no entry id, `surface`, `replacement`, `reason`, conversion or refusal changes, and
32+
neither door moves. `objectstack migrate meta` prints the corrected `verify:` line, and the
33+
generated migration registry carries the same text.
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21109, ruling A] The row-level write `check` judges a `date`, `datetime` or
5+
* `time` column in its STORED form, so the write and the read the same policy
6+
* scopes give one answer for one row.
7+
*
8+
* Driven through the real engine, the real plugin and two SQL driver families,
9+
* as a member who resolves a permission set. Each cell writes one value under a
10+
* policy whose `using` and `check` are the same predicate, then asks the read:
11+
* the write is admitted exactly when the read shows the row that value is
12+
* stored as. Measured on `main` before the step (`rls-check-stored-form.ts`):
13+
*
14+
* | `check` | written | write, before | stored | read |
15+
* |---|---|---|---|---|
16+
* | `record.due_on == '2026-01-05'` | `'2026-01-05T15:00:00Z'`, a `Date` | 403 | `2026-01-05` | shown |
17+
* | `record.due_on > '2026-01-05'` | `'2026-01-05T15:00:00Z'` | admitted | `2026-01-05` | hidden |
18+
* | `record.start_time == '09:00'` | `'09:00:00'` | 403 | `09:00:00` | shown |
19+
* | `record.due_at == '2026-01-05T10:00:00Z'` | `'2026-01-05T18:00:00+08:00'` | 403 | `2026-01-05T10:00:00.000Z` | shown |
20+
*
21+
* ## formula's whole-day copy is out of reach here
22+
*
23+
* `@objectstack/formula`'s matcher carries its own copy of the whole-day upper
24+
* bound (a bare-day `$lte` read as "through that day"), which today is what
25+
* admits a `due_on` written as an instant or a `Date` on the boundary day under
26+
* `record.due_on <= '2026-01-05'`. Ruling A retires that copy once the check
27+
* judges the stored form, so these cells must pass WITHOUT it: the matcher is
28+
* wrapped so a `$lte` reaches it as `$lt` or `$eq`, and a `$between` as `$gte`
29+
* plus that, and the copy never runs. The first cell pins that the wrapper is
30+
* live.
31+
*/
32+
33+
import { describe, it, expect, afterEach, vi } from 'vitest';
34+
// The mocked module (see `vi.mock` below), loaded at module top.
35+
import { matchesFilterCondition } from '@objectstack/formula';
36+
import { ObjectQL } from '@objectstack/objectql';
37+
import { SqlDriver } from '@objectstack/driver-sql';
38+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
39+
import { PermissionSetSchema } from '@objectstack/spec/security';
40+
import { SecurityPlugin } from './security-plugin.js';
41+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
42+
import { declaredTemporalColumns, storedFormCheckFilter, storedFormImage } from './rls-check-stored-form.js';
43+
44+
/** A plain object — a filter node or an operator map. */
45+
function isPlain(value: unknown): value is Record<string, unknown> {
46+
return value !== null && typeof value === 'object' && !Array.isArray(value)
47+
&& Object.getPrototypeOf(value) === Object.prototype;
48+
}
49+
50+
/** `$lte` as `$lt` or `$eq`, so the matcher's whole-day copy is never reached. */
51+
const plainLte = (column: string, bound: unknown) => ({
52+
$or: [{ [column]: { $lt: bound } }, { [column]: { $eq: bound } }],
53+
});
54+
55+
/** The filter with every `$lte` and `$between` spelled without the operators the copy serves. */
56+
function withoutWholeDayCopy(node: unknown): unknown {
57+
if (!isPlain(node)) return node;
58+
const conjuncts: unknown[] = [];
59+
for (const [key, value] of Object.entries(node)) {
60+
if ((key === '$and' || key === '$or') && Array.isArray(value)) {
61+
conjuncts.push({ [key]: value.map(withoutWholeDayCopy) });
62+
} else if (key === '$not') {
63+
conjuncts.push({ $not: withoutWholeDayCopy(value) });
64+
} else if (key.startsWith('$') || !isPlain(value) || !('$lte' in value || '$between' in value)) {
65+
conjuncts.push({ [key]: value });
66+
} else {
67+
const { $lte, $between, ...rest } = value;
68+
if (Object.keys(rest).length > 0) conjuncts.push({ [key]: rest });
69+
if ($lte !== undefined) conjuncts.push(plainLte(key, $lte));
70+
if (Array.isArray($between)) conjuncts.push({ [key]: { $gte: $between[0] } }, plainLte(key, $between[1]));
71+
}
72+
}
73+
return conjuncts.length === 1 ? conjuncts[0] : { $and: conjuncts };
74+
}
75+
76+
vi.mock('@objectstack/formula', async (importOriginal) => {
77+
const real = await importOriginal<typeof import('@objectstack/formula')>();
78+
return {
79+
...real,
80+
matchesFilterCondition: ((record, filter, options) =>
81+
real.matchesFilterCondition(record, withoutWholeDayCopy(filter) as never, options)) as typeof real.matchesFilterCondition,
82+
};
83+
});
84+
85+
const SYS_CTX = { isSystem: true, userId: 'usr_system' };
86+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
87+
88+
type Driver = { disconnect?: () => Promise<void> };
89+
const DRIVERS: Array<[name: string, make: () => Driver]> = [
90+
['driver-sql (better-sqlite3)', () =>
91+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true })],
92+
['driver-sqlite-wasm', () => new SqliteWasmDriver({ filename: ':memory:' })],
93+
];
94+
95+
const booted: ObjectQL[] = [];
96+
afterEach(async () => {
97+
while (booted.length) {
98+
try { await booted.pop()!.destroy(); } catch { /* noop */ }
99+
}
100+
});
101+
102+
let seq = 0;
103+
/** One engine and plugin, with ONE policy whose `using` and `check` are the same predicate. */
104+
async function boot(makeDriver: () => Driver, predicate: string) {
105+
const OBJ = `qa_due_stored_${process.pid}_${++seq}`;
106+
const engine = new ObjectQL();
107+
engine.registerDriver(makeDriver() as never, true);
108+
await engine.init();
109+
engine.registerApp({
110+
id: `com.objectstack.qa.rls-check-stored-form-${seq}`,
111+
name: 'RLS check stored form',
112+
version: '1.0.0',
113+
type: 'plugin',
114+
scope: 'system',
115+
objects: [
116+
{
117+
name: OBJ,
118+
label: 'Duty',
119+
sharingModel: 'public_read_write',
120+
fields: {
121+
id: { name: 'id', type: 'text', primaryKey: true },
122+
title: { name: 'title', type: 'text' },
123+
due_on: { name: 'due_on', type: 'date' },
124+
due_at: { name: 'due_at', type: 'datetime' },
125+
start_time: { name: 'start_time', type: 'time' },
126+
},
127+
},
128+
],
129+
} as never);
130+
await engine.syncSchemas();
131+
booted.push(engine);
132+
133+
const set = PermissionSetSchema.parse({
134+
name: 'qa_due_guard',
135+
objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
136+
rowLevelSecurity: [{ name: 'due_guard', object: OBJ, operation: 'all', using: predicate, check: predicate }],
137+
});
138+
const services: Record<string, unknown> = {
139+
manifest: { register: vi.fn() },
140+
objectql: engine,
141+
metadata: {
142+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
143+
list: async () => [MEMBER_DEFAULT, set],
144+
},
145+
};
146+
const ctx = {
147+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
148+
registerService: vi.fn(),
149+
getService: (name: string) => {
150+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
151+
return services[name];
152+
},
153+
};
154+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
155+
await plugin.init(ctx as never);
156+
await plugin.start(ctx as never);
157+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
158+
159+
const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' };
160+
const storedRow = async (id: string) =>
161+
((await engine.find(OBJ, { where: { id }, context: SYS_CTX } as never)) as Array<Record<string, unknown>>)[0];
162+
const shownTo = async (id: string) =>
163+
((await engine.find(OBJ, { where: { id }, context: caller } as never)) as unknown[]).length > 0;
164+
return { OBJ, engine, caller, storedRow, shownTo };
165+
}
166+
167+
type Envelope = { code: string; status: number };
168+
const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 };
169+
const envelopeOf = (e: unknown): Envelope => {
170+
const x = e as { code?: string; status?: number; statusCode?: number };
171+
return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) };
172+
};
173+
const outcome = (p: Promise<unknown>): Promise<'admitted' | Envelope> =>
174+
p.then(() => 'admitted' as const, (e: unknown) => envelopeOf(e));
175+
176+
const show = (v: unknown): string => (v instanceof Date ? `Date(${v.toISOString()})` : JSON.stringify(v));
177+
178+
type Cell = { predicate: string; column: string; value: unknown; stored: unknown; admitted: boolean };
179+
const CELLS: Cell[] = [
180+
// `date`, the boundary day under a bare-day `$lte`: the three ways an SDK writes it.
181+
{ predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: '2026-01-05T15:00:00Z', stored: '2026-01-05', admitted: true },
182+
{ predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: new Date('2026-01-05T15:00:00Z'), stored: '2026-01-05', admitted: true },
183+
{ predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: '2026-01-05', stored: '2026-01-05', admitted: true },
184+
{ predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: '2026-01-06T01:00:00Z', stored: '2026-01-06', admitted: false },
185+
// `date` equality: refused before for an instant or a `Date` on the day.
186+
{ predicate: "record.due_on == '2026-01-05'", column: 'due_on', value: '2026-01-05T15:00:00Z', stored: '2026-01-05', admitted: true },
187+
{ predicate: "record.due_on == '2026-01-05'", column: 'due_on', value: new Date('2026-01-05T15:00:00Z'), stored: '2026-01-05', admitted: true },
188+
{ predicate: "record.due_on == '2026-01-05'", column: 'due_on', value: '2026-01-06', stored: '2026-01-06', admitted: false },
189+
// `date`, strictly after the day: ADMITTED before, while the read hid the stored day.
190+
{ predicate: "record.due_on > '2026-01-05'", column: 'due_on', value: '2026-01-05T15:00:00Z', stored: '2026-01-05', admitted: false },
191+
// `datetime`: one instant in two zones, and a comparand the policy spells unlike the stored form.
192+
{ predicate: "record.due_at == '2026-01-05T10:00:00Z'", column: 'due_at', value: '2026-01-05T18:00:00+08:00', stored: '2026-01-05T10:00:00.000Z', admitted: true },
193+
{ predicate: "record.due_at == '2026-01-05T10:00:00Z'", column: 'due_at', value: '2026-01-05T10:00:01Z', stored: '2026-01-05T10:00:01.000Z', admitted: false },
194+
{ predicate: "record.due_at >= '2026-01-05T10:00:00Z'", column: 'due_at', value: '2026-01-05T10:00:00Z', stored: '2026-01-05T10:00:00.000Z', admitted: true },
195+
// `time`: a wall clock written with and without seconds.
196+
{ predicate: "record.start_time == '09:00'", column: 'start_time', value: '09:00:00', stored: '09:00:00', admitted: true },
197+
{ predicate: "record.start_time == '09:00'", column: 'start_time', value: '09:00', stored: '09:00:00', admitted: true },
198+
{ predicate: "record.start_time == '09:00'", column: 'start_time', value: '09:01', stored: '09:01:00', admitted: false },
199+
// Control: a TEXT column is judged as written, whatever its value looks like.
200+
{ predicate: "record.title == '2026-01-05'", column: 'title', value: '2026-01-05T15:00:00Z', stored: '2026-01-05T15:00:00Z', admitted: false },
201+
{ predicate: "record.title > '2026-01-05'", column: 'title', value: '2026-01-05T15:00:00Z', stored: '2026-01-05T15:00:00Z', admitted: true },
202+
];
203+
204+
describe("formula's whole-day copy is out of reach in this file", () => {
205+
it('a bare-day $lte no longer admits an instant later on that day', () => {
206+
expect(matchesFilterCondition({ d: '2026-01-05T15:00:00Z' }, { d: { $lte: '2026-01-05' } } as never)).toBe(false);
207+
expect(matchesFilterCondition({ d: '2026-01-05' }, { d: { $lte: '2026-01-05' } } as never)).toBe(true);
208+
});
209+
});
210+
211+
for (const [driverName, makeDriver] of DRIVERS) {
212+
describe(`${driverName}: the write check and the read give one answer for one temporal row`, () => {
213+
for (const cell of CELLS) {
214+
const verdict = cell.admitted ? 'admitted and shown' : 'refused 403 and hidden';
215+
it(`${cell.predicate}, ${cell.column} written as ${show(cell.value)}: ${verdict}`, async () => {
216+
const r = await boot(makeDriver, cell.predicate);
217+
const written = await outcome(r.engine.insert(r.OBJ, { id: 'w', [cell.column]: cell.value }, { context: r.caller } as never));
218+
expect(written).toEqual(cell.admitted ? 'admitted' : DENIED);
219+
// The read under the same predicate, over the same value stored by the system.
220+
await r.engine.insert(r.OBJ, { id: 'r', [cell.column]: cell.value }, { context: SYS_CTX } as never);
221+
expect((await r.storedRow('r'))?.[cell.column]).toEqual(cell.stored);
222+
expect(await r.shownTo('r')).toBe(cell.admitted);
223+
// A refused write stored nothing; an admitted one stored the same row.
224+
const w = await r.storedRow('w');
225+
expect(w === undefined ? undefined : w[cell.column]).toEqual(cell.admitted ? cell.stored : undefined);
226+
});
227+
}
228+
229+
it("a by-id update judges the stored form too: onto the day as an instant 'admitted', onto the next day 403 and unchanged", async () => {
230+
const r = await boot(makeDriver, "record.due_on == '2026-01-05'");
231+
await r.engine.insert(r.OBJ, { id: 'u', due_on: '2026-01-05' }, { context: SYS_CTX } as never);
232+
expect(await outcome(r.engine.update(r.OBJ, { due_on: '2026-01-05T23:30:00Z' }, { where: { id: 'u' }, context: r.caller } as never)))
233+
.toBe('admitted');
234+
expect(await outcome(r.engine.update(r.OBJ, { due_on: new Date('2026-01-06T00:30:00Z') }, { where: { id: 'u' }, context: r.caller } as never)))
235+
.toEqual(DENIED);
236+
expect((await r.storedRow('u'))?.due_on).toBe('2026-01-05');
237+
});
238+
});
239+
}
240+
241+
describe('the stored-form step reads the declaration, never the values', () => {
242+
const COLUMNS = declaredTemporalColumns({
243+
fields: {
244+
due_on: { type: 'date', multiple: false },
245+
due_at: { type: 'datetime', multiple: false },
246+
start_time: { type: 'time', multiple: false },
247+
title: { type: 'text', multiple: false },
248+
},
249+
});
250+
251+
it('names exactly the declared date / datetime / time columns, and none without a declaration', () => {
252+
expect([...COLUMNS.entries()]).toEqual([['due_on', 'date'], ['due_at', 'datetime'], ['start_time', 'time']]);
253+
expect(declaredTemporalColumns(undefined).size).toBe(0);
254+
});
255+
256+
it('puts every value comparand on a temporal column into its form, and leaves everything else as written', () => {
257+
const filter = {
258+
$and: [
259+
{ due_on: '2026-01-05T15:00:00Z' },
260+
{ due_on: { $in: ['2026-01-05T15:00:00Z', '2026-01-06'], $null: false } },
261+
{ start_time: { $between: ['09:00', '17:30'] } },
262+
{ due_at: { $gte: '2026-01-05T18:00:00+08:00', $contains: '2026' } },
263+
{ due_on: { $lte: { $field: 'title' } } },
264+
{ title: '2026-01-05T15:00:00Z' },
265+
],
266+
};
267+
const before = JSON.stringify(filter);
268+
expect(storedFormCheckFilter(filter, COLUMNS)).toEqual({
269+
$and: [
270+
{ due_on: '2026-01-05' },
271+
{ due_on: { $in: ['2026-01-05', '2026-01-06'], $null: false } },
272+
{ start_time: { $between: ['09:00:00', '17:30:00'] } },
273+
{ due_at: { $gte: '2026-01-05T10:00:00.000Z', $contains: '2026' } },
274+
{ due_on: { $lte: { $field: 'title' } } },
275+
{ title: '2026-01-05T15:00:00Z' },
276+
],
277+
});
278+
expect(JSON.stringify(filter)).toBe(before);
279+
});
280+
281+
it('hands back the same filter and image when nothing is temporal (copy-on-write)', () => {
282+
const filter = { $or: [{ title: '2026-01-05T15:00:00Z' }, { due_on: { $null: true } }] };
283+
expect(storedFormCheckFilter(filter, COLUMNS)).toBe(filter);
284+
const image = { title: '2026-01-05T15:00:00Z', due_on: '2026-01-05' };
285+
expect(storedFormImage(image, COLUMNS)).toBe(image);
286+
expect(storedFormImage({ due_on: new Date('2026-01-05T15:00:00Z') }, COLUMNS)).toEqual({ due_on: '2026-01-05' });
287+
});
288+
});

0 commit comments

Comments
 (0)