diff --git a/.changeset/20082-formula-default-can.md b/.changeset/20082-formula-default-can.md new file mode 100644 index 00000000000..43353b20300 --- /dev/null +++ b/.changeset/20082-formula-default-can.md @@ -0,0 +1,34 @@ +--- +'@objectstack/objectql': minor +--- + +fix(objectql): a `formula` field and a CEL `defaultValue` answer `current_user.can(object, verb)` from the security service (#20082) + +Clause-②: no (narrowing) + + + +**BREAKING**, for one fault path only, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA, so this banner and the ADR-0087 disposition above carry the breaking-ness). An insert row whose CEL `defaultValue` calls `current_user.can(…)` is now refused with the resolution's own error when the registered security service fails to resolve the caller's effective object permissions. Before, that default was left unset with a `warn` and the row was written. + +`@objectstack/formula` answers `can` from `EvalContext.permissions`, and neither engine site passed one. With the security plugin registered: + +- a formula field calling `current_user.can(…)` read `null` on every `find`, `findOne` and write response, and logged nothing; +- a CEL `defaultValue` calling it was left unset with the `warn` "Failed to evaluate default expression". A `required` field defaulted that way therefore refused every insert. + +**What changes.** Both sites now evaluate with the acting subject's effective object permissions. That is the map `ISecurityService.getEffectiveObjectPermissions` returns, which an option's `visibleWhen` already reads. + +- A formula field reads `true` or `false`. +- A CEL default stores `true` or `false`, so a `required` field defaulted by `can` is admitted. + +The engine asks the security service at most once per operation: once per `find` (not per row), and once per write, shared by its defaults, its `can`-gated options and the formula fields on its response. It asks only when a formula, or a default that will be applied, calls `can`, and only when the operation has an acting user. The answer is never kept past the operation. + +**When there is no map.** + +- No security service is registered. No permission data is passed, as before. A formula field still reads `null`, and each operation now logs one `warn` naming the object, the fields and `reason: 'no-permission-source'`. A default is still left unset with its existing `warn`. +- The resolution fails: it throws, or it returns a map that is not the published shape. A formula field reads `null`, and one `warn` carries the error with `reason: 'permission-resolution-failed'`, because a read is not refused over one computed field. An insert row whose `can` default needed the map is refused with the resolution's own error. Under `insertMany` only that row is refused, and the `validate()` preview rejects the same way. Rows that supply the field, and objects whose defaults never call `can`, are unaffected. + +In no case is `can()` answered `true` without a grant, or `false` from an empty map. + +`evaluateFormulaField` (and `resolveRecordTitle`, which uses it) is synchronous and passes no map, so a formula calling `can` still yields `null` there. + +No spec key, export or route is added or removed. diff --git a/packages/objectql/src/engine-formula-default-permission.test.ts b/packages/objectql/src/engine-formula-default-permission.test.ts new file mode 100644 index 00000000000..61461a9ce97 --- /dev/null +++ b/packages/objectql/src/engine-formula-default-permission.test.ts @@ -0,0 +1,462 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20082] `current_user.can(object, verb)` in the engine's two VALUE sites — + * a `formula` field and a CEL `defaultValue` — answered from the registered + * effective-permission resolver, driven through the real engine. + * + * Before this, both sites built their own `current_user` and passed no + * `permissions`, so with a resolver registered a `can` formula read `null` on + * every `find` / `findOne` / write echo with no log line, and a `can` default + * was left unset with a warn (a `required` one refused the write). The table + * pins every site × every case, and the cells follow each site's own rule: + * + * - the map (a granted verb, a denied verb) ⇒ the boolean `can` answers; + * - NO resolver ⇒ NO permission data (the contract member's rule for an absent + * method): a formula reads `null` WITH a warn naming the fields; a default + * is left unset with the site's existing warn, which carries formula's own + * refusal — ⛔ never an implicit grant, never a silent answer; + * - the resolver THROWS ⇒ fail CLOSED: a read cannot refuse a row for one + * computed field, so the formula reads `null` with a warn carrying the + * error; a write refuses a row whose `can` default needed the map, with the + * resolution's own error, untouched. + * + * And the resolution counts: at most ONE ask per operation — per `find` (not + * per row), per write across its defaults, option gates and response formulas + * — none at all for an operation that cannot need it, and never kept across + * operations. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { ObjectQL } from './engine.js'; +import { ValidationError } from './validation/record-validator.js'; + +import '@objectstack/spec'; +import '@objectstack/formula'; + +const cel = (source: string) => ({ dialect: 'cel' as const, source }); + +/** + * An in-memory driver that hands back shallow COPIES, never live rows: the + * formula pass writes onto the rows a driver returns, so a leaking double would + * store the computed values and a later read would pass with the evaluation + * gone. + */ +function makeDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const matches = (row: Record, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where as Record).every(([k, v]) => { + if (k === '$and') return (v as unknown[]).every((w) => matches(row, w)); + if (k === '$or') return (v as unknown[]).some((w) => matches(row, w)); + // Any other combinator is REFUSED, never read as a field name + // (`check:where-matcher`). + if (k.startsWith('$')) throw new Error(`test driver: unsupported combinator ${k}`); + const cond = v as Record | null; + if (cond && typeof cond === 'object' && !Array.isArray(cond)) { + if ('$in' in cond) return Array.isArray(cond.$in) && cond.$in.includes(row[k]); + if ('$eq' in cond) return row[k] === cond.$eq; + throw new Error(`test driver: unsupported condition on ${k}`); + } + return row[k] === cond; + }); + }; + const writes: Array<{ op: string; object: string; data: Record }> = []; + let n = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // Hold the caller's bound (`check:objectql-double-limit`). + const bounded = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + return bounded.map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Record) { + writes.push({ op: 'create', object, data: { ...data } }); + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return { ...row }; + }, + async update(object: string, id: string, data: Record) { + writes.push({ op: 'update', object, data: { ...data } }); + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return { ...row }; + }, + async updateMany() { return 0; }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor, writes }; +} + +/** A logger whose `warn` lines the cases read back; everything else is swallowed. */ +function captureLogger() { + const warns: Array<{ msg: string; meta: any }> = []; + const noop = () => {}; + const logger: any = { + debug: noop, info: noop, error: noop, trace: noop, fatal: noop, + warn: (msg: string, meta?: any) => warns.push({ msg, meta }), + child: () => logger, + }; + return { warns, logger }; +} + +/** A resolver that records every ask; `answer` is a value, or a function that may throw. */ +function countingResolver(answer: unknown | (() => unknown)) { + const asks: unknown[] = []; + const fn = async (ctx: unknown) => { + asks.push(ctx); + return typeof answer === 'function' ? (answer as () => unknown)() : answer; + }; + return { fn, asks }; +} + +const ACTING: ExecutionContext = { userId: 'u1', positions: ['sales_rep'] }; +const SYSTEM: ExecutionContext = { isSystem: true }; +/** The `objects` slot of `/auth/me/permissions`: `edit` granted on crm_account, `delete` not. */ +const MAP = { crm_account: { allowRead: true, allowEdit: true } }; +const BOOM = Object.assign(new Error('permission store unreachable'), { code: 'AUTHZ_STORE_UNAVAILABLE', status: 503 }); + +const CASE_OBJECT = { + name: 'crm_case', + fields: { + subject: { type: 'text' }, + stage: { + type: 'select', + options: [ + { value: 'open' }, + { value: 'escalated', visibleWhen: "current_user.can('crm_account', 'edit')" }, + ], + }, + may_edit_account: { type: 'formula', expression: cel("current_user.can('crm_account', 'edit')") }, + may_delete_account: { type: 'formula', expression: cel("current_user.can('crm_account', 'delete')") }, + // The control formula: evaluates on every cell, map or no map. + label: { type: 'formula', expression: cel("'case:' + record.subject") }, + edit_flag: { type: 'boolean', defaultValue: cel("current_user.can('crm_account', 'edit')") }, + delete_flag: { type: 'boolean', defaultValue: cel("current_user.can('crm_account', 'delete')") }, + }, +}; +/** A `required` field defaulted by `can` — refused by required-validation when the default cannot evaluate. */ +const GATE_OBJECT = { + name: 'crm_gate', + fields: { + subject: { type: 'text' }, + flag: { type: 'boolean', required: true, defaultValue: cel("current_user.can('crm_account', 'edit')") }, + }, +}; +/** The control object: a formula and a CEL default, neither calling `can`. */ +const NOTE_OBJECT = { + name: 'crm_note', + fields: { + body: { type: 'text' }, + shout: { type: 'formula', expression: cel("record.body + '!'") }, + author: { type: 'text', defaultValue: cel('current_user.id') }, + }, +}; + +type Scenario = 'granted' | 'denied' | 'no resolver' | 'resolver throws'; + +/** + * The table: what each case registers, and what each site reads. `formula` and + * `defaultField` name the field the scenario reads — the granted verb's field + * or the denied verb's; the throw/no-resolver cases read the granted verb's, + * so a `null` there can only be the missing map, never a denial. + */ +const TABLE: Array<{ + scenario: Scenario; + resolver?: () => unknown; + formula: 'may_edit_account' | 'may_delete_account'; + defaultField: 'edit_flag' | 'delete_flag'; + reads: boolean | null; + warnReason?: 'no-permission-source' | 'permission-resolution-failed'; + defaulted: boolean | undefined | 'refused'; +}> = [ + { scenario: 'granted', resolver: () => MAP, formula: 'may_edit_account', defaultField: 'edit_flag', reads: true, defaulted: true }, + { scenario: 'denied', resolver: () => MAP, formula: 'may_delete_account', defaultField: 'delete_flag', reads: false, defaulted: false }, + { scenario: 'no resolver', formula: 'may_edit_account', defaultField: 'edit_flag', reads: null, warnReason: 'no-permission-source', defaulted: undefined }, + { scenario: 'resolver throws', resolver: () => { throw BOOM; }, formula: 'may_edit_account', defaultField: 'edit_flag', reads: null, warnReason: 'permission-resolution-failed', defaulted: 'refused' }, +]; + +describe('#20082 — formula fields and CEL defaults answer `can` from the security service', () => { + let engine: ObjectQL; + let d: ReturnType; + let log: ReturnType; + + beforeEach(async () => { + log = captureLogger(); + engine = new ObjectQL({ logger: log.logger }); + d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + for (const def of [CASE_OBJECT, GATE_OBJECT, NOTE_OBJECT]) engine.registry.registerObject(def as any, 'test-package'); + }); + + /** Rows stored directly, as a system seed would leave them — every default field already valued. */ + function seed(count: number): void { + for (let i = 0; i < count; i++) { + d.storeFor('crm_case').set(`c${i}`, { id: `c${i}`, subject: `s${i}`, stage: 'open', edit_flag: false, delete_flag: false }); + } + } + const formulaWarns = () => log.warns.filter((w) => Array.isArray(w.meta?.fields)); + const defaultWarns = (field: string) => log.warns.filter((w) => w.meta?.field === field && w.meta?.error?.kind); + const created = (object: string) => d.writes.filter((w) => w.op === 'create' && w.object === object); + + async function refusal(p: Promise): Promise { + try { + await p; + } catch (err) { + return err; + } + throw new Error('expected the write to be refused'); + } + + for (const row of TABLE) { + describe(`${row.scenario}`, () => { + let asks: unknown[]; + beforeEach(() => { + asks = []; + if (row.resolver) { + const r = countingResolver(row.resolver); + asks = r.asks; + engine.registerEffectiveObjectPermissionsResolver(r.fn); + } + }); + + /** One read's formula cells: the scenario's value, the control evaluated, and one warn at most. */ + function expectFormulaCells(records: Array>, reads: number) { + for (const rec of records) { + expect(rec[row.formula]).toBe(row.reads); + expect(rec.label).toBe(`case:${String(rec.subject)}`); + } + const warns = formulaWarns(); + if (!row.warnReason) { + expect(warns).toHaveLength(0); + return; + } + // ONE line per read operation, never per row — naming every `can` field. + expect(warns).toHaveLength(reads); + for (const w of warns) { + expect(w.meta).toMatchObject({ object: 'crm_case', reason: row.warnReason }); + expect(w.meta.fields).toEqual(['may_edit_account', 'may_delete_account']); + if (row.warnReason === 'permission-resolution-failed') expect(w.meta.error).toBe(BOOM); + } + } + + it('formula field on find — one resolution for the whole result set', async () => { + seed(4); + const rows = await engine.find('crm_case', { context: ACTING }) as Array>; + expect(rows).toHaveLength(4); + expectFormulaCells(rows, 1); + expect(asks).toHaveLength(row.resolver ? 1 : 0); + }); + + it('formula field on findOne', async () => { + seed(2); + const rec = await engine.findOne('crm_case', { where: { id: 'c1' }, context: ACTING }) as Record; + expectFormulaCells([rec], 1); + expect(asks).toHaveLength(row.resolver ? 1 : 0); + }); + + it('formula field on the update echo', async () => { + seed(1); + const echo = await engine.update('crm_case', { subject: 'renamed' }, { where: { id: 'c0' }, context: ACTING }) as Record; + expectFormulaCells([echo], 1); + expect(asks).toHaveLength(row.resolver ? 1 : 0); + }); + + it('CEL defaultValue on insert — and the insert echo\'s formula reuses the same resolution', async () => { + const write = engine.insert('crm_case', { subject: 'n' }, { context: ACTING }); + if (row.defaulted === 'refused') { + // Fail CLOSED with the resolution's own error, untouched: ⛔ not read + // as "no grants" (which would STORE `false`), and nothing written. + const err = await refusal(write); + expect(err).toBe(BOOM); + expect(err.code).toBe('AUTHZ_STORE_UNAVAILABLE'); + expect(err.status).toBe(503); + expect(err).not.toBeInstanceOf(ValidationError); + expect(created('crm_case')).toHaveLength(0); + expect(asks).toHaveLength(1); + return; + } + const echo = await write as Record; + const stored = created('crm_case'); + expect(stored).toHaveLength(1); + if (row.defaulted === undefined) { + // Unset — neither a grant nor a denial — with the site's warn, which + // carries formula's own "no permission data" refusal. + expect(stored[0].data[row.defaultField] ?? null).toBeNull(); + const warns = defaultWarns(row.defaultField); + expect(warns).toHaveLength(1); + expect(warns[0].meta).toMatchObject({ object: 'crm_case', field: row.defaultField, error: { kind: 'runtime' } }); + expect(String(warns[0].meta.error.message)).toContain('carries no permission data'); + } else { + expect(stored[0].data[row.defaultField]).toBe(row.defaulted); + expect(defaultWarns(row.defaultField)).toHaveLength(0); + } + // The echo's formulas: the same answers, and one resolution for the whole write. + expectFormulaCells([echo], 1); + expect(asks).toHaveLength(row.resolver ? 1 : 0); + }); + + it('a `required` field defaulted by `can`', async () => { + const write = engine.insert('crm_gate', { subject: 'g' }, { context: ACTING }); + if (row.defaulted === 'refused') { + expect(await refusal(write)).toBe(BOOM); + expect(created('crm_gate')).toHaveLength(0); + } else if (row.defaulted === undefined) { + // Unchanged from before the seam: unset, so required-validation refuses. + const err = await refusal(write); + expect(err).toBeInstanceOf(ValidationError); + expect(err.code).toBe('VALIDATION_FAILED'); + expect(err.fields).toEqual([expect.objectContaining({ field: 'flag', code: 'required' })]); + expect(created('crm_gate')).toHaveLength(0); + } else { + // The map is in hand, so the default evaluates to a real value and the + // required field is satisfied — the row was refused here before. (This + // object's default asks the GRANTED verb; the denied verb's cell is the + // next test.) + await expect(write).resolves.toBeTruthy(); + expect(created('crm_gate')[0].data.flag).toBe(true); + } + }); + }); + } + + it('a `required` field defaulted by the DENIED verb stores `false` — a real answer, so it is admitted', async () => { + engine.registry.registerObject({ + name: 'crm_gate_denied', + fields: { flag: { type: 'boolean', required: true, defaultValue: cel("current_user.can('crm_account', 'delete')") } }, + } as any, 'test-package'); + engine.registerEffectiveObjectPermissionsResolver(countingResolver(MAP).fn); + await engine.insert('crm_gate_denied', {}, { context: ACTING }); + expect(created('crm_gate_denied')[0].data.flag).toBe(false); + }); + + it('ONE resolution for a write that needs the map three ways: default, option gate and response formula', async () => { + const r = countingResolver(MAP); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + const echo = await engine.insert('crm_case', { subject: 'x', stage: 'escalated' }, { context: ACTING }) as Record; + expect(created('crm_case')[0].data).toMatchObject({ stage: 'escalated', edit_flag: true, delete_flag: false }); + expect(echo.may_edit_account).toBe(true); + expect(r.asks).toHaveLength(1); + }); + + it('ONE resolution for a batch insert of N rows, and for a by-id update picking a gated option', async () => { + const r = countingResolver(MAP); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + await engine.insert('crm_case', Array.from({ length: 6 }, (_, i) => ({ subject: `b${i}` })) as any, { context: ACTING }); + expect(created('crm_case')).toHaveLength(6); + expect(created('crm_case').every((w) => w.data.edit_flag === true && w.data.delete_flag === false)).toBe(true); + expect(r.asks).toHaveLength(1); + + await engine.update('crm_case', { stage: 'escalated' }, { where: { id: 'r_1' }, context: ACTING }); + expect(r.asks).toHaveLength(2); + }); + + it('validate() previews the defaults and the option gate with ONE resolution', async () => { + const r = countingResolver(MAP); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + const preview = await engine.validate('crm_gate', [{ subject: 'a' }, { subject: 'b' }], { mode: 'insert', context: ACTING }); + expect(preview.valid).toBe(true); + expect(r.asks).toHaveLength(1); + }); + + it('validate() rejects the same way the write does when a row\'s `can` default needed a failed resolution', async () => { + engine.registerEffectiveObjectPermissionsResolver(countingResolver(() => { throw BOOM; }).fn); + await expect( + engine.validate('crm_gate', [{ subject: 'a' }], { mode: 'insert', context: ACTING }), + ).rejects.toBe(BOOM); + // A row that supplies the field previews without asking. + const supplied = await engine.validate('crm_gate', [{ subject: 'a', flag: false }], { mode: 'insert', context: ACTING }); + expect(supplied.valid).toBe(true); + }); + + it('never cached across operations: two finds ask twice', async () => { + seed(3); + const r = countingResolver(MAP); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + await engine.find('crm_case', { context: ACTING }); + await engine.find('crm_case', { context: ACTING }); + expect(r.asks).toHaveLength(2); + }); + + it('control: no `can` anywhere ⇒ never asks, even a resolver that would THROW, and no warn', async () => { + const r = countingResolver(() => { throw BOOM; }); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + const echo = await engine.insert('crm_note', { body: 'hi' }, { context: ACTING }) as Record; + expect(echo.shout).toBe('hi!'); + expect(created('crm_note')[0].data.author).toBe('u1'); + const rows = await engine.find('crm_note', { context: ACTING }) as Array>; + expect(rows[0].shout).toBe('hi!'); + expect(r.asks).toHaveLength(0); + expect(formulaWarns()).toHaveLength(0); + }); + + it('a row that SUPPLIES the `can`-defaulted fields never asks, so a failing resolver cannot refuse it', async () => { + const r = countingResolver(() => { throw BOOM; }); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + // The formula leg of the echo still reads null (with its warn): only the + // default path was spared, because only the default path did not need it. + await expect( + engine.insert('crm_case', { subject: 's', edit_flag: true, delete_flag: false }, { context: ACTING }), + ).resolves.toBeTruthy(); + expect(created('crm_case')[0].data).toMatchObject({ edit_flag: true, delete_flag: false }); + expect(r.asks).toHaveLength(1); // the echo's formulas — the default path asked nothing + }); + + it('a system read (no acting user) never asks — `can` would be about nobody', async () => { + seed(2); + const r = countingResolver(MAP); + engine.registerEffectiveObjectPermissionsResolver(r.fn); + await engine.find('crm_case', { context: SYSTEM }); + await engine.find('crm_case', {}); + expect(r.asks).toHaveLength(0); + expect(formulaWarns()).toHaveLength(0); + }); + + it('partial mode (insertMany): a failed resolution refuses only the rows whose `can` default needed it', async () => { + engine.registerEffectiveObjectPermissionsResolver(countingResolver(() => { throw BOOM; }).fn); + const out = await engine.insertMany('crm_case', [ + { subject: 'needs' }, + { subject: 'supplies', edit_flag: false, delete_flag: true }, + ], { context: ACTING }); + expect(out[0].ok).toBe(false); + expect((out[0] as { error: unknown }).error).toBe(BOOM); + expect(out[1].ok).toBe(true); + expect(created('crm_case').map((w) => w.data.subject)).toEqual(['supplies']); + }); + + it('a map that is not the published shape fails closed the same way (formula\'s door)', async () => { + engine.registerEffectiveObjectPermissionsResolver(countingResolver({ crm_account: true }).fn); + const err = await refusal(engine.insert('crm_case', { subject: 'n' }, { context: ACTING })); + expect(err).toBeInstanceOf(TypeError); + expect(String(err.message)).toContain("the entry for 'crm_account' is not an EffectiveObjectPermission"); + expect(created('crm_case')).toHaveLength(0); + + seed(1); + const rows = await engine.find('crm_case', { context: ACTING }) as Array>; + expect(rows[0].may_edit_account).toBeNull(); + expect(formulaWarns().at(-1)?.meta).toMatchObject({ reason: 'permission-resolution-failed' }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a9ec1306938..15ca43c2eb4 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -214,7 +214,7 @@ import { bindHooksToEngine } from './hook-binder.js'; import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; -import { collectPredicateRelationships, evaluateValidationRules, optionVisibilityReadsPermissions, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; +import { collectPredicateRelationships, evaluateValidationRules, optionVisibilityReadsPermissions, readsPermissionPredicate, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; // [#14088] The before-phase write recorder — the provenance channel the static // `readonly` strip needs to tell a hook's write from a caller's echo of the // SAME value. Armed and sealed in `update()`; the module owns the argument for @@ -981,6 +981,29 @@ function lowerWhereFilterArray( return lowered as T; } +/** + * [#20082] One operation's effective-permission resolution — see + * `ObjectQL.permissionResolution`. `get()` asks the registered resolver at most + * once for the operation and hands every caller the same answer, or the same + * rejection. + */ +interface PermissionResolution { + get(): Promise; +} + +/** + * Is this `defaultValue` an Expression envelope (`{ dialect, source }`) — + * evaluated at insert time — rather than a literal or a runtime token? The one + * test {@link ObjectQL.applyFieldDefaults} evaluates by and + * {@link ObjectQL.resolveDefaultPermissions} plans by, so the two can never + * disagree about which defaults are expressions. + */ +function isExpressionDefault(dv: unknown): dv is Expression { + return typeof dv === 'object' && dv !== null + && Boolean((dv as { dialect?: unknown }).dialect) + && typeof (dv as { source?: unknown }).source === 'string'; +} + interface FormulaPlanEntry { name: string; expression: Expression; @@ -1497,6 +1520,15 @@ function undeclaredWriteFieldErrors( * `{ record }`, so `now()`/`today()` ran against live wall-clock and user/org * were unreachable. * + * [#20082] `permissions` is the acting subject's effective object-permission + * map, the one `current_user.can(object, verb)` answers from. The CALLER + * resolves it — at most once per operation, and only when a planned formula + * calls `can` ({@link ObjectQL.resolveFormulaPermissions}) — because this + * function is synchronous and owns no resolver. `undefined` is "no permission + * data", never an empty map: a `can`-calling formula then fails to evaluate and + * reads `null`, the rule every formula that does not evaluate follows here, and + * the caller has already said why in its log line. + * * That context has the same SHAPE as `applyFieldDefaults`' — the same keys, so * one expression vocabulary serves `formula` and `defaultValue` alike — but NOT * the same `now` value, and the two are sourced independently on purpose @@ -1582,6 +1614,7 @@ function applyFormulaPlan( plan: FormulaPlanEntry[], records: any[], execCtx?: ExecutionContext, + permissions?: EvalPermissions, ): void { if (!plan.length) return; const now = new Date(); @@ -1591,7 +1624,7 @@ function applyFormulaPlan( for (const rec of records) { if (rec == null) continue; for (const fp of plan) { - const r = ExpressionEngine.evaluate(fp.expression, { now, timezone, user, org, record: rec }); + const r = ExpressionEngine.evaluate(fp.expression, { now, timezone, user, org, permissions, record: rec }); rec[fp.name] = r.ok ? roundFormulaValue(r.value, fp.scale) : null; } } @@ -1623,6 +1656,10 @@ function applyFormulaPlan( * - the execution context is threaded exactly as find threads it, so `os.user` * / `os.org` resolve identically on both sides. Widening what that context * carries is #1979's work and stays out of here. + * - [#20082] the permission map comes from the same place find's does: + * `permissionsFor` is the engine's {@link ObjectQL.resolveFormulaPermissions} + * bound to the WRITE's own resolution, so a write that already resolved the + * map for a default or an option gate reuses it rather than asking again. * * Evaluates against the record the driver returned (a full row: `create` uses * `RETURNING *`, `update` re-reads), so no extra round-trip is needed and no @@ -1634,18 +1671,19 @@ function applyFormulaPlan( * otherwise take a property assignment, which throws under ES module strict * mode. */ -function hydrateWriteFormulas( +async function hydrateWriteFormulas( schema: any, results: unknown[], - execCtx?: ExecutionContext, -): void { + execCtx: ExecutionContext | undefined, + permissionsFor: (plan: FormulaPlanEntry[], records: readonly unknown[]) => Promise, +): Promise { const records = results.filter( (r): r is Record => r != null && typeof r === 'object', ); if (records.length === 0) return; const { plan } = planFormulaProjection(schema, undefined); if (plan.length === 0) return; - applyFormulaPlan(plan, records, execCtx); + applyFormulaPlan(plan, records, execCtx, await permissionsFor(plan, records)); } /** @@ -1684,6 +1722,12 @@ function hydrateWriteFormulas( * `r.ok ? … : null`. A caller therefore cannot mistake "this title could not be * computed" for a computed value. * + * ⚠️ One scope difference, named rather than hidden [#20082]: this helper is + * synchronous and passes NO permission map, so a formula that calls + * `current_user.can(…)` does not evaluate here and yields `null` by the rule + * above. The read and write paths resolve the map first + * ({@link ObjectQL.resolveFormulaPermissions}); a helper with no resolver cannot. + * * Returns `undefined` when `field` is not a declared formula field, which is * how a caller tells "read the stored column instead" from "the formula * produced nothing". Evaluates against a shallow COPY: `applyFormulaPlan` @@ -4159,23 +4203,26 @@ export class ObjectQL implements IObjectQLEngine { /** * [#18783] Where the acting subject's EFFECTIVE object permissions come from — - * the map `current_user.can(object, verb)` in a per-option `visibleWhen` is - * answered from. Registered by the security plugin (the one producer, + * the map `current_user.can(object, verb)` is answered from: in a per-option + * `visibleWhen`, and since #20082 in a `formula` field and a CEL + * `defaultValue`. Registered by the security plugin (the one producer, * `ISecurityService.getEffectiveObjectPermissions`), the same way it * registers {@link registerWriteGateProbe}; last registration wins. * * Unregistered is a DEFINED state, not a fault: the engine then passes NO * permission data — ⛔ never an empty map and never one it merged itself — so - * a `can` predicate stays loudly unevaluable, exactly as the contract member - * prescribes for an absent method. See {@link resolveOptionPermissions} for - * when it is asked and what a throw does. + * a `can` expression stays loudly unevaluable, exactly as the contract member + * prescribes for an absent method. See {@link permissionResolution} for how + * one operation asks at most once, and each consumer + * ({@link resolveOptionPermissions}, {@link resolveDefaultPermissions}, + * {@link resolveFormulaPermissions}) for when it asks and what a throw does. */ private _effectiveObjectPermissionsResolver?: (context: unknown) => Promise; /** Wire the effective object-permission source (#18783). Last registration wins. */ registerEffectiveObjectPermissionsResolver(fn: (context: unknown) => Promise): void { this._effectiveObjectPermissionsResolver = fn; - this.logger.debug('Registered effective object-permission resolver for option visibleWhen can()'); + this.logger.debug('Registered effective object-permission resolver for current_user.can()'); } /** @@ -4210,18 +4257,20 @@ export class ObjectQL implements IObjectQLEngine { private async resolveOptionPermissions( schema: unknown, payloads: ReadonlyArray | null | undefined>, - context: ExecutionContext | undefined, + resolution: PermissionResolution | undefined, ): Promise<(payload: Record | null | undefined) => EvalPermissions | undefined> { const none = (): undefined => undefined; - const resolver = this._effectiveObjectPermissionsResolver; - if (!resolver || !this.buildEvalUser(context)) return none; + // [#20082] The write's ONE resolution ({@link permissionResolution}) — + // `undefined` on exactly the two conditions this method used to test + // itself: no resolver registered, or no acting user. + if (!resolution) return none; const fields = (schema as { fields?: Parameters[0] } | null | undefined)?.fields; const needs = (payload: Record | null | undefined): boolean => optionVisibilityReadsPermissions(fields, payload); if (!payloads.some(needs)) return none; let permissions: EvalPermissions; try { - permissions = toEvalPermissions(await resolver(context)); + permissions = await resolution.get(); } catch (err) { return (payload) => { if (needs(payload)) throw err; @@ -4231,6 +4280,158 @@ export class ObjectQL implements IObjectQLEngine { return (payload) => (needs(payload) ? permissions : undefined); } + /** + * [#20082] ONE operation's effective-permission resolution — the single + * resolver ask every consumer inside that operation shares. On a write those + * are its CEL `defaultValue`s ({@link resolveDefaultPermissions}), its option + * gates ({@link resolveOptionPermissions}) and the formula fields on its + * response ({@link resolveFormulaPermissions}); on a read, its formula fields. + * The contract member's "resolve it ONCE per request" is kept per engine + * operation: a write that carries a `can` default, a `can` option gate and a + * `can` formula asks the resolver once, not three times. + * + * `undefined` when there is nothing to ask — no resolver registered, or no + * acting user (`current_user` is then unbound, and `can` asks about nobody). + * Every consumer then passes NO permission data: ⛔ never an empty map, which + * is a real answer ("holds nothing") and would deny confidently. + * + * Lazy and memoised for the operation: the ask happens when the first consumer + * NEEDS the map, never earlier, and a later consumer awaits the same promise — + * its rejection included, so one failed resolution is the same failure + * everywhere in the operation rather than a second, possibly different, ask. + * The answer goes through `toEvalPermissions`, formula's one door into + * `EvalContext.permissions`, so an off-shape map fails here like a throw. + * ⛔ Never kept past the operation: each operation mints its own, because a map + * held across operations would serve a grant that may since have been revoked. + */ + private permissionResolution(context: ExecutionContext | undefined): PermissionResolution | undefined { + const resolver = this._effectiveObjectPermissionsResolver; + if (!resolver || !this.buildEvalUser(context)) return undefined; + let pending: Promise | undefined; + return { + // `Promise.resolve().then(...)` so a resolver that throws SYNCHRONOUSLY is + // memoised as a rejection too, rather than escaping `get()` un-memoised + // and being asked again by the next consumer. + get: () => (pending ??= Promise.resolve() + .then(() => resolver(context)) + .then((answer) => toEvalPermissions(answer))), + }; + } + + /** + * [#20082] The permission map a batch of `formula` fields is evaluated with — + * a read's (`find`, `findOne`) or a write response's + * ({@link hydrateWriteFormulas}). + * + * Asked only when it can change an answer: the context binds an acting user + * (the way {@link applyFormulaPlan} binds `current_user` — a truthy `userId`), + * some planned formula CALLS `can` ({@link readsPermissionPredicate}, the same + * reading the option gate uses), and there is a record to evaluate. Every + * other read pays nothing, and a whole `find` asks at most once, never per row. + * + * A read cannot refuse a row because one computed field lacks data, so when + * the map cannot be had the formula keeps the rule every formula that does not + * evaluate follows on this path — it reads `null` — and this method says so, + * once per operation, at `warn`, naming the object, the fields and the reason: + * + * - `no-permission-source`: no resolver is registered. NO map is passed (the + * contract member's rule for an absent method), so `can()` keeps its own + * loud refusal and the field reads `null`. + * - `permission-resolution-failed`: the resolution threw, or answered a map + * that is not the published shape. Fail CLOSED: ⛔ never read as "no + * grants" (an empty map would publish a confident `false`) and ⛔ never a + * grant — the field reads `null`, with the resolution's error in the line. + * + * `warn`, not `error`: a formula is computed on read and nothing is persisted + * from it, so the degradation is visible to whoever reads the field. + */ + private async resolveFormulaPermissions( + object: string, + plan: readonly FormulaPlanEntry[], + records: readonly unknown[], + context: ExecutionContext | undefined, + resolution: PermissionResolution | undefined, + ): Promise { + if (!context?.userId) return undefined; + if (!records.some((r) => r != null && typeof r === 'object')) return undefined; + const fields = plan.filter((entry) => readsPermissionPredicate(entry.expression)).map((entry) => entry.name); + if (fields.length === 0) return undefined; + if (!resolution) { + this.logger.warn( + `formula field(s) ${fields.join(', ')} on '${object}' call current_user.can(), and no ` + + 'effective-permission source is registered, so they read null: compose a security ' + + 'service that registers one (registerEffectiveObjectPermissionsResolver) for can() to ' + + 'be answered', + { object, fields, reason: 'no-permission-source' }, + ); + return undefined; + } + try { + return await resolution.get(); + } catch (error) { + this.logger.warn( + `formula field(s) ${fields.join(', ')} on '${object}' call current_user.can(), and the ` + + 'effective-permission resolution failed, so they read null on this read (failed closed: ' + + 'neither a grant nor a denial was assumed)', + { object, fields, reason: 'permission-resolution-failed', error }, + ); + return undefined; + } + } + + /** + * [#20082] The permission map a write's CEL `defaultValue`s are evaluated with + * — a per-row accessor, the shape {@link resolveOptionPermissions} hands back, + * over the write's ONE {@link permissionResolution}. + * + * Asked only when a row will really be defaulted by an expression that calls + * `can`: the write has an acting user (the way {@link applyFieldDefaults} + * binds `current_user` — a truthy `userId`), and the row leaves such a field + * without a value (`applyFieldDefaults`' own `!= null` test). A row that + * supplies the field, and an object whose defaults never call `can`, pay + * nothing and cannot be refused by a resolution they never depended on. + * + * - no resolution (no resolver registered): every row gets `undefined` — NO + * map. The default then does not evaluate and is left unset with + * `applyFieldDefaults`' existing warn, which carries formula's own "carries + * no permission data" refusal; a `required` field so defaulted is refused + * by required-validation, exactly as before this seam existed. + * - the resolution threw (or its map was refused): a row that needs it gets + * the throw, re-raised untouched — the write fails CLOSED with the + * resolution's own error, as an option gate's does. ⛔ Never read as "no + * grants", which would STORE `false` as though it had been measured. + */ + private async resolveDefaultPermissions( + object: string, + rows: ReadonlyArray, + context: ExecutionContext | undefined, + resolution: PermissionResolution | undefined, + /** Only these fields' defaults count — the insert's re-default pass keeps no others. */ + onlyFields?: readonly string[], + ): Promise<(row: unknown) => EvalPermissions | undefined> { + const none = (): undefined => undefined; + if (!resolution || !context?.userId) return none; + const gated = (this.defaultFieldEntries(object) ?? []) + .filter((f) => !onlyFields || onlyFields.includes(f.name)) + .filter((f) => isExpressionDefault(f.defaultValue) && readsPermissionPredicate(f.defaultValue)) + .map((f) => f.name); + if (gated.length === 0) return none; + const needs = (row: unknown): boolean => + row != null && typeof row === 'object' + && gated.some((name) => (row as Record)[name] == null); + if (!rows.some(needs)) return none; + let permissions: EvalPermissions; + try { + permissions = await resolution.get(); + } catch (err) { + return (row) => { + if (needs(row)) throw err; + return undefined; + }; + } + return (row) => (needs(row) ? permissions : undefined); + } + /** * [#11968] The engine-seam write epoch — the invalidation substrate of the @@ -5123,22 +5324,25 @@ export class ObjectQL implements IObjectQLEngine { * `default: true` ({@link resolveOptionDefault}, #7246) — the select idiom, * which until then was authorable, spec-valid, and read by nothing on this * path. + * + * [#20082] `permissions` is the acting subject's effective object-permission + * map, which an expression default calling `current_user.can(object, verb)` + * answers from. This method is synchronous, so the CALLER resolves it — once + * per write, only for a row that will be defaulted by such an expression + * ({@link resolveDefaultPermissions}, which also owns what a failed resolution + * does: it refuses the write before this runs). `undefined` is "no permission + * data": a `can` default then does not evaluate and takes this method's rule + * for every expression default that does not — left unset, with the warn below. */ private applyFieldDefaults( object: string, record: Record, execCtx?: ExecutionContext, nowSnapshot?: Date, + permissions?: EvalPermissions, ): Record { - const schema = this.getSchema(object); - const fieldsRaw = (schema as any)?.fields; - if (!fieldsRaw || typeof fieldsRaw !== 'object') return record; - // `fields` may be a Record (canonical) or an array (legacy). - const fieldEntries: Array<{ - name: string; type?: unknown; defaultValue?: unknown; options?: unknown; multiple?: unknown; - }> = Array.isArray(fieldsRaw) - ? fieldsRaw - : Object.entries(fieldsRaw).map(([name, def]) => ({ name, ...(def as object) })); + const fieldEntries = this.defaultFieldEntries(object); + if (!fieldEntries) return record; const out = { ...record }; const now = nowSnapshot ?? new Date(); for (const f of fieldEntries) { @@ -5167,12 +5371,13 @@ export class ObjectQL implements IObjectQLEngine { continue; } const dv = f.defaultValue; - if (typeof dv === 'object' && dv !== null && (dv as any).dialect && typeof (dv as any).source === 'string') { - const result = ExpressionEngine.evaluate(dv as any, { + if (isExpressionDefault(dv)) { + const result = ExpressionEngine.evaluate(dv, { now, timezone: execCtx?.timezone, user: execCtx?.userId ? { id: String(execCtx.userId), positions: execCtx?.positions ?? [] } : undefined, org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : undefined, + permissions, record: out, extra: { object }, }); @@ -5233,6 +5438,24 @@ export class ObjectQL implements IObjectQLEngine { return out; } + /** + * The field entries {@link applyFieldDefaults} walks — one reading of an + * object's `fields`, shared with {@link resolveDefaultPermissions} so the + * planner and the evaluator see the same defaults. `undefined` when the object + * declares no fields, which is `applyFieldDefaults`' early return (it hands + * the SAME record reference back, and the insert path relies on knowing that). + */ + private defaultFieldEntries(object: string): Array<{ + name: string; type?: unknown; defaultValue?: unknown; options?: unknown; multiple?: unknown; + }> | undefined { + const fieldsRaw = (this.getSchema(object) as any)?.fields; + if (!fieldsRaw || typeof fieldsRaw !== 'object') return undefined; + // `fields` may be a Record (canonical) or an array (legacy). + return Array.isArray(fieldsRaw) + ? fieldsRaw + : Object.entries(fieldsRaw).map(([name, def]) => ({ name, ...(def as object) })); + } + /** * Generate values for empty `autonumber` fields on insert — ONLY for drivers * that do not generate them natively (memory, mongodb). For SQL-backed objects @@ -10845,8 +11068,14 @@ export class ObjectQL implements IObjectQLEngine { try { let result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any); - // Post-process: evaluate formula virtual fields against the raw rows - if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result, opCtx.context); + // Post-process: evaluate formula virtual fields against the raw rows. + // [#20082] With the caller's permission map when a formula calls + // `can` — one resolution for the whole result set, never per row. + if (Array.isArray(result)) { + applyFormulaPlan(_findFormula.plan, result, opCtx.context, await this.resolveFormulaPermissions( + object, _findFormula.plan, result, opCtx.context, this.permissionResolution(opCtx.context), + )); + } // Post-process: expand related records if expand is requested if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) { @@ -11115,7 +11344,12 @@ export class ObjectQL implements IObjectQLEngine { let result = await driver.findOne(objectName, hookContext.input.ast as QueryAST, hookContext.input.options as any); // Post-process: evaluate formula virtual fields against the raw row - if (result != null) applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context); + // ([#20082] with the caller's permission map when a formula calls `can`). + if (result != null) { + applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context, await this.resolveFormulaPermissions( + objectName, _findOneFormula.plan, [result], opCtx.context, this.permissionResolution(opCtx.context), + )); + } // Post-process: expand related records if expand is requested if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) { @@ -11289,10 +11523,18 @@ export class ObjectQL implements IObjectQLEngine { // `null` means "clear it"), so neither does an `update`-mode preview. const rawRows = Array.isArray(data) ? data : [data]; const nowSnapshot = new Date(); + // [#20082] The preview's ONE permission resolution, shared by its CEL + // defaults and its option gates below, exactly as the write shares one. A + // resolution failure rejects the preview for a row whose `can` default needed + // it, as it fails the write. + const permissionResolution = this.permissionResolution(options?.context); + const defaultPermissionsFor = mode === 'insert' + ? await this.resolveDefaultPermissions(object, rawRows, options?.context, permissionResolution) + : () => undefined; const rows: Record[] = mode === 'insert' ? rawRows.map((row) => this.initializeSummaryFields( object, - this.applyFieldDefaults(object, row, options?.context, nowSnapshot), + this.applyFieldDefaults(object, row, options?.context, nowSnapshot, defaultPermissionsFor(row)), ) as Record) : rawRows; @@ -11346,7 +11588,7 @@ export class ObjectQL implements IObjectQLEngine { // [#18783] The preview answers a `can`-gated option with the SAME map the // write would — resolved once for the whole set, like every posture input // above. A resolution failure rejects the preview, as it fails the write. - const previewPermissionsFor = await this.resolveOptionPermissions(schemaForValidation, rows, options?.context); + const previewPermissionsFor = await this.resolveOptionPermissions(schemaForValidation, rows, permissionResolution); const results: NonNullable = rows.map((row) => { const warnings: ValidateDataIssue[] = []; @@ -11518,16 +11760,43 @@ export class ObjectQL implements IObjectQLEngine { (isBatch ? (opCtx.data as any[]) : [opCtx.data]).map( (row) => ({ ...((row ?? {}) as Record) }), ); + // [#20082] The write's ONE permission resolution, shared by every consumer + // below that needs the map: the CEL defaults here, the re-default after + // the static-`readonly` strip, the option gates at validation, and the + // formula fields on the response. Asked at most once for the whole write. + // + // A CEL default that calls `current_user.can(…)` is evaluated with it. A + // row whose default NEEDED it when the resolution failed is refused with + // the resolution's own error (fail CLOSED, see `resolveDefaultPermissions`) + // before any hook or producer runs for it: the whole write outside partial + // mode; under partial mode that row alone, carried with the declared-field + // door's per-row refusals — the array every later pass already reads as + // "this row is dead on arrival" (no hook, seeded into `rowErrors`). + const permissionResolution = this.permissionResolution(opCtx.context); + const payloadRows: unknown[] = isBatch ? (opCtx.data as unknown[]) : [opCtx.data]; + const defaultPermissionsFor = await this.resolveDefaultPermissions( + object, payloadRows.filter((_, i) => undeclaredPerRow[i] === undefined), opCtx.context, permissionResolution, + ); + const defaultPermissions: Array = payloadRows.map((row, i) => { + if (undeclaredPerRow[i] !== undefined) return undefined; + try { + return defaultPermissionsFor(row); + } catch (err) { + if (!partialRowMode) throw err; + undeclaredPerRow[i] = err as Error; + return undefined; + } + }); const defaultedData = isBatch - ? (opCtx.data as any[]).map((row) => + ? (opCtx.data as any[]).map((row, i) => this.initializeSummaryFields( object, - this.applyFieldDefaults(object, row as Record, opCtx.context, nowSnap), + this.applyFieldDefaults(object, row as Record, opCtx.context, nowSnap, defaultPermissions[i]), ), ) : this.initializeSummaryFields( object, - this.applyFieldDefaults(object, opCtx.data as Record, opCtx.context, nowSnap), + this.applyFieldDefaults(object, opCtx.data as Record, opCtx.context, nowSnap, defaultPermissions[0]), ); // Batch inserts trigger beforeInsert/afterInsert PER ROW, each with the @@ -11868,7 +12137,22 @@ export class ObjectQL implements IObjectQLEngine { // `null` must keep its null (the first defaults pass, ahead of // the hooks, is the one that owns those keys). if (takenFromRow.length > 0) { - const redefaulted = this.applyFieldDefaults(object, stripped, opCtx.context, nowSnap); + // [#20082] A re-derived `can` default takes the write's map too — + // from the same resolution, so this asks nothing new unless it is + // the first pass to need it. Scoped to the TAKEN keys: those are + // the only defaults this pass keeps, so no other field's default + // can make it ask, or fail. + let redefaultPermissions: EvalPermissions | undefined; + try { + redefaultPermissions = (await this.resolveDefaultPermissions( + object, [stripped], opCtx.context, permissionResolution, takenFromRow, + ))(stripped); + } catch (err) { + if (!partialRowMode) throw err; + rowErrors[i] = err; + continue; + } + const redefaulted = this.applyFieldDefaults(object, stripped, opCtx.context, nowSnap, redefaultPermissions); for (const k of takenFromRow) { if (redefaulted[k] !== undefined) stripped[k] = redefaulted[k]; } @@ -12085,7 +12369,7 @@ export class ObjectQL implements IObjectQLEngine { // failure refuses exactly the rows that needed it (per-row under // partial mode, like every other row error here). const insertPermissionsFor = await this.resolveOptionPermissions( - schemaForValidation, rows.filter((_, i) => rowErrors[i] === undefined), opCtx.context, + schemaForValidation, rows.filter((_, i) => rowErrors[i] === undefined), permissionResolution, ); for (let i = 0; i < rows.length; i++) { if (rowErrors[i] !== undefined) continue; @@ -12229,7 +12513,10 @@ export class ObjectQL implements IObjectQLEngine { // the caller-facing `rowCtx.result` carries the values too. // Batch (`insertMany` / `createManyData`) is covered by construction: // one hydration pass over every returned row, not one per call site. - hydrateWriteFormulas(schemaForValidation, resultRows, opCtx.context); + await hydrateWriteFormulas( + schemaForValidation, resultRows, opCtx.context, + (plan, records) => this.resolveFormulaPermissions(object, plan, records, opCtx.context, permissionResolution), + ); for (let k = 0; k < liveIndexes.length; k++) { const rowCtx = rowHookContexts[liveIndexes[k]]; rowCtx.event = 'afterInsert'; @@ -12648,6 +12935,10 @@ export class ObjectQL implements IObjectQLEngine { }; await this.executeWithMiddleware(opCtx, async () => { + // [#20082] The write's ONE permission resolution, shared by its option + // gates and the formula fields on its response — asked at most once, and + // only by the first of them that needs the map. + const permissionResolution = this.permissionResolution(opCtx.context); // [#8738] The declared-field door, the insert path's (#8682) applied to // the second write verb — same function, not a second predicate. First // act inside the middleware body: after middleware (which may rewrite @@ -13574,7 +13865,7 @@ export class ObjectQL implements IObjectQLEngine { // answered from — resolved only when the PATCH picks one, and a // resolution failure fails this write closed right here. const updatePayload = hookContext.input.data as Record; - const permissionsForUpdate = (await this.resolveOptionPermissions(updateSchema, [updatePayload], opCtx.context))(updatePayload); + const permissionsForUpdate = (await this.resolveOptionPermissions(updateSchema, [updatePayload], permissionResolution))(updatePayload); evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: roWhenParent, previousParent: roWhenPreviousParent, related: relatedForUpdate, permissions: permissionsForUpdate }); // [#4441] A repoint is as capable of dangling as an initial link. await this.assertReferencesResolve( @@ -13852,7 +14143,7 @@ export class ObjectQL implements IObjectQLEngine { // [#18783] ONE permission-map resolution for the whole matched // set — the patch is shared, so either every row picks a // `can`-gated option or none does. Never per row. - const bulkPermissions = (await this.resolveOptionPermissions(updateSchema, [bulkPatch], opCtx.context))(bulkPatch); + const bulkPermissions = (await this.resolveOptionPermissions(updateSchema, [bulkPatch], permissionResolution))(bulkPatch); if (rulesNeedRows) { for (const row of priorRows ?? []) { try { @@ -13909,10 +14200,11 @@ export class ObjectQL implements IObjectQLEngine { // letting a `typeof` sniff decide. Giving a bulk update a record // response is a contract change, not a hydration gap. if (!isPredicateWrite) { - hydrateWriteFormulas( + await hydrateWriteFormulas( updateSchema, Array.isArray(result) ? result : [result], opCtx.context, + (plan, records) => this.resolveFormulaPermissions(object, plan, records, opCtx.context, permissionResolution), ); } // Coerce boolean fields (SQLite 0/1 → JS bool) on the after-hook view diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 7a9ce26963f..90cec7aea79 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -3063,8 +3063,12 @@ const permissionPredicateCache = new Map(); * A non-CEL dialect, or a source that does not parse, answers `false`: the * evaluator runs the same parser and cannot evaluate what this cannot read, so * the map could not change its outcome. + * + * [#20082] Exported for the engine's two VALUE sites that bind `current_user` + * — a `formula` field and a CEL `defaultValue` — so "does this expression need + * the permission map?" has one answer for predicates and values alike. */ -function readsPermissionPredicate(cond: string | Expression): boolean { +export function readsPermissionPredicate(cond: string | Expression): boolean { const expr = toExpression(cond); if (expr.dialect !== 'cel') return false; const source = typeof expr.source === 'string' ? expr.source : '';