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
48 changes: 48 additions & 0 deletions .changeset/list-row-crud-effective-ops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@object-ui/plugin-grid": minor
"@object-ui/plugin-list": minor
---

feat: gate list row Edit/Delete and bulk delete on the server's effective operation set (#3720)

The **fourth** surface #3391 left open. The three earlier rounds — the toolbar
(objectui#2823), detail/form (#3546, objectui#2832 + #2876) and related lists
(#3546) — all route through `resolveCrudAffordances`. The main list's **row
CRUD** does not: it has its own resolver (`plugin-grid`'s
`resolveRowCrudAffordances`), so none of those rounds ever 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, the most destructive affordance on the list.

- **plugin-grid** `resolveRowCrudAffordances` now takes `managedBy` and
`effectiveApiOperations` and resolves the object verdict through the shared
`resolveCrudAffordances` policy — so the row gate is the SAME decision the
toolbar, record header, form and related lists make. It also returns
`objectCanDelete`, the object-level delete verdict that bulk delete gates on
(bulk rides `onBulkDelete`, a different callback from the row `onDelete`).
- **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']`, an author-declared
`bulkActions: ['delete']`, and any `bulkActionDefs` entry with
`operation: 'delete'`. A declared bulk action is a *wiring* declaration, not a
permission grant. Custom action ids and non-delete operations pass through
untouched.
- **plugin-list** `ListView`'s own bulk bar (the non-grid views — kanban /
calendar / gallery; the grid path delegates to `ObjectGrid`) drops its
built-in `delete` under the same verdict.

Also closes the ADR-0103 gap on this chain: `rowCrudAffordances` documented the
bucket lock 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 that the engine rejects
(`assertEngineOwnedWriteAllowed`). Running the shared policy applies it, and a
`userActions` opt-in still re-opens it (e.g. `sys_user`'s `edit`).

Same semantics as the earlier rounds: **intersection, never union** — a server
grant cannot re-open what the bucket or `userActions` closed, and a
`userActions` opt-in cannot survive a server denial. A missing effective set
(unrestricted object, older backend, or no `PermissionProvider`) preserves the
current behavior.
36 changes: 27 additions & 9 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1444,19 +1444,25 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
const wantEditAction = rowActionsList.includes('edit');
const wantDeleteAction = rowActionsList.includes('delete');
const customRowActions = rowActionsList.filter(a => a !== 'edit' && a !== 'delete');
// Honor the object's CRUD affordance flags: when `userActions.edit`/`delete`
// is explicitly false the object opted out of the generic row Edit/Delete
// (e.g. sys_environment ships a dedicated Rename + cascade-Delete instead).
// This stops a generic "Delete" from duplicating the object's own Delete
// action, and a generic "Edit" the object turned off from leaking back in.
const { canEdit, canDelete, editPredicates, deletePredicates } = resolveRowCrudAffordances({
// Honor the object's resolved CRUD affordance: the ADR-0103 lifecycle bucket
// (`managedBy`), the `userActions.edit`/`delete` override — explicit `false`
// opts out of the generic row Edit/Delete (e.g. sys_environment ships a
// dedicated Rename + cascade-Delete instead, and the generic entries would
// duplicate them) — and [#3720] the server's effective API operation set, so
// the row kebab never offers an update/delete the server would reject.
// `operations` above only says whether the CONSUMER wired the affordance; it
// is not a permission grant, which is why the object verdict is ANDed here
// rather than assumed to have been applied upstream.
const { canEdit, canDelete, objectCanDelete, editPredicates, deletePredicates } = resolveRowCrudAffordances({
operationsUpdate: operations?.update,
operationsDelete: operations?.delete,
wantEditAction,
wantDeleteAction,
hasOnEdit: !!onEdit,
hasOnDelete: !!onDelete,
managedBy: (objectSchema as any)?.managedBy,
userActions: (objectSchema as any)?.userActions,
effectiveApiOperations: effectiveApiOps,
});
const hasActions = !!(operations && (operations.update || operations.delete));
const hasRowActions = customRowActions.length > 0 || rowActionDefsList.length > 0 || wantEditAction || wantDeleteAction;
Expand Down Expand Up @@ -1578,10 +1584,22 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
// a bulk-delete affordance is implicitly available (canDelete + onBulkDelete
// wired by the consumer). This gives every list a multi-select + delete UX
// out of the box without forcing each view JSON to declare bulkActions.
const explicitBulkActions = schema.batchActions ?? schema.bulkActions;
const bulkActionDefs: BulkActionDef[] = Array.isArray(schema.bulkActionDefs)
// [#3720] Bulk delete is the most destructive affordance on the list, so it
// rides the same object-level `delete` verdict as the row kebab (bucket lock
// ∧ userActions ∧ the server's effective operation set). An author-declared
// `bulkActions: ['delete']` / `bulkActionDefs[].operation === 'delete'` is a
// WIRING declaration, not a permission grant — so the built-in delete is
// filtered out of both when the verdict is off. Custom action ids and
// non-delete operations are untouched: they route through the action runner
// and carry their own gates.
const declaredBulkActions = schema.batchActions ?? schema.bulkActions;
const explicitBulkActions = objectCanDelete
? declaredBulkActions
: declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete');
const bulkActionDefs: BulkActionDef[] = (Array.isArray(schema.bulkActionDefs)
? schema.bulkActionDefs
: [];
: []
).filter((def: BulkActionDef) => objectCanDelete || def?.operation !== 'delete');
const effectiveBulkActions: string[] =
explicitBulkActions && explicitBulkActions.length > 0
? explicitBulkActions
Expand Down
134 changes: 126 additions & 8 deletions packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,45 @@
import { describe, it, expect } from 'vitest';
import { resolveRowCrudAffordances } from '../rowCrudAffordances';

/** The row-level verdict only — drops the object-level bulk-delete bit. */
const rowGate = (opts: Parameters<typeof resolveRowCrudAffordances>[0]) => {
const { canEdit, canDelete } = resolveRowCrudAffordances(opts);
return { canEdit, canDelete };
};

describe('resolveRowCrudAffordances', () => {
const wired = { operationsUpdate: true, operationsDelete: true, hasOnEdit: true, hasOnDelete: true };

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

it('keeps both when userActions explicitly true', () => {
expect(resolveRowCrudAffordances({ ...wired, userActions: { edit: true, delete: true } }))
expect(rowGate({ ...wired, userActions: { edit: true, delete: true } }))
.toEqual({ canEdit: true, canDelete: true });
});

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

it('gates edit and delete independently', () => {
expect(resolveRowCrudAffordances({ ...wired, userActions: { edit: false } }))
expect(rowGate({ ...wired, userActions: { edit: false } }))
.toEqual({ canEdit: false, canDelete: true });
expect(resolveRowCrudAffordances({ ...wired, userActions: { delete: false } }))
expect(rowGate({ ...wired, userActions: { delete: false } }))
.toEqual({ canEdit: true, canDelete: false });
});

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

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

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

it('object form without predicates adds nothing (boolean-equivalent)', () => {
const res = resolveRowCrudAffordances({ ...wired, userActions: { edit: { enabled: true }, delete: {} } });
expect(res).toEqual({ canEdit: true, canDelete: true });
expect(rowGate({ ...wired, userActions: { edit: { enabled: true }, delete: {} } }))
.toEqual({ canEdit: true, canDelete: true });
expect(res.editPredicates).toBeUndefined();
expect(res.deletePredicates).toBeUndefined();
});
Expand All @@ -79,5 +86,116 @@ describe('resolveRowCrudAffordances', () => {
expect(res.canEdit).toBe(false);
expect(res.editPredicates).toBeUndefined();
});

it('a server denial drops the predicates along with the affordance', () => {
const res = resolveRowCrudAffordances({
...wired,
userActions: { edit: { disabledWhen: 'record.frozen == true' } },
effectiveApiOperations: ['get', 'list'],
});
expect(res.canEdit).toBe(false);
expect(res.editPredicates).toBeUndefined();
});
});

// [#3720] The fourth face of #3391: the row kebab intersects with the
// server-resolved effective operation set (`/me/permissions` apiOperations),
// matching the toolbar (objectui#2823), detail/form (#3546) and related-list
// gates. Intersection, never union.
describe('#3720 effective API operation set', () => {
const FULL = ['get', 'list', 'create', 'update', 'delete'];

it('keeps both entries when the effective set carries update + delete', () => {
expect(rowGate({ ...wired, effectiveApiOperations: FULL }))
.toEqual({ canEdit: true, canDelete: true });
});

it('hides both when the effective set is read-only', () => {
expect(rowGate({ ...wired, effectiveApiOperations: ['get', 'list'] }))
.toEqual({ canEdit: false, canDelete: false });
});

it('gates the two operations independently', () => {
expect(rowGate({ ...wired, effectiveApiOperations: ['get', 'list', 'update'] }))
.toEqual({ canEdit: true, canDelete: false });
expect(rowGate({ ...wired, effectiveApiOperations: ['get', 'list', 'delete'] }))
.toEqual({ canEdit: false, canDelete: true });
});

it('an empty effective set exposes nothing', () => {
expect(rowGate({ ...wired, effectiveApiOperations: [] }))
.toEqual({ canEdit: false, canDelete: false });
});

it('a missing effective set preserves the pre-#3720 behavior', () => {
expect(rowGate({ ...wired, effectiveApiOperations: undefined }))
.toEqual({ canEdit: true, canDelete: true });
expect(rowGate({ ...wired, effectiveApiOperations: null }))
.toEqual({ canEdit: true, canDelete: true });
});

it('intersects, never unions — a server grant cannot re-open a userActions opt-out', () => {
expect(rowGate({ ...wired, userActions: { edit: false, delete: false }, effectiveApiOperations: FULL }))
.toEqual({ canEdit: false, canDelete: false });
});

it('intersects, never unions — a userActions opt-in cannot survive a server denial', () => {
expect(rowGate({ ...wired, userActions: { edit: true, delete: true }, effectiveApiOperations: ['get', 'list'] }))
.toEqual({ canEdit: false, canDelete: false });
});
});

// [#3720] The same chain never applied the ADR-0103 bucket lock either: the
// module used to assume it arrived upstream via the view's `operations.*`,
// but `operations` defaults to "whatever the consumer wired" and every main
// list wires onEdit/onDelete unconditionally. Now it runs the shared policy.
describe('#3720 ADR-0103 lifecycle bucket', () => {
it('an absent managedBy resolves to the platform bucket (both entries on)', () => {
expect(rowGate({ ...wired, managedBy: undefined }))
.toEqual({ canEdit: true, canDelete: true });
});

it('config objects keep generic edit/delete', () => {
expect(rowGate({ ...wired, managedBy: 'config' }))
.toEqual({ canEdit: true, canDelete: true });
});

it.each(['system', 'engine-owned', 'append-only', 'better-auth'])(
'engine-owned bucket %s hides generic edit/delete',
(managedBy) => {
expect(rowGate({ ...wired, managedBy })).toEqual({ canEdit: false, canDelete: false });
},
);

it('a userActions opt-in re-opens a bucket-locked object (sys_user edit)', () => {
expect(rowGate({ ...wired, managedBy: 'better-auth', userActions: { edit: true } }))
.toEqual({ canEdit: true, canDelete: false });
});

it('the server effective set still clamps a bucket opt-in', () => {
expect(rowGate({
...wired,
managedBy: 'better-auth',
userActions: { edit: true },
effectiveApiOperations: ['get', 'list'],
})).toEqual({ canEdit: false, canDelete: false });
});
});

// [#3720] Bulk delete gates on the OBJECT verdict, not the row one: it rides
// `onBulkDelete`, a different callback from the row `onDelete`.
describe('#3720 objectCanDelete (the bulk-delete gate)', () => {
it('stays true when the row callback is absent but the object allows delete', () => {
const res = resolveRowCrudAffordances({ operationsUpdate: true, operationsDelete: true, hasOnDelete: false });
expect(res.canDelete).toBe(false);
expect(res.objectCanDelete).toBe(true);
});

it('follows the object verdict through every layer', () => {
expect(resolveRowCrudAffordances({ ...wired, userActions: { delete: false } }).objectCanDelete).toBe(false);
expect(resolveRowCrudAffordances({ ...wired, managedBy: 'append-only' }).objectCanDelete).toBe(false);
expect(resolveRowCrudAffordances({ ...wired, effectiveApiOperations: ['get', 'list'] }).objectCanDelete).toBe(false);
expect(resolveRowCrudAffordances({ ...wired, effectiveApiOperations: ['get', 'list', 'delete'] }).objectCanDelete).toBe(true);
});
});
});
Loading
Loading