Skip to content

Commit c68e8b1

Browse files
committed
wip: permission pin, spec docblock item 4, data-engine example, changesets, ADR anchor
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7bae28 commit c68e8b1

7 files changed

Lines changed: 306 additions & 22 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a dotted relation filter path names the nested-relation form as the route
6+
7+
A filter key such as `account.industry` — a dotted path through a relation field — is still refused with `INVALID_FIELD` / 400 at the query parameter door. Its words no longer say a filter reaches only the object's own columns, which stopped being true when the engine began serving the nested-relation form in `where`: they now name that form, `{ "account": { "industry": VALUE } }`, beside the denormalise remedy, in the same words as the engine's own refusal.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
feat(objectql): the nested-relation filter `{ relation: { field: value } }` is served in `where`, lowered at the engine's filter seam — the drivers receive `$in` / `$contains` and are unchanged
6+
7+
Clause-②: yes (widening)
8+
9+
A condition on a related record's own fields, written beneath a relation field of the queried object — `{ "account": { "industry": "tech" } }` beneath a `lookup` — is now answered by the engine in `where`, on every verb that takes one (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`) and by `judgeFilter`. It was refused with `INVALID_FILTER` / 400 until now; this supersedes the relation-field paragraph of the pending `20745-nested-object-door` entry.
10+
11+
**How it is answered.** The engine reads the related object with the condition, then matches the relation field against the ids that read returns, and the drivers receive only that: `{ "account": { "$in": [ids] } }` on a single-valued relation, and on a multi-valued one (`multiple: true`) an `$or` of one `$contains` per id, so it matches on any member. The relation types are `lookup`, `master_detail`, `user` and `tree`. It composes as written inside `$and` / `$or` / `$not`, and the `FilterArray` sugar lowers to it too. No related record matching selects no rows; under `$not`, a record whose relation is empty satisfies the negation.
12+
13+
**As the caller.** The related read is the engine's own `find` on the related object with the caller's execution context, so that object's access check, row scope and field permissions apply exactly as they do to a direct read of it. A condition on a field the caller cannot read is refused by the same check that refuses a direct filter on it (`PERMISSION_DENIED` / 403, naming the field), never answered with an empty list; a related record the caller cannot see matches no condition.
14+
15+
**Bounded.** At most `RELATION_FILTER_ID_CAP` (1,000, exported) related ids feed one condition. A condition matching more is refused with `INVALID_FILTER` / 400, naming the cap, the related object and the two-step route — never run over a cut-off list.
16+
17+
**Still refused, in the engine's words (`INVALID_FILTER` / 400, before any read):** a second level (a relation condition beneath the related object's own relation field, or a dotted key inside the condition), a key the related object does not declare, an empty condition `{}`, and a related object that is not registered. An aggregation's own `filter` and `having` keep refusing the form, and their words now name `where` as the place it is served. The dotted spelling `{ "account.industry": "tech" }` stays refused with `INVALID_FIELD` / 400, and its words now name the nested form to write instead. The structured-JSON and scalar-field refusals are unchanged.
18+
19+
Measured through `POST /api/v1/data/:object/query` on SQLite and PostgreSQL 16 (owner `u1`, region NA, on `d1` and `d3`; `d4` has no owner):
20+
21+
| `where` | before | now |
22+
|:--|:--|:--|
23+
| `{ owner: { region: "NA" } }` on a `lookup`, and its `master_detail` and multiple-lookup twins | `INVALID_FILTER` / 400 | `d1`, `d3` |
24+
| `{ parent: { title: "a" } }` on a `tree` field | `INVALID_FILTER` / 400 | `d2`, `d3` |
25+
| `{ $not: { owner: { region: "NA" } } }` | `INVALID_FILTER` / 400 | `d2`, `d4` |
26+
| `{ owner: { region: "APAC" } }` (no owner matches) | `INVALID_FILTER` / 400 | no rows |
27+
28+
On the in-memory driver, a multi-valued relation's `$contains` still matches a stored id by substring per element, so there an id that is a substring of another stored id (`u1` inside `u10`) also matches; SQLite and PostgreSQL match the element.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): the `FilterCondition` docblock says the query engine serves the nested-relation form in `where`
6+
7+
`FilterCondition`'s form 4, `{ relation: { field: value } }`, now states the served semantics: the engine reads the related object with the condition as the caller (its row scope and field permissions apply), matches the relation field against the ids it returns (`$in`, or any member on a multi-valued relation), reaches one level, and refuses a condition matching more related records than its cap rather than truncating. The `QueryFilter` example shows the form again, and the `Filter<T>` nested arm's comment says the engine serves one level. The type and the schema are unchanged.

‎content/docs/kernel/contracts/data-engine.mdx‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,18 +178,40 @@ where: {
178178
],
179179
}
180180

181-
// A condition on a related record's fields: filter the related object first,
182-
// then match the lookup against the ids it returns
181+
// Nested relation filter
182+
where: {
183+
account: { industry: 'tech' },
184+
}
185+
```
186+
187+
A nested relation filter is a condition on a related record's own fields, written
188+
beneath a relation field (`lookup`, `master_detail`, `user` or `tree`, single or
189+
multiple). The engine serves it in `where`, the same on every driver: it reads the
190+
related object with the condition **as the caller** — that object's row scope and field
191+
permissions apply, so a condition on a field the caller cannot read is refused
192+
(`PERMISSION_DENIED` / 403), never answered with an empty list — and then matches the
193+
relation field against the ids that read returns: `$in` on a single-valued relation, any
194+
member on a multi-valued one (`multiple: true`). No related record matching selects no
195+
rows; under `$not`, a record whose relation is empty satisfies the negation.
196+
197+
It reaches **one level**: every key must be a field the related object declares
198+
(`{ account: { owner: { region: 'NA' } } }` and the dotted `{ account: { 'owner.region': 'NA' } }`
199+
are refused), and a condition matching more than 1,000 related records is refused with
200+
`INVALID_FILTER` / 400 rather than run over a cut-off list. For either, run the two steps
201+
yourself — filter the related object, then match its ids:
202+
203+
```typescript
183204
const tech = await engine.find('account', { where: { industry: 'tech' }, fields: ['id'] });
184205
where: { account: { $in: tech.map((a) => a.id) } }
206+
// On a multi-valued lookup, one $contains per id:
207+
where: { $or: tech.map((a) => ({ accounts: { $contains: a.id } })) }
185208
```
186209

187-
A plain object with no `$` operator beneath a field — `{ account: { industry: 'tech' } }`
188-
under a lookup, `{ meta: { a: 1 } }` under a `json` field — is refused with
189-
`INVALID_FILTER` / 400 on every driver: no driver follows a relation into the related
190-
object, and a whole-value match on a JSON value means something different on each
191-
backend. On a multi-valued lookup (`multiple: true`), match each id with `$contains`
192-
(an `$or` of those for several ids) instead of `$in`.
210+
An aggregation's own `filter` and `having` do not serve the nested form (put the
211+
condition in `where`), and a plain object with no `$` operator beneath a `json` field —
212+
`{ meta: { a: 1 } }`, a whole-value match — is refused with `INVALID_FILTER` / 400 on
213+
every driver. A dotted path (`{ 'account.industry': 'tech' }`) is refused with
214+
`INVALID_FIELD` / 400: write it nested instead.
193215

194216
**Supported operators:** `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$between`, `$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$null`, `$exists`
195217

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20802] The nested-relation form reads the related object AS THE CALLER:
5+
* the related object's row scope and field permissions apply to the condition
6+
* exactly as they apply to a direct read of it — with the REAL security layer
7+
* (`SecurityPlugin` on a real `ObjectQL` over a real `SqlDriver`), through
8+
* `POST /api/v1/data/:object/query`.
9+
*
10+
* The ruling's permission axis, measured:
11+
*
12+
* - **A field the caller cannot read.** A direct filter on it is refused today
13+
* — `403 PERMISSION_DENIED`, the security layer's filter-oracle guard
14+
* (`assertReadableQueryFields`), naming the field. The nested form reaches
15+
* that SAME check through the related read, so it answers the same refusal,
16+
* loudly — ⛔ never a `200` with no rows, and ⛔ never the rows a system read
17+
* would have matched (which is filtering by a value the caller may not see).
18+
* There is no second copy of the rule in the engine.
19+
* - **The related object's row scope.** A related record the caller's RLS
20+
* hides matches no condition: the rows it would have selected are not
21+
* selected, and a system caller still gets them.
22+
*
23+
* SQLite only: the checks are the security layer's, before any driver
24+
* dialect is involved; `data-nested-object-door.test.ts` carries the dialect
25+
* axis of the lowering itself.
26+
*/
27+
28+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
29+
import type { FilterCondition } from '@objectstack/spec/data';
30+
import { PermissionSetSchema } from '@objectstack/spec/security';
31+
import { ObjectQL } from '@objectstack/objectql';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import { SecurityPlugin } from '@objectstack/plugin-security';
34+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
35+
import { RestServer } from './rest-server';
36+
37+
const OBJECT = 'rest_nested_perm_ledger';
38+
const OWNER = 'rest_nested_perm_owner';
39+
40+
const SYS_CTX = { isSystem: true, userId: 'usr_system' };
41+
42+
const MEMBER_SET = PermissionSetSchema.parse({
43+
name: 'member_default',
44+
label: 'Member',
45+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
46+
// The field the caller may not read, on the RELATED object.
47+
fields: { [`${OWNER}.secret`]: { readable: false, editable: false } },
48+
// The related object's row scope: the caller sees owners outside region HIDDEN only.
49+
rowLevelSecurity: [{ name: 'owner_scope', object: OWNER, operation: 'all', using: "record.region != 'HIDDEN'" }],
50+
});
51+
52+
const MEMBER_CTX = { userId: 'usr_member', positions: [], permissions: [MEMBER_SET.name], posture: 'MEMBER' };
53+
54+
const OWNERS = [
55+
{ id: 'u1', region: 'NA', secret: 's1' },
56+
{ id: 'u2', region: 'EU', secret: 's2' },
57+
{ id: 'u3', region: 'HIDDEN', secret: 's3' },
58+
];
59+
const ROWS = [
60+
{ id: 'd1', title: 'a', owner: 'u1' },
61+
{ id: 'd2', title: 'b', owner: 'u2' },
62+
{ id: 'd3', title: 'c', owner: 'u1' },
63+
{ id: 'd4', title: 'd', owner: 'u3' },
64+
];
65+
66+
function createMockServer() {
67+
const noop = () => {};
68+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
69+
}
70+
71+
function makeRes() {
72+
const res: any = {
73+
write: () => true, end: () => {},
74+
header: () => res,
75+
status: (code: number) => { res._status = code; return res; },
76+
json: (body: any) => { res._json = body; return res; },
77+
};
78+
return res;
79+
}
80+
81+
const idsOf = (rows: any): string[] => (Array.isArray(rows) ? rows : rows?.records ?? []).map((r: any) => r.id).sort();
82+
const envelopeOf = (e: any) => ({ code: e?.code, status: e?.statusCode ?? e?.status });
83+
84+
describe('[#20802] the nested-relation form reads the related object as the caller — the real security layer', () => {
85+
let engine: ObjectQL;
86+
let query: (where: FilterCondition, object?: string) => Promise<{ status: number; body: any }>;
87+
88+
beforeAll(async () => {
89+
engine = new ObjectQL();
90+
engine.registerDriver(
91+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any),
92+
true,
93+
);
94+
await engine.init();
95+
engine.registerApp({
96+
id: 'com.objectstack.qa.nested-relation-permission-20802',
97+
name: 'Nested relation permission',
98+
version: '1.0.0',
99+
type: 'plugin',
100+
scope: 'system',
101+
objects: [
102+
{
103+
name: OWNER,
104+
label: 'Owner',
105+
sharingModel: 'public_read_write',
106+
fields: {
107+
region: { name: 'region', type: 'text' },
108+
secret: { name: 'secret', type: 'text' },
109+
},
110+
},
111+
{
112+
name: OBJECT,
113+
label: 'Ledger',
114+
sharingModel: 'public_read_write',
115+
fields: {
116+
title: { name: 'title', type: 'text' },
117+
owner: { name: 'owner', type: 'lookup', reference: OWNER },
118+
},
119+
},
120+
],
121+
} as never);
122+
await engine.syncSchemas();
123+
124+
const services: Record<string, unknown> = {
125+
manifest: { register: vi.fn() },
126+
objectql: engine,
127+
metadata: {
128+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
129+
list: async () => [MEMBER_SET],
130+
},
131+
};
132+
const ctx = {
133+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
134+
registerService: vi.fn(),
135+
getService: (name: string) => {
136+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
137+
return services[name];
138+
},
139+
};
140+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
141+
await plugin.init(ctx as never);
142+
await plugin.start(ctx as never);
143+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
144+
145+
await engine.insert(OWNER, OWNERS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
146+
await engine.insert(OBJECT, ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
147+
148+
const protocol = new ObjectStackProtocolImplementation(engine as any);
149+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
150+
(rest as any).resolveExecCtx = async () => MEMBER_CTX;
151+
rest.registerRoutes();
152+
const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query');
153+
expect(route).toBeDefined();
154+
query = async (where, object = OBJECT) => {
155+
const res = makeRes();
156+
await route!.handler({ params: { object }, body: JSON.parse(JSON.stringify({ where })), query: {}, headers: {} } as any, res);
157+
return { status: res._status ?? 200, body: res._json };
158+
};
159+
});
160+
161+
afterAll(async () => {
162+
try { await engine?.destroy(); } catch { /* noop */ }
163+
});
164+
165+
it('CONTROL a condition on a field the caller can read is served under the security layer', async () => {
166+
const res = await query({ owner: { region: 'NA' } });
167+
expect(res.status, JSON.stringify(res.body)).toBe(200);
168+
expect(idsOf(res.body)).toEqual(['d1', 'd3']);
169+
});
170+
171+
it('a field the caller cannot read: the direct filter is refused today, and the nested form answers the SAME refusal — never an empty result', async () => {
172+
// The one check, measured on a direct read of the related object.
173+
const direct = await engine.find(OWNER, { where: { secret: 's1' }, context: MEMBER_CTX } as never).then(() => null, (e: any) => e);
174+
expect(envelopeOf(direct)).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
175+
expect(String(direct?.message)).toContain('secret');
176+
177+
// The nested form, in-process and at the public door.
178+
const nested = await engine.find(OBJECT, { where: { owner: { secret: 's1' } }, context: MEMBER_CTX } as never)
179+
.then((rows) => ({ rows }), (e: any) => e);
180+
expect(envelopeOf(nested)).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
181+
expect(String(nested?.message)).toContain('secret');
182+
expect(String(nested?.message)).toContain(OWNER);
183+
184+
const res = await query({ owner: { secret: 's1' } });
185+
expect(res.status, JSON.stringify(res.body)).toBe(403);
186+
expect(JSON.stringify(res.body)).toContain('PERMISSION_DENIED');
187+
expect(res.body?.records, 'no rows are served beside the refusal').toBeUndefined();
188+
189+
// What the refusal withholds: a system read CAN filter by the value.
190+
const system = await engine.find(OBJECT, { where: { owner: { secret: 's1' } }, context: SYS_CTX } as never);
191+
expect(idsOf(system)).toEqual(['d1', 'd3']);
192+
});
193+
194+
it('the related object\'s row scope applies: a related record the caller cannot see matches no condition', async () => {
195+
const res = await query({ owner: { region: 'HIDDEN' } });
196+
expect(res.status, JSON.stringify(res.body)).toBe(200);
197+
expect(idsOf(res.body)).toEqual([]);
198+
// The record is there, and a system caller's condition finds it.
199+
const system = await engine.find(OBJECT, { where: { owner: { region: 'HIDDEN' } }, context: SYS_CTX } as never);
200+
expect(idsOf(system)).toEqual(['d4']);
201+
});
202+
});

0 commit comments

Comments
 (0)