|
| 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