Skip to content

Commit dcf4c56

Browse files
committed
feat(spec): userActions.edit/delete accept per-record CEL predicates (objectui#2614)
Extend the object-level userActions.edit / userActions.delete flags from plain booleans to a union with an object form { enabled?, visibleWhen?, disabledWhen? } (RowCrudActionOverrideSchema), so the built-in row Edit/Delete affordances can be hidden or disabled per record via CEL predicates — the same evaluation contract custom actions already use. Semantics: visibleWhen false → not rendered (fail-closed); disabledWhen true → rendered disabled (fail-soft). The predicates are advisory UI gating; server enforcement stays with permissions/hooks. resolveCrudAffordances() keeps returning the resolved booleans (enabled falls back to the managedBy bucket default) and now surfaces the predicates as editPredicates / deletePredicates, pass-through as authored. Boolean-only inputs produce byte-identical output — zero behavior change for existing schemas. clampManagedObjectWrites (ADR-0092 D2 hint clamp) treats the object form by its explicit enabled flag only: per-record predicates are not a write grant, so a managed object stays fail-closed unless enabled === true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCZhYpQjRyg2E44RHBiNE
1 parent 330ceba commit dcf4c56

5 files changed

Lines changed: 196 additions & 10 deletions

File tree

packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ describe('clampManagedObjectWrites', () => {
9090
expect(objects.crm_lead.allowEdit).toBe(true);
9191
});
9292

93+
it('treats the #2614 object form by its enabled flag only (predicates are UI gating, not a grant)', () => {
94+
const schemas: Record<string, ManagedSchemaLike> = {
95+
sys_user: {
96+
managedBy: 'better-auth',
97+
userActions: { edit: { enabled: true, disabledWhen: 'record.frozen == true' } as never },
98+
},
99+
sys_account: {
100+
managedBy: 'better-auth',
101+
// enabled omitted → NOT an explicit opt-in; the clamp stays fail-closed.
102+
userActions: { edit: { disabledWhen: 'record.frozen == true' } as never },
103+
},
104+
};
105+
const objects: Record<string, any> = {
106+
sys_user: { allowEdit: true },
107+
sys_account: { allowEdit: true },
108+
};
109+
clampManagedObjectWrites(objects, (n) => schemas[n]);
110+
expect(objects.sys_user.allowEdit).toBe(true);
111+
expect(objects.sys_account.allowEdit).toBe(false);
112+
});
113+
93114
it('fold + clamp compose to permission ∩ guard for a platform admin', () => {
94115
// As produced for a platform admin (admin_full_access '*' modifyAll) who
95116
// also holds organization_admin (explicit managed denies).

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,19 @@ export function foldWildcardSuperUser(objects: Record<string, any>): void {
126126
/** Minimal schema shape the managed-write clamp needs. */
127127
export interface ManagedSchemaLike {
128128
managedBy?: string;
129-
userActions?: { create?: boolean; edit?: boolean; delete?: boolean } | null;
129+
userActions?: {
130+
create?: boolean;
131+
// edit/delete accept the #2614 object form ({ enabled, visibleWhen,
132+
// disabledWhen }); only the object-level `enabled` matters here — the
133+
// per-record predicates are UI gating, not a permission grant.
134+
edit?: boolean | { enabled?: boolean };
135+
delete?: boolean | { enabled?: boolean };
136+
} | null;
137+
}
138+
139+
/** True only when a userActions flag (bare boolean or object form) explicitly opts the write in. */
140+
function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean {
141+
return v === true || (typeof v === 'object' && v !== null && v.enabled === true);
130142
}
131143

132144
/**
@@ -157,9 +169,9 @@ export function clampManagedObjectWrites(
157169
const schema = schemaOf(obj);
158170
if (schema?.managedBy !== 'better-auth') continue;
159171
const ua = schema.userActions ?? {};
160-
if (ua.edit !== true) acc.allowEdit = false;
172+
if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false;
161173
if (ua.create !== true) acc.allowCreate = false;
162-
if (ua.delete !== true) acc.allowDelete = false;
174+
if (!isWriteOptedIn(ua.delete)) acc.allowDelete = false;
163175
}
164176
}
165177

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,7 @@
838838
"data/ReferenceResolution",
839839
"data/ReferenceResolutionError",
840840
"data/ReplicationConfig",
841+
"data/RowCrudActionOverride",
841842
"data/SQLDialect",
842843
"data/SQLDriverConfig",
843844
"data/SSLConfig",

packages/spec/src/data/object.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, type ServiceObject } from './object.zod';
2+
import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, resolveCrudAffordances, type ServiceObject } from './object.zod';
33

44
describe('ObjectCapabilities', () => {
55
it('should apply default values correctly', () => {
@@ -1342,3 +1342,69 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', ()
13421342
).toThrow(/removed from @objectstack\/spec after v15\.0/);
13431343
});
13441344
});
1345+
1346+
describe('userActions row predicates + resolveCrudAffordances (objectui#2614)', () => {
1347+
it('accepts the plain boolean form unchanged (back-compat)', () => {
1348+
const obj = ObjectSchema.parse({
1349+
name: 'invoice',
1350+
fields: { name: { type: 'text' } },
1351+
userActions: { edit: false, delete: true },
1352+
});
1353+
const aff = resolveCrudAffordances(obj);
1354+
expect(aff.edit).toBe(false);
1355+
expect(aff.delete).toBe(true);
1356+
expect(aff.editPredicates).toBeUndefined();
1357+
expect(aff.deletePredicates).toBeUndefined();
1358+
});
1359+
1360+
it('accepts the object form with CEL predicate shorthand strings', () => {
1361+
const obj = ObjectSchema.parse({
1362+
name: 'task_version_check_item',
1363+
fields: { name: { type: 'text' } },
1364+
userActions: {
1365+
edit: { disabledWhen: 'record.frozen == true' },
1366+
delete: { visibleWhen: 'record.frozen != true' },
1367+
},
1368+
});
1369+
// String shorthand normalizes to the canonical CEL envelope.
1370+
expect((obj.userActions?.edit as any).disabledWhen).toEqual({ dialect: 'cel', source: 'record.frozen == true' });
1371+
expect((obj.userActions?.delete as any).visibleWhen).toEqual({ dialect: 'cel', source: 'record.frozen != true' });
1372+
});
1373+
1374+
it('resolveCrudAffordances carries predicates through and defaults enabled from the bucket', () => {
1375+
const aff = resolveCrudAffordances({
1376+
managedBy: 'platform',
1377+
userActions: {
1378+
edit: { disabledWhen: { dialect: 'cel', source: 'record.frozen == true' } },
1379+
delete: { enabled: false, visibleWhen: { dialect: 'cel', source: 'record.frozen != true' } },
1380+
},
1381+
} as never);
1382+
// No `enabled` on edit → platform bucket default (true) applies.
1383+
expect(aff.edit).toBe(true);
1384+
expect(aff.editPredicates?.disabledWhen).toEqual({ dialect: 'cel', source: 'record.frozen == true' });
1385+
expect(aff.editPredicates?.visibleWhen).toBeUndefined();
1386+
// Explicit enabled:false wins over the bucket default; predicates still surface.
1387+
expect(aff.delete).toBe(false);
1388+
expect(aff.deletePredicates?.visibleWhen).toEqual({ dialect: 'cel', source: 'record.frozen != true' });
1389+
});
1390+
1391+
it('object form without predicates behaves exactly like the boolean form', () => {
1392+
const aff = resolveCrudAffordances({
1393+
managedBy: 'config',
1394+
userActions: { edit: { enabled: true }, delete: {} },
1395+
} as never);
1396+
expect(aff.edit).toBe(true);
1397+
expect(aff.delete).toBe(true); // config bucket default
1398+
expect(aff.editPredicates).toBeUndefined();
1399+
expect(aff.deletePredicates).toBeUndefined();
1400+
});
1401+
1402+
it('rejects unknown keys in the object form', () => {
1403+
const result = ObjectSchema.safeParse({
1404+
name: 'invoice',
1405+
fields: { name: { type: 'text' } },
1406+
userActions: { edit: { hideWhen: 'record.frozen == true' } },
1407+
});
1408+
expect(result.success).toBe(false);
1409+
});
1410+
});

packages/spec/src/data/object.zod.ts

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { ObjectListViewSchema } from '../ui/view.zod';
99
/**
1010
* API Operations Enum
1111
*/
12-
import { TemplateExpressionInputSchema } from '../shared/expression.zod';
12+
import { ExpressionInputSchema, TemplateExpressionInputSchema, type Expression, type ExpressionInput } from '../shared/expression.zod';
1313
import { lazySchema } from '../shared/lazy-schema';
1414
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
1515
import { ProtectionSchema } from '../shared/protection.zod';
@@ -556,6 +556,42 @@ export const ObjectExternalBindingSchema = z.object({
556556

557557
export type ObjectExternalBinding = z.infer<typeof ObjectExternalBindingSchema>;
558558

559+
/**
560+
* Object form of a `userActions.edit` / `userActions.delete` override —
561+
* extends the plain boolean with **per-record** CEL predicates so the
562+
* built-in row Edit/Delete affordances can be hidden or disabled for a
563+
* subset of rows (objectstack-ai/objectui#2614).
564+
*
565+
* Semantics (mirrors custom row actions' `visible` / `disabled`):
566+
* - `enabled` — object-level on/off, same meaning as the bare boolean.
567+
* Omitted → the `managedBy` bucket default.
568+
* - `visibleWhen` — CEL over `record.*`; evaluates **false** → the row's
569+
* button is not rendered. Fail-closed (a faulting
570+
* predicate hides, and warns once).
571+
* - `disabledWhen` — CEL over `record.*`; evaluates **true** → the row's
572+
* button renders greyed / non-clickable. Fail-soft (a
573+
* faulting predicate leaves the button enabled).
574+
*
575+
* The predicates are advisory UI gating only — server-side enforcement
576+
* stays with permissions / hooks (e.g. `beforeUpdate` rejecting frozen
577+
* rows). Evaluation happens on the canonical CEL engine, per row, with the
578+
* record bound as `record.*` (and bare fields) — the same machinery custom
579+
* actions already use, so authoring is identical.
580+
*/
581+
export const RowCrudActionOverrideSchema = z.object({
582+
enabled: z.boolean().optional().describe(
583+
'Object-level on/off for the generic affordance; same meaning as the bare boolean form. Omitted → managedBy bucket default.',
584+
),
585+
visibleWhen: ExpressionInputSchema.optional().describe(
586+
'Per-record CEL predicate; false → hide the row button for that record. Fail-closed.',
587+
),
588+
disabledWhen: ExpressionInputSchema.optional().describe(
589+
'Per-record CEL predicate; true → render the row button disabled for that record. Fail-soft.',
590+
),
591+
}).strict().describe('Boolean-or-predicates override for a built-in row CRUD affordance.');
592+
export type RowCrudActionOverride = z.infer<typeof RowCrudActionOverrideSchema>;
593+
export type RowCrudActionOverrideInput = z.input<typeof RowCrudActionOverrideSchema>;
594+
559595
const ObjectSchemaBase = z.object({
560596
/**
561597
* Identity & Metadata
@@ -642,8 +678,12 @@ const ObjectSchemaBase = z.object({
642678
userActions: z.object({
643679
create: z.boolean().optional().describe('Show generic "New" button.'),
644680
import: z.boolean().optional().describe('Show CSV import wizard entry.'),
645-
edit: z.boolean().optional().describe('Allow inline / form edit of existing rows.'),
646-
delete: z.boolean().optional().describe('Show row-level delete + bulk delete.'),
681+
edit: z.union([z.boolean(), RowCrudActionOverrideSchema]).optional().describe(
682+
'Allow inline / form edit of existing rows. Boolean, or an object adding per-record visibleWhen/disabledWhen CEL predicates.',
683+
),
684+
delete: z.union([z.boolean(), RowCrudActionOverrideSchema]).optional().describe(
685+
'Show row-level delete + bulk delete. Boolean, or an object adding per-record visibleWhen/disabledWhen CEL predicates.',
686+
),
647687
exportCsv: z.boolean().optional().describe('Show CSV export entry.'),
648688
}).optional().describe('Per-object override of the resolved CRUD affordance matrix.'),
649689

@@ -1298,6 +1338,24 @@ export interface CrudAffordances {
12981338
delete: boolean;
12991339
/** CSV / clipboard export. Allowed even on append-only audit tables by default. */
13001340
exportCsv: boolean;
1341+
/**
1342+
* Per-record CEL predicates for the built-in row Edit action, present only
1343+
* when `userActions.edit` used the object form (objectui#2614). Evaluate
1344+
* per row against `record.*`; see {@link RowCrudActionOverrideSchema}.
1345+
*/
1346+
editPredicates?: RowCrudPredicates;
1347+
/** Per-record CEL predicates for the built-in row Delete action. */
1348+
deletePredicates?: RowCrudPredicates;
1349+
}
1350+
1351+
/**
1352+
* Per-record gating predicates carried through {@link resolveCrudAffordances}.
1353+
* Kept as authored (`string` shorthand or `{ dialect, source }` envelope) —
1354+
* consumers hand them to the canonical CEL row-predicate evaluator untouched.
1355+
*/
1356+
export interface RowCrudPredicates {
1357+
visibleWhen?: Expression | ExpressionInput;
1358+
disabledWhen?: Expression | ExpressionInput;
13011359
}
13021360

13031361
/**
@@ -1343,13 +1401,41 @@ export function resolveCrudAffordances(
13431401
const bucket = (obj?.managedBy ?? 'platform') as keyof typeof CRUD_AFFORDANCE_DEFAULTS;
13441402
const base = CRUD_AFFORDANCE_DEFAULTS[bucket] ?? CRUD_AFFORDANCE_DEFAULTS.platform;
13451403
const overrides = obj?.userActions ?? {};
1346-
return {
1404+
const edit = normalizeRowCrudOverride(overrides.edit, base.edit);
1405+
const del = normalizeRowCrudOverride(overrides.delete, base.delete);
1406+
const out: CrudAffordances = {
13471407
create: overrides.create ?? base.create,
13481408
import: overrides.import ?? base.import,
1349-
edit: overrides.edit ?? base.edit,
1350-
delete: overrides.delete ?? base.delete,
1409+
edit: edit.enabled,
1410+
delete: del.enabled,
13511411
exportCsv: overrides.exportCsv ?? base.exportCsv,
13521412
};
1413+
if (edit.predicates) out.editPredicates = edit.predicates;
1414+
if (del.predicates) out.deletePredicates = del.predicates;
1415+
return out;
1416+
}
1417+
1418+
/**
1419+
* Collapse a `userActions.edit` / `userActions.delete` override — bare
1420+
* boolean or `{ enabled, visibleWhen, disabledWhen }` object — onto the
1421+
* bucket default. The predicates pass through as authored; `predicates` is
1422+
* only set when at least one predicate is present, so the boolean-only path
1423+
* stays byte-identical to the pre-#2614 result.
1424+
*/
1425+
function normalizeRowCrudOverride(
1426+
override: boolean | { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown } | null | undefined,
1427+
base: boolean,
1428+
): { enabled: boolean; predicates?: RowCrudPredicates } {
1429+
if (override == null) return { enabled: base };
1430+
if (typeof override === 'boolean') return { enabled: override };
1431+
const enabled = override.enabled ?? base;
1432+
const visibleWhen = override.visibleWhen as RowCrudPredicates['visibleWhen'];
1433+
const disabledWhen = override.disabledWhen as RowCrudPredicates['disabledWhen'];
1434+
if (visibleWhen == null && disabledWhen == null) return { enabled };
1435+
const predicates: RowCrudPredicates = {};
1436+
if (visibleWhen != null) predicates.visibleWhen = visibleWhen;
1437+
if (disabledWhen != null) predicates.disabledWhen = disabledWhen;
1438+
return { enabled, predicates };
13531439
}
13541440

13551441
// =================================================================

0 commit comments

Comments
 (0)