Skip to content

Commit fdcf1a3

Browse files
committed
refactor(objectql): move the ReadableIds composition and permission resolution bodies out of engine.ts
Eight bodies of the "Readable-ids security and permission/formula resolution" section move unedited into engine-readable-ids-permission-formula.ts behind ReadableIdsPermissionFormulaHost; the class keeps every method, signature and docblock. READABLE_IDS_MAX_CHAIN_DEPTH moves with its one reader; writeFailureLogMeta, isExpressionDefault and four shared types gain export in place and are imported back. core's row-serving census row for the ReadableIds admission ask moves with its call. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5nM7fbhbE84nNy6VkE
1 parent f986702 commit fdcf1a3

3 files changed

Lines changed: 423 additions & 205 deletions

File tree

‎packages/core/src/security/row-serving-door-exposure.pin.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ const CALLERS: Record<string, Classification> = {
101101
kind: 'not-a-door',
102102
reason: 'the provider\'s service registration: it binds the admission into the `security` service the plugin registers; it serves no rows',
103103
},
104-
'packages/objectql/src/engine.ts': {
104+
// [#25522 cut 15] The ReadableIds composition's admission ask, moved out of engine.ts.
105+
'packages/objectql/src/engine-readable-ids-permission-formula.ts': {
105106
kind: 'not-a-door',
106107
reason:
107108
'the ReadableIds composition (ADR-0139 D2): it asks the admission of a PARENT object to compose the scope of a `$in` leaf '
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 15] The `ReadableIds` composition, permission and formula
5+
* resolution bodies of `ObjectQL`, moved out of `engine.ts`:
6+
* `composeReadableIdsChain` (the chain of composed read scopes),
7+
* `findUnservableControlledByParentMasters` (the boot screen over
8+
* `controlled_by_parent` details), `readableIdsScopeOf` (the scope one leaf is
9+
* composed with), `resolveOptionPermissions`, `permissionResolution`,
10+
* `resolveFormulaPermissions` and `resolveDefaultPermissions` (an operation's
11+
* one effective-permission resolution and its three consumers) and
12+
* `reportFormulaFault` (a formula fault, said once per field).
13+
*
14+
* A MOVE, not a redesign. Each exported function below is the body of the
15+
* `ObjectQL` method of the same name as it stood, re-indented one level and
16+
* otherwise unedited. Each method's docblock stays on the method in
17+
* `engine.ts`, which is still the contract.
18+
*
19+
* What stays in `engine.ts`: `composeReadableIdsAtBoundary` and
20+
* `refuseUncomposableReadableIdsClasses` (ADR-0139 names them by path),
21+
* `resolveReadableIdsSecurity` (it hands `this` on as a value),
22+
* `resolveFormulaRelated` (its return type, `FormulaRelated`, is shared with
23+
* module helpers that stay, and exporting it reorders the published declaration
24+
* chunk), and the short ones that do not pay for a host
25+
* (`uncomposableReadableIdsParent`, `serveFormulaField`, `formulaFaultSink`).
26+
*
27+
* ## How the moved bodies reach the class
28+
*
29+
* The shape #23050 set for `rest-server.ts`, applied per method as
30+
* `engine-driver-options.ts` applies it. The class keeps all eight methods with
31+
* name, modifiers, signature, docblock and position. Seven bodies become one
32+
* literal of exactly the {@link ReadableIdsPermissionFormulaHost} members that
33+
* method reads, plus a `.call`. The fields a body reads (`_registry`, `logger`,
34+
* `_effectiveObjectPermissionsResolver`, `formulaFaultReported`) go as GETTERS,
35+
* so each read happens when the body makes it, as it did; no moved body assigns
36+
* a field. The methods they call go as late-bound closures over the instance,
37+
* so a double or spy set on the engine is still the one a call reaches, and
38+
* `composeReadableIdsChain`'s recursion re-enters through the method.
39+
* `resolveOptionPermissions` reads no member of the class, so its method calls
40+
* the function directly.
41+
*
42+
* ## Module helpers
43+
*
44+
* `READABLE_IDS_MAX_CHAIN_DEPTH` is read by `composeReadableIdsChain` alone, so
45+
* it moved with it. Two helpers are read by these bodies AND by bodies that
46+
* stay: `writeFailureLogMeta` (the write doors' failure logs) and
47+
* `isExpressionDefault` (`applyFieldDefaults`). One home each: they stay
48+
* declared in `engine.ts`, exported in place, and are imported here, never
49+
* copied. The types these bodies share with bodies that stay
50+
* (`PermissionResolution`, `FormulaPlanEntry`, `ReadableIdsSecurity`,
51+
* `ReadableIdsChain`) are exported in place the same way and imported as types.
52+
* Nothing re-exports any of them from the package entry, so the published
53+
* surface does not move.
54+
*
55+
* That import is a run-time module cycle (`engine.ts` imports this module, this
56+
* module imports `engine.ts`). Nothing here runs at module evaluation, only
57+
* declarations, and `engine.ts` imports this module after every module this one
58+
* imports, so the engine's evaluation order is what it was. The published build
59+
* bundles both files into one, where the cycle does not exist.
60+
*/
61+
62+
import { ReadableIds, RLS_DENY_FILTER, isControlledByParentMasterField, markFilterSubtreeProvenance } from '@objectstack/spec/data';
63+
import type { FilterCondition } from '@objectstack/spec/data';
64+
import type { ExecutionContext } from '@objectstack/spec/kernel';
65+
import { toEvalPermissions, type EvalError, type EvalPermissions } from '@objectstack/formula';
66+
import { collectComposableReadableIds, composeReadableIdsLeaves, describeReadableIdsLeaf, readableIdsRefusalError } from './filter-comparand-shape.js';
67+
import type { ComposableReadableIdsSite } from './filter-comparand-shape.js';
68+
import { optionVisibilityReadsPermissions, readsPermissionPredicate } from './validation/rule-validator.js';
69+
import { isExpressionDefault, writeFailureLogMeta } from './engine.js';
70+
import type { FormulaPlanEntry, PermissionResolution, ReadableIdsChain, ReadableIdsSecurity, UnservableControlledByParentMaster } from './engine.js';
71+
import type { ObjectQL } from './engine.js';
72+
73+
/**
74+
* The `ObjectQL` members the functions below read — every one of them, and no
75+
* other; each function's `this` is the `Pick` of the members IT reads. Each is
76+
* typed AS the class member (an indexed access on the class), so a change to a
77+
* member's type on the class is a change here too, and naming a member the
78+
* class does not have is a compile error. See the module header for which
79+
* members are getters, and which one also has a setter.
80+
*/
81+
export interface ReadableIdsPermissionFormulaHost {
82+
readonly _registry: ObjectQL['_registry'];
83+
readonly logger: ObjectQL['logger'];
84+
readonly _effectiveObjectPermissionsResolver: ObjectQL['_effectiveObjectPermissionsResolver'];
85+
readonly formulaFaultReported: ObjectQL['formulaFaultReported'];
86+
refuseUncomposableReadableIdsClasses: ObjectQL['refuseUncomposableReadableIdsClasses'];
87+
readableIdsScopeOf: ObjectQL['readableIdsScopeOf'];
88+
composeReadableIdsChain: ObjectQL['composeReadableIdsChain'];
89+
uncomposableReadableIdsParent: ObjectQL['uncomposableReadableIdsParent'];
90+
buildEvalUser: ObjectQL['buildEvalUser'];
91+
defaultFieldEntries: ObjectQL['defaultFieldEntries'];
92+
}
93+
94+
/**
95+
* [ADR-0139 D6, as amended — the nested master chain] How deep a chain of
96+
* composed scopes the engine composes: the scope answered for a leaf in the
97+
* read's own `where` is one scope deep, the scope answered for a leaf inside
98+
* it is two, and so on down a master-detail chain whose every master is
99+
* itself `controlled_by_parent`. A chain deeper than this is refused by name.
100+
*
101+
* A COST ceiling, not a semantic rule, and the same cut as the
102+
* `controlled_by_parent` derivation's (`CBP_MAX_CHAIN_DEPTH`,
103+
* `packages/plugins/plugin-security`): a detail with seven derived masters
104+
* above it composes, an eighth derived master is refused there and here.
105+
* Every authored chain in this repository is one hop, the deepest any consumer
106+
* has asked for is two, and 8 is four times that. Termination needs no bound —
107+
* the chain refuses a parent already on its branch — so what this caps is the
108+
* statement the driver compiles, one nested subquery per level. At the bound
109+
* the read is REFUSED, loud (D5's envelope), ⛔ never composed partway and
110+
* never answered "no restriction".
111+
*/
112+
const READABLE_IDS_MAX_CHAIN_DEPTH = 8;
113+
114+
/** The body of `ObjectQL#composeReadableIdsChain`; the method's docblock there is its contract. */
115+
export async function composeReadableIdsChain<N>(
116+
this: Pick<ReadableIdsPermissionFormulaHost, 'refuseUncomposableReadableIdsClasses' | 'readableIdsScopeOf' | 'composeReadableIdsChain'>,
117+
chain: ReadableIdsChain,
118+
node: N,
119+
sites: readonly ComposableReadableIdsSite[],
120+
ancestors: readonly string[],
121+
): Promise<N> {
122+
const { object, operation } = chain;
123+
const parents = this.refuseUncomposableReadableIdsClasses(object, operation, sites);
124+
for (const parent of parents) {
125+
if (chain.answers.has(parent)) continue;
126+
const site = sites.find((s) => s.leaf.object === parent)!;
127+
const branch = [object, ...ancestors, parent];
128+
if (ancestors.includes(parent)) {
129+
throw readableIdsRefusalError(
130+
object,
131+
`${operation}('${object}'): ${describeReadableIdsLeaf(site.at, site.leaf)}, inside the read scope of ` +
132+
`'${ancestors[ancestors.length - 1]}', and the engine composes no such leaf: the chain of read scopes ` +
133+
`${branch.join(' -> ')} returns to '${parent}', a cycle. The query was NOT run and no driver was asked. ` +
134+
`A parent's read scope cannot be derived from its own readable ids; fix the read scope the security ` +
135+
`service answers for '${ancestors[ancestors.length - 1]}'.`,
136+
);
137+
}
138+
if (ancestors.length >= READABLE_IDS_MAX_CHAIN_DEPTH) {
139+
throw readableIdsRefusalError(
140+
object,
141+
`${operation}('${object}'): ${describeReadableIdsLeaf(site.at, site.leaf)}, and the engine composes no ` +
142+
`such leaf: the chain of read scopes ${branch.join(' -> ')} is ${ancestors.length + 1} scopes deep, and the ` +
143+
`engine composes a chain at most ${READABLE_IDS_MAX_CHAIN_DEPTH} scopes deep. The query was NOT run and ` +
144+
`no driver was asked. A master-detail chain this deep is not one the engine composes; read '${parent}' as ` +
145+
'the caller first and match its ids with a list, or shorten the chain.',
146+
);
147+
}
148+
const answer = chain.security.kind === 'usable'
149+
? await this.readableIdsScopeOf(object, operation, parent, chain.security.svc, chain.context)
150+
: null;
151+
if (answer === null || answer.scope === undefined) {
152+
chain.answers.set(parent, answer);
153+
continue;
154+
}
155+
// The scope is the `where` of a read of `parent`: judged as one, and
156+
// the leaves it carries are composed before this parent's leaf is.
157+
const nested = collectComposableReadableIds(object, operation, answer.scope, 'where', `${site.at}.scope`);
158+
chain.answers.set(parent, nested.length === 0
159+
? answer
160+
: { scope: await this.composeReadableIdsChain(chain, answer.scope, nested, [...ancestors, parent]) });
161+
}
162+
// A parent with no answer (`null`, or never asked: not registered) is
163+
// `$in: []`.
164+
return composeReadableIdsLeaves(node, (leaf) => {
165+
const answer = chain.answers.get(leaf.object);
166+
return answer ? ReadableIds.compose(leaf, answer.scope) : [];
167+
});
168+
}
169+
170+
/** The body of `ObjectQL#findUnservableControlledByParentMasters`; the method's docblock there is its contract. */
171+
export function findUnservableControlledByParentMasters(
172+
this: Pick<ReadableIdsPermissionFormulaHost, '_registry' | 'uncomposableReadableIdsParent'>,
173+
): UnservableControlledByParentMaster[] {
174+
const found: UnservableControlledByParentMaster[] = [];
175+
for (const object of this._registry.getAllObjects() as Array<{ name?: string; sharingModel?: unknown; fields?: Record<string, unknown> }>) {
176+
if (object?.sharingModel !== 'controlled_by_parent' || typeof object.name !== 'string') continue;
177+
const masters = Object.entries(object.fields ?? {}).filter(([, f]) => isControlledByParentMasterField(f));
178+
if (masters.length !== 1) continue;
179+
const [field, def] = masters[0];
180+
const master = (def as { reference?: unknown }).reference;
181+
if (typeof master !== 'string' || !this._registry.getObject(master)) continue;
182+
const uncomposable = this.uncomposableReadableIdsParent(object.name, master);
183+
if (uncomposable) found.push({ detail: object.name, field, master, ...uncomposable });
184+
}
185+
return found;
186+
}
187+
188+
/** The body of `ObjectQL#readableIdsScopeOf`; the method's docblock there is its contract. */
189+
export async function readableIdsScopeOf(
190+
this: Pick<ReadableIdsPermissionFormulaHost, 'logger'>,
191+
object: string,
192+
operation: string,
193+
parent: string,
194+
svc: ReadableIdsSecurity,
195+
context: ExecutionContext | undefined,
196+
): Promise<{ scope: FilterCondition | undefined } | null> {
197+
const deny = (why: string, e?: unknown): null => {
198+
this.logger.warn(
199+
`[ObjectQL] ${operation}('${object}'): the ReadableIds leaf over '${parent}' is answered $in: [] ` +
200+
`(fail-closed) — ${why}`,
201+
e === undefined ? undefined : writeFailureLogMeta(e, { object, parent }),
202+
);
203+
return null;
204+
};
205+
let admitted: boolean;
206+
try {
207+
admitted = typeof svc.canReadObject === 'function'
208+
? (await svc.canReadObject(parent, context)) === true
209+
: (await svc.explain!({ object: parent, operation: 'read' }, context))?.allowed === true;
210+
} catch (e) {
211+
return deny('the object-level read admission threw', e);
212+
}
213+
if (!admitted) return null;
214+
let scope: unknown;
215+
try {
216+
scope = await svc.getReadFilter(parent, context);
217+
} catch (e) {
218+
return deny('getReadFilter() threw', e);
219+
}
220+
if (scope === undefined) return { scope: undefined };
221+
if (scope === RLS_DENY_FILTER) return null;
222+
if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) {
223+
return deny(`getReadFilter() answered ${scope === null ? 'null' : Array.isArray(scope) ? 'an array' : typeof scope}, not a filter`);
224+
}
225+
return { scope: markFilterSubtreeProvenance(scope as FilterCondition, 'policy') };
226+
}
227+
228+
/** The body of `ObjectQL#resolveOptionPermissions`; the method's docblock there is its contract. */
229+
export async function resolveOptionPermissions(
230+
schema: unknown,
231+
payloads: ReadonlyArray<Record<string, unknown> | null | undefined>,
232+
resolution: PermissionResolution | undefined,
233+
): Promise<(payload: Record<string, unknown> | null | undefined) => EvalPermissions | undefined> {
234+
const none = (): undefined => undefined;
235+
// [#20082] The write's ONE resolution ({@link permissionResolution}) —
236+
// `undefined` on exactly the two conditions this method used to test
237+
// itself: no resolver registered, or no acting user.
238+
if (!resolution) return none;
239+
const fields = (schema as { fields?: Parameters<typeof optionVisibilityReadsPermissions>[0] } | null | undefined)?.fields;
240+
const needs = (payload: Record<string, unknown> | null | undefined): boolean =>
241+
optionVisibilityReadsPermissions(fields, payload);
242+
if (!payloads.some(needs)) return none;
243+
let permissions: EvalPermissions;
244+
try {
245+
permissions = await resolution.get();
246+
} catch (err) {
247+
return (payload) => {
248+
if (needs(payload)) throw err;
249+
return undefined;
250+
};
251+
}
252+
return (payload) => (needs(payload) ? permissions : undefined);
253+
}
254+
255+
/** The body of `ObjectQL#permissionResolution`; the method's docblock there is its contract. */
256+
export function permissionResolution(
257+
this: Pick<ReadableIdsPermissionFormulaHost, '_effectiveObjectPermissionsResolver' | 'buildEvalUser'>,
258+
context: ExecutionContext | undefined,
259+
): PermissionResolution | undefined {
260+
const resolver = this._effectiveObjectPermissionsResolver;
261+
if (!resolver || !this.buildEvalUser(context)) return undefined;
262+
let pending: Promise<EvalPermissions> | undefined;
263+
return {
264+
// `Promise.resolve().then(...)` so a resolver that throws SYNCHRONOUSLY is
265+
// memoised as a rejection too, rather than escaping `get()` un-memoised
266+
// and being asked again by the next consumer.
267+
get: () => (pending ??= Promise.resolve()
268+
.then(() => resolver(context))
269+
.then((answer) => toEvalPermissions(answer))),
270+
};
271+
}
272+
273+
/** The body of `ObjectQL#resolveFormulaPermissions`; the method's docblock there is its contract. */
274+
export async function resolveFormulaPermissions(
275+
this: Pick<ReadableIdsPermissionFormulaHost, 'logger'>,
276+
object: string,
277+
plan: readonly FormulaPlanEntry[],
278+
records: readonly unknown[],
279+
context: ExecutionContext | undefined,
280+
resolution: PermissionResolution | undefined,
281+
): Promise<EvalPermissions | undefined> {
282+
if (!context?.userId) return undefined;
283+
if (!records.some((r) => r != null && typeof r === 'object')) return undefined;
284+
const fields = plan.filter((entry) => readsPermissionPredicate(entry.expression)).map((entry) => entry.name);
285+
if (fields.length === 0) return undefined;
286+
if (!resolution) {
287+
this.logger.warn(
288+
`formula field(s) ${fields.join(', ')} on '${object}' call current_user.can(), and no `
289+
+ 'effective-permission source is registered, so they read null: compose a security '
290+
+ 'service that registers one (registerEffectiveObjectPermissionsResolver) for can() to '
291+
+ 'be answered',
292+
{ object, fields, reason: 'no-permission-source' },
293+
);
294+
return undefined;
295+
}
296+
try {
297+
return await resolution.get();
298+
} catch (error) {
299+
this.logger.warn(
300+
`formula field(s) ${fields.join(', ')} on '${object}' call current_user.can(), and the `
301+
+ 'effective-permission resolution failed, so they read null on this read (failed closed: '
302+
+ 'neither a grant nor a denial was assumed)',
303+
{ object, fields, reason: 'permission-resolution-failed', error },
304+
);
305+
return undefined;
306+
}
307+
}
308+
309+
/** The body of `ObjectQL#reportFormulaFault`; the method's docblock there is its contract. */
310+
export function reportFormulaFault(
311+
this: Pick<ReadableIdsPermissionFormulaHost, 'formulaFaultReported' | 'logger'>,
312+
object: string,
313+
field: string,
314+
error: EvalError,
315+
): void {
316+
const key = JSON.stringify([object, field]);
317+
if (this.formulaFaultReported.has(key)) return;
318+
this.formulaFaultReported.add(key);
319+
// The evaluator's message carries the source and a caret on the lines after
320+
// its first; the first line goes in the sentence, the whole of it in the meta.
321+
const headline = error.message.split('\n', 1)[0];
322+
this.logger.warn(
323+
`formula field '${field}' on '${object}' could not be evaluated, so it reads null wherever this `
324+
+ `fault holds (${error.kind}: ${headline}). If the expression itself is at fault — an unknown `
325+
+ 'function, a missing field — `os validate`, or a re-save of the object, refuses it with a located '
326+
+ 'message; if it faults on some records\' values, guard the operands it reads. Reported once per '
327+
+ 'object and field per engine instance.',
328+
{ object, field, kind: error.kind, error: error.message },
329+
);
330+
}
331+
332+
/** The body of `ObjectQL#resolveDefaultPermissions`; the method's docblock there is its contract. */
333+
export async function resolveDefaultPermissions(
334+
this: Pick<ReadableIdsPermissionFormulaHost, 'defaultFieldEntries'>,
335+
object: string,
336+
rows: ReadonlyArray<unknown>,
337+
context: ExecutionContext | undefined,
338+
resolution: PermissionResolution | undefined,
339+
onlyFields?: readonly string[],
340+
): Promise<(row: unknown) => EvalPermissions | undefined> {
341+
const none = (): undefined => undefined;
342+
if (!resolution || !context?.userId) return none;
343+
const gated = (this.defaultFieldEntries(object) ?? [])
344+
.filter((f) => !onlyFields || onlyFields.includes(f.name))
345+
.filter((f) => isExpressionDefault(f.defaultValue) && readsPermissionPredicate(f.defaultValue))
346+
.map((f) => f.name);
347+
if (gated.length === 0) return none;
348+
const needs = (row: unknown): boolean =>
349+
row != null && typeof row === 'object'
350+
&& gated.some((name) => (row as Record<string, unknown>)[name] == null);
351+
if (!rows.some(needs)) return none;
352+
let permissions: EvalPermissions;
353+
try {
354+
permissions = await resolution.get();
355+
} catch (err) {
356+
return (row) => {
357+
if (needs(row)) throw err;
358+
return undefined;
359+
};
360+
}
361+
return (row) => (needs(row) ? permissions : undefined);
362+
}

0 commit comments

Comments
 (0)