From cc0d1ed1c249678d2ea4484eeb9de9436150d550 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 13:31:42 +0000 Subject: [PATCH] feat(objectql): enforce per-option visibleWhen server-side (objectui#2284) A select/multiselect/radio option may gate itself with a `visibleWhen` CEL predicate. Client-side hiding is UX, not a security boundary, so on write the engine now re-evaluates the picked value's predicate against the merged record + `current_user` and rejects a clean FALSE (`invalid_option`). Enforces both role/context gating (`'admin' in current_user.roles`) and cascade integrity (`record.country == 'cn'`) that a caller could bypass by submitting a hidden value directly. - rule-validator: `evaluateOptionVisibility` checks only WRITTEN choice fields (multi-select element-wise); fail-open on an unevaluable predicate (missing field / unbound current_user) so cascade predicates never brick a write. `needsPriorRecord` now accounts for option visibleWhen so a cascade predicate can read an unchanged sibling from the prior record on update. - engine: bind `current_user` (ADR-0068 EvalUser) from the execution context at the insert/update validation call sites. - 15 new unit tests (cascade insert/update, role gating, fail-open, multiselect). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019aYFm77ddLp6tzuJjt9hK2 --- .../select-option-server-enforcement.md | 23 +++ packages/objectql/src/engine.ts | 23 ++- .../rule-validator.option-visibility.test.ts | 140 ++++++++++++++++++ .../objectql/src/validation/rule-validator.ts | 101 ++++++++++++- 4 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 .changeset/select-option-server-enforcement.md create mode 100644 packages/objectql/src/validation/rule-validator.option-visibility.test.ts diff --git a/.changeset/select-option-server-enforcement.md b/.changeset/select-option-server-enforcement.md new file mode 100644 index 0000000000..371b0e2afa --- /dev/null +++ b/.changeset/select-option-server-enforcement.md @@ -0,0 +1,23 @@ +--- +'@objectstack/objectql': minor +--- + +Enforce per-option `visibleWhen` server-side (objectui#2284). + +A `select`/`multiselect`/`radio` option may gate itself with a `visibleWhen` CEL +predicate. Client-side hiding is UX, not a security boundary, so on write the +engine now re-evaluates the picked value's predicate against the merged record + +`current_user` and rejects a clean FALSE (`invalid_option`). This enforces both +role/context gating (`'admin' in current_user.roles`) and cascade integrity +(`record.country == 'cn'`) that a caller could otherwise bypass by submitting a +hidden value directly. + +- Only WRITTEN choice fields are checked; an unchanged persisted value is left + alone. Multi-select values are checked element-wise. +- A predicate that can't be evaluated (missing referenced field, or an unbound + `current_user` on a system write) is fail-open — matching every other + field-level rule — so broken cascade predicates never brick a write. + Authorization gating relies on the engine binding `current_user`, which it now + does from the execution context on authenticated insert/update. +- `needsPriorRecord` accounts for option `visibleWhen` so a cascade predicate can + read an unchanged sibling from the prior record on update. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index ab0ba62417..79e65ccaf6 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -693,6 +693,23 @@ export class ObjectQL implements IDataEngine { } as HookContext['session']; } + /** + * Build the acting-user object (ADR-0068 EvalUser shape) surfaced to + * validation-time predicates as `current_user` — notably per-option + * `visibleWhen` authorization gating (objectui#2284). Returns undefined for + * system / unauthenticated writes, where role predicates then fail-open. + */ + private buildEvalUser( + execCtx?: ExecutionContext, + ): { id: string; roles: string[]; organizationId: string | null } | undefined { + if (!execCtx || execCtx.userId == null) return undefined; + return { + id: String(execCtx.userId), + roles: execCtx.roles ?? [], + organizationId: execCtx.tenantId != null ? String(execCtx.tenantId) : null, + }; + } + /** * Build the DriverOptions blob passed to every IDataDriver call. * @@ -2148,7 +2165,7 @@ export class ObjectQL implements IDataEngine { for (const r of rows) { normalizeMultiValueFields(schemaForValidation, r); validateRecord(schemaForValidation, r, 'insert'); - evaluateValidationRules(schemaForValidation as any, r, 'insert', { logger: this.logger }); + evaluateValidationRules(schemaForValidation as any, r, 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) }); } if (driver.bulkCreate) { result = await driver.bulkCreate(object, rows, hookContext.input.options as any); @@ -2167,7 +2184,7 @@ export class ObjectQL implements IDataEngine { await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options); normalizeMultiValueFields(schemaForValidation, row); validateRecord(schemaForValidation, row, 'insert'); - evaluateValidationRules(schemaForValidation as any, row, 'insert', { logger: this.logger }); + evaluateValidationRules(schemaForValidation as any, row, 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) }); result = await driver.create(object, row, hookContext.input.options as any); } @@ -2292,7 +2309,7 @@ export class ObjectQL implements IDataEngine { // field is read-only for this record's state, so the incoming // change is ignored (the persisted value is kept). hookContext.input.data = stripReadonlyWhenFields(updateSchema as any, hookContext.input.data as Record, priorRecord, this.logger) as any; - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) }); result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); } else if (options?.multi && driver.updateMany) { await this.encryptSecretFields(object, hookContext.input.data as Record, opCtx.context, hookContext.input.options); diff --git a/packages/objectql/src/validation/rule-validator.option-visibility.test.ts b/packages/objectql/src/validation/rule-validator.option-visibility.test.ts new file mode 100644 index 0000000000..372b12fad6 --- /dev/null +++ b/packages/objectql/src/validation/rule-validator.option-visibility.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Server-side per-option `visibleWhen` enforcement (objectui#2284). + * + * A select/multiselect/radio option may gate itself with a `visibleWhen` CEL + * predicate. Client-side hiding is UX only, so on write the engine re-evaluates + * the picked value's predicate against the merged record + `current_user` and + * rejects a clean FALSE — enforcing both cascade integrity (country → province) + * and role/context gating. Broken/unbound predicates fail-open. + */ +import { describe, it, expect } from 'vitest'; +import { evaluateValidationRules, needsPriorRecord } from './rule-validator.js'; +import { ValidationError } from './record-validator.js'; + +// country → province cascade + a role-gated tier option. +const schema = { + fields: { + country: { type: 'select', options: [{ value: 'cn' }, { value: 'us' }] }, + province: { + type: 'select', + options: [ + { value: 'zj', visibleWhen: "record.country == 'cn'" }, + { value: 'ca', visibleWhen: "record.country == 'us'" }, + { value: 'other' }, // ungated — always allowed + ], + }, + tier: { + type: 'select', + options: [ + { value: 'standard' }, + { value: 'admin_only', visibleWhen: "'admin' in current_user.roles" }, + ], + }, + }, +}; + +describe('per-option visibleWhen — cascade enforcement (insert)', () => { + it('rejects a province that does not match the chosen country', () => { + expect(() => evaluateValidationRules(schema, { country: 'us', province: 'zj' }, 'insert')).toThrow( + ValidationError, + ); + }); + it('accepts a province valid for the country', () => { + expect(() => evaluateValidationRules(schema, { country: 'cn', province: 'zj' }, 'insert')).not.toThrow(); + }); + it('accepts an ungated option regardless of the parent', () => { + expect(() => evaluateValidationRules(schema, { country: 'us', province: 'other' }, 'insert')).not.toThrow(); + }); + it('leaves an unknown value to the enum validator (no visibleWhen match)', () => { + expect(() => evaluateValidationRules(schema, { country: 'cn', province: 'zzz' }, 'insert')).not.toThrow(); + }); +}); + +describe('per-option visibleWhen — cascade enforcement (update, merged record)', () => { + it('rejects using the prior country when the patch omits it', () => { + expect(() => + evaluateValidationRules(schema, { province: 'zj' }, 'update', { previous: { country: 'us' } }), + ).toThrow(ValidationError); + }); + it('accepts using the prior country when it matches', () => { + expect(() => + evaluateValidationRules(schema, { province: 'zj' }, 'update', { previous: { country: 'cn' } }), + ).not.toThrow(); + }); + it('does not check a field the patch never wrote', () => { + // province persisted as 'zj' but country now 'us'; patch touches only `note`. + expect(() => + evaluateValidationRules(schema, { note: 'x' } as any, 'update', { + previous: { country: 'us', province: 'zj' }, + }), + ).not.toThrow(); + }); +}); + +describe('per-option visibleWhen — role gating', () => { + it('rejects an admin-only value for a non-admin', () => { + expect(() => + evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', { + currentUser: { id: 'u1', roles: ['sales'] }, + }), + ).toThrow(ValidationError); + }); + it('accepts an admin-only value for an admin', () => { + expect(() => + evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', { + currentUser: { id: 'u1', roles: ['admin'] }, + }), + ).not.toThrow(); + }); + it('accepts the ungated standard value for anyone', () => { + expect(() => + evaluateValidationRules(schema, { tier: 'standard' }, 'insert', { + currentUser: { id: 'u1', roles: ['sales'] }, + }), + ).not.toThrow(); + }); + it('fails open when current_user is unbound (system write) — predicate faults', () => { + // `'admin' in current_user.roles` faults with no bound user → allowed through. + // Authorization gating therefore requires the engine to bind current_user. + expect(() => evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert')).not.toThrow(); + }); +}); + +describe('per-option visibleWhen — multi-select element-wise', () => { + const multi = { + fields: { + country: { type: 'select', options: [{ value: 'cn' }, { value: 'us' }] }, + provinces: { + type: 'multiselect', + options: [ + { value: 'zj', visibleWhen: "record.country == 'cn'" }, + { value: 'gd', visibleWhen: "record.country == 'cn'" }, + { value: 'ca', visibleWhen: "record.country == 'us'" }, + ], + }, + }, + }; + it('rejects when any selected element is invalid for the parent', () => { + expect(() => evaluateValidationRules(multi, { country: 'cn', provinces: ['zj', 'ca'] }, 'insert')).toThrow( + ValidationError, + ); + }); + it('accepts when every selected element is valid', () => { + expect(() => + evaluateValidationRules(multi, { country: 'cn', provinces: ['zj', 'gd'] }, 'insert'), + ).not.toThrow(); + }); +}); + +describe('needsPriorRecord accounts for option visibleWhen', () => { + it('is true when a choice field has a gated option (cascade may reference a prior sibling)', () => { + expect(needsPriorRecord(schema)).toBe(true); + }); + it('is false for plain option fields with no visibleWhen', () => { + expect(needsPriorRecord({ fields: { color: { type: 'select', options: [{ value: 'r' }, { value: 'b' }] } } })).toBe( + false, + ); + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 5f8a0c9751..a950e9693b 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -141,6 +141,14 @@ export interface EvaluateRulesOptions { previous?: Record | null; /** Optional logger for non-blocking diagnostics (broken rules, warnings). */ logger?: { warn?: (msg: string, meta?: any) => void }; + /** + * The acting user (ADR-0068 EvalUser shape), surfaced to per-option + * `visibleWhen` predicates as `current_user` so role/context-gated options can + * be enforced server-side (objectui#2284). Absent on system/unauthenticated + * writes — role predicates then reference an unbound `current_user`, fault, + * and fail-open (see {@link evaluateOptionVisibility}). + */ + currentUser?: { id?: string; roles?: string[]; organizationId?: string | null; [k: string]: unknown } | null; } /** @@ -215,10 +223,29 @@ function ruleNeedsPrior(r: unknown): boolean { /** Field-level conditional rules (B2): a field is required / read-only when its * CEL predicate is TRUE over the record. */ +interface ConditionalFieldOption { + value?: unknown; + /** Per-option visibility predicate (CEL) — objectui#2284. */ + visibleWhen?: string | Expression; +} + interface ConditionalFieldDef { requiredWhen?: string | Expression; conditionalRequired?: string | Expression; // back-compat alias of requiredWhen readonlyWhen?: string | Expression; + /** Field type — scopes per-option `visibleWhen` enforcement to choice fields. */ + type?: string; + /** Select/multiselect/radio options; an option may gate itself with `visibleWhen`. */ + options?: Array; +} + +/** Choice fields whose picked value(s) are drawn from `options`. */ +const CHOICE_FIELD_TYPES = new Set(['select', 'multiselect', 'radio']); + +/** True when a choice field carries at least one option gated by `visibleWhen`. */ +function fieldHasOptionVisibility(def: ConditionalFieldDef | undefined | null): boolean { + if (!def || !CHOICE_FIELD_TYPES.has(String(def.type))) return false; + return Array.isArray(def.options) && def.options.some((o) => o != null && o.visibleWhen != null); } function isMissing(v: unknown): boolean { @@ -226,11 +253,13 @@ function isMissing(v: unknown): boolean { } /** True when any field declares a conditional rule that needs the merged/prior - * record to evaluate (so the engine fetches `previous` on update). */ + * record to evaluate (so the engine fetches `previous` on update). Per-option + * `visibleWhen` counts too — a cascade predicate can reference an unchanged + * sibling that only `previous` supplies (objectui#2284). */ function fieldsNeedPrior(fields: Record | undefined): boolean { if (!fields) return false; return Object.values(fields).some( - (f) => f && (f.requiredWhen || f.conditionalRequired || f.readonlyWhen), + (f) => f && (f.requiredWhen || f.conditionalRequired || f.readonlyWhen || fieldHasOptionVisibility(f)), ); } @@ -239,6 +268,68 @@ function toExpression(cond: string | Expression): Expression { return typeof cond === 'string' ? { dialect: 'cel', source: cond } : cond; } +/** + * Per-option authorization / cascade enforcement (objectui#2284). + * + * A `select` / `multiselect` / `radio` option may gate itself with a + * `visibleWhen` CEL predicate, evaluated against the live record + `current_user` + * (the same predicate the client uses to hide the option). Client-side hiding is + * UX, not a security boundary — a caller can still submit a hidden value — so on + * write we re-evaluate the predicate for the *picked* value(s) and reject any + * that resolve cleanly to FALSE. This enforces both role/context gating + * (`'admin' in current_user.roles`) and cascade integrity (`record.country == + * 'cn'`), server-side. + * + * Only WRITTEN fields are checked — an unchanged persisted value is left alone. + * A predicate that can't be evaluated (missing referenced field, unbound + * `current_user` on a system write) is **fail-open** (logged, allowed), matching + * every other field rule here: a broken cascade predicate must never brick a + * write. Authorization gating therefore depends on the engine binding + * `current_user` on authenticated writes. + */ +function evaluateOptionVisibility( + fields: Record | undefined, + data: Record, + merged: Record, + previous: Record | undefined, + currentUser: EvaluateRulesOptions['currentUser'], + errors: FieldValidationError[], + logger: EvaluateRulesOptions['logger'], +): void { + if (!fields) return; + const user = (currentUser ?? undefined) as any; + for (const [name, def] of Object.entries(fields)) { + if (!fieldHasOptionVisibility(def) || !(name in data)) continue; + const raw = data[name]; + if (raw === undefined || raw === null || raw === '') continue; + const picked = Array.isArray(raw) ? raw : [raw]; + for (const value of picked) { + const opt = def.options!.find((o) => o != null && o.value === value); + // Unknown value (not in options) or an ungated option → nothing to enforce + // here; an out-of-set value is the enum validator's concern, not ours. + if (!opt || opt.visibleWhen == null) continue; + const res = ExpressionEngine.evaluate(toExpression(opt.visibleWhen), { + record: merged, + previous, + user, + }); + if (!res.ok) { + logger?.warn?.( + `option visibleWhen for '${name}=${String(value)}' failed to evaluate — allowed through`, + ); + continue; // fail-open + } + if (res.value === false) { + errors.push({ + field: name, + code: 'invalid_option', + message: `${name}: option '${String(value)}' is not available`, + }); + } + } + } +} + /** * Evaluate an object's declared validation rules against an incoming write. * @@ -297,6 +388,12 @@ export function evaluateValidationRules( } } + // Per-option authorization / cascade gating (objectui#2284): reject a written + // choice value whose option `visibleWhen` resolves cleanly to FALSE against the + // merged record + `current_user`. Complements the client-side hiding, which is + // not a security boundary. + evaluateOptionVisibility(fields, data, merged, previous, opts.currentUser, errors, opts.logger); + const ordered = (hasRules ? rules! : []) .filter((r): r is BaseRule => r != null && typeof r === 'object') .filter((r) => r.active !== false)