diff --git a/.changeset/22892-predicate-related-master-detail.md b/.changeset/22892-predicate-related-master-detail.md new file mode 100644 index 00000000000..94a1bcb4983 --- /dev/null +++ b/.changeset/22892-predicate-related-master-detail.md @@ -0,0 +1,32 @@ +--- +'@objectstack/objectql': minor +--- + +fix(objectql)!: a validation rule that reads its master through a master-detail field asks the caller's read door for the header first — a header the caller cannot read is unavailable to the rule as a header that exists nowhere is (#22892) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A detail write answers a header the caller cannot read exactly as a header that exists nowhere: its `parent` binding and its reference check ask the caller's read door first (the companion entry for #22519). A validation rule on the detail that reads the header through the master-detail field (`record.invoice.status == 'locked'`) still read the header elevated, and rules run before the reference check, so that rule could still answer by what an unreadable header holds. + +**FROM.** For a non-system caller who may write the detail but cannot read its header, a rule reading the header through the master-detail field judged the header's stored values. Measured with a rule refusing a line under a locked invoice: a header id that exists nowhere was refused with `_record` `rule_violation` ("the related record was not found"); an unreadable open header passed the rule and was refused `reference_not_found`; an unreadable locked header was refused with the rule's own message. Three answers, one bit of the header's state. + +**TO.** Through a `master_detail` field the rule's related read takes the write's two reads: + +1. The caller's read door, under the caller's own context. A header it does not return (hidden by row scope, a refused read of the header object, or a failed read) is unavailable to the rule exactly as a header that exists nowhere is: the rule cannot be evaluated and the write is refused `400 VALIDATION_FAILED` at `_record` (`rule_violation`, "the related record was not found"), whatever the header holds. +2. The elevated, tenant-kept read of the columns the rule names, only for the headers step 1 returned. A header the caller can read is still judged on its STORED values, a column field-level security hides or masks for the caller included. + +**Bindings.** + +- Every write door that runs the rule: `insert` (single and batch), `insertMany`, update by id (a repoint included), the predicate (bulk) update, and the `validate()` preview. +- System callers (`isSystem`: seed replay, boot, package install) ask no read door and are judged exactly as before. +- A rule reading through a plain `lookup` is unchanged. +- No new error code and no status change: the answer is the one a header that exists nowhere already got. +- Cost: one id-projected read under the caller's context per master-detail field a rule reads through, once per write batch. + +**Fix, if a write relied on the old answer:** give the writer read access to the header (an object permission or sharing rule on the master), or declare the detail `controlled_by_parent` so the header's access governs it. + +**Not measured.** How many deployed detail objects carry a rule reading their master was not measured: the rule only tightens, so it holds whatever that answer is. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b29da80d693..53d5bc304b3 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -137,7 +137,7 @@ that silently does not happen. | 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `packages/objectql/src/engine.ts#stripSearchCompanionFromRead` | | 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `packages/objectql/src/engine.ts#dependentCountIsDisclosable` | | 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `packages/objectql/src/engine.ts#recordReferenceCheckElevation` | -| 29b | A traversing validation rule's related read skips the **caller's bounds** | objectql | Get: no row bound from the caller's own read on a related object no organization wall scopes, and a related read even with no organization under a walled posture (a `tenantId` on the context still scopes it). Every caller that is not system is held to both | `packages/objectql/src/engine.ts#resolvePredicateRelated` | +| 29b | A traversing validation rule's related read skips the **caller's bounds** | objectql | Get: no row bound from the caller's own read on a related object no organization wall scopes, no read door on a header the rule reads through a `master_detail` field, and a related read even with no organization under a walled posture (a `tenantId` on the context still scopes it). Every caller that is not system is held to all three: a header its read door does not return is unavailable to the rule exactly as a header that exists nowhere is, whatever it holds, and a header it can read is judged on its stored values. A plain `lookup` to an object an organization wall scopes asks no read door, system caller or not | `packages/objectql/src/engine.ts#resolvePredicateRelated` | | 29c | A detail write's master-detail header read asks **no read door** | objectql | Get: the write binds the header a parent-scoped `requiredWhen` / `readonlyWhen` reads as `parent` from the header's stored row whoever can read it, as before. Every caller that is not system meets its own read door first: a header that read does not return answers as a header that exists nowhere — an unbound `parent`, and `reference_not_found` on the reference check (which a system caller's write skips altogether) | `packages/objectql/src/engine.ts#masterDetailHeadersCallerReads` | | 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `packages/plugins/plugin-security/src/security-plugin.ts#start` | diff --git a/packages/objectql/src/engine-predicate-related-master-detail-header.test.ts b/packages/objectql/src/engine-predicate-related-master-detail-header.test.ts new file mode 100644 index 00000000000..0d0456ef566 --- /dev/null +++ b/packages/objectql/src/engine-predicate-related-master-detail-header.test.ts @@ -0,0 +1,391 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22892] A predicate rule that reads one hop through a master-detail FK + * finds a header the caller cannot read unavailable, exactly as a header that + * exists nowhere is. Ruling A on #22519 (an unreadable header is a nonexistent + * one), extended to the rule's related read. + * + * ## The defect + * + * `resolvePredicateRelated` (#18682) read the related row ELEVATED (tenant + * kept), with no caller row bound on a walled object, a tenanted master + * included. Validation rules run before the reference check, so for a caller + * who may write a detail but cannot read its header the rule judged the + * header's stored state. Measured on `018b077fbf` with this file's rig and a + * rule refusing a line under a locked invoice: a header that exists nowhere + * answered `_record` `rule_violation` (the rule could not be evaluated), an + * unreadable open header `invoice` `reference_not_found`, and an unreadable + * locked header `_record` `rule_violation` with the rule's own message. Three + * answers for "unreadable or nonexistent": one bit of the header's state. + * + * ## The fix + * + * Through a `master_detail` FK the related read takes the write's two-read + * shape (`masterDetailHeadersCallerReads`, PR #22891): the caller's read door + * first, under the caller's own context; a header it does not return is + * `unresolved`, exactly as a header that exists nowhere is; the elevated, + * tenant-kept read follows for the headers the door returned only. + * + * ## The rig + * + * The one #22519's pins use: a store-backed driver and a synthetic read scope + * shaped like the security/sharing middlewares (a non-system caller reads only + * the headers it owns; Carol's read of the header object is refused outright). + * + * ## Pins + * + * - a header that exists nowhere, an unreadable open header and an unreadable + * locked header answer identically: insert, the batch insert, an update by + * id, a repoint by id, the bulk update and the `validate()` preview; a + * refused read of the header object answers alike; the partial batch; + * - the mechanism: the read door first, and no elevated read for a header it + * did not return; + * CONTROLS: a readable header is judged on its stored values (a column the + * read door hides or masks included); a plain `lookup` traversal to the same + * object is unchanged; a system caller is unchanged. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { ObjectQL } from './engine.js'; +import { ValidationError } from './validation/record-validator.js'; + +const HEADER = 'ph_invoice'; +const LINE = 'ph_line'; +const LOOKUP_LINE = 'ph_lookup_line'; + +const RULE_MESSAGE = 'A locked invoice takes no lines.'; + +const HEADER_OBJECT = { + name: HEADER, + label: 'Invoice', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + owner_name: { name: 'owner_name', label: 'Owner', type: 'text' }, + status: { name: 'status', label: 'Status', type: 'text' }, + }, +}; + +/** The detail: a predicate rule reads its master through the master-detail FK. */ +const LINE_OBJECT = { + name: LINE, + label: 'Line', + fields: { + invoice: { name: 'invoice', label: 'Invoice', type: 'master_detail', reference: HEADER }, + quantity: { name: 'quantity', label: 'Quantity', type: 'number' }, + }, + validations: [{ + name: 'no_lines_on_locked', type: 'script', severity: 'error', + message: RULE_MESSAGE, condition: "record.invoice.status == 'locked'", + }], +}; + +/** The same rule through a plain `lookup` to the same object: not this rule's. */ +const LOOKUP_LINE_OBJECT = { + name: LOOKUP_LINE, + label: 'Lookup line', + fields: { + invoice_ref: { name: 'invoice_ref', label: 'Invoice', type: 'lookup', reference: HEADER }, + }, + validations: [{ + name: 'no_refs_to_locked', type: 'script', severity: 'error', + message: RULE_MESSAGE, condition: "record.invoice_ref.status == 'locked'", + }], +}; + +/** A store-backed driver: what is stored is what a later read answers. */ +function makeStoreDriver() { + 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: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k.startsWith('$')) throw new Error(`store driver: unsupported combinator ${k}`); + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + return Object.entries(v).every(([op, target]) => { + if (op === '$eq') return row?.[k] === target; + if (op === '$in') return Array.isArray(target) && target.includes(row?.[k]); + throw new Error(`store driver: unsupported operator ${op}`); + }); + } + return row?.[k] === v; + }); + }; + let n = 0; + const driver: any = { + name: 'ph-store', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async syncSchema() {}, + async find(o: string, ast: any) { + const hits = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return (typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits).map((r) => ({ ...r })); + }, + async findOne(o: string, ast: any) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(o: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `rec_${n}`; + const row = { ...data, id }; + storeFor(o).set(id, row); + return { ...row }; + }, + async update(o: string, id: string, data: Record) { + const row = { ...storeFor(o).get(id), ...data, id }; + storeFor(o).set(id, row); + return { ...row }; + }, + async updateMany(o: string, ast: any, data: Record) { + const hits = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + for (const r of hits) storeFor(o).set(r.id, { ...r, ...data, id: r.id }); + return hits.length; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length; + }, + }; + return { driver, storeFor }; +} + +/** The refusal an object-level read denial throws — `PermissionDeniedError`'s declared envelope. */ +const readRefusal = () => Object.assign(new Error('[Security] Access denied: read on the header object'), { + name: 'PermissionDeniedError', code: 'PERMISSION_DENIED', status: 403, statusCode: 403, +}); + +/** Every header read: its operation, whether it ran elevated, and the ids it asked. */ +type HeaderRead = { operation: string; elevated: boolean; where: unknown }; + +function makeEngine() { + const store = makeStoreDriver(); + const engine = new ObjectQL(); + engine.registerDriver(store.driver, true); + engine.registerApp({ + id: 'ph', + name: 'Predicate read through a master-detail FK', + objects: [HEADER_OBJECT, LINE_OBJECT, LOOKUP_LINE_OBJECT], + } as any); + const headerReads: HeaderRead[] = []; + engine.registerMiddleware(async (ctx: any, next: () => Promise) => { + if (ctx.object === HEADER && ['find', 'findOne'].includes(ctx.operation)) { + const elevated = ctx.context?.isSystem === true; + headerReads.push({ operation: ctx.operation, elevated, where: ctx.ast?.where }); + const userId = ctx.context?.userId; + if (!elevated && userId === 'carol') throw readRefusal(); + // Read scope shaped like the security/sharing middlewares: a non-system + // caller reads only the headers it owns. + if (!elevated && userId) { + const scoped = { owner_name: userId }; + const ast: any = ctx.ast ?? { object: ctx.object }; + ast.where = ast.where ? { $and: [ast.where, scoped] } : scoped; + ctx.ast = ast; + } + } + await next(); + }); + const headers = store.storeFor(HEADER); + for (const [id, owner, status] of [ + ['h_mine_open', 'alice', 'open'], + ['h_mine_locked', 'alice', 'locked'], + ['h_other_open', 'bob', 'open'], + ['h_other_locked', 'bob', 'locked'], + ] as const) { + headers.set(id, { id, name: id, owner_name: owner, status }); + } + return { engine, headerReads, ...store }; +} + +/** Alice may write every line and reads only the headers she owns. */ +const ALICE = { userId: 'alice' }; +/** Carol's read of the header object is refused outright. */ +const CAROL = { userId: 'carol' }; +/** Seed replay / boot. */ +const SYSTEM = { isSystem: true, userId: 'alice' }; + +const NOWHERE = 'h_nowhere'; +const UNREADABLE = ['h_other_open', 'h_other_locked'] as const; + +type Answer = { status: number; code: string; fields: Array<{ field: string; code: string }>; message: string }; + +/** The refusal as every HTTP door reads it (ADR-0112), its field codes, and its text with the header id taken out. */ +function answerOf(err: unknown, id: string): Answer { + const thrown = resolveThrownHttpError(err); + const fields = ((err as any)?.fields ?? []).map((f: any) => ({ field: f.field, code: f.code })); + return { status: thrown.status, code: thrown.code, fields, message: String((err as Error).message).split(id).join('ID') }; +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the write to be refused, but it succeeded'); +} + +/** ADR-0137 D2: the rule has no verdict over a header that is not there. */ +const UNEVALUABLE = { status: 400, code: 'VALIDATION_FAILED', fields: [{ field: '_record', code: 'rule_violation' }] }; + +/** The rule's own verdict: it read the header's stored state and refused (the bulk path adds the row id). */ +function expectRuleVerdict(answer: Answer): void { + expect(answer).toMatchObject(UNEVALUABLE); + expect(answer.message.startsWith(RULE_MESSAGE)).toBe(true); +} + +describe('[#22892] a predicate rule reading its master through the master-detail FK finds an unreadable header as a nonexistent one', () => { + let engine: ObjectQL; + let headerReads: HeaderRead[]; + let storeFor: (o: string) => Map>; + + beforeEach(() => { + ({ engine, headerReads, storeFor } = makeEngine()); + }); + + type Door = 'insert' | 'batch insert' | 'update by id' | 'repoint by id' | 'bulk update'; + const DOORS: Door[] = ['insert', 'batch insert', 'update by id', 'repoint by id', 'bulk update']; + + /** One write through `door` that lands the line under `header`. */ + const write = (door: Door, header: string, context: Record = ALICE) => { + const lines = storeFor(LINE); + if (door === 'insert') return engine.insert(LINE, { invoice: header, quantity: 1 }, { context } as any); + if (door === 'batch insert') { + return engine.insert(LINE, [{ invoice: 'h_mine_open', quantity: 1 }, { invoice: header, quantity: 2 }], { context } as any); + } + if (door === 'repoint by id') { + lines.set('l_r', { id: 'l_r', invoice: 'h_mine_open', quantity: 1 }); + return engine.update(LINE, { invoice: header }, { where: { id: 'l_r' }, context } as any); + } + // The stored line already hangs off `header`; the patch does not name it. + const id = `l_${door.replace(/ /g, '_')}_${header}`; + lines.set(id, { id, invoice: header, quantity: 1 }); + const bulk = door === 'bulk update'; + return engine.update(LINE, { quantity: 5 }, { where: bulk ? { id: { $in: [id] } } : { id }, multi: bulk, context } as any); + }; + + describe('the three answers become one', () => { + it.each(DOORS)('%s — a header that exists nowhere, an unreadable open header and an unreadable locked header answer identically', async (door) => { + const before = [...storeFor(LINE).values()]; + const nowhere = await refusalOf(() => write(door, NOWHERE)); + expect(nowhere).toBeInstanceOf(ValidationError); + expect(answerOf(nowhere, NOWHERE)).toMatchObject(UNEVALUABLE); + expect(answerOf(nowhere, NOWHERE).message).toContain('the related record was not found'); + for (const header of UNREADABLE) { + expect(answerOf(await refusalOf(() => write(door, header)), header)).toEqual(answerOf(nowhere, NOWHERE)); + } + // Nothing written: no line added, and every stored line keeps its values. + const after = [...storeFor(LINE).values()]; + expect(after.filter((r) => r.quantity === 5 || r.quantity === 2)).toEqual([]); + expect(after.filter((r) => r.id === 'l_r').every((r) => r.invoice === 'h_mine_open')).toBe(true); + expect(after.length).toBeGreaterThanOrEqual(before.length); + }); + + it.each(DOORS)('%s — a refused read of the header object answers as a header that exists nowhere', async (door) => { + const nowhere = answerOf(await refusalOf(() => write(door, NOWHERE, CAROL)), NOWHERE); + expect(nowhere).toMatchObject(UNEVALUABLE); + // Alice's headers exist; Carol's read door refuses the object, whatever they hold. + for (const header of ['h_mine_open', 'h_mine_locked']) { + expect(answerOf(await refusalOf(() => write(door, header, CAROL)), header)).toEqual(nowhere); + } + }); + + it('the validate() preview answers as the write does', async () => { + const preview = async (header: string) => { + const out = await engine.validate(LINE, { invoice: header, quantity: 1 }, { mode: 'insert', context: ALICE } as any); + return (out.results?.[0]?.errors ?? []).map((e: any) => ({ field: e.field, code: e.code, message: String(e.message).split(header).join('ID') })); + }; + const nowhere = await preview(NOWHERE); + expect(nowhere).toEqual([expect.objectContaining({ field: '_record', code: 'rule_violation' })]); + for (const header of UNREADABLE) expect(await preview(header)).toEqual(nowhere); + // CONTROL: a readable header is judged on its stored state. + expect(await preview('h_mine_locked')).toEqual([{ field: '_record', code: 'rule_violation', message: RULE_MESSAGE }]); + expect(await preview('h_mine_open')).toEqual([]); + }); + + it('the partial batch: unreadable rows fail as a nonexistent row does, and readable rows are judged on their stored state', async () => { + const out = await engine.insertMany(LINE, [ + { invoice: 'h_other_open', quantity: 1 }, + { invoice: 'h_other_locked', quantity: 2 }, + { invoice: NOWHERE, quantity: 3 }, + { invoice: 'h_mine_locked', quantity: 4 }, + { invoice: 'h_mine_open', quantity: 5 }, + ], { context: ALICE } as any); + expect(out.map((o) => o.ok)).toEqual([false, false, false, false, true]); + const errors = out.map((o) => (o.ok ? undefined : o.error)); + const nowhere = answerOf(errors[2], NOWHERE); + expect(nowhere).toMatchObject(UNEVALUABLE); + expect(answerOf(errors[0], 'h_other_open')).toEqual(nowhere); + expect(answerOf(errors[1], 'h_other_locked')).toEqual(nowhere); + expectRuleVerdict(answerOf(errors[3], 'h_mine_locked')); + expect([...storeFor(LINE).values()].map((r) => r.invoice)).toEqual(['h_mine_open']); + }); + + it('the mechanism: the caller\'s read door first, and no elevated read of a header it did not return', async () => { + await refusalOf(() => engine.insert(LINE, { invoice: 'h_other_locked', quantity: 1 }, { context: ALICE } as any)); + expect(headerReads).toEqual([ + { operation: 'find', elevated: false, where: { id: 'h_other_locked' } }, + ]); + headerReads.length = 0; + await engine.insert(LINE, { invoice: 'h_mine_open', quantity: 1 }, { context: ALICE } as any); + expect(headerReads.slice(0, 2)).toEqual([ + // the rule's related read: the read door, then the elevated read of what it returned + { operation: 'find', elevated: false, where: { id: 'h_mine_open' } }, + { operation: 'find', elevated: true, where: { id: { $in: ['h_mine_open'] } } }, + ]); + }); + }); + + describe('CONTROLS', () => { + it.each(DOORS)('%s — a readable header is judged on its stored values: locked refuses with the rule\'s message, open lands', async (door) => { + expectRuleVerdict(answerOf(await refusalOf(() => write(door, 'h_mine_locked')), 'h_mine_locked')); + await write(door, 'h_mine_open'); + }); + + it.each(['hide', 'mask'] as const)('a readable header whose state column the read door %ss is still judged on its stored value', async (how) => { + engine.registerMiddleware(async (ctx: any, next: () => Promise) => { + await next(); + if (ctx.object !== HEADER || ctx.context?.isSystem || ctx.context?.userId !== 'alice') return; + const apply = (r: any) => { + if (!r || typeof r !== 'object') return; + if (how === 'hide') delete r.status; + else if (typeof r.status === 'string') r.status = '*'.repeat(r.status.length); + }; + if (Array.isArray(ctx.result)) ctx.result.forEach(apply); + else apply(ctx.result); + }); + // Precondition: the read door serves Alice her header, its state column restricted. + const served = await engine.findOne(HEADER, { where: { id: 'h_mine_locked' }, context: ALICE }); + expect(served).toMatchObject({ id: 'h_mine_locked' }); + expect(served?.status).not.toBe('locked'); + const err = await refusalOf(() => engine.insert(LINE, { invoice: 'h_mine_locked', quantity: 1 }, { context: ALICE } as any)); + expectRuleVerdict(answerOf(err, 'h_mine_locked')); + }); + + it('a plain lookup traversal to the same object is unchanged: the header\'s stored state is judged, and no read door is asked', async () => { + const err = await refusalOf(() => engine.insert(LOOKUP_LINE, { invoice_ref: 'h_other_locked' }, { context: ALICE } as any)); + expectRuleVerdict(answerOf(err, 'h_other_locked')); + await engine.insert(LOOKUP_LINE, { invoice_ref: 'h_other_open' }, { context: ALICE } as any); + expect([...storeFor(LOOKUP_LINE).values()].map((r) => r.invoice_ref)).toEqual(['h_other_open']); + expect(headerReads.length).toBeGreaterThan(0); + expect(headerReads.filter((r) => !r.elevated)).toEqual([]); + }); + + it('a system caller is unchanged: the header is judged whoever owns it, and no read door is asked', async () => { + const err = await refusalOf(() => engine.insert(LINE, { invoice: 'h_other_locked', quantity: 1 }, { context: SYSTEM } as any)); + expectRuleVerdict(answerOf(err, 'h_other_locked')); + await engine.insert(LINE, { invoice: 'h_other_open', quantity: 1 }, { context: SYSTEM } as any); + storeFor(LINE).set('l_s', { id: 'l_s', invoice: 'h_other_locked', quantity: 1 }); + const updated = await refusalOf(() => engine.update(LINE, { quantity: 9 }, { where: { id: 'l_s' }, context: SYSTEM } as any)); + expectRuleVerdict(answerOf(updated, 'h_other_locked')); + expect([...storeFor(LINE).values()].map((r) => r.invoice)).toEqual(['h_other_open', 'h_other_locked']); + expect(headerReads.length).toBeGreaterThan(0); + expect(headerReads.filter((r) => !r.elevated)).toEqual([]); + }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9528eabdaff..2a14a9f089a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -9169,23 +9169,40 @@ export class ObjectQL implements IObjectQLEngine { * `evaluateValidationRules` as `related`. When no rule traverses anything the * schema says so up front and this costs NOTHING — no query, no closure work * — which is what bounds the N+1: one hop, only the fields a rule names, one - * batched read per reference field per write, and only when a rule asks. - * - * ## Read under SYSTEM authority, like `parent` and for a related reason - * - * {@link resolveMasterDetailParent} reads a header's VALUES as SYSTEM (once - * the caller's read door has returned that header, #22519) because a - * master-detail lock is a property of the header's state, not of the caller's - * visibility of its columns. This read is the same shape: a validation rule's output is a pass/fail - * the SYSTEM enforces, not data handed to the caller — categorically unlike - * an access-control rule, which is why RLS predicates are excluded from this - * capability altogether. Reading as the acting user instead made the rule - * unauthorable for exactly the persona it exists to constrain. - * - * What bounds the elevation is the PROJECTION: only the - * columns the predicate names, intersected with the related object's declared - * fields. ⛔ Never the whole row. On a related object no organization wall - * scopes, the own read of any caller that is not SYSTEM also bounds the ROWS. + * batched read per reference field per write (two for a caller that is not + * SYSTEM where a read door bounds the rows, below), and only when a rule asks. + * + * ## Which related rows a caller's rule may read, and how their values are read + * + * The VALUES are read as SYSTEM: a validation rule's output is a pass/fail the + * SYSTEM enforces, not data handed to the caller — categorically unlike an + * access-control rule, which is why RLS predicates are excluded from this + * capability altogether. Reading the columns as the acting user instead made + * the rule unauthorable for exactly the persona it exists to constrain. What + * bounds that elevation is the PROJECTION: only the columns the predicate + * names, intersected with the related object's declared fields. ⛔ Never the + * whole row. + * + * Which ROWS a caller that is not SYSTEM may have read is a different + * question, answered before any value is read: + * + * - ⛔ Through a `master_detail` FK the related row is the detail's HEADER, + * and ruling A on #22519 holds here as on the write's own header read + * ({@link resolveMasterDetailParent}): an unreadable header is a header + * that exists nowhere (#22892). The caller's read door first, under the + * caller's own context ({@link masterDetailHeadersCallerReads}); a header + * it does not return is `unresolved`, exactly as a header that exists + * nowhere is, so the rule's answer never depends on what an unreadable + * header holds. The elevated, tenant-kept read follows for the headers the + * door returned only, and a header the caller CAN read is judged on its + * stored values, as the write judges it. + * - On a related object no organization wall scopes, the own read of the + * caller bounds the rows, and any other id is `unreadable`, stored or not. + * - ⛔ A plain `lookup` to a walled object keeps the tenant-kept elevated read + * alone: no ruling extends the header rule to it. + * + * A SYSTEM caller (seed replay, boot) asks no read door: the elevated read + * alone, as before. * * ## An unresolved row is left UNAVAILABLE, and it says which kind * @@ -9317,25 +9334,40 @@ export class ObjectQL implements IObjectQLEngine { // declared fields above. ⛔ Never the whole row. // // The accepted cost, recorded so nobody widens it by accident: a caller - // can INFER a value they cannot see by observing which writes refuse. - // The value itself never appears — not in the row handed to CEL beyond - // the predicate's own use of it, and not in the refusal text, which - // names the field and the rule and never the value. + // can INFER a value they cannot see by observing which writes refuse — + // a column hidden from them on a row they may read, or a column of a + // row a plain `lookup` names on a walled object. The value itself never + // appears — not in the row handed to CEL beyond the predicate's own use + // of it, and not in the refusal text, which names the field and the + // rule and never the value. [#22892] ⛔ Never what a master-detail + // header the caller cannot read holds: the door below answers first. // // ⛔ No organization wall scopes a related object with no tenant column // (e.g. `sys_user`), `tenancy.enabled: false` or `external`, so for a // caller that is not SYSTEM its rows are the ones its OWN read returns, // through every enforcement layer; any other id is 'unreadable', stored or not. let ownRead: Set | undefined; + let readSet: ReadonlySet = ids; if (bound && (isFederatedObject(targetSchema) || resolveTenantFieldName(targetSchema) === null)) { const own = await this.find(target, { where: { id: { $in: [...ids] } }, fields: ['id'], context: caller as EngineQueryOptions['context'], }) as Array>; ownRead = new Set((Array.isArray(own) ? own : []).flatMap((r) => (r?.id == null ? [] : [String(r.id)]))); if (ownRead.size === 0) { resolved.set(fk, { object: target, byId: new Map(), ownRead }); continue; } + readSet = ownRead; + } else if (bound && (fields[fk] as { type?: unknown } | undefined)?.type === 'master_detail') { + // [#22892] Through a master-detail FK the related row is the detail's + // HEADER, and ruling A on #22519 holds here as on the write's own + // header read: the caller's read door first, under the caller's own + // context. A header it does not return gets no row below and no + // `ownRead`, so it is `unresolved`, exactly as a header that exists + // nowhere is, whatever it holds. A failed door read returned no + // header, so every header is missing. + readSet = await this.masterDetailHeadersCallerReads(target, [...ids], caller); + if (readSet.size === 0) { resolved.set(fk, { object: target, byId: new Map() }); continue; } } const query: EngineQueryOptions = { - where: { id: { $in: [...(ownRead ?? ids)] } }, + where: { id: { $in: [...readSet] } }, fields: [...new Set(['id', ...named])], context: { ...(context as Record ?? {}), isSystem: true } as EngineQueryOptions['context'], };