Skip to content

Commit 8b123c0

Browse files
fix(plugin-security,service-analytics): a boolean comparand is judged by the spec verdict at the RLS compile seam and in the NativeSQL strategy (#21424)
Fixes #21376 Clause-②: no (narrowing) Both compilers that build filters outside the engine's field-aware door now run the spec's boolean-comparand verdict (`booleanComparandDoorVerdict`, `@objectstack/spec/data`). They answer what the engine door answers. Neither copies the verdict's table or its words. ## What changes - **Position 1: the RLS compile seam (`plugin-security`, `rls-compiler.ts`).** - The boolean arm sits beside the number arm in `judgeCompiledComparands`, in one walk, as the engine's walk does. - `narrowPolicyNumberComparands` became `narrowPolicyComparands`. At a field key the number arm judges first; where it does not judge, the boolean arm may. The two classes are disjoint. - Both arms share one field-spec walker (`narrowedFieldSpec`), so they judge the same positions by construction. The boolean positions are the number door's by identity, as in the spec. - The boolean arm reads `booleanComparandFieldVerdict` / `booleanComparandDoorVerdict` and words its refusal with `booleanComparandRefusalMessage`. - It reads the field metas from the guard's existing `number` map. That map records every declared column with its `type` / `returnType`, which is the same slice the boolean verdict reads. So `security-plugin.ts` is untouched. - A refusal leaves through the existing `refused-comparand` route. That is the envelope the number arm answers: the policy joins `deniedBy`, the read gets `RLS_DENY_FILTER` (zero rows), the write check answers `PERMISSION_DENIED` / 403, and the WARN detail is rooted at the clause (`using.flag.$ne`). - An accepted spelling narrows copy-on-write: `'true'` / `'false'`, `'1'` / `'0'` and `1` / `0`. - **Position 2: the NativeSQL strategy (`service-analytics`, `native-sql-strategy.ts`).** - `compileClauses` runs the same verdict on every filter it compiles: the query's `where`, each measure's own `filter` and the dataset's own scope. - The dataset door's `runtimeFilter` arrives merged into the `where` (`DatasetExecutor.combineFilters`). - The condition is lowered by `lowerAnalyticsWhere` (the shared faces first, both spellings) and then walked. A member is judged at the column `resolveStorageTarget` resolves it to, through the host's `declaredFieldType` hook. That is the same target the datetime lowering, the text-operator constant and `$empty` already ask. - An accepted spelling narrows copy-on-write. Anything else the verdict refuses is refused through `invalidFilterError`, the analytics `where` door's own envelope (`INVALID_FILTER` / 400), before any statement runs. - A host with no `declaredFieldType` hook judges nothing, the tiering every such hook here takes. - **Docs.** `content/docs/permissions/rls.mdx` said "Four ways a policy denies rather than leaks". It now names the declared-type comparand refusal as the fourth, for the boolean and the number classes. - **Changeset.** `patch` for both packages, `Clause-②: no`. ## Measured before and after (the engine door is the target column) Base `6d67ad5ec`, head `ef3ea8700`. Two servers: SQLite (`SqlDriver`, better-sqlite3) and a private PostgreSQL 16.14 started for this run. **Position 1.** The real `SecurityPlugin` middleware over a real `ObjectQL`, as a member whose permission set has one policy with `using` and `check` the same predicate. Two rows: `t` stores `true`, `f` stores `false`. "write" is an insert of a `true` / `false` row as the member. | predicate | before: read, SQLite | before: read, PG | before: write t / f | after: read (both) | after: write t / f | engine door (`where`, both) | |---|---|---|---|---|---|---| | `record.flag == true` | t | t | admitted / 403 | t | admitted / 403 | t | | `record.flag == 'true'` | none | t | 403 / 403 | t | admitted / 403 | t | | `record.flag != 'true'` | **f, t** | f | **admitted** / admitted | **f** | 403 / admitted | f | | `record.flag == 'yes'` | none | **t** | 403 / 403 | none, both clauses dropped `refused-comparand` | 403 / 403 | `INVALID_FILTER` / 400 | | `record.flag == 1` | t | t | 403 / 403 | t | admitted / 403 | t | | `record.flag == '1'` | t | t | 403 / 403 | t | admitted / 403 | t | The negation was fail-open on both faces before: the read kept the excluded row on SQLite, and the write check admitted it on both dialects. On PostgreSQL `'yes'` was read as `true`. **Position 2.** Three faces were measured: - the dataset door through `RestServer`'s own `POST /api/v1/analytics/dataset/query` route handler; - the cube read through `AnalyticsService.query`, which the runtime's `POST /analytics/query` relays verbatim; - the same service over `AnalyticsServicePlugin`'s composition, narrowed to the ObjectQL strategy, as the engine-door column. The base cells below use two rows (one `true`, one `false`). | `runtimeFilter` / `where` | before: native, SQLite | before: native, PG | after: native (both) | engine door | |---|---|---|---|---| | `{ flag: true }` | 200, 1 | 200, 1 | 200, 1 | 200, 1 | | `{ flag: "true" }` | **200, 0** | 200, 1 | 200, 1 | 200, 1 | | `{ flag: { $ne: "true" } }` | **200, 2** | 200, 1 | 200, 1 | 200, 1 | | `{ flag: "yes" }` | **200, 0** | **200, 1** | 400 `INVALID_FILTER`, no statement ran | 400 `INVALID_FILTER` | | `{ flag: 1 }` | 200, 1 | 200, 1 | 200, 1 | 200, 1 | | `{ flag: "1" }` | 200, 1 | 200, 1 | 200, 1 | 200, 1 | The cube read showed the same cells, plus `"false"`, `$in` and `$nin` of strings, all aligned after the change. ## The raise-rule measurement (first) - `git grep` at `6d67ad5ec` over `examples`, `packages` (`create-objectstack` templates included) and `skills` found 0 RLS predicates comparing with `'true'` / `'false'` / `'1'` / `'0'`, and 0 analytics `where` / `filter` / `runtimeFilter` / `FilterArray` comparands spelling a boolean as text. - Every shipped `using` / `check` predicate compares an id, an email, an org or a `null`. - The one `== "yes"` hit is a showcase action-visibility predicate on a `radio` field. It is not a boolean, not RLS and not analytics. - **Studio's policy condition builder at `.objectui-sha` `89cad75d5`: NOT MEASURED.** The objectui sibling is not checked out in this container. ## Copy-on-write - PM assumption 4 measured: on `main` the compiled policy filter is not shared across requests. `compileCelToFilter` keeps no cache, and `compileFilter` is called per read and per write check (`security-plugin.ts`, the `layer1` compile and the write-check compile). - The narrowing is copy-on-write anyway. The RLS pin's `@objectstack/formula` mock deep-freezes every filter `compileCelToFilter` returns, so every cell would throw on an edit in place. One test also reads the frozen filters back and finds the string still there. - The analytics pin deep-freezes every input filter and the registered dataset (its own `filter` and its measure's `filter`). ## Pins - `packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts`, 26 cases. - 7 narrowed cells and 5 refused cells per dialect. The PostgreSQL cell runs where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. - Each cell asserts the `where` twin's rows or envelope, the member's read, both writes, and the drop reasons. - One test checks that the detail is rooted at its clause, and one is the copy-on-write read-back. - `packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts`, 68 cases. - 16 filters at the cube read and at the dataset door, per dialect. - Each case asserts the engine face's answer and the native face's equality with it. It also asserts which strategy answered, and that a refusal ran no statement. - Two more tests: the FilterArray spelling with the cube-qualified member, and a registered dataset's own scope and measure filter read by the cube door. - After merging `main`, `45efcfa3d` had widened the verdict to refuse a number other than 1 / 0, a `Date` and an array. Both compilers followed with no code change, because they hold no table of their own. The pins gained a `2` cell at each position. ## Ablations (each mutation landed and was restored on disk by `scripts/ablation-replace.mjs`; blob equal to `HEAD` and `git diff HEAD` empty after each) Run at `95bf8c4d0`, before the merge and before the `2` cells were added. Both pins import their subject by relative path (`./security-plugin.js`, `../plugin.js`), so the ablated code is `src/`, never a `dist/`. No build leg or dist preflight applies. | mutation | pin | result | |---|---|---| | boolean arm removed (`false &&` before the RLS boolean field verdict) | RLS, SQLite | 12 failed / 1 passed (the `== true` control); the negation cell shows `f, t` again | | RLS narrowing removed (`narrows` returns the comparand) | RLS, SQLite | 7 failed (every narrowed cell and the read-back) / 6 passed (the control, the 4 refusals, the detail) | | NativeSQL narrowing removed | analytics, SQLite | 16 failed (the canonical-string, negation, list and combinator cells, the array spelling, the registered dataset) / 16 passed (controls, refusals, and `'1'` / `$eq '0'`, which SQLite affinity already answered) | | NativeSQL verdict call removed (`judgedBooleanComparands` returns its input) | analytics, SQLite | 22 failed / 10 passed (the controls and the `'1'` cells) | ## Verification (at the head named) - **Gate derivation.** `dispatch-gates --commands` at `78e4f3eb2` derived 96 commands. All 96 exit 0. - Four first answered `PREREQUISITE NOT MET` (exit 3) and were rerun after building what they read: `check:skill-examples` (client and client-react), `check:i18n` (its turbo closure), `check:dual-build-cjs-loads` (the full `turbo run build`), and `check:type-check-debt`, which overran a 9-minute timeout on the shared box and then finished. - `--ran` reconciliation: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN. - **At `ef3ea8700`.** The only later commit is a 2-line type fix in the RLS pin. - `check:type-check-coverage`, `check:type-check-debt`, `check:test-source-alias`, `check:cross-package-test-inputs` and `check:nul-bytes` were rerun: all exit 0. The gate list is unchanged. - Both pins on SQLite and PostgreSQL: 26 and 68 passed. - **Full test scripts at `78e4f3eb2`.** - `pnpm --filter @objectstack/plugin-security test`: 161 files, 3513 passed, 45 skipped. - `pnpm --filter @objectstack/service-analytics test`: 169 files, 3828 passed, 123 skipped. - **Typecheck at `ef3ea8700`.** `pnpm --filter @objectstack/plugin-security typecheck` (test layer included) and `pnpm --filter @objectstack/service-analytics typecheck` both pass. The first run found 2 TS2345 in the new pin, which `ef3ea8700` fixes. - **Lint, narrowed to the 4 changed TS files at `ef3ea8700`.** - `eslint --no-inline-config --format json` read 4 files and found 0 errors and 0 warnings. - `--print-config` shows no `parserOptions.project` or `projectService` for any of them. `eslint.config.mjs` states it never enables type-aware linting, so this diff cannot move an untouched file's verdict. - The `.md` / `.mdx` files are outside eslint's `files` globs. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **The `== 1` write cell moves.** A policy `record.flag == 1` used to refuse writing a `true` row while its read showed that row. The write check compared the stored `true` with `1`. Narrowing `1` to `true` is the spec's verdict and the engine door's answer, so the write check now agrees with the read. The control `== true` does not move on any face. - **The guard key `RlsFieldGuard.number` now feeds both arms.** It always recorded every declared column. Renaming it to a class-neutral name would touch `security-plugin.ts`, which is outside this card's surface. Noted, not filed. - **A relationship-path member** (`account.active`) is judged at the column it resolves to in NativeSQL. The engine-door column for that case is NOT MEASURED here. - **A `formula` returning boolean is `deferred` at NativeSQL.** The host's `declaredFieldType` hook relays no `returnType`, because the plugin retired that relay. The engine refuses a formula filter one door earlier anyway. - **The NativeSQL face does not run the number-comparand door either.** This is the twin of position 2, measured on SQLite through `AnalyticsService.query`: - `{ amount: "abc" }` answers 200 / 0, `{ amount: { $lte: "9999-12-31" } }` answers 200 / 2, and `{ amount: true }` answers 200 / 0; - the engine door answers `INVALID_FILTER` / 400 for each. - It is out of this card's scope and left untouched, and the report hands it to the seat. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 68c5ab7 commit 8b123c0

6 files changed

Lines changed: 939 additions & 60 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
Row-level security policies and the analytics native-SQL path judge a comparand against a declared boolean field by the platform's boolean-comparand rule, the one the data engine's `where` already applies
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal or narrowing of a filter comparand against a declared boolean column at two compilers outside the engine's where door, the same comparand that door already judges: no authorable key, spelling, export or stored shape moves. RowLevelSecurityPolicySchema, every permission set, every dataset, cube and analytics query parse and save as before, the predicate's and the filter's text are untouched, @objectstack/plugin-security and @objectstack/service-analytics export the same names with the same types, and no stored row is read or rewritten. Which boolean the author meant by a refused comparand is not something a ledger entry can decide, so there is nothing for objectstack migrate meta to rewrite. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers a filter comparand's type and this diff adds none (not registered / already-registered); and the change is runtime behaviour with no published interface or type changed (not runtime-interface-only / type-surface-only). -->
11+
12+
**BREAKING**: this narrows what two compilers outside the engine's `where` door accept. The RLS compile seam now drops a row-level policy, and the analytics native-SQL face now refuses a query, when either compares a declared boolean field with a comparand outside the accepted set. It ships as `minor` under the launch-window convention for accept-set narrowings. No export, type or error code changes.
13+
14+
- **Row-level security (`@objectstack/plugin-security`).** A compiled `using` / `check` predicate on a `boolean` or `toggle` column (or a `formula` returning `boolean`) is judged by `booleanComparandDoorVerdict` from `@objectstack/spec/data`, in the same pass as the number rule. `'true'` / `'false'`, `'1'` / `'0'` and `1` / `0` are read as the boolean each names. Anything else the rule refuses (a string such as `'yes'`, `'TRUE'` or `''`, a number other than `1` / `0`) drops the policy as a refused comparand: the read is filtered by the deny sentinel, the write is refused 403, and the WARN line names the clause, the field and the position. Before, `record.flag != 'true'` kept every row on SQLite and the write check admitted every row, so the exclusion the author wrote was not applied.
15+
- **Analytics native SQL (`@objectstack/service-analytics`).** The query's `where` (and the dataset query's `runtimeFilter`, which is merged into it), each measure's own `filter` and a dataset's own `filter` are judged by the same rule before the statement compiles. An accepted spelling is read as its boolean, and anything else the rule refuses is refused `INVALID_FILTER` / 400 with the rule's own message, before any statement runs. The native strategy now answers what the engine-aggregate strategy answers. Before, `{ flag: 'true' }` counted no rows on SQLite, `{ flag: { $ne: 'true' } }` counted every row, and `{ flag: 'yes' }` answered 200.
16+
- **What you may notice.** A policy or analytics filter that compared a boolean field with a value outside the accepted set now refuses instead of answering. Write `true` / `false`. A policy `record.flag == 1` now admits writing a `true` row, which its read already showed.
17+
- **Unchanged.** A boolean literal, a column that is not boolean, a `{ $field }` reference, and an object whose declaration cannot be read (nothing is judged without one).

‎content/docs/permissions/rls.mdx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,23 @@ Layer 1 (business RLS) ─┘
197197

198198
## The fail-closed contract
199199

200-
Four ways a policy denies rather than leaks:
200+
Five ways a policy denies rather than leaks:
201201

202202
1. A policy exists but **every** applicable expression fails to compile → a
203203
deny-everything filter.
204204
2. A referenced context variable is missing, null, or an empty array → that
205205
policy drops out (it cannot match).
206206
3. A policy references a column the object doesn't have → deny.
207-
4. `check` is omitted → `using` stands in as the `check`. The choice is
207+
4. A policy compares a column with a literal its declared type cannot be
208+
compared with → that policy drops out, for `using` and `check` alike: on a
209+
`boolean` / `toggle` column, anything but `true` / `false` and the
210+
spellings `'true'` / `'false'`, `1` / `0` and `'1'` / `'0'`
211+
(`active == 'yes'`, `active != 'TRUE'`, `active == 2`); on a numeric
212+
column, a comparand that is not a number (a string with no numeric reading,
213+
a boolean). These are the comparisons a caller's `where` is refused for
214+
(`INVALID_FILTER`). An accepted spelling is read as the value it names, so
215+
`active != 'true'` hides the `true` rows exactly as `active != true` does.
216+
5. `check` is omitted → `using` stands in as the `check`. The choice is
208217
made per operation across all the applicable policies, not policy by
209218
policy: when any of them declares a `check`, only the declared checks
210219
decide, and a USING-only sibling's `using` is not part of the check.
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21376] A row-level policy comparing a BOOLEAN column with a comparand that
5+
* is not a boolean is refused at the RLS compile seam, and an accepted
6+
* spelling is narrowed to the boolean it names — as the engine's `where` door
7+
* answers the same comparison — so the read, the write and a caller's `where`
8+
* give one answer.
9+
*
10+
* Driven through the real engine, the real plugin and `SqlDriver`, as a member
11+
* resolving a permission set whose `using` and `check` are the same predicate,
12+
* over two rows (`t` stores `true`, `f` stores `false`). Measured before the
13+
* seam ran the spec's boolean-comparand verdict (`booleanComparandDoorVerdict`,
14+
* the one the engine's `where` door consults beside the number one):
15+
*
16+
* | predicate | read, SQLite | read, PostgreSQL 16 | write `true` / `false` | `where` twin |
17+
* |---|---|---|---|---|
18+
* | `record.flag == true` | `t` | `t` | admitted / 403 | `t` |
19+
* | `record.flag == 'true'` | none | `t` | 403 / 403 | `t` |
20+
* | `record.flag != 'true'` | **`f`, `t`** | `f` | **admitted** / admitted | `f` |
21+
* | `record.flag == 'yes'` | none | **`t`** | 403 / 403 | `INVALID_FILTER` / 400 |
22+
* | `record.flag == 1` | `t` | `t` | 403 / 403 | `t` |
23+
* | `record.flag == '1'` | `t` | `t` | 403 / 403 | `t` |
24+
*
25+
* The negation was fail-open on both faces: the read kept the row the author
26+
* excluded, and the write check admitted it. The policy's comparand is now
27+
* judged by the spec's verdict in the same walk as the number arm: `'true'` /
28+
* `'false'`, `'1'` / `'0'` and `1` / `0` narrow to the boolean each names, so
29+
* every cell answers the `where` twin's rows and the write check admits
30+
* exactly what the read shows; anything else the verdict refuses drops the
31+
* policy through the `refused-comparand` route for both clauses (the read gets
32+
* the deny sentinel, the write a 403). `record.flag == true` is the control
33+
* and does not move.
34+
*
35+
* The compiled policy filter is deep-frozen as `compileCelToFilter` returns it
36+
* (the mock below), so every cell also holds the narrowing to copy-on-write:
37+
* an edit in place would throw. The last block reads the frozen filter back.
38+
*
39+
* The PostgreSQL cell runs where `OS_TEST_POSTGRES_URL` is set and is a named
40+
* skip otherwise; no CI step provisions that variable for this package. Each
41+
* live table is dropped after its case.
42+
*/
43+
44+
import { describe, it, expect, afterEach, vi } from 'vitest';
45+
import { ObjectQL } from '@objectstack/objectql';
46+
import { SqlDriver } from '@objectstack/driver-sql';
47+
import { PermissionSetSchema } from '@objectstack/spec/security';
48+
import { SecurityPlugin } from './security-plugin.js';
49+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
50+
51+
/** Every compiled policy filter `compileCelToFilter` handed the seam, deep-frozen, by predicate. */
52+
const compiled = new Map<string, unknown[]>();
53+
54+
function deepFreeze<T>(value: T): T {
55+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
56+
Object.freeze(value);
57+
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
58+
}
59+
return value;
60+
}
61+
62+
vi.mock('@objectstack/formula', async (importOriginal) => {
63+
const real = await importOriginal<typeof import('@objectstack/formula')>();
64+
return {
65+
...real,
66+
compileCelToFilter: ((expression, options) => {
67+
const result = real.compileCelToFilter(expression, options);
68+
if (result.ok) {
69+
deepFreeze(result.filter);
70+
const key = typeof expression === 'string' ? expression : (expression.source ?? '');
71+
compiled.set(key, [...(compiled.get(key) ?? []), result.filter]);
72+
}
73+
return result;
74+
}) as typeof real.compileCelToFilter,
75+
};
76+
});
77+
78+
const SYS_CTX = { isSystem: true, userId: 'usr_system' };
79+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
80+
81+
interface Cell {
82+
id: 'sqlite' | 'pg';
83+
label: string;
84+
config: () => Record<string, unknown> | null;
85+
}
86+
87+
const DRIVER_CELLS: readonly Cell[] = [
88+
{ id: 'sqlite', label: 'SqlDriver, SQLite', config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
89+
{
90+
id: 'pg',
91+
label: 'SqlDriver, live PostgreSQL',
92+
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
93+
},
94+
];
95+
96+
const cleanups: Array<() => Promise<void>> = [];
97+
afterEach(async () => {
98+
while (cleanups.length) {
99+
try { await cleanups.pop()!(); } catch { /* noop */ }
100+
}
101+
});
102+
103+
let seq = 0;
104+
/** One engine and plugin over `config`, with ONE policy whose `using` and `check` are the same predicate. */
105+
async function boot(config: Record<string, unknown>, predicate: string) {
106+
const OBJ = `qa_rls_boolean_${process.pid}_${++seq}`;
107+
const driver = new SqlDriver(config as never);
108+
const engine = new ObjectQL();
109+
engine.registerDriver(driver as never, true);
110+
await engine.init();
111+
engine.registerApp({
112+
id: `com.objectstack.qa.rls-boolean-comparand-${seq}`,
113+
name: 'RLS boolean comparand',
114+
version: '1.0.0',
115+
type: 'plugin',
116+
scope: 'system',
117+
objects: [
118+
{
119+
name: OBJ,
120+
label: 'Flagged line',
121+
sharingModel: 'public_read_write',
122+
fields: {
123+
id: { name: 'id', type: 'text', primaryKey: true },
124+
flag: { name: 'flag', type: 'boolean' },
125+
},
126+
},
127+
],
128+
} as never);
129+
await engine.syncSchemas();
130+
cleanups.push(async () => {
131+
if (config.client === 'pg') await (driver as unknown as { execute(sql: string): Promise<unknown> }).execute(`drop table if exists ${OBJ}`);
132+
await engine.destroy();
133+
});
134+
135+
const set = PermissionSetSchema.parse({
136+
name: 'qa_boolean_guard',
137+
objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
138+
rowLevelSecurity: [{ name: 'boolean_guard', object: OBJ, operation: 'all', using: predicate, check: predicate }],
139+
});
140+
const services: Record<string, unknown> = {
141+
manifest: { register: vi.fn() },
142+
objectql: engine,
143+
metadata: {
144+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
145+
list: async () => [MEMBER_DEFAULT, set],
146+
},
147+
};
148+
const warn = vi.fn();
149+
const ctx = {
150+
logger: { info: vi.fn(), warn, error: vi.fn(), debug: vi.fn() },
151+
registerService: vi.fn(),
152+
getService: (name: string) => {
153+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
154+
return services[name];
155+
},
156+
};
157+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
158+
await plugin.init(ctx as never);
159+
await plugin.start(ctx as never);
160+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
161+
162+
await engine.insert(OBJ, { id: 't', flag: true }, { context: SYS_CTX } as never);
163+
await engine.insert(OBJ, { id: 'f', flag: false }, { context: SYS_CTX } as never);
164+
165+
const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' };
166+
const ids = (rows: unknown) => (rows as Array<{ id: string }>).map((r) => r.id).sort();
167+
/** The ids the member's read shows. */
168+
const shown = async () => ids(await engine.find(OBJ, { context: caller } as never));
169+
/** The engine's `where` door on the same comparison: the rows it serves, or its refusal envelope. */
170+
const whereTwin = (where: Record<string, unknown>) =>
171+
engine.find(OBJ, { where, context: SYS_CTX } as never).then(ids, (e: unknown) => envelopeOf(e));
172+
const write = (id: string, flag: boolean) =>
173+
outcome(engine.insert(OBJ, { id, flag }, { context: caller } as never));
174+
/** `clause:reason` of every fail-closed drop of this policy the compile seam logged. */
175+
const drops = () => [
176+
...new Set(
177+
warn.mock.calls
178+
.map((call) => call[1] as { reason?: string; clause?: string; policy?: string } | undefined)
179+
.filter((meta) => meta?.policy === 'boolean_guard')
180+
.map((meta) => `${meta!.clause}:${meta!.reason}`),
181+
),
182+
].sort();
183+
/** The `detail` of each fail-closed drop of this policy, by clause. */
184+
const details = () =>
185+
Object.fromEntries(
186+
warn.mock.calls
187+
.map((call) => call[1] as { clause?: string; policy?: string; detail?: string } | undefined)
188+
.filter((meta) => meta?.policy === 'boolean_guard')
189+
.map((meta) => [meta!.clause!, meta!.detail!]),
190+
) as Record<string, string>;
191+
return { shown, whereTwin, write, drops, details };
192+
}
193+
194+
type Envelope = { code: string; status: number };
195+
const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 };
196+
const REFUSED: Envelope = { code: 'INVALID_FILTER', status: 400 };
197+
const envelopeOf = (e: unknown): Envelope => {
198+
const x = e as { code?: string; status?: number; statusCode?: number };
199+
return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) };
200+
};
201+
const outcome = (p: Promise<unknown>): Promise<'admitted' | Envelope> =>
202+
p.then(() => 'admitted' as const, (e: unknown) => envelopeOf(e));
203+
204+
interface Row {
205+
predicate: string;
206+
/** The same comparison as a caller's `where`. */
207+
where: Record<string, unknown>;
208+
/** What the member's read shows — the `where` twin's rows. */
209+
shown: string[];
210+
/** The write check on a `true` row and on a `false` row. */
211+
writes: readonly ['admitted' | Envelope, 'admitted' | Envelope];
212+
}
213+
214+
/** Each cell answers the engine-door column; the write check admits what the read shows. */
215+
const NARROWED: readonly Row[] = [
216+
// The control: a boolean literal. Unchanged on every face.
217+
{ predicate: 'record.flag == true', where: { flag: true }, shown: ['t'], writes: ['admitted', DENIED] },
218+
{ predicate: "record.flag == 'true'", where: { flag: 'true' }, shown: ['t'], writes: ['admitted', DENIED] },
219+
// The negation hides the row the author excluded, and the write check refuses it.
220+
{ predicate: "record.flag != 'true'", where: { flag: { $ne: 'true' } }, shown: ['f'], writes: [DENIED, 'admitted'] },
221+
{ predicate: "record.flag == 'false'", where: { flag: 'false' }, shown: ['f'], writes: [DENIED, 'admitted'] },
222+
// The number spelling: the read was already right on both dialects; the
223+
// write check compared the stored `true` with `1` and refused it.
224+
{ predicate: 'record.flag == 1', where: { flag: 1 }, shown: ['t'], writes: ['admitted', DENIED] },
225+
{ predicate: "record.flag == '1'", where: { flag: '1' }, shown: ['t'], writes: ['admitted', DENIED] },
226+
{ predicate: "record.flag in ['true']", where: { flag: { $in: ['true'] } }, shown: ['t'], writes: ['admitted', DENIED] },
227+
];
228+
229+
/** Each refused: no row shown, both writes 403, both clauses dropped, and the twin is the engine's 400. */
230+
const REFUSED_ROWS: ReadonlyArray<readonly [predicate: string, where: Record<string, unknown>]> = [
231+
["record.flag == 'yes'", { flag: 'yes' }],
232+
["record.flag != 'yes'", { flag: { $ne: 'yes' } }],
233+
["record.flag == 'TRUE'", { flag: 'TRUE' }],
234+
["record.flag in [true, 'on']", { flag: { $in: [true, 'on'] } }],
235+
// A number other than 1 / 0 is the verdict's refusal too: the seam carries no
236+
// table of its own, so it answers whatever the spec's verdict answers.
237+
['record.flag == 2', { flag: 2 }],
238+
];
239+
240+
for (const cell of DRIVER_CELLS) {
241+
const config = cell.config();
242+
const suffix = config ? '' : ' (skipped: set OS_TEST_POSTGRES_URL to run this cell)';
243+
244+
describe.skipIf(!config)(`[#21376] a boolean comparand is narrowed at the RLS seam as the where door narrows it — ${cell.label}${suffix}`, () => {
245+
for (const row of NARROWED) {
246+
it(`${row.predicate}: the read shows [${row.shown.join(', ')}], the where twin's rows, and the write check agrees`, async () => {
247+
const r = await boot(config!, row.predicate);
248+
expect(await r.whereTwin(row.where)).toEqual(row.shown);
249+
expect(await r.shown()).toEqual(row.shown);
250+
expect(await r.write('wt', true)).toEqual(row.writes[0]);
251+
expect(await r.write('wf', false)).toEqual(row.writes[1]);
252+
expect(r.drops()).toEqual([]);
253+
});
254+
}
255+
});
256+
257+
describe.skipIf(!config)(`[#21376] a string that names no boolean is refused at the RLS seam, read and write alike — ${cell.label}${suffix}`, () => {
258+
for (const [predicate, where] of REFUSED_ROWS) {
259+
it(`${predicate}: no row is shown, both writes are 403, and the where twin is INVALID_FILTER / 400`, async () => {
260+
const r = await boot(config!, predicate);
261+
expect(await r.whereTwin(where)).toEqual(REFUSED);
262+
expect(await r.shown()).toEqual([]);
263+
expect(await r.write('wt', true)).toEqual(DENIED);
264+
expect(await r.write('wf', false)).toEqual(DENIED);
265+
// Both clauses dropped the policy through the shared-face route.
266+
expect(r.drops()).toEqual(['check:refused-comparand', 'using:refused-comparand']);
267+
});
268+
}
269+
});
270+
}
271+
272+
describe('[#21376] the refusal names its clause and the comparand\'s position', () => {
273+
it("record.flag != 'yes': each clause's detail is rooted at that clause", async () => {
274+
const r = await boot(DRIVER_CELLS[0].config()!, "record.flag != 'yes'");
275+
expect(await r.shown()).toEqual([]);
276+
expect(await r.write('wt', true)).toEqual(DENIED);
277+
const { using, check } = r.details();
278+
expect(using).toContain('`using` predicate compares a boolean column');
279+
expect(using).toContain('using.flag.$ne');
280+
expect(check).toContain('`check` predicate compares a boolean column');
281+
expect(check).toContain('check.flag.$ne');
282+
});
283+
});
284+
285+
describe('[#21376] narrowing is copy-on-write: the compiled policy filter is never edited', () => {
286+
it("record.flag != 'true': the filter the compiler returned still holds the string after the read and the write", async () => {
287+
const predicate = "record.flag != 'true'";
288+
compiled.delete(predicate);
289+
const r = await boot(DRIVER_CELLS[0].config()!, predicate);
290+
expect(await r.shown()).toEqual(['f']);
291+
expect(await r.write('wt', true)).toEqual(DENIED);
292+
const filters = compiled.get(predicate) ?? [];
293+
// One compile per read and per write check, every one frozen and unedited.
294+
expect(filters.length).toBeGreaterThanOrEqual(2);
295+
for (const filter of filters) {
296+
expect(Object.isFrozen(filter)).toBe(true);
297+
expect(JSON.stringify(filter)).toContain('"true"');
298+
}
299+
});
300+
});

0 commit comments

Comments
 (0)