Skip to content

Commit ba45145

Browse files
os-zhuangclaude
andauthored
feat(grid): gate list row Edit/Delete and bulk delete on the effective operation set (objectstack#3720) (#2889)
The fourth surface objectstack#3391 left open. The toolbar (#2823), detail/form (#2832 + #2876) and related lists (#2832) all route through `resolveCrudAffordances`; the main list's row CRUD has its own resolver and none of those rounds reached it. Its gate was `operations ?? { update: !!onEdit, delete: !!onDelete }`, and ObjectView wires onEdit/onDelete unconditionally while view JSON rarely declares `operations` — so it was effectively always-on. A caller whose effective set carried neither `update` nor `delete` still got the row kebab's Edit/Delete and the bulk delete. - plugin-grid `resolveRowCrudAffordances` takes `managedBy` + `effectiveApiOperations` and resolves the object verdict through the shared `resolveCrudAffordances` policy, so the row gate is the same decision every other face makes. It also returns `objectCanDelete` — bulk delete rides `onBulkDelete`, a different callback from the row `onDelete`, so it must not be judged by whether the row handler happens to be wired. - plugin-grid `ObjectGrid` threads its existing `effectiveApiOps` (until now fed only to Export) into the row gate, and applies the delete verdict to bulk delete: the implicit `['delete']`, a declared `bulkActions: ['delete']`, and any `bulkActionDefs` entry with `operation: 'delete'`. Custom ids and non-delete operations pass through untouched. - plugin-list `ListView`'s own bulk bar (the non-grid views) drops its built-in `delete` under the same verdict. Also closes the ADR-0103 gap on this chain: the bucket lock was documented as applied upstream via the view's `operations.*`, but the all-open default meant it never was — an engine-owned system / append-only / better-auth object leaked a generic row Edit/Delete the engine rejects. A `userActions` opt-in still re-opens it. Intersection, never union. A missing effective set preserves current behavior. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent aa88056 commit ba45145

6 files changed

Lines changed: 531 additions & 40 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@object-ui/plugin-grid": minor
3+
"@object-ui/plugin-list": minor
4+
---
5+
6+
feat: gate list row Edit/Delete and bulk delete on the server's effective operation set (#3720)
7+
8+
The **fourth** surface #3391 left open. The three earlier rounds — the toolbar
9+
(objectui#2823), detail/form (#3546, objectui#2832 + #2876) and related lists
10+
(#3546) — all route through `resolveCrudAffordances`. The main list's **row
11+
CRUD** does not: it has its own resolver (`plugin-grid`'s
12+
`resolveRowCrudAffordances`), so none of those rounds ever reached it.
13+
14+
Its gate was `operations ?? { update: !!onEdit, delete: !!onDelete }` — and
15+
`ObjectView` wires `onEdit`/`onDelete` unconditionally while view JSON rarely
16+
declares `operations`, so it was effectively always-on. A caller whose effective
17+
set carried neither `update` nor `delete` still got the row kebab's Edit/Delete
18+
**and** the bulk delete, the most destructive affordance on the list.
19+
20+
- **plugin-grid** `resolveRowCrudAffordances` now takes `managedBy` and
21+
`effectiveApiOperations` and resolves the object verdict through the shared
22+
`resolveCrudAffordances` policy — so the row gate is the SAME decision the
23+
toolbar, record header, form and related lists make. It also returns
24+
`objectCanDelete`, the object-level delete verdict that bulk delete gates on
25+
(bulk rides `onBulkDelete`, a different callback from the row `onDelete`).
26+
- **plugin-grid** `ObjectGrid` threads its existing `effectiveApiOps` — until
27+
now fed only to Export — into the row gate, and applies the delete verdict to
28+
bulk delete: the implicit `['delete']`, an author-declared
29+
`bulkActions: ['delete']`, and any `bulkActionDefs` entry with
30+
`operation: 'delete'`. A declared bulk action is a *wiring* declaration, not a
31+
permission grant. Custom action ids and non-delete operations pass through
32+
untouched.
33+
- **plugin-list** `ListView`'s own bulk bar (the non-grid views — kanban /
34+
calendar / gallery; the grid path delegates to `ObjectGrid`) drops its
35+
built-in `delete` under the same verdict.
36+
37+
Also closes the ADR-0103 gap on this chain: `rowCrudAffordances` documented the
38+
bucket lock as "applied upstream via the view's `operations.*`", but the
39+
all-open default meant it never was — an engine-owned `system` / `append-only` /
40+
`better-auth` object leaked a generic row Edit/Delete that the engine rejects
41+
(`assertEngineOwnedWriteAllowed`). Running the shared policy applies it, and a
42+
`userActions` opt-in still re-opens it (e.g. `sys_user`'s `edit`).
43+
44+
Same semantics as the earlier rounds: **intersection, never union** — a server
45+
grant cannot re-open what the bucket or `userActions` closed, and a
46+
`userActions` opt-in cannot survive a server denial. A missing effective set
47+
(unrestricted object, older backend, or no `PermissionProvider`) preserves the
48+
current behavior.

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,19 +1444,25 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
14441444
const wantEditAction = rowActionsList.includes('edit');
14451445
const wantDeleteAction = rowActionsList.includes('delete');
14461446
const customRowActions = rowActionsList.filter(a => a !== 'edit' && a !== 'delete');
1447-
// Honor the object's CRUD affordance flags: when `userActions.edit`/`delete`
1448-
// is explicitly false the object opted out of the generic row Edit/Delete
1449-
// (e.g. sys_environment ships a dedicated Rename + cascade-Delete instead).
1450-
// This stops a generic "Delete" from duplicating the object's own Delete
1451-
// action, and a generic "Edit" the object turned off from leaking back in.
1452-
const { canEdit, canDelete, editPredicates, deletePredicates } = resolveRowCrudAffordances({
1447+
// Honor the object's resolved CRUD affordance: the ADR-0103 lifecycle bucket
1448+
// (`managedBy`), the `userActions.edit`/`delete` override — explicit `false`
1449+
// opts out of the generic row Edit/Delete (e.g. sys_environment ships a
1450+
// dedicated Rename + cascade-Delete instead, and the generic entries would
1451+
// duplicate them) — and [#3720] the server's effective API operation set, so
1452+
// the row kebab never offers an update/delete the server would reject.
1453+
// `operations` above only says whether the CONSUMER wired the affordance; it
1454+
// is not a permission grant, which is why the object verdict is ANDed here
1455+
// rather than assumed to have been applied upstream.
1456+
const { canEdit, canDelete, objectCanDelete, editPredicates, deletePredicates } = resolveRowCrudAffordances({
14531457
operationsUpdate: operations?.update,
14541458
operationsDelete: operations?.delete,
14551459
wantEditAction,
14561460
wantDeleteAction,
14571461
hasOnEdit: !!onEdit,
14581462
hasOnDelete: !!onDelete,
1463+
managedBy: (objectSchema as any)?.managedBy,
14591464
userActions: (objectSchema as any)?.userActions,
1465+
effectiveApiOperations: effectiveApiOps,
14601466
});
14611467
const hasActions = !!(operations && (operations.update || operations.delete));
14621468
const hasRowActions = customRowActions.length > 0 || rowActionDefsList.length > 0 || wantEditAction || wantDeleteAction;
@@ -1578,10 +1584,22 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
15781584
// a bulk-delete affordance is implicitly available (canDelete + onBulkDelete
15791585
// wired by the consumer). This gives every list a multi-select + delete UX
15801586
// out of the box without forcing each view JSON to declare bulkActions.
1581-
const explicitBulkActions = schema.batchActions ?? schema.bulkActions;
1582-
const bulkActionDefs: BulkActionDef[] = Array.isArray(schema.bulkActionDefs)
1587+
// [#3720] Bulk delete is the most destructive affordance on the list, so it
1588+
// rides the same object-level `delete` verdict as the row kebab (bucket lock
1589+
// ∧ userActions ∧ the server's effective operation set). An author-declared
1590+
// `bulkActions: ['delete']` / `bulkActionDefs[].operation === 'delete'` is a
1591+
// WIRING declaration, not a permission grant — so the built-in delete is
1592+
// filtered out of both when the verdict is off. Custom action ids and
1593+
// non-delete operations are untouched: they route through the action runner
1594+
// and carry their own gates.
1595+
const declaredBulkActions = schema.batchActions ?? schema.bulkActions;
1596+
const explicitBulkActions = objectCanDelete
1597+
? declaredBulkActions
1598+
: declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete');
1599+
const bulkActionDefs: BulkActionDef[] = (Array.isArray(schema.bulkActionDefs)
15831600
? schema.bulkActionDefs
1584-
: [];
1601+
: []
1602+
).filter((def: BulkActionDef) => objectCanDelete || def?.operation !== 'delete');
15851603
const effectiveBulkActions: string[] =
15861604
explicitBulkActions && explicitBulkActions.length > 0
15871605
? explicitBulkActions

packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,45 @@
22
import { describe, it, expect } from 'vitest';
33
import { resolveRowCrudAffordances } from '../rowCrudAffordances';
44

5+
/** The row-level verdict only — drops the object-level bulk-delete bit. */
6+
const rowGate = (opts: Parameters<typeof resolveRowCrudAffordances>[0]) => {
7+
const { canEdit, canDelete } = resolveRowCrudAffordances(opts);
8+
return { canEdit, canDelete };
9+
};
10+
511
describe('resolveRowCrudAffordances', () => {
612
const wired = { operationsUpdate: true, operationsDelete: true, hasOnEdit: true, hasOnDelete: true };
713

814
it('surfaces generic edit/delete by default (userActions undefined)', () => {
9-
expect(resolveRowCrudAffordances({ ...wired })).toEqual({ canEdit: true, canDelete: true });
15+
expect(rowGate({ ...wired })).toEqual({ canEdit: true, canDelete: true });
1016
});
1117

1218
it('keeps both when userActions explicitly true', () => {
13-
expect(resolveRowCrudAffordances({ ...wired, userActions: { edit: true, delete: true } }))
19+
expect(rowGate({ ...wired, userActions: { edit: true, delete: true } }))
1420
.toEqual({ canEdit: true, canDelete: true });
1521
});
1622

1723
it('suppresses generic edit/delete when the object opts out (userActions false)', () => {
1824
// sys_environment case: edit:false + delete:false → no generic kebab entries,
1925
// leaving only the object's dedicated Rename / Delete actions (no duplicate).
20-
expect(resolveRowCrudAffordances({ ...wired, userActions: { edit: false, delete: false } }))
26+
expect(rowGate({ ...wired, userActions: { edit: false, delete: false } }))
2127
.toEqual({ canEdit: false, canDelete: false });
2228
});
2329

2430
it('gates edit and delete independently', () => {
25-
expect(resolveRowCrudAffordances({ ...wired, userActions: { edit: false } }))
31+
expect(rowGate({ ...wired, userActions: { edit: false } }))
2632
.toEqual({ canEdit: false, canDelete: true });
27-
expect(resolveRowCrudAffordances({ ...wired, userActions: { delete: false } }))
33+
expect(rowGate({ ...wired, userActions: { delete: false } }))
2834
.toEqual({ canEdit: true, canDelete: false });
2935
});
3036

3137
it('still requires the callback + operation to be wired (opt-out is not opt-in)', () => {
32-
expect(resolveRowCrudAffordances({ hasOnEdit: false, hasOnDelete: false, operationsUpdate: true, operationsDelete: true }))
38+
expect(rowGate({ hasOnEdit: false, hasOnDelete: false, operationsUpdate: true, operationsDelete: true }))
3339
.toEqual({ canEdit: false, canDelete: false });
3440
});
3541

3642
it('honors explicit rowActions (edit/delete strings) when callbacks exist', () => {
37-
expect(resolveRowCrudAffordances({ wantEditAction: true, wantDeleteAction: true, hasOnEdit: true, hasOnDelete: true }))
43+
expect(rowGate({ wantEditAction: true, wantDeleteAction: true, hasOnEdit: true, hasOnDelete: true }))
3844
.toEqual({ canEdit: true, canDelete: true });
3945
});
4046

@@ -65,7 +71,8 @@ describe('resolveRowCrudAffordances', () => {
6571

6672
it('object form without predicates adds nothing (boolean-equivalent)', () => {
6773
const res = resolveRowCrudAffordances({ ...wired, userActions: { edit: { enabled: true }, delete: {} } });
68-
expect(res).toEqual({ canEdit: true, canDelete: true });
74+
expect(rowGate({ ...wired, userActions: { edit: { enabled: true }, delete: {} } }))
75+
.toEqual({ canEdit: true, canDelete: true });
6976
expect(res.editPredicates).toBeUndefined();
7077
expect(res.deletePredicates).toBeUndefined();
7178
});
@@ -79,5 +86,116 @@ describe('resolveRowCrudAffordances', () => {
7986
expect(res.canEdit).toBe(false);
8087
expect(res.editPredicates).toBeUndefined();
8188
});
89+
90+
it('a server denial drops the predicates along with the affordance', () => {
91+
const res = resolveRowCrudAffordances({
92+
...wired,
93+
userActions: { edit: { disabledWhen: 'record.frozen == true' } },
94+
effectiveApiOperations: ['get', 'list'],
95+
});
96+
expect(res.canEdit).toBe(false);
97+
expect(res.editPredicates).toBeUndefined();
98+
});
99+
});
100+
101+
// [#3720] The fourth face of #3391: the row kebab intersects with the
102+
// server-resolved effective operation set (`/me/permissions` apiOperations),
103+
// matching the toolbar (objectui#2823), detail/form (#3546) and related-list
104+
// gates. Intersection, never union.
105+
describe('#3720 effective API operation set', () => {
106+
const FULL = ['get', 'list', 'create', 'update', 'delete'];
107+
108+
it('keeps both entries when the effective set carries update + delete', () => {
109+
expect(rowGate({ ...wired, effectiveApiOperations: FULL }))
110+
.toEqual({ canEdit: true, canDelete: true });
111+
});
112+
113+
it('hides both when the effective set is read-only', () => {
114+
expect(rowGate({ ...wired, effectiveApiOperations: ['get', 'list'] }))
115+
.toEqual({ canEdit: false, canDelete: false });
116+
});
117+
118+
it('gates the two operations independently', () => {
119+
expect(rowGate({ ...wired, effectiveApiOperations: ['get', 'list', 'update'] }))
120+
.toEqual({ canEdit: true, canDelete: false });
121+
expect(rowGate({ ...wired, effectiveApiOperations: ['get', 'list', 'delete'] }))
122+
.toEqual({ canEdit: false, canDelete: true });
123+
});
124+
125+
it('an empty effective set exposes nothing', () => {
126+
expect(rowGate({ ...wired, effectiveApiOperations: [] }))
127+
.toEqual({ canEdit: false, canDelete: false });
128+
});
129+
130+
it('a missing effective set preserves the pre-#3720 behavior', () => {
131+
expect(rowGate({ ...wired, effectiveApiOperations: undefined }))
132+
.toEqual({ canEdit: true, canDelete: true });
133+
expect(rowGate({ ...wired, effectiveApiOperations: null }))
134+
.toEqual({ canEdit: true, canDelete: true });
135+
});
136+
137+
it('intersects, never unions — a server grant cannot re-open a userActions opt-out', () => {
138+
expect(rowGate({ ...wired, userActions: { edit: false, delete: false }, effectiveApiOperations: FULL }))
139+
.toEqual({ canEdit: false, canDelete: false });
140+
});
141+
142+
it('intersects, never unions — a userActions opt-in cannot survive a server denial', () => {
143+
expect(rowGate({ ...wired, userActions: { edit: true, delete: true }, effectiveApiOperations: ['get', 'list'] }))
144+
.toEqual({ canEdit: false, canDelete: false });
145+
});
146+
});
147+
148+
// [#3720] The same chain never applied the ADR-0103 bucket lock either: the
149+
// module used to assume it arrived upstream via the view's `operations.*`,
150+
// but `operations` defaults to "whatever the consumer wired" and every main
151+
// list wires onEdit/onDelete unconditionally. Now it runs the shared policy.
152+
describe('#3720 ADR-0103 lifecycle bucket', () => {
153+
it('an absent managedBy resolves to the platform bucket (both entries on)', () => {
154+
expect(rowGate({ ...wired, managedBy: undefined }))
155+
.toEqual({ canEdit: true, canDelete: true });
156+
});
157+
158+
it('config objects keep generic edit/delete', () => {
159+
expect(rowGate({ ...wired, managedBy: 'config' }))
160+
.toEqual({ canEdit: true, canDelete: true });
161+
});
162+
163+
it.each(['system', 'engine-owned', 'append-only', 'better-auth'])(
164+
'engine-owned bucket %s hides generic edit/delete',
165+
(managedBy) => {
166+
expect(rowGate({ ...wired, managedBy })).toEqual({ canEdit: false, canDelete: false });
167+
},
168+
);
169+
170+
it('a userActions opt-in re-opens a bucket-locked object (sys_user edit)', () => {
171+
expect(rowGate({ ...wired, managedBy: 'better-auth', userActions: { edit: true } }))
172+
.toEqual({ canEdit: true, canDelete: false });
173+
});
174+
175+
it('the server effective set still clamps a bucket opt-in', () => {
176+
expect(rowGate({
177+
...wired,
178+
managedBy: 'better-auth',
179+
userActions: { edit: true },
180+
effectiveApiOperations: ['get', 'list'],
181+
})).toEqual({ canEdit: false, canDelete: false });
182+
});
183+
});
184+
185+
// [#3720] Bulk delete gates on the OBJECT verdict, not the row one: it rides
186+
// `onBulkDelete`, a different callback from the row `onDelete`.
187+
describe('#3720 objectCanDelete (the bulk-delete gate)', () => {
188+
it('stays true when the row callback is absent but the object allows delete', () => {
189+
const res = resolveRowCrudAffordances({ operationsUpdate: true, operationsDelete: true, hasOnDelete: false });
190+
expect(res.canDelete).toBe(false);
191+
expect(res.objectCanDelete).toBe(true);
192+
});
193+
194+
it('follows the object verdict through every layer', () => {
195+
expect(resolveRowCrudAffordances({ ...wired, userActions: { delete: false } }).objectCanDelete).toBe(false);
196+
expect(resolveRowCrudAffordances({ ...wired, managedBy: 'append-only' }).objectCanDelete).toBe(false);
197+
expect(resolveRowCrudAffordances({ ...wired, effectiveApiOperations: ['get', 'list'] }).objectCanDelete).toBe(false);
198+
expect(resolveRowCrudAffordances({ ...wired, effectiveApiOperations: ['get', 'list', 'delete'] }).objectCanDelete).toBe(true);
199+
});
82200
});
83201
});

0 commit comments

Comments
 (0)