Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/select-option-server-enforcement.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 20 additions & 3 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down Expand Up @@ -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<string, unknown>, priorRecord, this.logger) as any;
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: priorRecord, logger: this.logger });
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, '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<string, unknown>, hookContext.input.options as any);
} else if (options?.multi && driver.updateMany) {
await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
101 changes: 99 additions & 2 deletions packages/objectql/src/validation/rule-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ export interface EvaluateRulesOptions {
previous?: Record<string, unknown> | 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;
}

/**
Expand Down Expand Up @@ -215,22 +223,43 @@ 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<ConditionalFieldOption | null | undefined>;
}

/** 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 {
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
}

/** 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<string, ConditionalFieldDef> | 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)),
);
}

Expand All @@ -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<string, ConditionalFieldDef> | undefined,
data: Record<string, unknown>,
merged: Record<string, unknown>,
previous: Record<string, unknown> | 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<boolean>(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.
*
Expand Down Expand Up @@ -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)
Expand Down