From fbe331cbf551e61a34a95eb9a859fb2ded91d7c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:03:54 +0000 Subject: [PATCH 01/47] feat(formula): analyse the relationship hops a predicate names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static analysis only — no I/O, no schema, no query function. Answers which reference fields an authored predicate reads through (one hop), which it uses as a plain value, and which it reads deeper than one hop, so a call site that owns a query engine can preload exactly those fields BEFORE evaluation and stdlib's purity invariant is untouched. Also reports the one shape that cannot be served as written: a field both traversed and used as a value. Hydrating it serves the traversal and silently turns the value comparison false, and a validation predicate expresses the FAILURE condition, so a silent false is a rule that stops firing. Measured on this front end, that shape cannot work today — the traversal faults on every row that reaches it — so refusing it removes nothing an author has working. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/formula/src/index.ts | 17 ++ .../src/relationship-traversal.test.ts | 141 ++++++++++ .../formula/src/relationship-traversal.ts | 249 ++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 packages/formula/src/relationship-traversal.test.ts create mode 100644 packages/formula/src/relationship-traversal.ts diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index 162b35bf666..a46917c6ea7 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -72,6 +72,23 @@ export type { PermissionBinding } from './stdlib'; // hand-rolled permission map has no shape of its own to be wrong against: it // parses, `can` answers from it, and the answer is a confident silent denial. export { toEvalPermissions } from './eval-permissions'; +// #18682 — relationship traversal in predicates. The static half: which +// reference fields an authored predicate reads through, and which shapes the +// authoring layer must refuse. Published because two layers ask the same +// question and must get the same answer — `@objectstack/lint` (and the engine's +// publish path) refuse the conflicting shapes, and the ObjectQL engine uses the +// hop list to preload exactly those related fields before evaluation. A second +// implementation in either consumer is how the accepted set forks. +export { + analyzeRelationshipTraversals, + findTraversalConflicts, + DEFAULT_TRAVERSAL_ROOT, +} from './relationship-traversal'; +export type { + RelationshipTraversalAnalysis, + TraversalConflict, + TraversalConflictKind, +} from './relationship-traversal'; export { resolveSeed, resolveSeedRecord } from './seed-eval'; export { normalizeExpression, normalizeExpressionTree } from './normalize'; // ADR-0058 — canonical CEL → FilterCondition pushdown compiler (one AST, diff --git a/packages/formula/src/relationship-traversal.test.ts b/packages/formula/src/relationship-traversal.test.ts new file mode 100644 index 00000000000..2dc9334f2a9 --- /dev/null +++ b/packages/formula/src/relationship-traversal.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { + analyzeRelationshipTraversals, + findTraversalConflicts, +} from './relationship-traversal'; + +/** Shorthand: the related fields named on one FK, as a sorted array. */ +const hop = (source: string, field: string): string[] => { + const a = analyzeRelationshipTraversals(source); + return [...(a?.traversals.get(field) ?? [])].sort(); +}; + +const bare = (source: string): string[] => { + const a = analyzeRelationshipTraversals(source); + return [...(a?.bareFields ?? [])].sort(); +}; + +const isLookup = (f: string): boolean => f === 'crm_account' || f === 'owner'; + +describe('analyzeRelationshipTraversals — which hops an expression names', () => { + it('names the related field behind a one-hop read', () => { + expect(hop("record.crm_account.type == 'partner'", 'crm_account')).toEqual(['type']); + }); + + it('collects every related field named on the same FK', () => { + const source = "record.crm_account.type == 'partner' && record.crm_account.tier > 2"; + expect(hop(source, 'crm_account')).toEqual(['tier', 'type']); + }); + + it('separates two different FKs', () => { + const source = "record.crm_account.type == 'partner' && record.owner.email != ''"; + expect(hop(source, 'crm_account')).toEqual(['type']); + expect(hop(source, 'owner')).toEqual(['email']); + }); + + it('reports a plain field read as BARE, not as a traversal', () => { + const a = analyzeRelationshipTraversals("record.amount > 100"); + expect(a?.traversals.size).toBe(0); + expect(bare('record.amount > 100')).toEqual(['amount']); + }); + + it('a traversed FK is NOT also reported bare when only traversed', () => { + expect(bare("record.crm_account.type == 'partner'")).toEqual([]); + }); + + it('reports a FK that is BOTH traversed and used as a value', () => { + const source = "record.crm_account.type == 'partner' && record.crm_account == 'acc_1'"; + expect(hop(source, 'crm_account')).toEqual(['type']); + expect(bare(source)).toEqual(['crm_account']); + }); + + // A method receiver is a VALUE use: `startsWith` reads the string, it does + // not read through a relationship. Mistaking it for a hop would make the + // engine preload a field that is not a reference at all. + it('treats a method receiver as a value, not a hop', () => { + const source = "record.name.startsWith('A')"; + expect(analyzeRelationshipTraversals(source)?.traversals.size).toBe(0); + expect(bare(source)).toEqual(['name']); + }); + + it('sees through a function argument', () => { + expect(hop("has(record.crm_account.type)", 'crm_account')).toEqual(['type']); + }); + + it('sees through both arms of a ternary and of a disjunction', () => { + expect(hop("record.flag ? record.crm_account.type : 'x'", 'crm_account')).toEqual(['type']); + expect(hop("record.a == 1 || record.crm_account.tier > 2", 'crm_account')).toEqual(['tier']); + }); + + it('flags a read deeper than one hop, attributed to the first field', () => { + const a = analyzeRelationshipTraversals('record.crm_account.owner.email != null'); + expect([...(a?.multiHopFields ?? [])]).toEqual(['crm_account']); + }); + + it('only reads the root it was asked about', () => { + const source = "previous.crm_account.type == 'partner'"; + expect(analyzeRelationshipTraversals(source)?.traversals.size).toBe(0); + expect(hop(source.replace('previous', 'record'), 'crm_account')).toEqual(['type']); + expect([...(analyzeRelationshipTraversals(source, 'previous')?.traversals.keys() ?? [])]) + .toEqual(['crm_account']); + }); + + // The caller already reports parse faults; this returns null rather than + // inventing a second verdict channel for the same failure. + it('returns null for a source that does not parse', () => { + expect(analyzeRelationshipTraversals('record.stage ==')).toBeNull(); + expect(analyzeRelationshipTraversals('')).toBeNull(); + }); +}); + +describe('findTraversalConflicts — what the authoring layer refuses', () => { + it('refuses a FK that is both traversed and compared bare', () => { + const a = analyzeRelationshipTraversals( + "record.crm_account.type == 'partner' && record.crm_account == 'acc_1'", + )!; + const conflicts = findTraversalConflicts(a, isLookup); + expect(conflicts).toHaveLength(1); + expect(conflicts[0].kind).toBe('bare-and-traversed'); + expect(conflicts[0].field).toBe('crm_account'); + // The prescription must name the repair, or the refusal is a dead end. + expect(conflicts[0].message).toContain('record.crm_account.id'); + }); + + // The short-circuit form is the one shape that can evaluate today for SOME + // rows (the traversal is skipped when the left arm decides the verdict) and + // fault for others. It is refused for that reason, not despite it. + it('refuses the short-circuit form too', () => { + const a = analyzeRelationshipTraversals( + "record.crm_account == 'acc_1' || record.crm_account.type == 'partner'", + )!; + expect(findTraversalConflicts(a, isLookup)).toHaveLength(1); + }); + + it('refuses a read deeper than one hop', () => { + const a = analyzeRelationshipTraversals('record.crm_account.owner.email != null')!; + const conflicts = findTraversalConflicts(a, isLookup); + expect(conflicts.some((c) => c.kind === 'multi-hop')).toBe(true); + }); + + // The whole point of taking a predicate rather than a field list: a + // non-reference object-valued field traverses today and must keep doing so. + it('leaves a NON-reference field entirely alone', () => { + const a = analyzeRelationshipTraversals( + "record.address.city == 'SF' && record.address != null", + )!; + expect(findTraversalConflicts(a, isLookup)).toEqual([]); + }); + + it('accepts the plain traversal — the shape this capability exists to serve', () => { + const a = analyzeRelationshipTraversals("record.crm_account.type == 'partner'")!; + expect(findTraversalConflicts(a, isLookup)).toEqual([]); + }); + + it('accepts a bare FK comparison on its own', () => { + const a = analyzeRelationshipTraversals("record.crm_account == 'acc_1'")!; + expect(findTraversalConflicts(a, isLookup)).toEqual([]); + }); +}); diff --git a/packages/formula/src/relationship-traversal.ts b/packages/formula/src/relationship-traversal.ts new file mode 100644 index 00000000000..8336ae99545 --- /dev/null +++ b/packages/formula/src/relationship-traversal.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Static analysis of the relationship hops an authored predicate names, so the + * call site that owns a query engine can preload exactly those related fields + * into the evaluation context BEFORE evaluation. + * + * ## Why analysis, and not a query function + * + * `os.lookup(...)` / `os.exists` / `os.count` were the shape of a declaration + * that was never bound, and they stay removed: a predicate that reaches a row + * during evaluation makes evaluation impure, and `stdlib.ts`'s purity invariant + * (every registered function pure once `now` is pinned; `objectstack build` + * byte-stable) is what keeps build artifacts reproducible. So the data is + * PINNED BEFORE evaluation instead — the same discipline + * `EvalContext.permissions` follows for `can()`, and the same one the engine + * already follows when it resolves a master-detail header and hands it over. + * This module is only the part that answers WHICH fields to pin; it performs no + * I/O, holds no schema and reaches nothing. + * + * ## What "one hop" means here, mechanically + * + * `record.crm_account.type` is two member accesses on the `record` root: the + * first names a reference-typed FIELD (`crm_account`), the second names a field + * ON THE RELATED RECORD (`type`). Anything deeper is a second hop and is + * reported, not silently truncated — truncating it would pin one hop and leave + * the predicate to fault at `No such key` on the hop nobody loaded, which reads + * to an author exactly like the bug this capability exists to remove. + * + * ## The conflict this module exists to make visible + * + * A reference field's stored value is an id. Hydrating it in place — the + * platform's own `$expand` convention, which `REFERENCE_VALUE_TYPES` documents + * as "the related record object in expanded form" — is what makes + * `record.crm_account.type` resolve. But then a BARE `record.crm_account` + * compares a map against a string, and CEL answers that comparison `false` + * WITHOUT faulting. In a validation predicate, which expresses the FAILURE + * condition, a silent `false` is a declared rule that silently stops firing. + * + * Measured on the platform's own CEL front end, an expression that both + * traverses a reference field and uses it bare CANNOT work today: the traversal + * faults with `No such key` on every row that reaches it. So refusing that + * shape at authoring time removes nothing an author has working — it replaces a + * per-row runtime fault with one loud, prescriptive refusal — which is why this + * is reported as a conflict rather than resolved by a precedence rule. + */ + +import { parseCelToAst } from './cel-engine'; +import type { CelAstNode } from './cel-engine'; + +/** The default scope root a record-scoped predicate traverses from. */ +export const DEFAULT_TRAVERSAL_ROOT = 'record'; + +/** + * What an expression asks of one scope root: which fields it reads through + * (one hop), which it uses as a plain value, and which it reads through more + * than one hop. + */ +export interface RelationshipTraversalAnalysis { + /** + * Field name → the related-record fields the expression names on it. + * `record.crm_account.type` yields `crm_account -> { 'type' }`. + */ + readonly traversals: ReadonlyMap>; + /** + * Fields the expression uses as a VALUE rather than as a traversal receiver — + * `record.crm_account == 'acc_1'`, and also a method receiver such as + * `record.name.startsWith('A')`, where the value is what the method reads. + */ + readonly bareFields: ReadonlySet; + /** + * Fields the expression reads through MORE than one hop + * (`record.a.b.c`). One hop is the declared depth; these are reported so the + * authoring layer can refuse them instead of pinning a prefix. + */ + readonly multiHopFields: ReadonlySet; +} + +const EMPTY_ANALYSIS: RelationshipTraversalAnalysis = { + traversals: new Map(), + bareFields: new Set(), + multiHopFields: new Set(), +}; + +/** `{ op: 'id', args: '' }` — a bare identifier node for `root`. */ +function isRootId(node: unknown, root: string): boolean { + if (!node || typeof node !== 'object') return false; + const { op, args } = node as { op?: unknown; args?: unknown }; + return op === 'id' && args === root; +} + +/** `{ op: '.', args: [receiver, 'name'] }` — a member access, or `null`. */ +function asMember(node: unknown): { receiver: unknown; name: string } | null { + if (!node || typeof node !== 'object') return null; + const { op, args } = node as { op?: unknown; args?: unknown }; + if (op !== '.' || !Array.isArray(args) || args.length < 2) return null; + const name = args[1]; + if (typeof name !== 'string') return null; + return { receiver: args[0], name }; +} + +/** + * Analyse one authored CEL source for the hops it takes through `root`. + * + * Returns `null` when the source does not parse or blows the platform's bounds + * — the caller already has a channel for that verdict (`compile()` / + * `validateExpression`) and this function deliberately does not invent a second + * one. An expression that parses but names no member of `root` yields an empty + * analysis, which is the common case and costs the caller nothing. + * + * Reads the AST through {@link parseCelToAst} rather than a private + * `new Environment()`, so "what parses" has exactly one answer across build, + * lint and runtime — and so this analysis can never admit a shape the engine + * would refuse to evaluate. + */ +export function analyzeRelationshipTraversals( + source: string, + root: string = DEFAULT_TRAVERSAL_ROOT, +): RelationshipTraversalAnalysis | null { + const ast = parseCelToAst(source); + if (ast == null) return null; + + const traversals = new Map>(); + const bareFields = new Set(); + const multiHopFields = new Set(); + + // Member-access nodes reached AS THE RECEIVER of another member access are + // traversals, not values. Collect those first so the value pass can exclude + // them by identity rather than by re-deriving the shape. + const traversalReceivers = new Set(); + + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + if (!node || typeof node !== 'object') return; + + const outer = asMember(node); + if (outer) { + const inner = asMember(outer.receiver); + if (inner && isRootId(inner.receiver, root)) { + // root.. — one hop through `inner.name`. + traversalReceivers.add(outer.receiver); + let fields = traversals.get(inner.name); + if (!fields) traversals.set(inner.name, (fields = new Set())); + fields.add(outer.name); + } else if (inner) { + // Deeper than one hop: root.a.b.c reaches here as (root.a.b).c, whose + // own receiver is itself a traversal. Attribute it to the FIRST field + // so the refusal can name what the author wrote. + const base = asMember(inner.receiver); + if (base && isRootId(base.receiver, root)) multiHopFields.add(base.name); + } + } + + for (const value of Object.values(node as Record)) walk(value); + }; + walk(ast); + + // Second pass: every `root.` node that was NOT consumed as a traversal + // receiver is a value use. + const walkValues = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) walkValues(child); + return; + } + if (!node || typeof node !== 'object') return; + const member = asMember(node); + if (member && isRootId(member.receiver, root) && !traversalReceivers.has(node)) { + bareFields.add(member.name); + } + for (const value of Object.values(node as Record)) walkValues(value); + }; + walkValues(ast); + + return { traversals, bareFields, multiHopFields }; +} + +/** Why a traversal the expression names cannot be served as written. */ +export type TraversalConflictKind = + /** + * The same reference field is both traversed and used as a value. Hydrating + * it serves the traversal and silently changes the value comparison, so the + * expression is refused rather than served half-right. + */ + | 'bare-and-traversed' + /** Read through more than one hop; one hop is the declared depth. */ + | 'multi-hop'; + +/** One refusal-worthy finding about one field. */ +export interface TraversalConflict { + readonly field: string; + readonly kind: TraversalConflictKind; + /** Author-facing sentence: what is wrong and what to write instead. */ + readonly message: string; +} + +/** + * The conflicts in an analysis, judged against the caller's knowledge of which + * fields are reference-typed. + * + * `isReferenceField` is a predicate rather than a field table because the two + * callers hold that knowledge in different shapes — the authoring layer has + * `fieldTypes`, the engine has the object registry — and neither should have to + * build the other's structure to ask this question. A field the predicate does + * not recognise as a reference is left entirely alone: `record.address.city` on + * an object-valued field traverses today and must keep traversing. + */ +export function findTraversalConflicts( + analysis: RelationshipTraversalAnalysis, + isReferenceField: (field: string) => boolean, + root: string = DEFAULT_TRAVERSAL_ROOT, +): TraversalConflict[] { + const conflicts: TraversalConflict[] = []; + + for (const field of analysis.traversals.keys()) { + if (!isReferenceField(field)) continue; + if (!analysis.bareFields.has(field)) continue; + conflicts.push({ + field, + kind: 'bare-and-traversed', + message: + `\`${root}.${field}\` is read BOTH through the relationship ` + + `(\`${root}.${field}.\`) and as a plain value ` + + `(\`${root}.${field}\`) in the same expression. Reading through the ` + + `relationship resolves \`${root}.${field}\` to the related RECORD, so the ` + + `plain-value comparison would stop matching the stored id — silently. ` + + `Compare the id explicitly: write \`${root}.${field}.id\` for the value ` + + `comparison, and keep \`${root}.${field}.\` for the traversal.`, + }); + } + + for (const field of analysis.multiHopFields) { + if (!isReferenceField(field)) continue; + conflicts.push({ + field, + kind: 'multi-hop', + message: + `\`${root}.${field}\` is read through more than one relationship hop. ` + + `Predicates resolve ONE hop (\`${root}.${field}.\`); a ` + + `second hop is not loaded, so the expression would fault at evaluation ` + + `time and reject the write. Denormalise the value you need onto ` + + `\`${field}\`'s object, or read it in a hook instead.`, + }); + } + + return conflicts; +} From 2fd7ca53e3dede68ddaec5ede7bbca997d55ecef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:11:54 +0000 Subject: [PATCH 02/47] feat(objectql): evaluate a validation predicate one hop through a lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkPredicate` evaluated with `{ record, previous }` only, so a lookup field carried its id and `record.account.type` faulted with `No such key: type` — and a faulting validation predicate rejects the write, so the rule could not be authored at all. The engine resolves the related rows (it owns the driver) and hands them over as `EvaluateRulesOptions.related`, the same division of labour `parent` follows. Two deliberate differences from `parent`: the rows are read under the ACTING USER so the referenced object's RLS and FLS apply, and an unresolved row is left absent on purpose — the traversal then faults and the write is rejected, which is the loud failure an unreadable related field owes. Hydration is per RULE and onto a COPY. Per rule, because hydrating a field replaces its stored id with the related record and a sibling rule comparing the bare id must keep seeing the id. Onto a copy, because the record handed to a rule is the real write payload. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../formula/src/relationship-traversal.ts | 7 - packages/objectql/src/core.ts | 4 + packages/objectql/src/index.ts | 4 + .../rule-relationship-traversal.test.ts | 217 ++++++++++++++++++ .../objectql/src/validation/rule-validator.ts | 185 ++++++++++++++- 5 files changed, 405 insertions(+), 12 deletions(-) create mode 100644 packages/objectql/src/validation/rule-relationship-traversal.test.ts diff --git a/packages/formula/src/relationship-traversal.ts b/packages/formula/src/relationship-traversal.ts index 8336ae99545..a142196abc0 100644 --- a/packages/formula/src/relationship-traversal.ts +++ b/packages/formula/src/relationship-traversal.ts @@ -46,7 +46,6 @@ */ import { parseCelToAst } from './cel-engine'; -import type { CelAstNode } from './cel-engine'; /** The default scope root a record-scoped predicate traverses from. */ export const DEFAULT_TRAVERSAL_ROOT = 'record'; @@ -76,12 +75,6 @@ export interface RelationshipTraversalAnalysis { readonly multiHopFields: ReadonlySet; } -const EMPTY_ANALYSIS: RelationshipTraversalAnalysis = { - traversals: new Map(), - bareFields: new Set(), - multiHopFields: new Set(), -}; - /** `{ op: 'id', args: '' }` — a bare identifier node for `root`. */ function isRootId(node: unknown, root: string): boolean { if (!node || typeof node !== 'object') return false; diff --git a/packages/objectql/src/core.ts b/packages/objectql/src/core.ts index 6b25f2bf147..22cb96f4642 100644 --- a/packages/objectql/src/core.ts +++ b/packages/objectql/src/core.ts @@ -90,6 +90,10 @@ export type { WrapDeclarativeOptions } from './hook-wrappers.js'; export { ValidationError, validateRecord } from './validation/record-validator.js'; export type { FieldValidationError } from './validation/record-validator.js'; export { evaluateValidationRules, needsPriorRecord, legalNextStates } from './validation/rule-validator.js'; +// #18682 — the engine asks which reference fields an object's predicate rules +// read through, then hands the rows back as `EvaluateRulesOptions.related`. +export { collectPredicateRelationships } from './validation/rule-validator.js'; +export type { RelatedRecordBinding } from './validation/rule-validator.js'; export type { EvaluateRulesOptions } from './validation/rule-validator.js'; // #4953 — published so a package that duplicates this algorithm for its own // zero-build-dependency reasons (`@objectstack/trigger-record-change`'s diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index d382fee8e42..376322a4caf 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -435,6 +435,10 @@ export type { export { summaryEmptySetValue, summaryNullIsBackfillable, aggregateSummaryValue } from './summary-aggregate.js'; export type { SummaryDescriptor, SummaryAggregateEngine } from './summary-aggregate.js'; export { evaluateValidationRules, needsPriorRecord, legalNextStates } from './validation/rule-validator.js'; +// #18682 — the engine asks which reference fields an object's predicate rules +// read through, then hands the rows back as `EvaluateRulesOptions.related`. +export { collectPredicateRelationships } from './validation/rule-validator.js'; +export type { RelatedRecordBinding } from './validation/rule-validator.js'; export type { EvaluateRulesOptions } from './validation/rule-validator.js'; export { InMemoryHookMetricsRecorder, diff --git a/packages/objectql/src/validation/rule-relationship-traversal.test.ts b/packages/objectql/src/validation/rule-relationship-traversal.test.ts new file mode 100644 index 00000000000..2e3d90e5b5c --- /dev/null +++ b/packages/objectql/src/validation/rule-relationship-traversal.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { + collectPredicateRelationships, + evaluateValidationRules, +} from './rule-validator.js'; +import { ValidationError } from './record-validator.js'; + +// [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE +// LOAD, not inside a clocked `it()` body. +import '@objectstack/spec'; +import '@objectstack/formula'; + +/** + * [#18682] A validation rule on `crm_opportunity` that reads the owning + * account's category one hop through the `account` lookup — the card's own + * worked case, and the shape hotcrm #1915's gate is written in. + * + * The predicate expresses the FAILURE condition: a `partner` account may not + * carry an opportunity over 10000. + */ +const opportunity = { + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'currency', label: 'Amount' }, + account: { type: 'lookup', reference: 'crm_account', label: 'Account' }, + // A NON-reference object-valued field, to prove the collector leaves it be. + address: { type: 'object', label: 'Address' }, + }, + validations: [ + { + name: 'partner_cap', + type: 'script', + severity: 'error', + message: 'Partner accounts are capped at 10000.', + fields: ['amount'], + condition: "record.account.type == 'partner' && record.amount > 10000", + }, + ], +}; + +const evaluate = ( + data: Record, + related?: Record | null>, +): void => { + evaluateValidationRules(opportunity as any, data, 'insert', { related }); +}; + +describe('#18682 — collectPredicateRelationships: what the engine must preload', () => { + it('names the reference field and the related field the rule reads', () => { + const map = collectPredicateRelationships(opportunity as any); + expect([...map.keys()]).toEqual(['account']); + expect([...map.get('account')!]).toEqual(['type']); + }); + + it('is empty when no rule traverses — the engine then pays no extra read', () => { + const map = collectPredicateRelationships({ + fields: opportunity.fields, + validations: [{ name: 'cap', type: 'script', condition: 'record.amount > 10000' }], + } as any); + expect(map.size).toBe(0); + }); + + // The collector asks the spec's own arbiter what a field points at, so an + // object-valued field that traverses today keeps traversing and is never + // preloaded. + it('ignores a NON-reference field that is traversed', () => { + const map = collectPredicateRelationships({ + fields: opportunity.fields, + validations: [{ name: 'a', type: 'script', condition: "record.address.city == 'SF'" }], + } as any); + expect(map.size).toBe(0); + }); + + it('reaches a predicate nested inside a `conditional`', () => { + const map = collectPredicateRelationships({ + fields: opportunity.fields, + validations: [{ + name: 'wrapper', + type: 'conditional', + when: 'record.amount > 0', + then: { name: 'inner', type: 'cross_field', condition: "record.account.tier == 'gold'" }, + }], + } as any); + expect([...map.get('account')!]).toEqual(['tier']); + }); +}); + +describe('#18682 — the three acceptance outcomes (ADR-0136 D2.4)', () => { + // ── PASSES when the parent field matches ────────────────────────────────── + it('ACCEPTS the write when the parent field does not trip the rule', () => { + expect(() => + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: { id: 'acc_1', type: 'direct' } }), + ).not.toThrow(); + }); + + it('ACCEPTS when the parent matches but the local half does not', () => { + expect(() => + evaluate({ name: 'A', amount: 10, account: 'acc_1' }, { account: { id: 'acc_1', type: 'partner' } }), + ).not.toThrow(); + }); + + // ── REFUSES the write when it does not ──────────────────────────────────── + it('REFUSES the write when the parent field trips the rule', () => { + expect(() => + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: { id: 'acc_1', type: 'partner' } }), + ).toThrow(ValidationError); + }); + + it('the refusal carries the authored message, not a fault', () => { + try { + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: { id: 'acc_1', type: 'partner' } }); + throw new Error('expected a ValidationError'); + } catch (e) { + const err = e as ValidationError; + expect(err).toBeInstanceOf(ValidationError); + const detail = JSON.stringify((err as unknown as { errors?: unknown }).errors ?? err.message); + expect(detail).toContain('Partner accounts are capped at 10000.'); + expect(detail).not.toContain('could not be evaluated'); + } + }); + + // ── FAULTS LOUDLY when the acting user cannot read the parent field ─────── + // + // The engine reads the related row under the ACTING USER, so a row the user + // may not read arrives as `null` and is deliberately NOT overlaid. The stored + // id stays, the traversal faults with `No such key`, and an unevaluable + // validation predicate REJECTS the write (#4649). Never silently true, and + // never silently false either. + it('FAULTS LOUDLY and rejects when the related row is unreadable (null)', () => { + expect(() => + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: null }), + ).toThrow(ValidationError); + }); + + it('FAULTS LOUDLY and rejects when no binding was supplied at all', () => { + expect(() => evaluate({ name: 'A', amount: 50000, account: 'acc_1' })).toThrow(ValidationError); + }); + + it('the fault is reported AS a fault, naming the unevaluable rule', () => { + try { + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: null }); + throw new Error('expected a ValidationError'); + } catch (e) { + const err = e as ValidationError; + const detail = JSON.stringify((err as unknown as { errors?: unknown }).errors ?? err.message); + expect(detail).toContain('could not be evaluated'); + expect(detail).toContain('partner_cap'); + } + }); + + // The unreadable case must not be quietly waved through even when the LOCAL + // half of the predicate would have decided it. Short-circuit order is not a + // permission decision. + it('rejects on an unreadable parent even when the local half is false', () => { + expect(() => + evaluate( + { name: 'A', amount: 50000, account: 'acc_1' }, + { account: null }, + ), + ).toThrow(ValidationError); + }); +}); + +describe('#18682 — hydration is per-rule and never reaches the write payload', () => { + // The hazard this pins: `checkPredicate` is handed the engine's merged write + // payload. Hydrating it in place would send the expanded related RECORD to + // the driver in place of the foreign key. + it('does NOT mutate the record it was handed', () => { + const data = { name: 'A', amount: 10, account: 'acc_1' }; + evaluate(data, { account: { id: 'acc_1', type: 'partner' } }); + expect(data.account).toBe('acc_1'); + }); + + // Two rules on one object need not agree about how they read a field. The + // rule that traverses is hydrated; the rule that compares the bare id is not. + it('leaves a sibling rule that compares the BARE foreign key untouched', () => { + const schema = { + fields: opportunity.fields, + validations: [ + // traverses — gets the related record + { name: 'partner_cap', type: 'script', severity: 'error', message: 'capped', + condition: "record.account.type == 'partner' && record.amount > 10000" }, + // compares the bare id — must still see the stored id, so this FIRES + { name: 'blocked_account', type: 'script', severity: 'error', message: 'blocked account', + condition: "record.account == 'acc_1'" }, + ], + }; + try { + evaluateValidationRules(schema as any, { name: 'A', amount: 10, account: 'acc_1' }, 'insert', + { related: { account: { id: 'acc_1', type: 'partner' } } }); + throw new Error('expected a ValidationError'); + } catch (e) { + const detail = JSON.stringify((e as unknown as { errors?: unknown }).errors ?? (e as Error).message); + // The bare-id rule fired on the stored id — hydration did not leak into it. + expect(detail).toContain('blocked account'); + // …and it fired as a VIOLATION, not as an unevaluable fault. + expect(detail).not.toContain('could not be evaluated'); + } + }); + + // A rule that names no traversal must be handed exactly what it was handed + // before this change — that is what keeps every existing rule unaffected. + it('a non-traversing rule is unaffected by a binding being present', () => { + const schema = { + fields: opportunity.fields, + validations: [{ name: 'cap', type: 'script', severity: 'error', message: 'too big', + condition: 'record.amount > 10000' }], + }; + expect(() => + evaluateValidationRules(schema as any, { name: 'A', amount: 10, account: 'acc_1' }, 'insert', + { related: { account: { id: 'acc_1', type: 'partner' } } }), + ).not.toThrow(); + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index bb835196080..4bca55137a2 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -192,9 +192,10 @@ * evaluator once per matched row — one payload, N priors (#3106). */ -import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formula'; +import { ExpressionEngine, collectCelRootIdentifiers, analyzeRelationshipTraversals } from '@objectstack/formula'; +import type { RelationshipTraversalAnalysis } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; -import { AUDIT_PROVENANCE_FIELDS, RUNTIME_OWNED_FIELD_TYPES, resolveInjectedSystemColumns } from '@objectstack/spec/data'; +import { AUDIT_PROVENANCE_FIELDS, RUNTIME_OWNED_FIELD_TYPES, referenceTargetOf, resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { recordAdvisoryHit } from '@objectstack/core'; // [#8215] The canonical spelling of the primary-key column — the sanctioned use // of this registry ("what is the canonical spelling of the column that plays @@ -290,6 +291,9 @@ interface RuleContext { /** Locale + translation hooks: the BUILT-IN messages (#3957) and the * authored `rule.message` (#14253) — one hook, two message sources. */ messages: ValidationMessageContext | undefined; + /** [#18682] Related rows the engine resolved for this write, or undefined + * when it resolved none. Applied per rule — see {@link hydrateRelated}. */ + related: RelatedRecordBinding | undefined; } /** @@ -379,6 +383,27 @@ export interface EvaluateRulesOptions { * the same row, and the engine does not pay a second read to prove it. */ previousParent?: ParentBinding; + /** + * [#18682] The related records this write's predicates read ONE HOP through a + * reference field — `record.crm_account.type` on an opportunity. Keyed by the + * reference FIELD name; the value is the related row, or `null` when it could + * not be read. + * + * Only the engine owns a driver, so it resolves these and hands them over — + * the same division of labour `parent` follows. One difference, and it is + * deliberate: `parent` reads as SYSTEM because a master-detail lock is a + * property of the header's state, whereas these rows are read under the + * ACTING USER so the referenced object's RLS and FLS apply. A field the user + * may not read therefore arrives ABSENT, the predicate faults on it, and a + * faulting validation predicate REJECTS the write (#4649) — loudly, never + * silently true. + * + * ⛔ NOT applied to every rule alike. A rule is hydrated only for the + * reference fields ITS OWN condition reads through, because hydrating a field + * replaces its stored id with the related record: a sibling rule that + * compares the bare id must keep seeing the id. See {@link hydrateRelated}. + */ + related?: RelatedRecordBinding; /** * When true, `state_machine` rules are skipped entirely — both the * `initialStates` entry-point check on insert (#3165) and the transition @@ -415,6 +440,75 @@ export function needsPriorRecord( return !!(ruleNeeds || fieldsNeedPrior(objectSchema?.fields)); } +/** + * [#18682] The reference fields an object's PREDICATE rules read one hop + * through, and the related fields they name on each — everything the engine + * must preload before evaluating this object's validation rules, and nothing + * more. + * + * Returns an empty map when no rule traverses anything, which is the common + * case and is what lets the engine skip the extra read entirely: the N+1 bound + * is "one hop, only the named fields, only when a rule asks". + * + * ## Scope: `script` / `cross_field`, including inside `conditional` + * + * These are the rules {@link checkPredicate} evaluates, and they are fail-CLOSED + * (#4649) — the one policy under which an unreadable related field produces the + * loud refusal the permission rule requires. The field-level `requiredWhen` / + * `readonlyWhen` / option `visibleWhen` predicates are deliberately NOT + * collected here: they fail OPEN, so a related field the acting user cannot + * read would silently not enforce their gate, which is the opposite of what a + * permission-sensitive read must do. They are their own card. + * + * ## Only REFERENCE-typed fields + * + * Judged with the spec's own `REFERENCE_VALUE_TYPES` through + * {@link referenceTargetOf}, the same arbiter the `$expand` gate and the engine + * already ask, so "what does this field point at" cannot answer differently + * here than it does one layer down. `record.address.city` on an object-valued + * field is left alone — it traverses today and keeps traversing. + */ +export function collectPredicateRelationships( + objectSchema: { validations?: unknown[]; fields?: Record } | undefined | null, +): Map> { + const out = new Map>(); + const rules = objectSchema?.validations; + if (!Array.isArray(rules) || rules.length === 0) return out; + const fields = objectSchema?.fields; + if (!fields) return out; + + const addFrom = (cond: unknown): void => { + const source = typeof cond === 'string' + ? cond + : (cond && typeof cond === 'object' ? (cond as Expression).source : undefined); + if (typeof source !== 'string' || !source) return; + const analysis = analysisFor(source); + if (!analysis) return; + for (const [field, related] of analysis.traversals) { + // `referenceTargetOf` answers undefined for a non-reference field AND for + // a reference field naming no target — both mean "nothing to preload". + if (!referenceTargetOf(fields[field])) continue; + let set = out.get(field); + if (!set) out.set(field, (set = new Set())); + for (const name of related) set.add(name); + } + }; + + const visit = (rule: unknown, depth: number): void => { + if (!rule || typeof rule !== 'object' || depth > 8) return; + const r = rule as { type?: unknown; condition?: unknown; then?: unknown; otherwise?: unknown }; + if (r.type === 'script' || r.type === 'cross_field') addFrom(r.condition); + // A `conditional` wraps the rules it guards; its own `when` is evaluated + // by a different seam, so only the wrapped rules are collected here. + if (r.type === 'conditional') { + visit(r.then, depth + 1); + visit(r.otherwise, depth + 1); + } + }; + for (const rule of rules) visit(rule, 0); + return out; +} + /** * The master-detail header a `parent`-scoped predicate reads (#4889). `null` * means "this operation could not resolve one" — which is NOT the same as @@ -423,6 +517,15 @@ export function needsPriorRecord( */ export type ParentBinding = Record | null | undefined; +/** + * [#18682] Reference FIELD name → the related row, or `null` when it could not + * be read (no id stored, row gone, or the acting user may not read it). The + * three collapse on purpose: every one of them means "this predicate cannot be + * answered from data the caller is allowed to see", and the predicate must + * fault rather than quietly pick a verdict. + */ +export type RelatedRecordBinding = Readonly | null>>; + /** * The two CEL roots a field `readonlyWhen` predicate reads — `record` (the * prior row overlaid with the PATCH) and `previous` — made TOTAL over the @@ -2346,7 +2449,7 @@ export function evaluateValidationRules( // and update: what a predicate can read is the object's DECLARED shape, not // whatever subset of columns this driver happened to return. if (groundTruth) materializeDeclaredFields(merged, fields); - const ctx: RuleContext = { data, merged, previous, mode, logger: opts.logger, fields, messages: opts.messages }; + const ctx: RuleContext = { data, merged, previous, mode, logger: opts.logger, fields, messages: opts.messages, related: opts.related }; const errors: FieldValidationError[] = []; @@ -2561,7 +2664,7 @@ function evaluateRule(rule: BaseRule, ctx: RuleContext): FieldValidationError | return checkStateMachine(rule as StateMachineRule, ctx.mode, ctx.data, ctx.previous, ctx); case 'script': case 'cross_field': - return checkPredicate(rule as PredicateRule, ctx.merged, ctx.previous, ctx.logger, ctx.messages); + return checkPredicate(rule as PredicateRule, ctx.merged, ctx.previous, ctx.logger, ctx.messages, ctx.related); case 'format': return checkFormat(rule as FormatRule, ctx.data, ctx.logger, ctx.messages); case 'json_schema': @@ -2710,16 +2813,88 @@ function unevaluableRuleError( * declared field — is a broken rule, and a broken validation is **fail-closed** * (#4649): it rejects the write rather than waving it through. */ +/** + * [#18682] The parsed hop analysis for one authored source, memoised. + * + * Authored predicates are a small closed set per deployment, so this is bounded + * in practice; the cap is a guard against a caller that synthesises sources, + * and overflowing it costs a re-parse, never a wrong answer. + */ +const traversalAnalysisCache = new Map(); +const TRAVERSAL_CACHE_CAP = 512; + +function analysisFor(source: string): RelationshipTraversalAnalysis | null { + const hit = traversalAnalysisCache.get(source); + if (hit !== undefined) return hit; + const analysis = analyzeRelationshipTraversals(source); + if (traversalAnalysisCache.size < TRAVERSAL_CACHE_CAP) { + traversalAnalysisCache.set(source, analysis); + } + return analysis; +} + +/** + * [#18682] Overlay the related rows THIS predicate reads through onto a COPY of + * the record. + * + * ## Why a copy, always + * + * The record handed to a rule is the engine's merged write payload. Hydrating + * it in place would replace a stored foreign key with the related RECORD and + * then hand that to the driver — writing an expanded object into the column. + * The copy is shallow, which is enough: only the top-level reference keys are + * replaced, and nothing mutates the related rows themselves. + * + * ## Why per RULE, and not once per write + * + * Hydrating `crm_account` makes `record.crm_account` the related record, so a + * rule comparing the bare id would stop matching. Rules on one object do not + * have to agree about how they read a field, so each rule is hydrated for + * exactly the fields ITS OWN condition reads through. A rule that never + * traverses is handed the record untouched — byte-for-byte the pre-#18682 + * input, which is what keeps this change invisible to every existing rule. + * + * ## Absence is left absent, deliberately + * + * A field with no entry, or an entry of `null`, is NOT overlaid: the stored id + * stays, `record..` faults with `No such key`, and an unevaluable + * validation predicate rejects the write (#4649). That is the loud failure the + * permission rule requires — a related row the acting user may not read must + * never resolve to a quiet verdict. + */ +function hydrateRelated( + record: Record, + source: string, + related: RelatedRecordBinding | undefined, +): Record { + if (!related) return record; + const analysis = analysisFor(source); + if (!analysis || analysis.traversals.size === 0) return record; + + let copy: Record | undefined; + for (const field of analysis.traversals.keys()) { + const row = related[field]; + if (row == null) continue; + if (!copy) copy = { ...record }; + copy[field] = row; + } + return copy ?? record; +} + function checkPredicate( rule: PredicateRule, record: Record, previous: Record | undefined, logger: EvaluateRulesOptions['logger'], messages?: ValidationMessageContext, + related?: RelatedRecordBinding, ): FieldValidationError | null { const expr = toExpression(rule.condition); + const scopeRecord = typeof expr.source === 'string' + ? hydrateRelated(record, expr.source, related) + : record; const result = ExpressionEngine.evaluate(expr, { - record, + record: scopeRecord, previous: previous ?? undefined, }); From d74371bcfabe062fe23aeaf72e5d5f37b876b2a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:14:11 +0000 Subject: [PATCH 03/47] feat(objectql): resolve predicate relationships at every validation seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One batched resolver serves the insert, single-update and bulk-update seams: collect the reference fields the object's predicate rules traverse, read the related rows ONCE per field under the CALLER's context so the referenced object's CRUD gate, RLS and FLS apply, and hand them to the evaluator. Free when no rule traverses — no query and no closure work. `needsPriorRecord` now counts a traversing rule. The hop is taken from the foreign KEY and a PATCH that does not touch that key does not carry it, so without the prior row there is no id to resolve and the rule would fault and reject a write it should have accepted. Counting it keeps the bulk path's no-prior branch unreachable for such an object, the same argument #4977 makes for `parent`. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 124 +++++++++++++++++- .../objectql/src/validation/rule-validator.ts | 11 +- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1fa48b71830..e12147b4d5b 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -210,7 +210,8 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container' 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 { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; +import type { RelatedRecordBinding } from './validation/rule-validator.js'; +import { collectPredicateRelationships, evaluateValidationRules, 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 @@ -6886,6 +6887,99 @@ export class ObjectQL implements IObjectQLEngine { }; } + /** + * [#18682] Resolve the related rows this object's PREDICATE rules read one + * hop through a reference field, for a whole batch of rows at once. + * + * Returns a per-row lookup the validation seams hand to + * `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 as the ACTING USER, deliberately unlike `parent` + * + * {@link resolveMasterDetailParent} reads as SYSTEM because a master-detail + * lock is a property of the header's state, not of the caller's visibility of + * it. This read is the opposite case: the value lands in a predicate whose + * verdict the caller can observe through the accept/reject of their own + * write, so it goes through the engine's own `find` path under the caller's + * context and the referenced object's CRUD gate, RLS and FLS all apply. A row + * — or a field — the caller may not read therefore does not arrive. + * + * ## An unresolved row is left ABSENT, and that is the loud answer + * + * No id, row gone, refused by the security layer, read threw: all four leave + * the field unbound. The stored id stays in the record, the traversal faults + * with `No such key`, and an unevaluable validation predicate REJECTS the + * write (#4649). ⛔ Never silently true, and never silently false — the two + * verdicts a security-relevant absence must not be allowed to pick between. + * + * The projection always names `id` alongside the fields the rules read: + * the map below is keyed on `row.id`, and a projection that omitted it would + * build an EMPTY map and leave every row unbound (#7537's shape, one seam + * over). + */ + private async resolvePredicateRelated( + schema: any, + rows: ReadonlyArray | undefined | null>, + context: unknown, + ): Promise<((row: Record | undefined | null) => RelatedRecordBinding | undefined)> { + const unbound = () => undefined; + const wanted = collectPredicateRelationships(schema); + if (wanted.size === 0) return unbound; + + const fields = (schema?.fields ?? {}) as Record; + // fk field -> (id -> related row) + const resolved = new Map>>(); + + for (const [fk, namedFields] of wanted) { + const target = referenceTargetOf(fields[fk]); + if (!target) continue; + const ids = new Set(); + for (const row of rows) { + const value = row?.[fk]; + // A multi-value reference cannot be one hop: `record..` on a + // list has no single related record to read, so it is left unbound and + // the predicate faults rather than picking an element. + if (value == null || Array.isArray(value) || typeof value === 'object') continue; + ids.add(String(value)); + } + if (ids.size === 0) continue; + try { + const related = await this.find(target, { + where: { id: { $in: [...ids] } }, + fields: [...new Set(['id', ...namedFields])], + context, + } as any) as Array>; + const byId = new Map>(); + for (const row of Array.isArray(related) ? related : []) { + if (row?.id != null) byId.set(String(row.id), row); + } + resolved.set(fk, byId); + } catch (err) { + // Left unbound on purpose — see the docblock. Logged at `warn` because + // the write is still REJECTED downstream, loudly, in the caller's own + // response: nothing is silently lost here. + this.logger?.warn?.('predicate relationship lookup failed — the related field stays unbound and the rule will reject the write', { + object: target, field: fk, error: err, + }); + } + } + if (resolved.size === 0) return unbound; + + return (row) => { + if (!row) return undefined; + const binding: Record | null> = {}; + for (const [fk, byId] of resolved) { + const value = row[fk]; + if (value == null || Array.isArray(value) || typeof value === 'object') { binding[fk] = null; continue; } + binding[fk] = byId.get(String(value)) ?? null; + } + return binding; + }; + } + /** * [#6457] Make a resolved master-detail header TOTAL over the MASTER * object's declared fields, so a `parent.` predicate is evaluable @@ -11292,12 +11386,16 @@ export class ObjectQL implements IObjectQLEngine { const insertParentForRow = hasParentScopedRequiredWhen(schemaForValidation as any) ? await this.resolveMasterDetailParents(schemaForValidation, null, rows) : undefined; + // [#18682] The related rows this object's predicate rules read one hop + // through a reference field. Batched across the whole insert, and free + // when no rule traverses. Read under the CALLER's context, not system. + const insertRelatedForRow = await this.resolvePredicateRelated(schemaForValidation, rows, opCtx.context); for (let i = 0; i < rows.length; i++) { if (rowErrors[i] !== undefined) continue; try { normalizeMultiValueFields(schemaForValidation, rows[i]); validateRecord(schemaForValidation, rows[i], 'insert', { mediaValueShapeStrict, valueShapeStrict, messages: msgCtx, onAdmittedValueShapeViolation }); - evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]) }); + evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]), related: insertRelatedForRow(rows[i]) }); await this.assertReferencesResolve( schemaForValidation, rows[i], suppliedPerRow[i], opCtx.context, msgCtx, ); @@ -12620,6 +12718,12 @@ export class ObjectQL implements IObjectQLEngine { // field is read-only for this record's state, so the incoming // change is ignored (the persisted value is kept). const preRoWhen = hookContext.input.data as Record; + // [#18682] The reference FK a predicate traverses may come from + // the PATCH or from the stored row, so the id is read off the + // same merged view `evaluateValidationRules` will evaluate. + const relatedForUpdate = (await this.resolvePredicateRelated( + updateSchema, [{ ...(priorRecord ?? {}), ...preRoWhen }], opCtx.context, + ))({ ...(priorRecord ?? {}), ...preRoWhen }); // [#4889] A `parent`-scoped predicate ("once the header invoice // is Paid, its lines are frozen") needs the master-detail header // bound as `parent`. Only the engine can fetch it, so the strip @@ -12708,7 +12812,7 @@ export class ObjectQL implements IObjectQLEngine { // "you sent a read-only field" should not depend on whether some // other field also failed a business rule. assertNoStrictDrops(); - 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 }); + 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 }); // [#4441] A repoint is as capable of dangling as an initial link. await this.assertReferencesResolve( updateSchema, hookContext.input.data as Record, @@ -12897,10 +13001,22 @@ export class ObjectQL implements IObjectQLEngine { // the payload-only evaluation covers format / json_schema / // non-prior conditional at zero fetch cost. const bulkEvalUser = this.buildEvalUser(opCtx.context); + // [#18682] One batched resolution for the whole matched set, off + // the same merged view each row will be evaluated as. The + // no-prior branch below needs none: `needsPriorRecord` counts a + // traversing rule, so an object with one never reaches it. + const bulkPatch = hookContext.input.data as Record; + const bulkRelatedForRow = rulesNeedRows + ? await this.resolvePredicateRelated( + updateSchema, + (priorRows ?? []).map((r) => ({ ...(r ?? {}), ...bulkPatch })), + opCtx.context, + ) + : undefined; if (rulesNeedRows) { for (const row of priorRows ?? []) { try { - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: parentForRow?.(row), previousParent: previousParentForRow?.(row) }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx, parent: parentForRow?.(row), previousParent: previousParentForRow?.(row), related: bulkRelatedForRow?.({ ...(row ?? {}), ...bulkPatch }) }); } catch (err) { if (err instanceof ValidationError && row?.id != null) { throw new ValidationError(err.fields.map((f) => ({ ...f, message: `${f.message} (record ${String(row.id)})` }))); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 4bca55137a2..77f25648d74 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -437,7 +437,16 @@ export function needsPriorRecord( ): boolean { const rules = objectSchema?.validations; const ruleNeeds = Array.isArray(rules) && rules.some((r) => ruleNeedsPrior(r)); - return !!(ruleNeeds || fieldsNeedPrior(objectSchema?.fields)); + // [#18682] A rule that reads ONE HOP through a reference field needs the + // prior row too, and for a reason the `previous`-reading rules do not share: + // the hop is taken from the foreign KEY, and a PATCH that does not touch that + // key does not carry it. Without the prior row the engine has no id to + // resolve, the related field arrives absent, and the rule faults and rejects + // a write it should have accepted. Counting it here is what keeps the bulk + // path's no-prior branch unreachable for such an object — the same argument + // #4977 makes for `parent`, which is bound only on the per-row branch. + const traverses = collectPredicateRelationships(objectSchema).size > 0; + return !!(ruleNeeds || traverses || fieldsNeedPrior(objectSchema?.fields)); } /** From bf12f83c9a822077d9b627e7b7521447305dd0cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:19:54 +0000 Subject: [PATCH 04/47] feat(formula): refuse the traversal shapes that cannot be served as written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authoring-time half. Two shapes are refused with a prescription rather than served half-right: a reference field read BOTH through the relationship and as a plain value (hydrating it silently turns the value comparison false), and a read deeper than the one hop predicates resolve. Neither removes anything an author has working — measured on this front end, both fault at evaluation today, and a faulting validation predicate rejects the write. The refusal replaces a per-row runtime fault with one loud message naming the repair. Gated on `fieldTypes`, which is what tells a REFERENCE field from an object-valued one: `record.address.city` traverses today and keeps traversing. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/relationship-traversal.test.ts | 56 +++++++++++++++++++ packages/formula/src/validate.ts | 17 ++++++ 2 files changed, 73 insertions(+) diff --git a/packages/formula/src/relationship-traversal.test.ts b/packages/formula/src/relationship-traversal.test.ts index 2dc9334f2a9..884722e58c9 100644 --- a/packages/formula/src/relationship-traversal.test.ts +++ b/packages/formula/src/relationship-traversal.test.ts @@ -6,6 +6,7 @@ import { analyzeRelationshipTraversals, findTraversalConflicts, } from './relationship-traversal'; +import { validateExpression } from './validate'; /** Shorthand: the related fields named on one FK, as a sorted array. */ const hop = (source: string, field: string): string[] => { @@ -139,3 +140,58 @@ describe('findTraversalConflicts — what the authoring layer refuses', () => { expect(findTraversalConflicts(a, isLookup)).toEqual([]); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// The authoring-time half: the refusal an author actually meets. `fieldTypes` +// is what tells a REFERENCE field from an object-valued one, and +// `@objectstack/lint` already supplies it at every record-scoped site. +// ───────────────────────────────────────────────────────────────────────────── +describe('validateExpression — refuses the unserviceable traversal shapes', () => { + const schema = { + objectName: 'crm_opportunity', + fields: ['account', 'amount', 'address'], + fieldTypes: { account: 'lookup', amount: 'currency', address: 'object' }, + scope: 'record' as const, + }; + + it('accepts the plain one-hop traversal — the shape this card adds', () => { + const r = validateExpression('predicate', "record.account.type == 'partner'", schema); + expect(r.ok).toBe(true); + }); + + it('refuses a reference field read BOTH through the relationship and bare', () => { + const r = validateExpression( + 'predicate', + "record.account.type == 'partner' && record.account == 'acc_1'", + schema, + ); + expect(r.ok).toBe(false); + const message = r.errors.map((e) => e.message).join('\n'); + expect(message).toContain('record.account.id'); + }); + + it('refuses a read deeper than one hop', () => { + const r = validateExpression('predicate', 'record.account.owner.email != null', schema); + expect(r.ok).toBe(false); + expect(r.errors.map((e) => e.message).join('\n')).toContain('ONE hop'); + }); + + // The narrowing must not reach a field whose traversal works today. + it('leaves an object-valued field alone', () => { + const r = validateExpression( + 'predicate', + "record.address.city == 'SF' && record.address != null", + schema, + ); + expect(r.ok).toBe(true); + }); + + it('checks nothing when the caller supplies no fieldTypes', () => { + const r = validateExpression( + 'predicate', + "record.account.type == 'partner' && record.account == 'acc_1'", + { objectName: 'crm_opportunity', fields: ['account'], scope: 'record' }, + ); + expect(r.ok).toBe(true); + }); +}); diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index e751cb547c3..2b172d5e935 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -26,6 +26,8 @@ import { type FieldCelType, } from './cel-engine'; import { templateEngine } from './template-engine'; +import { analyzeRelationshipTraversals, findTraversalConflicts } from './relationship-traversal'; +import { REFERENCE_VALUE_TYPES } from '@objectstack/spec/data'; // #13594 — the one reader of cel-js's `found no matching overload for '…'` // template. Both this module (which asks whether the name is ADVERTISED, to word // a hint) and `firstUnknownFunctionCall` (which asks whether the environment @@ -853,6 +855,21 @@ export function validateExpression( } } } + // [#18682] Relationship traversal: refuse the shapes that cannot be served + // as written. Needs `fieldTypes` to tell a REFERENCE field from an + // object-valued one — `record.address.city` traverses today and must keep + // traversing — so a caller that supplies none is not checked, exactly like + // the type-soundness pass above. + if (schema?.fieldTypes) { + const analysis = analyzeRelationshipTraversals(source); + if (analysis) { + const conflicts = findTraversalConflicts( + analysis, + (field) => REFERENCE_VALUE_TYPES.has(schema.fieldTypes![field] ?? ''), + ); + for (const conflict of conflicts) errors.push({ source, message: conflict.message }); + } + } return { ok: errors.length === 0, errors, warnings }; } From 2484484a7b6c111fcc760dba62e4a503a4b3b508 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:39:48 +0000 Subject: [PATCH 05/47] feat(showcase): enforce the churned-account rule on the server, not just the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invoice `account` picker already scopes churned accounts out of the dropdown with `lookupFilters`, which is exactly why the rule earns its place: a picker filter is a UI affordance, not a server guarantee. A write that never touches the picker — REST, an import, a flow, an agent — reached the same column with no scoping at all. Adds the ADR-0136 D2.4 acceptance item under `records-forms` covering the three ruled outcomes: the write passes when the parent field matches, is refused with the AUTHORED message when it does not, and faults loudly when the acting user cannot read the parent. Plus the changeset and both locales' bundle entries for the new rule message. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 72 +++++++++++++++++ .../areas/records-forms.json | 77 +++++++++++++++++++ .../src/data/objects/invoice.object.ts | 25 ++++++ .../src/system/translations/index.ts | 15 ++++ 4 files changed, 189 insertions(+) create mode 100644 .changeset/18682-predicate-relationship-traversal.md diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md new file mode 100644 index 00000000000..f1cdabfab26 --- /dev/null +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -0,0 +1,72 @@ +--- +'@objectstack/formula': minor +'@objectstack/objectql': minor +--- + +A validation rule can read one hop through a lookup — `record.account.type` on an opportunity resolves the owning account's field instead of faulting (#18682) + +Clause-②: yes (widening) + +A validation predicate could only read the record it guards. A `lookup` / +`master_detail` field carries an **id**, so the natural cross-object rule — +"a partner account may not carry an opportunity over 10000" — faulted with +`runtime: No such key: type`, and because a broken validation is fail-closed it +rejected every write on the object. The capability mainstream platforms provide +as a matter of course could not be authored at all. + +### What you can write now + +```ts +validations: [{ + name: 'partner_cap', + type: 'script', + message: 'Partner accounts are capped at 10000.', + condition: "record.account.type == 'partner' && record.amount > 10000", +}] +``` + +One hop, through any reference-typed field (`lookup`, `master_detail`, `user`, +`tree`). The engine reads the related row before evaluating and binds it in +place of the id, so `record..` resolves. + +### It is data pinned BEFORE evaluation, not a query from inside CEL + +There is no `os.lookup(...)` / `os.exists` / `os.count` — those stay removed. +The engine statically analyses the predicate, learns exactly which reference +fields it reads through and which related fields it names, and loads those +**before** evaluation. Every registered function stays pure once `now` is +pinned, so `objectstack build` artifacts stay byte-stable. + +The cost is bounded by construction: one hop, only the fields a rule actually +names, one batched read per reference field per write, and nothing at all when +no rule traverses. + +### Permission semantics — absent, loudly + +The related rows are read under the **acting user**, through the engine's own +read path, so the referenced object's CRUD gate, RLS and FLS all apply. A row +or a field the caller may not read therefore does not arrive: the stored id +stays, the traversal faults, and the write is **rejected**. A rule that guards +data the caller cannot see never silently passes — and never silently fails +either. + +### Two shapes are refused at authoring time, with a prescription + +Both fault at evaluation today, so neither removes anything that works: + +| Shape | Why | Write instead | +| --- | --- | --- | +| `record.account.type == 'x' && record.account == 'acc_1'` | reading through the relationship resolves `record.account` to the related RECORD, so the id comparison would stop matching — silently | `record.account.id == 'acc_1'` for the value comparison | +| `record.account.owner.email` | a second hop is not loaded | denormalise onto `account`'s object, or read it in a hook | + +A field that is **not** reference-typed is untouched: `record.address.city` on +an object-valued field traverses today and keeps traversing. + +### Scope + +Object validation rules (`script` / `cross_field`) — the seam that is +fail-closed, and therefore the only one where an unreadable related field can +produce the loud refusal the permission rule above requires. The field-level +`requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open** +and are deliberately not covered here; RLS predicates are out too. Depth is one +hop. diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 611bb7d610a..f99c6d06d22 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -4100,6 +4100,83 @@ "ref": "#sweep-2026-08-30" } ] + }, + { + "id": "records-forms.predicate-relationship-traversal", + "title": "A validation rule reads a parent field one hop through a lookup", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "a member who cannot read showcase_account — for the unreadable-parent clause" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_invoice — carries the script validation no_invoice_for_churned_account, whose condition reads record.account.status one hop through the `account` lookup (examples/app-showcase/src/data/objects/invoice.object.ts)", + "showcase_account — the referenced object; its `status` select carries the value 'churned' (examples/app-showcase/src/data/objects/account.object.ts)", + "at least one churned account and one non-churned account to invoice against" + ], + "knownGaps": [ + "The unreadable-parent clause needs a persona who cannot read showcase_account. showcase_account is public_read_write in the showcase app, so no seeded persona fails to read it and that clause scores blocked(fixture) there — it is covered by unit test in packages/objectql (rule-relationship-traversal.test.ts) until a recipe supplies such a persona." + ] + }, + "steps": [ + "POST /api/v1/data/showcase_invoice with `account` pointing at a NON-churned account", + "POST /api/v1/data/showcase_invoice with `account` pointing at a CHURNED account", + "PATCH an existing invoice to REPOINT it at a churned account (the update path reads the FK off the merged record, not the patch alone)", + "as the persona who cannot read showcase_account, POST an invoice against any account" + ], + "acceptance": [ + { + "clause": "the write PASSES when the parent field does not trip the rule — the traversal resolves, it does not fault", + "oracle": "api", + "verify": "2xx and the row is readable; the response carries no validation error, and in particular no 'could not be evaluated' fault", + "evidence": "the create response and the read-back" + }, + { + "clause": "the write is REFUSED when the parent field trips the rule, carrying the AUTHORED message, not a fault", + "oracle": "api", + "verify": "400 VALIDATION_FAILED naming the rule no_invoice_for_churned_account, with the authored sentence about reactivating the account — a refusal that reads 'could not be evaluated' is a FAIL, not a pass", + "evidence": "the rejection envelope" + }, + { + "clause": "a REPOINT on update is judged against the account the write lands on", + "oracle": "api", + "verify": "the PATCH onto a churned account is refused even though the patch alone carries no status, because the FK is read off the merged record", + "evidence": "the patch response" + }, + { + "clause": "a parent the acting user CANNOT read FAULTS LOUDLY and rejects the write — never silently allowed", + "oracle": "api", + "verify": "the write is REJECTED with the unevaluable-rule envelope; a 2xx here is the defect this clause exists to catch", + "evidence": "the rejection envelope for the restricted persona" + } + ], + "negative": [ + "a 2xx on the churned-account create is a FAIL — the rule did not run", + "a rejection whose text is the unevaluable-rule fault on the READABLE-parent cases is a FAIL: the related row was not preloaded and the rule is rejecting every write, which is the pre-capability behaviour", + "a 2xx for the persona who cannot read the parent is the WORST failure here — a rule guarding data the caller cannot see must never silently pass" + ], + "traps": [ + "seed-data-thin" + ], + "source": [ + "examples/app-showcase/src/data/objects/invoice.object.ts (no_invoice_for_churned_account)", + "packages/objectql/src/validation/rule-validator.ts (checkPredicate, hydrateRelated, collectPredicateRelationships)", + "packages/formula/src/relationship-traversal.ts (which hops a predicate names)" + ], + "history": [ + { + "revision": 1, + "date": "2026-09-22", + "change": "initial — ADR-0136 D2.4 acceptance for predicate relationship traversal; the capability's three ruled outcomes (passes / refuses / faults loudly on an unreadable parent)", + "ref": "claude/issue-18682-predicate-relationship-traversal" + } + ] } ] } \ No newline at end of file diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index 79a8c242141..63b860eae57 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -160,6 +160,31 @@ export const Invoice = ObjectSchema.create({ summaryOperations: { object: 'showcase_invoice_line', field: 'amount', function: 'sum' }, }), }, + + validations: [ + { + // RELATIONSHIP TRAVERSAL — the rule reads a field of the record this one + // POINTS AT, one hop through the `account` lookup. + // + // The picker above already scopes churned accounts out of the dropdown + // with `lookupFilters`, and that is exactly why this rule earns its place: + // a picker filter is a UI affordance, not a server guarantee. A write that + // never touches the picker — the REST API, an import, a flow, an AI agent + // — reaches the same column with no such scoping. Declared in the UI and + // unenforced on the server is the shape the platform refuses to ship. + // + // The predicate states the FAILURE condition. The engine reads the + // account under the ACTING USER before evaluating, so a rep who cannot + // read the account does not get a quiet pass: the rule faults and the + // write is rejected. + type: 'script' as const, + name: 'no_invoice_for_churned_account', + label: 'No Invoice For Churned Account', + description: 'An invoice cannot be issued against an account that has churned.', + condition: P`record.account.status == 'churned'`, + message: 'This account has churned — reactivate it before invoicing.', + }, + ], }); /** Invoice line item — owned by its invoice, entered inline in the grid. */ diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 87519c6f6d9..b7270b2d709 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -233,6 +233,15 @@ export const ShowcaseTranslationBundle = { paid_on: { label: 'Paid On' }, total: { label: 'Total' }, }, + // The object's ONE authored rule message, on the #14253 channel. The + // `en` entry is the authored sentence VERBATIM — the bundle WINS over + // `rule.message` in every locale, so a drifted entry would turn the + // object's own sentence into dead text no reader ever sees. + _validations: { + no_invoice_for_churned_account: { + message: 'This account has churned — reactivate it before invoicing.', + }, + }, }, // Translated at birth, like `globalActions` below and for the same // reason: `incurred_at` is a NEW declared label (objectui#3569's inline- @@ -644,6 +653,12 @@ export const ShowcaseTranslationBundle = { paid_on: { label: '付款日期' }, total: { label: '合计' }, }, + // The zh-CN mirror of the `en` `_validations` block above. + _validations: { + no_invoice_for_churned_account: { + message: '该客户已流失 —— 请先将其恢复为活跃状态,然后再开具发票。', + }, + }, }, // See the `en` side for why this entry translates exactly ONE field and // no more (check-i18n-coverage is a two-sided ratchet). From 0065a406b822361bc81bc0c49a7fc5a6d9436f85 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 13:12:22 +0000 Subject: [PATCH 06/47] fix(objectql): carry the declared query-options type on the related-row read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new batched read went through `as any`, which grew the query-options-erasure ratchet on engine.ts from 8 to 9. The options bag is declared — `EngineQueryOptions` — so it is named rather than erased. The ratchet's unswept count drops 68 to 67. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e12147b4d5b..0c49acc0342 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -6947,11 +6947,12 @@ export class ObjectQL implements IObjectQLEngine { } if (ids.size === 0) continue; try { - const related = await this.find(target, { + const query: EngineQueryOptions = { where: { id: { $in: [...ids] } }, fields: [...new Set(['id', ...namedFields])], - context, - } as any) as Array>; + context: context as EngineQueryOptions['context'], + }; + const related = await this.find(target, query) as Array>; const byId = new Map>(); for (const row of Array.isArray(related) ? related : []) { if (row?.id != null) byId.set(String(row.id), row); From 45db6b8c5a63810fc2f85721d104b821fb1e159c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 14:04:36 +0000 Subject: [PATCH 07/47] fix(objectql,formula): refuse in the engine, name the related object, decide readability first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-review rework, findings 2-7. ADR: the acceptance cited ADR-0136, which names no record. ADR-0137 (predicate fault semantics are contract) is what governs the fault this adds a cause to. Fail closed in the ENGINE, not only in lint (ADR-0137 D1, ADR-0124). No runtime package imports lint, so the mixed shape — a reference field read both through the relationship and as a value — was rejected before this capability and would have been accepted after it, with the bare arm silently false, on every path that authors metadata without lint. `checkPredicate` now refuses it itself. The comment claiming an engine publish path already refused is deleted; there is none. Name the RELATED object in the refusal (ADR-0137 D2). The generic prescription said the field "this object does not declare" — on a traversal the field IS declared, on another object, and an author following it added a bogus column to the wrong file. Decide readability BEFORE evaluation. A related column the caller may read but which is empty now materialises to null and evaluates; one the caller may not read refuses. A driver returns both as the same missing key, so the engine asks `getReadableFields` through a new seam the security plugin fills. This also stops the verdict depending on which operator was written: `has()`, `.?` and `[?]` read a missing key as an ordinary false, and all three are now seen by the analysis and refused the same way. Scope the authoring refusal to where hydration happens. Gating on `fieldTypes` reached nine seams — field requiredWhen/readonlyWhen, option visibleWhen, action visible/disabled, sharing (RLS), hooks, flow node and edge conditions — where nothing hydrates and the prescription is false. Now an explicit opt-in, set by lint at the validation-rule condition alone. Also: the dry-run validate() seam resolves related rows, so preview and write agree; and the relationship collector checks the dialect. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../areas/records-forms.json | 2 +- packages/formula/src/index.ts | 16 +- .../src/relationship-traversal.test.ts | 45 +++- .../formula/src/relationship-traversal.ts | 41 +++- packages/formula/src/validate.ts | 38 ++- packages/lint/src/validate-expressions.ts | 19 +- packages/objectql/src/engine.ts | 131 ++++++++-- .../rule-relationship-traversal.test.ts | 134 +++++++++- .../objectql/src/validation/rule-validator.ts | 231 ++++++++++++++++-- .../plugin-security/src/security-plugin.ts | 17 ++ 10 files changed, 605 insertions(+), 69 deletions(-) diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index f99c6d06d22..923586f8539 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -4173,7 +4173,7 @@ { "revision": 1, "date": "2026-09-22", - "change": "initial — ADR-0136 D2.4 acceptance for predicate relationship traversal; the capability's three ruled outcomes (passes / refuses / faults loudly on an unreadable parent)", + "change": "initial — acceptance for predicate relationship traversal under ADR-0137 D2 (a field-rule predicate that faults refuses the submit and names the field and the rule); the capability's three ruled outcomes (passes / refuses / faults loudly on an unreadable parent)", "ref": "claude/issue-18682-predicate-relationship-traversal" } ] diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index a46917c6ea7..d5adab3b9eb 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -73,12 +73,16 @@ export type { PermissionBinding } from './stdlib'; // parses, `can` answers from it, and the answer is a confident silent denial. export { toEvalPermissions } from './eval-permissions'; // #18682 — relationship traversal in predicates. The static half: which -// reference fields an authored predicate reads through, and which shapes the -// authoring layer must refuse. Published because two layers ask the same -// question and must get the same answer — `@objectstack/lint` (and the engine's -// publish path) refuse the conflicting shapes, and the ObjectQL engine uses the -// hop list to preload exactly those related fields before evaluation. A second -// implementation in either consumer is how the accepted set forks. +// reference fields an authored predicate reads through, and which shapes cannot +// be served as written. Published because two layers ask the same question and +// must get the same answer: `@objectstack/lint` reports the conflicting shapes +// to the AUTHOR, and ObjectQL's `checkPredicate` refuses them again in the +// ENGINE — independently, because no runtime package imports lint, so metadata +// authored through Studio, written straight to `sys_metadata` or produced by an +// agent never meets the author-side check (ADR-0124: a client-side direction is +// never the whole answer). The engine also uses the hop list to preload exactly +// the named related fields before evaluation. A second implementation of the +// analysis in either consumer is how the two accepted sets fork. export { analyzeRelationshipTraversals, findTraversalConflicts, diff --git a/packages/formula/src/relationship-traversal.test.ts b/packages/formula/src/relationship-traversal.test.ts index 884722e58c9..0856935fb57 100644 --- a/packages/formula/src/relationship-traversal.test.ts +++ b/packages/formula/src/relationship-traversal.test.ts @@ -71,6 +71,22 @@ describe('analyzeRelationshipTraversals — which hops an expression names', () expect(hop("record.a == 1 || record.crm_account.tier > 2", 'crm_account')).toEqual(['tier']); }); + // Every spelling of the same read must be seen, or the check is side-stepped + // by writing `.?` — and the optional forms are exactly the ones an author + // reaches for on a nullable lookup. + it.each([ + ['optional selection', "record.crm_account.?type == 'partner'"], + ['optional index', "record.crm_account[?'type'] == 'partner'"], + ['plain index', "record.crm_account['type'] == 'partner'"], + ])('sees a traversal written as %s', (_name, source) => { + expect(hop(source, 'crm_account')).toEqual(['type']); + }); + + it('does not invent a field for an index whose key is not a literal', () => { + const a = analyzeRelationshipTraversals('record.crm_account[record.key] == 1'); + expect([...(a?.traversals.keys() ?? [])]).toEqual([]); + }); + it('flags a read deeper than one hop, attributed to the first field', () => { const a = analyzeRelationshipTraversals('record.crm_account.owner.email != null'); expect([...(a?.multiHopFields ?? [])]).toEqual(['crm_account']); @@ -147,11 +163,14 @@ describe('findTraversalConflicts — what the authoring layer refuses', () => { // `@objectstack/lint` already supplies it at every record-scoped site. // ───────────────────────────────────────────────────────────────────────────── describe('validateExpression — refuses the unserviceable traversal shapes', () => { + // `traversalHydration` is the opt-in a caller sets ONLY where the traversal is + // actually served — ObjectQL's `checkPredicate`. See the key's docblock. const schema = { objectName: 'crm_opportunity', fields: ['account', 'amount', 'address'], fieldTypes: { account: 'lookup', amount: 'currency', address: 'object' }, scope: 'record' as const, + traversalHydration: true, }; it('accepts the plain one-hop traversal — the shape this card adds', () => { @@ -186,11 +205,35 @@ describe('validateExpression — refuses the unserviceable traversal shapes', () expect(r.ok).toBe(true); }); + // ⭐ The finding this closes: gating on `fieldTypes` alone reached nine other + // seams — field `requiredWhen`/`readonlyWhen`, option `visibleWhen`, action + // visible/disabled, sharing-rule (RLS) conditions, hook conditions, flow node + // and edge conditions — where NOTHING hydrates, so the prescription "write + // `record.account.id`" produces an expression that faults. On the fail-open + // seams among them that means the rule stops enforcing altogether. + it('checks NOTHING when the caller has not opted in', () => { + const optedOut = { ...schema, traversalHydration: undefined }; + for (const source of [ + "record.account.type == 'partner' && record.account == 'acc_1'", + 'record.account.owner.email != null', + ]) { + expect(validateExpression('predicate', source, optedOut).ok).toBe(true); + } + }); + + // A formula `value` never hydrates either, even on an opted-in object. + it('checks nothing in the `value` role', () => { + const r = validateExpression( + 'value', "record.account.type == 'partner' && record.account == 'acc_1'", schema, + ); + expect(r.errors.map((e) => e.message).join()).not.toContain('record.account.id'); + }); + it('checks nothing when the caller supplies no fieldTypes', () => { const r = validateExpression( 'predicate', "record.account.type == 'partner' && record.account == 'acc_1'", - { objectName: 'crm_opportunity', fields: ['account'], scope: 'record' }, + { objectName: 'crm_opportunity', fields: ['account'], scope: 'record', traversalHydration: true }, ); expect(r.ok).toBe(true); }); diff --git a/packages/formula/src/relationship-traversal.ts b/packages/formula/src/relationship-traversal.ts index a142196abc0..a81ab2afd5f 100644 --- a/packages/formula/src/relationship-traversal.ts +++ b/packages/formula/src/relationship-traversal.ts @@ -82,14 +82,45 @@ function isRootId(node: unknown, root: string): boolean { return op === 'id' && args === root; } -/** `{ op: '.', args: [receiver, 'name'] }` — a member access, or `null`. */ +/** + * A member access on `node`, whatever spelling it was written in, or `null`. + * + * ⭐ All four spellings are recognised on purpose, because they are + * interchangeable ways to read the same related column and a check that saw + * only the plain one would be trivially side-stepped. That matters most for the + * OPTIONAL forms: `has(...)`, `.?` and `[?]` read a missing key as an ordinary + * `false`/default, so an author reaching for a null-safe spelling would have + * turned "the acting user may not read this column" into a quiet non-firing + * rule. The engine decides readability before evaluation precisely so the + * verdict cannot depend on which operator was written — and this function is + * what makes the analysis see every operator in the first place. + * + * - `a.b` → `{ op: '.', args: [receiver, 'b'] }` + * - `a.?b` → `{ op: '.?', args: [receiver, 'b'] }` + * - `a['b']` → `{ op: '[]', args: [receiver, { op: 'value', args: 'b' }] }` + * - `a[?'b']` → `{ op: '[?]', args: [receiver, { op: 'value', args: 'b' }] }` + * + * An index whose key is not a literal string (`a[someVar]`) names no field this + * analysis can resolve, so it is not a member access here. + */ +const MEMBER_OPS = new Set(['.', '.?']); +const INDEX_OPS = new Set(['[]', '[?]']); + function asMember(node: unknown): { receiver: unknown; name: string } | null { if (!node || typeof node !== 'object') return null; const { op, args } = node as { op?: unknown; args?: unknown }; - if (op !== '.' || !Array.isArray(args) || args.length < 2) return null; - const name = args[1]; - if (typeof name !== 'string') return null; - return { receiver: args[0], name }; + if (typeof op !== 'string' || !Array.isArray(args) || args.length < 2) return null; + if (MEMBER_OPS.has(op)) { + const name = args[1]; + return typeof name === 'string' ? { receiver: args[0], name } : null; + } + if (INDEX_OPS.has(op)) { + const key = args[1] as { op?: unknown; args?: unknown } | undefined; + if (key && typeof key === 'object' && key.op === 'value' && typeof key.args === 'string') { + return { receiver: args[0], name: key.args }; + } + } + return null; } /** diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index 2b172d5e935..1255188ad6c 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -141,6 +141,26 @@ export interface ExprSchemaHint { * role that then silently never matches. Absent => role checks are skipped. */ roleCatalog?: readonly string[]; + /** + * [#18682] Opt IN to the relationship-traversal conflict checks. + * + * ⭐ Deliberately opt-in rather than derived from `fieldTypes`, because the + * prescription these checks hand out is only TRUE where something hydrates. + * `record..` resolves the related record at exactly + * one seam — the object validation rules (`script` / `cross_field`) evaluated + * by ObjectQL's `checkPredicate`. Nothing hydrates at a field `requiredWhen` / + * `readonlyWhen`, an option `visibleWhen`, an action's `visible` / `disabled`, + * a sharing-rule (RLS) condition, a hook condition, a flow node or edge + * condition, or a formula `value`. Telling an author at one of those sites to + * "write `record.account.id` instead" produces an expression that faults at + * evaluation — and on the fail-OPEN seams among them, a faulting predicate + * stops enforcing altogether. So the refusal would trade a working rule for a + * dead one, at nine seams the traversal capability never covered. + * + * Set it only where the traversal is actually served. Absent ⇒ the checks do + * not run, which is every caller's behaviour until it opts in. + */ + traversalHydration?: boolean; } export interface ExprValidationError { @@ -856,11 +876,19 @@ export function validateExpression( } } // [#18682] Relationship traversal: refuse the shapes that cannot be served - // as written. Needs `fieldTypes` to tell a REFERENCE field from an - // object-valued one — `record.address.city` traverses today and must keep - // traversing — so a caller that supplies none is not checked, exactly like - // the type-soundness pass above. - if (schema?.fieldTypes) { + // as written — but ONLY where they are served. Three conditions, all + // required: the caller opted in (`traversalHydration`, see its docblock for + // why this is not derived from `fieldTypes`), the slot is a PREDICATE (a + // formula `value` never hydrates), and `fieldTypes` is present to tell a + // REFERENCE field from an object-valued one — `record.address.city` traverses + // today and must keep traversing. + // + // ⛔ This arm is the AUTHOR-side half only. ADR-0124: a client-side direction + // is never the whole answer, and no runtime package imports this one, so the + // same conflict is refused independently by ObjectQL's `checkPredicate`. + // Metadata authored through Studio, written straight to `sys_metadata` or + // produced by an agent never reaches this check. + if (schema?.fieldTypes && schema.traversalHydration === true && role === 'predicate') { const analysis = analyzeRelationshipTraversals(source); if (analysis) { const conflicts = findTraversalConflicts( diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 7c085e15ace..395d7e63db2 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -1115,6 +1115,15 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { * all. */ fieldRuleVerdictIssued?: boolean, + /** + * [#18682] Opt this ONE site in to the relationship-traversal conflict + * checks. True only where the traversal is actually SERVED — object + * validation rules, which ObjectQL's `checkPredicate` hydrates. Everywhere + * else the checks' prescription ("write `record..id`") is false, + * because nothing hydrates there and the repaired expression would fault; + * on the fail-open seams that means the rule stops enforcing entirely. + */ + traversalHydration?: boolean, ): void => { if (raw == null) return; const fields = objectName ? fieldIndex.get(objectName) : undefined; @@ -1122,7 +1131,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // `record`-scoped sites, so it is harmless to pass for flattened ones too. const fieldTypes = objectName ? fieldTypeIndex.get(objectName) : undefined; const res = validateExpression('predicate', raw as string | { dialect?: string; source?: string }, - objectName ? { objectName, fields, fieldTypes, scope } : { scope }); + objectName ? { objectName, fields, fieldTypes, scope, traversalHydration } : { scope }); for (const e of res.errors) { if (fieldRuleVerdictIssued && isBareReferenceToAny(e.message, FIELD_RULE_NOWHERE_BOUND_ROOTS)) continue; issues.push({ where, message: e.message, source: e.source, severity: 'error' }); @@ -1518,8 +1527,14 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // The declared predicate key is `condition` (see `rulePredicates`). // Validation predicates are `record`-scoped — no field flattening — so // bare refs are flagged (#1928). - check(where, rule.condition, objectName, 'record'); + // [#18682] The two sites where a relationship traversal is SERVED: these + // are the `script` / `cross_field` conditions ObjectQL's `checkPredicate` + // hydrates. `traversalHydration` is passed here and NOWHERE else. + check(where, rule.condition, objectName, 'record', undefined, true); // `conditional` rules carry a nested `when` predicate (record-scoped). + // ⚠️ `when` is evaluated by `checkConditional` WITHOUT hydration today, so + // it is opted OUT: a traversal there faults, and the conflict checks' + // prescription would not repair it. check(`${where} when`, (rule as AnyRec).when, objectName, 'record'); // #4763 — null-guard gate over every predicate the rule carries, nested // `then`/`otherwise` branches included. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0c49acc0342..81715a2af31 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -210,7 +210,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container' 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 { RelatedRecordBinding } from './validation/rule-validator.js'; +import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; import { collectPredicateRelationships, evaluateValidationRules, 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 @@ -3935,6 +3935,31 @@ export class ObjectQL implements IObjectQLEngine { this.logger.debug('Registered held-file resolver for sys_file hydration'); } + /** + * [#18682] "Which fields of this object may this caller READ?" — supplied by + * the security plugin, never derived here. + * + * A predicate that reads one hop through a reference field must distinguish a + * related column the caller MAY read but which is empty (evaluate it as + * `null`) from one the caller may NOT read (refuse the write). A driver + * returns both as the same missing key, so the answer cannot come from the + * rows — it has to come from schema + context, which is what + * `ISecurityService.getReadableFields` answers. + * + * The engine declares the seam and `@objectstack/plugin-security` fills it, + * the same handover {@link registerHeldFileResolver} makes to storage. Left + * unwired (an embedding with no security plugin) every declared field is + * readable by construction, which is exactly the behaviour such a composition + * has today. + */ + private _readableFieldsResolver?: (object: string, context: unknown) => Promise; + + /** Wire the readable-fields question (#18682). Last registration wins. */ + registerReadableFieldsResolver(fn: (object: string, context: unknown) => Promise): void { + this._readableFieldsResolver = fn; + this.logger.debug('Registered readable-fields resolver for predicate relationship traversal'); + } + /** * [#11968] The engine-seam write epoch — the invalidation substrate of the * ruled authorization caching design (#11633 §2.1, §3). @@ -6930,52 +6955,103 @@ export class ObjectQL implements IObjectQLEngine { if (wanted.size === 0) return unbound; const fields = (schema?.fields ?? {}) as Record; - // fk field -> (id -> related row) - const resolved = new Map>>(); + type Resolved = { + object: string; + byId: Map>; + /** Set when NO row of this field is usable, whatever the id. */ + blocked?: { reason: 'unreadable' | 'field-unreadable'; unreadableFields?: string[] }; + }; + const resolved = new Map(); for (const [fk, namedFields] of wanted) { const target = referenceTargetOf(fields[fk]); if (!target) continue; + const named = [...namedFields]; + + // ── Readability is decided HERE, from schema + context, never from the + // rows. `getReadableFields` is explicitly "immune to an all-null column + // (which a driver may omit from every row) and to an empty result set", + // which is exactly the ambiguity that must not reach the predicate: a + // column the caller MAY read but which is empty has to evaluate as + // `null`, while a column the caller may NOT read has to refuse. Both + // arrive from a driver as the same missing key, so the answer cannot be + // derived from the data (#6457, one root over and fail-CLOSED). + let readable: string[] | undefined; + if (this._readableFieldsResolver) { + try { + readable = await this._readableFieldsResolver(target, context); + } catch { + // No answer is not a denial; fall through to the read, which is + // itself gated by the security middleware and refuses if it must. + readable = undefined; + } + } + if (readable) { + const denied = named.filter((n) => !readable!.includes(n)); + if (denied.length > 0) { + resolved.set(fk, { object: target, byId: new Map(), blocked: { reason: 'field-unreadable', unreadableFields: denied } }); + continue; + } + } + const ids = new Set(); for (const row of rows) { const value = row?.[fk]; - // A multi-value reference cannot be one hop: `record..` on a - // list has no single related record to read, so it is left unbound and - // the predicate faults rather than picking an element. + // A multi-value reference cannot be ONE hop: `record.fk.field` on a list + // names no single related record, so it is never hydrated. if (value == null || Array.isArray(value) || typeof value === 'object') continue; ids.add(String(value)); } - if (ids.size === 0) continue; + if (ids.size === 0) { resolved.set(fk, { object: target, byId: new Map() }); continue; } try { const query: EngineQueryOptions = { where: { id: { $in: [...ids] } }, - fields: [...new Set(['id', ...namedFields])], + fields: [...new Set(['id', ...named])], context: context as EngineQueryOptions['context'], }; const related = await this.find(target, query) as Array>; const byId = new Map>(); + // Materialise the NAMED fields to `null` — but only the ones this caller + // may actually read. With a readable set in hand that is exact; without + // one (no security plugin in this composition) every declared field is + // readable by construction, which is the pre-plugin behaviour. + const fillable = readable ? named.filter((n) => readable!.includes(n)) : named; for (const row of Array.isArray(related) ? related : []) { - if (row?.id != null) byId.set(String(row.id), row); + if (row?.id == null) continue; + const copy: Record = { ...row }; + for (const name of fillable) if (!(name in copy)) copy[name] = null; + byId.set(String(row.id), copy); } - resolved.set(fk, byId); + resolved.set(fk, { object: target, byId }); } catch (err) { - // Left unbound on purpose — see the docblock. Logged at `warn` because - // the write is still REJECTED downstream, loudly, in the caller's own - // response: nothing is silently lost here. - this.logger?.warn?.('predicate relationship lookup failed — the related field stays unbound and the rule will reject the write', { + // A refusal from the security layer is a real answer: this caller may + // not read the related object. Recorded as such so the predicate refuses + // with a sentence naming it, rather than faulting on a missing key. + this.logger?.warn?.('predicate relationship read refused or failed — the rule will reject the write', { object: target, field: fk, error: err, }); + resolved.set(fk, { object: target, byId: new Map(), blocked: { reason: 'unreadable' } }); } } if (resolved.size === 0) return unbound; return (row) => { if (!row) return undefined; - const binding: Record | null> = {}; - for (const [fk, byId] of resolved) { + const binding: Record = {}; + for (const [fk, entry] of resolved) { + if (entry.blocked) { + binding[fk] = { object: entry.object, unavailable: entry.blocked.reason, unreadableFields: entry.blocked.unreadableFields }; + continue; + } const value = row[fk]; - if (value == null || Array.isArray(value) || typeof value === 'object') { binding[fk] = null; continue; } - binding[fk] = byId.get(String(value)) ?? null; + if (value == null || Array.isArray(value) || typeof value === 'object') { + binding[fk] = { object: entry.object, unavailable: 'no-reference' }; + continue; + } + const found = entry.byId.get(String(value)); + binding[fk] = found + ? { object: entry.object, row: found } + : { object: entry.object, unavailable: 'unresolved' }; } return binding; }; @@ -10663,6 +10739,24 @@ export class ObjectQL implements IObjectQLEngine { const currentUser = this.buildEvalUser(options?.context); const skipStateMachine = shouldSkipStateMachine(options?.context); + // [#18682] The preview owes the SAME relationship resolution the real write + // does. Without it a rule that reads one hop through a reference field + // reports `valid: false` (unevaluable) against a row `insert()` happily + // accepts — the false alarm this operation was created to prevent, and the + // import dry run rides on it. + // + // Resolved once for the whole set, like every other posture input above, + // and under the CALLER's context so the preview's permission answer is the + // caller's own. ⚠️ Named limit, not widened here: an `update`-mode preview + // carries no prior row (nothing is read), so a traversing rule whose FK the + // PATCH does not itself carry has no id to resolve and still refuses. The + // real update path reads the prior row and does resolve it; closing the + // preview's half needs a read this operation's "nothing is executed" + // contract does not make. + const previewRelatedForRow = await this.resolvePredicateRelated( + schemaForValidation, rows, options?.context, + ); + const results: NonNullable = rows.map((row) => { const warnings: ValidateDataIssue[] = []; // Warn-first admissions are the posture signal the caller came for, so @@ -10691,6 +10785,7 @@ export class ObjectQL implements IObjectQLEngine { }); evaluateValidationRules(schemaForValidation as any, row, mode, { logger: this.logger, currentUser, skipStateMachine, messages, + related: previewRelatedForRow(row), }); } catch (e) { if (e instanceof ValidationError) { diff --git a/packages/objectql/src/validation/rule-relationship-traversal.test.ts b/packages/objectql/src/validation/rule-relationship-traversal.test.ts index 2e3d90e5b5c..3acc01573c5 100644 --- a/packages/objectql/src/validation/rule-relationship-traversal.test.ts +++ b/packages/objectql/src/validation/rule-relationship-traversal.test.ts @@ -41,11 +41,19 @@ const opportunity = { ], }; +/** A readable, resolved parent row. */ +const row = (r: Record) => ({ object: 'crm_account', row: r }); +/** The engine could not make the parent readable for this caller. */ +const unavailable = ( + reason: 'no-reference' | 'unreadable' | 'field-unreadable' | 'unresolved', + unreadableFields?: string[], +) => ({ object: 'crm_account', unavailable: reason, unreadableFields }); + const evaluate = ( data: Record, - related?: Record | null>, + related?: Record, ): void => { - evaluateValidationRules(opportunity as any, data, 'insert', { related }); + evaluateValidationRules(opportunity as any, data, 'insert', { related: related as never }); }; describe('#18682 — collectPredicateRelationships: what the engine must preload', () => { @@ -88,30 +96,30 @@ describe('#18682 — collectPredicateRelationships: what the engine must preload }); }); -describe('#18682 — the three acceptance outcomes (ADR-0136 D2.4)', () => { +describe('#18682 — the three acceptance outcomes (ADR-0137 D2)', () => { // ── PASSES when the parent field matches ────────────────────────────────── it('ACCEPTS the write when the parent field does not trip the rule', () => { expect(() => - evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: { id: 'acc_1', type: 'direct' } }), + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: row({ id: 'acc_1', type: 'direct' }) }), ).not.toThrow(); }); it('ACCEPTS when the parent matches but the local half does not', () => { expect(() => - evaluate({ name: 'A', amount: 10, account: 'acc_1' }, { account: { id: 'acc_1', type: 'partner' } }), + evaluate({ name: 'A', amount: 10, account: 'acc_1' }, { account: row({ id: 'acc_1', type: 'partner' }) }), ).not.toThrow(); }); // ── REFUSES the write when it does not ──────────────────────────────────── it('REFUSES the write when the parent field trips the rule', () => { expect(() => - evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: { id: 'acc_1', type: 'partner' } }), + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: row({ id: 'acc_1', type: 'partner' }) }), ).toThrow(ValidationError); }); it('the refusal carries the authored message, not a fault', () => { try { - evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: { id: 'acc_1', type: 'partner' } }); + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: row({ id: 'acc_1', type: 'partner' }) }); throw new Error('expected a ValidationError'); } catch (e) { const err = e as ValidationError; @@ -131,7 +139,7 @@ describe('#18682 — the three acceptance outcomes (ADR-0136 D2.4)', () => { // never silently false either. it('FAULTS LOUDLY and rejects when the related row is unreadable (null)', () => { expect(() => - evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: null }), + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: unavailable('unreadable') }), ).toThrow(ValidationError); }); @@ -141,7 +149,7 @@ describe('#18682 — the three acceptance outcomes (ADR-0136 D2.4)', () => { it('the fault is reported AS a fault, naming the unevaluable rule', () => { try { - evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: null }); + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: unavailable('unreadable') }); throw new Error('expected a ValidationError'); } catch (e) { const err = e as ValidationError; @@ -158,7 +166,7 @@ describe('#18682 — the three acceptance outcomes (ADR-0136 D2.4)', () => { expect(() => evaluate( { name: 'A', amount: 50000, account: 'acc_1' }, - { account: null }, + { account: unavailable('unreadable') }, ), ).toThrow(ValidationError); }); @@ -170,7 +178,7 @@ describe('#18682 — hydration is per-rule and never reaches the write payload', // the driver in place of the foreign key. it('does NOT mutate the record it was handed', () => { const data = { name: 'A', amount: 10, account: 'acc_1' }; - evaluate(data, { account: { id: 'acc_1', type: 'partner' } }); + evaluate(data, { account: row({ id: 'acc_1', type: 'partner' }) }); expect(data.account).toBe('acc_1'); }); @@ -190,7 +198,7 @@ describe('#18682 — hydration is per-rule and never reaches the write payload', }; try { evaluateValidationRules(schema as any, { name: 'A', amount: 10, account: 'acc_1' }, 'insert', - { related: { account: { id: 'acc_1', type: 'partner' } } }); + { related: { account: row({ id: 'acc_1', type: 'partner' }) } }); throw new Error('expected a ValidationError'); } catch (e) { const detail = JSON.stringify((e as unknown as { errors?: unknown }).errors ?? (e as Error).message); @@ -211,7 +219,107 @@ describe('#18682 — hydration is per-rule and never reaches the write payload', }; expect(() => evaluateValidationRules(schema as any, { name: 'A', amount: 10, account: 'acc_1' }, 'insert', - { related: { account: { id: 'acc_1', type: 'partner' } } }), + { related: { account: row({ id: 'acc_1', type: 'partner' }) } }), + ).not.toThrow(); + }); +}); + +describe('#18682 — the engine refuses the unserviceable shape, not only lint', () => { + // ⭐ The defect this closes: the mixed shape was REJECTED before the + // capability (the traversal faulted) and would have been ACCEPTED after it, + // with the bare arm silently `false` — option B's harm, on every path that + // authors metadata without running lint (Studio, `sys_metadata`, an agent). + // ADR-0124: an author-side direction is never the whole answer. + const mixed = { + fields: opportunity.fields, + validations: [{ + name: 'mixed', type: 'script', severity: 'error', message: 'should never be reached', + condition: "record.account.type == 'partner' && record.account == 'acc_1'", + }], + }; + + it('REFUSES a rule that reads a reference field both ways, even with a readable parent', () => { + try { + evaluateValidationRules(mixed as any, { name: 'A', account: 'acc_1' }, 'insert', + { related: { account: row({ id: 'acc_1', type: 'partner' }) } as never }); + throw new Error('expected a ValidationError'); + } catch (e) { + const detail = JSON.stringify((e as unknown as { errors?: unknown }).errors ?? (e as Error).message); + expect(detail).toContain('could not be evaluated'); + expect(detail).toContain('record.account.id'); + // ⛔ and never the rule's own message — the rule produced NO verdict. + expect(detail).not.toContain('should never be reached'); + } + }); + + it('REFUSES a read deeper than one hop in the engine too', () => { + const deep = { + fields: opportunity.fields, + validations: [{ name: 'deep', type: 'script', severity: 'error', message: 'no', + condition: "record.account.owner.email == 'x@y.z'" }], + }; + expect(() => evaluateValidationRules(deep as any, { name: 'A', account: 'acc_1' }, 'insert', {})) + .toThrow(ValidationError); + }); +}); + +describe('#18682 — the refusal names the RELATED object, not the referencing one', () => { + // ADR-0137 D2: a faulting field-rule predicate names the field and the rule. + // The generic undeclared-key prescription said the field "this object does not + // declare", which on a traversal is false in every clause — the field IS + // declared, on the related object — and sent the author to the wrong file. + const cases: Array<[string, ReturnType, string[]]> = [ + ['unreadable object', unavailable('unreadable'), ["may not read", "'crm_account'"]], + ['unreadable field', unavailable('field-unreadable', ['type']), ["may not read", "'type'"]], + ['no reference stored', unavailable('no-reference'), ['no related record']], + ['related row gone', unavailable('unresolved'), ['could not be read']], + ]; + + for (const [name, binding, expected] of cases) { + it(`names the related object and field — ${name}`, () => { + try { + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: binding }); + throw new Error('expected a ValidationError'); + } catch (e) { + const detail = JSON.stringify((e as unknown as { errors?: unknown }).errors ?? (e as Error).message); + for (const needle of expected) expect(detail).toContain(needle); + // ⛔ never the prescription that names the REFERENCING object. + expect(detail).not.toContain('which this object does not declare'); + expect(detail).toContain('partner_cap'); + } + }); + } +}); + +describe('#18682 — the permission verdict does not depend on the CEL operator', () => { + // `has()` returns `false` on an absent key, and `.?` yields a default — so an + // author who reaches for a null-safe spelling would have turned "the caller + // may not read this" into an ordinary `false` and the rule would stop firing. + // The engine decides readability BEFORE evaluation, so every spelling refuses. + const guarded = (condition: string) => ({ + fields: opportunity.fields, + validations: [{ name: 'guarded', type: 'script', severity: 'error', message: 'fired', condition }], + }); + + it.each([ + ['plain member access', "record.account.type == 'partner'"], + ['has() guard', "has(record.account.type) && record.account.type == 'partner'"], + ['optional selection', "record.account.?type.orValue('') == 'partner'"], + ])('refuses an unreadable parent — %s', (_name, condition) => { + expect(() => + evaluateValidationRules(guarded(condition) as any, { name: 'A', account: 'acc_1' }, 'insert', + { related: { account: unavailable('field-unreadable', ['type']) } as never }), + ).toThrow(ValidationError); + }); +}); + +describe('#18682 — a readable but EMPTY related column evaluates, it does not refuse', () => { + // #6457's trap, one root over and on a fail-CLOSED seam: a driver that does + // not echo an all-null column must not make a valid write fail. The engine + // materialises readable declared fields to `null`, so the predicate evaluates. + it('accepts when the parent field is materialised null and the rule does not trip', () => { + expect(() => + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: row({ id: 'acc_1', type: null }) }), ).not.toThrow(); }); }); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 77f25648d74..2dcc8edc39d 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -192,7 +192,7 @@ * evaluator once per matched row — one payload, N priors (#3106). */ -import { ExpressionEngine, collectCelRootIdentifiers, analyzeRelationshipTraversals } from '@objectstack/formula'; +import { ExpressionEngine, collectCelRootIdentifiers, analyzeRelationshipTraversals, findTraversalConflicts } from '@objectstack/formula'; import type { RelationshipTraversalAnalysis } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; import { AUDIT_PROVENANCE_FIELDS, RUNTIME_OWNED_FIELD_TYPES, referenceTargetOf, resolveInjectedSystemColumns } from '@objectstack/spec/data'; @@ -487,6 +487,12 @@ export function collectPredicateRelationships( if (!fields) return out; const addFrom = (cond: unknown): void => { + // The DIALECT is checked, not assumed: a `template` or `js` source that + // happens to parse as CEL would otherwise be analysed and hydrated, and the + // hop list would describe an expression no CEL engine ever evaluates. + // A bare string is CEL by the envelope's own default. + if (cond && typeof cond === 'object' && (cond as Expression).dialect !== undefined + && (cond as Expression).dialect !== 'cel') return; const source = typeof cond === 'string' ? cond : (cond && typeof cond === 'object' ? (cond as Expression).source : undefined); @@ -533,7 +539,52 @@ export type ParentBinding = Record | null | undefined; * answered from data the caller is allowed to see", and the predicate must * fault rather than quietly pick a verdict. */ -export type RelatedRecordBinding = Readonly | null>>; +export type RelatedUnavailableReason = + /** The record stores no reference — the FK is null/empty, so there is no row. */ + | 'no-reference' + /** The acting user may not read the related object, or the read was refused. */ + | 'unreadable' + /** One or more of the FIELDS the predicate names are not readable by this user. */ + | 'field-unreadable' + /** A reference is stored but the row it names does not exist (or the read threw). */ + | 'unresolved'; + +/** + * [#18682] What the engine resolved for ONE reference field a predicate reads + * through. + * + * ⭐ Why this is a discriminated record and not just `row | null`: the permission + * verdict must be decided by the ENGINE, BEFORE evaluation, and it must not + * depend on which CEL operator the author happened to write. Handing CEL an + * absent key delegates the verdict to key-absence semantics, and `has(...)`, + * `.?` and `orValue(...)` all read an absent key as an ordinary `false`/default + * — so a field the caller may not read would quietly stop the rule firing. The + * engine therefore says WHY a row is unusable and {@link checkPredicate} turns + * that into a refusal, rather than letting the expression discover it. + * + * It also separates the two absences #6457 taught us to keep apart: a field that + * is READABLE but legitimately empty is materialised to `null` on `row` (so the + * predicate evaluates), while a field that is NOT readable makes the whole + * binding unavailable (so the predicate refuses). Before this split both arrived + * as "the key is missing" and the verdict depended on which columns a driver + * happened to echo. + */ +export interface RelatedFieldBinding { + /** The object this reference field points at — named in the refusal text. */ + readonly object: string; + /** + * The related row, materialised to `null` over the readable declared fields + * the predicate names. Present iff the row is usable. + */ + readonly row?: Record; + /** Why `row` is absent. Present iff `row` is absent. */ + readonly unavailable?: RelatedUnavailableReason; + /** For `field-unreadable`: the named fields this caller may not read. */ + readonly unreadableFields?: readonly string[]; +} + +/** Reference FIELD name → what the engine resolved for it. */ +export type RelatedRecordBinding = Readonly>; /** * The two CEL roots a field `readonlyWhen` predicate reads — `record` (the @@ -2673,7 +2724,7 @@ function evaluateRule(rule: BaseRule, ctx: RuleContext): FieldValidationError | return checkStateMachine(rule as StateMachineRule, ctx.mode, ctx.data, ctx.previous, ctx); case 'script': case 'cross_field': - return checkPredicate(rule as PredicateRule, ctx.merged, ctx.previous, ctx.logger, ctx.messages, ctx.related); + return checkPredicate(rule as PredicateRule, ctx.merged, ctx.previous, ctx.logger, ctx.messages, ctx.related, ctx.fields); case 'format': return checkFormat(rule as FormatRule, ctx.data, ctx.logger, ctx.messages); case 'json_schema': @@ -2871,23 +2922,148 @@ function analysisFor(source: string): RelationshipTraversalAnalysis | null { * permission rule requires — a related row the acting user may not read must * never resolve to a quiet verdict. */ -function hydrateRelated( +/** What {@link resolveTraversalScope} decided for one predicate. */ +type TraversalScope = + /** Evaluate against `record` (hydrated where the rule traverses). */ + | { readonly ok: true; readonly record: Record } + /** ⛔ Do not evaluate: refuse the write with this sentence. */ + | { readonly ok: false; readonly summary: string; readonly detail: string }; + +/** + * [#18682] Decide, BEFORE evaluation, what this one predicate may be evaluated + * against — or that it may not be evaluated at all. + * + * Three outcomes, and the two refusing ones are the point of the function: + * + * 1. **Refuse — unserviceable shape.** A reference field read BOTH through the + * relationship and as a plain value cannot be served: hydrating it makes the + * plain-value comparison compare a map against a string, which CEL answers + * `false` WITHOUT faulting, so the rule silently stops firing. That is a + * silent verdict flip on a fail-closed seam, so the rule is refused here — + * in the ENGINE — and not only in the authoring layer. `@objectstack/lint` + * refuses the same shape with the same prescription, but no runtime package + * imports lint: metadata authored through Studio, written straight to + * `sys_metadata`, or produced by an agent never meets it. ADR-0137 D1 — + * a predicate slot accepts only what the engine can actually run — is a + * statement about the ENGINE, and ADR-0124's server-enforces/client-is- + * courtesy rule says an author-side direction is never the whole answer. + * + * 2. **Refuse — the related data is not readable.** The engine already decided + * this (see {@link RelatedFieldBinding}); this function only turns the reason + * into a sentence that names the RELATED object and field. + * + * 3. **Evaluate**, against a shallow COPY carrying the related rows for exactly + * the reference fields THIS rule traverses. Per rule, because hydrating a + * field replaces its stored id and a sibling rule comparing the bare id must + * keep seeing the id. Onto a copy, because the record a rule is handed is the + * write payload. + * + * A rule that traverses nothing is handed the record untouched — byte-identical + * to the pre-#18682 input, which is what keeps every existing rule unaffected. + */ +function resolveTraversalScope( record: Record, source: string, related: RelatedRecordBinding | undefined, -): Record { - if (!related) return record; + fields: Record | undefined, +): TraversalScope { const analysis = analysisFor(source); - if (!analysis || analysis.traversals.size === 0) return record; + if (!analysis || (analysis.traversals.size === 0 && analysis.multiHopFields.size === 0)) { + return { ok: true, record }; + } + // (1) The shapes the engine cannot serve, judged against the SAME arbiter the + // authoring layer and the `$expand` gate ask — `referenceTargetOf` — so the + // two layers can never disagree about which fields are references. + const isReference = (field: string): boolean => !!referenceTargetOf(fields?.[field]); + const conflicts = findTraversalConflicts(analysis, isReference); + if (conflicts.length > 0) { + return { + ok: false, + summary: conflicts[0].kind === 'multi-hop' + ? 'reads more than one relationship hop' + : 'reads a reference field both through the relationship and as a value', + detail: ' ' + conflicts.map((c) => c.message).join(' '), + }; + } + + // (2)/(3) Hydrate what is available; refuse on the first field that is not. let copy: Record | undefined; for (const field of analysis.traversals.keys()) { - const row = related[field]; - if (row == null) continue; - if (!copy) copy = { ...record }; - copy[field] = row; + if (!isReference(field)) continue; + const binding = related?.[field]; + // No binding at all means the engine resolved nothing for this write (an + // embedding that never called `collectPredicateRelationships`). Leave the + // record alone and let evaluation fault as it did before — this function + // does not invent a verdict for a seam that was never wired. + if (!binding) continue; + if (binding.row) { + if (!copy) copy = { ...record }; + copy[field] = binding.row; + continue; + } + return { ok: false, ...traversalRefusal(field, binding, analysis.traversals.get(field)) }; + } + return { ok: true, record: copy ?? record }; +} + +/** + * [#18682 / ADR-0137 D2] The sentence a traversal refusal carries. + * + * ⭐ It names the RELATED object and the related field. The generic + * undeclared-key prescription cannot be reused here: it reads "the predicate + * reads 'status', which this object does not declare — fix the rule's condition, + * or declare the field", and on a traversal every clause of that is wrong. The + * field IS declared, on another object, and an author who follows it adds a + * bogus column to the object they were editing. ADR-0137 D2 requires a faulting + * field-rule predicate to name the field and the rule; naming the wrong object + * sends the author to the wrong file. + */ +function traversalRefusal( + field: string, + binding: RelatedFieldBinding, + named: ReadonlySet | undefined, +): { summary: string; detail: string } { + const names = [...(named ?? [])].sort(); + const columns = names.length === 1 ? `'${names[0]}'` : names.map((n) => `'${n}'`).join(', '); + const on = `\`${field}\` (object '${binding.object}')`; + switch (binding.unavailable) { + case 'no-reference': + return { + summary: `cannot read ${columns} through ${on}: no related record`, + detail: + ` The rule reads ${columns} through ${on}, but this record stores no reference there,` + + ' so there is no related record to read. Guard the rule on the reference being set,' + + ' or make the reference required.', + }; + case 'field-unreadable': { + const denied = (binding.unreadableFields ?? []).map((n) => `'${n}'`).join(', ') || columns; + return { + summary: `cannot read ${denied} on '${binding.object}' as this user`, + detail: + ` The rule reads ${denied} through ${on}. The acting user may not read` + + ` ${denied} on '${binding.object}', so the rule has no verdict and the write is` + + ' rejected rather than allowed on an unchecked rule. Grant read on those fields, or' + + ' rewrite the rule to read only data this caller can see.', + }; + } + case 'unresolved': + return { + summary: `cannot read ${columns} through ${on}: the related record was not found`, + detail: + ` The rule reads ${columns} through ${on}, but the referenced record could not be` + + ' read — it may have been deleted, or it may be outside this caller\'s visibility.', + }; + case 'unreadable': + default: + return { + summary: `cannot read '${binding.object}' as this user`, + detail: + ` The rule reads ${columns} through ${on}, and the acting user may not read` + + ` '${binding.object}'. The rule has no verdict, so the write is rejected rather than` + + ' allowed on an unchecked rule. Grant read on that object, or rewrite the rule.', + }; } - return copy ?? record; } function checkPredicate( @@ -2897,18 +3073,37 @@ function checkPredicate( logger: EvaluateRulesOptions['logger'], messages?: ValidationMessageContext, related?: RelatedRecordBinding, + fields?: Record, ): FieldValidationError | null { const expr = toExpression(rule.condition); - const scopeRecord = typeof expr.source === 'string' - ? hydrateRelated(record, expr.source, related) - : record; + const field = rule.fields?.[0] ?? '_record'; + + // [#18682 / ADR-0137 D2] Decide the relationship question BEFORE evaluation. + // A refusal here is a rule that HAS no verdict — never a rule whose verdict is + // `false` — so it rejects the write exactly as an unevaluable predicate does + // (#4649), but says which RELATED object and field it could not read. + if (typeof expr.source === 'string' && expr.dialect === 'cel') { + const scope = resolveTraversalScope(record, expr.source, related, fields); + if (!scope.ok) { + logger?.warn?.( + `Validation rule '${rule.name}' predicate could not be evaluated (${scope.summary}) — write rejected (#4649)`, + ); + return { + field, + code: 'rule_violation', + message: + `Validation rule '${rule.name}' could not be evaluated (${scope.summary}) — write rejected.${scope.detail}`, + constraint: { rule: rule.name, reason: 'unevaluable', fault: scope.summary }, + }; + } + record = scope.record; + } + const result = ExpressionEngine.evaluate(expr, { - record: scopeRecord, + record, previous: previous ?? undefined, }); - const field = rule.fields?.[0] ?? '_record'; - if (!result.ok) { // Still logged — the operator needs the fault in the log even though the // caller now gets it in the response. Note the verb: rejected, not skipped. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 3a8fd52073f..22bee8e62e7 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1226,6 +1226,23 @@ export class SecurityPlugin implements Plugin { this.metadata = metadata; this.ql = ql; + // [#18682] Fill the engine's readable-fields seam. A validation rule that + // reads one hop through a reference field has to tell a related column the + // caller MAY read but which is empty (evaluate it as `null`) from one the + // caller may NOT read (refuse the write). A driver returns both as the same + // missing key, so the engine cannot derive it from the rows and asks here — + // `getReadableFields` is computed from schema + context and is explicitly + // "immune to an all-null column … and to an empty result set". + // + // Feature-detected on the engine, like every other optional seam this plugin + // fills: an older ObjectQL simply does not offer it, and this plugin must + // keep booting against one. + if (typeof (ql as any).registerReadableFieldsResolver === 'function') { + (ql as any).registerReadableFieldsResolver( + (object: string, context: unknown) => this.getReadableFields(object, context as any), + ); + } + // [#11968] Bind the invalidation epoch to the ENGINE's seam when the wired // engine exposes one. Resolved here, once, rather than probed per request: // the plugin DI graph is static after start, and a per-request probe would From 2a3e97d63ce6be11f35c8029501e3f1def11794d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 14:06:54 +0000 Subject: [PATCH 08/47] test(objectql): pin the engine half of relationship traversal The rule-validator tests hand `related` in by hand, so none of the engine behaviour was pinned. Driven end-to-end through the real engine and a real driver: the related read runs under the acting user and never as system, the projection is id plus only the fields the rules name, a batch costs one read, the foreign key is read off the prior row when a patch omits it, the driver's write payload carries the key and not the expanded record, a readable-but-empty column evaluates as null, an unreadable field refuses under every operator spelling, the dry run agrees with the write, and a non-traversing object pays no read at all. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/engine-predicate-relationship.test.ts | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/objectql/src/engine-predicate-relationship.test.ts diff --git a/packages/objectql/src/engine-predicate-relationship.test.ts b/packages/objectql/src/engine-predicate-relationship.test.ts new file mode 100644 index 00000000000..328c189eb5f --- /dev/null +++ b/packages/objectql/src/engine-predicate-relationship.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18682] The ENGINE half of relationship traversal, driven end-to-end through + * the real engine and a real driver. + * + * The rule-validator tests hand `related` in by hand, which pins the evaluator + * and nothing about how the binding is produced. Everything this file asserts is + * engine behaviour that only a call site can show (PD #10: a `case` label is not + * enforcement — check the CALL SITE): + * + * - the related read runs under the ACTING USER's context, ⛔ never `isSystem` + * (`resolveMasterDetailParent` reads as system on purpose; this must not); + * - the projection names `id` plus only the fields the rules actually read; + * - on UPDATE the foreign key is read off the PRIOR row when the patch omits + * it, so a rule still resolves; + * - the driver's write payload carries the foreign KEY, never the expanded + * related record; + * - a readable-but-empty related column evaluates as `null` rather than + * refusing (#6457's trap, one root over and on a fail-CLOSED seam); + * - an unreadable related field refuses, whichever CEL operator was written. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +import '@objectstack/spec'; +import '@objectstack/formula'; + +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: 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 === '$or') return (v as any[]).some((w) => matches(row, w)); + const cond = v as any; + 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; + } + return row?.[k] === cond; + }); + }; + const calls: Array<{ object: string; ast: any }> = []; + const writes: Array<{ op: string; object: string; data: any }> = []; + 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) { + calls.push({ object, ast }); + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // The driver echoes ONLY the projected keys, and — like `driver-memory` — + // omits a key whose value is `undefined`. That omission is the shape this + // file pins the engine against. + const fields: string[] | undefined = ast?.fields; + if (!fields) return rows; + return rows.map((r) => { + const out: any = {}; + for (const f of fields) if (r[f] !== undefined) out[f] = r[f]; + return out; + }); + }, + 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 }); + 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 }); + 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, undefined))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor, calls, writes }; +} + +const ACTING = { userId: 'u1', positions: ['rep'] } as any; + +describe('#18682 — engine-produced relationship bindings', () => { + let engine: ObjectQL; + let d: ReturnType; + + beforeEach(async () => { + engine = new ObjectQL(); + d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'crm_account', + fields: { name: { type: 'text' }, type: { type: 'text' }, secret: { type: 'text' } }, + } as any, 'test-package'); + engine.registry.registerObject({ + name: 'crm_opportunity', + fields: { + name: { type: 'text' }, + amount: { type: 'number' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + validations: [{ + name: 'partner_cap', type: 'script', severity: 'error', + message: 'Partner accounts are capped at 10000.', + condition: "record.account.type == 'partner' && record.amount > 10000", + }], + } as any, 'test-package'); + d.storeFor('crm_account').set('acc_p', { id: 'acc_p', name: 'P', type: 'partner', secret: 's' }); + d.storeFor('crm_account').set('acc_d', { id: 'acc_d', name: 'D', type: 'direct', secret: 's' }); + // A readable account whose `type` was never set — the driver will omit it. + d.storeFor('crm_account').set('acc_empty', { id: 'acc_empty', name: 'E', secret: 's' }); + }); + + const relatedReads = () => d.calls.filter((c) => c.object === 'crm_account'); + + it('reads the related row under the ACTING USER, never as system', async () => { + // Captured at the middleware seam — the same one RLS and sharing compose on, + // so this is the context the security layer would actually judge. + const seen: any[] = []; + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx?.objectName === 'crm_account' || opCtx?.object === 'crm_account') seen.push(opCtx); + await next(); + }); + await engine.insert('crm_opportunity', { name: 'A', amount: 10, account: 'acc_d' }, { context: ACTING } as any); + expect(seen.length).toBeGreaterThan(0); + const ctx = seen[0].context ?? seen[0].executionContext; + // ⭐ The ruled difference from `parent`, which reads `{ isSystem: true }`. + expect(ctx?.isSystem).toBeFalsy(); + expect(ctx?.userId).toBe('u1'); + }); + + it('projects id plus only the fields the rules name — not the whole row', async () => { + await engine.insert('crm_opportunity', { name: 'A', amount: 10, account: 'acc_d' }, { context: ACTING } as any); + const fields = [...(relatedReads()[0].ast?.fields ?? [])].sort(); + expect(fields).toEqual(['id', 'type']); + // `secret` is declared on the related object and named by no rule. + expect(fields).not.toContain('secret'); + }); + + it('costs ONE related read for a batch, not one per row', async () => { + await engine.insert('crm_opportunity', [ + { name: 'A', amount: 10, account: 'acc_d' }, + { name: 'B', amount: 20, account: 'acc_p' }, + { name: 'C', amount: 30, account: 'acc_d' }, + ] as any, { context: ACTING } as any); + expect(relatedReads()).toHaveLength(1); + }); + + it('ACCEPTS and REFUSES on the real write path, per the parent field', async () => { + await expect( + engine.insert('crm_opportunity', { name: 'ok', amount: 50000, account: 'acc_d' }, { context: ACTING } as any), + ).resolves.toBeTruthy(); + await expect( + engine.insert('crm_opportunity', { name: 'no', amount: 50000, account: 'acc_p' }, { context: ACTING } as any), + ).rejects.toThrow(/Partner accounts are capped/); + }); + + it('writes the foreign KEY to the driver, never the expanded related record', async () => { + await engine.insert('crm_opportunity', { name: 'A', amount: 10, account: 'acc_p' }, { context: ACTING } as any); + const create = d.writes.find((w) => w.op === 'create' && w.object === 'crm_opportunity'); + expect(create!.data.account).toBe('acc_p'); + expect(typeof create!.data.account).toBe('string'); + }); + + it('resolves the FK off the PRIOR row when the patch does not carry it', async () => { + const made = await engine.insert( + 'crm_opportunity', { name: 'A', amount: 10, account: 'acc_p' }, { context: ACTING } as any, + ) as any; + // The patch touches `amount` only; the rule still needs the account. + await expect( + engine.update('crm_opportunity', { amount: 50000 }, { where: { id: made.id }, context: ACTING } as any), + ).rejects.toThrow(/Partner accounts are capped/); + }); + + // #6457 one root over: a readable column the driver did not echo must not + // refuse a valid write. The engine materialises it to `null`. + it('ACCEPTS when a readable related column is simply empty', async () => { + await expect( + engine.insert('crm_opportunity', { name: 'A', amount: 50000, account: 'acc_empty' }, { context: ACTING } as any), + ).resolves.toBeTruthy(); + }); + + it('REFUSES when the acting user may not read the related field — every spelling', async () => { + // The security plugin is not in this composition, so the seam is filled by + // hand with the answer that plugin would give. + (engine as any).registerReadableFieldsResolver(async () => ['id', 'name']); + for (const condition of [ + "record.account.type == 'partner'", + "has(record.account.type) && record.account.type == 'partner'", + "record.account.?type.orValue('') == 'partner'", + ]) { + engine.registry.registerObject({ + name: 'crm_opportunity', + fields: { + name: { type: 'text' }, amount: { type: 'number' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + validations: [{ name: 'guarded', type: 'script', severity: 'error', message: 'fired', condition }], + } as any, 'test-package'); + await expect( + engine.insert('crm_opportunity', { name: 'A', amount: 1, account: 'acc_p' }, { context: ACTING } as any), + ).rejects.toThrow(/could not be evaluated/); + } + }); + + it('the dry run agrees with the write it previews', async () => { + const preview = await engine.validate( + 'crm_opportunity', { name: 'A', amount: 50000, account: 'acc_d' }, + { mode: 'insert', context: ACTING } as any, + ); + expect(preview.results?.[0]?.valid).toBe(true); + await expect( + engine.insert('crm_opportunity', { name: 'A', amount: 50000, account: 'acc_d' }, { context: ACTING } as any), + ).resolves.toBeTruthy(); + + const refused = await engine.validate( + 'crm_opportunity', { name: 'B', amount: 50000, account: 'acc_p' }, + { mode: 'insert', context: ACTING } as any, + ); + expect(refused.results?.[0]?.valid).toBe(false); + }); + + it('pays nothing when no rule traverses', async () => { + engine.registry.registerObject({ + name: 'plain', + fields: { name: { type: 'text' }, account: { type: 'lookup', reference: 'crm_account' } }, + validations: [{ name: 'n', type: 'script', condition: "record.name == ''", message: 'x' }], + } as any, 'test-package'); + d.calls.length = 0; + await engine.insert('plain', { name: 'A', account: 'acc_p' }, { context: ACTING } as any); + expect(d.calls.filter((c) => c.object === 'crm_account')).toHaveLength(0); + }); +}); From d4aa3ff65a79774d4f169d6918be521a3b6d17f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 14:19:03 +0000 Subject: [PATCH 09/47] feat(objectql): read the related record under system authority for validation rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruled: 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 out of this capability entirely. Reading as the acting user made the rule unauthorable for exactly the persona it exists to constrain: a member with CRUD on the child and no read on the parent faulted on every write, which the dogfood gate measured. What bounds the elevation is the PROJECTION, not the caller: only the columns the predicate names, intersected with the related object's declared fields. A column the related object does not declare never enters the query and is refused as the authoring fault it is — and stays distinguishable from a column that exists and is empty, which materialises to null and evaluates. The refusal names the field and the rule, never the value. A caller can infer a value by observing which writes refuse; that channel was accepted knowingly and is kept no wider than "this rule refused this write". Confined to `checkPredicate`'s seam. The readable-fields seam the acting-user model needed is removed again, with it. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../areas/records-forms.json | 14 +-- .../src/engine-predicate-relationship.test.ts | 85 ++++++++----- packages/objectql/src/engine.ts | 113 ++++++++---------- .../rule-relationship-traversal.test.ts | 20 ++-- .../objectql/src/validation/rule-validator.ts | 67 ++++++----- .../plugin-security/src/security-plugin.ts | 16 --- 6 files changed, 160 insertions(+), 155 deletions(-) diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 923586f8539..7d00ef0d856 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -4111,7 +4111,7 @@ "surface": "api", "personas": [ "seeded admin (admin@objectos.ai / admin123)", - "a member who cannot read showcase_account — for the unreadable-parent clause" + "a member with showcase_invoice CRUD and NO read on showcase_account — the persona the rule exists to constrain" ], "fixtures": { "app": "showcase", @@ -4121,14 +4121,14 @@ "at least one churned account and one non-churned account to invoice against" ], "knownGaps": [ - "The unreadable-parent clause needs a persona who cannot read showcase_account. showcase_account is public_read_write in the showcase app, so no seeded persona fails to read it and that clause scores blocked(fixture) there — it is covered by unit test in packages/objectql (rule-relationship-traversal.test.ts) until a recipe supplies such a persona." + "An earlier revision of this item asserted that no seeded persona fails to read showcase_account. CI measured that false: the Dogfood Regression Gate persona in packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts holds showcase_invoice CRUD and no grant on showcase_account, and under the security plugin CRUD gate member_default does not grant it either. That is now the POINT of the item rather than a gap: a validation rule reads the related record under SYSTEM authority, bounded by the fields the predicate names, so this persona writes successfully and the rule still enforces. The clause that once expected a loud fault for this persona has been inverted accordingly." ] }, "steps": [ "POST /api/v1/data/showcase_invoice with `account` pointing at a NON-churned account", "POST /api/v1/data/showcase_invoice with `account` pointing at a CHURNED account", "PATCH an existing invoice to REPOINT it at a churned account (the update path reads the FK off the merged record, not the patch alone)", - "as the persona who cannot read showcase_account, POST an invoice against any account" + "as the persona with NO read on showcase_account, POST an invoice against a non-churned account, then against a churned one" ], "acceptance": [ { @@ -4150,16 +4150,16 @@ "evidence": "the patch response" }, { - "clause": "a parent the acting user CANNOT read FAULTS LOUDLY and rejects the write — never silently allowed", + "clause": "a rule whose parent the acting user cannot read still ENFORCES, and still lets a legitimate write through — the read is system-authority, bounded by the fields the predicate names", "oracle": "api", - "verify": "the write is REJECTED with the unevaluable-rule envelope; a 2xx here is the defect this clause exists to catch", - "evidence": "the rejection envelope for the restricted persona" + "verify": "the non-churned write is 2xx and the churned write is 400 carrying the AUTHORED message; an unevaluable-rule envelope for this persona is the defect this clause exists to catch, and so is a 2xx on the churned account", + "evidence": "both responses for the restricted persona" } ], "negative": [ "a 2xx on the churned-account create is a FAIL — the rule did not run", "a rejection whose text is the unevaluable-rule fault on the READABLE-parent cases is a FAIL: the related row was not preloaded and the rule is rejecting every write, which is the pre-capability behaviour", - "a 2xx for the persona who cannot read the parent is the WORST failure here — a rule guarding data the caller cannot see must never silently pass" + "a 400 unevaluable-rule envelope for the persona who cannot read the parent is a FAIL: it means the related read was not system-authority, and the rule cannot be authored for the persona it exists to constrain. A 2xx on the CHURNED account is also a FAIL — the rule did not enforce" ], "traps": [ "seed-data-thin" diff --git a/packages/objectql/src/engine-predicate-relationship.test.ts b/packages/objectql/src/engine-predicate-relationship.test.ts index 328c189eb5f..410379543fc 100644 --- a/packages/objectql/src/engine-predicate-relationship.test.ts +++ b/packages/objectql/src/engine-predicate-relationship.test.ts @@ -135,22 +135,6 @@ describe('#18682 — engine-produced relationship bindings', () => { const relatedReads = () => d.calls.filter((c) => c.object === 'crm_account'); - it('reads the related row under the ACTING USER, never as system', async () => { - // Captured at the middleware seam — the same one RLS and sharing compose on, - // so this is the context the security layer would actually judge. - const seen: any[] = []; - engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { - if (opCtx?.objectName === 'crm_account' || opCtx?.object === 'crm_account') seen.push(opCtx); - await next(); - }); - await engine.insert('crm_opportunity', { name: 'A', amount: 10, account: 'acc_d' }, { context: ACTING } as any); - expect(seen.length).toBeGreaterThan(0); - const ctx = seen[0].context ?? seen[0].executionContext; - // ⭐ The ruled difference from `parent`, which reads `{ isSystem: true }`. - expect(ctx?.isSystem).toBeFalsy(); - expect(ctx?.userId).toBe('u1'); - }); - it('projects id plus only the fields the rules name — not the whole row', async () => { await engine.insert('crm_opportunity', { name: 'A', amount: 10, account: 'acc_d' }, { context: ACTING } as any); const fields = [...(relatedReads()[0].ast?.fields ?? [])].sort(); @@ -202,26 +186,69 @@ describe('#18682 — engine-produced relationship bindings', () => { ).resolves.toBeTruthy(); }); - it('REFUSES when the acting user may not read the related field — every spelling', async () => { - // The security plugin is not in this composition, so the seam is filled by - // hand with the answer that plugin would give. - (engine as any).registerReadableFieldsResolver(async () => ['id', 'name']); - for (const condition of [ - "record.account.type == 'partner'", - "has(record.account.type) && record.account.type == 'partner'", - "record.account.?type.orValue('') == 'partner'", - ]) { + // ⭐ The ruled read authority: a validation rule's output is a pass/fail the + // SYSTEM enforces, so the related row is read under system authority and the + // rule is authorable for exactly the persona it exists to constrain. Bounded + // by the PROJECTION, never by the caller. + it('reads the related row under SYSTEM authority', async () => { + const seen: any[] = []; + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx?.objectName === 'crm_account' || opCtx?.object === 'crm_account') seen.push(opCtx); + await next(); + }); + await engine.insert('crm_opportunity', { name: 'A', amount: 10, account: 'acc_d' }, { context: ACTING } as any); + const ctx = seen[0].context ?? seen[0].executionContext; + expect(ctx?.isSystem).toBe(true); + // …and the acting identity is carried through, so audit still sees who wrote. + expect(ctx?.userId).toBe('u1'); + }); + + // ⛔ The bound on the elevation. A predicate that names a column the related + // object does not declare must NOT put that name into a system-authority + // query — the projection is the whole of what limits an elevated read. + it('never smuggles an UNDECLARED field into the system read set', async () => { + engine.registry.registerObject({ + name: 'crm_opportunity', + fields: { + name: { type: 'text' }, amount: { type: 'number' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + validations: [{ name: 'sneaky', type: 'script', severity: 'error', message: 'x', + condition: "record.account.not_a_column == 'x'" }], + } as any, 'test-package'); + d.calls.length = 0; + await expect( + engine.insert('crm_opportunity', { name: 'A', amount: 1, account: 'acc_p' }, { context: ACTING } as any), + ).rejects.toThrow(/declares no 'not_a_column'/); + // The read either never happened or never named the column. + for (const call of d.calls.filter((c) => c.object === 'crm_account')) { + expect(call.ast?.fields ?? []).not.toContain('not_a_column'); + } + }); + + // The refusal is about the WRITE, never about the value. A caller can infer a + // value by observing refusals — an accepted, deliberately narrow channel — so + // nothing may make the refusal more informative than "this rule refused". + it('never echoes the related VALUE in the refusal', async () => { + await expect( + engine.insert('crm_opportunity', { name: 'A', amount: 50000, account: 'acc_p' }, { context: ACTING } as any), + ).rejects.toThrow(/Partner accounts are capped/); + try { engine.registry.registerObject({ name: 'crm_opportunity', fields: { name: { type: 'text' }, amount: { type: 'number' }, account: { type: 'lookup', reference: 'crm_account' }, }, - validations: [{ name: 'guarded', type: 'script', severity: 'error', message: 'fired', condition }], + validations: [{ name: 'sneaky', type: 'script', severity: 'error', message: 'x', + condition: "record.account.not_a_column == 'x'" }], } as any, 'test-package'); - await expect( - engine.insert('crm_opportunity', { name: 'A', amount: 1, account: 'acc_p' }, { context: ACTING } as any), - ).rejects.toThrow(/could not be evaluated/); + await engine.insert('crm_opportunity', { name: 'A', amount: 1, account: 'acc_p' }, { context: ACTING } as any); + } catch (e) { + const text = JSON.stringify((e as any).fields ?? (e as Error).message); + // 'partner' / 'P' / 's' are the stored values on acc_p. + expect(text).not.toContain('partner'); + expect(text).not.toContain('"s"'); } }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 81715a2af31..ddaf3d36a4a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3935,30 +3935,6 @@ export class ObjectQL implements IObjectQLEngine { this.logger.debug('Registered held-file resolver for sys_file hydration'); } - /** - * [#18682] "Which fields of this object may this caller READ?" — supplied by - * the security plugin, never derived here. - * - * A predicate that reads one hop through a reference field must distinguish a - * related column the caller MAY read but which is empty (evaluate it as - * `null`) from one the caller may NOT read (refuse the write). A driver - * returns both as the same missing key, so the answer cannot come from the - * rows — it has to come from schema + context, which is what - * `ISecurityService.getReadableFields` answers. - * - * The engine declares the seam and `@objectstack/plugin-security` fills it, - * the same handover {@link registerHeldFileResolver} makes to storage. Left - * unwired (an embedding with no security plugin) every declared field is - * readable by construction, which is exactly the behaviour such a composition - * has today. - */ - private _readableFieldsResolver?: (object: string, context: unknown) => Promise; - - /** Wire the readable-fields question (#18682). Last registration wins. */ - registerReadableFieldsResolver(fn: (object: string, context: unknown) => Promise): void { - this._readableFieldsResolver = fn; - this.logger.debug('Registered readable-fields resolver for predicate relationship traversal'); - } /** * [#11968] The engine-seam write epoch — the invalidation substrate of the @@ -6958,8 +6934,10 @@ export class ObjectQL implements IObjectQLEngine { type Resolved = { object: string; byId: Map>; - /** Set when NO row of this field is usable, whatever the id. */ - blocked?: { reason: 'unreadable' | 'field-unreadable'; unreadableFields?: string[] }; + /** Named fields the RELATED object does not declare. */ + undeclared?: string[]; + /** Set when the read itself failed, whatever the id. */ + blocked?: boolean; }; const resolved = new Map(); @@ -6968,30 +6946,19 @@ export class ObjectQL implements IObjectQLEngine { if (!target) continue; const named = [...namedFields]; - // ── Readability is decided HERE, from schema + context, never from the - // rows. `getReadableFields` is explicitly "immune to an all-null column - // (which a driver may omit from every row) and to an empty result set", - // which is exactly the ambiguity that must not reach the predicate: a - // column the caller MAY read but which is empty has to evaluate as - // `null`, while a column the caller may NOT read has to refuse. Both - // arrive from a driver as the same missing key, so the answer cannot be - // derived from the data (#6457, one root over and fail-CLOSED). - let readable: string[] | undefined; - if (this._readableFieldsResolver) { - try { - readable = await this._readableFieldsResolver(target, context); - } catch { - // No answer is not a denial; fall through to the read, which is - // itself gated by the security middleware and refuses if it must. - readable = undefined; - } - } - if (readable) { - const denied = named.filter((n) => !readable!.includes(n)); - if (denied.length > 0) { - resolved.set(fk, { object: target, byId: new Map(), blocked: { reason: 'field-unreadable', unreadableFields: denied } }); - continue; - } + // ── The READ SET is the intersection of "what the predicate names" and + // "what the related object DECLARES", and it is computed here so that it + // can never be anything else. A predicate naming a column the related + // object does not declare must not put that name into a system-authority + // query: the read is elevated, so its projection is the whole of what + // bounds it. An undeclared name is also a real authoring fault and is + // reported as one rather than silently dropped. + const targetSchema = this._registry.getObject(target) as { fields?: Record } | undefined; + const declared = targetSchema?.fields; + const undeclared = declared ? named.filter((n) => !(n in declared)) : []; + if (undeclared.length > 0) { + resolved.set(fk, { object: target, byId: new Map(), undeclared }); + continue; } const ids = new Set(); @@ -7004,33 +6971,50 @@ export class ObjectQL implements IObjectQLEngine { } if (ids.size === 0) { resolved.set(fk, { object: target, byId: new Map() }); continue; } try { + // ⭐ SYSTEM authority, and ONLY for this seam. + // + // 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: a member + // with CRUD on the child and no read on the parent faulted on every + // write, so a legitimate business rule could not ship. + // + // What bounds the elevation is the PROJECTION, not the caller: only the + // columns this predicate names, intersected with the related object's + // 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. const query: EngineQueryOptions = { where: { id: { $in: [...ids] } }, fields: [...new Set(['id', ...named])], - context: context as EngineQueryOptions['context'], + context: { ...(context as Record ?? {}), isSystem: true } as EngineQueryOptions['context'], }; const related = await this.find(target, query) as Array>; const byId = new Map>(); - // Materialise the NAMED fields to `null` — but only the ones this caller - // may actually read. With a readable set in hand that is exact; without - // one (no security plugin in this composition) every declared field is - // readable by construction, which is the pre-plugin behaviour. - const fillable = readable ? named.filter((n) => readable!.includes(n)) : named; + // Materialise the named DECLARED columns to `null`. A driver omits a key + // whose value is `undefined`, so without this a legitimately-empty + // parent column would fault and refuse a valid write — #6457's trap, one + // root over and on a fail-CLOSED seam. A column the related object does + // not declare never reaches here (it was reported above), so a real + // "this field does not exist" fault stays distinguishable from a null. for (const row of Array.isArray(related) ? related : []) { if (row?.id == null) continue; const copy: Record = { ...row }; - for (const name of fillable) if (!(name in copy)) copy[name] = null; + for (const name of named) if (!(name in copy)) copy[name] = null; byId.set(String(row.id), copy); } resolved.set(fk, { object: target, byId }); } catch (err) { - // A refusal from the security layer is a real answer: this caller may - // not read the related object. Recorded as such so the predicate refuses - // with a sentence naming it, rather than faulting on a missing key. - this.logger?.warn?.('predicate relationship read refused or failed — the rule will reject the write', { + this.logger?.warn?.('predicate relationship read failed — the rule will reject the write', { object: target, field: fk, error: err, }); - resolved.set(fk, { object: target, byId: new Map(), blocked: { reason: 'unreadable' } }); + resolved.set(fk, { object: target, byId: new Map(), blocked: true }); } } if (resolved.size === 0) return unbound; @@ -7039,10 +7023,11 @@ export class ObjectQL implements IObjectQLEngine { if (!row) return undefined; const binding: Record = {}; for (const [fk, entry] of resolved) { - if (entry.blocked) { - binding[fk] = { object: entry.object, unavailable: entry.blocked.reason, unreadableFields: entry.blocked.unreadableFields }; + if (entry.undeclared) { + binding[fk] = { object: entry.object, unavailable: 'undeclared-field', undeclaredFields: entry.undeclared }; continue; } + if (entry.blocked) { binding[fk] = { object: entry.object, unavailable: 'unreadable' }; continue; } const value = row[fk]; if (value == null || Array.isArray(value) || typeof value === 'object') { binding[fk] = { object: entry.object, unavailable: 'no-reference' }; diff --git a/packages/objectql/src/validation/rule-relationship-traversal.test.ts b/packages/objectql/src/validation/rule-relationship-traversal.test.ts index 3acc01573c5..269b879df35 100644 --- a/packages/objectql/src/validation/rule-relationship-traversal.test.ts +++ b/packages/objectql/src/validation/rule-relationship-traversal.test.ts @@ -45,9 +45,9 @@ const opportunity = { const row = (r: Record) => ({ object: 'crm_account', row: r }); /** The engine could not make the parent readable for this caller. */ const unavailable = ( - reason: 'no-reference' | 'unreadable' | 'field-unreadable' | 'unresolved', - unreadableFields?: string[], -) => ({ object: 'crm_account', unavailable: reason, unreadableFields }); + reason: 'no-reference' | 'unreadable' | 'undeclared-field' | 'unresolved', + undeclaredFields?: string[], +) => ({ object: 'crm_account', unavailable: reason, undeclaredFields }); const evaluate = ( data: Record, @@ -139,7 +139,7 @@ describe('#18682 — the three acceptance outcomes (ADR-0137 D2)', () => { // never silently false either. it('FAULTS LOUDLY and rejects when the related row is unreadable (null)', () => { expect(() => - evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: unavailable('unreadable') }), + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: unavailable('unresolved') }), ).toThrow(ValidationError); }); @@ -149,7 +149,7 @@ describe('#18682 — the three acceptance outcomes (ADR-0137 D2)', () => { it('the fault is reported AS a fault, naming the unevaluable rule', () => { try { - evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: unavailable('unreadable') }); + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: unavailable('unresolved') }); throw new Error('expected a ValidationError'); } catch (e) { const err = e as ValidationError; @@ -166,7 +166,7 @@ describe('#18682 — the three acceptance outcomes (ADR-0137 D2)', () => { expect(() => evaluate( { name: 'A', amount: 50000, account: 'acc_1' }, - { account: unavailable('unreadable') }, + { account: unavailable('unresolved') }, ), ).toThrow(ValidationError); }); @@ -269,8 +269,8 @@ describe('#18682 — the refusal names the RELATED object, not the referencing o // declare", which on a traversal is false in every clause — the field IS // declared, on the related object — and sent the author to the wrong file. const cases: Array<[string, ReturnType, string[]]> = [ - ['unreadable object', unavailable('unreadable'), ["may not read", "'crm_account'"]], - ['unreadable field', unavailable('field-unreadable', ['type']), ["may not read", "'type'"]], + ['read failed', unavailable('unreadable'), ['could not read', "'crm_account'"]], + ['undeclared related field', unavailable('undeclared-field', ['type']), ['declares no', "'type'"]], ['no reference stored', unavailable('no-reference'), ['no related record']], ['related row gone', unavailable('unresolved'), ['could not be read']], ]; @@ -305,10 +305,10 @@ describe('#18682 — the permission verdict does not depend on the CEL operator' ['plain member access', "record.account.type == 'partner'"], ['has() guard', "has(record.account.type) && record.account.type == 'partner'"], ['optional selection', "record.account.?type.orValue('') == 'partner'"], - ])('refuses an unreadable parent — %s', (_name, condition) => { + ])('refuses a genuinely faulted parent — %s', (_name, condition) => { expect(() => evaluateValidationRules(guarded(condition) as any, { name: 'A', account: 'acc_1' }, 'insert', - { related: { account: unavailable('field-unreadable', ['type']) } as never }), + { related: { account: unavailable('undeclared-field', ['type']) } as never }), ).toThrow(ValidationError); }); }); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 2dcc8edc39d..8e66227bd81 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -542,32 +542,43 @@ export type ParentBinding = Record | null | undefined; export type RelatedUnavailableReason = /** The record stores no reference — the FK is null/empty, so there is no row. */ | 'no-reference' - /** The acting user may not read the related object, or the read was refused. */ + /** The related read failed outright. */ | 'unreadable' - /** One or more of the FIELDS the predicate names are not readable by this user. */ - | 'field-unreadable' - /** A reference is stored but the row it names does not exist (or the read threw). */ + /** + * The predicate names a column the RELATED object does not declare. A real + * authoring fault, and deliberately distinct from a column that exists and is + * empty: the latter evaluates as `null`, this one refuses. + */ + | 'undeclared-field' + /** A reference is stored but the row it names does not exist. */ | 'unresolved'; /** * [#18682] What the engine resolved for ONE reference field a predicate reads * through. * - * ⭐ Why this is a discriminated record and not just `row | null`: the permission - * verdict must be decided by the ENGINE, BEFORE evaluation, and it must not - * depend on which CEL operator the author happened to write. Handing CEL an - * absent key delegates the verdict to key-absence semantics, and `has(...)`, - * `.?` and `orValue(...)` all read an absent key as an ordinary `false`/default - * — so a field the caller may not read would quietly stop the rule firing. The - * engine therefore says WHY a row is unusable and {@link checkPredicate} turns - * that into a refusal, rather than letting the expression discover it. - * - * It also separates the two absences #6457 taught us to keep apart: a field that - * is READABLE but legitimately empty is materialised to `null` on `row` (so the - * predicate evaluates), while a field that is NOT readable makes the whole + * ⭐ Why this is a discriminated record and not just `row | null`: the verdict + * must be decided by the ENGINE, BEFORE evaluation, and it must not depend on + * which CEL operator the author happened to write. Handing CEL an absent key + * delegates the verdict to key-absence semantics, and `has(...)`, `.?` and + * `orValue(...)` all read an absent key as an ordinary `false`/default — so a + * genuine fault would quietly stop the rule firing. The engine therefore says + * WHY a row is unusable and {@link checkPredicate} turns that into a refusal, + * rather than letting the expression discover it. + * + * It also separates the two absences #6457 taught us to keep apart: a column the + * related object DECLARES but which is empty is materialised to `null` on `row` + * (so the predicate evaluates), while a column it does not declare makes the * binding unavailable (so the predicate refuses). Before this split both arrived * as "the key is missing" and the verdict depended on which columns a driver * happened to echo. + * + * ⚠️ The related row is read under SYSTEM authority — a validation rule's output + * is a pass/fail the system enforces, not data handed to the caller. The read is + * bounded by its PROJECTION (only the columns the predicate names, intersected + * with the related object's declared fields), never by the caller. ⛔ This + * applies to validation rules alone; RLS and UI predicates are out of the + * capability entirely. */ export interface RelatedFieldBinding { /** The object this reference field points at — named in the refusal text. */ @@ -579,8 +590,8 @@ export interface RelatedFieldBinding { readonly row?: Record; /** Why `row` is absent. Present iff `row` is absent. */ readonly unavailable?: RelatedUnavailableReason; - /** For `field-unreadable`: the named fields this caller may not read. */ - readonly unreadableFields?: readonly string[]; + /** For `undeclared-field`: the named fields the related object does not declare. */ + readonly undeclaredFields?: readonly string[]; } /** Reference FIELD name → what the engine resolved for it. */ @@ -3036,15 +3047,14 @@ function traversalRefusal( + ' so there is no related record to read. Guard the rule on the reference being set,' + ' or make the reference required.', }; - case 'field-unreadable': { - const denied = (binding.unreadableFields ?? []).map((n) => `'${n}'`).join(', ') || columns; + case 'undeclared-field': { + const missing = (binding.undeclaredFields ?? []).map((n) => `'${n}'`).join(', ') || columns; return { - summary: `cannot read ${denied} on '${binding.object}' as this user`, + summary: `'${binding.object}' declares no ${missing}`, detail: - ` The rule reads ${denied} through ${on}. The acting user may not read` - + ` ${denied} on '${binding.object}', so the rule has no verdict and the write is` - + ' rejected rather than allowed on an unchecked rule. Grant read on those fields, or' - + ' rewrite the rule to read only data this caller can see.', + ` The rule reads ${missing} through ${on}, but '${binding.object}' declares no such` + + ` field. Fix the rule's condition, or declare ${missing} on '${binding.object}' —` + + ` ⛔ not on the object carrying this rule.`, }; } case 'unresolved': @@ -3057,11 +3067,10 @@ function traversalRefusal( case 'unreadable': default: return { - summary: `cannot read '${binding.object}' as this user`, + summary: `could not read '${binding.object}'`, detail: - ` The rule reads ${columns} through ${on}, and the acting user may not read` - + ` '${binding.object}'. The rule has no verdict, so the write is rejected rather than` - + ' allowed on an unchecked rule. Grant read on that object, or rewrite the rule.', + ` The rule reads ${columns} through ${on}, and that read failed. The rule has no` + + ' verdict, so the write is rejected rather than allowed on an unchecked rule.', }; } } diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 22bee8e62e7..8c417003494 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1226,22 +1226,6 @@ export class SecurityPlugin implements Plugin { this.metadata = metadata; this.ql = ql; - // [#18682] Fill the engine's readable-fields seam. A validation rule that - // reads one hop through a reference field has to tell a related column the - // caller MAY read but which is empty (evaluate it as `null`) from one the - // caller may NOT read (refuse the write). A driver returns both as the same - // missing key, so the engine cannot derive it from the rows and asks here — - // `getReadableFields` is computed from schema + context and is explicitly - // "immune to an all-null column … and to an empty result set". - // - // Feature-detected on the engine, like every other optional seam this plugin - // fills: an older ObjectQL simply does not offer it, and this plugin must - // keep booting against one. - if (typeof (ql as any).registerReadableFieldsResolver === 'function') { - (ql as any).registerReadableFieldsResolver( - (object: string, context: unknown) => this.getReadableFields(object, context as any), - ); - } // [#11968] Bind the invalidation epoch to the ENGINE's seam when the wired // engine exposes one. Resolved here, once, rather than probed per request: From cdaf954443a65c41bc77bd8aae0af54d14e930a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 14:37:34 +0000 Subject: [PATCH 10/47] fix(dogfood,qa): pick the persona-matrix control account by the rule, not by position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `showcase_invoice` now carries a real business rule — no invoice against a churned account — so the matrix's "admin control payload is VALID" assertion depended on which account the seed happened to return first. The control is chosen to satisfy the rule instead of by position; the rule is untouched. Also states the checklist's known gap as what CI actually measured: the dogfood persona holds invoice CRUD and no grant on showcase_account, which is now the point of the item rather than a gap, because the related read is system-authority. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 32 ++++++++++++------- ...owcase-crud-persona-matrix.dogfood.test.ts | 12 ++++++- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index f1cdabfab26..34912be8ec2 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -41,14 +41,25 @@ The cost is bounded by construction: one hop, only the fields a rule actually names, one batched read per reference field per write, and nothing at all when no rule traverses. -### Permission semantics — absent, loudly - -The related rows are read under the **acting user**, through the engine's own -read path, so the referenced object's CRUD gate, RLS and FLS all apply. A row -or a field the caller may not read therefore does not arrive: the stored id -stays, the traversal faults, and the write is **rejected**. A rule that guards -data the caller cannot see never silently passes — and never silently fails -either. +### Read authority — system, bounded by the projection + +The related row is read under **system authority**. A validation rule's output +is a pass/fail the *system* enforces, not data handed to the caller — 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: a member with CRUD on the child and no read on the parent faulted +on every write. + +What bounds the elevation is the **projection**, not the caller: only the +columns the predicate names, intersected with the related object's declared +fields. A column the related object does not declare never enters the query, and +is refused as the authoring fault it is — distinct from a column that exists and +is empty, which evaluates as `null`. + +⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value +they cannot see by observing which writes are refused. The value itself never +appears — the refusal names the field and the rule, never the value — and the +channel is deliberately no wider than "this rule refused this write". ### Two shapes are refused at authoring time, with a prescription @@ -64,9 +75,8 @@ an object-valued field traverses today and keeps traversing. ### Scope -Object validation rules (`script` / `cross_field`) — the seam that is -fail-closed, and therefore the only one where an unreadable related field can -produce the loud refusal the permission rule above requires. The field-level +Object validation rules (`script` / `cross_field`) — and the system-authority +read is confined to that one seam. The field-level `requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open** and are deliberately not covered here; RLS predicates are out too. Depth is one hop. diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index b8975c3a8d5..15e2a7cce9c 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -307,7 +307,17 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { const firstId = async (object: string) => String((await ql.find(object, { limit: 1, context: SYS }))?.[0]?.id ?? ''); - seed.accountId = await firstId('showcase_account'); + // [#18682] NOT `firstId`: `showcase_invoice` now carries a real business + // rule — an invoice may not be issued against a CHURNED account — so an + // arbitrary first account makes this file's "admin control payload is + // VALID" assertion depend on seed ordering. The control has to be a payload + // the platform actually accepts, so the account is chosen to satisfy the + // rule rather than by position. + seed.accountId = String( + (await ql.find('showcase_account', { + where: { status: { $ne: 'churned' } }, limit: 1, context: SYS, + }))?.[0]?.id ?? '', + ); seed.productId = await firstId('showcase_product'); seed.projectId = await firstId('showcase_project'); seed.invoiceId = await firstId('showcase_invoice'); From 82db310ae2dd04b6496f3b9cea3c851c24024af6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 15:03:27 +0000 Subject: [PATCH 11/47] fix(objectql): keep the tracker id out of runtime prose and hold the bound in the test double Two gate findings from this diff. The new unevaluable log line copied a tracker id into prose an operator reads; ids belong in the lesson, not the sentence. And the new engine test's `find` double ignored the caller's `limit`, which is how a real double-limit defect rides through unnoticed. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../objectql/src/engine-predicate-relationship.test.ts | 7 +++++-- packages/objectql/src/validation/rule-validator.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/engine-predicate-relationship.test.ts b/packages/objectql/src/engine-predicate-relationship.test.ts index 410379543fc..1ac6c5c188c 100644 --- a/packages/objectql/src/engine-predicate-relationship.test.ts +++ b/packages/objectql/src/engine-predicate-relationship.test.ts @@ -59,9 +59,12 @@ function makeDriver() { // The driver echoes ONLY the projected keys, and — like `driver-memory` — // omits a key whose value is `undefined`. That omission is the shape this // file pins the engine against. + // Hold the caller's bound: a double that ignores `limit` lets a real + // double-limit defect through unnoticed (`check:objectql-double-limit`). + const bounded = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; const fields: string[] | undefined = ast?.fields; - if (!fields) return rows; - return rows.map((r) => { + if (!fields) return bounded; + return bounded.map((r) => { const out: any = {}; for (const f of fields) if (r[f] !== undefined) out[f] = r[f]; return out; diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 8e66227bd81..8d51c01eecb 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -3095,7 +3095,7 @@ function checkPredicate( const scope = resolveTraversalScope(record, expr.source, related, fields); if (!scope.ok) { logger?.warn?.( - `Validation rule '${rule.name}' predicate could not be evaluated (${scope.summary}) — write rejected (#4649)`, + `Validation rule '${rule.name}' predicate could not be evaluated (${scope.summary}) — write rejected`, ); return { field, From 995266cea04facfc44da01bab85140fbb1632a40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 15:07:33 +0000 Subject: [PATCH 12/47] chore(plugin-security): drop a stray blank line left by a reverted seam The acting-user model needed a readable-fields resolver wired here; the ruled system-authority read does not. Removing the wiring left one blank line behind, which kept a package this card no longer touches inside its diff. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/plugins/plugin-security/src/security-plugin.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 8c417003494..3a8fd52073f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1226,7 +1226,6 @@ export class SecurityPlugin implements Plugin { this.metadata = metadata; this.ql = ql; - // [#11968] Bind the invalidation epoch to the ENGINE's seam when the wired // engine exposes one. Resolved here, once, rather than probed per request: // the plugin DI graph is static after start, and a per-request probe would From 0446ca9f985d1f8d348d0ea1f01439e207fd949e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 15:54:09 +0000 Subject: [PATCH 13/47] fix(objectql): make the prescribed repair work, gate the dry run, and retire the acting-user copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings. The repair the conflict refusal prescribes did not exist. It tells an author to compare `record..id`, and the engine refused that too — the primary key is declared by the platform, not the author, so it is absent from every object's field map — then prescribed declaring `id` on the related object, which is equally impossible. The PK spelling now counts as declared for traversal resolution; the read set widens by nothing, since `id` was already unconditionally in the projection. Pinned end to end: take the message the refusal emits, author the rule it asks for, require it accepted, and require it to fire for real so `.id` is proven to resolve. The dry run handed the accepted inference channel to non-writers. On the write path the CRUD gate runs in middleware, so only someone who could write could observe a rule's verdict — which is the bound the channel was accepted under. `validate()` runs no middleware for its target and its import ingress checks no caller CRUD, so the elevated read reached any authenticated caller. It is now behind the caller's own create/update gate, answered by the security plugin with the same evaluator the CRUD gate uses. A caller who could not perform the write gets no elevated read at all and the predicate refuses. The docblock no longer claims nothing is executed. The superseded acting-user semantics survived in the code after the visible copy was corrected, including surfaces that ship: the exported `EvaluateRulesOptions.related` docblock, the resolver header and both call sites, the reason union, both test headers, the checklist source and history, an orphaned docblock with two dangling links, and — worst — the showcase example telling reference-app readers the opposite of what the dogfood on this head measures. All swept. Also: lint owes a changeset line, the no-reference sentence now names all three stored shapes that reach it, and the computed-receiver traversal is documented as deliberately unprescribed. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 1 + .../areas/records-forms.json | 4 +- .../src/data/objects/invoice.object.ts | 9 +- .../formula/src/relationship-traversal.ts | 9 ++ .../src/engine-predicate-relationship.test.ts | 92 +++++++++++++- packages/objectql/src/engine.ts | 119 ++++++++++++++---- .../rule-relationship-traversal.test.ts | 27 ++-- .../objectql/src/validation/rule-validator.ts | 79 +++++------- .../plugin-security/src/security-plugin.ts | 33 +++++ 9 files changed, 280 insertions(+), 93 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index 34912be8ec2..17335618bd9 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -1,5 +1,6 @@ --- '@objectstack/formula': minor +'@objectstack/lint': minor '@objectstack/objectql': minor --- diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 7d00ef0d856..767c362120f 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -4166,14 +4166,14 @@ ], "source": [ "examples/app-showcase/src/data/objects/invoice.object.ts (no_invoice_for_churned_account)", - "packages/objectql/src/validation/rule-validator.ts (checkPredicate, hydrateRelated, collectPredicateRelationships)", + "packages/objectql/src/validation/rule-validator.ts (checkPredicate, resolveTraversalScope, collectPredicateRelationships)", "packages/formula/src/relationship-traversal.ts (which hops a predicate names)" ], "history": [ { "revision": 1, "date": "2026-09-22", - "change": "initial — acceptance for predicate relationship traversal under ADR-0137 D2 (a field-rule predicate that faults refuses the submit and names the field and the rule); the capability's three ruled outcomes (passes / refuses / faults loudly on an unreadable parent)", + "change": "initial — acceptance for predicate relationship traversal under ADR-0137 D2 (a field-rule predicate that faults refuses the submit and names the field and the rule). The related record is read under SYSTEM authority bounded by the fields the predicate names, so the ruled outcomes are: the write passes when the parent field does not trip the rule, is refused with the AUTHORED message when it does, and is refused naming the related object when the rule cannot be evaluated at all", "ref": "claude/issue-18682-predicate-relationship-traversal" } ] diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index 63b860eae57..9b7b3d720f9 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -174,9 +174,12 @@ export const Invoice = ObjectSchema.create({ // unenforced on the server is the shape the platform refuses to ship. // // The predicate states the FAILURE condition. The engine reads the - // account under the ACTING USER before evaluating, so a rep who cannot - // read the account does not get a quiet pass: the rule faults and the - // write is rejected. + // account under SYSTEM authority before evaluating — a validation rule's + // output is a pass/fail the system enforces, not data handed to the user + // — and reads ONLY the columns the predicate names. So a rep who cannot + // read accounts at all is still held to this rule, and can still file + // invoices against the accounts it allows. Reading as the rep instead + // would make the rule unauthorable for exactly the person it constrains. type: 'script' as const, name: 'no_invoice_for_churned_account', label: 'No Invoice For Churned Account', diff --git a/packages/formula/src/relationship-traversal.ts b/packages/formula/src/relationship-traversal.ts index a81ab2afd5f..a4dfe650929 100644 --- a/packages/formula/src/relationship-traversal.ts +++ b/packages/formula/src/relationship-traversal.ts @@ -102,6 +102,15 @@ function isRootId(node: unknown, root: string): boolean { * * An index whose key is not a literal string (`a[someVar]`) names no field this * analysis can resolve, so it is not a member access here. + * + * ⚠️ A traversal whose RECEIVER is computed rather than named — the ternary + * shape `(c ? record.a : record.b).type` — is likewise not recognised, and that + * is deliberate rather than an oversight: which reference field is being read + * is not decidable before evaluation, so there is nothing the engine could + * preload. Such an expression is left to fault at evaluation, which on this + * fail-CLOSED seam rejects the write. ⛔ It carries no prescription, because + * the only honest one would be "write the traversal on a named field", which is + * a rewrite of the author's expression rather than a repair of it. */ const MEMBER_OPS = new Set(['.', '.?']); const INDEX_OPS = new Set(['[]', '[?]']); diff --git a/packages/objectql/src/engine-predicate-relationship.test.ts b/packages/objectql/src/engine-predicate-relationship.test.ts index 1ac6c5c188c..7b0da2e0a44 100644 --- a/packages/objectql/src/engine-predicate-relationship.test.ts +++ b/packages/objectql/src/engine-predicate-relationship.test.ts @@ -9,16 +9,18 @@ * engine behaviour that only a call site can show (PD #10: a `case` label is not * enforcement — check the CALL SITE): * - * - the related read runs under the ACTING USER's context, ⛔ never `isSystem` - * (`resolveMasterDetailParent` reads as system on purpose; this must not); - * - the projection names `id` plus only the fields the rules actually read; + * - the related read runs under SYSTEM authority (like `parent`, and for the + * same kind of reason: a validation verdict is the system's, not the + * caller's), bounded by its PROJECTION rather than by the caller; + * - the projection names `id` plus only the fields the rules actually read, + * and never smuggles a column the related object does not declare; * - on UPDATE the foreign key is read off the PRIOR row when the patch omits * it, so a rule still resolves; * - the driver's write payload carries the foreign KEY, never the expanded * related record; - * - a readable-but-empty related column evaluates as `null` rather than + * - a DECLARED but empty related column evaluates as `null` rather than * refusing (#6457's trap, one root over and on a fail-CLOSED seam); - * - an unreadable related field refuses, whichever CEL operator was written. + * - an unresolvable related field refuses, whichever CEL operator was written. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -272,6 +274,86 @@ describe('#18682 — engine-produced relationship bindings', () => { expect(refused.results?.[0]?.valid).toBe(false); }); + // ⭐ THE REPAIR THE REFUSAL PRESCRIBES MUST ACTUALLY WORK. + // + // The conflict arm tells an author to compare `record..id`. The primary + // key is declared by the platform, not by the author, so it is absent from + // every object's field map — which made the engine refuse the very spelling + // it had just prescribed, and then prescribe declaring `id` on the related + // object, which is equally impossible. An actively misleading prescription is + // worse than none (ADR-0078 §6), so this drives the loop end to end: take the + // message the refusal emits, write the rule it asks for, and require that it + // is ACCEPTED. + it('accepts the repair its own refusal prescribes', async () => { + const mixed = "record.account.type == 'partner' && record.account == 'acc_p'"; + engine.registry.registerObject({ + name: 'crm_opportunity', + fields: { + name: { type: 'text' }, amount: { type: 'number' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + validations: [{ name: 'mixed', type: 'script', severity: 'error', message: 'x', condition: mixed }], + } as any, 'test-package'); + + let prescription = ''; + try { + await engine.insert('crm_opportunity', { name: 'A', amount: 1, account: 'acc_p' }, { context: ACTING } as any); + throw new Error('expected the mixed shape to be refused'); + } catch (e) { + prescription = JSON.stringify((e as any).fields ?? (e as Error).message); + } + // The refusal names the repair… + expect(prescription).toContain('record.account.id'); + + // …and the repair is accepted, on the same engine, against the same row. + engine.registry.registerObject({ + name: 'crm_opportunity', + fields: { + name: { type: 'text' }, amount: { type: 'number' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + validations: [{ + name: 'repaired', type: 'script', severity: 'error', message: 'partner acc_p is capped', + condition: "record.account.type == 'partner' && record.account.id == 'acc_p' && record.amount > 10000", + }], + } as any, 'test-package'); + + // Under the cap: accepted, and NOT with an unevaluable fault. + await expect( + engine.insert('crm_opportunity', { name: 'ok', amount: 10, account: 'acc_p' }, { context: ACTING } as any), + ).resolves.toBeTruthy(); + // Over the cap: the rule fires for real, so `.id` genuinely resolved. + await expect( + engine.insert('crm_opportunity', { name: 'no', amount: 50000, account: 'acc_p' }, { context: ACTING } as any), + ).rejects.toThrow(/partner acc_p is capped/); + }); + + // The gate that keeps the accepted inference channel to writers only. A + // caller who could not perform this write gets NO elevated read at all. + it('issues NO elevated read in validate() for a caller who may not write', async () => { + (engine as any).registerWriteGateProbe(async () => false); + d.calls.length = 0; + const preview = await engine.validate( + 'crm_opportunity', { name: 'A', amount: 50000, account: 'acc_d' }, + { mode: 'insert', context: ACTING } as any, + ); + // ⛔ Nothing was read on the related object. + expect(d.calls.filter((c) => c.object === 'crm_account')).toHaveLength(0); + // …and the preview refuses rather than answering from data it never read. + expect(preview.results?.[0]?.valid).toBe(false); + }); + + it('issues the elevated read in validate() for a caller who MAY write', async () => { + (engine as any).registerWriteGateProbe(async () => true); + d.calls.length = 0; + const preview = await engine.validate( + 'crm_opportunity', { name: 'A', amount: 50000, account: 'acc_d' }, + { mode: 'insert', context: ACTING } as any, + ); + expect(d.calls.filter((c) => c.object === 'crm_account').length).toBeGreaterThan(0); + expect(preview.results?.[0]?.valid).toBe(true); + }); + it('pays nothing when no rule traverses', async () => { engine.registry.registerObject({ name: 'plain', diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index ddaf3d36a4a..11ecd817a0e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -58,6 +58,7 @@ import { renderOperationMessage, objectLabelKey, resolveBundleLocale, + SystemFieldName, } from '@objectstack/spec/system'; import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; @@ -3935,6 +3936,48 @@ export class ObjectQL implements IObjectQLEngine { this.logger.debug('Registered held-file resolver for sys_file hydration'); } + /** + * [#18682] "May this context CREATE or UPDATE this object?" — supplied by the + * security plugin, never decided here. + * + * ## What it protects, and why only the dry run needs it + * + * A validation rule reads its related record under SYSTEM authority, and the + * accepted cost of that is an inference channel: a caller can learn something + * about a value they cannot read by observing which of THEIR WRITES are + * refused. That bound holds on the real write path for free — the CRUD gate + * runs in middleware and refuses a caller with no write grant long before any + * rule is evaluated, so only someone who could already write the row can + * observe anything at all. + * + * `validate()` runs NO middleware for the target object, by design: it + * executes nothing. Its network ingress (the `dryRun` import) checks auth and + * API access but not the caller's CRUD grant on the object. So the elevated + * read, wired into the preview without this gate, would hand the same + * one-bit-per-row oracle to any authenticated caller with no permission on the + * object and no ability to write it at all — a strictly wider channel than the + * one that was accepted. + * + * This restores the accepted bound rather than narrowing or widening it: the + * preview answers for callers who could perform the write, and for nobody + * else. + * + * ## Unwired + * + * A composition with no security plugin has no CRUD gate on the write path + * either, so there is no bound to restore and the preview resolves. That is + * the same behaviour such a composition already has everywhere else. + */ + private _writeGateProbe?: (object: string, operation: 'insert' | 'update', context: unknown) => Promise; + + /** Wire the create/update gate question (#18682). Last registration wins. */ + registerWriteGateProbe( + fn: (object: string, operation: 'insert' | 'update', context: unknown) => Promise, + ): void { + this._writeGateProbe = fn; + this.logger.debug('Registered write-gate probe for validate() relationship resolution'); + } + /** * [#11968] The engine-seam write epoch — the invalidation substrate of the @@ -6898,23 +6941,27 @@ export class ObjectQL implements IObjectQLEngine { * — 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 as the ACTING USER, deliberately unlike `parent` + * ## Read under SYSTEM authority, like `parent` and for a related reason * * {@link resolveMasterDetailParent} reads as SYSTEM because a master-detail * lock is a property of the header's state, not of the caller's visibility of - * it. This read is the opposite case: the value lands in a predicate whose - * verdict the caller can observe through the accept/reject of their own - * write, so it goes through the engine's own `find` path under the caller's - * context and the referenced object's CRUD gate, RLS and FLS all apply. A row - * — or a field — the caller may not read therefore does not arrive. + * it. 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, not the caller: only the + * columns the predicate names, intersected with the related object's declared + * fields. ⛔ Never the whole row. * - * ## An unresolved row is left ABSENT, and that is the loud answer + * ## An unresolved row is left UNAVAILABLE, and it says which kind * - * No id, row gone, refused by the security layer, read threw: all four leave - * the field unbound. The stored id stays in the record, the traversal faults - * with `No such key`, and an unevaluable validation predicate REJECTS the - * write (#4649). ⛔ Never silently true, and never silently false — the two - * verdicts a security-relevant absence must not be allowed to pick between. + * No reference stored, row gone, the related object declares no such column, + * or the read failed: each is recorded as its own reason, and + * {@link checkPredicate} turns it into a refusal naming the related object and + * column. The write is REJECTED rather than judged on a rule that produced no + * verdict. ⛔ Never silently true, and never silently false. * * The projection always names `id` alongside the fields the rules read: * the map below is keyed on `row.id`, and a projection that omitted it would @@ -6955,7 +7002,21 @@ export class ObjectQL implements IObjectQLEngine { // reported as one rather than silently dropped. const targetSchema = this._registry.getObject(target) as { fields?: Record } | undefined; const declared = targetSchema?.fields; - const undeclared = declared ? named.filter((n) => !(n in declared)) : []; + // [#8215] The PRIMARY KEY is declared by the platform, not by the author, + // so it is absent from every object's field map — the map carries the + // injected audit/tenant/owner columns but never the PK. It still has to + // count as declared HERE, because `record..id` is the repair this + // capability's own conflict refusal prescribes: without this the engine + // refuses the very spelling it just told the author to write, and then + // hands them a second prescription ("declare `id` on the related object") + // that is equally impossible. An actively misleading prescription is + // worse than none (ADR-0078 §6). + // + // ⛔ This widens the READ SET by nothing: `id` is already unconditionally + // in the projection below, because the by-id map is keyed on it. + const declaredHere = (n: string): boolean => + n === SystemFieldName.ID || !!declared && n in declared; + const undeclared = declared ? named.filter((n) => !declaredHere(n)) : []; if (undeclared.length > 0) { resolved.set(fk, { object: target, byId: new Map(), undeclared }); continue; @@ -10704,7 +10765,14 @@ export class ObjectQL implements IObjectQLEngine { // // Both helpers are pure and synchronous: they read the registry, copy the // row, and touch neither driver nor hook — so running them here keeps the - // "nothing is written, nothing is executed" contract intact. `update()` + // "nothing is WRITTEN" contract intact. + // + // ⚠️ "Nothing is executed" is no longer literally true and must not be + // restated as if it were: a traversing validation rule needs its related + // rows, so this operation issues a READ per reference field the rules name + // (see the `previewRelatedForRow` block below). Nothing is written, no hook + // runs, and the read happens only for a caller who could perform the write + // being previewed. `update()` // deliberately does not default (#2706: a PATCH's explicit `null` means // "clear it"), so neither does an `update`-mode preview. const rawRows = Array.isArray(data) ? data : [data]; @@ -10730,17 +10798,25 @@ export class ObjectQL implements IObjectQLEngine { // accepts — the false alarm this operation was created to prevent, and the // import dry run rides on it. // - // Resolved once for the whole set, like every other posture input above, - // and under the CALLER's context so the preview's permission answer is the - // caller's own. ⚠️ Named limit, not widened here: an `update`-mode preview + // Resolved once for the whole set, like every other posture input above. + // ⚠️ Named limit, not widened here: an `update`-mode preview // carries no prior row (nothing is read), so a traversing rule whose FK the // PATCH does not itself carry has no id to resolve and still refuses. The // real update path reads the prior row and does resolve it; closing the // preview's half needs a read this operation's "nothing is executed" // contract does not make. - const previewRelatedForRow = await this.resolvePredicateRelated( - schemaForValidation, rows, options?.context, - ); + // ⛔ Behind the caller's own create/update gate — see + // {@link registerWriteGateProbe} for why the preview needs a gate the write + // path gets from middleware for free. A caller who could not perform this + // write gets NO elevated read: `related` stays unresolved, and a traversing + // rule then refuses, which is the fail-closed direction and is honest about + // what it did not evaluate. + const mayWrite = this._writeGateProbe + ? await this._writeGateProbe(object, mode, options?.context).catch(() => false) + : true; + const previewRelatedForRow = mayWrite + ? await this.resolvePredicateRelated(schemaForValidation, rows, options?.context) + : () => undefined; const results: NonNullable = rows.map((row) => { const warnings: ValidateDataIssue[] = []; @@ -11469,7 +11545,8 @@ export class ObjectQL implements IObjectQLEngine { : undefined; // [#18682] The related rows this object's predicate rules read one hop // through a reference field. Batched across the whole insert, and free - // when no rule traverses. Read under the CALLER's context, not system. + // when no rule traverses. Read under SYSTEM authority, bounded by the + // projection — see `resolvePredicateRelated`. const insertRelatedForRow = await this.resolvePredicateRelated(schemaForValidation, rows, opCtx.context); for (let i = 0; i < rows.length; i++) { if (rowErrors[i] !== undefined) continue; diff --git a/packages/objectql/src/validation/rule-relationship-traversal.test.ts b/packages/objectql/src/validation/rule-relationship-traversal.test.ts index 269b879df35..77bda61cbab 100644 --- a/packages/objectql/src/validation/rule-relationship-traversal.test.ts +++ b/packages/objectql/src/validation/rule-relationship-traversal.test.ts @@ -130,20 +130,21 @@ describe('#18682 — the three acceptance outcomes (ADR-0137 D2)', () => { } }); - // ── FAULTS LOUDLY when the acting user cannot read the parent field ─────── + // ── REFUSES LOUDLY when the rule cannot be evaluated at all ─────────────── // - // The engine reads the related row under the ACTING USER, so a row the user - // may not read arrives as `null` and is deliberately NOT overlaid. The stored - // id stays, the traversal faults with `No such key`, and an unevaluable - // validation predicate REJECTS the write (#4649). Never silently true, and - // never silently false either. - it('FAULTS LOUDLY and rejects when the related row is unreadable (null)', () => { + // The related row is read under SYSTEM authority, so "the caller may not read + // it" is not a cause here. What remains is: no reference stored, the row is + // gone, the related object declares no such column, or the read failed. Each + // is refused with a sentence naming the related object, and the write is + // REJECTED rather than judged on a rule that produced no verdict. Never + // silently true, and never silently false either. + it('REFUSES LOUDLY and rejects when the related row could not be resolved', () => { expect(() => evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: unavailable('unresolved') }), ).toThrow(ValidationError); }); - it('FAULTS LOUDLY and rejects when no binding was supplied at all', () => { + it('REFUSES and rejects when no binding was supplied at all', () => { expect(() => evaluate({ name: 'A', amount: 50000, account: 'acc_1' })).toThrow(ValidationError); }); @@ -159,10 +160,10 @@ describe('#18682 — the three acceptance outcomes (ADR-0137 D2)', () => { } }); - // The unreadable case must not be quietly waved through even when the LOCAL - // half of the predicate would have decided it. Short-circuit order is not a - // permission decision. - it('rejects on an unreadable parent even when the local half is false', () => { + // An unresolvable parent must not be quietly waved through even when the + // LOCAL half of the predicate would have decided it. Short-circuit order is + // not a verdict. + it('rejects on an unresolvable parent even when the local half is false', () => { expect(() => evaluate( { name: 'A', amount: 50000, account: 'acc_1' }, @@ -271,7 +272,7 @@ describe('#18682 — the refusal names the RELATED object, not the referencing o const cases: Array<[string, ReturnType, string[]]> = [ ['read failed', unavailable('unreadable'), ['could not read', "'crm_account'"]], ['undeclared related field', unavailable('undeclared-field', ['type']), ['declares no', "'type'"]], - ['no reference stored', unavailable('no-reference'), ['no related record']], + ['no reference stored', unavailable('no-reference'), ['no single related record', 'MULTIPLE references']], ['related row gone', unavailable('unresolved'), ['could not be read']], ]; diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 8d51c01eecb..126865fab83 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -292,7 +292,8 @@ interface RuleContext { * authored `rule.message` (#14253) — one hook, two message sources. */ messages: ValidationMessageContext | undefined; /** [#18682] Related rows the engine resolved for this write, or undefined - * when it resolved none. Applied per rule — see {@link hydrateRelated}. */ + * when it resolved none. Applied per rule — see + * {@link resolveTraversalScope}. */ related: RelatedRecordBinding | undefined; } @@ -390,18 +391,18 @@ export interface EvaluateRulesOptions { * not be read. * * Only the engine owns a driver, so it resolves these and hands them over — - * the same division of labour `parent` follows. One difference, and it is - * deliberate: `parent` reads as SYSTEM because a master-detail lock is a - * property of the header's state, whereas these rows are read under the - * ACTING USER so the referenced object's RLS and FLS apply. A field the user - * may not read therefore arrives ABSENT, the predicate faults on it, and a - * faulting validation predicate REJECTS the write (#4649) — loudly, never - * silently true. + * the same division of labour `parent` follows, and like `parent` the read is + * made under SYSTEM authority. A validation rule's output is a pass/fail the + * SYSTEM enforces, not data handed to the caller, which is why RLS predicates + * are excluded from this capability altogether. What bounds the elevation is + * the PROJECTION — only the columns the predicate names, intersected with the + * related object's declared fields — never the caller. * * ⛔ NOT applied to every rule alike. A rule is hydrated only for the * reference fields ITS OWN condition reads through, because hydrating a field * replaces its stored id with the related record: a sibling rule that - * compares the bare id must keep seeing the id. See {@link hydrateRelated}. + * compares the bare id must keep seeing the id. See + * {@link resolveTraversalScope}. */ related?: RelatedRecordBinding; /** @@ -465,9 +466,9 @@ export function needsPriorRecord( * (#4649) — the one policy under which an unreadable related field produces the * loud refusal the permission rule requires. The field-level `requiredWhen` / * `readonlyWhen` / option `visibleWhen` predicates are deliberately NOT - * collected here: they fail OPEN, so a related field the acting user cannot - * read would silently not enforce their gate, which is the opposite of what a - * permission-sensitive read must do. They are their own card. + * collected here: they fail OPEN, so a rule that could not be evaluated would + * silently not enforce their gate — the opposite of what this capability's + * refusal is for. They are their own card. * * ## Only REFERENCE-typed fields * @@ -534,10 +535,10 @@ export type ParentBinding = Record | null | undefined; /** * [#18682] Reference FIELD name → the related row, or `null` when it could not - * be read (no id stored, row gone, or the acting user may not read it). The - * three collapse on purpose: every one of them means "this predicate cannot be - * answered from data the caller is allowed to see", and the predicate must - * fault rather than quietly pick a verdict. + * be read (no reference stored, the row is gone, the related object declares no + * such column, or the read failed). They do NOT collapse: each names itself in + * the refusal, because "there is no parent" and "that column does not exist" + * send an author to different repairs. */ export type RelatedUnavailableReason = /** The record stores no reference — the FK is null/empty, so there is no row. */ @@ -2904,35 +2905,6 @@ function analysisFor(source: string): RelationshipTraversalAnalysis | null { return analysis; } -/** - * [#18682] Overlay the related rows THIS predicate reads through onto a COPY of - * the record. - * - * ## Why a copy, always - * - * The record handed to a rule is the engine's merged write payload. Hydrating - * it in place would replace a stored foreign key with the related RECORD and - * then hand that to the driver — writing an expanded object into the column. - * The copy is shallow, which is enough: only the top-level reference keys are - * replaced, and nothing mutates the related rows themselves. - * - * ## Why per RULE, and not once per write - * - * Hydrating `crm_account` makes `record.crm_account` the related record, so a - * rule comparing the bare id would stop matching. Rules on one object do not - * have to agree about how they read a field, so each rule is hydrated for - * exactly the fields ITS OWN condition reads through. A rule that never - * traverses is handed the record untouched — byte-for-byte the pre-#18682 - * input, which is what keeps this change invisible to every existing rule. - * - * ## Absence is left absent, deliberately - * - * A field with no entry, or an entry of `null`, is NOT overlaid: the stored id - * stays, `record..` faults with `No such key`, and an unevaluable - * validation predicate rejects the write (#4649). That is the loud failure the - * permission rule requires — a related row the acting user may not read must - * never resolve to a quiet verdict. - */ /** What {@link resolveTraversalScope} decided for one predicate. */ type TraversalScope = /** Evaluate against `record` (hydrated where the rule traverses). */ @@ -3040,12 +3012,21 @@ function traversalRefusal( const on = `\`${field}\` (object '${binding.object}')`; switch (binding.unavailable) { case 'no-reference': + // Three stored shapes reach this arm and the sentence names all three, + // because "empty" and "a list" and "already expanded" send an author to + // different repairs: a null/empty FK has nothing to read; a MULTI-valued + // reference names no single related record, so one hop is not defined on + // it at all; and a slot already holding an expanded object is not a + // foreign key this can resolve from. return { - summary: `cannot read ${columns} through ${on}: no related record`, + summary: `cannot read ${columns} through ${on}: no single related record`, detail: - ` The rule reads ${columns} through ${on}, but this record stores no reference there,` - + ' so there is no related record to read. Guard the rule on the reference being set,' - + ' or make the reference required.', + ` The rule reads ${columns} through ${on}, but this record holds no single` + + ' reference there to read — the field is empty, holds MULTIPLE references, or' + + ' already holds an expanded record rather than an id. A predicate resolves ONE' + + ' hop through a single reference. Guard the rule on the reference being set, make' + + ' it required, or — for a multi-value reference — test it with a macro' + + ' (`exists`, `size`) instead of reading through it.', }; case 'undeclared-field': { const missing = (binding.undeclaredFields ?? []).map((n) => `'${n}'`).join(', ') || columns; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 3a8fd52073f..57e0851f165 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1226,6 +1226,39 @@ export class SecurityPlugin implements Plugin { this.metadata = metadata; this.ql = ql; + // [#18682] Answer the engine's create/update gate question for `validate()`. + // + // The real write path gets this gate from the middleware below for free, so + // only a caller who could write can observe a validation rule's verdict. + // `validate()` runs no middleware for its target object, so without this the + // dry run would answer for callers the write path refuses. Same evaluator + // and same permission sets the CRUD gate itself uses — ⛔ not a second + // decision about who may write. + // + // Fails CLOSED: any resolution error denies, and the engine treats a denial + // as "resolve nothing", which makes a traversing rule refuse in preview. + if (typeof (ql as any).registerWriteGateProbe === 'function') { + (ql as any).registerWriteGateProbe( + async (object: string, operation: 'insert' | 'update', context: any): Promise => { + if (context?.isSystem) return true; + if (!context?.userId) return false; + try { + const meta = await this.getObjectSecurityMeta(object); + const sets = await this.resolvePermissionSetsForContext(context); + return this.permissionEvaluator.checkObjectPermission( + operation, object, sets, { isPrivate: meta.isPrivate }, + ); + } catch (e) { + this.logger.warn?.( + `[security] validate() write-gate probe failed for object '${object}' — denying (fail-closed)`, + e instanceof Error ? e : new Error(String(e)), + ); + return false; + } + }, + ); + } + // [#11968] Bind the invalidation epoch to the ENGINE's seam when the wired // engine exposes one. Resolved here, once, rather than probed per request: // the plugin DI graph is static after start, and a per-request probe would From 1201afdfc7993e2442f7e9fdb71817cd9e3cd78b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 16:37:00 +0000 Subject: [PATCH 14/47] chore(docs): re-derive the system-context census after the validation-rule elevation The related-record read for a traversing validation rule is a new elevation site, so the census page's six declared counts moved 110 to 111. Mechanical, regenerated with `gen:system-context-census`. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 57a87493f92..12c6bd07067 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -9,7 +9,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **110 +because the flag is not one concept: it is a single boolean read at **111 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -133,7 +133,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 110 sites**. +The largest single consumer — **17 of the 111 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -279,7 +279,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 110 read sites +- **Shipped semantics.** `isSystem` is a published contract with 111 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -353,12 +353,12 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 23 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 116 | ✅ | +| — parsed as a property **read** | 117 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **110** | ✅ | -| — behaviour-bearing (rows 1–63 above) | 106 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **111** | ✅ | +| — behaviour-bearing (rows 1–63 above) | 107 | ✅ | | — carry the flag onward only (rows 64–67 above) | 4 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 45 | ✅ | From ac98ad3df5f9d0106f02e6d51d7da3d3d1b0cb3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 17:10:20 +0000 Subject: [PATCH 15/47] fix(plugin-security,objectql): make the write-gate probe the write path's own decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe checked `isSystem`, a principal and the CRUD grant. The middleware's write gate composes more, and two caller classes reached the write PREVIEW that `insert()` refuses: one holding `allowCreate` but not an ADR-0066 D3 capability the object requires, and an ADR-0090 D10 `onBehalfOf` naming a delegator that does not exist. The first is reachable from the wire through the dryRun import. `SecurityPlugin.canWriteObject` is now the write sibling of the existing `canReadObject` — the same six arms in the middleware's own order, ending at the CRUD grant rather than starting there — and the probe delegates to it. ⭐ The fix is the EQUIVALENCE pin, not the two arms. `can-write-object-admission` drives the real registered middleware and the method over the same cases and asserts the answers are equal, so a future edit to either one fails there whichever way it moves. Hand-adding two arms would have closed today's gap and left the next one open. Also: the refusal sentence that still named the caller's visibility, which cannot be a cause under a system read; three softer survivals of the same superseded semantics; the shipped docblock that described the binding as `row | null`; a census ROW for the new elevation, which the gate could only demand once the read moved out of an inline closure into a named method; the changeset entry plugin-security was owed; and a boot warn when a middleware-capable engine offers no seam. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 16 ++ content/docs/permissions/system-context.mdx | 3 +- .../formula/src/relationship-traversal.ts | 4 +- .../rule-relationship-traversal.test.ts | 9 +- .../objectql/src/validation/rule-validator.ts | 15 +- .../src/can-write-object-admission.test.ts | 232 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 144 +++++++++-- 7 files changed, 393 insertions(+), 30 deletions(-) create mode 100644 packages/plugins/plugin-security/src/can-write-object-admission.test.ts diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index 17335618bd9..ef4747a728e 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -2,6 +2,7 @@ '@objectstack/formula': minor '@objectstack/lint': minor '@objectstack/objectql': minor +'@objectstack/plugin-security': minor --- A validation rule can read one hop through a lookup — `record.account.type` on an opportunity resolves the owning account's field instead of faulting (#18682) @@ -74,6 +75,21 @@ Both fault at evaluation today, so neither removes anything that works: A field that is **not** reference-typed is untouched: `record.address.city` on an object-valued field traverses today and keeps traversing. +### `@objectstack/plugin-security` gains `canWriteObject` + +The object-level WRITE admission — the sibling of the existing `canReadObject`, +and the same six arms in the middleware's own order: system bypass, no resolved +permission sets, unresolvable posture, the ADR-0066 D3 `requiredPermissions` +capability AND-gate for both principals, the CRUD grant, and the ADR-0090 D10 +delegator check. It exists for doors that must ask "could this caller perform +this write" without running the engine middleware — the write preview is the +first — and an equivalence suite pins its answer EQUAL to the registered +middleware's, case for case, so the two cannot drift. + +⛔ Object-level only. `true` never means the write will succeed: record scope, +field-level security, `readonlyWhen` and the rules themselves are all still +ahead of it. + ### Scope Object validation rules (`script` / `cross_field`) — and the system-authority diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 12c6bd07067..ff55b20ef42 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -101,6 +101,7 @@ that silently does not happen. | 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` | | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | +| 6b | Object-level WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to callers who could perform the write. An equivalence suite pins the answer EQUAL to the registered middleware's, case for case, including the ADR-0066 D3 capability arm and the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | @@ -362,7 +363,7 @@ still holds equal to the census on every pull request: | — carry the flag onward only (rows 64–67 above) | 4 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 45 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 91 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 92 | ✅ | | — of those files, the ones holding more than one read in one symbol | 9 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were diff --git a/packages/formula/src/relationship-traversal.ts b/packages/formula/src/relationship-traversal.ts index a4dfe650929..3be4bbe3441 100644 --- a/packages/formula/src/relationship-traversal.ts +++ b/packages/formula/src/relationship-traversal.ts @@ -90,8 +90,8 @@ function isRootId(node: unknown, root: string): boolean { * only the plain one would be trivially side-stepped. That matters most for the * OPTIONAL forms: `has(...)`, `.?` and `[?]` read a missing key as an ordinary * `false`/default, so an author reaching for a null-safe spelling would have - * turned "the acting user may not read this column" into a quiet non-firing - * rule. The engine decides readability before evaluation precisely so the + * turned "this related column could not be resolved" into a quiet non-firing + * rule. The engine decides resolvability before evaluation precisely so the * verdict cannot depend on which operator was written — and this function is * what makes the analysis see every operator in the first place. * diff --git a/packages/objectql/src/validation/rule-relationship-traversal.test.ts b/packages/objectql/src/validation/rule-relationship-traversal.test.ts index 77bda61cbab..acb52ca3d50 100644 --- a/packages/objectql/src/validation/rule-relationship-traversal.test.ts +++ b/packages/objectql/src/validation/rule-relationship-traversal.test.ts @@ -43,7 +43,7 @@ const opportunity = { /** A readable, resolved parent row. */ const row = (r: Record) => ({ object: 'crm_account', row: r }); -/** The engine could not make the parent readable for this caller. */ +/** The engine could not resolve the parent row for this predicate. */ const unavailable = ( reason: 'no-reference' | 'unreadable' | 'undeclared-field' | 'unresolved', undeclaredFields?: string[], @@ -132,9 +132,10 @@ describe('#18682 — the three acceptance outcomes (ADR-0137 D2)', () => { // ── REFUSES LOUDLY when the rule cannot be evaluated at all ─────────────── // - // The related row is read under SYSTEM authority, so "the caller may not read - // it" is not a cause here. What remains is: no reference stored, the row is - // gone, the related object declares no such column, or the read failed. Each + // The related row is read under SYSTEM authority, so a permission verdict is + // not among the causes here at all. What remains is: no reference stored, the + // row is gone, the related object declares no such column, or the read + // failed outright. Each // is refused with a sentence naming the related object, and the write is // REJECTED rather than judged on a rule that produced no verdict. Never // silently true, and never silently false either. diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 126865fab83..7dfc154c64a 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -387,8 +387,10 @@ export interface EvaluateRulesOptions { /** * [#18682] The related records this write's predicates read ONE HOP through a * reference field — `record.crm_account.type` on an opportunity. Keyed by the - * reference FIELD name; the value is the related row, or `null` when it could - * not be read. + * reference FIELD name; the value is a {@link RelatedFieldBinding}, which + * either carries the related row or says WHY it has none. ⛔ Not `row | null`: + * the reason is what lets the refusal name the related object and column + * instead of leaving CEL to discover a missing key. * * Only the engine owns a driver, so it resolves these and hands them over — * the same division of labour `parent` follows, and like `parent` the read is @@ -463,8 +465,8 @@ export function needsPriorRecord( * ## Scope: `script` / `cross_field`, including inside `conditional` * * These are the rules {@link checkPredicate} evaluates, and they are fail-CLOSED - * (#4649) — the one policy under which an unreadable related field produces the - * loud refusal the permission rule requires. The field-level `requiredWhen` / + * (#4649) — the one policy under which a rule that cannot be evaluated refuses + * the write instead of waving it through. The field-level `requiredWhen` / * `readonlyWhen` / option `visibleWhen` predicates are deliberately NOT * collected here: they fail OPEN, so a rule that could not be evaluated would * silently not enforce their gate — the opposite of what this capability's @@ -3042,8 +3044,9 @@ function traversalRefusal( return { summary: `cannot read ${columns} through ${on}: the related record was not found`, detail: - ` The rule reads ${columns} through ${on}, but the referenced record could not be` - + ' read — it may have been deleted, or it may be outside this caller\'s visibility.', + ` The rule reads ${columns} through ${on}, but no record with that id exists — it` + + ' has most likely been deleted, leaving the reference dangling. This read is made' + + ' under system authority, so it is NOT a question of what the caller may see.', }; case 'unreadable': default: diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts new file mode 100644 index 00000000000..579307809fe --- /dev/null +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18682] `canWriteObject` agrees with the engine middleware, case for case. + * + * ## Why this file exists, and why it is an EQUIVALENCE + * + * `ObjectQL.validate()` previews a write without running middleware, and a + * validation rule that reads one hop through a reference field is evaluated + * there against a related row fetched under SYSTEM authority. The accepted cost + * of that elevation is an inference channel bounded to callers who could + * perform the write — a bound the real write path gets for free, because the + * middleware refuses first. The preview has to ask for it, and + * {@link SecurityPlugin.canWriteObject} is what it asks. + * + * The first version of that question was NOT the middleware's decision. It + * checked `isSystem`, a principal, and the CRUD grant — and admitted two + * classes the write path refuses: + * + * - a caller holding `allowCreate` but NOT an ADR-0066 D3 `requiredPermissions` + * capability the object declares (reachable from the wire: the `dryRun` + * import route); + * - an ADR-0090 D10 `onBehalfOf` context naming a delegator that does not + * exist (the in-process / `/mcp` door). + * + * Both are cases below. ⭐ But the point of this file is not those two cases: it + * is that the first block asserts no expected boolean per case at all. It drives + * the REAL registered middleware with the write for the same (object, context) + * and requires the method's answer to EQUAL whether the middleware admitted. Two + * doors that merely agree today drift the first time one of them grows an arm, + * and hand-adding the two missing arms without this pin would leave exactly that + * exposure standing. + * + * Harness mirrors `can-read-object-admission.test.ts`, whose read twin this is. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const ADMIN_SET = defaultPermissionSets.find((s) => s.name === ADMIN_FULL_ACCESS); +if (!ADMIN_SET) throw new Error(`fixture: '${ADMIN_FULL_ACCESS}' is not among the default permission sets`); + +/** Holds a write grant on one object and nothing at all on the other. */ +const WRITER_SET: PermissionSet = { + name: 'member_default', + label: 'Writer', + objects: { invoice: { allowRead: true, allowCreate: true, allowEdit: true } }, +} as unknown as PermissionSet; + +/** W3 — holds the write grant but NOT the capability the object requires. */ +const CAPLESS_SET: PermissionSet = { + name: 'member_default', + label: 'Writer without the capability', + objects: { payroll_run: { allowRead: true, allowCreate: true, allowEdit: true } }, +} as unknown as PermissionSet; + +/** …and the same grant WITH the capability, so the D3 arm is proven both ways. */ +const CAPABLE_SET: PermissionSet = { + name: 'member_default', + label: 'Writer with the capability', + objects: { payroll_run: { allowRead: true, allowCreate: true, allowEdit: true } }, + systemPermissions: ['manage_payroll'], +} as unknown as PermissionSet; + +/** Read but no write — the grant axis itself. */ +const READER_SET: PermissionSet = { + name: 'member_default', + label: 'Reader only', + objects: { invoice: { allowRead: true } }, +} as unknown as PermissionSet; + +const schema = (name: string, extra: Record = {}) => ({ + name, + fields: { + organization_id: { type: 'text', label: 'Organization' }, + title: { type: 'text', label: 'Title' }, + }, + ...extra, +}); + +const SCHEMAS: Record> = { + invoice: schema('invoice'), + ledger: schema('ledger'), + payroll_run: schema('payroll_run', { requiredPermissions: ['manage_payroll'] }), +}; + +const WRITER_CTX = { userId: 'u_writer', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; +/** W4 — names a delegator no `findOne` will resolve. */ +const DANGLING_DELEGATOR_CTX = { ...WRITER_CTX, onBehalfOf: { userId: 'u_ghost' } }; + +async function boot(sets: PermissionSet[]) { + const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { + registerMiddleware: (mw: any) => middlewares.push(mw), + getSchema: (name: string) => SCHEMAS[name], + // Every delegator lookup misses — which is what makes + // DANGLING_DELEGATOR_CTX the D10 case. + findOne: vi.fn(async () => null), + }, + metadata: { + get: async (_type: string, name: string) => SCHEMAS[name], + list: async () => sets, + }, + }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx: Record = { + logger, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware'); + return { plugin, middleware: middlewares[0] }; +} + +/** Would the ENGINE middleware admit this write here? */ +async function middlewareAdmits( + middleware: (opCtx: any, next: () => Promise) => Promise, + object: string, + operation: 'insert' | 'update', + context: Record, +): Promise { + const opCtx: any = { + object, + operation, + context: { ...context }, + options: {}, + data: { title: 'x' }, + ast: { where: {} }, + }; + try { + await middleware(opCtx, async () => {}); + return true; + } catch { + return false; + } +} + +describe('canWriteObject agrees with the engine middleware, case for case', () => { + const CASES: Array<{ + label: string; + object: string; + operation: 'insert' | 'update'; + sets: PermissionSet[]; + context: Record; + }> = [ + { label: 'no grant of any kind on the object', object: 'ledger', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, + { label: 'an explicit create grant', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, + { label: 'an explicit edit grant', object: 'invoice', operation: 'update', sets: [WRITER_SET], context: WRITER_CTX }, + { label: 'read but no write grant', object: 'invoice', operation: 'insert', sets: [READER_SET], context: WRITER_CTX }, + { label: 'a superuser wildcard', object: 'ledger', operation: 'insert', sets: [ADMIN_SET], context: { ...WRITER_CTX, posture: 'PLATFORM_ADMIN' } }, + // ⭐ W3 — the ADR-0066 D3 capability arm, the first class that leaked. + { label: 'a required capability the caller LACKS', object: 'payroll_run', operation: 'insert', sets: [CAPLESS_SET], context: WRITER_CTX }, + { label: 'a required capability the caller HOLDS', object: 'payroll_run', operation: 'insert', sets: [CAPABLE_SET], context: WRITER_CTX }, + // ⭐ W4 — the ADR-0090 D10 dangling delegator, the second class that leaked. + { label: 'an onBehalfOf naming a delegator that does not exist', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: DANGLING_DELEGATOR_CTX }, + // Whichever way the middleware falls on these, the method must fall the + // same way — asserted as agreement rather than as an expected boolean, + // because the fall direction is the middleware's to choose. + { label: 'a principal-less context', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: { positions: [], permissions: [] } }, + { label: 'an object whose posture cannot be resolved', object: 'not_a_registered_object', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, + ]; + + for (const c of CASES) { + it(`agrees on ${c.label}`, async () => { + const { plugin, middleware } = await boot(c.sets); + const admitted = await middlewareAdmits(middleware, c.object, c.operation, c.context); + const answered = await plugin.canWriteObject(c.object, c.operation, c.context); + expect(answered).toBe(admitted); + }); + } +}); + +/** + * The two arms the first probe was missing, pinned individually so a failure + * says WHICH arm moved rather than only that something did. Both assert the + * DENY direction explicitly — the equivalence block above would still pass if + * both doors admitted together, and these are the cases where admitting is the + * defect. + */ +describe('the arms the CRUD grant alone does not cover', () => { + it('DENIES a caller holding the write grant but not the required capability (ADR-0066 D3)', async () => { + const { plugin } = await boot([CAPLESS_SET]); + await expect(plugin.canWriteObject('payroll_run', 'insert', WRITER_CTX)).resolves.toBe(false); + }); + + it('ADMITS the same caller once the capability is held — so the arm is not a blanket deny', async () => { + const { plugin } = await boot([CAPABLE_SET]); + await expect(plugin.canWriteObject('payroll_run', 'insert', WRITER_CTX)).resolves.toBe(true); + }); + + it('DENIES an onBehalfOf naming a delegator that does not exist (ADR-0090 D10)', async () => { + const { plugin } = await boot([WRITER_SET]); + await expect(plugin.canWriteObject('invoice', 'insert', DANGLING_DELEGATOR_CTX)).resolves.toBe(false); + }); + + it('ADMITS the same caller with no onBehalfOf — so the D10 arm is not a blanket deny', async () => { + const { plugin } = await boot([WRITER_SET]); + await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX)).resolves.toBe(true); + }); + + it('separates create from edit rather than answering one for both', async () => { + const CREATE_ONLY: PermissionSet = { + name: 'member_default', + label: 'Create but not edit', + objects: { invoice: { allowRead: true, allowCreate: true } }, + } as unknown as PermissionSet; + const { plugin } = await boot([CREATE_ONLY]); + await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX)).resolves.toBe(true); + await expect(plugin.canWriteObject('invoice', 'update', WRITER_CTX)).resolves.toBe(false); + }); + + it('fails CLOSED on an empty object name', async () => { + const { plugin } = await boot([ADMIN_SET]); + await expect(plugin.canWriteObject('', 'insert', WRITER_CTX)).resolves.toBe(false); + }); + + it('admits a system context, like every other door', async () => { + const { plugin } = await boot([WRITER_SET]); + await expect(plugin.canWriteObject('ledger', 'insert', { isSystem: true })).resolves.toBe(true); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 57e0851f165..b4e956983c1 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1239,23 +1239,22 @@ export class SecurityPlugin implements Plugin { // as "resolve nothing", which makes a traversing rule refuse in preview. if (typeof (ql as any).registerWriteGateProbe === 'function') { (ql as any).registerWriteGateProbe( - async (object: string, operation: 'insert' | 'update', context: any): Promise => { - if (context?.isSystem) return true; - if (!context?.userId) return false; - try { - const meta = await this.getObjectSecurityMeta(object); - const sets = await this.resolvePermissionSetsForContext(context); - return this.permissionEvaluator.checkObjectPermission( - operation, object, sets, { isPrivate: meta.isPrivate }, - ); - } catch (e) { - this.logger.warn?.( - `[security] validate() write-gate probe failed for object '${object}' — denying (fail-closed)`, - e instanceof Error ? e : new Error(String(e)), - ); - return false; - } - }, + (object: string, operation: 'insert' | 'update', context: any): Promise => + this.canWriteObject(object, operation, context), + ); + } else { + // Absence must be loud. This engine takes middleware — so its write path + // IS gated — but exposes no seam for the preview to ask the same + // question, which means `validate()` there answers a traversing rule for + // callers the write path would refuse. Functional degradation, not + // durability: the deployment is visibly older than this plugin, and the + // remedy is the version bump. + ctx.logger.warn( + '[security] this ObjectQL exposes no write-gate seam (registerWriteGateProbe), so the ' + + 'write PREVIEW (validate() / the dryRun import) cannot ask whether the caller could ' + + 'perform the write. A validation rule that reads through a reference field will be ' + + 'answered there for callers the real write path refuses. Upgrade @objectstack/objectql ' + + 'to a version that offers the seam.', ); } @@ -5101,6 +5100,117 @@ export class SecurityPlugin implements Plugin { } } + /** + * [#18682] Whether `context` may CREATE or UPDATE `object` at all — the + * object-level WRITE admission, and the exact sibling of + * {@link canReadObject}. + * + * ## Why it exists + * + * `ObjectQL.validate()` is a write PREVIEW that runs no middleware for its + * target object, by design: it executes nothing. A validation rule that reads + * one hop through a reference field is evaluated there against a related row + * fetched under SYSTEM authority, and the accepted cost of that elevation is + * an inference channel bounded to callers who could perform the write — a + * bound the real path gets for free, because the middleware's write gate + * refuses long before any rule is evaluated. The preview has no such gate, so + * it asks this. + * + * ## The arms, in the middleware's own order — ⛔ the CRUD grant is not the gate + * + * A probe that checked only `isSystem`, a principal and the CRUD grant admits + * two classes the write path refuses: a caller holding `allowCreate` but not a + * D3 `requiredPermissions` capability, and an `onBehalfOf` context naming a + * delegator that does not exist. Both were measured reaching the preview while + * `insert()` refused them. So the arms are the middleware's, in its order: + * + * 1. `isSystem` → admit (the total bypass); + * 2. no permission sets resolved → admit (the middleware guards its whole + * CRUD gate with `if (permissionSets.length > 0)`); + * 3. `secMeta.unresolved` → DENY (#3545); + * 4. ADR-0066 D3/⑤ `requiredPermissions` capability AND-gate for the WRITE + * CRUD class, checked BEFORE the grant, for the caller AND (D10) the + * delegator; + * 5. the `allowCreate` / `allowEdit` CRUD grant for the operation asked; + * 6. ADR-0090 D10 — the delegator must independently hold the same grant; + * a dangling delegator denies. + * + * The equality with the registered middleware is pinned as an EQUIVALENCE + * (`can-write-object-admission.test.ts`) rather than asserted here, for the + * same reason `canReadObject`'s is: two doors that merely agree today drift + * the first time one of them grows an arm. + * + * Fails CLOSED: a throw anywhere denies, and callers must treat a throw as a + * denial too. + * + * ⛔ Object-level ONLY. `true` never means "this write will succeed" — record + * scope, field-level security, `readonlyWhen` and the rules themselves are all + * still ahead of it. Nothing here may be used to widen. + */ + async canWriteObject(object: string, operation: 'insert' | 'update', context?: any): Promise { + const objectName = String(object ?? ''); + if (!objectName) return false; + // 1. System operations bypass. + if (context?.isSystem) return true; + + try { + const permissionSets = await this.resolvePermissionSetsForContext(context); + // 2. No sets resolved → no permission-set restriction applies. + if (permissionSets.length === 0) return true; + + const { isPrivate, unresolved, requiredPermissions } = + await this.getObjectSecurityMeta(objectName); + // 3. [#3545] Posture unresolvable → deny. + if (unresolved) return false; + + // [ADR-0090 D10] Resolve the delegator ONCE — arms 4 and 6 both need it, + // and a dangling link denies before either runs. + let delegatorSets: PermissionSet[] | null = null; + if (context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind === 'missing') return false; + if (del.kind === 'resolved') { + delegatorSets = await this.resolvePermissionSetsForContext(del.context); + } + } + + // 4. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both + // principals. + const required = requiredCapsForOperation(requiredPermissions, operation); + if (required.length > 0) { + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + if (required.some((cap) => !held.has(cap))) return false; + if (delegatorSets && delegatorSets.length > 0) { + const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); + if (required.some((cap) => !delHeld.has(cap))) return false; + } + } + + // 5. The object-level CRUD grant for the operation asked. + if (!this.permissionEvaluator.checkObjectPermission(operation, objectName, permissionSets, { isPrivate })) { + return false; + } + + // 6. [ADR-0090 D10] The delegator must independently grant the same write. + if ( + delegatorSets && + delegatorSets.length > 0 && + !this.permissionEvaluator.checkObjectPermission(operation, objectName, delegatorSets, { isPrivate }) + ) { + return false; + } + + return true; + } catch (e) { + this.logger.error?.( + `[security] canWriteObject could not resolve the object-level write admission for ` + + `'${objectName}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, + e instanceof Error ? e : new Error(String(e)), + ); + return false; + } + } + /** * [#3544] Whether `context` may EXPORT `object` — the user-level export axis. * From 3fded106c4e2c39370d3a0b23c06a119c64d4203 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 17:30:48 +0000 Subject: [PATCH 16/47] test(objectql): follow the reworded unresolved-refusal sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal no longer says the row may be outside the caller's visibility — it cannot be, under a system read — so the pin follows the sentence it guards. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../objectql/src/validation/rule-relationship-traversal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objectql/src/validation/rule-relationship-traversal.test.ts b/packages/objectql/src/validation/rule-relationship-traversal.test.ts index acb52ca3d50..4b8a26a875a 100644 --- a/packages/objectql/src/validation/rule-relationship-traversal.test.ts +++ b/packages/objectql/src/validation/rule-relationship-traversal.test.ts @@ -274,7 +274,7 @@ describe('#18682 — the refusal names the RELATED object, not the referencing o ['read failed', unavailable('unreadable'), ['could not read', "'crm_account'"]], ['undeclared related field', unavailable('undeclared-field', ['type']), ['declares no', "'type'"]], ['no reference stored', unavailable('no-reference'), ['no single related record', 'MULTIPLE references']], - ['related row gone', unavailable('unresolved'), ['could not be read']], + ['related row gone', unavailable('unresolved'), ['no record with that id exists', 'system authority']], ]; for (const [name, binding, expected] of cases) { From e6036cb3a17f97921b029168aeb778bf0ae52242 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 18:50:28 +0000 Subject: [PATCH 17/47] feat(security): the write-gate probe judges the payload, not just the object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six object-level arms were not the whole write decision. The middleware refuses payload-dependent writes before `next()`, and the first of them is the field-level-security write gate: a caller holding the object's CRUD grant but not `editable` on a field the payload names is refused there. The probe carried no payload, so `validate()` could not ask it — and an editor of the child object who is FLS-locked out of the lookup column was refused by `insert()` with zero related reads while the preview issued one and answered the rule's verdict. `registerWriteGateProbe` now takes the caller's RAW rows (the same image the middleware gates on, before defaults and hooks), and `canWriteObject` runs step 2.5's own primitives over them in the middleware's order: `getFieldPermissions` folded through `foldFieldRequiredPermissions`, intersected with the delegator's mask under D10, then `detectForbiddenWrites`. The equivalence table carries the payload to both doors, in both directions and once under delegation, and a composed-runtime pin runs the real engine with the real plugin so a probe that silently dropped the payload cannot stay green in either package's own suite. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 27 +- content/docs/permissions/system-context.mdx | 2 +- packages/objectql/src/engine.ts | 74 +++- .../src/can-write-object-admission.test.ts | 144 +++++++- .../plugin-security/src/security-plugin.ts | 78 ++++- .../write-preview-field-gate-parity.test.ts | 330 ++++++++++++++++++ 6 files changed, 622 insertions(+), 33 deletions(-) create mode 100644 packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index ef4747a728e..53733849a14 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -77,18 +77,23 @@ an object-valued field traverses today and keeps traversing. ### `@objectstack/plugin-security` gains `canWriteObject` -The object-level WRITE admission — the sibling of the existing `canReadObject`, -and the same six arms in the middleware's own order: system bypass, no resolved +The WRITE admission — the sibling of the existing `canReadObject`, running the +middleware's own arms in the middleware's own order: system bypass, no resolved permission sets, unresolvable posture, the ADR-0066 D3 `requiredPermissions` -capability AND-gate for both principals, the CRUD grant, and the ADR-0090 D10 -delegator check. It exists for doors that must ask "could this caller perform -this write" without running the engine middleware — the write preview is the -first — and an equivalence suite pins its answer EQUAL to the registered -middleware's, case for case, so the two cannot drift. - -⛔ Object-level only. `true` never means the write will succeed: record scope, -field-level security, `readonlyWhen` and the rules themselves are all still -ahead of it. +capability AND-gate for both principals, the CRUD grant, the ADR-0090 D10 +delegator check, and — when the caller's payload is supplied — the field-level +security WRITE gate over it (`getFieldPermissions`, folded through the D3 +field-capability contract, intersected with the delegator's mask under D10, then +the forbidden-write detection). It exists for doors that must ask "could this +caller perform this write" without running the engine middleware — the write +preview is the first — and an equivalence suite pins its answer EQUAL to the +registered middleware's, case for case AND payload for payload, so the two +cannot drift. + +⛔ `true` never means the write will succeed. What is still ahead of it, by +name: the row-level pre-image (this method is asked about no ROW, and without a +payload about no FIELD either), `readonlyWhen`, the static `readonly` strip, and +the validation rules themselves. ### Scope diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index ff55b20ef42..9a55a87f636 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -101,7 +101,7 @@ that silently does not happen. | 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` | | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | -| 6b | Object-level WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to callers who could perform the write. An equivalence suite pins the answer EQUAL to the registered middleware's, case for case, including the ADR-0066 D3 capability arm and the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | +| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to callers who could perform the write. An equivalence suite pins the answer EQUAL to the registered middleware's, case for case, including the ADR-0066 D3 capability arm, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 11ecd817a0e..983fd16e6b4 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3958,9 +3958,35 @@ export class ObjectQL implements IObjectQLEngine { * object and no ability to write it at all — a strictly wider channel than the * one that was accepted. * - * This restores the accepted bound rather than narrowing or widening it: the - * preview answers for callers who could perform the write, and for nobody - * else. + * ## The payload is part of the question — and so are the limits + * + * The write decision is not object-level. The middleware also refuses a write + * for reasons that depend on THE PAYLOAD, before any rule runs: the + * field-level-security write gate refuses a caller who holds the object's + * CRUD grant but is not `editable` on a field the payload names. So the + * caller's rows are handed to the probe, and the plugin answers over them — + * an editor of the child object who is FLS-locked out of the lookup column is + * refused by `insert()` and refused the elevated read here, alike. + * + * ⚠️ What this gate still does NOT cover, named rather than implied — the + * preview is NOT a promise that the write would succeed: + * + * - **The row-level pre-image (the middleware's step 2.7).** A row-level + * `using` / `check` policy judges a ROW, and the preview names none: it + * reads nothing and has no prior image to judge. A caller the row filter + * would refuse can still reach the verdict here. + * - **The static `readonly` strip.** `insert()` strips an author-declared + * `readonly` reference field inside the write's executor, so the real + * write resolves NO related row for it and a traversing rule refuses; + * the preview runs no strip, resolves the caller's own foreign key and + * answers the rule against it. Every writer is affected alike — it widens + * the channel to no caller the write path refuses — but the id the + * preview judges is one the write path never carries. + * + * So the bound this restores is the one that was accepted, neither narrowed + * nor widened: an elevated read is issued only for a caller the write path + * would admit THIS PAYLOAD from, and the two limits above are the distance + * between "admitted this payload" and "this write would succeed". * * ## Unwired * @@ -3968,11 +3994,29 @@ export class ObjectQL implements IObjectQLEngine { * either, so there is no bound to restore and the preview resolves. That is * the same behaviour such a composition already has everywhere else. */ - private _writeGateProbe?: (object: string, operation: 'insert' | 'update', context: unknown) => Promise; + private _writeGateProbe?: ( + object: string, + operation: 'insert' | 'update', + context: unknown, + data: unknown, + ) => Promise; - /** Wire the create/update gate question (#18682). Last registration wins. */ + /** + * Wire the create/update gate question (#18682). Last registration wins. + * + * `data` is the caller's RAW payload for this preview — the rows exactly as + * they arrived, before `applyFieldDefaults` and before any hook, which is the + * same image the middleware's own field-level gate reads off `opCtx.data`. + * ⛔ Not the defaulted rows: a default the runtime fills is not a field the + * caller wrote, and judging it would refuse writes the real path accepts. + */ registerWriteGateProbe( - fn: (object: string, operation: 'insert' | 'update', context: unknown) => Promise, + fn: ( + object: string, + operation: 'insert' | 'update', + context: unknown, + data: unknown, + ) => Promise, ): void { this._writeGateProbe = fn; this.logger.debug('Registered write-gate probe for validate() relationship resolution'); @@ -10805,14 +10849,30 @@ export class ObjectQL implements IObjectQLEngine { // real update path reads the prior row and does resolve it; closing the // preview's half needs a read this operation's "nothing is executed" // contract does not make. + // ⚠️ Second named limit, the mirror of the first: a reference field the + // author declared static `readonly` is STRIPPED from the caller's payload + // inside `insert()`'s executor (`stripRuntimeOwnedFields`), so the real + // write resolves no related row for it and a traversing rule refuses there. + // Nothing is stripped here, so the preview resolves the caller's own + // foreign key and answers the rule against an id the write path never + // carries. Every writer is affected alike — this reaches no caller the + // write path refuses — but the preview's verdict is not the write's for + // that declaration. Running the strip here would make them agree and is a + // behaviour change on the preview's payload, so it is named, not done. // ⛔ Behind the caller's own create/update gate — see // {@link registerWriteGateProbe} for why the preview needs a gate the write // path gets from middleware for free. A caller who could not perform this // write gets NO elevated read: `related` stays unresolved, and a traversing // rule then refuses, which is the fail-closed direction and is honest about // what it did not evaluate. + // ⛔ `rawRows`, not `rows`: the gate's field-level arm judges WHICH FIELDS + // THE CALLER WROTE, and `rows` has already been through + // `applyFieldDefaults` / `initializeSummaryFields` above. Handing it the + // defaulted image would offer the plugin keys the caller never sent — the + // exact reading the middleware avoids by gating on `opCtx.data`, which is + // the raw payload (defaults are resolved inside the executor, under it). const mayWrite = this._writeGateProbe - ? await this._writeGateProbe(object, mode, options?.context).catch(() => false) + ? await this._writeGateProbe(object, mode, options?.context, rawRows).catch(() => false) : true; const previewRelatedForRow = mayWrite ? await this.resolvePredicateRelated(schemaForValidation, rows, options?.context) diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index 579307809fe..b44fff76bd6 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -31,6 +31,21 @@ * and hand-adding the two missing arms without this pin would leave exactly that * exposure standing. * + * ## …and why the PAYLOAD is part of every case + * + * That pin had a blind spot of its own for exactly one round: its payload was + * `{ title: 'x' }`, which names no field any fixture restricts, so a whole + * class of the middleware's write decision was invisible to it. The middleware + * refuses payload-dependent writes before `next()` — the field-level-security + * write gate (step 2.5) refuses a caller who holds the object's CRUD grant but + * is not `editable` on a field the payload names. An editor of the child object + * who is FLS-locked out of the lookup column is that caller, and it is the + * common shape, not an exotic one. + * + * So the payload travels with the case and reaches BOTH doors, and the table + * carries a field the fixtures actually restrict — in both directions, and once + * under D10 delegation where the two masks must intersect rather than union. + * * Harness mirrors `can-read-object-admission.test.ts`, whose read twin this is. */ @@ -72,11 +87,45 @@ const READER_SET: PermissionSet = { objects: { invoice: { allowRead: true } }, } as unknown as PermissionSet; +/** + * ⭐ W6 — the write grant on the object, and NO `editable` on the lookup + * column. The persona the FLS write gate exists for: may edit invoices, may not + * repoint the account. + */ +const FLS_LOCKED_SET: PermissionSet = { + name: 'member_default', + label: 'Writer, FLS-locked on the reference column', + objects: { invoice: { allowRead: true, allowCreate: true, allowEdit: true } }, + fields: { 'invoice.account': { readable: true, editable: false } }, +} as unknown as PermissionSet; + +/** …and the twin that MAY edit it, so the arm is proven in both directions. */ +const FLS_OPEN_SET: PermissionSet = { + name: 'member_default', + label: 'Writer who may edit the reference column', + objects: { invoice: { allowRead: true, allowCreate: true, allowEdit: true } }, + fields: { 'invoice.account': { readable: true, editable: true } }, +} as unknown as PermissionSet; + +/** + * The AGENT's own set, which grants the column the baseline denies — so the D10 + * case turns on `intersectFieldMasks` and on nothing else. Resolved because the + * context names it in `permissions`; the delegator resolves to the baseline + * alone (`member_default`), which is `FLS_LOCKED_SET` in that boot. + */ +const AGENT_FLS_OPEN_SET: PermissionSet = { + name: 'agent_writer', + label: 'Agent who may edit the reference column', + objects: { invoice: { allowRead: true, allowCreate: true, allowEdit: true } }, + fields: { 'invoice.account': { readable: true, editable: true } }, +} as unknown as PermissionSet; + const schema = (name: string, extra: Record = {}) => ({ name, fields: { organization_id: { type: 'text', label: 'Organization' }, title: { type: 'text', label: 'Title' }, + account: { type: 'lookup', label: 'Account', reference: 'crm_account' }, }, ...extra, }); @@ -90,6 +139,18 @@ const SCHEMAS: Record> = { const WRITER_CTX = { userId: 'u_writer', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; /** W4 — names a delegator no `findOne` will resolve. */ const DANGLING_DELEGATOR_CTX = { ...WRITER_CTX, onBehalfOf: { userId: 'u_ghost' } }; +/** The one delegator id the harness's `sys_user` lookup DOES resolve. */ +const LIVE_DELEGATOR = 'u_boss'; +/** The agent principal, acting for a delegator who resolves to the baseline. */ +const AGENT_CTX = { + userId: 'u_agent', tenantId: 'org-1', positions: [], permissions: ['agent_writer'], posture: 'MEMBER', +}; +const DELEGATED_AGENT_CTX = { ...AGENT_CTX, onBehalfOf: { userId: LIVE_DELEGATOR } }; + +/** The payload every case carries unless it is about a restricted field. */ +const PLAIN_PAYLOAD = { title: 'x' }; +/** …and the one that names the column the FLS fixtures restrict. */ +const REFERENCE_PAYLOAD = { title: 'x', account: 'acc_churn' }; async function boot(sets: PermissionSet[]) { const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; @@ -98,9 +159,15 @@ async function boot(sets: PermissionSet[]) { objectql: { registerMiddleware: (mw: any) => middlewares.push(mw), getSchema: (name: string) => SCHEMAS[name], - // Every delegator lookup misses — which is what makes - // DANGLING_DELEGATOR_CTX the D10 case. - findOne: vi.fn(async () => null), + // Exactly one delegator exists. Every other lookup misses — which is what + // makes DANGLING_DELEGATOR_CTX the D10 fail-closed case, while + // DELEGATED_AGENT_CTX gets a delegator that really resolves (to the + // additive baseline, and to nothing else). + findOne: vi.fn(async (_object: string, query: any) => ( + query?.where?.id === LIVE_DELEGATOR + ? { id: LIVE_DELEGATOR, email: 'boss@example.test' } + : null + )), }, metadata: { get: async (_type: string, name: string) => SCHEMAS[name], @@ -129,13 +196,14 @@ async function middlewareAdmits( object: string, operation: 'insert' | 'update', context: Record, + data: unknown, ): Promise { const opCtx: any = { object, operation, context: { ...context }, options: {}, - data: { title: 'x' }, + data, ast: { where: {} }, }; try { @@ -153,6 +221,8 @@ describe('canWriteObject agrees with the engine middleware, case for case', () = operation: 'insert' | 'update'; sets: PermissionSet[]; context: Record; + /** The caller's payload, reaching BOTH doors. Defaults to `PLAIN_PAYLOAD`. */ + data?: unknown; }> = [ { label: 'no grant of any kind on the object', object: 'ledger', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, { label: 'an explicit create grant', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, @@ -169,13 +239,27 @@ describe('canWriteObject agrees with the engine middleware, case for case', () = // because the fall direction is the middleware's to choose. { label: 'a principal-less context', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: { positions: [], permissions: [] } }, { label: 'an object whose posture cannot be resolved', object: 'not_a_registered_object', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, + // ⭐ W6 — the field-level-security write gate, the third class that leaked, + // and the first that only a PAYLOAD can reach. Both directions, both modes: + // the same caller and the same payload, differing only in whether the + // fixture grants `editable` on the column the payload names. + { label: 'a payload naming a field the caller may NOT edit', object: 'invoice', operation: 'insert', sets: [FLS_LOCKED_SET], context: WRITER_CTX, data: REFERENCE_PAYLOAD }, + { label: 'a payload naming a field the caller MAY edit', object: 'invoice', operation: 'insert', sets: [FLS_OPEN_SET], context: WRITER_CTX, data: REFERENCE_PAYLOAD }, + { label: 'W6u — the same non-editable field in UPDATE mode', object: 'invoice', operation: 'update', sets: [FLS_LOCKED_SET], context: WRITER_CTX, data: REFERENCE_PAYLOAD }, + { label: 'a restricted field the payload does not name', object: 'invoice', operation: 'insert', sets: [FLS_LOCKED_SET], context: WRITER_CTX, data: PLAIN_PAYLOAD }, + // ⭐ The D10 half of the same arm: the agent may edit the column, the + // delegator may not, and the effective mask is the INTERSECTION. Its + // control twin is the identical caller with no delegation link. + { label: 'a delegated agent whose delegator may not edit the field', object: 'invoice', operation: 'insert', sets: [AGENT_FLS_OPEN_SET, FLS_LOCKED_SET], context: DELEGATED_AGENT_CTX, data: REFERENCE_PAYLOAD }, + { label: 'the same agent acting for nobody', object: 'invoice', operation: 'insert', sets: [AGENT_FLS_OPEN_SET, FLS_LOCKED_SET], context: AGENT_CTX, data: REFERENCE_PAYLOAD }, ]; for (const c of CASES) { it(`agrees on ${c.label}`, async () => { const { plugin, middleware } = await boot(c.sets); - const admitted = await middlewareAdmits(middleware, c.object, c.operation, c.context); - const answered = await plugin.canWriteObject(c.object, c.operation, c.context); + const data = 'data' in c ? c.data : PLAIN_PAYLOAD; + const admitted = await middlewareAdmits(middleware, c.object, c.operation, c.context, data); + const answered = await plugin.canWriteObject(c.object, c.operation, c.context, data); expect(answered).toBe(admitted); }); } @@ -229,4 +313,52 @@ describe('the arms the CRUD grant alone does not cover', () => { const { plugin } = await boot([WRITER_SET]); await expect(plugin.canWriteObject('ledger', 'insert', { isSystem: true })).resolves.toBe(true); }); + + // ── the field-level-security write gate (the middleware's step 2.5) ─────── + + it('DENIES a payload naming a field the caller may not edit', async () => { + const { plugin } = await boot([FLS_LOCKED_SET]); + await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, REFERENCE_PAYLOAD)).resolves.toBe(false); + }); + + it('DENIES it in UPDATE mode too — the gate is not insert-only', async () => { + const { plugin } = await boot([FLS_LOCKED_SET]); + await expect(plugin.canWriteObject('invoice', 'update', WRITER_CTX, REFERENCE_PAYLOAD)).resolves.toBe(false); + }); + + it('ADMITS the same payload once the field is editable — so the arm is not a blanket deny', async () => { + const { plugin } = await boot([FLS_OPEN_SET]); + await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, REFERENCE_PAYLOAD)).resolves.toBe(true); + }); + + it('ADMITS the locked caller for a payload that does not name the field', async () => { + const { plugin } = await boot([FLS_LOCKED_SET]); + await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); + }); + + it('judges EVERY row of a batch, not just the first', async () => { + const { plugin } = await boot([FLS_LOCKED_SET]); + await expect( + plugin.canWriteObject('invoice', 'insert', WRITER_CTX, [PLAIN_PAYLOAD, REFERENCE_PAYLOAD]), + ).resolves.toBe(false); + }); + + it('asks NOTHING about fields when no payload is supplied — the middleware skips 2.5 the same way', async () => { + const { plugin } = await boot([FLS_LOCKED_SET]); + await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX)).resolves.toBe(true); + }); + + it('DENIES a delegated agent the DELEGATOR may not edit the field for (ADR-0090 D10)', async () => { + const { plugin } = await boot([AGENT_FLS_OPEN_SET, FLS_LOCKED_SET]); + await expect( + plugin.canWriteObject('invoice', 'insert', DELEGATED_AGENT_CTX, REFERENCE_PAYLOAD), + ).resolves.toBe(false); + }); + + it('ADMITS the same agent acting for nobody — so the intersection is what denied', async () => { + const { plugin } = await boot([AGENT_FLS_OPEN_SET, FLS_LOCKED_SET]); + await expect( + plugin.canWriteObject('invoice', 'insert', AGENT_CTX, REFERENCE_PAYLOAD), + ).resolves.toBe(true); + }); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b4e956983c1..633e357f8c1 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1235,12 +1235,18 @@ export class SecurityPlugin implements Plugin { // and same permission sets the CRUD gate itself uses — ⛔ not a second // decision about who may write. // + // The PAYLOAD is passed straight through, because the write decision is not + // object-level: the middleware's field-level write gate (step 2.5 below) + // refuses a caller who holds the CRUD grant but may not edit a field the + // payload names, before any rule runs. A probe that dropped the payload + // would answer the preview for exactly that caller. + // // Fails CLOSED: any resolution error denies, and the engine treats a denial // as "resolve nothing", which makes a traversing rule refuse in preview. if (typeof (ql as any).registerWriteGateProbe === 'function') { (ql as any).registerWriteGateProbe( - (object: string, operation: 'insert' | 'update', context: any): Promise => - this.canWriteObject(object, operation, context), + (object: string, operation: 'insert' | 'update', context: any, data: unknown): Promise => + this.canWriteObject(object, operation, context, data), ); } else { // Absence must be loud. This engine takes middleware — so its write path @@ -5135,6 +5141,28 @@ export class SecurityPlugin implements Plugin { * 6. ADR-0090 D10 — the delegator must independently hold the same grant; * a dangling delegator denies. * + * ## …and the seventh, which needs the PAYLOAD + * + * The object-level six are not the whole write decision either. The + * middleware refuses payload-dependent writes before `next()`, and the first + * of them is the field-level-security write gate (step 2.5): a caller holding + * the object's CRUD grant but not `editable` on a field the payload names is + * refused `PERMISSION_DENIED` there. A probe carrying no payload cannot ask + * it — so an editor of the child object who is FLS-locked out of the lookup + * column was refused by `insert()` and answered by the preview, the same + * divergence arms 4 and 6 close one gate earlier. + * + * 7. step 2.5's own primitives over `data`, in the middleware's order — + * `getFieldPermissions` folded through `foldFieldRequiredPermissions` + * (ADR-0066 D3), intersected under D10 with the delegator's mask via + * `intersectFieldMasks`, then `detectForbiddenWrites`. Skipped when no + * payload is supplied, exactly as the middleware skips it on `!opCtx.data`. + * + * `data` is the caller's RAW payload — one row or an array, the shape + * `detectForbiddenWrites` already normalises and the shape `opCtx.data` + * carries. ⛔ Not a post-default image: a key the runtime filled is not a + * field the caller wrote. + * * The equality with the registered middleware is pinned as an EQUIVALENCE * (`can-write-object-admission.test.ts`) rather than asserted here, for the * same reason `canReadObject`'s is: two doors that merely agree today drift @@ -5143,11 +5171,18 @@ export class SecurityPlugin implements Plugin { * Fails CLOSED: a throw anywhere denies, and callers must treat a throw as a * denial too. * - * ⛔ Object-level ONLY. `true` never means "this write will succeed" — record - * scope, field-level security, `readonlyWhen` and the rules themselves are all - * still ahead of it. Nothing here may be used to widen. + * ⛔ `true` never means "this write will succeed". What is still ahead of it, + * by name: the row-level pre-image (step 2.7 — this answers about no ROW, and + * without a payload about no FIELD either), `readonlyWhen`, the static + * `readonly` strip, and the validation rules themselves. Nothing here may be + * used to widen. */ - async canWriteObject(object: string, operation: 'insert' | 'update', context?: any): Promise { + async canWriteObject( + object: string, + operation: 'insert' | 'update', + context?: any, + data?: unknown, + ): Promise { const objectName = String(object ?? ''); if (!objectName) return false; // 1. System operations bypass. @@ -5158,7 +5193,7 @@ export class SecurityPlugin implements Plugin { // 2. No sets resolved → no permission-set restriction applies. if (permissionSets.length === 0) return true; - const { isPrivate, unresolved, requiredPermissions } = + const { isPrivate, unresolved, requiredPermissions, fieldRequiredPermissions } = await this.getObjectSecurityMeta(objectName); // 3. [#3545] Posture unresolvable → deny. if (unresolved) return false; @@ -5200,10 +5235,37 @@ export class SecurityPlugin implements Plugin { return false; } + // 7. The field-level-security WRITE gate — the middleware's step 2.5, + // over the payload the caller supplied. Same primitives, same order, + // same guards: the middleware runs this only for an `insert`/`update` + // carrying `opCtx.data` with permission sets resolved, and both of the + // latter already hold here (arm 2 returned for the empty resolution). + // ⛔ Not a re-derivation — a second spelling of "which fields may this + // caller write" is the drift this whole method exists to avoid. + if (data) { + let fieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets); + // [ADR-0066 D3] AND-gate field-level requiredPermissions into the map. + fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, fieldRequiredPermissions, permissionSets); + // [ADR-0090 D10] Intersect with the delegator's field perms — a field + // the agent may edit but the delegator may not becomes forbidden. + if (delegatorSets) { + let delFieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); + delFieldPerms = this.foldFieldRequiredPermissions(delFieldPerms, fieldRequiredPermissions, delegatorSets); + fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); + } + if (Object.keys(fieldPerms).length > 0) { + const forbidden = this.fieldMasker.detectForbiddenWrites( + data as Record | Record[], + fieldPerms, + ); + if (forbidden.length > 0) return false; + } + } + return true; } catch (e) { this.logger.error?.( - `[security] canWriteObject could not resolve the object-level write admission for ` + + `[security] canWriteObject could not resolve the write admission for ` + `'${objectName}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, e instanceof Error ? e : new Error(String(e)), ); diff --git a/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts new file mode 100644 index 00000000000..35f78cc3b5a --- /dev/null +++ b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts @@ -0,0 +1,330 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18682] The write PREVIEW and the write PATH agree about a payload the + * field-level-security gate refuses — measured through both packages at once. + * + * ## Why this file exists, and why the unit suites could not hold it + * + * `ObjectQL.validate()` resolves a traversing validation rule's related row + * under SYSTEM authority. The accepted cost is an inference channel bounded to + * callers who could perform the write — a bound the real path gets from the + * security middleware and the preview has to ask for, through + * `registerWriteGateProbe`. + * + * The first probe asked an OBJECT-LEVEL question, and the write decision is not + * object-level. Before `next()` the middleware also refuses PAYLOAD-dependent + * writes, and the first of them is the field-level-security write gate: a + * caller who holds the object's CRUD grant but is not `editable` on a field the + * payload names is refused `PERMISSION_DENIED`. A probe carrying no payload + * cannot ask it, so that caller — an editor of the child object who is + * FLS-locked out of the lookup column, the common persona, not an exotic one — + * was refused by `insert()` with ZERO related reads and answered by the preview + * after ONE, receiving the rule's verdict about a row they may not point at. + * On the real path such a caller can never choose which related row a rule is + * judged against; through the preview they could choose any id they can name. + * It is wire-reachable by the same door as the object-level classes: + * `POST /data/:object/import` with `dryRun: true`. + * + * Neither package's own suite can see that. `can-write-object-admission.test.ts` + * pins the METHOD against the middleware and knows nothing about how the engine + * calls it; the engine's `engine-predicate-relationship.test.ts` drives a STUB + * probe and would stay green against a probe that silently dropped the payload. + * The seam is only observable by running both: here, as a preview answering a + * caller the write path refuses. + * + * ## What is asserted + * + * For one caller and one payload, both doors, in both modes: + * + * - `insert()` / `update()` refuse on the ADR-0112 `PERMISSION_DENIED` + * envelope, and the related object is never read; + * - `validate()` issues NO related read either, and the verdict it returns is + * NOT the traversing rule's — the rule's authored message must not appear, + * because that message IS the channel. + * + * Both controls run on the same harness, so this cannot pass by refusing + * everything: the same caller with the column `editable` is admitted by both + * doors, pays exactly ONE related read on each, and DOES receive the rule's + * verdict. + * + * ⚠️ What this file does NOT claim. The gate is not a promise that the write + * would succeed — the row-level pre-image (the middleware's step 2.7) judges a + * row the preview does not name, and the static `readonly` strip runs inside + * the write's executor and not here. Both are named limits on the engine seam + * (`registerWriteGateProbe`), deliberately unpinned: a test asserting today's + * answer there would advertise a guarantee the runtime does not deliver. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; + +import '@objectstack/spec'; +import '@objectstack/formula'; + +/** The rule's authored message — the verdict that must not cross the gate. */ +const RULE_MESSAGE = 'Partner accounts are capped at 10000.'; + +const OPPORTUNITY = { + name: 'crm_opportunity', + fields: { + name: { type: 'text' }, + amount: { type: 'number' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + validations: [{ + name: 'partner_cap', type: 'script', severity: 'error', + message: RULE_MESSAGE, + condition: "record.account.type == 'partner' && record.amount > 10000", + }], +}; + +/** May create and edit opportunities — and may NOT repoint the account. */ +const FLS_LOCKED: PermissionSet = { + name: 'member_default', + label: 'Opportunity editor, locked out of the lookup column', + objects: { crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true } }, + fields: { 'crm_opportunity.account': { readable: true, editable: false } }, +} as unknown as PermissionSet; + +/** The identical grant WITH the column editable — the control. */ +const FLS_OPEN: PermissionSet = { + name: 'member_default', + label: 'Opportunity editor who may repoint the account', + objects: { crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true } }, + fields: { 'crm_opportunity.account': { readable: true, editable: true } }, +} as unknown as PermissionSet; + +const CALLER = { userId: 'u_editor', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; + +/** The payload under test: it names the restricted column, and it trips the rule. */ +const PARTNER_PAYLOAD = { name: 'A', amount: 50000, account: 'acc_p' }; + +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: 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 === '$or') return (v as any[]).some((w) => matches(row, w)); + const cond = v as any; + 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; + } + return row?.[k] === cond; + }); + }; + const calls: Array<{ object: string; ast: any }> = []; + 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) { + calls.push({ object, ast }); + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // Hold the caller's bound: a double that ignores `limit` lets a real + // double-limit defect through unnoticed (`check:objectql-double-limit`). + const bounded = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + const fields: string[] | undefined = ast?.fields; + if (!fields) return bounded; + return bounded.map((r) => { + const out: any = {}; + for (const f of fields) if (r[f] !== undefined) out[f] = r[f]; + return out; + }); + }, + async findOne(object: string, ast: any) { + calls.push({ object, ast }); + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + 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) { + 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, undefined))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor, calls }; +} + +interface Outcome { ok: boolean; code?: string; status?: number; message?: string } + +const attempt = async (run: () => Promise): Promise => { + try { + await run(); + return { ok: true }; + } catch (e) { + const err = e as { code?: string; statusCode?: number; status?: number; message?: string }; + return { + ok: false, + code: err.code, + status: err.statusCode ?? err.status, + message: String(err.message ?? e), + }; + } +}; + +async function boot(sets: PermissionSet[]) { + const engine = new ObjectQL(); + const d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'crm_account', + fields: { name: { type: 'text' }, type: { type: 'text' } }, + } as any, 'test-package'); + engine.registry.registerObject(OPPORTUNITY as any, 'test-package'); + d.storeFor('crm_account').set('acc_p', { id: 'acc_p', name: 'P', type: 'partner' }); + d.storeFor('crm_account').set('acc_d', { id: 'acc_d', name: 'D', type: 'direct' }); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => sets, + }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + + // A row the caller may edit, seeded past every gate. + await engine.insert( + 'crm_opportunity', + { id: 'opp_1', name: 'seed', amount: 1, account: 'acc_d' }, + { context: SYS_CTX } as any, + ); + d.calls.length = 0; + return { + engine, + relatedReads: () => d.calls.filter((c) => c.object === 'crm_account').length, + reset: () => { d.calls.length = 0; }, + }; +} + +describe('#18682 — the preview answers nobody the FLS write gate refuses', () => { + describe('W6 — insert, a caller who may not edit the reference column', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot([FLS_LOCKED]); }); + + it('insert() refuses on the PERMISSION_DENIED envelope, having read nothing related', async () => { + const outcome = await attempt( + () => h.engine.insert('crm_opportunity', { ...PARTNER_PAYLOAD }, { context: CALLER } as any), + ); + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toMatch(/not permitted to edit/); + expect(h.relatedReads()).toBe(0); + }); + + it('validate() reads nothing related either, and never returns the rule verdict', async () => { + const preview = await h.engine.validate( + 'crm_opportunity', { ...PARTNER_PAYLOAD }, { mode: 'insert', context: CALLER } as any, + ); + expect(h.relatedReads()).toBe(0); + // Fail-closed, and — the whole point — NOT the rule's own answer about a + // related row this caller may not point at. + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).not.toContain(RULE_MESSAGE); + }); + }); + + describe('W6u — update, the same caller against a row they may edit', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot([FLS_LOCKED]); }); + + it('update() refuses on the same envelope, having read nothing related', async () => { + const outcome = await attempt(() => h.engine.update( + 'crm_opportunity', { amount: 50000, account: 'acc_p' }, + { where: { id: 'opp_1' }, context: CALLER } as any, + )); + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(h.relatedReads()).toBe(0); + }); + + it('validate({ mode: update }) reads nothing related and returns no rule verdict', async () => { + const preview = await h.engine.validate( + 'crm_opportunity', { amount: 50000, account: 'acc_p' }, + { mode: 'update', context: CALLER } as any, + ); + expect(h.relatedReads()).toBe(0); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).not.toContain(RULE_MESSAGE); + }); + }); + + // ⭐ The controls. Same harness, same caller, same payload — the fixture + // differs by `editable` alone. Without these the block above would pass on a + // gate that refused everyone, which is the other way to get this wrong. + describe('the control: the identical caller who MAY edit the column', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot([FLS_OPEN]); }); + + it('insert() reaches the rule and refuses with the rule, after ONE related read', async () => { + const outcome = await attempt( + () => h.engine.insert('crm_opportunity', { ...PARTNER_PAYLOAD }, { context: CALLER } as any), + ); + expect(outcome.ok).toBe(false); + expect(outcome.message).toContain(RULE_MESSAGE); + expect(h.relatedReads()).toBe(1); + }); + + it('validate() agrees with it, after ONE related read', async () => { + const preview = await h.engine.validate( + 'crm_opportunity', { ...PARTNER_PAYLOAD }, { mode: 'insert', context: CALLER } as any, + ); + expect(h.relatedReads()).toBe(1); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).toContain(RULE_MESSAGE); + }); + + it('…and both doors ACCEPT the payload the rule allows', async () => { + const preview = await h.engine.validate( + 'crm_opportunity', { name: 'B', amount: 50000, account: 'acc_d' }, + { mode: 'insert', context: CALLER } as any, + ); + expect(preview.results?.[0]?.valid).toBe(true); + await expect(h.engine.insert( + 'crm_opportunity', { name: 'B', amount: 50000, account: 'acc_d' }, { context: CALLER } as any, + )).resolves.toBeTruthy(); + }); + }); +}); From 0e5c7704bcf3acf2ca024b8aa058de82ca514950 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 20:43:33 +0000 Subject: [PATCH 18/47] docs(security): state the write-gate probe's bound positively, not as a completeness claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replacement docblock claimed the elevated read is issued "only for a caller the write path would admit THIS PAYLOAD from", and that the two named limits were "the distance" to write success. Measured false: with `allowCreate` held and the lookup column editable, a payload carrying `owner_id` for another user is refused by `insert()` at the middleware's step 3.5 (403, ZERO related reads) while `validate()` issues ONE related read and answers the traversing rule. The defect is the sentence SHAPE — an "only / the distance is exactly X" claim asserts an exhaustive enumeration of everything that can refuse a write, and the middleware's own step list refuses before `next()` in at least six further places the probe is never asked: 2.5a masked echo, 2.7 row pre-image, 2.8 ADR-0055 controlled-by-parent master edit, 2.9 anti-filter-oracle predicate guard, 3.5 owner forge, 3.6 RLS `check` post-image, 3.7 tenant post-image. So the shipped text now states what the probe DOES close — the object-level and field-level halves of the write decision, both pinned equal to the middleware — names the row-level/post-image and payload-value families as the nearest ones it does not, and declines to enumerate the distance to write success at all. Prose only: no code, test, pin, probe or changeset-level change. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 19 +++++-- content/docs/permissions/system-context.mdx | 2 +- packages/objectql/src/engine.ts | 53 +++++++++++++------ .../plugin-security/src/security-plugin.ts | 28 +++++++--- 4 files changed, 74 insertions(+), 28 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index 53733849a14..df2ac09e4d8 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -90,10 +90,21 @@ preview is the first — and an equivalence suite pins its answer EQUAL to the registered middleware's, case for case AND payload for payload, so the two cannot drift. -⛔ `true` never means the write will succeed. What is still ahead of it, by -name: the row-level pre-image (this method is asked about no ROW, and without a -payload about no FIELD either), `readonlyWhen`, the static `readonly` strip, and -the validation rules themselves. +⭐ What it answers, POSITIVELY: the OBJECT-level and the FIELD-level halves of +the write decision — the object arms over the object, the payload arm over the +keys the payload names — each pinned EQUAL to the registered middleware's. + +⛔ `true` never means the write will succeed, and ⛔ what follows is not an +enumeration of the distance to success: the middleware refuses before `next()` +for reasons this method is never asked. Nearest to hand are the row-level and +post-image refusals — the `using` pre-image, the ADR-0055 controlled-by-parent +master edit, the RLS `check` post-image and the Layer 0 tenant post-image, none +of which this method can judge because it is asked about no ROW; the +payload-VALUE refusals the same caller passes by simply not sending the value — +the masked echo and the `owner_id` forge, which therefore widen the caller class +by nothing; the anti-filter-oracle guard on the caller's own predicate, which +this method is handed none of; and, outside the middleware entirely, +`readonlyWhen`, the static `readonly` strip and the validation rules themselves. ### Scope diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 9a55a87f636..f1896cf5551 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -101,7 +101,7 @@ that silently does not happen. | 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` | | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | -| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to callers who could perform the write. An equivalence suite pins the answer EQUAL to the registered middleware's, case for case, including the ADR-0066 D3 capability arm, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | +| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to callers the write path would admit at the object and field level (⛔ not a promise the write would succeed). An equivalence suite pins the answer EQUAL to the registered middleware's, case for case, including the ADR-0066 D3 capability arm, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 983fd16e6b4..257b68e9b9e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3968,13 +3968,38 @@ export class ObjectQL implements IObjectQLEngine { * an editor of the child object who is FLS-locked out of the lookup column is * refused by `insert()` and refused the elevated read here, alike. * - * ⚠️ What this gate still does NOT cover, named rather than implied — the - * preview is NOT a promise that the write would succeed: - * - * - **The row-level pre-image (the middleware's step 2.7).** A row-level - * `using` / `check` policy judges a ROW, and the preview names none: it - * reads nothing and has no prior image to judge. A caller the row filter - * would refuse can still reach the verdict here. + * ⭐ What this gate closes, stated POSITIVELY. The probe closes the + * OBJECT-level and the FIELD-level halves of the write decision: an elevated + * read is issued only for a caller the write path would admit at the object + * level (the CRUD grant, the ADR-0066 D3 capability AND-gate, the ADR-0090 + * D10 delegator intersection and the fail-closed postures) AND at the field + * level (the middleware's own step 2.5 FLS write gate, over the keys THIS + * payload names). Both halves are pinned EQUAL to the registered + * middleware's, arm for arm. + * + * ⛔ That is the whole of the claim. A `true` here is NOT a promise that the + * write would succeed, and ⛔ no enumeration of the distance to success is + * attempted — the middleware refuses before `next()` for reasons this gate is + * never asked. The families nearest to hand, named so the two halves above + * are not mistaken for the whole list: + * + * - **Row-level and post-image refusals — the preview names no stored row.** + * The step 2.7 `using` pre-image, the ADR-0055 controlled-by-parent + * master-edit check (step 2.8), the RLS `check` post-image (step 3.6) and + * the Layer 0 tenant post-image (step 3.7) each judge a ROW: a prior + * image, a master record, or a pre-image merged with the change set. The + * preview reads nothing and holds none of them, so it judges none of them. + * - **Payload-VALUE refusals — the same caller passes by not sending the + * value.** The masked-echo write refusal (step 2.5a) and the `owner_id` + * ownership forge (step 3.5) refuse a VALUE, not a caller: the identical + * caller sending the identical row without the echoed or forged value is + * admitted. They widen the caller class by nothing, which is why this gate + * does not ask them — measured, with the CRUD grant held and the column + * editable and `owner_id` naming another user, `insert()` refuses at step + * 3.5 with ZERO related reads while the preview issues ONE. + * - **The caller's own PREDICATE.** Step 2.9's anti-filter-oracle guard + * refuses an update whose `where` names a field the caller may not read. + * The preview carries no predicate, so that guard is never asked here. * - **The static `readonly` strip.** `insert()` strips an author-declared * `readonly` reference field inside the write's executor, so the real * write resolves NO related row for it and a traversing rule refuses; @@ -3983,11 +4008,6 @@ export class ObjectQL implements IObjectQLEngine { * the channel to no caller the write path refuses — but the id the * preview judges is one the write path never carries. * - * So the bound this restores is the one that was accepted, neither narrowed - * nor widened: an elevated read is issued only for a caller the write path - * would admit THIS PAYLOAD from, and the two limits above are the distance - * between "admitted this payload" and "this write would succeed". - * * ## Unwired * * A composition with no security plugin has no CRUD gate on the write path @@ -10815,10 +10835,11 @@ export class ObjectQL implements IObjectQLEngine { // restated as if it were: a traversing validation rule needs its related // rows, so this operation issues a READ per reference field the rules name // (see the `previewRelatedForRow` block below). Nothing is written, no hook - // runs, and the read happens only for a caller who could perform the write - // being previewed. `update()` - // deliberately does not default (#2706: a PATCH's explicit `null` means - // "clear it"), so neither does an `update`-mode preview. + // runs, and the read happens only for a caller the write path would admit + // at the object and field level — ⛔ not a promise the write would succeed + // (`registerWriteGateProbe` names what that bound does and does not carry). + // `update()` deliberately does not default (#2706: a PATCH's explicit + // `null` means "clear it"), so neither does an `update`-mode preview. const rawRows = Array.isArray(data) ? data : [data]; const nowSnapshot = new Date(); const rows: Record[] = mode === 'insert' diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 633e357f8c1..4c9586b9ab9 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5117,10 +5117,12 @@ export class SecurityPlugin implements Plugin { * target object, by design: it executes nothing. A validation rule that reads * one hop through a reference field is evaluated there against a related row * fetched under SYSTEM authority, and the accepted cost of that elevation is - * an inference channel bounded to callers who could perform the write — a + * an inference channel bounded to callers the write path would admit — a * bound the real path gets for free, because the middleware's write gate * refuses long before any rule is evaluated. The preview has no such gate, so - * it asks this. + * it asks this. What this method restores is the OBJECT-level and the + * FIELD-level halves of that bound, ⛔ never the whole of the write decision + * — the closing paragraph names what stays ahead of it. * * ## The arms, in the middleware's own order — ⛔ the CRUD grant is not the gate * @@ -5171,11 +5173,23 @@ export class SecurityPlugin implements Plugin { * Fails CLOSED: a throw anywhere denies, and callers must treat a throw as a * denial too. * - * ⛔ `true` never means "this write will succeed". What is still ahead of it, - * by name: the row-level pre-image (step 2.7 — this answers about no ROW, and - * without a payload about no FIELD either), `readonlyWhen`, the static - * `readonly` strip, and the validation rules themselves. Nothing here may be - * used to widen. + * ⭐ What it answers, POSITIVELY: the OBJECT-level and the FIELD-level halves + * of the write decision — arms 1-6 over the object, arm 7 over the keys the + * payload names — each pinned EQUAL to the registered middleware's. + * + * ⛔ `true` never means "this write will succeed", and ⛔ what follows is not + * an enumeration of the distance to success: the middleware refuses before + * `next()` for reasons this method is never asked. Nearest to hand are the + * row-level and post-image refusals — the step 2.7 `using` pre-image, the + * ADR-0055 controlled-by-parent master edit (2.8), the RLS `check` + * post-image (3.6) and the Layer 0 tenant post-image (3.7), none of which + * this method can judge because it is asked about no ROW; the payload-VALUE + * refusals the same caller passes by simply not sending the value — the + * masked echo (2.5a) and the `owner_id` forge (3.5), which therefore widen + * the caller class by nothing; the anti-filter-oracle guard on the caller's + * own predicate (2.9), which this method is handed none of; and, outside the + * middleware entirely, `readonlyWhen`, the static `readonly` strip and the + * validation rules themselves. Nothing here may be used to widen. */ async canWriteObject( object: string, From 47eb9321e2ccae2862acdc1c776e232aa7a3fca9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:49:26 +0000 Subject: [PATCH 19/47] feat(security): add the ADR-0103 and ADR-0090 D12 pre-resolution arms to canWriteObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write-gate probe answered `true` for two caller classes the middleware refuses before any permission set resolves: a user-context write to an `engine-owned` object (ADR-0103) and a plain-CRUD holder on an RBAC link table (ADR-0090 D12). Both are object-level and caller-class — the same caller cannot pass by omitting a value or naming another row — so the preview handed the elevated related read to a caller who cannot write the object at all. Both arms call the middleware's own primitive at the middleware's own point in its order, inside the fail-closed try. The positive sentence at all three shipped sites is restated over the arms the probe RUNS, and the four remaining pre-resolution gates are named among the families it does not close. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 51 +++-- packages/objectql/src/engine.ts | 47 +++-- .../plugin-security/src/security-plugin.ts | 184 +++++++++++++----- 3 files changed, 200 insertions(+), 82 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index df2ac09e4d8..d48c1485815 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -78,33 +78,46 @@ an object-valued field traverses today and keeps traversing. ### `@objectstack/plugin-security` gains `canWriteObject` The WRITE admission — the sibling of the existing `canReadObject`, running the -middleware's own arms in the middleware's own order: system bypass, no resolved -permission sets, unresolvable posture, the ADR-0066 D3 `requiredPermissions` -capability AND-gate for both principals, the CRUD grant, the ADR-0090 D10 -delegator check, and — when the caller's payload is supplied — the field-level -security WRITE gate over it (`getFieldPermissions`, folded through the D3 -field-capability contract, intersected with the delegator's mask under D10, then -the forbidden-write detection). It exists for doors that must ask "could this -caller perform this write" without running the engine middleware — the write -preview is the first — and an equivalence suite pins its answer EQUAL to the -registered middleware's, case for case AND payload for payload, so the two -cannot drift. - -⭐ What it answers, POSITIVELY: the OBJECT-level and the FIELD-level halves of -the write decision — the object arms over the object, the payload arm over the -keys the payload names — each pinned EQUAL to the registered middleware's. +middleware's own arms in the middleware's own order: system bypass; then, before +anything resolves, the ADR-0103 engine-owned write guard and the ADR-0090 D12 +delegated-administration gate, each called as the middleware's own primitive; +then no resolved permission sets, unresolvable posture, the ADR-0066 D3 +`requiredPermissions` capability AND-gate for both principals, the CRUD grant, +the ADR-0090 D10 delegator check, and — when the caller's payload is supplied — +the field-level security WRITE gate over it (`getFieldPermissions`, folded +through the D3 field-capability contract, intersected with the delegator's mask +under D10, then the forbidden-write detection). It exists for doors that must ask +"could this caller perform this write" without running the engine middleware — +the write preview is the first — and an equivalence suite pins its answer EQUAL +to the registered middleware's, case for case AND payload for payload, so the +two cannot drift. + +⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the +write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12 +delegated-admin gate, the fail-closed postures, the ADR-0066 D3 capability +AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 +delegator's independent grant, and the step 2.5 FLS write gate over the keys the +payload names — each pinned EQUAL to the registered middleware's, arm for arm. +It says nothing about any refusal not in that list. ⛔ `true` never means the write will succeed, and ⛔ what follows is not an -enumeration of the distance to success: the middleware refuses before `next()` -for reasons this method is never asked. Nearest to hand are the row-level and +enumeration of the distance to success: the middleware refuses both before and +after `next()` for reasons this method is never asked. Nearest to hand are the +remaining pre-resolution gates that run beside the two named above — the +package-managed and system-row write gates, which judge a row's PROVENANCE; the +curated-capability-name and audience-anchor binding refusals, which judge a +payload VALUE; and the ADR-0056 public-form grant, which no caller can present +to this method and which has no extracted primitive to call; the row-level and post-image refusals — the `using` pre-image, the ADR-0055 controlled-by-parent master edit, the RLS `check` post-image and the Layer 0 tenant post-image, none of which this method can judge because it is asked about no ROW; the payload-VALUE refusals the same caller passes by simply not sending the value — the masked echo and the `owner_id` forge, which therefore widen the caller class by nothing; the anti-filter-oracle guard on the caller's own predicate, which -this method is handed none of; and, outside the middleware entirely, -`readonlyWhen`, the static `readonly` strip and the validation rules themselves. +this method is handed none of; the post-`next()` assertion that the insert +`check` seam really ran, which judges an executed write; and, outside the +middleware entirely, `readonlyWhen`, the static `readonly` strip and the +validation rules themselves. ### Scope diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 257b68e9b9e..d6fd3859d83 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3968,21 +3968,35 @@ export class ObjectQL implements IObjectQLEngine { * an editor of the child object who is FLS-locked out of the lookup column is * refused by `insert()` and refused the elevated read here, alike. * - * ⭐ What this gate closes, stated POSITIVELY. The probe closes the - * OBJECT-level and the FIELD-level halves of the write decision: an elevated - * read is issued only for a caller the write path would admit at the object - * level (the CRUD grant, the ADR-0066 D3 capability AND-gate, the ADR-0090 - * D10 delegator intersection and the fail-closed postures) AND at the field - * level (the middleware's own step 2.5 FLS write gate, over the keys THIS - * payload names). Both halves are pinned EQUAL to the registered - * middleware's, arm for arm. - * - * ⛔ That is the whole of the claim. A `true` here is NOT a promise that the - * write would succeed, and ⛔ no enumeration of the distance to success is - * attempted — the middleware refuses before `next()` for reasons this gate is - * never asked. The families nearest to hand, named so the two halves above - * are not mistaken for the whole list: - * + * ⭐ What this gate closes, stated POSITIVELY — by NAMING WHAT IT RUNS, ⛔ + * never by naming a category of the write decision. An elevated read is + * issued only for a caller that passes, in the middleware's own order: the + * ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin + * gate (both BEFORE any permission set resolves, both the middleware's own + * primitives), the fail-closed postures, the ADR-0066 D3 capability AND-gate + * for both principals, the `allowCreate`/`allowEdit` CRUD grant, the ADR-0090 + * D10 delegator's independent grant, and the middleware's own step 2.5 FLS + * write gate over the keys THIS payload names. Each of those is pinned EQUAL + * to the registered middleware's, arm for arm. The gate says nothing about + * any refusal not in that list. + * + * ⛔ A `true` here is NOT a promise that the write would succeed, and ⛔ no + * enumeration of the distance to success is attempted — the middleware + * refuses both before and after `next()` for reasons this gate is never + * asked. The families nearest to hand, named so the arms above are not read + * as the whole write decision: + * + * - **The remaining PRE-RESOLUTION gates, which run beside the two named + * above.** Two judge a row's PROVENANCE, which the preview holds no row to + * carry: the ADR-0086/0094 package-managed write gate and the ADR-0066 + * system-row write gate. Two judge a payload VALUE: the ADR-0066 D1 + * curated-capability-name refusal and the ADR-0090 D5/D9 audience-anchor + * binding guard. And the ADR-0056 `publicFormGrant` scope, which admits + * create plus read-back on exactly the granted object and refuses + * everything else — not asked because no caller can present that grant + * here (only the public form-submit route constructs one, and it goes to + * the real write, never to a preview) and because it has no extracted + * primitive to call, so an arm would be a second spelling of its scope. * - **Row-level and post-image refusals — the preview names no stored row.** * The step 2.7 `using` pre-image, the ADR-0055 controlled-by-parent * master-edit check (step 2.8), the RLS `check` post-image (step 3.6) and @@ -4000,6 +4014,9 @@ export class ObjectQL implements IObjectQLEngine { * - **The caller's own PREDICATE.** Step 2.9's anti-filter-oracle guard * refuses an update whose `where` names a field the caller may not read. * The preview carries no predicate, so that guard is never asked here. + * - **After `next()`.** The #16608 fail-closed assertion that the insert + * `check` seam really ran refuses a write that already executed, which no + * preview can be asked about at all. * - **The static `readonly` strip.** `insert()` strips an author-declared * `readonly` reference field inside the write's executor, so the real * write resolves NO related row for it and a traversing rule refuses; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 4c9586b9ab9..fb265494bf4 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5107,9 +5107,9 @@ export class SecurityPlugin implements Plugin { } /** - * [#18682] Whether `context` may CREATE or UPDATE `object` at all — the - * object-level WRITE admission, and the exact sibling of - * {@link canReadObject}. + * [#18682] Whether `context` may CREATE or UPDATE `object` under the nine + * arms enumerated below — the WRITE admission the preview asks for, and the + * exact sibling of {@link canReadObject}. * * ## Why it exists * @@ -5120,41 +5120,59 @@ export class SecurityPlugin implements Plugin { * an inference channel bounded to callers the write path would admit — a * bound the real path gets for free, because the middleware's write gate * refuses long before any rule is evaluated. The preview has no such gate, so - * it asks this. What this method restores is the OBJECT-level and the - * FIELD-level halves of that bound, ⛔ never the whole of the write decision - * — the closing paragraph names what stays ahead of it. + * it asks this. What this method restores is the nine arms enumerated below + * and nothing beyond them — the closing paragraph names the refusals that + * stay ahead of it. * * ## The arms, in the middleware's own order — ⛔ the CRUD grant is not the gate * * A probe that checked only `isSystem`, a principal and the CRUD grant admits - * two classes the write path refuses: a caller holding `allowCreate` but not a - * D3 `requiredPermissions` capability, and an `onBehalfOf` context naming a - * delegator that does not exist. Both were measured reaching the preview while - * `insert()` refused them. So the arms are the middleware's, in its order: + * classes the write path refuses: a caller holding `allowCreate` but not a D3 + * `requiredPermissions` capability; an `onBehalfOf` context naming a delegator + * that does not exist; and — earlier than either, before anything resolves — + * a caller asking about an object whose writes a platform service owns, or + * about an RBAC link table on the strength of a plain CRUD grant. All were + * measured reaching the preview while `insert()` refused them. So the arms + * are the middleware's, in its order: * * 1. `isSystem` → admit (the total bypass); - * 2. no permission sets resolved → admit (the middleware guards its whole + * 2. ADR-0103 `assertEngineOwnedWriteAllowed` over the registered schema — + * the middleware's own primitive, ⛔ never a second reading of + * `resolveCrudAffordances`: a user-context write to an `engine-owned` / + * `append-only` object whose `userActions` do not open the verb DENIES, + * ahead of the fall-open below and of every resolution; + * 3. ADR-0090 D12 `delegatedAdminGate.assert` — the same gate object the + * middleware calls, handed the members its `assert` reads (`object`, + * `operation`, `context`, the rows of `data`). A plain-CRUD holder on an + * RBAC link table DENIES; a tenant admin passes to the arms below; + * 4. no permission sets resolved → admit (the middleware guards its whole * CRUD gate with `if (permissionSets.length > 0)`); - * 3. `secMeta.unresolved` → DENY (#3545); - * 4. ADR-0066 D3/⑤ `requiredPermissions` capability AND-gate for the WRITE + * 5. `secMeta.unresolved` → DENY (#3545); + * 6. ADR-0066 D3/⑤ `requiredPermissions` capability AND-gate for the WRITE * CRUD class, checked BEFORE the grant, for the caller AND (D10) the * delegator; - * 5. the `allowCreate` / `allowEdit` CRUD grant for the operation asked; - * 6. ADR-0090 D10 — the delegator must independently hold the same grant; + * 7. the `allowCreate` / `allowEdit` CRUD grant for the operation asked; + * 8. ADR-0090 D10 — the delegator must independently hold the same grant; * a dangling delegator denies. * - * ## …and the seventh, which needs the PAYLOAD + * Arms 2 and 3 refuse a CALLER CLASS: the same caller cannot reach the write + * by omitting a value or by naming a different row, so a preview that + * answered them would hand the elevated read to someone who cannot write the + * object at all, for that verb, under any payload. Arms 6 and 8 were added on + * that same ground one gate later. * - * The object-level six are not the whole write decision either. The - * middleware refuses payload-dependent writes before `next()`, and the first - * of them is the field-level-security write gate (step 2.5): a caller holding - * the object's CRUD grant but not `editable` on a field the payload names is - * refused `PERMISSION_DENIED` there. A probe carrying no payload cannot ask - * it — so an editor of the child object who is FLS-locked out of the lookup - * column was refused by `insert()` and answered by the preview, the same - * divergence arms 4 and 6 close one gate earlier. + * ## …and the ninth, which needs the PAYLOAD * - * 7. step 2.5's own primitives over `data`, in the middleware's order — + * The eight above are not the whole write decision either. The middleware + * also refuses payload-dependent writes, and the first of them is the + * field-level-security write gate (step 2.5): a caller holding the object's + * CRUD grant but not `editable` on a field the payload names is refused + * `PERMISSION_DENIED` there. A probe carrying no payload cannot ask it — so + * an editor of the child object who is FLS-locked out of the lookup column + * was refused by `insert()` and answered by the preview, the same divergence + * arms 6 and 8 close one gate earlier. + * + * 9. step 2.5's own primitives over `data`, in the middleware's order — * `getFieldPermissions` folded through `foldFieldRequiredPermissions` * (ADR-0066 D3), intersected under D10 with the delegator's mask via * `intersectFieldMasks`, then `detectForbiddenWrites`. Skipped when no @@ -5173,23 +5191,53 @@ export class SecurityPlugin implements Plugin { * Fails CLOSED: a throw anywhere denies, and callers must treat a throw as a * denial too. * - * ⭐ What it answers, POSITIVELY: the OBJECT-level and the FIELD-level halves - * of the write decision — arms 1-6 over the object, arm 7 over the keys the - * payload names — each pinned EQUAL to the registered middleware's. + * ⭐ What it answers, POSITIVELY — stated by NAMING WHAT IT RUNS, ⛔ never by + * naming a category of the write decision: a `true` here means this caller + * passed the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 + * delegated-admin gate (arms 2 and 3, the middleware's own primitives, at the + * middleware's own point in its order), the fail-closed postures (#3545's + * unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 + * capability AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD + * grant, the D10 delegator's independent grant, and the step 2.5 FLS write + * gate over the keys THIS payload names. Each of those is pinned EQUAL to the + * registered middleware's, arm for arm. It means nothing about any refusal + * not in that list. * * ⛔ `true` never means "this write will succeed", and ⛔ what follows is not - * an enumeration of the distance to success: the middleware refuses before - * `next()` for reasons this method is never asked. Nearest to hand are the - * row-level and post-image refusals — the step 2.7 `using` pre-image, the - * ADR-0055 controlled-by-parent master edit (2.8), the RLS `check` - * post-image (3.6) and the Layer 0 tenant post-image (3.7), none of which - * this method can judge because it is asked about no ROW; the payload-VALUE - * refusals the same caller passes by simply not sending the value — the - * masked echo (2.5a) and the `owner_id` forge (3.5), which therefore widen - * the caller class by nothing; the anti-filter-oracle guard on the caller's - * own predicate (2.9), which this method is handed none of; and, outside the - * middleware entirely, `readonlyWhen`, the static `readonly` strip and the - * validation rules themselves. Nothing here may be used to widen. + * an enumeration of the distance to success: the middleware refuses both + * before and after `next()` for reasons this method is never asked. The + * families nearest to hand, named so the arms above are not read as the whole + * write decision: + * + * - **The remaining PRE-RESOLUTION gates, which run beside arms 2 and 3 and + * are not asked here.** Two judge a row's PROVENANCE, which a preview + * holds no row to carry: the ADR-0086/0094 package-managed write gate and + * the ADR-0066 system-row write gate. Two judge a payload VALUE: the + * ADR-0066 D1 curated-capability-name refusal and the ADR-0090 D5/D9 + * audience-anchor binding guard. And the ADR-0056 `publicFormGrant` scope, + * which admits create plus read-back on exactly the granted object and + * refuses everything else pre-resolution — not asked because no caller can + * present the grant here (it is constructed only by the public form-submit + * route, whose context goes to the real write, and the key is not in the + * inbound `ENTRY_EXECUTION_CONTEXT_FIELDS` set) and because it has no + * extracted primitive, so an arm would be a SECOND SPELLING of its scope — + * the drift this method exists to avoid. + * - **Row-level and post-image refusals — the preview names no stored row.** + * The step 2.7 `using` pre-image, the ADR-0055 controlled-by-parent master + * edit (2.8), the RLS `check` post-image (3.6) and the Layer 0 tenant + * post-image (3.7), none of which this method can judge because it is + * asked about no ROW. + * - **Payload-VALUE refusals the same caller passes by not sending the + * value** — the masked echo (2.5a) and the `owner_id` forge (3.5), which + * therefore widen the caller class by nothing. + * - **The caller's own PREDICATE** — the anti-filter-oracle guard (2.9), + * which this method is handed none of. + * - **After `next()`** — the #16608 fail-closed assertion that the insert + * `check` seam really ran, which judges an executed write. + * - **Outside the middleware entirely** — `readonlyWhen`, the static + * `readonly` strip and the validation rules themselves. + * + * Nothing here may be used to widen. */ async canWriteObject( object: string, @@ -5203,16 +5251,56 @@ export class SecurityPlugin implements Plugin { if (context?.isSystem) return true; try { + // 2-3. The middleware's two PRE-RESOLUTION caller-class refusals, called + // as the middleware calls them and at the same point in its order: + // after the `isSystem` bypass, BEFORE the principal-less fall-open + // (arm 4) and before any permission set resolves. Both answer about + // the CALLER and the OBJECT, so a caller they refuse cannot reach + // the write by omitting a value or naming a different row — which + // is exactly the class this method exists to keep out of the + // preview. + try { + // 2. [ADR-0103] Engine-owned write affordance. ⛔ Not a re-derivation: + // this is the middleware's own primitive over the same registered + // schema, so the `userActions` members that reopen a verb pass here + // for the one reason they pass there. + assertEngineOwnedWriteAllowed( + typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(objectName) : undefined, + operation, + context, + ); + // 3. [ADR-0090 D12] Delegated administration on the RBAC link tables. + // The middleware hands its whole `opCtx`; `assert` reads exactly + // `object`, `operation`, `context` and the ROWS of `data` (plus, for + // a delegate's update/delete, a single scalar id off + // `options.where.id` / `where.id` / `id`). A preview names no stored + // row, so it supplies the four it has and NOTHING else — which + // leaves the gate's delegate branch refusing an id-less mutation it + // cannot boundary-check. That is NARROWER than the write path for a + // scope-holding delegate, never wider, and narrower is the safe + // direction for a gate whose whole job is to withhold. + if (this.delegatedAdminGate) { + await this.delegatedAdminGate.assert({ object: objectName, operation, context, data }); + } + } catch (e) { + // A refusal from either gate IS the admission answer — the write path's + // own denial, not a failure to resolve one. Anything else is a + // subsystem failure and belongs to the outer catch, which logs it and + // still denies. + if (e instanceof PermissionDeniedError) return false; + throw e; + } + const permissionSets = await this.resolvePermissionSetsForContext(context); - // 2. No sets resolved → no permission-set restriction applies. + // 4. No sets resolved → no permission-set restriction applies. if (permissionSets.length === 0) return true; const { isPrivate, unresolved, requiredPermissions, fieldRequiredPermissions } = await this.getObjectSecurityMeta(objectName); - // 3. [#3545] Posture unresolvable → deny. + // 5. [#3545] Posture unresolvable → deny. if (unresolved) return false; - // [ADR-0090 D10] Resolve the delegator ONCE — arms 4 and 6 both need it, + // [ADR-0090 D10] Resolve the delegator ONCE — arms 6 and 8 both need it, // and a dangling link denies before either runs. let delegatorSets: PermissionSet[] | null = null; if (context?.onBehalfOf?.userId) { @@ -5223,7 +5311,7 @@ export class SecurityPlugin implements Plugin { } } - // 4. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both + // 6. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both // principals. const required = requiredCapsForOperation(requiredPermissions, operation); if (required.length > 0) { @@ -5235,12 +5323,12 @@ export class SecurityPlugin implements Plugin { } } - // 5. The object-level CRUD grant for the operation asked. + // 7. The object-level CRUD grant for the operation asked. if (!this.permissionEvaluator.checkObjectPermission(operation, objectName, permissionSets, { isPrivate })) { return false; } - // 6. [ADR-0090 D10] The delegator must independently grant the same write. + // 8. [ADR-0090 D10] The delegator must independently grant the same write. if ( delegatorSets && delegatorSets.length > 0 && @@ -5249,11 +5337,11 @@ export class SecurityPlugin implements Plugin { return false; } - // 7. The field-level-security WRITE gate — the middleware's step 2.5, + // 9. The field-level-security WRITE gate — the middleware's step 2.5, // over the payload the caller supplied. Same primitives, same order, // same guards: the middleware runs this only for an `insert`/`update` // carrying `opCtx.data` with permission sets resolved, and both of the - // latter already hold here (arm 2 returned for the empty resolution). + // latter already hold here (arm 4 returned for the empty resolution). // ⛔ Not a re-derivation — a second spelling of "which fields may this // caller write" is the drift this whole method exists to avoid. if (data) { From af24584e9ed386627fa3aa21712c25ce055e5295 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:55:40 +0000 Subject: [PATCH 20/47] test(security): pin both pre-resolution arms, in both directions and on both doors `can-write-object-admission.test.ts` gains ten equivalence cases (engine-owned insert/update, the `userActions`-reopened sibling, the isSystem and principal-less controls; the RBAC link table under plain CRUD in both modes, the tenant-admin, isSystem and principal-less controls) and ten direction pins. The equivalence block keeps its discipline: `expect(answered).toBe(admitted)` against the real middleware, no expected boolean per case. `write-preview-field-gate-parity.test.ts` drives the composed runtime for both classes: ZERO related reads and no rule verdict on BOTH doors for the refused caller, and the opened control firing after exactly ONE. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/can-write-object-admission.test.ts | 156 ++++++++++- .../write-preview-field-gate-parity.test.ts | 246 ++++++++++++++++++ 2 files changed, 399 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index b44fff76bd6..cc4c07ea96c 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -14,16 +14,21 @@ * {@link SecurityPlugin.canWriteObject} is what it asks. * * The first version of that question was NOT the middleware's decision. It - * checked `isSystem`, a principal, and the CRUD grant — and admitted two + * checked `isSystem`, a principal, and the CRUD grant — and admitted four * classes the write path refuses: * * - a caller holding `allowCreate` but NOT an ADR-0066 D3 `requiredPermissions` * capability the object declares (reachable from the wire: the `dryRun` * import route); * - an ADR-0090 D10 `onBehalfOf` context naming a delegator that does not - * exist (the in-process / `/mcp` door). + * exist (the in-process / `/mcp` door); + * - a user-context caller on an ADR-0103 `engine-owned` object whose + * `userActions` do not reopen the verb — refused BEFORE anything resolves, + * so no grant the caller can hold changes the answer; + * - a plain-CRUD holder on one of the ADR-0090 D12 RBAC link tables, refused + * at the same pre-resolution point for the same reason. * - * Both are cases below. ⭐ But the point of this file is not those two cases: it + * All four are cases below. ⭐ But the point of this file is not those cases: it * is that the first block asserts no expected boolean per case at all. It drives * the REAL registered middleware with the write for the same (object, context) * and requires the method's answer to EQUAL whether the middleware admitted. Two @@ -120,6 +125,29 @@ const AGENT_FLS_OPEN_SET: PermissionSet = { fields: { 'invoice.account': { readable: true, editable: true } }, } as unknown as PermissionSet; +/** + * ⭐ Y1 — full CRUD on an object a platform service owns end to end, and on the + * sibling whose `userActions` reopen the verb. One fixture, both directions. + */ +const ENGINE_OWNED_SET: PermissionSet = { + name: 'member_default', + label: 'Full CRUD on an engine-owned object', + objects: { + eng_log: { allowRead: true, allowCreate: true, allowEdit: true }, + eng_log_amendable: { allowRead: true, allowCreate: true, allowEdit: true }, + }, +} as unknown as PermissionSet; + +/** + * ⭐ Y3 — plain CRUD on an RBAC link table. ADR-0090 D12's whole point: holding + * this does not make the caller a permission administrator. + */ +const RBAC_CRUD_SET: PermissionSet = { + name: 'member_default', + label: 'Plain CRUD on sys_user_position', + objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true } }, +} as unknown as PermissionSet; + const schema = (name: string, extra: Record = {}) => ({ name, fields: { @@ -134,6 +162,24 @@ const SCHEMAS: Record> = { invoice: schema('invoice'), ledger: schema('ledger'), payroll_run: schema('payroll_run', { requiredPermissions: ['manage_payroll'] }), + // ⭐ Y1 — ADR-0103. The bucket's locked default grants no write, so the + // resolved affordances refuse every user-context verb… + eng_log: schema('eng_log', { managedBy: 'engine-owned' }), + // …and this one is the SAME bucket with `userActions` reopening create and + // edit, which is how the admin/user-writable members of it pass the guard. + eng_log_amendable: schema('eng_log_amendable', { + managedBy: 'engine-owned', + userActions: { create: true, edit: true }, + }), + // ⭐ Y3 — ADR-0090 D12 governs this object by NAME, not by a bucket. + sys_user_position: { + name: 'sys_user_position', + fields: { + organization_id: { type: 'text', label: 'Organization' }, + user: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + }, + }, }; const WRITER_CTX = { userId: 'u_writer', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; @@ -147,8 +193,28 @@ const AGENT_CTX = { }; const DELEGATED_AGENT_CTX = { ...AGENT_CTX, onBehalfOf: { userId: LIVE_DELEGATOR } }; +/** + * ⭐ The tenant-level admin ADR-0090 D12 exists to let through: the context NAMES + * the wildcard set, because the harness resolves a set only when the context + * asks for it. Without the name the caller resolves to the baseline alone and + * the case would prove nothing about the D12 arm. + */ +const TENANT_ADMIN_CTX = { + userId: 'u_admin', tenantId: 'org-1', positions: [], + permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN', +}; +/** A principal-less context — no positions, no sets, no `userId`. */ +const PRINCIPAL_LESS_CTX = { positions: [], permissions: [] }; +/** The system bypass, spelled the way every door spells it. */ +const SYSTEM_CTX = { isSystem: true, userId: 'usr_system' }; /** The payload every case carries unless it is about a restricted field. */ const PLAIN_PAYLOAD = { title: 'x' }; +/** + * ⭐ The D12 payload. `position` is deliberately NOT `everyone` / `guest`: those + * two are refused for every caller by the gate's audience-anchor invariant, and + * a case that tripped it would prove nothing about the delegated-admin arm. + */ +const RBAC_PAYLOAD = { user: 'u_target', position: 'sales' }; /** …and the one that names the column the FLS fixtures restrict. */ const REFERENCE_PAYLOAD = { title: 'x', account: 'acc_churn' }; @@ -252,6 +318,26 @@ describe('canWriteObject agrees with the engine middleware, case for case', () = // control twin is the identical caller with no delegation link. { label: 'a delegated agent whose delegator may not edit the field', object: 'invoice', operation: 'insert', sets: [AGENT_FLS_OPEN_SET, FLS_LOCKED_SET], context: DELEGATED_AGENT_CTX, data: REFERENCE_PAYLOAD }, { label: 'the same agent acting for nobody', object: 'invoice', operation: 'insert', sets: [AGENT_FLS_OPEN_SET, FLS_LOCKED_SET], context: AGENT_CTX, data: REFERENCE_PAYLOAD }, + // ⭐ Y1 — ADR-0103, the first of the two PRE-RESOLUTION classes that leaked. + // The caller holds every grant the object's own permission set can give and + // the write path still refuses them: the refusal is about the OBJECT and the + // CALLER CLASS, so no payload and no row can get them past it. + { label: 'an engine-owned object under a full CRUD grant', object: 'eng_log', operation: 'insert', sets: [ENGINE_OWNED_SET], context: WRITER_CTX }, + { label: 'the same engine-owned object in UPDATE mode', object: 'eng_log', operation: 'update', sets: [ENGINE_OWNED_SET], context: WRITER_CTX }, + // …and the OTHER direction, which is what keeps the arm from being a blanket + // deny on the bucket: the same bucket, the same caller, `userActions` open. + { label: 'an engine-owned object whose userActions reopen create', object: 'eng_log_amendable', operation: 'insert', sets: [ENGINE_OWNED_SET], context: WRITER_CTX }, + { label: 'an engine-owned object for a SYSTEM context', object: 'eng_log', operation: 'insert', sets: [ENGINE_OWNED_SET], context: SYSTEM_CTX }, + { label: 'an engine-owned object for a principal-less context', object: 'eng_log', operation: 'insert', sets: [ENGINE_OWNED_SET], context: PRINCIPAL_LESS_CTX }, + // ⭐ Y3 — ADR-0090 D12, the second. Same shape, different mechanism: the + // gate governs the object by name and asks about the caller's delegated + // administration, which a CRUD grant is not. + { label: 'an RBAC link table under a plain CRUD grant', object: 'sys_user_position', operation: 'insert', sets: [RBAC_CRUD_SET], context: WRITER_CTX, data: RBAC_PAYLOAD }, + { label: 'the same RBAC link table in UPDATE mode', object: 'sys_user_position', operation: 'update', sets: [RBAC_CRUD_SET], context: WRITER_CTX, data: RBAC_PAYLOAD }, + // …and its other direction: the tenant admin the gate exists to let through. + { label: 'an RBAC link table for a tenant-level admin', object: 'sys_user_position', operation: 'insert', sets: [ADMIN_SET], context: TENANT_ADMIN_CTX, data: RBAC_PAYLOAD }, + { label: 'an RBAC link table for a SYSTEM context', object: 'sys_user_position', operation: 'insert', sets: [RBAC_CRUD_SET], context: SYSTEM_CTX, data: RBAC_PAYLOAD }, + { label: 'an RBAC link table for a principal-less context', object: 'sys_user_position', operation: 'insert', sets: [RBAC_CRUD_SET], context: PRINCIPAL_LESS_CTX, data: RBAC_PAYLOAD }, ]; for (const c of CASES) { @@ -361,4 +447,68 @@ describe('the arms the CRUD grant alone does not cover', () => { plugin.canWriteObject('invoice', 'insert', AGENT_CTX, REFERENCE_PAYLOAD), ).resolves.toBe(true); }); + + // ── arm 2: the ADR-0103 engine-owned affordance gate (pre-resolution) ───── + + it('DENIES a full-CRUD holder on an engine-owned object (ADR-0103)', async () => { + const { plugin } = await boot([ENGINE_OWNED_SET]); + await expect(plugin.canWriteObject('eng_log', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(false); + }); + + it('DENIES it in UPDATE mode too — the affordance is per verb, not per object', async () => { + const { plugin } = await boot([ENGINE_OWNED_SET]); + await expect(plugin.canWriteObject('eng_log', 'update', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(false); + }); + + it('ADMITS the same bucket once userActions reopen the verb — so the arm is not a blanket deny', async () => { + const { plugin } = await boot([ENGINE_OWNED_SET]); + await expect(plugin.canWriteObject('eng_log_amendable', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); + }); + + it('ADMITS a system context on the engine-owned object — the bypass is intact', async () => { + const { plugin } = await boot([ENGINE_OWNED_SET]); + await expect(plugin.canWriteObject('eng_log', 'insert', SYSTEM_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); + }); + + // ── arm 3: the ADR-0090 D12 delegated-admin gate (pre-resolution) ───────── + + it('DENIES a plain-CRUD holder on an RBAC link table (ADR-0090 D12)', async () => { + const { plugin } = await boot([RBAC_CRUD_SET]); + await expect( + plugin.canWriteObject('sys_user_position', 'insert', WRITER_CTX, RBAC_PAYLOAD), + ).resolves.toBe(false); + }); + + it('DENIES it in UPDATE mode too', async () => { + const { plugin } = await boot([RBAC_CRUD_SET]); + await expect( + plugin.canWriteObject('sys_user_position', 'update', WRITER_CTX, RBAC_PAYLOAD), + ).resolves.toBe(false); + }); + + it('ADMITS a tenant-level admin on the same table — so the arm is not a blanket deny', async () => { + const { plugin } = await boot([ADMIN_SET]); + await expect( + plugin.canWriteObject('sys_user_position', 'insert', TENANT_ADMIN_CTX, RBAC_PAYLOAD), + ).resolves.toBe(true); + }); + + it('DENIES a principal-less context on the RBAC link table — the gate fails CLOSED before the fall-open', async () => { + const { plugin } = await boot([RBAC_CRUD_SET]); + await expect( + plugin.canWriteObject('sys_user_position', 'insert', PRINCIPAL_LESS_CTX, RBAC_PAYLOAD), + ).resolves.toBe(false); + }); + + it('ADMITS a system context on the RBAC link table — the bypass is intact', async () => { + const { plugin } = await boot([RBAC_CRUD_SET]); + await expect( + plugin.canWriteObject('sys_user_position', 'insert', SYSTEM_CTX, RBAC_PAYLOAD), + ).resolves.toBe(true); + }); + + it('leaves an ordinary object untouched by either pre-resolution arm', async () => { + const { plugin } = await boot([WRITER_SET]); + await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); + }); }); diff --git a/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts index 35f78cc3b5a..5d678d29ce7 100644 --- a/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts +++ b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts @@ -48,6 +48,18 @@ * doors, pays exactly ONE related read on each, and DOES receive the rule's * verdict. * + * ## …and the two PRE-RESOLUTION classes, which are a different shape + * + * The second half of the file drives the same two doors for two callers the + * middleware refuses BEFORE any permission set resolves: a user-context write + * to an ADR-0103 `engine-owned` object, and a plain-CRUD holder on one of the + * ADR-0090 D12 RBAC link tables. The FLS caller above could have reached the + * write by sending a different payload; these two cannot reach it at all, under + * any payload and against any row — so a preview that answered them would hand + * the oracle to a caller with no write channel whatsoever. Each carries its own + * control on the identical harness: the same engine-owned bucket with + * `userActions` reopening the verb, and the tenant-level admin D12 admits. + * * ⚠️ What this file does NOT claim. The gate is not a promise that the write * would succeed — the row-level pre-image (the middleware's step 2.7) judges a * row the preview does not name, and the static `readonly` strip runs inside @@ -81,6 +93,48 @@ const OPPORTUNITY = { }], }; +/** + * ⭐ Y1 — an object a platform service owns end to end (ADR-0103), carrying the + * same traversing rule. `managedBy: 'engine-owned'` is an AUTHORABLE key, so an + * app declares one the day it wants one. + */ +const ENG_LOG = { + name: 'eng_log', + managedBy: 'engine-owned', + fields: { + name: { type: 'text' }, + amount: { type: 'number' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + validations: [{ + name: 'partner_cap', type: 'script', severity: 'error', + message: RULE_MESSAGE, + condition: "record.account.type == 'partner' && record.amount > 10000", + }], +}; + +/** …and the control: the SAME bucket with `userActions` reopening create. */ +const ENG_LOG_AMENDABLE = { ...ENG_LOG, name: 'eng_log_amendable', userActions: { create: true, edit: true } }; + +/** + * ⭐ Y3 — one of the five RBAC link tables ADR-0090 D12 governs by NAME, + * carrying the same traversing rule. + */ +const USER_POSITION = { + name: 'sys_user_position', + fields: { + user: { type: 'text' }, + position: { type: 'text' }, + amount: { type: 'number' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + validations: [{ + name: 'partner_cap', type: 'script', severity: 'error', + message: RULE_MESSAGE, + condition: "record.account.type == 'partner' && record.amount > 10000", + }], +}; + /** May create and edit opportunities — and may NOT repoint the account. */ const FLS_LOCKED: PermissionSet = { name: 'member_default', @@ -97,11 +151,42 @@ const FLS_OPEN: PermissionSet = { fields: { 'crm_opportunity.account': { readable: true, editable: true } }, } as unknown as PermissionSet; +/** + * ⭐ Y1/Y3 — the same persona holding EVERY grant an ordinary permission set can + * give on the two objects, the lookup column included. Arms 4-9 all admit them; + * only the two pre-resolution gates do not. + */ +const FULL_CRUD: PermissionSet = { + name: 'member_default', + label: 'Full CRUD on the engine-owned and RBAC objects', + objects: { + eng_log: { allowRead: true, allowCreate: true, allowEdit: true }, + eng_log_amendable: { allowRead: true, allowCreate: true, allowEdit: true }, + sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true }, + }, + fields: { + 'eng_log.account': { readable: true, editable: true }, + 'eng_log_amendable.account': { readable: true, editable: true }, + 'sys_user_position.account': { readable: true, editable: true }, + }, +} as unknown as PermissionSet; + +/** …and the tenant-level admin ADR-0090 D12 exists to let through. */ +const TENANT_ADMIN: PermissionSet = { + name: 'tenant_admin', + label: 'Tenant-level administrator', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true } }, +} as unknown as PermissionSet; + const CALLER = { userId: 'u_editor', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; +/** The admin caller NAMES its set — the harness resolves only what is asked for. */ +const ADMIN_CALLER = { userId: 'u_admin', tenantId: 'org-1', positions: [], permissions: ['tenant_admin'], posture: 'PLATFORM_ADMIN' }; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; /** The payload under test: it names the restricted column, and it trips the rule. */ const PARTNER_PAYLOAD = { name: 'A', amount: 50000, account: 'acc_p' }; +/** ⭐ The D12 payload. `position` is deliberately not an audience anchor. */ +const RBAC_PARTNER_PAYLOAD = { user: 'u_target', position: 'sales', amount: 50000, account: 'acc_p' }; function makeDriver() { const stores = new Map>(); @@ -200,6 +285,9 @@ async function boot(sets: PermissionSet[]) { fields: { name: { type: 'text' }, type: { type: 'text' } }, } as any, 'test-package'); engine.registry.registerObject(OPPORTUNITY as any, 'test-package'); + engine.registry.registerObject(ENG_LOG as any, 'test-package'); + engine.registry.registerObject(ENG_LOG_AMENDABLE as any, 'test-package'); + engine.registry.registerObject(USER_POSITION as any, 'test-package'); d.storeFor('crm_account').set('acc_p', { id: 'acc_p', name: 'P', type: 'partner' }); d.storeFor('crm_account').set('acc_d', { id: 'acc_d', name: 'D', type: 'direct' }); @@ -229,6 +317,13 @@ async function boot(sets: PermissionSet[]) { { id: 'opp_1', name: 'seed', amount: 1, account: 'acc_d' }, { context: SYS_CTX } as any, ); + // …and the update targets for the two pre-resolution cases, seeded the same way. + await engine.insert('eng_log', { id: 'log_1', name: 'seed', amount: 1, account: 'acc_d' }, { context: SYS_CTX } as any); + await engine.insert( + 'sys_user_position', + { id: 'pos_1', user: 'u_target', position: 'sales', amount: 1, account: 'acc_d' }, + { context: SYS_CTX } as any, + ); d.calls.length = 0; return { engine, @@ -328,3 +423,154 @@ describe('#18682 — the preview answers nobody the FLS write gate refuses', () }); }); }); + +/** + * ⭐ [#18682] The two PRE-RESOLUTION caller-class refusals, on the same composed + * runtime. They are a different shape from W6 above and the difference is the + * point: W6's caller could have reached the write by sending a different + * payload, and these two cannot reach it at all. The write path admits NO + * user-context caller on the object for the verb, so a preview that answered + * would be handing the oracle to someone with no write channel whatsoever. + * + * Each has its control on the identical harness, so neither block can pass on a + * gate that refused everyone: for ADR-0103 the SAME bucket with `userActions` + * reopening the verb, for D12 the tenant-level admin the gate exists to admit. + */ +describe('#18682 — the preview answers nobody the two pre-resolution gates refuse', () => { + describe('Y1 — an ADR-0103 engine-owned object under a full CRUD grant', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot([FULL_CRUD]); }); + + it('insert() refuses on the PERMISSION_DENIED envelope, having read nothing related', async () => { + const outcome = await attempt( + () => h.engine.insert('eng_log', { ...PARTNER_PAYLOAD }, { context: CALLER } as any), + ); + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toMatch(/is engine-owned/); + expect(h.relatedReads()).toBe(0); + }); + + it('update() refuses on the same envelope, having read nothing related', async () => { + const outcome = await attempt(() => h.engine.update( + 'eng_log', { amount: 50000, account: 'acc_p' }, + { where: { id: 'log_1' }, context: CALLER } as any, + )); + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(h.relatedReads()).toBe(0); + }); + + it('validate() reads nothing related either, and never returns the rule verdict', async () => { + const preview = await h.engine.validate( + 'eng_log', { ...PARTNER_PAYLOAD }, { mode: 'insert', context: CALLER } as any, + ); + expect(h.relatedReads()).toBe(0); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).not.toContain(RULE_MESSAGE); + }); + + it('validate({ mode: update }) reads nothing related and returns no rule verdict', async () => { + const preview = await h.engine.validate( + 'eng_log', { amount: 50000, account: 'acc_p' }, { mode: 'update', context: CALLER } as any, + ); + expect(h.relatedReads()).toBe(0); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).not.toContain(RULE_MESSAGE); + }); + }); + + describe('the ADR-0103 control: the same bucket with userActions reopening create', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot([FULL_CRUD]); }); + + it('insert() reaches the rule and refuses with the rule, after ONE related read', async () => { + const outcome = await attempt( + () => h.engine.insert('eng_log_amendable', { ...PARTNER_PAYLOAD }, { context: CALLER } as any), + ); + expect(outcome.ok).toBe(false); + expect(outcome.message).toContain(RULE_MESSAGE); + expect(h.relatedReads()).toBe(1); + }); + + it('validate() agrees with it, after ONE related read', async () => { + const preview = await h.engine.validate( + 'eng_log_amendable', { ...PARTNER_PAYLOAD }, { mode: 'insert', context: CALLER } as any, + ); + expect(h.relatedReads()).toBe(1); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).toContain(RULE_MESSAGE); + }); + }); + + describe('Y3 — an ADR-0090 D12 RBAC link table under a plain CRUD grant', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot([FULL_CRUD]); }); + + it('insert() refuses on the PERMISSION_DENIED envelope, having read nothing related', async () => { + const outcome = await attempt( + () => h.engine.insert('sys_user_position', { ...RBAC_PARTNER_PAYLOAD }, { context: CALLER } as any), + ); + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toMatch(/delegated adminScope/); + expect(h.relatedReads()).toBe(0); + }); + + it('update() refuses on the same envelope, having read nothing related', async () => { + const outcome = await attempt(() => h.engine.update( + 'sys_user_position', { amount: 50000, account: 'acc_p' }, + { where: { id: 'pos_1' }, context: CALLER } as any, + )); + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(h.relatedReads()).toBe(0); + }); + + it('validate() reads nothing related either, and never returns the rule verdict', async () => { + const preview = await h.engine.validate( + 'sys_user_position', { ...RBAC_PARTNER_PAYLOAD }, { mode: 'insert', context: CALLER } as any, + ); + expect(h.relatedReads()).toBe(0); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).not.toContain(RULE_MESSAGE); + }); + + it('validate({ mode: update }) reads nothing related and returns no rule verdict', async () => { + const preview = await h.engine.validate( + 'sys_user_position', { amount: 50000, account: 'acc_p' }, + { mode: 'update', context: CALLER } as any, + ); + expect(h.relatedReads()).toBe(0); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).not.toContain(RULE_MESSAGE); + }); + }); + + describe('the D12 control: the tenant-level admin the gate admits', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot([TENANT_ADMIN]); }); + + it('insert() reaches the rule and refuses with the rule, after ONE related read', async () => { + const outcome = await attempt( + () => h.engine.insert('sys_user_position', { ...RBAC_PARTNER_PAYLOAD }, { context: ADMIN_CALLER } as any), + ); + expect(outcome.ok).toBe(false); + expect(outcome.message).toContain(RULE_MESSAGE); + expect(h.relatedReads()).toBe(1); + }); + + it('validate() agrees with it, after ONE related read', async () => { + const preview = await h.engine.validate( + 'sys_user_position', { ...RBAC_PARTNER_PAYLOAD }, { mode: 'insert', context: ADMIN_CALLER } as any, + ); + expect(h.relatedReads()).toBe(1); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).toContain(RULE_MESSAGE); + }); + }); +}); From 66c2de0346e1445ea3707dcb6f0ddecc95226284 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:35:55 +0000 Subject: [PATCH 21/47] docs(security): name the arms at the two remaining sites that still said "object and field level" The sweep found the same category phrasing the ruling refuses in two places the three named sites do not cover: the `validate()` body comment in the engine and the system-context table's write-admission row. Both now name the arms the probe runs, the two new pre-resolution ones included. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 2 +- packages/objectql/src/engine.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index f1896cf5551..b1372ec28cf 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -101,7 +101,7 @@ that silently does not happen. | 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` | | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | -| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to callers the write path would admit at the object and field level (⛔ not a promise the write would succeed). An equivalence suite pins the answer EQUAL to the registered middleware's, case for case, including the ADR-0066 D3 capability arm, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | +| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. An equivalence suite pins the answer EQUAL to the registered middleware's, case for case: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures, the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d6fd3859d83..3a8109ce789 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -10852,9 +10852,10 @@ export class ObjectQL implements IObjectQLEngine { // restated as if it were: a traversing validation rule needs its related // rows, so this operation issues a READ per reference field the rules name // (see the `previewRelatedForRow` block below). Nothing is written, no hook - // runs, and the read happens only for a caller the write path would admit - // at the object and field level — ⛔ not a promise the write would succeed - // (`registerWriteGateProbe` names what that bound does and does not carry). + // runs, and the read happens only for a caller who passes the arms the + // write-gate probe RUNS — ⛔ never a category of the write decision, and + // ⛔ not a promise the write would succeed (`registerWriteGateProbe` names + // those arms, and the families it does not carry). // `update()` deliberately does not default (#2706: a PATCH's explicit // `null` means "clear it"), so neither does an `update`-mode preview. const rawRows = Array.isArray(data) ? data : [data]; From c12c0ec26d3b8cefc2be34c157dc075680b89dea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 23:49:30 +0000 Subject: [PATCH 22/47] docs(security): name the post-next() seam by its mechanism, not by a dangling number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two bullets this change added cited an issue number that answers 404, so `check-issue-citations`' diff-scoped half exited 2 and stopped `Lint & Repo Gates` at that step, leaving ten gates behind it unmeasured. The number also shipped in both packages' emitted declarations. Name the mechanism instead — the fail-closed assertion that the engine honoured `OperationContext.postHookWriteImageCheck` — which is what a reader can act on and carries no number to dangle. The nine pre-existing citations in these two files are deliberately untouched: editing one would pull it into the diff the gate judges. Folded in from the same review: - The D12 arm is handed SHALLOW COPIES of the rows. The gate stamps `granted_by` onto the rows it materialises and materialises an insert's rows by reference, so a write PREVIEW could write into the caller's own payload. Nothing reads `granted_by` back, so the decision cannot notice the copy. - The shipped "pinned EQUAL … arm for arm" sentence now discloses the one NARROWER exception (an id-less update for a scope-holding delegate) that until now lived only in a body comment that does not ship. - "the fail-closed postures" is expanded at the three sites that carried the bare group label. - The "superuser wildcard" equivalence case names the set it is about; without the name it resolved to the baseline and both doors answered false. - "no caller can present the grant here" is now "no wire caller and no constructor in the tree". Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 14 ++++--- content/docs/permissions/system-context.mdx | 2 +- packages/objectql/src/engine.ts | 31 ++++++++------ .../src/can-write-object-admission.test.ts | 7 +++- .../plugin-security/src/security-plugin.ts | 42 +++++++++++++++---- 5 files changed, 68 insertions(+), 28 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index d48c1485815..99bfca460cd 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -94,11 +94,15 @@ two cannot drift. ⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12 -delegated-admin gate, the fail-closed postures, the ADR-0066 D3 capability -AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 -delegator's independent grant, and the step 2.5 FLS write gate over the keys the -payload names — each pinned EQUAL to the registered middleware's, arm for arm. -It says nothing about any refusal not in that list. +delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and +the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both +principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's +independent grant, and the step 2.5 FLS write gate over the keys the payload +names — each pinned EQUAL to the registered middleware's, arm for arm, with ONE +exception disclosed because it is not pinned: a preview names no stored row, so +on an UPDATE the D12 arm is handed no id and refuses a scope-holding delegate it +cannot boundary-check, where the middleware — holding that id — can admit. +NARROWER, never wider. It says nothing about any refusal not in that list. ⛔ `true` never means the write will succeed, and ⛔ what follows is not an enumeration of the distance to success: the middleware refuses both before and diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b1372ec28cf..95e0e6bd83e 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -101,7 +101,7 @@ that silently does not happen. | 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` | | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | -| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. An equivalence suite pins the answer EQUAL to the registered middleware's, case for case: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures, the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | +| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. An equivalence suite pins the answer EQUAL to the registered middleware's, case for case: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 3a8109ce789..a2b9efabd51 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3973,12 +3973,17 @@ export class ObjectQL implements IObjectQLEngine { * issued only for a caller that passes, in the middleware's own order: the * ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin * gate (both BEFORE any permission set resolves, both the middleware's own - * primitives), the fail-closed postures, the ADR-0066 D3 capability AND-gate - * for both principals, the `allowCreate`/`allowEdit` CRUD grant, the ADR-0090 - * D10 delegator's independent grant, and the middleware's own step 2.5 FLS - * write gate over the keys THIS payload names. Each of those is pinned EQUAL - * to the registered middleware's, arm for arm. The gate says nothing about - * any refusal not in that list. + * primitives), the fail-closed postures (#3545's unresolvable posture and the + * D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both + * principals, the `allowCreate`/`allowEdit` CRUD grant, the ADR-0090 D10 + * delegator's independent grant, and the middleware's own step 2.5 FLS write + * gate over the keys THIS payload names. Each of those is pinned EQUAL to the + * registered middleware's, arm for arm, with ONE exception disclosed here + * because it is not pinned: a preview names no stored row, so on an UPDATE + * the D12 arm is handed no id and refuses a scope-holding delegate it cannot + * boundary-check, where the middleware — holding that id — can admit. + * NARROWER, never wider. The gate says nothing about any refusal not in that + * list. * * ⛔ A `true` here is NOT a promise that the write would succeed, and ⛔ no * enumeration of the distance to success is attempted — the middleware @@ -3993,10 +3998,11 @@ export class ObjectQL implements IObjectQLEngine { * curated-capability-name refusal and the ADR-0090 D5/D9 audience-anchor * binding guard. And the ADR-0056 `publicFormGrant` scope, which admits * create plus read-back on exactly the granted object and refuses - * everything else — not asked because no caller can present that grant - * here (only the public form-submit route constructs one, and it goes to - * the real write, never to a preview) and because it has no extracted - * primitive to call, so an arm would be a second spelling of its scope. + * everything else — not asked because no wire caller and no constructor + * in the tree presents that grant here (only the public form-submit route + * constructs one, and it goes to the real write, never to a preview) and + * because it has no extracted primitive to call, so an arm would be a + * second spelling of its scope. * - **Row-level and post-image refusals — the preview names no stored row.** * The step 2.7 `using` pre-image, the ADR-0055 controlled-by-parent * master-edit check (step 2.8), the RLS `check` post-image (step 3.6) and @@ -4014,8 +4020,9 @@ export class ObjectQL implements IObjectQLEngine { * - **The caller's own PREDICATE.** Step 2.9's anti-filter-oracle guard * refuses an update whose `where` names a field the caller may not read. * The preview carries no predicate, so that guard is never asked here. - * - **After `next()`.** The #16608 fail-closed assertion that the insert - * `check` seam really ran refuses a write that already executed, which no + * - **After `next()`.** The fail-closed assertion that the engine honoured + * `OperationContext.postHookWriteImageCheck` — that the insert `check` + * seam really ran — refuses a write that already executed, which no * preview can be asked about at all. * - **The static `readonly` strip.** `insert()` strips an author-declared * `readonly` reference field inside the write's executor, so the real diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index cc4c07ea96c..4a9d0917e0c 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -294,7 +294,12 @@ describe('canWriteObject agrees with the engine middleware, case for case', () = { label: 'an explicit create grant', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, { label: 'an explicit edit grant', object: 'invoice', operation: 'update', sets: [WRITER_SET], context: WRITER_CTX }, { label: 'read but no write grant', object: 'invoice', operation: 'insert', sets: [READER_SET], context: WRITER_CTX }, - { label: 'a superuser wildcard', object: 'ledger', operation: 'insert', sets: [ADMIN_SET], context: { ...WRITER_CTX, posture: 'PLATFORM_ADMIN' } }, + // ⭐ The context NAMES the wildcard set, for the reason TENANT_ADMIN_CTX + // does: the harness resolves a set only when the context asks for it, so + // without the name this caller resolves to the baseline alone, both doors + // answer `false`, and the case agrees about something that is not a + // wildcard at all — green, and vacuous with respect to its own label. + { label: 'a superuser wildcard', object: 'ledger', operation: 'insert', sets: [ADMIN_SET], context: { ...WRITER_CTX, permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN' } }, // ⭐ W3 — the ADR-0066 D3 capability arm, the first class that leaked. { label: 'a required capability the caller LACKS', object: 'payroll_run', operation: 'insert', sets: [CAPLESS_SET], context: WRITER_CTX }, { label: 'a required capability the caller HOLDS', object: 'payroll_run', operation: 'insert', sets: [CAPABLE_SET], context: WRITER_CTX }, diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index fb265494bf4..76cdd45c1e2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5200,8 +5200,12 @@ export class SecurityPlugin implements Plugin { * capability AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD * grant, the D10 delegator's independent grant, and the step 2.5 FLS write * gate over the keys THIS payload names. Each of those is pinned EQUAL to the - * registered middleware's, arm for arm. It means nothing about any refusal - * not in that list. + * registered middleware's, arm for arm, with ONE exception disclosed here + * because it is not pinned: a preview names no stored row, so on an UPDATE + * arm 3 is handed no id and refuses a scope-holding delegate it cannot + * boundary-check, where the middleware — holding that id — can admit. + * NARROWER, never wider. It means nothing about any refusal not in that + * list. * * ⛔ `true` never means "this write will succeed", and ⛔ what follows is not * an enumeration of the distance to success: the middleware refuses both @@ -5216,10 +5220,11 @@ export class SecurityPlugin implements Plugin { * ADR-0066 D1 curated-capability-name refusal and the ADR-0090 D5/D9 * audience-anchor binding guard. And the ADR-0056 `publicFormGrant` scope, * which admits create plus read-back on exactly the granted object and - * refuses everything else pre-resolution — not asked because no caller can - * present the grant here (it is constructed only by the public form-submit - * route, whose context goes to the real write, and the key is not in the - * inbound `ENTRY_EXECUTION_CONTEXT_FIELDS` set) and because it has no + * refuses everything else pre-resolution — not asked because no wire + * caller and no constructor in the tree presents the grant here (it is + * constructed only by the public form-submit route, whose context goes to + * the real write, and the key is not in the inbound + * `ENTRY_EXECUTION_CONTEXT_FIELDS` set) and because it has no * extracted primitive, so an arm would be a SECOND SPELLING of its scope — * the drift this method exists to avoid. * - **Row-level and post-image refusals — the preview names no stored row.** @@ -5232,8 +5237,9 @@ export class SecurityPlugin implements Plugin { * therefore widen the caller class by nothing. * - **The caller's own PREDICATE** — the anti-filter-oracle guard (2.9), * which this method is handed none of. - * - **After `next()`** — the #16608 fail-closed assertion that the insert - * `check` seam really ran, which judges an executed write. + * - **After `next()`** — the fail-closed assertion that the engine honoured + * `OperationContext.postHookWriteImageCheck`, i.e. that the insert `check` + * seam really ran, which judges an executed write. * - **Outside the middleware entirely** — `readonlyWhen`, the static * `readonly` strip and the validation rules themselves. * @@ -5279,8 +5285,26 @@ export class SecurityPlugin implements Plugin { // cannot boundary-check. That is NARROWER than the write path for a // scope-holding delegate, never wider, and narrower is the safe // direction for a gate whose whole job is to withhold. + // + // ⭐ And the rows it supplies are SHALLOW COPIES, never the caller's + // own objects. The gate stamps `granted_by` onto the rows it + // materialises (its dual audit), and on an insert it materialises + // the payload rows BY REFERENCE — while `validate()` hands this + // method the caller's RAW payload, so passing it straight through + // would let a PREVIEW write into the caller's own objects. The + // decision cannot notice the copy: nothing reads `granted_by` back, + // it is only ever written. if (this.delegatedAdminGate) { - await this.delegatedAdminGate.assert({ object: objectName, operation, context, data }); + const shallow = (row: unknown) => + row && typeof row === 'object' && !Array.isArray(row) + ? { ...(row as Record) } + : row; + await this.delegatedAdminGate.assert({ + object: objectName, + operation, + context, + data: Array.isArray(data) ? data.map(shallow) : shallow(data), + }); } } catch (e) { // A refusal from either gate IS the admission answer — the write path's From 659d5a45caa33464ccbe856f4d29acebb6b4ce22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 01:15:37 +0000 Subject: [PATCH 23/47] test(security): pin the D12 arm's row copy and its one DIRECTION case The shallow copy canWriteObject hands the ADR-0090 D12 gate was pinned by nothing: passing the caller's rows straight through left the whole package suite green. Two cases now drive a delegated administrator through the real booted plugin and the real gate, prove the gate stamped `granted_by` on what it was handed (a call-through spy, so a refusal before the stamp cannot pass vacuously), and then read the caller's own rows: a single row, and a batch in the array shape validate() sends. The one disclosed exception to "pinned EQUAL, arm for arm" gets its pin too, in a block of its own and as a direction: a scope-holding delegate's id-less UPDATE preview is refused by the probe and admitted by the middleware holding the id. The middleware door takes an optional id for it, spelled as the engine's by-id update (options.where.id, no AST); every equivalence case still gets the id-less shape. The harness answers find/findOne from stored rows only on a boot handed tables, so no existing case sees a different engine double. The three shipped sentences that disclosed the exception "because it is not pinned" (the plugin's canWriteObject docblock, the engine's write-gate probe docblock, the changeset) now say where it is pinned and that it is pinned as a direction, not as an equivalence. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 11 +- packages/objectql/src/engine.ts | 14 +- .../src/can-write-object-admission.test.ts | 203 +++++++++++++++++- .../plugin-security/src/security-plugin.ts | 19 +- 4 files changed, 220 insertions(+), 27 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index 99bfca460cd..207e961cb4d 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -99,10 +99,13 @@ the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's independent grant, and the step 2.5 FLS write gate over the keys the payload names — each pinned EQUAL to the registered middleware's, arm for arm, with ONE -exception disclosed because it is not pinned: a preview names no stored row, so -on an UPDATE the D12 arm is handed no id and refuses a scope-holding delegate it -cannot boundary-check, where the middleware — holding that id — can admit. -NARROWER, never wider. It says nothing about any refusal not in that list. +exception, pinned as a DIRECTION and not as an equivalence: a preview names no +stored row, so on an UPDATE the D12 arm is handed no id and refuses a +scope-holding delegate it cannot boundary-check, where the middleware — holding +that id — can admit. `@objectstack/plugin-security`'s admission suite holds that +case in a block of its own, asserting the method `false` and the middleware +`true`: NARROWER, never wider. It says nothing about any refusal not in that +list. ⛔ `true` never means the write will succeed, and ⛔ what follows is not an enumeration of the distance to success: the middleware refuses both before and diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a2b9efabd51..8070e0967ab 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3978,12 +3978,14 @@ export class ObjectQL implements IObjectQLEngine { * principals, the `allowCreate`/`allowEdit` CRUD grant, the ADR-0090 D10 * delegator's independent grant, and the middleware's own step 2.5 FLS write * gate over the keys THIS payload names. Each of those is pinned EQUAL to the - * registered middleware's, arm for arm, with ONE exception disclosed here - * because it is not pinned: a preview names no stored row, so on an UPDATE - * the D12 arm is handed no id and refuses a scope-holding delegate it cannot - * boundary-check, where the middleware — holding that id — can admit. - * NARROWER, never wider. The gate says nothing about any refusal not in that - * list. + * registered middleware's, arm for arm, with ONE exception, pinned as a + * DIRECTION and not as an equivalence: a preview names no stored row, so on + * an UPDATE the D12 arm is handed no id and refuses a scope-holding delegate + * it cannot boundary-check, where the middleware — holding that id — can + * admit. `@objectstack/plugin-security`'s `can-write-object-admission.test.ts` + * holds that case in a block of its own, asserting the probe `false` and the + * middleware `true`: NARROWER, never wider. The gate says nothing about any + * refusal not in that list. * * ⛔ A `true` here is NOT a promise that the write would succeed, and ⛔ no * enumeration of the distance to success is attempted — the middleware diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index 4a9d0917e0c..d75e31ac013 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -51,6 +51,24 @@ * carries a field the fixtures actually restrict — in both directions, and once * under D10 delegation where the two masks must intersect rather than union. * + * ## …and the two blocks that are NOT equivalences + * + * Both need a caller the equivalence block has no fixture for: a DELEGATED + * administrator (ADR-0090 D12) holding a real `adminScope` over a business-unit + * subtree — the only caller the D12 gate's delegate branch judges, stamps and + * refuses without an id. `boot(sets, tables)` hands the gate the stored rows it + * reads for one (the topology is `delegated-admin-gate.test.ts`'s own); every + * other boot passes no tables and gets the engine double exactly as before. + * + * - **The copy.** The D12 arm is handed SHALLOW COPIES of the caller's rows, + * because the gate stamps `granted_by` onto the rows it is handed and the + * preview passes this method the caller's own payload. Nothing reads the + * stamp back, so no admission answer can notice the copy — only the + * caller's row can, and that is what the block asserts. + * - **The DIRECTION.** On an id-less UPDATE the probe refuses a delegate the + * middleware — holding the id — admits: the one place the two doors are + * pinned UNEQUAL, and only in the narrower direction. + * * Harness mirrors `can-read-object-admission.test.ts`, whose read twin this is. */ @@ -218,7 +236,69 @@ const RBAC_PAYLOAD = { user: 'u_target', position: 'sales' }; /** …and the one that names the column the FLS fixtures restrict. */ const REFERENCE_PAYLOAD = { title: 'x', account: 'acc_churn' }; -async function boot(sets: PermissionSet[]) { +/** + * ⭐ A DELEGATED administrator (ADR-0090 D12): no tenant-level wildcard, a plain + * CRUD grant on the link table, and an `adminScope` over the `east` subtree — + * the scope `delegated-admin-gate.test.ts` calls `EAST_SCOPE`, over the same + * topology: + * + * hq (bu_hq) + * ├── east (bu_east) ← the scope's root + * │ └── east_sales (bu_es) + * └── west (bu_west) + */ +const EAST_SCOPE = { + businessUnit: 'east', + includeSubtree: true, + manageAssignments: true, + manageBindings: true, + authorEnvironmentSets: true, + assignablePermissionSets: ['sales_user', 'sub_admin'], +}; +const DELEGATE_SET: PermissionSet = { + name: 'sub_admin', + label: 'Delegated administrator of the east subtree', + objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true } }, + adminScope: EAST_SCOPE, +} as unknown as PermissionSet; +const DELEGATE_CTX = { + userId: 'u_delegate', tenantId: 'org-1', positions: [], permissions: ['sub_admin'], posture: 'MEMBER', +}; + +type Tables = Record>>; + +/** + * The stored rows the D12 gate reads for that delegate: the BU tree its subtree + * resolves over, the one set `sales_rep` distributes (allowlisted by the scope), + * and — for the by-id update — the pre-image `a_prev`, anchored inside the + * subtree. A fresh copy per boot, so no case sees another's rows. + */ +const delegateTables = (): Tables => ({ + sys_business_unit: [ + { id: 'bu_hq', name: 'hq', parent_business_unit_id: null }, + { id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' }, + { id: 'bu_es', name: 'east_sales', parent_business_unit_id: 'bu_east' }, + { id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq' }, + ], + sys_position: [{ id: 'pos_sales', name: 'sales_rep' }], + sys_position_permission_set: [{ id: 'b1', position_id: 'pos_sales', permission_set_id: 'ps_sales' }], + sys_permission_set: [{ id: 'ps_sales', name: 'sales_user' }], + sys_user_position: [{ id: 'a_prev', user: 'u_east_1', position: 'sales_rep', business_unit_id: 'bu_es' }], +}); + +/** Equality and `$in`, nothing else — an operator this double does not know fails loudly, never matches silently. */ +function rowMatches(row: Record, where: Record | undefined): boolean { + return Object.entries(where ?? {}).every(([key, want]) => { + if (key.startsWith('$')) throw new Error(`engine double: unsupported operator ${key}`); + if (want && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)) { + return ((want as { $in: unknown[] }).$in).includes(row[key]); + } + if (want && typeof want === 'object') throw new Error(`engine double: unsupported predicate on ${key}`); + return row[key] === want; + }); +} + +async function boot(sets: PermissionSet[], tables?: Tables) { const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; const services: Record = { manifest: { register: vi.fn() }, @@ -228,12 +308,26 @@ async function boot(sets: PermissionSet[]) { // Exactly one delegator exists. Every other lookup misses — which is what // makes DANGLING_DELEGATOR_CTX the D10 fail-closed case, while // DELEGATED_AGENT_CTX gets a delegator that really resolves (to the - // additive baseline, and to nothing else). - findOne: vi.fn(async (_object: string, query: any) => ( - query?.where?.id === LIVE_DELEGATOR + // additive baseline, and to nothing else). A boot handed `tables` answers + // those objects from its rows instead; no other boot does. + findOne: vi.fn(async (object: string, query: any) => { + if (tables && object in tables) { + return tables[object].find((row) => rowMatches(row, query?.where)) ?? null; + } + return query?.where?.id === LIVE_DELEGATOR ? { id: LIVE_DELEGATOR, email: 'boss@example.test' } - : null - )), + : null; + }), + // `find` exists ONLY on a boot handed `tables`, so every other boot keeps + // the engine double the equivalence block has always run against. + ...(tables + ? { + find: vi.fn(async (object: string, query: any) => { + const rows = (tables[object] ?? []).filter((row) => rowMatches(row, query?.where)); + return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; + }), + } + : {}), }, metadata: { get: async (_type: string, name: string) => SCHEMAS[name], @@ -256,21 +350,27 @@ async function boot(sets: PermissionSet[]) { return { plugin, middleware: middlewares[0] }; } -/** Would the ENGINE middleware admit this write here? */ +/** + * Would the ENGINE middleware admit this write here? + * + * Without `id` this is the id-less write every equivalence case uses. With one + * it is the engine's by-id update: the row named as `options.where.id`, and no + * AST, because `update()` builds one only when it has no single id. + */ async function middlewareAdmits( middleware: (opCtx: any, next: () => Promise) => Promise, object: string, operation: 'insert' | 'update', context: Record, data: unknown, + id?: string, ): Promise { const opCtx: any = { object, operation, context: { ...context }, - options: {}, + ...(id === undefined ? { options: {}, ast: { where: {} } } : { options: { where: { id } } }), data, - ast: { where: {} }, }; try { await middleware(opCtx, async () => {}); @@ -517,3 +617,88 @@ describe('the arms the CRUD grant alone does not cover', () => { await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); }); }); + +/** + * ⭐ The D12 arm is handed COPIES of the caller's rows — a preview never writes + * into the caller's own objects. + * + * The gate stamps `granted_by` (its dual audit) onto the rows it materialises, + * and on an insert it materialises the payload rows BY REFERENCE. `validate()` + * hands this method the caller's RAW payload, so passing it straight through + * would stamp the caller's objects during a PREVIEW. No admission answer can + * notice either way — nothing reads `granted_by` back — so only the caller's + * row can, and that is what these cases read. + * + * Driven through the REAL booted plugin and the REAL gate. The call-through spy + * is the non-vacuity leg: each case first proves the gate DID stamp what it was + * handed — the hazard is live on this path — and only then that the caller's + * row carries no stamp. Without that leg a gate refusing before its stamp would + * leave the caller's row clean for the wrong reason, and the pin would hold + * while pinning nothing. + */ +describe("the D12 arm judges copies — a preview never stamps the caller's rows", () => { + const assignment = (user: string, businessUnit: string) => ({ + user, position: 'sales_rep', business_unit_id: businessUnit, + }); + + it('leaves a single caller row unstamped, while the gate stamps the copy it was handed', async () => { + const { plugin } = await boot([DELEGATE_SET], delegateTables()); + const handedToGate = vi.spyOn((plugin as any).delegatedAdminGate, 'assert'); + const row = assignment('u_east_1', 'bu_es'); + + await expect( + plugin.canWriteObject('sys_user_position', 'insert', DELEGATE_CTX, row), + ).resolves.toBe(true); + + expect(handedToGate).toHaveBeenCalledTimes(1); + expect((handedToGate.mock.calls[0][0] as any).data.granted_by).toBe('u_delegate'); + expect('granted_by' in row).toBe(false); + }); + + it('leaves every row of a batch unstamped — the shape validate() actually sends', async () => { + const { plugin } = await boot([DELEGATE_SET], delegateTables()); + const handedToGate = vi.spyOn((plugin as any).delegatedAdminGate, 'assert'); + const rows = [assignment('u_east_1', 'bu_es'), assignment('u_east_2', 'bu_east')]; + + await expect( + plugin.canWriteObject('sys_user_position', 'insert', DELEGATE_CTX, rows), + ).resolves.toBe(true); + + expect(handedToGate).toHaveBeenCalledTimes(1); + const handed = (handedToGate.mock.calls[0][0] as any).data as Array>; + expect(handed.map((r) => r.granted_by)).toEqual(['u_delegate', 'u_delegate']); + expect(rows.map((r) => 'granted_by' in r)).toEqual([false, false]); + }); +}); + +/** + * ⭐ DIRECTION, not equivalence — the ONE arm where this method is pinned + * NARROWER than the middleware, and only in that direction. + * + * A preview names no stored row, so on an UPDATE the probe hands the D12 gate + * no id, and the gate's delegate branch refuses a mutation it cannot attribute + * to one pre-imaged row (`isMutationWithoutId`, ahead of its branch switch). + * The middleware holds that id — the engine's by-id update carries it as + * `options.where.id` — and judges the pre-image instead, so the same delegate + * sending the same patch is ADMITTED there. + * + * So this is asserted as a direction — this method `false`, the middleware + * `true` — ⛔ never as equality, and it is kept out of the equivalence block, + * whose doors must agree by construction. Both doors get the shape they really + * receive: the probe an ARRAY (`validate()` always sends `rawRows`), the + * middleware the engine's by-id `data` and id. If the two ever agree here, + * either the probe learned an id it has no way to hold, or the middleware lost + * the one it has — a change to be looked at, not absorbed. + */ +describe('DIRECTION — the one arm where the probe is narrower than the write path', () => { + it("REFUSES a scope-holding delegate's id-less UPDATE that the middleware, holding the id, ADMITS (ADR-0090 D12)", async () => { + const { plugin, middleware } = await boot([DELEGATE_SET], delegateTables()); + const patch = { position: 'sales_rep', business_unit_id: 'bu_es' }; + + const admitted = await middlewareAdmits(middleware, 'sys_user_position', 'update', DELEGATE_CTX, patch, 'a_prev'); + const answered = await plugin.canWriteObject('sys_user_position', 'update', DELEGATE_CTX, [patch]); + + expect(admitted).toBe(true); + expect(answered).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 76cdd45c1e2..f6c0db8e127 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5200,12 +5200,13 @@ export class SecurityPlugin implements Plugin { * capability AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD * grant, the D10 delegator's independent grant, and the step 2.5 FLS write * gate over the keys THIS payload names. Each of those is pinned EQUAL to the - * registered middleware's, arm for arm, with ONE exception disclosed here - * because it is not pinned: a preview names no stored row, so on an UPDATE - * arm 3 is handed no id and refuses a scope-holding delegate it cannot - * boundary-check, where the middleware — holding that id — can admit. - * NARROWER, never wider. It means nothing about any refusal not in that - * list. + * registered middleware's, arm for arm, with ONE exception, pinned as a + * DIRECTION and not as an equivalence: a preview names no stored row, so on + * an UPDATE arm 3 is handed no id and refuses a scope-holding delegate it + * cannot boundary-check, where the middleware — holding that id — can admit. + * `can-write-object-admission.test.ts` holds that case in a block of its own, + * asserting this method `false` and the middleware `true`: NARROWER, never + * wider. It means nothing about any refusal not in that list. * * ⛔ `true` never means "this write will succeed", and ⛔ what follows is not * an enumeration of the distance to success: the middleware refuses both @@ -5284,7 +5285,8 @@ export class SecurityPlugin implements Plugin { // leaves the gate's delegate branch refusing an id-less mutation it // cannot boundary-check. That is NARROWER than the write path for a // scope-holding delegate, never wider, and narrower is the safe - // direction for a gate whose whole job is to withhold. + // direction for a gate whose whole job is to withhold — pinned as + // that direction in `can-write-object-admission.test.ts`. // // ⭐ And the rows it supplies are SHALLOW COPIES, never the caller's // own objects. The gate stamps `granted_by` onto the rows it @@ -5293,7 +5295,8 @@ export class SecurityPlugin implements Plugin { // method the caller's RAW payload, so passing it straight through // would let a PREVIEW write into the caller's own objects. The // decision cannot notice the copy: nothing reads `granted_by` back, - // it is only ever written. + // it is only ever written — so its pin reads the caller's rows + // instead (`can-write-object-admission.test.ts`, the copy block). if (this.delegatedAdminGate) { const shallow = (row: unknown) => row && typeof row === 'object' && !Array.isArray(row) From fd85fd5fb047989b299401ce0e7e76fbec06d1dc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 02:51:42 +0000 Subject: [PATCH 24/47] docs(security): state only what the admission suite pins, with no count The shipped texts said canWriteObject matches the registered middleware "arm for arm, with ONE exception", and two more said "case for case" / "cannot drift". The count is a completeness claim nothing pins, and the universals became false once the admission suite pinned an unequal case. Delete the count and the unpinned universals. What stays is what the tests pin, bounded by the file that pins them: can-write-object-admission.test.ts pins the answer equal to the middleware's on the cases it lists, and pins one D12 UPDATE case as a direction. Docs row 6b keeps its list of arms and drops the equality claim. The arm-3 body comment drops its incomplete list of id sources. Test comments lose three over-claims; test code is untouched. Comments and prose only: no executable change. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 17 ++++------ content/docs/permissions/system-context.mdx | 2 +- packages/objectql/src/engine.ts | 14 +++----- .../src/can-write-object-admission.test.ts | 14 +++----- .../plugin-security/src/security-plugin.ts | 32 ++++++------------- 5 files changed, 27 insertions(+), 52 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index 207e961cb4d..cfd6d001602 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -88,9 +88,7 @@ the field-level security WRITE gate over it (`getFieldPermissions`, folded through the D3 field-capability contract, intersected with the delegator's mask under D10, then the forbidden-write detection). It exists for doors that must ask "could this caller perform this write" without running the engine middleware — -the write preview is the first — and an equivalence suite pins its answer EQUAL -to the registered middleware's, case for case AND payload for payload, so the -two cannot drift. +the write preview is the first. ⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12 @@ -98,14 +96,11 @@ delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's independent grant, and the step 2.5 FLS write gate over the keys the payload -names — each pinned EQUAL to the registered middleware's, arm for arm, with ONE -exception, pinned as a DIRECTION and not as an equivalence: a preview names no -stored row, so on an UPDATE the D12 arm is handed no id and refuses a -scope-holding delegate it cannot boundary-check, where the middleware — holding -that id — can admit. `@objectstack/plugin-security`'s admission suite holds that -case in a block of its own, asserting the method `false` and the middleware -`true`: NARROWER, never wider. It says nothing about any refusal not in that -list. +names. It says nothing about any refusal not in that list. +`@objectstack/plugin-security`'s `can-write-object-admission.test.ts` pins the +method's answer equal to the registered middleware's on the cases it lists, and +pins one D12 UPDATE case as a direction: the method `false`, the middleware +`true`. ⛔ `true` never means the write will succeed, and ⛔ what follows is not an enumeration of the distance to success: the middleware refuses both before and diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 95e0e6bd83e..c4f579c5fa5 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -101,7 +101,7 @@ that silently does not happen. | 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` | | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | -| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. An equivalence suite pins the answer EQUAL to the registered middleware's, case for case: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | +| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 8070e0967ab..8569e99c7b0 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3977,15 +3977,11 @@ export class ObjectQL implements IObjectQLEngine { * D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both * principals, the `allowCreate`/`allowEdit` CRUD grant, the ADR-0090 D10 * delegator's independent grant, and the middleware's own step 2.5 FLS write - * gate over the keys THIS payload names. Each of those is pinned EQUAL to the - * registered middleware's, arm for arm, with ONE exception, pinned as a - * DIRECTION and not as an equivalence: a preview names no stored row, so on - * an UPDATE the D12 arm is handed no id and refuses a scope-holding delegate - * it cannot boundary-check, where the middleware — holding that id — can - * admit. `@objectstack/plugin-security`'s `can-write-object-admission.test.ts` - * holds that case in a block of its own, asserting the probe `false` and the - * middleware `true`: NARROWER, never wider. The gate says nothing about any - * refusal not in that list. + * gate over the keys THIS payload names. The gate says nothing about any + * refusal not in that list. `@objectstack/plugin-security`'s + * `can-write-object-admission.test.ts` pins the probe's answer equal to the + * registered middleware's on the cases it lists, and pins one D12 UPDATE case + * as a direction: the probe `false`, the middleware `true`. * * ⛔ A `true` here is NOT a promise that the write would succeed, and ⛔ no * enumeration of the distance to success is attempted — the middleware diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index d75e31ac013..b17831f6a23 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#18682] `canWriteObject` agrees with the engine middleware, case for case. + * [#18682] `canWriteObject` against the engine middleware. * * ## Why this file exists, and why it is an EQUIVALENCE * @@ -51,7 +51,7 @@ * carries a field the fixtures actually restrict — in both directions, and once * under D10 delegation where the two masks must intersect rather than union. * - * ## …and the two blocks that are NOT equivalences + * ## …and two blocks that are NOT equivalences * * Both need a caller the equivalence block has no fixture for: a DELEGATED * administrator (ADR-0090 D12) holding a real `adminScope` over a business-unit @@ -66,8 +66,7 @@ * stamp back, so no admission answer can notice the copy — only the * caller's row can, and that is what the block asserts. * - **The DIRECTION.** On an id-less UPDATE the probe refuses a delegate the - * middleware — holding the id — admits: the one place the two doors are - * pinned UNEQUAL, and only in the narrower direction. + * middleware — holding the id — admits. * * Harness mirrors `can-read-object-admission.test.ts`, whose read twin this is. */ @@ -672,8 +671,7 @@ describe("the D12 arm judges copies — a preview never stamps the caller's rows }); /** - * ⭐ DIRECTION, not equivalence — the ONE arm where this method is pinned - * NARROWER than the middleware, and only in that direction. + * ⭐ DIRECTION, not equivalence. * * A preview names no stored row, so on an UPDATE the probe hands the D12 gate * no id, and the gate's delegate branch refuses a mutation it cannot attribute @@ -686,9 +684,7 @@ describe("the D12 arm judges copies — a preview never stamps the caller's rows * `true` — ⛔ never as equality, and it is kept out of the equivalence block, * whose doors must agree by construction. Both doors get the shape they really * receive: the probe an ARRAY (`validate()` always sends `rawRows`), the - * middleware the engine's by-id `data` and id. If the two ever agree here, - * either the probe learned an id it has no way to hold, or the middleware lost - * the one it has — a change to be looked at, not absorbed. + * middleware the engine's by-id `data` and id. */ describe('DIRECTION — the one arm where the probe is narrower than the write path', () => { it("REFUSES a scope-holding delegate's id-less UPDATE that the middleware, holding the id, ADMITS (ADR-0090 D12)", async () => { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index f6c0db8e127..31d3140dd54 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5183,10 +5183,9 @@ export class SecurityPlugin implements Plugin { * carries. ⛔ Not a post-default image: a key the runtime filled is not a * field the caller wrote. * - * The equality with the registered middleware is pinned as an EQUIVALENCE - * (`can-write-object-admission.test.ts`) rather than asserted here, for the - * same reason `canReadObject`'s is: two doors that merely agree today drift - * the first time one of them grows an arm. + * `can-write-object-admission.test.ts` pins this method's answer equal to the + * registered middleware's on the cases it lists, and pins one arm-3 UPDATE + * case as a direction: this method `false`, the middleware `true`. * * Fails CLOSED: a throw anywhere denies, and callers must treat a throw as a * denial too. @@ -5199,14 +5198,8 @@ export class SecurityPlugin implements Plugin { * unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 * capability AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD * grant, the D10 delegator's independent grant, and the step 2.5 FLS write - * gate over the keys THIS payload names. Each of those is pinned EQUAL to the - * registered middleware's, arm for arm, with ONE exception, pinned as a - * DIRECTION and not as an equivalence: a preview names no stored row, so on - * an UPDATE arm 3 is handed no id and refuses a scope-holding delegate it - * cannot boundary-check, where the middleware — holding that id — can admit. - * `can-write-object-admission.test.ts` holds that case in a block of its own, - * asserting this method `false` and the middleware `true`: NARROWER, never - * wider. It means nothing about any refusal not in that list. + * gate over the keys THIS payload names. It means nothing about any refusal + * not in that list. * * ⛔ `true` never means "this write will succeed", and ⛔ what follows is not * an enumeration of the distance to success: the middleware refuses both @@ -5277,16 +5270,11 @@ export class SecurityPlugin implements Plugin { context, ); // 3. [ADR-0090 D12] Delegated administration on the RBAC link tables. - // The middleware hands its whole `opCtx`; `assert` reads exactly - // `object`, `operation`, `context` and the ROWS of `data` (plus, for - // a delegate's update/delete, a single scalar id off - // `options.where.id` / `where.id` / `id`). A preview names no stored - // row, so it supplies the four it has and NOTHING else — which - // leaves the gate's delegate branch refusing an id-less mutation it - // cannot boundary-check. That is NARROWER than the write path for a - // scope-holding delegate, never wider, and narrower is the safe - // direction for a gate whose whole job is to withhold — pinned as - // that direction in `can-write-object-admission.test.ts`. + // The middleware hands its whole `opCtx`; a preview names no stored + // row, so this hands `object`, `operation`, `context` and the rows + // of `data` and NOTHING else — leaving the gate's delegate branch + // refusing an id-less mutation it cannot boundary-check, pinned as a + // direction in `can-write-object-admission.test.ts`. // // ⭐ And the rows it supplies are SHALLOW COPIES, never the caller's // own objects. The gate stamps `granted_by` onto the rows it From 5472eeb7790ac8ed4a528d98720f5098d8164169 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 03:23:40 +0000 Subject: [PATCH 25/47] test(security): retitle the DIRECTION block without a count The block's title called it "the one arm where the probe is narrower than the write path": a count, and false as a statement about behaviour. The new title names the case the block pins: a delegate UPDATE the probe refuses and the middleware admits. Only the title literal changes; the block's assertions and every other test are untouched. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../plugin-security/src/can-write-object-admission.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index b17831f6a23..db9005b37fa 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -686,7 +686,7 @@ describe("the D12 arm judges copies — a preview never stamps the caller's rows * receive: the probe an ARRAY (`validate()` always sends `rawRows`), the * middleware the engine's by-id `data` and id. */ -describe('DIRECTION — the one arm where the probe is narrower than the write path', () => { +describe('DIRECTION — a delegate UPDATE the probe refuses and the middleware admits', () => { it("REFUSES a scope-holding delegate's id-less UPDATE that the middleware, holding the id, ADMITS (ADR-0090 D12)", async () => { const { plugin, middleware } = await boot([DELEGATE_SET], delegateTables()); const patch = { position: 'sales_rep', business_unit_id: 'bu_es' }; From 4918cb1732225b62220c23caa96096b32a8ec84c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 04:52:57 +0000 Subject: [PATCH 26/47] fix(objectql): resolve no relationship on the referential FK clear The cleanup UPDATE a set_null delete issues carries `__referentialFieldClear`, and plugin-security exempts exactly that write from its object-level CRUD check. update() then resolved the referencing object's traversing rule under system authority, so a deleter with no grant on B and no read on C learned one bit of C per delete: the rule refused the delete when C matched and let it through when it did not. resolvePredicateRelated now resolves nothing for a context carrying the marker. The rule meets the bare id, faults and refuses the cleanup, which is the merge-base outcome (measured at fb7b74691f: both the secret and the public case refused VALIDATION_FAILED, C read zero times). Pinned end to end on the real SecurityPlugin over a real ObjectQL, in delete-reference-cleanup-system-identity.test.ts: a secret C and a public C end the delete identically with C never read, with two controls (no rule: the delete succeeds; an ordinary update of B: C is read and the rule refuses with its own message). The harness double learns `$in`, without which the related read answers "not found" and the pin would hold for the double's reason. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 5 +- packages/objectql/src/engine.ts | 11 ++ ...-reference-cleanup-system-identity.test.ts | 134 +++++++++++++++++- 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index cfd6d001602..3c7c9808c1d 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -127,4 +127,7 @@ Object validation rules (`script` / `cross_field`) — and the system-authority read is confined to that one seam. The field-level `requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open** and are deliberately not covered here; RLS predicates are out too. Depth is one -hop. +hop. The cleanup UPDATE a `set_null` delete issues on a referencing record +resolves no relationship, so a rule there is evaluated as before this release — +against the bare id, where reading through it faults and refuses the cleanup, +and with it the delete. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 8569e99c7b0..2246d0de907 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7060,6 +7060,17 @@ export class ObjectQL implements IObjectQLEngine { context: unknown, ): Promise<((row: Record | undefined | null) => RelatedRecordBinding | undefined)> { const unbound = () => undefined; + // ⛔ A referential FK clear resolves NOTHING. `cascadeDeleteRelations` + // stamps `__referentialFieldClear` on its cleanup UPDATE, and plugin-security + // exempts exactly that write from its object-level CRUD check — so the + // cleanup reaches this seam for a deleter holding no grant on the + // referencing object, and a system read here would let a traversing rule's + // verdict decide their delete: one bit of a related record they cannot + // read, per delete. Left unbound, such a rule faults and refuses the cleanup + // as it did before this seam existed (`resolveTraversalScope` leaves an + // unbound record to CEL). Pinned end to end in plugin-security's + // `delete-reference-cleanup-system-identity.test.ts`. + if (this.buildReferentialFieldClear(context as ExecutionContext | undefined)) return unbound; const wanted = collectPredicateRelationships(schema); if (wanted.size === 0) return unbound; diff --git a/packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts b/packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts index 4eab6c0c298..82ab6a33487 100644 --- a/packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts +++ b/packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts @@ -253,6 +253,11 @@ function makeStubDriver() { return values.some((v) => v != null && typeof v !== 'object' && String(v) === String(cmp)); } if (op === '$eq') return (stored ?? null) === ((cmp as any) ?? null); + // [#18682] The by-id batch a validation rule's related read issues. A + // double that ignored it would answer "no such record", and a traversing + // rule would refuse for the double's reason — the #18682 pins below + // would hold while pinning nothing. + if (op === '$in') return Array.isArray(cmp) && cmp.some((c) => (stored ?? null) === (c ?? null)); return false; } return (stored ?? null) === ((spec as any) ?? null); @@ -307,7 +312,7 @@ function makeStubDriver() { interface LogRecord { msg: string; meta?: Record } -async function boot(sets: PermissionSet[] = [LINE_LEAD]) { +async function boot(sets: PermissionSet[] = [LINE_LEAD], extraObjects: Array<{ name: string }> = []) { const info: LogRecord[] = []; const engineLogger = { info: vi.fn((msg: string, meta?: Record) => { info.push({ msg, meta }); }), @@ -317,10 +322,11 @@ async function boot(sets: PermissionSet[] = [LINE_LEAD]) { const { driver, stores } = makeStubDriver(); engine.registerDriver(driver, true); await engine.init(); - for (const o of [PRODUCT, ANDON, BATCH]) engine.registry.registerObject(o as any, 'test'); + for (const o of [PRODUCT, ANDON, BATCH, ...extraObjects]) engine.registry.registerObject(o as any, 'test'); const schemas: Record = { os_ehr_product: PRODUCT, os_ehr_andon_record: ANDON, os_ehr_batch: BATCH, + ...Object.fromEntries(extraObjects.map((o) => [o.name, o])), }; const services: Record = { manifest: { register: vi.fn() }, @@ -689,3 +695,127 @@ describe('#12597 — the referential FK clear is exempt from the object-level CR expect(h.stores.get('os_ehr_andon_record')?.get(a.id)?.product).toBe(p.id); }); }); + +// --------------------------------------------------------------------------- +// #18682 — the referential FK clear resolves no relationship a rule reads. +// +// The cleanup UPDATE is exempt from the object-level CRUD check (the describe +// above), so a deleter holding NO grant on the referencing object reaches its +// validation rules. A rule there that reads one hop through a lookup, handed +// the related row under system authority, would decide the delete by a value +// of a THIRD record the deleter cannot read — one bit of it per delete. So the +// cleanup resolves nothing, and the rule meets the bare id, as it did before +// relationship traversal existed. +// --------------------------------------------------------------------------- + +/** C — a record the deleter holds no grant on, not even read. */ +const LINE = { + name: 'os_ehr_line', + label: 'Line', + sharingModel: 'private', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + organization_id: { name: 'organization_id', type: 'text' as const }, + owner_id: { name: 'owner_id', type: 'text' as const }, + kind: { name: 'kind', type: 'text' as const }, + }, +}; + +const SECRET_LINE_MESSAGE = 'Inspections on a secret line are frozen.'; + +/** + * B — references A through an OPTIONAL lookup (so the delete clears it) and C + * through `line`, with a rule that reads C through that lookup. + */ +const INSPECTION = { + name: 'os_ehr_inspection', + label: 'Inspection', + sharingModel: 'private', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + organization_id: { name: 'organization_id', type: 'text' as const }, + owner_id: { name: 'owner_id', type: 'text' as const }, + product: { name: 'product', type: 'lookup' as const, reference: 'os_ehr_product' }, + line: { name: 'line', type: 'lookup' as const, reference: 'os_ehr_line' }, + }, + validations: [{ + name: 'no_secret_line', type: 'script', severity: 'error', + message: SECRET_LINE_MESSAGE, + condition: "record.line.kind == 'secret'", + }], +}; + +describe('#18682 — the referential FK clear resolves no relationship, so a rule cannot turn a delete into an oracle', () => { + /** Boot with B referencing a fresh A and a C of the given `kind`. */ + async function withLine(kind: 'secret' | 'public', inspection: typeof INSPECTION = INSPECTION) { + const h = await boot([LINE_LEAD], [LINE, inspection]); + const p = await h.seed('os_ehr_product', { name: 'Widget' }); + const line = await h.seed('os_ehr_line', { kind }); + // Straight into the store: a seed through the engine is judged by the very + // rule under test, and the fixture is not the subject. + h.stores.set('os_ehr_inspection', new Map([ + ['insp_1', { id: 'insp_1', product: p.id, line: line.id, owner_id: 'u_other' }], + ])); + const readsOfLine = vi.spyOn(h.engine, 'find'); + return { + h, + productId: p.id as string, + linesRead: () => readsOfLine.mock.calls.filter(([object]) => object === 'os_ehr_line').length, + }; + } + + async function deleteProductAsLead(kind: 'secret' | 'public', inspection?: typeof INSPECTION) { + const { h, productId, linesRead } = await withLine(kind, inspection); + const err = await h.deleteAs('os_ehr_product', productId, h.caller()); + return { + linesRead: linesRead(), + outcome: { + code: err?.code ?? null, + message: err?.message ?? null, + productStored: h.stores.get('os_ehr_product')?.has(productId) ?? false, + fkKept: h.stores.get('os_ehr_inspection')?.get('insp_1')?.product === productId, + }, + }; + } + + it('the deleter cannot read C — the premise every case below rests on', async () => { + const { h } = await withLine('secret'); + const err = await h.engine + .find('os_ehr_line', { context: h.caller() } as any) + .then(() => null, (e: any) => e); + expect(err?.code).toBe('PERMISSION_DENIED'); + }); + + it('THE CONTRACT: a secret C and a public C end the delete identically — refused, C never read', async () => { + const secret = await deleteProductAsLead('secret'); + const open = await deleteProductAsLead('public'); + + expect(open.outcome).toEqual(secret.outcome); + // The outcome the rule has always produced here: it meets the bare id, + // faults, and refuses the cleanup — so the delete does not land. + expect(secret.outcome.code).toBe('VALIDATION_FAILED'); + expect(secret.outcome.message).not.toContain(SECRET_LINE_MESSAGE); + expect(secret.outcome.productStored).toBe(true); + expect(secret.outcome.fkKept).toBe(true); + // ⛔ And nothing was read from C on the deleter's behalf. + expect(secret.linesRead).toBe(0); + expect(open.linesRead).toBe(0); + }); + + it('CONTROL: with no rule on B the same delete succeeds and clears the FK', async () => { + const unruled = await deleteProductAsLead('secret', { ...INSPECTION, validations: [] }); + expect(unruled.outcome.code).toBe(null); + expect(unruled.outcome.productStored).toBe(false); + expect(unruled.outcome.fkKept).toBe(false); + }); + + it('CONTROL: an ordinary update of B DOES read C and refuses with the rule — the harness can answer the read', async () => { + const { h, linesRead } = await withLine('secret'); + const err = await h.engine + .update('os_ehr_inspection', { id: 'insp_1', owner_id: 'u_next' }, { context: { isSystem: true } } as any) + .then(() => null, (e: any) => e); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect(err?.message).toContain(SECRET_LINE_MESSAGE); + expect(linesRead()).toBe(1); + }); +}); From 6f2c060bd9b7ff68fff202ec992333eae67d1834 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 04:53:34 +0000 Subject: [PATCH 27/47] feat(security): canWriteObject asks the ADR-0123 D2 organization wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authenticated session with no active organization was admitted by canWriteObject while the middleware's step 3.7 refused its write on an organization-scoped object, so validate() issued the elevated related read for a caller the write path refuses. The step-3.7 verdict moves into one private method, organizationWallRefusal, which the middleware throws on exactly as before and canWriteObject now asks as arm 10: under the step's own guard (a payload, a context naming a user), after the field gate, and also on arm 4's path, because the middleware runs the wall with no permission set resolved. The wall is handed no row. Pins: the equivalence block gains the org-less caller in both modes, the org-less caller who resolves no set, and the org-bound control; the arms block pins the direction; the parity file drives the composed runtime under the isolated posture — insert() refuses and validate() reads nothing related, while the org-bound twin reads once on each door. Every text that enumerates the arms names the new one: the canWriteObject and _writeGateProbe docblocks, the changeset and system-context.mdx row 6b; counts of arms are dropped from the prose, and "the cases it lists" now names the equivalence block. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18682-predicate-relationship-traversal.md | 16 +- content/docs/permissions/system-context.mdx | 2 +- packages/objectql/src/engine.ts | 13 +- .../src/can-write-object-admission.test.ts | 45 ++++- .../plugin-security/src/security-plugin.ts | 166 +++++++++++------- .../write-preview-field-gate-parity.test.ts | 111 +++++++++++- 6 files changed, 271 insertions(+), 82 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index 3c7c9808c1d..e739b1ed054 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -86,7 +86,9 @@ then no resolved permission sets, unresolvable posture, the ADR-0066 D3 the ADR-0090 D10 delegator check, and — when the caller's payload is supplied — the field-level security WRITE gate over it (`getFieldPermissions`, folded through the D3 field-capability contract, intersected with the delegator's mask -under D10, then the forbidden-write detection). It exists for doors that must ask +under D10, then the forbidden-write detection); and last, the ADR-0123 D2 +no-active-organization wall, the same verdict the middleware's step 3.7 throws +on. It exists for doors that must ask "could this caller perform this write" without running the engine middleware — the write preview is the first. @@ -95,12 +97,12 @@ write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12 delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's -independent grant, and the step 2.5 FLS write gate over the keys the payload -names. It says nothing about any refusal not in that list. -`@objectstack/plugin-security`'s `can-write-object-admission.test.ts` pins the -method's answer equal to the registered middleware's on the cases it lists, and -pins one D12 UPDATE case as a direction: the method `false`, the middleware -`true`. +independent grant, the step 2.5 FLS write gate over the keys the payload +names, and the ADR-0123 D2 organization wall. It says nothing about any refusal +not in that list. `@objectstack/plugin-security`'s +`can-write-object-admission.test.ts` pins the method's answer equal to the +registered middleware's on its equivalence block's cases, and pins one D12 +UPDATE case as a direction: the method `false`, the middleware `true`. ⛔ `true` never means the write will succeed, and ⛔ what follows is not an enumeration of the distance to success: the middleware refuses both before and diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c4f579c5fa5..3258f1ce7dd 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -101,7 +101,7 @@ that silently does not happen. | 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` | | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | -| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, and — when the caller's payload is supplied — the field-level-security write gate over it | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | +| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2246d0de907..5f6ce23a998 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3976,12 +3976,13 @@ export class ObjectQL implements IObjectQLEngine { * primitives), the fail-closed postures (#3545's unresolvable posture and the * D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both * principals, the `allowCreate`/`allowEdit` CRUD grant, the ADR-0090 D10 - * delegator's independent grant, and the middleware's own step 2.5 FLS write - * gate over the keys THIS payload names. The gate says nothing about any - * refusal not in that list. `@objectstack/plugin-security`'s - * `can-write-object-admission.test.ts` pins the probe's answer equal to the - * registered middleware's on the cases it lists, and pins one D12 UPDATE case - * as a direction: the probe `false`, the middleware `true`. + * delegator's independent grant, the middleware's own step 2.5 FLS write gate + * over the keys THIS payload names, and its ADR-0123 D2 organization wall + * (step 3.7). The gate says nothing about any refusal not in that list. + * `@objectstack/plugin-security`'s `can-write-object-admission.test.ts` pins + * the probe's answer equal to the registered middleware's on its equivalence + * block's cases, and pins one D12 UPDATE case as a direction: the probe + * `false`, the middleware `true`. * * ⛔ A `true` here is NOT a promise that the write would succeed, and ⛔ no * enumeration of the distance to success is attempted — the middleware diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index db9005b37fa..31e5582bebe 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -220,6 +220,12 @@ const TENANT_ADMIN_CTX = { userId: 'u_admin', tenantId: 'org-1', positions: [], permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN', }; +/** + * ⭐ An authenticated session with NO active organization (ADR-0123 D2): the + * writer's context minus its `tenantId`. Under a walled posture the write path + * refuses it before `next()`. + */ +const ORGLESS_CTX = { userId: 'u_writer', positions: [], permissions: [], posture: 'MEMBER' }; /** A principal-less context — no positions, no sets, no `userId`. */ const PRINCIPAL_LESS_CTX = { positions: [], permissions: [] }; /** The system bypass, spelled the way every door spells it. */ @@ -297,9 +303,12 @@ function rowMatches(row: Record, where: Record }); } -async function boot(sets: PermissionSet[], tables?: Tables) { +async function boot(sets: PermissionSet[], tables?: Tables, opts: { orgScoping?: boolean } = {}) { const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; const services: Record = { + // The `isolated` posture, resolved the way the plugin falls back to it when + // no `tenancy` service is wired: only an ADR-0123 D2 case asks for it. + ...(opts.orgScoping ? { 'org-scoping': { name: 'org-scoping' } } : {}), manifest: { register: vi.fn() }, objectql: { registerMiddleware: (mw: any) => middlewares.push(mw), @@ -388,6 +397,8 @@ describe('canWriteObject agrees with the engine middleware, case for case', () = context: Record; /** The caller's payload, reaching BOTH doors. Defaults to `PLAIN_PAYLOAD`. */ data?: unknown; + /** Boot under the `isolated` posture, so the ADR-0123 D2 wall is armed. */ + orgScoping?: true; }> = [ { label: 'no grant of any kind on the object', object: 'ledger', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, { label: 'an explicit create grant', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, @@ -442,11 +453,18 @@ describe('canWriteObject agrees with the engine middleware, case for case', () = { label: 'an RBAC link table for a tenant-level admin', object: 'sys_user_position', operation: 'insert', sets: [ADMIN_SET], context: TENANT_ADMIN_CTX, data: RBAC_PAYLOAD }, { label: 'an RBAC link table for a SYSTEM context', object: 'sys_user_position', operation: 'insert', sets: [RBAC_CRUD_SET], context: SYSTEM_CTX, data: RBAC_PAYLOAD }, { label: 'an RBAC link table for a principal-less context', object: 'sys_user_position', operation: 'insert', sets: [RBAC_CRUD_SET], context: PRINCIPAL_LESS_CTX, data: RBAC_PAYLOAD }, + // ⭐ ADR-0123 D2 — the no-active-organization wall, under the `isolated` + // posture. The write grant is held; only the missing organization differs + // from the control twin below, which the wall admits. + { label: 'an authenticated caller with no active organization (ADR-0123 D2)', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: ORGLESS_CTX, orgScoping: true }, + { label: 'the same org-less caller in UPDATE mode', object: 'invoice', operation: 'update', sets: [WRITER_SET], context: ORGLESS_CTX, orgScoping: true }, + { label: 'an org-less caller who resolves no permission set at all', object: 'invoice', operation: 'insert', sets: [], context: ORGLESS_CTX, orgScoping: true }, + { label: 'the same caller WITH an active organization, under the same posture', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX, orgScoping: true }, ]; for (const c of CASES) { it(`agrees on ${c.label}`, async () => { - const { plugin, middleware } = await boot(c.sets); + const { plugin, middleware } = await boot(c.sets, undefined, { orgScoping: c.orgScoping }); const data = 'data' in c ? c.data : PLAIN_PAYLOAD; const admitted = await middlewareAdmits(middleware, c.object, c.operation, c.context, data); const answered = await plugin.canWriteObject(c.object, c.operation, c.context, data); @@ -615,6 +633,29 @@ describe('the arms the CRUD grant alone does not cover', () => { const { plugin } = await boot([WRITER_SET]); await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); }); + + // ── arm 10: the ADR-0123 D2 no-active-organization wall (step 3.7) ───────── + + it('DENIES an authenticated caller with no active organization under a walled posture (ADR-0123 D2)', async () => { + const { plugin } = await boot([WRITER_SET], undefined, { orgScoping: true }); + await expect(plugin.canWriteObject('invoice', 'insert', ORGLESS_CTX, PLAIN_PAYLOAD)).resolves.toBe(false); + await expect(plugin.canWriteObject('invoice', 'update', ORGLESS_CTX, PLAIN_PAYLOAD)).resolves.toBe(false); + }); + + it('DENIES it when no permission set resolves, too — the wall is not behind the CRUD guard', async () => { + const { plugin } = await boot([], undefined, { orgScoping: true }); + await expect(plugin.canWriteObject('invoice', 'insert', ORGLESS_CTX, PLAIN_PAYLOAD)).resolves.toBe(false); + }); + + it('ADMITS the same caller with an active organization — so the arm is not a blanket deny', async () => { + const { plugin } = await boot([WRITER_SET], undefined, { orgScoping: true }); + await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); + }); + + it('ADMITS the org-less caller under the `single` posture — the wall arms only where the posture walls', async () => { + const { plugin } = await boot([WRITER_SET]); + await expect(plugin.canWriteObject('invoice', 'insert', ORGLESS_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); + }); }); /** diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 31d3140dd54..f22b6c8e69f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -3160,7 +3160,8 @@ export class SecurityPlugin implements Plugin { // // The cheap posture/context pre-test comes first on purpose: the common // deployment is `single` (Layer 0 inert), and the common caller HAS an - // active organization. Neither pays for the layered compile below. + // active organization. Neither pays for the layered compile inside + // `organizationWallRefusal` — the one verdict `canWriteObject` asks too. // // Ordered AHEAD of the forge guard deliberately. A caller with no // organization scope at all who also supplies a foreign `organization_id` @@ -3170,49 +3171,32 @@ export class SecurityPlugin implements Plugin { // tenant, so its target is selected through the Layer 0 ROW wall, which // already resolves to nothing (ADR-0123 D2, stated there as a boundary // rather than left as an omission). - if (this.orgScopingEnabled && !callerHasOrganizationScope(opCtx.context, this.tenancyPosture)) { - const callerWall = await this.computeWriteTenantCheckFilter( - permissionSets, - opCtx.object, - opCtx.operation, - opCtx.context, + const denied = await this.organizationWallRefusal( + permissionSets, + opCtx.object, + opCtx.operation, + opCtx.context, + delegatorSets, + delegatorContext, + ); + if (denied) { + const principal = denied === 'delegator' ? 'the delegating principal' : 'this session'; + this.logger.warn?.( + `[Security] Layer 0 tenant wall REFUSED ${opCtx.operation} '${opCtx.object}' — ` + + `${principal} has no active organization to place the record in (ADR-0123 D2, fail-closed)`, + ); + throw new PermissionDeniedError( + `[Security] Access denied: '${opCtx.object}' is scoped to an organization, and ` + + `${principal} has no active organization — so this ${opCtx.operation} has no ` + + `organization to place the record in. Join or select an active organization and retry.`, + { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, + `[ADR-0123 D2] Tenant-scoped writes are refused when the execution context carries no active ` + + `organization: tenancy posture '${this.tenancyPosture}' walls '${opCtx.object}', and ` + + `${denied === 'delegator' ? "the delegator's" : "the caller's"} context resolved neither ` + + `\`tenantId\` nor a non-empty \`accessible_org_ids\`. Reads under this state resolve to nothing; ` + + `writes are refused rather than landing a row with a NULL organization that no reader — ` + + `including its own author — could ever see. System contexts and platform operators are unaffected.`, ); - // [ADR-0090 D10] The delegator is walled on its own context, exactly as - // the forge guard walls it — an on-behalf-of write may not land a row - // the delegator itself could not place. - const delegatorWall = - delegatorSets && !callerHasOrganizationScope(delegatorContext, this.tenancyPosture) - ? await this.computeWriteTenantCheckFilter( - delegatorSets, - opCtx.object, - opCtx.operation, - delegatorContext, - ) - : null; - const denied = isTenantWallDenial(callerWall) - ? 'caller' - : isTenantWallDenial(delegatorWall) - ? 'delegator' - : null; - if (denied) { - const principal = denied === 'delegator' ? 'the delegating principal' : 'this session'; - this.logger.warn?.( - `[Security] Layer 0 tenant wall REFUSED ${opCtx.operation} '${opCtx.object}' — ` + - `${principal} has no active organization to place the record in (ADR-0123 D2, fail-closed)`, - ); - throw new PermissionDeniedError( - `[Security] Access denied: '${opCtx.object}' is scoped to an organization, and ` + - `${principal} has no active organization — so this ${opCtx.operation} has no ` + - `organization to place the record in. Join or select an active organization and retry.`, - { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, - `[ADR-0123 D2] Tenant-scoped writes are refused when the execution context carries no active ` + - `organization: tenancy posture '${this.tenancyPosture}' walls '${opCtx.object}', and ` + - `${denied === 'delegator' ? "the delegator's" : "the caller's"} context resolved neither ` + - `\`tenantId\` nor a non-empty \`accessible_org_ids\`. Reads under this state resolve to nothing; ` + - `writes are refused rather than landing a row with a NULL organization that no reader — ` + - `including its own author — could ever see. System contexts and platform operators are unaffected.`, - ); - } } const suppliedRows = writeRows.filter( @@ -5107,8 +5091,8 @@ export class SecurityPlugin implements Plugin { } /** - * [#18682] Whether `context` may CREATE or UPDATE `object` under the nine - * arms enumerated below — the WRITE admission the preview asks for, and the + * [#18682] Whether `context` may CREATE or UPDATE `object` under the arms + * enumerated below — the WRITE admission the preview asks for, and the * exact sibling of {@link canReadObject}. * * ## Why it exists @@ -5142,11 +5126,11 @@ export class SecurityPlugin implements Plugin { * `append-only` object whose `userActions` do not open the verb DENIES, * ahead of the fall-open below and of every resolution; * 3. ADR-0090 D12 `delegatedAdminGate.assert` — the same gate object the - * middleware calls, handed the members its `assert` reads (`object`, - * `operation`, `context`, the rows of `data`). A plain-CRUD holder on an - * RBAC link table DENIES; a tenant admin passes to the arms below; - * 4. no permission sets resolved → admit (the middleware guards its whole - * CRUD gate with `if (permissionSets.length > 0)`); + * middleware calls, handed `object`, `operation`, `context` and the rows + * of `data`. A plain-CRUD holder on an RBAC link table DENIES; a tenant + * admin passes to the arms below; + * 4. no permission sets resolved → arm 10 decides (the middleware guards its + * whole CRUD gate with `if (permissionSets.length > 0)`, not that wall); * 5. `secMeta.unresolved` → DENY (#3545); * 6. ADR-0066 D3/⑤ `requiredPermissions` capability AND-gate for the WRITE * CRUD class, checked BEFORE the grant, for the caller AND (D10) the @@ -5161,9 +5145,9 @@ export class SecurityPlugin implements Plugin { * object at all, for that verb, under any payload. Arms 6 and 8 were added on * that same ground one gate later. * - * ## …and the ninth, which needs the PAYLOAD + * ## …and the arm that needs the PAYLOAD * - * The eight above are not the whole write decision either. The middleware + * The arms above are not the whole write decision either. The middleware * also refuses payload-dependent writes, and the first of them is the * field-level-security write gate (step 2.5): a caller holding the object's * CRUD grant but not `editable` on a field the payload names is refused @@ -5183,9 +5167,18 @@ export class SecurityPlugin implements Plugin { * carries. ⛔ Not a post-default image: a key the runtime filled is not a * field the caller wrote. * + * ## …and the organization wall + * + * 10. ADR-0123 D2 `organizationWallRefusal` — the method the middleware's + * step 3.7 throws on, asked under that step's guard (a payload is + * supplied, the context names a user) at that step's point, after arm + * 9; arm 4 asks it too, as the middleware does with no set resolved. It + * is handed no row, so no payload changes its answer: it refuses a + * CALLER CLASS, as arms 2 and 3 do. + * * `can-write-object-admission.test.ts` pins this method's answer equal to the - * registered middleware's on the cases it lists, and pins one arm-3 UPDATE - * case as a direction: this method `false`, the middleware `true`. + * registered middleware's on its equivalence block's cases, and pins one + * arm-3 UPDATE case as a direction: this method `false`, the middleware `true`. * * Fails CLOSED: a throw anywhere denies, and callers must treat a throw as a * denial too. @@ -5197,9 +5190,9 @@ export class SecurityPlugin implements Plugin { * middleware's own point in its order), the fail-closed postures (#3545's * unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 * capability AND-gate for both principals, the `allowCreate`/`allowEdit` CRUD - * grant, the D10 delegator's independent grant, and the step 2.5 FLS write - * gate over the keys THIS payload names. It means nothing about any refusal - * not in that list. + * grant, the D10 delegator's independent grant, the step 2.5 FLS write gate + * over the keys THIS payload names, and the ADR-0123 D2 organization wall. It + * means nothing about any refusal not in that list. * * ⛔ `true` never means "this write will succeed", and ⛔ what follows is not * an enumeration of the distance to success: the middleware refuses both @@ -5307,21 +5300,39 @@ export class SecurityPlugin implements Plugin { } const permissionSets = await this.resolvePermissionSetsForContext(context); - // 4. No sets resolved → no permission-set restriction applies. - if (permissionSets.length === 0) return true; + // 10. [ADR-0123 D2] The no-active-organization write wall — the + // middleware's own verdict, `organizationWallRefusal`, under its step + // 3.7 guard: a payload is supplied and the context names a user. Its + // point is last, after the field gate; it is spelled here because + // arm 4 returns through it — the middleware asks it with no set + // resolved as well. + const organizationWallAdmits = async ( + delegatorSets: PermissionSet[] | null, + delegatorContext: unknown, + ): Promise => + !(data && typeof data === 'object' && context?.userId + && (await this.organizationWallRefusal( + permissionSets, objectName, operation, context, delegatorSets, delegatorContext, + ))); + // 4. No sets resolved → no permission-set restriction applies (the + // middleware guards its whole CRUD gate with `if (permissionSets.length > 0)`), + // and arm 10 is not behind that guard. + if (permissionSets.length === 0) return await organizationWallAdmits(null, null); const { isPrivate, unresolved, requiredPermissions, fieldRequiredPermissions } = await this.getObjectSecurityMeta(objectName); // 5. [#3545] Posture unresolvable → deny. if (unresolved) return false; - // [ADR-0090 D10] Resolve the delegator ONCE — arms 6 and 8 both need it, - // and a dangling link denies before either runs. + // [ADR-0090 D10] Resolve the delegator ONCE — the arms below need it, + // and a dangling link denies before any of them runs. let delegatorSets: PermissionSet[] | null = null; + let delegatorContext: unknown = null; if (context?.onBehalfOf?.userId) { const del = await resolveDelegatorContext(this.ql, context); if (del.kind === 'missing') return false; if (del.kind === 'resolved') { + delegatorContext = del.context; delegatorSets = await this.resolvePermissionSetsForContext(del.context); } } @@ -5379,7 +5390,8 @@ export class SecurityPlugin implements Plugin { } } - return true; + // 10. [ADR-0123 D2] At the middleware's point — see above. + return await organizationWallAdmits(delegatorSets, delegatorContext); } catch (e) { this.logger.error?.( `[security] canWriteObject could not resolve the write admission for ` + @@ -6935,6 +6947,36 @@ export class SecurityPlugin implements Plugin { return layer0; } + /** + * [ADR-0123 D2] Which principal the no-active-organization write wall + * refuses, or `null` — the verdict the middleware's step 3.7 throws on. + * {@link canWriteObject} asks this same method, so the two doors share one + * spelling of the wall. It is handed no row, stored or written. + */ + private async organizationWallRefusal( + permissionSets: PermissionSet[], + object: string, + operation: string, + context: any, + delegatorSets: PermissionSet[] | null, + delegatorContext: any, + ): Promise<'caller' | 'delegator' | null> { + if (!this.orgScopingEnabled || callerHasOrganizationScope(context, this.tenancyPosture)) return null; + const callerWall = await this.computeWriteTenantCheckFilter(permissionSets, object, operation, context); + // [ADR-0090 D10] The delegator is walled on its own context, exactly as + // the forge guard walls it — an on-behalf-of write may not land a row + // the delegator itself could not place. + const delegatorWall = + delegatorSets && !callerHasOrganizationScope(delegatorContext, this.tenancyPosture) + ? await this.computeWriteTenantCheckFilter(delegatorSets, object, operation, delegatorContext) + : null; + return isTenantWallDenial(callerWall) + ? 'caller' + : isTenantWallDenial(delegatorWall) + ? 'delegator' + : null; + } + /** * Resolve a controlled_by_parent object's master-detail relation (the FK field * key + the master object name), or null. Prefers a required `master_detail` diff --git a/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts index 5d678d29ce7..d2a030d9c19 100644 --- a/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts +++ b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts @@ -113,6 +113,25 @@ const ENG_LOG = { }], }; +/** + * ⭐ ADR-0123 D2 — an ORGANIZATION-SCOPED object (it declares `organization_id`) + * carrying the same traversing rule, for the no-active-organization wall. + */ +const ORG_TASK = { + name: 'crm_task', + fields: { + organization_id: { type: 'text' }, + name: { type: 'text' }, + amount: { type: 'number' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + validations: [{ + name: 'partner_cap', type: 'script', severity: 'error', + message: RULE_MESSAGE, + condition: "record.account.type == 'partner' && record.amount > 10000", + }], +}; + /** …and the control: the SAME bucket with `userActions` reopening create. */ const ENG_LOG_AMENDABLE = { ...ENG_LOG, name: 'eng_log_amendable', userActions: { create: true, edit: true } }; @@ -171,6 +190,14 @@ const FULL_CRUD: PermissionSet = { }, } as unknown as PermissionSet; +/** Full write on the organization-scoped task, lookup column included. */ +const TASK_EDITOR: PermissionSet = { + name: 'member_default', + label: 'Task editor', + objects: { crm_task: { allowRead: true, allowCreate: true, allowEdit: true } }, + fields: { 'crm_task.account': { readable: true, editable: true } }, +} as unknown as PermissionSet; + /** …and the tenant-level admin ADR-0090 D12 exists to let through. */ const TENANT_ADMIN: PermissionSet = { name: 'tenant_admin', @@ -182,6 +209,8 @@ const CALLER = { userId: 'u_editor', tenantId: 'org-1', positions: [], permissio /** The admin caller NAMES its set — the harness resolves only what is asked for. */ const ADMIN_CALLER = { userId: 'u_admin', tenantId: 'org-1', positions: [], permissions: ['tenant_admin'], posture: 'PLATFORM_ADMIN' }; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; +/** ⭐ ADR-0123 D2 — `CALLER` minus its `tenantId`: authenticated, no active organization. */ +const ORGLESS_CALLER = { userId: 'u_editor', positions: [], permissions: [], posture: 'MEMBER' }; /** The payload under test: it names the restricted column, and it trips the rule. */ const PARTNER_PAYLOAD = { name: 'A', amount: 50000, account: 'acc_p' }; @@ -275,7 +304,7 @@ const attempt = async (run: () => Promise): Promise => { } }; -async function boot(sets: PermissionSet[]) { +async function boot(sets: PermissionSet[], opts: { orgScoping?: boolean } = {}) { const engine = new ObjectQL(); const d = makeDriver(); engine.registerDriver(d.driver, true); @@ -288,10 +317,14 @@ async function boot(sets: PermissionSet[]) { engine.registry.registerObject(ENG_LOG as any, 'test-package'); engine.registry.registerObject(ENG_LOG_AMENDABLE as any, 'test-package'); engine.registry.registerObject(USER_POSITION as any, 'test-package'); + engine.registry.registerObject(ORG_TASK as any, 'test-package'); d.storeFor('crm_account').set('acc_p', { id: 'acc_p', name: 'P', type: 'partner' }); d.storeFor('crm_account').set('acc_d', { id: 'acc_d', name: 'D', type: 'direct' }); const services: Record = { + // The `isolated` posture, as the plugin resolves it with no `tenancy` + // service wired: only the ADR-0123 D2 block asks for it. + ...(opts.orgScoping ? { 'org-scoping': { name: 'org-scoping' } } : {}), manifest: { register: vi.fn() }, objectql: engine, metadata: { @@ -311,18 +344,21 @@ async function boot(sets: PermissionSet[]) { await plugin.init(ctx as any); await plugin.start(ctx as any); + // A system write under a walled posture must name its organization, so the + // seeds carry one there; everywhere else they are exactly `SYS_CTX`. + const seedCtx = opts.orgScoping ? { ...SYS_CTX, tenantId: 'org-1' } : SYS_CTX; // A row the caller may edit, seeded past every gate. await engine.insert( 'crm_opportunity', { id: 'opp_1', name: 'seed', amount: 1, account: 'acc_d' }, - { context: SYS_CTX } as any, + { context: seedCtx } as any, ); // …and the update targets for the two pre-resolution cases, seeded the same way. - await engine.insert('eng_log', { id: 'log_1', name: 'seed', amount: 1, account: 'acc_d' }, { context: SYS_CTX } as any); + await engine.insert('eng_log', { id: 'log_1', name: 'seed', amount: 1, account: 'acc_d' }, { context: seedCtx } as any); await engine.insert( 'sys_user_position', { id: 'pos_1', user: 'u_target', position: 'sales', amount: 1, account: 'acc_d' }, - { context: SYS_CTX } as any, + { context: seedCtx } as any, ); d.calls.length = 0; return { @@ -574,3 +610,70 @@ describe('#18682 — the preview answers nobody the two pre-resolution gates ref }); }); }); + +/** + * ⭐ [#18682] The ADR-0123 D2 no-active-organization wall, on the same composed + * runtime under the `isolated` posture. The write path refuses an authenticated + * session with no active organization before `next()`, on a verdict handed no + * row, so a preview that answered it would hand the rule's verdict to a caller + * the write path refuses. The control is the identical caller WITH an active + * organization, which both doors admit to the rule. + */ +describe('#18682 — the preview answers nobody the organization wall refuses', () => { + describe('ADR-0123 D2 — an authenticated session with no active organization', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot([TASK_EDITOR], { orgScoping: true }); }); + + it('insert() refuses on the PERMISSION_DENIED envelope, having read nothing related', async () => { + const outcome = await attempt( + () => h.engine.insert('crm_task', { ...PARTNER_PAYLOAD }, { context: ORGLESS_CALLER } as any), + ); + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toMatch(/no active organization/); + expect(h.relatedReads()).toBe(0); + }); + + it('validate() reads nothing related either, and never returns the rule verdict', async () => { + const preview = await h.engine.validate( + 'crm_task', { ...PARTNER_PAYLOAD }, { mode: 'insert', context: ORGLESS_CALLER } as any, + ); + expect(h.relatedReads()).toBe(0); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).not.toContain(RULE_MESSAGE); + }); + + it('validate({ mode: update }) reads nothing related and returns no rule verdict', async () => { + const preview = await h.engine.validate( + 'crm_task', { amount: 50000, account: 'acc_p' }, { mode: 'update', context: ORGLESS_CALLER } as any, + ); + expect(h.relatedReads()).toBe(0); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).not.toContain(RULE_MESSAGE); + }); + }); + + describe('the D2 control: the same caller WITH an active organization', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot([TASK_EDITOR], { orgScoping: true }); }); + + it('insert() reaches the rule and refuses with the rule, after ONE related read', async () => { + const outcome = await attempt( + () => h.engine.insert('crm_task', { ...PARTNER_PAYLOAD }, { context: CALLER } as any), + ); + expect(outcome.ok).toBe(false); + expect(outcome.message).toContain(RULE_MESSAGE); + expect(h.relatedReads()).toBe(1); + }); + + it('validate() agrees with it, after ONE related read', async () => { + const preview = await h.engine.validate( + 'crm_task', { ...PARTNER_PAYLOAD }, { mode: 'insert', context: CALLER } as any, + ); + expect(h.relatedReads()).toBe(1); + expect(preview.results?.[0]?.valid).toBe(false); + expect(JSON.stringify(preview.results?.[0]?.errors ?? [])).toContain(RULE_MESSAGE); + }); + }); +}); From 1adad1ea9d4b00b4ca7c77c02960f470d320d071 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 04:53:35 +0000 Subject: [PATCH 28/47] docs(security,objectql): delete the sentences that bounded the channel to writers Two shipped sentences in the _writeGateProbe docblock were false: that the real write path bounds the inference channel "for free" because the CRUD gate refuses before any rule runs (the referential FK clear is exempt from that check), and that the readonly-strip gap "widens the channel to no caller the write path refuses" (an org-less session was refused by insert() and answered by validate()). Both are deleted, with every sentence that restated the same premise: the two twins in validate(), the canWriteObject "Why it exists" paragraph and the probe registration comment in security-plugin.ts, and the test-file headers of can-write-object-admission.test.ts, write-preview-field-gate-parity.test.ts and engine-predicate-relationship.test.ts. The docblock heading no longer claims only the dry run needs protection. Arm 3's entry names what it is handed rather than what assert reads. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/engine-predicate-relationship.test.ts | 3 +-- packages/objectql/src/engine.ts | 27 +++++++------------ .../src/can-write-object-admission.test.ts | 7 ++--- .../plugin-security/src/security-plugin.ts | 11 +++----- .../write-preview-field-gate-parity.test.ts | 4 +-- 5 files changed, 17 insertions(+), 35 deletions(-) diff --git a/packages/objectql/src/engine-predicate-relationship.test.ts b/packages/objectql/src/engine-predicate-relationship.test.ts index 7b0da2e0a44..a2affd030e4 100644 --- a/packages/objectql/src/engine-predicate-relationship.test.ts +++ b/packages/objectql/src/engine-predicate-relationship.test.ts @@ -328,8 +328,7 @@ describe('#18682 — engine-produced relationship bindings', () => { ).rejects.toThrow(/partner acc_p is capped/); }); - // The gate that keeps the accepted inference channel to writers only. A - // caller who could not perform this write gets NO elevated read at all. + // The probe's refusal: a caller it refuses gets NO elevated read at all. it('issues NO elevated read in validate() for a caller who may not write', async () => { (engine as any).registerWriteGateProbe(async () => false); d.calls.length = 0; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 5f6ce23a998..2a12eba759c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3940,15 +3940,12 @@ export class ObjectQL implements IObjectQLEngine { * [#18682] "May this context CREATE or UPDATE this object?" — supplied by the * security plugin, never decided here. * - * ## What it protects, and why only the dry run needs it + * ## What it protects * * A validation rule reads its related record under SYSTEM authority, and the * accepted cost of that is an inference channel: a caller can learn something * about a value they cannot read by observing which of THEIR WRITES are - * refused. That bound holds on the real write path for free — the CRUD gate - * runs in middleware and refuses a caller with no write grant long before any - * rule is evaluated, so only someone who could already write the row can - * observe anything at all. + * refused. * * `validate()` runs NO middleware for the target object, by design: it * executes nothing. Its network ingress (the `dryRun` import) checks auth and @@ -4027,9 +4024,7 @@ export class ObjectQL implements IObjectQLEngine { * `readonly` reference field inside the write's executor, so the real * write resolves NO related row for it and a traversing rule refuses; * the preview runs no strip, resolves the caller's own foreign key and - * answers the rule against it. Every writer is affected alike — it widens - * the channel to no caller the write path refuses — but the id the - * preview judges is one the write path never carries. + * answers the rule against it — an id the write path never carries. * * ## Unwired * @@ -10909,18 +10904,16 @@ export class ObjectQL implements IObjectQLEngine { // author declared static `readonly` is STRIPPED from the caller's payload // inside `insert()`'s executor (`stripRuntimeOwnedFields`), so the real // write resolves no related row for it and a traversing rule refuses there. - // Nothing is stripped here, so the preview resolves the caller's own + // Nothing is stripped here: the preview resolves the caller's own // foreign key and answers the rule against an id the write path never - // carries. Every writer is affected alike — this reaches no caller the - // write path refuses — but the preview's verdict is not the write's for - // that declaration. Running the strip here would make them agree and is a + // carries, so the preview's verdict is not the write's for that + // declaration. Running the strip here would make them agree and is a // behaviour change on the preview's payload, so it is named, not done. // ⛔ Behind the caller's own create/update gate — see - // {@link registerWriteGateProbe} for why the preview needs a gate the write - // path gets from middleware for free. A caller who could not perform this - // write gets NO elevated read: `related` stays unresolved, and a traversing - // rule then refuses, which is the fail-closed direction and is honest about - // what it did not evaluate. + // {@link registerWriteGateProbe}. A caller the probe refuses gets NO + // elevated read: `related` stays unresolved, and a traversing rule then + // refuses, which is the fail-closed direction and is honest about what it + // did not evaluate. // ⛔ `rawRows`, not `rows`: the gate's field-level arm judges WHICH FIELDS // THE CALLER WROTE, and `rows` has already been through // `applyFieldDefaults` / `initializeSummaryFields` above. Handing it the diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index 31e5582bebe..43025f7810a 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -7,11 +7,8 @@ * * `ObjectQL.validate()` previews a write without running middleware, and a * validation rule that reads one hop through a reference field is evaluated - * there against a related row fetched under SYSTEM authority. The accepted cost - * of that elevation is an inference channel bounded to callers who could - * perform the write — a bound the real write path gets for free, because the - * middleware refuses first. The preview has to ask for it, and - * {@link SecurityPlugin.canWriteObject} is what it asks. + * there against a related row fetched under SYSTEM authority. The preview asks + * {@link SecurityPlugin.canWriteObject} before it reads. * * The first version of that question was NOT the middleware's decision. It * checked `isSystem`, a principal, and the CRUD grant — and admitted four diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index f22b6c8e69f..a24c992a066 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1228,8 +1228,6 @@ export class SecurityPlugin implements Plugin { // [#18682] Answer the engine's create/update gate question for `validate()`. // - // The real write path gets this gate from the middleware below for free, so - // only a caller who could write can observe a validation rule's verdict. // `validate()` runs no middleware for its target object, so without this the // dry run would answer for callers the write path refuses. Same evaluator // and same permission sets the CRUD gate itself uses — ⛔ not a second @@ -5101,12 +5099,9 @@ export class SecurityPlugin implements Plugin { * target object, by design: it executes nothing. A validation rule that reads * one hop through a reference field is evaluated there against a related row * fetched under SYSTEM authority, and the accepted cost of that elevation is - * an inference channel bounded to callers the write path would admit — a - * bound the real path gets for free, because the middleware's write gate - * refuses long before any rule is evaluated. The preview has no such gate, so - * it asks this. What this method restores is the nine arms enumerated below - * and nothing beyond them — the closing paragraph names the refusals that - * stay ahead of it. + * an inference channel — so the preview asks this before it reads. What this + * method restores is the arms enumerated below and nothing beyond them — the + * closing paragraph names the refusals that stay ahead of it. * * ## The arms, in the middleware's own order — ⛔ the CRUD grant is not the gate * diff --git a/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts index d2a030d9c19..b2290d91fa4 100644 --- a/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts +++ b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts @@ -7,9 +7,7 @@ * ## Why this file exists, and why the unit suites could not hold it * * `ObjectQL.validate()` resolves a traversing validation rule's related row - * under SYSTEM authority. The accepted cost is an inference channel bounded to - * callers who could perform the write — a bound the real path gets from the - * security middleware and the preview has to ask for, through + * under SYSTEM authority, behind the gate it asks for through * `registerWriteGateProbe`. * * The first probe asked an OBJECT-LEVEL question, and the write decision is not From c59a206c1da530ea6d5884e09b11b2ab1c64ec91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:29:24 +0000 Subject: [PATCH 29/47] test(security): make the empty-resolution D2 case reach arm 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "resolves no permission set at all" case booted with the default bootstrap sets, so the caller resolved the member baseline and never reached arm 4 — measured: ablating the organization wall left it green. The boot gains `noBaseline` (no bootstrap sets, no baseline), the arms block asserts the empty resolution as its premise, and an org-bound twin pins that arm 4 still admits. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/can-write-object-admission.test.ts | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index 43025f7810a..d40664671a9 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -300,7 +300,11 @@ function rowMatches(row: Record, where: Record }); } -async function boot(sets: PermissionSet[], tables?: Tables, opts: { orgScoping?: boolean } = {}) { +async function boot( + sets: PermissionSet[], + tables?: Tables, + opts: { orgScoping?: boolean; noBaseline?: boolean } = {}, +) { const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; const services: Record = { // The `isolated` posture, resolved the way the plugin falls back to it when @@ -348,7 +352,13 @@ async function boot(sets: PermissionSet[], tables?: Tables, opts: { orgScoping?: return services[name]; }, }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + // `noBaseline` leaves NOTHING to resolve — no bootstrap sets, no baseline — + // so an authenticated caller reaches arm 4 with an empty resolution. + const plugin = new SecurityPlugin( + opts.noBaseline + ? { defaultPermissionSets: [], fallbackPermissionSet: null } + : { fallbackPermissionSet: 'member_default' }, + ); await plugin.init(ctx as any); await plugin.start(ctx as any); if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware'); @@ -396,6 +406,8 @@ describe('canWriteObject agrees with the engine middleware, case for case', () = data?: unknown; /** Boot under the `isolated` posture, so the ADR-0123 D2 wall is armed. */ orgScoping?: true; + /** Boot with nothing to resolve, so the caller reaches arm 4. */ + noBaseline?: true; }> = [ { label: 'no grant of any kind on the object', object: 'ledger', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, { label: 'an explicit create grant', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX }, @@ -455,13 +467,14 @@ describe('canWriteObject agrees with the engine middleware, case for case', () = // from the control twin below, which the wall admits. { label: 'an authenticated caller with no active organization (ADR-0123 D2)', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: ORGLESS_CTX, orgScoping: true }, { label: 'the same org-less caller in UPDATE mode', object: 'invoice', operation: 'update', sets: [WRITER_SET], context: ORGLESS_CTX, orgScoping: true }, - { label: 'an org-less caller who resolves no permission set at all', object: 'invoice', operation: 'insert', sets: [], context: ORGLESS_CTX, orgScoping: true }, + { label: 'an org-less caller who resolves no permission set at all', object: 'invoice', operation: 'insert', sets: [], context: ORGLESS_CTX, orgScoping: true, noBaseline: true }, + { label: 'the org-bound twin who resolves no permission set at all', object: 'invoice', operation: 'insert', sets: [], context: WRITER_CTX, orgScoping: true, noBaseline: true }, { label: 'the same caller WITH an active organization, under the same posture', object: 'invoice', operation: 'insert', sets: [WRITER_SET], context: WRITER_CTX, orgScoping: true }, ]; for (const c of CASES) { it(`agrees on ${c.label}`, async () => { - const { plugin, middleware } = await boot(c.sets, undefined, { orgScoping: c.orgScoping }); + const { plugin, middleware } = await boot(c.sets, undefined, { orgScoping: c.orgScoping, noBaseline: c.noBaseline }); const data = 'data' in c ? c.data : PLAIN_PAYLOAD; const admitted = await middlewareAdmits(middleware, c.object, c.operation, c.context, data); const answered = await plugin.canWriteObject(c.object, c.operation, c.context, data); @@ -639,11 +652,19 @@ describe('the arms the CRUD grant alone does not cover', () => { await expect(plugin.canWriteObject('invoice', 'update', ORGLESS_CTX, PLAIN_PAYLOAD)).resolves.toBe(false); }); - it('DENIES it when no permission set resolves, too — the wall is not behind the CRUD guard', async () => { - const { plugin } = await boot([], undefined, { orgScoping: true }); + it('DENIES it when no permission set resolves, too — arm 4 does not admit past the wall', async () => { + const { plugin } = await boot([], undefined, { orgScoping: true, noBaseline: true }); + // The premise, or the case proves nothing about arm 4: nothing resolves. + await expect((plugin as any).resolvePermissionSetsForContext(ORGLESS_CTX)).resolves.toEqual([]); await expect(plugin.canWriteObject('invoice', 'insert', ORGLESS_CTX, PLAIN_PAYLOAD)).resolves.toBe(false); }); + it('ADMITS the org-bound twin who resolves no permission set — arm 4 still admits', async () => { + const { plugin } = await boot([], undefined, { orgScoping: true, noBaseline: true }); + await expect((plugin as any).resolvePermissionSetsForContext(WRITER_CTX)).resolves.toEqual([]); + await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); + }); + it('ADMITS the same caller with an active organization — so the arm is not a blanket deny', async () => { const { plugin } = await boot([WRITER_SET], undefined, { orgScoping: true }); await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true); From 2361befb06f2a3254db6e464a6e655a0bcba309b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:49:28 +0000 Subject: [PATCH 30/47] test(security): type the two option bags the FK-clear pin adds check:query-options-erasure counted the new `find` bag cast to `any` (test surface 236 -> 237). Both new bags type-check without the cast, so the surface holds at 236. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/delete-reference-cleanup-system-identity.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts b/packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts index 82ab6a33487..5e8bd0fdfcb 100644 --- a/packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts +++ b/packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts @@ -781,7 +781,7 @@ describe('#18682 — the referential FK clear resolves no relationship, so a rul it('the deleter cannot read C — the premise every case below rests on', async () => { const { h } = await withLine('secret'); const err = await h.engine - .find('os_ehr_line', { context: h.caller() } as any) + .find('os_ehr_line', { context: h.caller() }) .then(() => null, (e: any) => e); expect(err?.code).toBe('PERMISSION_DENIED'); }); @@ -812,7 +812,7 @@ describe('#18682 — the referential FK clear resolves no relationship, so a rul it('CONTROL: an ordinary update of B DOES read C and refuses with the rule — the harness can answer the read', async () => { const { h, linesRead } = await withLine('secret'); const err = await h.engine - .update('os_ehr_inspection', { id: 'insp_1', owner_id: 'u_next' }, { context: { isSystem: true } } as any) + .update('os_ehr_inspection', { id: 'insp_1', owner_id: 'u_next' }, { context: { isSystem: true } }) .then(() => null, (e: any) => e); expect(err?.code).toBe('VALIDATION_FAILED'); expect(err?.message).toContain(SECRET_LINE_MESSAGE); From 6ff984239abf8bab34c69c2416567ba6a02cb6a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:05:30 +0000 Subject: [PATCH 31/47] docs(objectql,security): bound three comments to what the code does The guard comment in resolvePredicateRelated said a traversing rule "faults" on the FK clear; it meets the bare id, where reading through it faults. resolveTraversalScope named one cause of an absent binding; the FK clear is the second. The arm-10 entry said no payload changes the wall's answer; the payload decides only whether it is asked. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 9 +++++---- packages/objectql/src/validation/rule-validator.ts | 6 +++--- packages/plugins/plugin-security/src/security-plugin.ts | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2a12eba759c..9e271377d74 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7058,13 +7058,14 @@ export class ObjectQL implements IObjectQLEngine { const unbound = () => undefined; // ⛔ A referential FK clear resolves NOTHING. `cascadeDeleteRelations` // stamps `__referentialFieldClear` on its cleanup UPDATE, and plugin-security - // exempts exactly that write from its object-level CRUD check — so the + // exempts an update carrying it from its object-level CRUD check — so the // cleanup reaches this seam for a deleter holding no grant on the // referencing object, and a system read here would let a traversing rule's // verdict decide their delete: one bit of a related record they cannot - // read, per delete. Left unbound, such a rule faults and refuses the cleanup - // as it did before this seam existed (`resolveTraversalScope` leaves an - // unbound record to CEL). Pinned end to end in plugin-security's + // read, per delete. Left unbound, such a rule meets the bare id as it did + // before this seam existed (`resolveTraversalScope` leaves an unbound record + // to CEL), where reading through it faults and refuses the cleanup. Pinned + // end to end in plugin-security's // `delete-reference-cleanup-system-identity.test.ts`. if (this.buildReferentialFieldClear(context as ExecutionContext | undefined)) return unbound; const wanted = collectPredicateRelationships(schema); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 7dfc154c64a..ef5bc1bfeb9 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -2978,9 +2978,9 @@ function resolveTraversalScope( if (!isReference(field)) continue; const binding = related?.[field]; // No binding at all means the engine resolved nothing for this write (an - // embedding that never called `collectPredicateRelationships`). Leave the - // record alone and let evaluation fault as it did before — this function - // does not invent a verdict for a seam that was never wired. + // embedding that never called `collectPredicateRelationships`, or the + // referential FK clear). Leave the record alone and let evaluation meet the + // bare id as it did before — this function invents no verdict for it. if (!binding) continue; if (binding.row) { if (!copy) copy = { ...record }; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index a24c992a066..f902dd2ce79 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5168,8 +5168,8 @@ export class SecurityPlugin implements Plugin { * step 3.7 throws on, asked under that step's guard (a payload is * supplied, the context names a user) at that step's point, after arm * 9; arm 4 asks it too, as the middleware does with no set resolved. It - * is handed no row, so no payload changes its answer: it refuses a - * CALLER CLASS, as arms 2 and 3 do. + * is handed no row — the payload decides only whether it is asked — so + * it refuses a CALLER CLASS, as arms 2 and 3 do. * * `can-write-object-admission.test.ts` pins this method's answer equal to the * registered middleware's on its equivalence block's cases, and pins one From 7ef7ea90f901db6adcce194d25c4573f6936af6e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 07:32:17 +0000 Subject: [PATCH 32/47] fix(objectql): the unresolved refusal states only that the record was not found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `unresolved` detail said "no record with that id exists — it has most likely been deleted" and "this read is made under system authority, so it is NOT a question of what the caller may see". Measured false when the reference names another organization's row: the record exists, and the read is scoped to the caller's organization by the driver. The detail is deleted; the summary line already says "the related record was not found", which is what the read observed, and it reads the same for "exists in another organization" and "exists nowhere". The RelatedUnavailableReason docblocks and resolvePredicateRelated's list say "not found" instead of "row gone" / "does not exist". rule-relationship-traversal.test.ts's case drops the two deleted needles and names what the message still carries. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 4 ++-- .../validation/rule-relationship-traversal.test.ts | 8 +++----- packages/objectql/src/validation/rule-validator.ts | 11 ++++------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9e271377d74..51fcf7b2cd3 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7039,8 +7039,8 @@ export class ObjectQL implements IObjectQLEngine { * * ## An unresolved row is left UNAVAILABLE, and it says which kind * - * No reference stored, row gone, the related object declares no such column, - * or the read failed: each is recorded as its own reason, and + * No reference stored, the related record not found, the related object + * declares no such column, or the read failed: each is its own reason, and * {@link checkPredicate} turns it into a refusal naming the related object and * column. The write is REJECTED rather than judged on a rule that produced no * verdict. ⛔ Never silently true, and never silently false. diff --git a/packages/objectql/src/validation/rule-relationship-traversal.test.ts b/packages/objectql/src/validation/rule-relationship-traversal.test.ts index 4b8a26a875a..aaa1beade2d 100644 --- a/packages/objectql/src/validation/rule-relationship-traversal.test.ts +++ b/packages/objectql/src/validation/rule-relationship-traversal.test.ts @@ -132,10 +132,8 @@ describe('#18682 — the three acceptance outcomes (ADR-0137 D2)', () => { // ── REFUSES LOUDLY when the rule cannot be evaluated at all ─────────────── // - // The related row is read under SYSTEM authority, so a permission verdict is - // not among the causes here at all. What remains is: no reference stored, the - // row is gone, the related object declares no such column, or the read - // failed outright. Each + // The causes: no reference stored, the related record was not found, the + // related object declares no such column, or the read failed outright. Each // is refused with a sentence naming the related object, and the write is // REJECTED rather than judged on a rule that produced no verdict. Never // silently true, and never silently false either. @@ -274,7 +272,7 @@ describe('#18682 — the refusal names the RELATED object, not the referencing o ['read failed', unavailable('unreadable'), ['could not read', "'crm_account'"]], ['undeclared related field', unavailable('undeclared-field', ['type']), ['declares no', "'type'"]], ['no reference stored', unavailable('no-reference'), ['no single related record', 'MULTIPLE references']], - ['related row gone', unavailable('unresolved'), ['no record with that id exists', 'system authority']], + ['related record not found', unavailable('unresolved'), ["'crm_account'", 'the related record was not found']], ]; for (const [name, binding, expected] of cases) { diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index ef5bc1bfeb9..ab4de5160c4 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -537,8 +537,8 @@ export type ParentBinding = Record | null | undefined; /** * [#18682] Reference FIELD name → the related row, or `null` when it could not - * be read (no reference stored, the row is gone, the related object declares no - * such column, or the read failed). They do NOT collapse: each names itself in + * be read (no reference stored, the related record was not found, the related + * object declares no such column, or the read failed). They do NOT collapse: each names itself in * the refusal, because "there is no parent" and "that column does not exist" * send an author to different repairs. */ @@ -553,7 +553,7 @@ export type RelatedUnavailableReason = * empty: the latter evaluates as `null`, this one refuses. */ | 'undeclared-field' - /** A reference is stored but the row it names does not exist. */ + /** A reference is stored but the related record was not found. */ | 'unresolved'; /** @@ -3043,10 +3043,7 @@ function traversalRefusal( case 'unresolved': return { summary: `cannot read ${columns} through ${on}: the related record was not found`, - detail: - ` The rule reads ${columns} through ${on}, but no record with that id exists — it` - + ' has most likely been deleted, leaving the reference dangling. This read is made' - + ' under system authority, so it is NOT a question of what the caller may see.', + detail: '', }; case 'unreadable': default: From b6c74e6d9400b85e52df60d234150ca3662db07c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 07:35:24 +0000 Subject: [PATCH 33/47] test(security): pin the related read inside the caller's organization The related read is system authority, and it stays in the caller's organization only because the engine spreads the caller's context, tenantId included, and the driver scopes by it. Nothing pinned that. On the real SqlDriver under the isolated posture, through the real middleware: a caller bound to org X references org Y's row, once secret and once public. Both end identically on insert and on validate(), and the driver hands the related read nothing. The org X control reaches the rule's own message on both doors, with one row read on each. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...redicate-related-read-tenant-scope.test.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts diff --git a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts new file mode 100644 index 00000000000..30b602eaf9c --- /dev/null +++ b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18682] A validation rule's related read stays inside the caller's + * organization — so a reference naming ANOTHER organization's row tells the + * caller nothing about that row. + * + * The related read runs under system authority, which skips this plugin's + * Layer 0 wall. What keeps it in the caller's organization is that the engine + * spreads the caller's context into it, `tenantId` included, and the driver + * scopes the read by that. A bare `{ isSystem: true }` would read the other + * organization's row, and the rule's verdict on it would reach the caller. + * + * Driven on the real `SqlDriver` under the `isolated` posture, through the + * real middleware: a caller bound to org X writes a reference to org Y's row, + * once with that row `secret` and once `public`. The two must end identically, + * the read must return nothing, and the org X control must reach the rule. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; + +const RULE_MESSAGE = 'Inspections on a secret line are frozen.'; + +const OBJECTS = [ + { + name: 'qa_line', + label: 'Line', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + kind: { name: 'kind', type: 'text' }, + }, + }, + { + name: 'qa_inspection', + label: 'Inspection', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + line: { name: 'line', type: 'lookup', reference: 'qa_line' }, + }, + validations: [{ + name: 'no_secret_line', type: 'script', severity: 'error', + message: RULE_MESSAGE, condition: "record.line.kind == 'secret'", + }], + }, +]; + +const MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { + qa_inspection: { allowRead: true, allowCreate: true, allowEdit: true }, + qa_line: { allowRead: true }, + }, +} as unknown as PermissionSet; + +/** Bound to org X. */ +const CALLER = { userId: 'u_x', tenantId: 'org_x', positions: [], permissions: [], posture: 'MEMBER' }; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** `kind` is org Y's row's value; org X holds one `secret` line of its own. */ +async function boot(kind: 'secret' | 'public') { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const engine = new ObjectQL(); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.predicate-related-read-tenant-scope', + name: 'Predicate related read tenant scope', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: OBJECTS, + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const services: Record = { + 'org-scoping': { name: 'org-scoping' }, + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { get: async (_t: string, name: string) => engine.getSchema(name) ?? null, list: async () => [MEMBER] }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + // Straight into the table, past every scope: the fixture is not the subject. + await driver.knex('qa_line').insert([ + { id: 'line_x', kind: 'secret', organization_id: 'org_x' }, + { id: 'line_y', kind, organization_id: 'org_y' }, + ]); + + // What the driver hands back for every read of the related object. + const readsOfLine: unknown[][] = []; + const find = driver.find.bind(driver); + vi.spyOn(driver, 'find').mockImplementation(async (object, ast, options) => { + const rows = await find(object, ast, options); + if (object === 'qa_line') readsOfLine.push(rows as unknown[]); + return rows; + }); + + const stored = async (name: string) => (await driver.knex('qa_inspection').where({ name }).select('id')).length; + return { engine, readsOfLine, stored }; +} + +/** Everything the caller sees of one write and one preview naming `line`. */ +async function observe(kind: 'secret' | 'public', line: string) { + const h = await boot(kind); + const refusal = await h.engine + .insert('qa_inspection', { name: 'probe', line }, { context: CALLER } as never) + .then(() => null, (e: { code?: string; message?: string }) => ({ code: e.code, message: e.message })); + const preview = await h.engine.validate( + 'qa_inspection', { name: 'probe', line }, { mode: 'insert', context: CALLER } as never, + ); + return { + seen: { + refusal, + committed: await h.stored('probe'), + preview: { valid: preview.results?.[0]?.valid, errors: preview.results?.[0]?.errors?.map((e) => e.message) }, + }, + readsOfLine: h.readsOfLine, + }; +} + +describe('#18682 — the related read stays inside the caller’s organization', () => { + it('a reference to org Y’s row ends identically whether that row is secret or public', async () => { + const secret = await observe('secret', 'line_y'); + const open = await observe('public', 'line_y'); + + expect(open.seen).toEqual(secret.seen); + expect(secret.seen.refusal?.code).toBe('VALIDATION_FAILED'); + expect(secret.seen.committed).toBe(0); + // The read happened, once per door, and found nothing in org X. + expect(secret.readsOfLine).toEqual([[], []]); + expect(open.readsOfLine).toEqual([[], []]); + }); + + it('CONTROL: org X’s own secret line reaches the rule on both doors', async () => { + const own = await observe('public', 'line_x'); + + expect(own.seen.refusal).toEqual({ code: 'VALIDATION_FAILED', message: RULE_MESSAGE }); + expect(own.seen.committed).toBe(0); + expect(own.seen.preview).toEqual({ valid: false, errors: [RULE_MESSAGE] }); + expect(own.readsOfLine.map((rows) => rows.length)).toEqual([1, 1]); + }); +}); From b3efd1d2de32c67089bcaa650af0fee08662745d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 07:42:46 +0000 Subject: [PATCH 34/47] test(security): reach the pin's table past SqlDriver's protected knex check:test-typecheck read two TS2445 errors in the new file: `knex` is protected on SqlDriver. The pin now reaches it through a typed accessor. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/predicate-related-read-tenant-scope.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts index 30b602eaf9c..dd16930f3a9 100644 --- a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts +++ b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts @@ -60,6 +60,12 @@ const MEMBER: PermissionSet = { }, } as unknown as PermissionSet; +/** The driver's own query builder, reached past its `protected` modifier. */ +type Table = (name: string) => { + insert(rows: Array>): Promise; + where(match: Record): { select(...columns: string[]): Promise }; +}; + /** Bound to org X. */ const CALLER = { userId: 'u_x', tenantId: 'org_x', positions: [], permissions: [], posture: 'MEMBER' }; @@ -107,7 +113,8 @@ async function boot(kind: 'secret' | 'public') { vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); // Straight into the table, past every scope: the fixture is not the subject. - await driver.knex('qa_line').insert([ + const table = (driver as unknown as { knex: Table }).knex; + await table('qa_line').insert([ { id: 'line_x', kind: 'secret', organization_id: 'org_x' }, { id: 'line_y', kind, organization_id: 'org_y' }, ]); @@ -121,7 +128,7 @@ async function boot(kind: 'secret' | 'public') { return rows; }); - const stored = async (name: string) => (await driver.knex('qa_inspection').where({ name }).select('id')).length; + const stored = async (name: string) => (await table('qa_inspection').where({ name }).select('id')).length; return { engine, readsOfLine, stored }; } From d8e47c9bdda12eb7f3b85c7deeb81779cd689f44 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 08:27:24 +0000 Subject: [PATCH 35/47] test(security): give the D12 fixture's business units their organization main's delegated-admin gate now resolves a scope's business-unit anchor inside the caller's own organization. DELEGATE_CTX carries tenantId 'org-1' and delegateTables()'s business-unit rows carried none, so on the merged tree the delegate's subtree was empty and the two copy tests and the DIRECTION test went red. The rows now carry organization_id 'org-1'. Fixture data only; no assertion changes. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/can-write-object-admission.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index d40664671a9..8d726ffba79 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -277,10 +277,10 @@ type Tables = Record>>; */ const delegateTables = (): Tables => ({ sys_business_unit: [ - { id: 'bu_hq', name: 'hq', parent_business_unit_id: null }, - { id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' }, - { id: 'bu_es', name: 'east_sales', parent_business_unit_id: 'bu_east' }, - { id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq' }, + { id: 'bu_hq', name: 'hq', parent_business_unit_id: null, organization_id: 'org-1' }, + { id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq', organization_id: 'org-1' }, + { id: 'bu_es', name: 'east_sales', parent_business_unit_id: 'bu_east', organization_id: 'org-1' }, + { id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq', organization_id: 'org-1' }, ], sys_position: [{ id: 'pos_sales', name: 'sales_rep' }], sys_position_permission_set: [{ id: 'b1', position_id: 'pos_sales', permission_set_id: 'ps_sales' }], From 79f2e9a6a5e42bd9d4972cf881ec7872ec8a4f12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 08:27:25 +0000 Subject: [PATCH 36/47] docs(objectql): drop ", not the caller" from the related read's bound The related read is also bounded by the caller's organization (pinned in predicate-related-read-tenant-scope.test.ts), so "What bounds the elevation is the PROJECTION, not the caller" was false. The three words are deleted at the resolvePredicateRelated docblock, the comment at the read, and the changeset. No phrasing is added. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/18682-predicate-relationship-traversal.md | 2 +- packages/objectql/src/engine.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index e739b1ed054..3f90504421f 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -52,7 +52,7 @@ acting user instead made the rule unauthorable for exactly the persona it exists to constrain: a member with CRUD on the child and no read on the parent faulted on every write. -What bounds the elevation is the **projection**, not the caller: only the +What bounds the elevation is the **projection**: only the columns the predicate names, intersected with the related object's declared fields. A column the related object does not declare never enters the query, and is refused as the authoring fault it is — distinct from a column that exists and diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 51fcf7b2cd3..e005db2cdca 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7033,7 +7033,7 @@ export class ObjectQL implements IObjectQLEngine { * 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, not the caller: only the + * 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. * @@ -7136,7 +7136,7 @@ export class ObjectQL implements IObjectQLEngine { // with CRUD on the child and no read on the parent faulted on every // write, so a legitimate business rule could not ship. // - // What bounds the elevation is the PROJECTION, not the caller: only the + // What bounds the elevation is the PROJECTION: only the // columns this predicate names, intersected with the related object's // declared fields above. ⛔ Never the whole row. // From cc4868624f135769e1a714273b4e8eca493539bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 09:01:06 +0000 Subject: [PATCH 37/47] docs(objectql): delete the remaining claims that the caller does not bound the related read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The related read is also bounded by the caller's organization (pinned in predicate-related-read-tenant-scope.test.ts). The claims to the contrary are deleted: - rule-validator.ts, EvaluateRulesOptions.related: "— never the caller"; - rule-validator.ts, RelatedFieldBinding: ", never by the caller"; - engine.ts, resolvePredicateRelated's read-set comment: "so its projection is the whole of what bounds it"; - engine-predicate-relationship.test.ts: "rather than by the caller", ", never by the caller" and "the projection is the whole of what limits an elevated read". Deletion only; no phrasing is added. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/objectql/src/engine-predicate-relationship.test.ts | 6 +++--- packages/objectql/src/engine.ts | 5 ++--- packages/objectql/src/validation/rule-validator.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/engine-predicate-relationship.test.ts b/packages/objectql/src/engine-predicate-relationship.test.ts index a2affd030e4..10c44722a1b 100644 --- a/packages/objectql/src/engine-predicate-relationship.test.ts +++ b/packages/objectql/src/engine-predicate-relationship.test.ts @@ -11,7 +11,7 @@ * * - the related read runs under SYSTEM authority (like `parent`, and for the * same kind of reason: a validation verdict is the system's, not the - * caller's), bounded by its PROJECTION rather than by the caller; + * caller's), bounded by its PROJECTION; * - the projection names `id` plus only the fields the rules actually read, * and never smuggles a column the related object does not declare; * - on UPDATE the foreign key is read off the PRIOR row when the patch omits @@ -194,7 +194,7 @@ describe('#18682 — engine-produced relationship bindings', () => { // ⭐ The ruled read authority: a validation rule's output is a pass/fail the // SYSTEM enforces, so the related row is read under system authority and the // rule is authorable for exactly the persona it exists to constrain. Bounded - // by the PROJECTION, never by the caller. + // by the PROJECTION. it('reads the related row under SYSTEM authority', async () => { const seen: any[] = []; engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { @@ -210,7 +210,7 @@ describe('#18682 — engine-produced relationship bindings', () => { // ⛔ The bound on the elevation. A predicate that names a column the related // object does not declare must NOT put that name into a system-authority - // query — the projection is the whole of what limits an elevated read. + // query. it('never smuggles an UNDECLARED field into the system read set', async () => { engine.registry.registerObject({ name: 'crm_opportunity', diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e005db2cdca..7df02b412ca 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7091,9 +7091,8 @@ export class ObjectQL implements IObjectQLEngine { // "what the related object DECLARES", and it is computed here so that it // can never be anything else. A predicate naming a column the related // object does not declare must not put that name into a system-authority - // query: the read is elevated, so its projection is the whole of what - // bounds it. An undeclared name is also a real authoring fault and is - // reported as one rather than silently dropped. + // query: the read is elevated. An undeclared name is also a real + // authoring fault and is reported as one rather than silently dropped. const targetSchema = this._registry.getObject(target) as { fields?: Record } | undefined; const declared = targetSchema?.fields; // [#8215] The PRIMARY KEY is declared by the platform, not by the author, diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index ab4de5160c4..d571942dbe7 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -398,7 +398,7 @@ export interface EvaluateRulesOptions { * SYSTEM enforces, not data handed to the caller, which is why RLS predicates * are excluded from this capability altogether. What bounds the elevation is * the PROJECTION — only the columns the predicate names, intersected with the - * related object's declared fields — never the caller. + * related object's declared fields. * * ⛔ NOT applied to every rule alike. A rule is hydrated only for the * reference fields ITS OWN condition reads through, because hydrating a field @@ -579,7 +579,7 @@ export type RelatedUnavailableReason = * ⚠️ The related row is read under SYSTEM authority — a validation rule's output * is a pass/fail the system enforces, not data handed to the caller. The read is * bounded by its PROJECTION (only the columns the predicate names, intersected - * with the related object's declared fields), never by the caller. ⛔ This + * with the related object's declared fields). ⛔ This * applies to validation rules alone; RLS and UI predicates are out of the * capability entirely. */ From f61d7d1d2bd95ec3f7b439169fcdd8e97397bdcb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 10:11:32 +0000 Subject: [PATCH 38/47] fix(objectql): judge a by-id update's traversing rule against the FK it stores The by-id UPDATE resolved a predicate's related row from the patch BEFORE the readonly / readonlyWhen strips, then evaluated the rule on the post-strip record. A repoint the strip dropped was therefore judged against the account the caller asked for, not the one the row keeps: a rule the stored row violates committed, and a rule it satisfies refused. - Resolve after both strips, off the post-strip merged view, like the bulk branch already does. - resolveTraversalScope hydrates a binding only when its row id is the record's own FK; any other row refuses as unresolved. Pins: both strip variants, each with its control and the reverse direction; the guard alone in the evaluator. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/engine-predicate-relationship.test.ts | 64 +++++++++++++++++++ packages/objectql/src/engine.ts | 11 ++-- .../rule-relationship-traversal.test.ts | 14 ++++ .../objectql/src/validation/rule-validator.ts | 6 +- 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/engine-predicate-relationship.test.ts b/packages/objectql/src/engine-predicate-relationship.test.ts index 10c44722a1b..ff308738361 100644 --- a/packages/objectql/src/engine-predicate-relationship.test.ts +++ b/packages/objectql/src/engine-predicate-relationship.test.ts @@ -364,3 +364,67 @@ describe('#18682 — engine-produced relationship bindings', () => { expect(d.calls.filter((c) => c.object === 'crm_account')).toHaveLength(0); }); }); + +// ⛔ A by-id UPDATE whose FK repoint is STRIPPED keeps the stored FK, so the +// rule must be judged against the account the stored row points at — never the +// one the caller asked for and did not get. +describe.each([ + ['static `readonly`', { readonly: true }], + ['a TRUE `readonlyWhen`', { readonlyWhen: "record.stage == 'closed'" }], +])('#18682 — a stripped FK repoint is judged against the stored FK (%s)', (_name, lock) => { + let engine: ObjectQL; + let d: ReturnType; + + beforeEach(async () => { + engine = new ObjectQL(); + d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'crm_account', + fields: { name: { type: 'text' }, type: { type: 'text' } }, + } as any, 'test-package'); + engine.registry.registerObject({ + name: 'crm_opportunity', + fields: { + name: { type: 'text' }, + amount: { type: 'number' }, + stage: { type: 'text' }, + account: { type: 'lookup', reference: 'crm_account', ...lock }, + }, + validations: [{ + name: 'partner_cap', type: 'script', severity: 'error', + message: 'Partner accounts are capped at 10000.', + condition: "record.account.type == 'partner' && record.amount > 10000", + }], + } as any, 'test-package'); + d.storeFor('crm_account').set('acc_p', { id: 'acc_p', name: 'P', type: 'partner' }); + d.storeFor('crm_account').set('acc_d', { id: 'acc_d', name: 'D', type: 'direct' }); + }); + + const seed = (account: string) => + d.storeFor('crm_opportunity').set('opp_1', { id: 'opp_1', name: 'O', amount: 10, stage: 'closed', account }); + const update = (patch: Record) => + engine.update('crm_opportunity', patch, { where: { id: 'opp_1' }, context: ACTING } as any); + const readIds = () => d.calls.filter((c) => c.object === 'crm_account').map((c) => c.ast?.where?.id?.$in); + + it('REFUSES a repoint away from a partner account the strip keeps', async () => { + seed('acc_p'); + await expect(update({ amount: 50000, account: 'acc_d' })).rejects.toThrow(/Partner accounts are capped/); + expect(d.storeFor('crm_opportunity').get('opp_1')).toMatchObject({ account: 'acc_p', amount: 10 }); + expect(readIds()).toEqual([['acc_p']]); + }); + + it('CONTROL: the same amount without the repoint is refused alike', async () => { + seed('acc_p'); + await expect(update({ amount: 50000 })).rejects.toThrow(/Partner accounts are capped/); + expect(d.storeFor('crm_opportunity').get('opp_1')).toMatchObject({ account: 'acc_p', amount: 10 }); + }); + + it('ACCEPTS a repoint onto a partner account the strip drops', async () => { + seed('acc_d'); + await expect(update({ amount: 50000, account: 'acc_p' })).resolves.toBeTruthy(); + expect(d.storeFor('crm_opportunity').get('opp_1')).toMatchObject({ account: 'acc_d', amount: 50000 }); + expect(readIds()).toEqual([['acc_d']]); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 7df02b412ca..8a769c13584 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -12985,12 +12985,6 @@ export class ObjectQL implements IObjectQLEngine { // field is read-only for this record's state, so the incoming // change is ignored (the persisted value is kept). const preRoWhen = hookContext.input.data as Record; - // [#18682] The reference FK a predicate traverses may come from - // the PATCH or from the stored row, so the id is read off the - // same merged view `evaluateValidationRules` will evaluate. - const relatedForUpdate = (await this.resolvePredicateRelated( - updateSchema, [{ ...(priorRecord ?? {}), ...preRoWhen }], opCtx.context, - ))({ ...(priorRecord ?? {}), ...preRoWhen }); // [#4889] A `parent`-scoped predicate ("once the header invoice // is Paid, its lines are frozen") needs the master-detail header // bound as `parent`. Only the engine can fetch it, so the strip @@ -13079,6 +13073,11 @@ export class ObjectQL implements IObjectQLEngine { // "you sent a read-only field" should not depend on whether some // other field also failed a business rule. assertNoStrictDrops(); + // [#18682] The reference FK a predicate traverses may come from + // the PATCH or from the stored row, so the id is read off the + // POST-strip merged view `evaluateValidationRules` evaluates. + const updateView = { ...(priorRecord ?? {}), ...(hookContext.input.data as Record) }; + const relatedForUpdate = (await this.resolvePredicateRelated(updateSchema, [updateView], opCtx.context))(updateView); 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 }); // [#4441] A repoint is as capable of dangling as an initial link. await this.assertReferencesResolve( diff --git a/packages/objectql/src/validation/rule-relationship-traversal.test.ts b/packages/objectql/src/validation/rule-relationship-traversal.test.ts index aaa1beade2d..c0fb3f58775 100644 --- a/packages/objectql/src/validation/rule-relationship-traversal.test.ts +++ b/packages/objectql/src/validation/rule-relationship-traversal.test.ts @@ -323,3 +323,17 @@ describe('#18682 — a readable but EMPTY related column evaluates, it does not ).not.toThrow(); }); }); + +describe('#18682 — a binding is used only for the record its foreign key names', () => { + // The evaluator judges `record`, so a related row for any OTHER id is not + // this record's parent: it refuses as unresolved rather than evaluating. + it('REFUSES a row whose id is not the record’s foreign key', () => { + try { + evaluate({ name: 'A', amount: 50000, account: 'acc_1' }, { account: row({ id: 'acc_2', type: 'direct' }) }); + throw new Error('expected a ValidationError'); + } catch (e) { + const detail = JSON.stringify((e as unknown as { errors?: unknown }).errors ?? (e as Error).message); + expect(detail).toContain('the related record was not found'); + } + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index d571942dbe7..87b4422956b 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -2982,12 +2982,14 @@ function resolveTraversalScope( // referential FK clear). Leave the record alone and let evaluation meet the // bare id as it did before — this function invents no verdict for it. if (!binding) continue; - if (binding.row) { + // ⛔ Only the row this record's own foreign key names; any other is unresolved. + if (binding.row && binding.row.id != null && String(binding.row.id) === String(record[field])) { if (!copy) copy = { ...record }; copy[field] = binding.row; continue; } - return { ok: false, ...traversalRefusal(field, binding, analysis.traversals.get(field)) }; + const reason = binding.row ? { object: binding.object, unavailable: 'unresolved' as const } : binding; + return { ok: false, ...traversalRefusal(field, reason, analysis.traversals.get(field)) }; } return { ok: true, record: copy ?? record }; } From 1c1fe2663956e9dce6108938543f325ef2cc10ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 10:11:42 +0000 Subject: [PATCH 39/47] fix(objectql): no related read for an org-less member under the group posture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `group` the security plugin admits a member with a non-empty `accessible_org_ids` and no `tenantId`, and walls that member's own reads to the membership set. The related read runs under system authority, which skips that wall, and the driver scopes it only by `tenantId` — so it read across organizations, and `validate()` and a by-id `update()` answered differently for another organization's secret and public row. Such a caller (a `userId`, no `tenantId`, the `group` posture) now resolves no related row: every stored reference is unresolved and the rule refuses with the same text whatever the row holds. A caller with a `tenantId`, and a caller with no `userId`, are unchanged. Pin: the group posture on the real SecurityPlugin + SqlDriver, insert / validate / by-id update, secret == public and zero related reads, with the active-organization control. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 9 +++- ...redicate-related-read-tenant-scope.test.ts | 52 ++++++++++++++++--- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 8a769c13584..9a80376e4e4 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -153,6 +153,7 @@ import { isPlatformObjectOutOfTenantAuditScope } from './tenancy/platform-object import { resolveTenancyPosture } from '@objectstack/types'; import { normalizeTenancyPosture, + postureUsesUnionScope, TenantLayer0VerdictSchema, type TenancyPosture, type TenantLayer0Verdict, @@ -7070,6 +7071,12 @@ export class ObjectQL implements IObjectQLEngine { if (this.buildReferentialFieldClear(context as ExecutionContext | undefined)) return unbound; const wanted = collectPredicateRelationships(schema); if (wanted.size === 0) return unbound; + // ⛔ Under `group` a USER caller with no active organization has no `tenantId` to + // scope this read by, while its own reads are walled: it reads nothing, and + // every stored reference stays unresolved. + const caller = context as ExecutionContext | undefined; + const readsNothing = !!caller?.userId && !carriesOrganization(caller?.tenantId) + && postureUsesUnionScope(this.resolveEnginePosture()); const fields = (schema?.fields ?? {}) as Record; type Resolved = { @@ -7123,7 +7130,7 @@ export class ObjectQL implements IObjectQLEngine { if (value == null || Array.isArray(value) || typeof value === 'object') continue; ids.add(String(value)); } - if (ids.size === 0) { resolved.set(fk, { object: target, byId: new Map() }); continue; } + if (ids.size === 0 || readsNothing) { resolved.set(fk, { object: target, byId: new Map() }); continue; } try { // ⭐ SYSTEM authority, and ONLY for this seam. // diff --git a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts index dd16930f3a9..5b1cff8505e 100644 --- a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts +++ b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts @@ -77,7 +77,7 @@ afterEach(async () => { }); /** `kind` is org Y's row's value; org X holds one `secret` line of its own. */ -async function boot(kind: 'secret' | 'public') { +async function boot(kind: 'secret' | 'public', posture?: 'group') { const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); const engine = new ObjectQL(); engine.registerDriver(driver as never, true); @@ -95,6 +95,7 @@ async function boot(kind: 'secret' | 'public') { const services: Record = { 'org-scoping': { name: 'org-scoping' }, + ...(posture ? { tenancy: { posture } } : {}), manifest: { register: vi.fn() }, objectql: engine, metadata: { get: async (_t: string, name: string) => engine.getSchema(name) ?? null, list: async () => [MEMBER] }, @@ -129,23 +130,32 @@ async function boot(kind: 'secret' | 'public') { }); const stored = async (name: string) => (await table('qa_inspection').where({ name }).select('id')).length; - return { engine, readsOfLine, stored }; + return { engine, readsOfLine, stored, table }; } /** Everything the caller sees of one write and one preview naming `line`. */ -async function observe(kind: 'secret' | 'public', line: string) { - const h = await boot(kind); +async function observe(kind: 'secret' | 'public', line: string, caller: object = CALLER, posture?: 'group') { + const h = await boot(kind, posture); const refusal = await h.engine - .insert('qa_inspection', { name: 'probe', line }, { context: CALLER } as never) + .insert('qa_inspection', { name: 'probe', line }, { context: caller } as never) .then(() => null, (e: { code?: string; message?: string }) => ({ code: e.code, message: e.message })); const preview = await h.engine.validate( - 'qa_inspection', { name: 'probe', line }, { mode: 'insert', context: CALLER } as never, + 'qa_inspection', { name: 'probe', line }, { mode: 'insert', context: caller } as never, ); + // Under `group`, the by-id UPDATE door too: org X's own inspection repointed at `line`. + let update: unknown; + if (posture) { + await h.table('qa_inspection').insert([{ id: 'insp_x', name: 'seed', organization_id: 'org_x' }]); + update = await h.engine + .update('qa_inspection', { line }, { where: { id: 'insp_x' }, context: caller } as never) + .then(() => 'committed', (e: { code?: string; message?: string }) => ({ code: e.code, message: e.message })); + } return { seen: { refusal, committed: await h.stored('probe'), preview: { valid: preview.results?.[0]?.valid, errors: preview.results?.[0]?.errors?.map((e) => e.message) }, + update, }, readsOfLine: h.readsOfLine, }; @@ -173,3 +183,33 @@ describe('#18682 — the related read stays inside the caller’s organization', expect(own.readsOfLine.map((rows) => rows.length)).toEqual([1, 1]); }); }); + +/** + * Under `group` a member's reach is their membership set, so a member with no + * ACTIVE organization is admitted to the preview, yet carries no `tenantId` + * to scope the related read by. Such a caller gets no related read at all. + */ +describe('#18682 — under `group`, a member with no active organization reads nothing related', () => { + const ORGLESS_MEMBER = { userId: 'u_x', accessible_org_ids: ['org_x'], positions: [], permissions: [], posture: 'MEMBER' }; + + it('a reference to org Y’s row ends identically whether that row is secret or public', async () => { + const secret = await observe('secret', 'line_y', ORGLESS_MEMBER, 'group'); + const open = await observe('public', 'line_y', ORGLESS_MEMBER, 'group'); + + expect(open.seen).toEqual(secret.seen); + expect(secret.seen.committed).toBe(0); + expect(secret.seen.preview.valid).toBe(false); + expect(secret.seen.update).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(secret.readsOfLine).toEqual([]); + expect(open.readsOfLine).toEqual([]); + }); + + it('CONTROL: WITH an active organization, org X’s own secret line reaches the rule on every door', async () => { + const own = await observe('public', 'line_x', { ...ORGLESS_MEMBER, tenantId: 'org_x' }, 'group'); + + expect(own.seen.refusal).toEqual({ code: 'VALIDATION_FAILED', message: RULE_MESSAGE }); + expect(own.seen.preview).toEqual({ valid: false, errors: [RULE_MESSAGE] }); + expect(own.seen.update).toEqual({ code: 'VALIDATION_FAILED', message: RULE_MESSAGE }); + expect(own.readsOfLine.map((rows) => rows.length)).toEqual([1, 1, 1]); + }); +}); From 94bb92e0b38535d7a1f3c696844ed89fa6df399f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 10:11:48 +0000 Subject: [PATCH 40/47] docs(objectql,plugin-security): delete three false sentences - "it executes nothing" (the registerWriteGateProbe and canWriteObject docblocks): the preview issues a related read for an admitted caller. - The RelatedUnavailableReason docblock's opening map description: the declaration is a string union. - "at authoring time" in the changeset heading: the engine refuses the two shapes too, on paths that never run lint. Deletions only. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/18682-predicate-relationship-traversal.md | 2 +- packages/objectql/src/engine.ts | 4 ++-- packages/objectql/src/validation/rule-validator.ts | 4 +--- packages/plugins/plugin-security/src/security-plugin.ts | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index 3f90504421f..9f2c4cc5b77 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -63,7 +63,7 @@ they cannot see by observing which writes are refused. The value itself never appears — the refusal names the field and the rule, never the value — and the channel is deliberately no wider than "this rule refused this write". -### Two shapes are refused at authoring time, with a prescription +### Two shapes are refused, with a prescription Both fault at evaluation today, so neither removes anything that works: diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9a80376e4e4..2efcc376631 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3948,8 +3948,8 @@ export class ObjectQL implements IObjectQLEngine { * about a value they cannot read by observing which of THEIR WRITES are * refused. * - * `validate()` runs NO middleware for the target object, by design: it - * executes nothing. Its network ingress (the `dryRun` import) checks auth and + * `validate()` runs NO middleware for the target object, by design. + * Its network ingress (the `dryRun` import) checks auth and * API access but not the caller's CRUD grant on the object. So the elevated * read, wired into the preview without this gate, would hand the same * one-bit-per-row oracle to any authenticated caller with no permission on the diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 87b4422956b..3e12bf5d57a 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -536,9 +536,7 @@ export function collectPredicateRelationships( export type ParentBinding = Record | null | undefined; /** - * [#18682] Reference FIELD name → the related row, or `null` when it could not - * be read (no reference stored, the related record was not found, the related - * object declares no such column, or the read failed). They do NOT collapse: each names itself in + * [#18682] The reasons do NOT collapse: each names itself in * the refusal, because "there is no parent" and "that column does not exist" * send an author to different repairs. */ diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 9ade0730923..d359336843a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5101,7 +5101,7 @@ export class SecurityPlugin implements Plugin { * ## Why it exists * * `ObjectQL.validate()` is a write PREVIEW that runs no middleware for its - * target object, by design: it executes nothing. A validation rule that reads + * target object, by design. A validation rule that reads * one hop through a reference field is evaluated there against a related row * fetched under SYSTEM authority, and the accepted cost of that elevation is * an inference channel — so the preview asks this before it reads. What this From ada09f09532717f322845fd85466fc58a099d919 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 11:05:31 +0000 Subject: [PATCH 41/47] docs(objectql): shorten the group-posture gate comment to one line Comment only; the round's prose stays at net zero lines. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2efcc376631..7b62d496015 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7071,9 +7071,7 @@ export class ObjectQL implements IObjectQLEngine { if (this.buildReferentialFieldClear(context as ExecutionContext | undefined)) return unbound; const wanted = collectPredicateRelationships(schema); if (wanted.size === 0) return unbound; - // ⛔ Under `group` a USER caller with no active organization has no `tenantId` to - // scope this read by, while its own reads are walled: it reads nothing, and - // every stored reference stays unresolved. + // ⛔ `group`: a USER caller with no `tenantId` cannot be scoped here, so it reads nothing. const caller = context as ExecutionContext | undefined; const readsNothing = !!caller?.userId && !carriesOrganization(caller?.tenantId) && postureUsesUnionScope(this.resolveEnginePosture()); From 3b9c5f2fcaa647de39890e1d4697ee174a07079d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 11:52:04 +0000 Subject: [PATCH 42/47] fix(objectql): no related read for an org-less user under any walled posture The related-read gate covered `group` only. Under `isolated` the organization wall refuses an org-less writer only on an object it covers, so an org-less USER caller writing an object declared `tenancy: { enabled: false }` still reached a rule that traverses into a tenant-scoped object, and the related read, carrying no `tenantId`, read across organizations: insert, validate() and a by-id update answered differently for another organization's secret and public row. The gate now asks postureEnforcesWall (every posture but `single`). Pin: `isolated`, the real SecurityPlugin + SqlDriver, an org-less user writing a non-tenant object on insert / validate / by-id update, secret == public with zero related reads, and the active-organization control. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 6 +- ...redicate-related-read-tenant-scope.test.ts | 70 ++++++++++++++++--- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 7b62d496015..1248eaade00 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -153,7 +153,7 @@ import { isPlatformObjectOutOfTenantAuditScope } from './tenancy/platform-object import { resolveTenancyPosture } from '@objectstack/types'; import { normalizeTenancyPosture, - postureUsesUnionScope, + postureEnforcesWall, TenantLayer0VerdictSchema, type TenancyPosture, type TenantLayer0Verdict, @@ -7071,10 +7071,10 @@ export class ObjectQL implements IObjectQLEngine { if (this.buildReferentialFieldClear(context as ExecutionContext | undefined)) return unbound; const wanted = collectPredicateRelationships(schema); if (wanted.size === 0) return unbound; - // ⛔ `group`: a USER caller with no `tenantId` cannot be scoped here, so it reads nothing. + // ⛔ Walled posture: a USER caller with no `tenantId` cannot be scoped here, so it reads nothing. const caller = context as ExecutionContext | undefined; const readsNothing = !!caller?.userId && !carriesOrganization(caller?.tenantId) - && postureUsesUnionScope(this.resolveEnginePosture()); + && postureEnforcesWall(this.resolveEnginePosture()); const fields = (schema?.fields ?? {}) as Record; type Resolved = { diff --git a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts index 5b1cff8505e..d86bfaf75f3 100644 --- a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts +++ b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts @@ -49,6 +49,22 @@ const OBJECTS = [ message: RULE_MESSAGE, condition: "record.line.kind == 'secret'", }], }, + { + // The same rule on an object the organization wall does not cover. + name: 'qa_note', + label: 'Note', + sharingModel: 'public_read_write', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + line: { name: 'line', type: 'lookup', reference: 'qa_line' }, + }, + validations: [{ + name: 'no_secret_line', type: 'script', severity: 'error', + message: RULE_MESSAGE, condition: "record.line.kind == 'secret'", + }], + }, ]; const MEMBER: PermissionSet = { @@ -56,6 +72,7 @@ const MEMBER: PermissionSet = { label: 'Member', objects: { qa_inspection: { allowRead: true, allowCreate: true, allowEdit: true }, + qa_note: { allowRead: true, allowCreate: true, allowEdit: true }, qa_line: { allowRead: true }, }, } as unknown as PermissionSet; @@ -77,7 +94,7 @@ afterEach(async () => { }); /** `kind` is org Y's row's value; org X holds one `secret` line of its own. */ -async function boot(kind: 'secret' | 'public', posture?: 'group') { +async function boot(kind: 'secret' | 'public', posture?: 'group' | 'isolated') { const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); const engine = new ObjectQL(); engine.registerDriver(driver as never, true); @@ -129,31 +146,34 @@ async function boot(kind: 'secret' | 'public', posture?: 'group') { return rows; }); - const stored = async (name: string) => (await table('qa_inspection').where({ name }).select('id')).length; + const stored = async (object: string, name: string) => (await table(object).where({ name }).select('id')).length; return { engine, readsOfLine, stored, table }; } /** Everything the caller sees of one write and one preview naming `line`. */ -async function observe(kind: 'secret' | 'public', line: string, caller: object = CALLER, posture?: 'group') { +async function observe( + kind: 'secret' | 'public', line: string, caller: object = CALLER, + posture?: 'group' | 'isolated', object = 'qa_inspection', +) { const h = await boot(kind, posture); const refusal = await h.engine - .insert('qa_inspection', { name: 'probe', line }, { context: caller } as never) + .insert(object, { name: 'probe', line }, { context: caller } as never) .then(() => null, (e: { code?: string; message?: string }) => ({ code: e.code, message: e.message })); const preview = await h.engine.validate( - 'qa_inspection', { name: 'probe', line }, { mode: 'insert', context: caller } as never, + object, { name: 'probe', line }, { mode: 'insert', context: caller } as never, ); - // Under `group`, the by-id UPDATE door too: org X's own inspection repointed at `line`. + // Given a posture, the by-id UPDATE door too: a seeded row the caller may edit, repointed at `line`. let update: unknown; if (posture) { - await h.table('qa_inspection').insert([{ id: 'insp_x', name: 'seed', organization_id: 'org_x' }]); + await h.table(object).insert([{ id: 'row_x', name: 'seed', ...(object === 'qa_inspection' ? { organization_id: 'org_x' } : {}) }]); update = await h.engine - .update('qa_inspection', { line }, { where: { id: 'insp_x' }, context: caller } as never) + .update(object, { line }, { where: { id: 'row_x' }, context: caller } as never) .then(() => 'committed', (e: { code?: string; message?: string }) => ({ code: e.code, message: e.message })); } return { seen: { refusal, - committed: await h.stored('probe'), + committed: await h.stored(object, 'probe'), preview: { valid: preview.results?.[0]?.valid, errors: preview.results?.[0]?.errors?.map((e) => e.message) }, update, }, @@ -213,3 +233,35 @@ describe('#18682 — under `group`, a member with no active organization reads n expect(own.readsOfLine.map((rows) => rows.length)).toEqual([1, 1, 1]); }); }); + +/** + * Under `isolated` the organization wall refuses an org-less writer only on an + * object it covers. On an object declared `tenancy: { enabled: false }` the + * write is admitted, and its rule's related read has no `tenantId` to scope by: + * such a caller gets no related read at all. + */ +describe('#18682 — under `isolated`, an org-less writer of an unwalled object reads nothing related', () => { + const ORGLESS_USER = { userId: 'u_x', positions: [], permissions: [], posture: 'MEMBER' }; + + it('a reference to org Y’s row ends identically whether that row is secret or public', async () => { + const secret = await observe('secret', 'line_y', ORGLESS_USER, 'isolated', 'qa_note'); + const open = await observe('public', 'line_y', ORGLESS_USER, 'isolated', 'qa_note'); + + expect(open.seen).toEqual(secret.seen); + expect(secret.seen.refusal?.code).toBe('VALIDATION_FAILED'); + expect(secret.seen.committed).toBe(0); + expect(secret.seen.preview.valid).toBe(false); + expect(secret.seen.update).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(secret.readsOfLine).toEqual([]); + expect(open.readsOfLine).toEqual([]); + }); + + it('CONTROL: WITH an active organization, org X’s own secret line reaches the rule on every door', async () => { + const own = await observe('public', 'line_x', { ...ORGLESS_USER, tenantId: 'org_x' }, 'isolated', 'qa_note'); + + expect(own.seen.refusal).toEqual({ code: 'VALIDATION_FAILED', message: RULE_MESSAGE }); + expect(own.seen.preview).toEqual({ valid: false, errors: [RULE_MESSAGE] }); + expect(own.seen.update).toEqual({ code: 'VALIDATION_FAILED', message: RULE_MESSAGE }); + expect(own.readsOfLine.map((rows) => rows.length)).toEqual([1, 1, 1]); + }); +}); From 1d6866b262ed97ed7572550d062f4f03e90bef5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:19:10 +0000 Subject: [PATCH 43/47] fix(objectql): a related row outside every organization wall is answered by the caller's own read A validation rule reading one hop through a reference field reads the related row under system authority, scoped by the caller's organization. A related object with no tenant column (sys_user), `tenancy.enabled: false` or `external` is not scoped by any organization, so a rule could be evaluated on a user the caller cannot read, and its pass/fail revealed that user's field. For a user caller, such a related object's rows are now the ones the caller's OWN read returns, through every enforcement layer (CRUD, row-level security, sharing). Any other stored id binds as 'unreadable' and the rule refuses the write with the not-readable prescription; it is never evaluated on that row and its columns are never read. Tenant-scoped related objects and system callers are unchanged. Pinned on the real SecurityPlugin + SqlDriver with the shipped member_default sys_user wall, on insert, validate() and update. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- packages/objectql/src/engine.ts | 28 ++++-- .../objectql/src/validation/rule-validator.ts | 4 +- ...redicate-related-read-tenant-scope.test.ts | 88 +++++++++++++++++-- 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a36c37fde57..c411750887a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7274,12 +7274,13 @@ export class ObjectQL implements IObjectQLEngine { * * 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. + * fields. ⛔ Never the whole row. On a related object no organization wall + * scopes, a user caller's own read of it also bounds the ROWS. * * ## An unresolved row is left UNAVAILABLE, and it says which kind * * No reference stored, the related record not found, the related object - * declares no such column, or the read failed: each is its own reason, and + * declares no such column, or the row could not be read: each is its own reason, and * {@link checkPredicate} turns it into a refusal naming the related object and * column. The write is REJECTED rather than judged on a rule that produced no * verdict. ⛔ Never silently true, and never silently false. @@ -7322,6 +7323,8 @@ export class ObjectQL implements IObjectQLEngine { undeclared?: string[]; /** Set when the read itself failed, whatever the id. */ blocked?: boolean; + /** The ids the caller's OWN read returned, when that read decides (below). */ + ownRead?: ReadonlySet; }; const resolved = new Map(); @@ -7336,7 +7339,7 @@ export class ObjectQL implements IObjectQLEngine { // object does not declare must not put that name into a system-authority // query: the read is elevated. An undeclared name is also a real // authoring fault and is reported as one rather than silently dropped. - const targetSchema = this._registry.getObject(target) as { fields?: Record } | undefined; + const targetSchema = this._registry.getObject(target) as { fields?: Record; external?: unknown } | undefined; const declared = targetSchema?.fields; // [#8215] The PRIMARY KEY is declared by the platform, not by the author, // so it is absent from every object's field map — the map carries the @@ -7387,8 +7390,21 @@ export class ObjectQL implements IObjectQLEngine { // 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. + // + // ⛔ No organization wall scopes a related object with no tenant column + // (e.g. `sys_user`), `tenancy.enabled: false` or `external`, so for a + // USER caller its rows are the ones the caller's OWN read returns, through + // every enforcement layer; any other id is 'unreadable', stored or not. + let ownRead: Set | undefined; + if (caller?.userId && (targetSchema?.external != null || 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; } + } const query: EngineQueryOptions = { - where: { id: { $in: [...ids] } }, + where: { id: { $in: [...(ownRead ?? ids)] } }, fields: [...new Set(['id', ...named])], context: { ...(context as Record ?? {}), isSystem: true } as EngineQueryOptions['context'], }; @@ -7406,7 +7422,7 @@ export class ObjectQL implements IObjectQLEngine { for (const name of named) if (!(name in copy)) copy[name] = null; byId.set(String(row.id), copy); } - resolved.set(fk, { object: target, byId }); + resolved.set(fk, { object: target, byId, ownRead }); } catch (err) { this.logger?.warn?.('predicate relationship read failed — the rule will reject the write', { object: target, field: fk, error: err, @@ -7433,7 +7449,7 @@ export class ObjectQL implements IObjectQLEngine { const found = entry.byId.get(String(value)); binding[fk] = found ? { object: entry.object, row: found } - : { object: entry.object, unavailable: 'unresolved' }; + : { object: entry.object, unavailable: entry.ownRead ? 'unreadable' : 'unresolved' }; } return binding; }; diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index d50737a9eec..f9ab07dcc8f 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -543,7 +543,7 @@ export type ParentBinding = Record | null | undefined; export type RelatedUnavailableReason = /** The record stores no reference — the FK is null/empty, so there is no row. */ | 'no-reference' - /** The related read failed outright. */ + /** The related read failed, or — for a related object no organization wall scopes — the caller's own read does not return the row. */ | 'unreadable' /** * The predicate names a column the RELATED object does not declare. A real @@ -3343,7 +3343,7 @@ function traversalRefusal( return { summary: `could not read '${binding.object}'`, detail: - ` The rule reads ${columns} through ${on}, and that read failed. The rule has no` + ` The rule reads ${columns} through ${on}, and that row could not be read. The rule has no` + ' verdict, so the write is rejected rather than allowed on an unchecked rule.', }; } diff --git a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts index d86bfaf75f3..ff09a3b8a13 100644 --- a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts +++ b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts @@ -21,7 +21,9 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import type { PermissionSet } from '@objectstack/spec/security'; +import { SysUser } from '@objectstack/platform-objects/identity'; import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; const RULE_MESSAGE = 'Inspections on a secret line are frozen.'; @@ -65,8 +67,27 @@ const OBJECTS = [ message: RULE_MESSAGE, condition: "record.line.kind == 'secret'", }], }, + SysUser, + { + // A rule reading through a `user` field into `sys_user`, which has no tenant column. + name: 'qa_review', + label: 'Review', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + reviewer: { name: 'reviewer', type: 'user' }, + }, + validations: [{ + name: 'no_banned_reviewer', type: 'script', severity: 'error', + message: RULE_MESSAGE, condition: 'record.reviewer.banned == true', + }], + }, ]; +/** The shipped `sys_user` wall: `member_default`'s grant and row-level security. */ +const SHIPPED_MEMBER = defaultPermissionSets.find((s) => s.name === 'member_default')!; + const MEMBER: PermissionSet = { name: 'member_default', label: 'Member', @@ -74,7 +95,10 @@ const MEMBER: PermissionSet = { qa_inspection: { allowRead: true, allowCreate: true, allowEdit: true }, qa_note: { allowRead: true, allowCreate: true, allowEdit: true }, qa_line: { allowRead: true }, + qa_review: { allowRead: true, allowCreate: true, allowEdit: true }, + sys_user: SHIPPED_MEMBER.objects.sys_user, }, + rowLevelSecurity: SHIPPED_MEMBER.rowLevelSecurity?.filter((p) => p.object === 'sys_user'), } as unknown as PermissionSet; /** The driver's own query builder, reached past its `protected` modifier. */ @@ -136,18 +160,26 @@ async function boot(kind: 'secret' | 'public', posture?: 'group' | 'isolated') { { id: 'line_x', kind: 'secret', organization_id: 'org_x' }, { id: 'line_y', kind, organization_id: 'org_y' }, ]); + // The caller u_x and its peer u_x2 (org X); u_y belongs to org Y only. + await table('sys_user').insert([ + { id: 'u_x', name: 'X', email: 'x@x.test', banned: false }, + { id: 'u_x2', name: 'X2', email: 'x2@x.test', banned: true }, + { id: 'u_y', name: 'Y', email: 'y@y.test', banned: kind === 'secret' }, + ]); // What the driver hands back for every read of the related object. const readsOfLine: unknown[][] = []; + const userColumnsRead: unknown[] = []; const find = driver.find.bind(driver); vi.spyOn(driver, 'find').mockImplementation(async (object, ast, options) => { const rows = await find(object, ast, options); if (object === 'qa_line') readsOfLine.push(rows as unknown[]); + if (object === 'sys_user') userColumnsRead.push(...((ast as { fields?: unknown[] }).fields ?? ['*'])); return rows; }); const stored = async (object: string, name: string) => (await table(object).where({ name }).select('id')).length; - return { engine, readsOfLine, stored, table }; + return { engine, readsOfLine, userColumnsRead, stored, table }; } /** Everything the caller sees of one write and one preview naming `line`. */ @@ -156,18 +188,19 @@ async function observe( posture?: 'group' | 'isolated', object = 'qa_inspection', ) { const h = await boot(kind, posture); + const ref = object === 'qa_review' ? 'reviewer' : 'line'; const refusal = await h.engine - .insert(object, { name: 'probe', line }, { context: caller } as never) + .insert(object, { name: 'probe', [ref]: line }, { context: caller } as never) .then(() => null, (e: { code?: string; message?: string }) => ({ code: e.code, message: e.message })); const preview = await h.engine.validate( - object, { name: 'probe', line }, { mode: 'insert', context: caller } as never, + object, { name: 'probe', [ref]: line }, { mode: 'insert', context: caller } as never, ); // Given a posture, the by-id UPDATE door too: a seeded row the caller may edit, repointed at `line`. let update: unknown; if (posture) { - await h.table(object).insert([{ id: 'row_x', name: 'seed', ...(object === 'qa_inspection' ? { organization_id: 'org_x' } : {}) }]); + await h.table(object).insert([{ id: 'row_x', name: 'seed', ...(object !== 'qa_note' ? { organization_id: 'org_x' } : {}) }]); update = await h.engine - .update(object, { line }, { where: { id: 'row_x' }, context: caller } as never) + .update(object, { [ref]: line }, { where: { id: 'row_x' }, context: caller } as never) .then(() => 'committed', (e: { code?: string; message?: string }) => ({ code: e.code, message: e.message })); } return { @@ -178,6 +211,7 @@ async function observe( update, }, readsOfLine: h.readsOfLine, + userColumnsRead: h.userColumnsRead, }; } @@ -265,3 +299,47 @@ describe('#18682 — under `isolated`, an org-less writer of an unwalled object expect(own.readsOfLine.map((rows) => rows.length)).toEqual([1, 1, 1]); }); }); + +/** + * `sys_user` has no tenant column, so no organization wall scopes the related + * read; its own wall is the shipped `member_default` row-level security. A user + * the caller's own read of `sys_user` does not return is NOT READABLE: the rule + * faults loudly, is never evaluated on that user, and never reads its columns. + */ +describe('#18682 — a related user the caller cannot read makes the rule fault loudly', () => { + const PEERS = { ...CALLER, org_user_ids: ['u_x', 'u_x2'] }; + const NOT_READABLE = "could not read 'sys_user'"; + + it('CONTROL: the caller’s own read of sys_user returns org X’s users, never org Y’s', async () => { + const h = await boot('secret', 'isolated'); + const own = await h.engine.find('sys_user', { where: { id: { $in: ['u_x2', 'u_y'] } }, context: PEERS } as never); + + expect((own as Array<{ id: string }>).map((row) => row.id)).toEqual(['u_x2']); + }); + + it('a user only org Y holds: every door refuses identically, whatever that user’s value', async () => { + const banned = await observe('secret', 'u_y', PEERS, 'isolated', 'qa_review'); + const clear = await observe('public', 'u_y', PEERS, 'isolated', 'qa_review'); + + expect(clear.seen).toEqual(banned.seen); + for (const door of [banned.seen.refusal, banned.seen.update]) { + expect(door).toMatchObject({ code: 'VALIDATION_FAILED', message: expect.stringContaining(NOT_READABLE) }); + } + expect(banned.seen.committed).toBe(0); + expect(banned.seen.preview).toEqual({ valid: false, errors: [expect.stringContaining(NOT_READABLE)] }); + expect(banned.userColumnsRead).not.toContain('banned'); + }); + + it('CONTROL: a user in org X — the rule evaluates on every door, in both directions', async () => { + const peer = await observe('public', 'u_x2', PEERS, 'isolated', 'qa_review'); + const self = await observe('public', 'u_x', PEERS, 'isolated', 'qa_review'); + + expect(peer.seen.refusal).toEqual({ code: 'VALIDATION_FAILED', message: RULE_MESSAGE }); + expect(peer.seen.preview).toEqual({ valid: false, errors: [RULE_MESSAGE] }); + expect(peer.seen.update).toEqual({ code: 'VALIDATION_FAILED', message: RULE_MESSAGE }); + expect(peer.userColumnsRead).toContain('banned'); + expect(self.seen).toEqual({ + refusal: null, committed: 1, preview: { valid: true, errors: [] }, update: 'committed', + }); + }); +}); From 1e487ef1108a7eb6fd52cf173ddf80f364e307f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:22:42 +0000 Subject: [PATCH 44/47] docs(changeset): state the row bound on a related object no organization wall scopes Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- .changeset/18682-predicate-relationship-traversal.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index 9f2c4cc5b77..752f5d00777 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -58,6 +58,12 @@ fields. A column the related object does not declare never enters the query, and is refused as the authoring fault it is — distinct from a column that exists and is empty, which evaluates as `null`. +A related object no organization wall scopes — no tenant column (`sys_user` +behind a `user` field), `tenancy.enabled: false`, or `external` — is bounded by +row as well: for a user caller, only a row the caller's own read of that object +returns. A reference to any other row refuses the write as not readable, +whatever that row holds. + ⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value they cannot see by observing which writes are refused. The value itself never appears — the refusal names the field and the rule, never the value — and the From 11ddc2dc0a81288226334271759533593c589eac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:22:11 +0000 Subject: [PATCH 45/47] fix(objectql): bound a traversing rule's related read for every caller that is not system Both bounds in resolvePredicateRelated keyed on `caller.userId`, so a caller that is neither system nor a user (the public-form submitter, a principal-less context) got the bare system read, bounded by neither organization nor row. They now key on "not isSystem": under a walled posture such a caller with no organization reads nothing, and on a related object no organization wall scopes it is bound by its own read. System callers are unchanged. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- packages/objectql/src/engine.ts | 15 ++++--- ...redicate-related-read-tenant-scope.test.ts | 45 ++++++++++++++++++- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index c411750887a..0fb7caf21f3 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7275,7 +7275,7 @@ export class ObjectQL implements IObjectQLEngine { * 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, a user caller's own read of it also bounds the ROWS. + * scopes, the own read of any caller that is not SYSTEM also bounds the ROWS. * * ## An unresolved row is left UNAVAILABLE, and it says which kind * @@ -7310,9 +7310,12 @@ export class ObjectQL implements IObjectQLEngine { if (this.buildReferentialFieldClear(context as ExecutionContext | undefined)) return unbound; const wanted = collectPredicateRelationships(schema); if (wanted.size === 0) return unbound; - // ⛔ Walled posture: a USER caller with no `tenantId` cannot be scoped here, so it reads nothing. + // ⛔ Every bound below keys on "not SYSTEM", never on `userId`: a public-form + // submitter or a principal-less caller is bound exactly like a user. Walled + // posture: such a caller with no `tenantId` cannot be scoped here, so it reads nothing. const caller = context as ExecutionContext | undefined; - const readsNothing = !!caller?.userId && !carriesOrganization(caller?.tenantId) + const bound = !caller?.isSystem; + const readsNothing = bound && !carriesOrganization(caller?.tenantId) && postureEnforcesWall(this.resolveEnginePosture()); const fields = (schema?.fields ?? {}) as Record; @@ -7393,10 +7396,10 @@ export class ObjectQL implements IObjectQLEngine { // // ⛔ No organization wall scopes a related object with no tenant column // (e.g. `sys_user`), `tenancy.enabled: false` or `external`, so for a - // USER caller its rows are the ones the caller's OWN read returns, through - // every enforcement layer; any other id is 'unreadable', stored or not. + // 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; - if (caller?.userId && (targetSchema?.external != null || resolveTenantFieldName(targetSchema) === null)) { + if (bound && (targetSchema?.external != null || resolveTenantFieldName(targetSchema) === null)) { const own = await this.find(target, { where: { id: { $in: [...ids] } }, fields: ['id'], context: caller as EngineQueryOptions['context'], }) as Array>; diff --git a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts index ff09a3b8a13..cd431de3179 100644 --- a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts +++ b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts @@ -118,7 +118,7 @@ afterEach(async () => { }); /** `kind` is org Y's row's value; org X holds one `secret` line of its own. */ -async function boot(kind: 'secret' | 'public', posture?: 'group' | 'isolated') { +async function boot(kind: 'secret' | 'public', posture?: 'single' | 'group' | 'isolated') { const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); const engine = new ObjectQL(); engine.registerDriver(driver as never, true); @@ -185,7 +185,7 @@ async function boot(kind: 'secret' | 'public', posture?: 'group' | 'isolated') { /** Everything the caller sees of one write and one preview naming `line`. */ async function observe( kind: 'secret' | 'public', line: string, caller: object = CALLER, - posture?: 'group' | 'isolated', object = 'qa_inspection', + posture?: 'single' | 'group' | 'isolated', object = 'qa_inspection', ) { const h = await boot(kind, posture); const ref = object === 'qa_review' ? 'reviewer' : 'line'; @@ -343,3 +343,44 @@ describe('#18682 — a related user the caller cannot read makes the rule fault }); }); }); + +/** + * A caller that is not SYSTEM is bound whether or not it carries a `userId`: the + * public-form submitter (the grant the REST form route builds) and a + * principal-less context (the middleware's fall-open) included. + */ +describe('#18682 — a caller with no userId that is not system is bound like a user', () => { + const PUBLIC_FORM = (object: string) => ({ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }); + + it('org Y’s line: every door refuses identically, whatever that row holds', async () => { + for (const caller of [PUBLIC_FORM('qa_note'), { positions: [], permissions: [] }]) { + const secret = await observe('secret', 'line_y', caller, 'isolated', 'qa_note'); + const open = await observe('public', 'line_y', caller, 'isolated', 'qa_note'); + + expect(open.seen).toEqual(secret.seen); + expect(secret.seen.refusal?.code).toBe('VALIDATION_FAILED'); + expect(secret.seen.committed).toBe(0); + expect(secret.seen.preview.valid).toBe(false); + expect(secret.readsOfLine).toEqual([]); + } + }); + + it('a user only org Y holds, under no wall: every door refuses as not readable, whatever its value', async () => { + const banned = await observe('secret', 'u_y', PUBLIC_FORM('qa_review'), 'single', 'qa_review'); + const clear = await observe('public', 'u_y', PUBLIC_FORM('qa_review'), 'single', 'qa_review'); + + expect(clear.seen).toEqual(banned.seen); + expect(banned.seen.refusal).toMatchObject({ code: 'VALIDATION_FAILED', message: expect.stringContaining("could not read 'sys_user'") }); + expect(banned.seen.committed).toBe(0); + expect(banned.seen.preview).toEqual({ valid: false, errors: [expect.stringContaining("could not read 'sys_user'")] }); + expect(banned.userColumnsRead).not.toContain('banned'); + }); + + it('CONTROL: a system caller still evaluates the rule on org Y’s line, in both directions', async () => { + const secret = await observe('secret', 'line_y', { isSystem: true }, 'isolated', 'qa_note'); + const open = await observe('public', 'line_y', { isSystem: true }, 'isolated', 'qa_note'); + + expect(secret.seen).toMatchObject({ refusal: { message: RULE_MESSAGE }, committed: 0, update: { message: RULE_MESSAGE } }); + expect(open.seen).toMatchObject({ refusal: null, committed: 1, preview: { valid: true }, update: 'committed' }); + }); +}); From 5b11234f948224d5ef1dadf6edab26171b4922ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:23:24 +0000 Subject: [PATCH 46/47] docs(changeset): name every caller the row bound holds, and the org-less "not found" case The changeset said the own-read row bound applies to "a user caller"; it applies to every caller that is not system. It now also states that under a walled posture an org-less caller gets no related read and is refused as not found. The exported RelatedFieldBinding docblock names the row bound beside the projection, and `row` no longer claims an FLS-filtered ("readable") set. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- .changeset/18682-predicate-relationship-traversal.md | 9 ++++++--- packages/objectql/src/validation/rule-validator.ts | 8 +++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md index 752f5d00777..a019ae301a0 100644 --- a/.changeset/18682-predicate-relationship-traversal.md +++ b/.changeset/18682-predicate-relationship-traversal.md @@ -60,9 +60,12 @@ is empty, which evaluates as `null`. A related object no organization wall scopes — no tenant column (`sys_user` behind a `user` field), `tenancy.enabled: false`, or `external` — is bounded by -row as well: for a user caller, only a row the caller's own read of that object -returns. A reference to any other row refuses the write as not readable, -whatever that row holds. +row as well: for any caller that is not system (a user, a public-form +submitter, a caller with no principal), only a row the caller's own read of that +object returns. A reference to any other row refuses the write as not readable, +whatever that row holds. Under a walled posture (`group` or `isolated`), such a +caller with no active organization gets no related read at all: a rule reading +through a stored reference refuses the write as not found. ⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value they cannot see by observing which writes are refused. The value itself never diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index f9ab07dcc8f..f9158659c4b 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -577,7 +577,9 @@ export type RelatedUnavailableReason = * ⚠️ The related row is read under SYSTEM authority — a validation rule's output * is a pass/fail the system enforces, not data handed to the caller. The read is * bounded by its PROJECTION (only the columns the predicate names, intersected - * with the related object's declared fields). ⛔ This + * with the related object's declared fields) and, on a related object no + * organization wall scopes, by the ROWS the own read of a caller that is not + * system returns. ⛔ This * applies to validation rules alone; RLS and UI predicates are out of the * capability entirely. */ @@ -585,8 +587,8 @@ export interface RelatedFieldBinding { /** The object this reference field points at — named in the refusal text. */ readonly object: string; /** - * The related row, materialised to `null` over the readable declared fields - * the predicate names. Present iff the row is usable. + * The related row, materialised to `null` over the declared fields the + * predicate names. Present iff the row is usable. */ readonly row?: Record; /** Why `row` is absent. Present iff `row` is absent. */ From 3e8b3c3a22a6a0b67e77668d9220d6cf7cce0c31 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:35:20 +0000 Subject: [PATCH 47/47] docs(permissions): census the not-system read that now bounds a traversing rule's related read `resolvePredicateRelated` reads `ExecutionContext.isSystem` to decide which callers its two bounds hold, so it is an elevation read site: row 29b anchors it, and the declared counts move 111 -> 112 (regenerated by check-system-context-census --fix). Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 3258f1ce7dd..05375ab208e 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -9,7 +9,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **111 +because the flag is not one concept: it is a single boolean read at **112 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -130,11 +130,12 @@ 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` | | 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` | ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 111 sites**. +The largest single consumer — **17 of the 112 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -280,7 +281,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 111 read sites +- **Shipped semantics.** `isSystem` is a published contract with 112 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -354,16 +355,16 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 23 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 117 | ✅ | +| — parsed as a property **read** | 118 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **111** | ✅ | -| — behaviour-bearing (rows 1–63 above) | 107 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **112** | ✅ | +| — behaviour-bearing (rows 1–63 above) | 108 | ✅ | | — carry the flag onward only (rows 64–67 above) | 4 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 45 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 92 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 93 | ✅ | | — of those files, the ones holding more than one read in one symbol | 9 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were