Skip to content

Commit 5a0da03

Browse files
os-zhuangclaude
andauthored
feat(objectql): enforce per-option visibleWhen server-side (objectui#2284) (#2637)
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). Claude-Session: https://claude.ai/code/session_019aYFm77ddLp6tzuJjt9hK2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7eceec commit 5a0da03

4 files changed

Lines changed: 282 additions & 5 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
Enforce per-option `visibleWhen` server-side (objectui#2284).
6+
7+
A `select`/`multiselect`/`radio` option may gate itself with a `visibleWhen` CEL
8+
predicate. Client-side hiding is UX, not a security boundary, so on write the
9+
engine now re-evaluates the picked value's predicate against the merged record +
10+
`current_user` and rejects a clean FALSE (`invalid_option`). This enforces both
11+
role/context gating (`'admin' in current_user.roles`) and cascade integrity
12+
(`record.country == 'cn'`) that a caller could otherwise bypass by submitting a
13+
hidden value directly.
14+
15+
- Only WRITTEN choice fields are checked; an unchanged persisted value is left
16+
alone. Multi-select values are checked element-wise.
17+
- A predicate that can't be evaluated (missing referenced field, or an unbound
18+
`current_user` on a system write) is fail-open — matching every other
19+
field-level rule — so broken cascade predicates never brick a write.
20+
Authorization gating relies on the engine binding `current_user`, which it now
21+
does from the execution context on authenticated insert/update.
22+
- `needsPriorRecord` accounts for option `visibleWhen` so a cascade predicate can
23+
read an unchanged sibling from the prior record on update.

‎packages/objectql/src/engine.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,23 @@ export class ObjectQL implements IDataEngine {
693693
} as HookContext['session'];
694694
}
695695

696+
/**
697+
* Build the acting-user object (ADR-0068 EvalUser shape) surfaced to
698+
* validation-time predicates as `current_user` — notably per-option
699+
* `visibleWhen` authorization gating (objectui#2284). Returns undefined for
700+
* system / unauthenticated writes, where role predicates then fail-open.
701+
*/
702+
private buildEvalUser(
703+
execCtx?: ExecutionContext,
704+
): { id: string; roles: string[]; organizationId: string | null } | undefined {
705+
if (!execCtx || execCtx.userId == null) return undefined;
706+
return {
707+
id: String(execCtx.userId),
708+
roles: execCtx.roles ?? [],
709+
organizationId: execCtx.tenantId != null ? String(execCtx.tenantId) : null,
710+
};
711+
}
712+
696713
/**
697714
* Build the DriverOptions blob passed to every IDataDriver call.
698715
*
@@ -2148,7 +2165,7 @@ export class ObjectQL implements IDataEngine {
21482165
for (const r of rows) {
21492166
normalizeMultiValueFields(schemaForValidation, r);
21502167
validateRecord(schemaForValidation, r, 'insert');
2151-
evaluateValidationRules(schemaForValidation as any, r, 'insert', { logger: this.logger });
2168+
evaluateValidationRules(schemaForValidation as any, r, 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) });
21522169
}
21532170
if (driver.bulkCreate) {
21542171
result = await driver.bulkCreate(object, rows, hookContext.input.options as any);
@@ -2167,7 +2184,7 @@ export class ObjectQL implements IDataEngine {
21672184
await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
21682185
normalizeMultiValueFields(schemaForValidation, row);
21692186
validateRecord(schemaForValidation, row, 'insert');
2170-
evaluateValidationRules(schemaForValidation as any, row, 'insert', { logger: this.logger });
2187+
evaluateValidationRules(schemaForValidation as any, row, 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) });
21712188
result = await driver.create(object, row, hookContext.input.options as any);
21722189
}
21732190

@@ -2292,7 +2309,7 @@ export class ObjectQL implements IDataEngine {
22922309
// field is read-only for this record's state, so the incoming
22932310
// change is ignored (the persisted value is kept).
22942311
hookContext.input.data = stripReadonlyWhenFields(updateSchema as any, hookContext.input.data as Record<string, unknown>, priorRecord, this.logger) as any;
2295-
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: priorRecord, logger: this.logger });
2312+
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) });
22962313
result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
22972314
} else if (options?.multi && driver.updateMany) {
22982315
await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Server-side per-option `visibleWhen` enforcement (objectui#2284).
5+
*
6+
* A select/multiselect/radio option may gate itself with a `visibleWhen` CEL
7+
* predicate. Client-side hiding is UX only, so on write the engine re-evaluates
8+
* the picked value's predicate against the merged record + `current_user` and
9+
* rejects a clean FALSE — enforcing both cascade integrity (country → province)
10+
* and role/context gating. Broken/unbound predicates fail-open.
11+
*/
12+
import { describe, it, expect } from 'vitest';
13+
import { evaluateValidationRules, needsPriorRecord } from './rule-validator.js';
14+
import { ValidationError } from './record-validator.js';
15+
16+
// country → province cascade + a role-gated tier option.
17+
const schema = {
18+
fields: {
19+
country: { type: 'select', options: [{ value: 'cn' }, { value: 'us' }] },
20+
province: {
21+
type: 'select',
22+
options: [
23+
{ value: 'zj', visibleWhen: "record.country == 'cn'" },
24+
{ value: 'ca', visibleWhen: "record.country == 'us'" },
25+
{ value: 'other' }, // ungated — always allowed
26+
],
27+
},
28+
tier: {
29+
type: 'select',
30+
options: [
31+
{ value: 'standard' },
32+
{ value: 'admin_only', visibleWhen: "'admin' in current_user.roles" },
33+
],
34+
},
35+
},
36+
};
37+
38+
describe('per-option visibleWhen — cascade enforcement (insert)', () => {
39+
it('rejects a province that does not match the chosen country', () => {
40+
expect(() => evaluateValidationRules(schema, { country: 'us', province: 'zj' }, 'insert')).toThrow(
41+
ValidationError,
42+
);
43+
});
44+
it('accepts a province valid for the country', () => {
45+
expect(() => evaluateValidationRules(schema, { country: 'cn', province: 'zj' }, 'insert')).not.toThrow();
46+
});
47+
it('accepts an ungated option regardless of the parent', () => {
48+
expect(() => evaluateValidationRules(schema, { country: 'us', province: 'other' }, 'insert')).not.toThrow();
49+
});
50+
it('leaves an unknown value to the enum validator (no visibleWhen match)', () => {
51+
expect(() => evaluateValidationRules(schema, { country: 'cn', province: 'zzz' }, 'insert')).not.toThrow();
52+
});
53+
});
54+
55+
describe('per-option visibleWhen — cascade enforcement (update, merged record)', () => {
56+
it('rejects using the prior country when the patch omits it', () => {
57+
expect(() =>
58+
evaluateValidationRules(schema, { province: 'zj' }, 'update', { previous: { country: 'us' } }),
59+
).toThrow(ValidationError);
60+
});
61+
it('accepts using the prior country when it matches', () => {
62+
expect(() =>
63+
evaluateValidationRules(schema, { province: 'zj' }, 'update', { previous: { country: 'cn' } }),
64+
).not.toThrow();
65+
});
66+
it('does not check a field the patch never wrote', () => {
67+
// province persisted as 'zj' but country now 'us'; patch touches only `note`.
68+
expect(() =>
69+
evaluateValidationRules(schema, { note: 'x' } as any, 'update', {
70+
previous: { country: 'us', province: 'zj' },
71+
}),
72+
).not.toThrow();
73+
});
74+
});
75+
76+
describe('per-option visibleWhen — role gating', () => {
77+
it('rejects an admin-only value for a non-admin', () => {
78+
expect(() =>
79+
evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', {
80+
currentUser: { id: 'u1', roles: ['sales'] },
81+
}),
82+
).toThrow(ValidationError);
83+
});
84+
it('accepts an admin-only value for an admin', () => {
85+
expect(() =>
86+
evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', {
87+
currentUser: { id: 'u1', roles: ['admin'] },
88+
}),
89+
).not.toThrow();
90+
});
91+
it('accepts the ungated standard value for anyone', () => {
92+
expect(() =>
93+
evaluateValidationRules(schema, { tier: 'standard' }, 'insert', {
94+
currentUser: { id: 'u1', roles: ['sales'] },
95+
}),
96+
).not.toThrow();
97+
});
98+
it('fails open when current_user is unbound (system write) — predicate faults', () => {
99+
// `'admin' in current_user.roles` faults with no bound user → allowed through.
100+
// Authorization gating therefore requires the engine to bind current_user.
101+
expect(() => evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert')).not.toThrow();
102+
});
103+
});
104+
105+
describe('per-option visibleWhen — multi-select element-wise', () => {
106+
const multi = {
107+
fields: {
108+
country: { type: 'select', options: [{ value: 'cn' }, { value: 'us' }] },
109+
provinces: {
110+
type: 'multiselect',
111+
options: [
112+
{ value: 'zj', visibleWhen: "record.country == 'cn'" },
113+
{ value: 'gd', visibleWhen: "record.country == 'cn'" },
114+
{ value: 'ca', visibleWhen: "record.country == 'us'" },
115+
],
116+
},
117+
},
118+
};
119+
it('rejects when any selected element is invalid for the parent', () => {
120+
expect(() => evaluateValidationRules(multi, { country: 'cn', provinces: ['zj', 'ca'] }, 'insert')).toThrow(
121+
ValidationError,
122+
);
123+
});
124+
it('accepts when every selected element is valid', () => {
125+
expect(() =>
126+
evaluateValidationRules(multi, { country: 'cn', provinces: ['zj', 'gd'] }, 'insert'),
127+
).not.toThrow();
128+
});
129+
});
130+
131+
describe('needsPriorRecord accounts for option visibleWhen', () => {
132+
it('is true when a choice field has a gated option (cascade may reference a prior sibling)', () => {
133+
expect(needsPriorRecord(schema)).toBe(true);
134+
});
135+
it('is false for plain option fields with no visibleWhen', () => {
136+
expect(needsPriorRecord({ fields: { color: { type: 'select', options: [{ value: 'r' }, { value: 'b' }] } } })).toBe(
137+
false,
138+
);
139+
});
140+
});

‎packages/objectql/src/validation/rule-validator.ts‎

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ export interface EvaluateRulesOptions {
141141
previous?: Record<string, unknown> | null;
142142
/** Optional logger for non-blocking diagnostics (broken rules, warnings). */
143143
logger?: { warn?: (msg: string, meta?: any) => void };
144+
/**
145+
* The acting user (ADR-0068 EvalUser shape), surfaced to per-option
146+
* `visibleWhen` predicates as `current_user` so role/context-gated options can
147+
* be enforced server-side (objectui#2284). Absent on system/unauthenticated
148+
* writes — role predicates then reference an unbound `current_user`, fault,
149+
* and fail-open (see {@link evaluateOptionVisibility}).
150+
*/
151+
currentUser?: { id?: string; roles?: string[]; organizationId?: string | null; [k: string]: unknown } | null;
144152
}
145153

146154
/**
@@ -215,22 +223,43 @@ function ruleNeedsPrior(r: unknown): boolean {
215223

216224
/** Field-level conditional rules (B2): a field is required / read-only when its
217225
* CEL predicate is TRUE over the record. */
226+
interface ConditionalFieldOption {
227+
value?: unknown;
228+
/** Per-option visibility predicate (CEL) — objectui#2284. */
229+
visibleWhen?: string | Expression;
230+
}
231+
218232
interface ConditionalFieldDef {
219233
requiredWhen?: string | Expression;
220234
conditionalRequired?: string | Expression; // back-compat alias of requiredWhen
221235
readonlyWhen?: string | Expression;
236+
/** Field type — scopes per-option `visibleWhen` enforcement to choice fields. */
237+
type?: string;
238+
/** Select/multiselect/radio options; an option may gate itself with `visibleWhen`. */
239+
options?: Array<ConditionalFieldOption | null | undefined>;
240+
}
241+
242+
/** Choice fields whose picked value(s) are drawn from `options`. */
243+
const CHOICE_FIELD_TYPES = new Set(['select', 'multiselect', 'radio']);
244+
245+
/** True when a choice field carries at least one option gated by `visibleWhen`. */
246+
function fieldHasOptionVisibility(def: ConditionalFieldDef | undefined | null): boolean {
247+
if (!def || !CHOICE_FIELD_TYPES.has(String(def.type))) return false;
248+
return Array.isArray(def.options) && def.options.some((o) => o != null && o.visibleWhen != null);
222249
}
223250

224251
function isMissing(v: unknown): boolean {
225252
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
226253
}
227254

228255
/** True when any field declares a conditional rule that needs the merged/prior
229-
* record to evaluate (so the engine fetches `previous` on update). */
256+
* record to evaluate (so the engine fetches `previous` on update). Per-option
257+
* `visibleWhen` counts too — a cascade predicate can reference an unchanged
258+
* sibling that only `previous` supplies (objectui#2284). */
230259
function fieldsNeedPrior(fields: Record<string, ConditionalFieldDef> | undefined): boolean {
231260
if (!fields) return false;
232261
return Object.values(fields).some(
233-
(f) => f && (f.requiredWhen || f.conditionalRequired || f.readonlyWhen),
262+
(f) => f && (f.requiredWhen || f.conditionalRequired || f.readonlyWhen || fieldHasOptionVisibility(f)),
234263
);
235264
}
236265

@@ -239,6 +268,68 @@ function toExpression(cond: string | Expression): Expression {
239268
return typeof cond === 'string' ? { dialect: 'cel', source: cond } : cond;
240269
}
241270

271+
/**
272+
* Per-option authorization / cascade enforcement (objectui#2284).
273+
*
274+
* A `select` / `multiselect` / `radio` option may gate itself with a
275+
* `visibleWhen` CEL predicate, evaluated against the live record + `current_user`
276+
* (the same predicate the client uses to hide the option). Client-side hiding is
277+
* UX, not a security boundary — a caller can still submit a hidden value — so on
278+
* write we re-evaluate the predicate for the *picked* value(s) and reject any
279+
* that resolve cleanly to FALSE. This enforces both role/context gating
280+
* (`'admin' in current_user.roles`) and cascade integrity (`record.country ==
281+
* 'cn'`), server-side.
282+
*
283+
* Only WRITTEN fields are checked — an unchanged persisted value is left alone.
284+
* A predicate that can't be evaluated (missing referenced field, unbound
285+
* `current_user` on a system write) is **fail-open** (logged, allowed), matching
286+
* every other field rule here: a broken cascade predicate must never brick a
287+
* write. Authorization gating therefore depends on the engine binding
288+
* `current_user` on authenticated writes.
289+
*/
290+
function evaluateOptionVisibility(
291+
fields: Record<string, ConditionalFieldDef> | undefined,
292+
data: Record<string, unknown>,
293+
merged: Record<string, unknown>,
294+
previous: Record<string, unknown> | undefined,
295+
currentUser: EvaluateRulesOptions['currentUser'],
296+
errors: FieldValidationError[],
297+
logger: EvaluateRulesOptions['logger'],
298+
): void {
299+
if (!fields) return;
300+
const user = (currentUser ?? undefined) as any;
301+
for (const [name, def] of Object.entries(fields)) {
302+
if (!fieldHasOptionVisibility(def) || !(name in data)) continue;
303+
const raw = data[name];
304+
if (raw === undefined || raw === null || raw === '') continue;
305+
const picked = Array.isArray(raw) ? raw : [raw];
306+
for (const value of picked) {
307+
const opt = def.options!.find((o) => o != null && o.value === value);
308+
// Unknown value (not in options) or an ungated option → nothing to enforce
309+
// here; an out-of-set value is the enum validator's concern, not ours.
310+
if (!opt || opt.visibleWhen == null) continue;
311+
const res = ExpressionEngine.evaluate<boolean>(toExpression(opt.visibleWhen), {
312+
record: merged,
313+
previous,
314+
user,
315+
});
316+
if (!res.ok) {
317+
logger?.warn?.(
318+
`option visibleWhen for '${name}=${String(value)}' failed to evaluate — allowed through`,
319+
);
320+
continue; // fail-open
321+
}
322+
if (res.value === false) {
323+
errors.push({
324+
field: name,
325+
code: 'invalid_option',
326+
message: `${name}: option '${String(value)}' is not available`,
327+
});
328+
}
329+
}
330+
}
331+
}
332+
242333
/**
243334
* Evaluate an object's declared validation rules against an incoming write.
244335
*
@@ -297,6 +388,12 @@ export function evaluateValidationRules(
297388
}
298389
}
299390

391+
// Per-option authorization / cascade gating (objectui#2284): reject a written
392+
// choice value whose option `visibleWhen` resolves cleanly to FALSE against the
393+
// merged record + `current_user`. Complements the client-side hiding, which is
394+
// not a security boundary.
395+
evaluateOptionVisibility(fields, data, merged, previous, opts.currentUser, errors, opts.logger);
396+
300397
const ordered = (hasRules ? rules! : [])
301398
.filter((r): r is BaseRule => r != null && typeof r === 'object')
302399
.filter((r) => r.active !== false)

0 commit comments

Comments
 (0)