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
36 changes: 36 additions & 0 deletions .changeset/bulk-action-required-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/spec": minor
---

feat(spec): `BulkActionDefSchema` accepts `requiredPermissions` — the capability gate the selection bar already enforces (#6257)

The renderer has filtered selection-bar buttons on `def.requiredPermissions`
since objectui#3492 (`BulkActionBar` runs the same `useCapabilityGate` as the
row kebab and record header), but the `.strict()` `BulkActionDefSchema` did not
declare the key, so no legal metadata could ever reach that filter —
`enforced ≠ declarable`, the mirror image of the "declared ≠ enforced" gap.
The forms with no workaround were the INLINE data-plane defs
(`operation: 'update' | 'delete'`): they dispatch no action, so unlike a def
promoted from `bulkActions: ['<name>']` (or an aggregate def naming a declared
action) they have nothing to inherit a gate from. In practice that meant a
declarative bulk delete — the button that most needs a gate — was visible to
every caller who could open the list, and rejected only per record, server-side,
after the click.

`BulkActionDefSchema` now declares an optional `requiredPermissions: string[]`
with `action.requiredPermissions` semantics verbatim: absent or empty always
passes, several entries AND, a client that cannot resolve the caller's
capabilities fails OPEN (the server stays the authority), and the platform-admin
bit grants no exemption — the gate reads grants. On a data-plane def the key
governs visibility only; the write is still authorized by the data API's object
permissions and server hooks. The `ActionSchema` near-miss aliases
(`permissions`, `capabilities`, `requiresPermissions`, `requiredCapabilities`,
`acl`) rename onto the new key here too. No renderer change: objectui's
`BulkActionDef` type and `BulkActionBar` filter shipped in objectui 11
(objectui#3548).

Specimens: `examples/app-showcase` `showcase_project.default` gains the two
inline gated defs the #6157 action-gating matrix could not pin — `relabel_ops`
(`update` + `patch`, gated on the Ops-held `showcase.export_data`) and
`purge_restricted` (`delete`, gated on the granted-to-nobody
`showcase.restricted_ops`).
1 change: 1 addition & 0 deletions content/docs/references/ui/bulk-action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const result = BulkActionDefSchema.parse(data);
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL), same shape as `action.visible`. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. |
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['<name>']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. |
| **maxRecords** | `integer` | optional | Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request. |
| **batchSize** | `integer` | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. |

Expand Down
10 changes: 10 additions & 0 deletions content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,16 @@ Aggregate is the def form, which is where `execution` lives. A def that says
time: the renderer has no action attached to such a def, so it used to render a
button that reported success for every selected record and did nothing.

**Gating a def by capability.** An inline def takes
`requiredPermissions: string[]` with `action.requiredPermissions` semantics —
absent/empty always passes, several entries AND, unknown caller capabilities
fail open (the server stays the authority). A def promoted from
`bulkActions: ['<name>']` (or an aggregate def naming a declared action)
inherits the action's own declaration instead, so this key matters chiefly for
the `update`/`delete` data-plane forms, which dispatch no action and have
nothing to inherit from. On those defs the gate governs **visibility only** —
the mass write itself is still authorized by the data API's object permissions.

A url or api action rendered on the list **toolbar** can also read the current
selection through target interpolation — `${ctx.selection.ids}` (comma-joined)
and `${ctx.selection.count}` — without any bulk wiring.
Expand Down
49 changes: 49 additions & 0 deletions examples/app-showcase/e2e/bulk-capability-gate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';

/**
* Selection-bar capability gate over INLINE bulk defs (#6257; ADR-0066 D4).
*
* `showcase_project.default` declares two capability-gated inline defs next to
* four ungated ones (src/ui/views/project.view.ts):
*
* • `relabel_ops` — requiredPermissions: ['showcase.export_data'],
* granted to the Ops position only;
* • `purge_restricted` — requiredPermissions: ['showcase.restricted_ops'],
* granted to NOBODY (src/security/capabilities.ts).
*
* The signed-in caller is the seeded platform admin (e2e/global-setup.ts), who
* holds NEITHER showcase capability: `admin_full_access` carries only the
* platform capability set, and the gate reads GRANTS — the admin bit is not an
* exemption. So with rows selected, the bar must show every ungated def and
* neither gated one. Only the "absent" half is pinned here because it is the
* half CI can assert without mutating grants; the positive Ops-held cell and
* the declaration flip (`['showcase.restricted_ops']` → `[]` → back) are the
* issue's real-machine protocol (#6257).
*
* Before spec #6257 the gated pair was not even declarable: `.strict()`
* `BulkActionDefSchema` had no `requiredPermissions`, while the bar already
* filtered on it (objectui#3492) — this spec is what notices either side
* regressing.
*/
const APP = process.env.SHOWCASE_APP || 'com.example.showcase';

test('selection bar hides capability-gated inline defs from a caller without the grants', async ({ page }) => {
await page.goto(`/_console/apps/${APP}/showcase_project`, { waitUntil: 'domcontentloaded' });
await page.locator('main').first().waitFor({ state: 'visible', timeout: 25_000 });

// Tick the header select-all checkbox — the grid's first checkbox.
const selectAll = page.getByRole('checkbox').first();
await selectAll.waitFor({ state: 'visible', timeout: 25_000 });
await selectAll.click();

// The bar offers the ungated defs…
await expect(page.getByTestId('bulk-action-set_labels')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('bulk-action-assign_team')).toBeVisible();
await expect(page.getByTestId('bulk-action-reassign_account')).toBeVisible();
await expect(page.getByTestId('bulk-action-reschedule')).toBeVisible();

// …and neither gated def — the ungated assertions above are what make these
// two absences evidence of the GATE, not of a bar that failed to render.
await expect(page.getByTestId('bulk-action-relabel_ops')).toHaveCount(0);
await expect(page.getByTestId('bulk-action-purge_restricted')).toHaveCount(0);
});
8 changes: 5 additions & 3 deletions examples/app-showcase/src/security/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ export const ExportDataCapability = defineCapability({
* the ADR-0066 three-way separation, and the one a demo usually leaves out.
*
* Its whole job is to be required and never held: `showcase_zoo_perm_missing`
* (and the AND-gate specimen next to it, see ui/actions/predicate-matrix.action.ts)
* lists it in `requiredPermissions`, so those buttons must be absent for every
* caller on every one of the four action surfaces. Without a capability nobody
* (and the AND-gate specimen next to it, see ui/actions/predicate-matrix.action.ts),
* plus the INLINE data-plane bulk def `purge_restricted`
* (ui/views/project.view.ts, #6257), list it in `requiredPermissions`, so those
* buttons must be absent for every caller on every one of the four action
* surfaces. Without a capability nobody
* holds there is nothing to notice when a surface stops applying the gate —
* which is exactly how the selection bar shipped ignoring `requiredPermissions`
* outright (objectui#3492) while three other surfaces honoured it.
Expand Down
31 changes: 31 additions & 0 deletions examples/app-showcase/src/ui/views/project.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const ProjectViews = defineView({
// • reassign_account → single-select on a `lookup` param (searchable
// reference picker, not a bare dropdown)
// • reschedule → the new `date` control + a single-select together
// The last two defs are the CAPABILITY-GATED pair (ADR-0066 D4, #6257) —
// see their own comment below.
bulkActionDefs: [
{
name: 'set_labels',
Expand Down Expand Up @@ -117,6 +119,35 @@ export const ProjectViews = defineView({
},
],
},
// ── Capability-gated INLINE defs (ADR-0066 D4, #6257) ────────────────
// The cell the #6157 action-gating matrix could not pin: an inline
// DATA-PLANE def dispatches no action, so it has nothing to inherit a
// gate from — before #6257 there was no legal spelling of
// `requiredPermissions` here at all. Same specimen pair as the zoo's
// `showcase_zoo_perm_held` / `showcase_zoo_perm_missing` (see
// security/capabilities.ts): one capability Operations HOLDS, one that
// is granted to NOBODY. The selection bar must therefore show
// `relabel_ops` exactly to Ops-position callers, show `purge_restricted`
// to no one (platform admin included — the gate reads GRANTS, not the
// admin bit), and keep the four ungated defs above visible to everyone
// who can open the list. The gate governs visibility only: the write
// itself is still authorized by the data API's object permissions.
{
name: 'relabel_ops',
label: 'Relabel (Ops)',
operation: 'update',
patch: { labels: ['qa'] },
requiredPermissions: ['showcase.export_data'],
confirmText: 'Reset the labels of every selected project to QA?',
},
{
name: 'purge_restricted',
label: 'Purge (Restricted)',
operation: 'delete',
variant: 'danger',
requiredPermissions: ['showcase.restricted_ops'],
confirmText: 'Permanently delete every selected project?',
},
],
},
listViews: {
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
"ui/BulkActionDef:operation",
"ui/BulkActionDef:params",
"ui/BulkActionDef:patch",
"ui/BulkActionDef:requiredPermissions",
"ui/BulkActionDef:variant",
"ui/BulkActionDef:visible",
"ui/BulkActionParam:default",
Expand Down
64 changes: 64 additions & 0 deletions packages/spec/src/ui/bulk-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,68 @@ describe('BulkActionDefSchema (#4457)', () => {
.toBeDefined();
});
});

describe('— `requiredPermissions` is declarable on every def form (ADR-0066 D4, #6257)', () => {
// The renderer filtered on this key from objectui#3492 on, while the schema
// rejected every attempt to write it — `enforced ≠ declarable`. The forms
// below are the ones that USED to have no legal spelling: a data-plane def
// dispatches no action, so there is nothing to inherit a gate from.
it('accepts the gate on an inline `delete` def — the form with nowhere to inherit from', () => {
const def = ok({ name: 'purge_projects', operation: 'delete', requiredPermissions: ['showcase.restricted_ops'] });
expect(def.requiredPermissions).toEqual(['showcase.restricted_ops']);
});

it('accepts the gate on a declarative `update` + `patch` def', () => {
const def = ok({
name: 'archive_projects',
operation: 'update',
patch: { status: 'archived' },
requiredPermissions: ['plan.push_down'],
});
expect(def.requiredPermissions).toEqual(['plan.push_down']);
});

it('accepts the gate on an aggregate custom def alongside the inherited route', () => {
// Legal but usually redundant — an aggregate def already inherits the
// named action's declaration. Authored here, the authored key wins the
// renderer's merge, so it must parse rather than force the inherit path.
const def = ok({
name: 'recalc_selection',
operation: 'custom',
execution: 'aggregate',
requiredPermissions: ['showcase.export_data'],
});
expect(def.requiredPermissions).toEqual(['showcase.export_data']);
});

it('accepts several capabilities — the renderer ANDs them', () => {
const def = ok({
name: 'purge_projects',
operation: 'delete',
requiredPermissions: ['showcase.export_data', 'showcase.restricted_ops'],
});
expect(def.requiredPermissions).toHaveLength(2);
});

it('accepts `requiredPermissions: []` — the explicit always-pass declaration', () => {
expect(ok({ name: 'purge_projects', operation: 'delete', requiredPermissions: [] }).requiredPermissions)
.toEqual([]);
});

it('stays optional — a def without it parses exactly as before', () => {
expect(ok({ name: 'purge_projects', operation: 'delete' }).requiredPermissions).toBeUndefined();
});

it('rejects a bare-string declaration — the gate is a list, never a scalar', () => {
expect(reject({ name: 'purge_projects', operation: 'delete', requiredPermissions: 'showcase.restricted_ops' })
.join('\n')).toContain('requiredPermissions');
});

it('renames the `ActionSchema` near-misses onto the canonical key', () => {
expect(reject({ name: 'purge_projects', operation: 'delete', permissions: ['x'] }).join('\n'))
.toContain('`permissions` → `requiredPermissions`');
expect(reject({ name: 'purge_projects', operation: 'delete', requiredCapabilities: ['x'] }).join('\n'))
.toContain('`requiredCapabilities` → `requiredPermissions`');
});
});
});
10 changes: 9 additions & 1 deletion packages/spec/src/ui/bulk-action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ export type BulkActionParam = z.infer<typeof BulkActionParamSchema>;
/** Declared keys of a bulk-action def — the "did you mean" pool. */
const BULK_ACTION_DEF_KEYS = [
'name', 'label', 'icon', 'variant', 'operation', 'execution', 'patch',
'params', 'confirmText', 'confirmLabel', 'visible', 'maxRecords', 'batchSize',
'params', 'confirmText', 'confirmLabel', 'visible', 'requiredPermissions',
'maxRecords', 'batchSize',
] as const;

const bulkActionDefUnknownKeyError = strictUnknownKeyError({
Expand All @@ -153,6 +154,12 @@ const bulkActionDefUnknownKeyError = strictUnknownKeyError({
limit: 'maxRecords',
max: 'maxRecords',
batch: 'batchSize',
// The capability gate IS a declared key here too — `requiredPermissions`
// (ADR-0066 D4, #6257) — so its near-misses RENAME onto it, exactly as
// they do on `ActionSchema`.
permissions: 'requiredPermissions', capabilities: 'requiredPermissions',
requiresPermissions: 'requiredPermissions', requiredCapabilities: 'requiredPermissions',
acl: 'requiredPermissions',
},
guidance: {
// Not a typo — a real key the RENDERER attaches, which is exactly why an
Expand Down Expand Up @@ -208,6 +215,7 @@ export const BulkActionDefSchema = lazySchema(() => z.object({
confirmText: z.string().optional().describe('Confirmation text shown above the affected-record summary.'),
confirmLabel: z.string().optional().describe('Custom Confirm button label (default: "Run").'),
visible: ExpressionInputSchema.optional().describe('Eligibility predicate (CEL), same shape as `action.visible`. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record.'),
requiredPermissions: z.array(z.string()).optional().describe("[ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['<name>']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks."),
maxRecords: z.number().int().positive().optional().describe('Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request.'),
batchSize: z.number().int().positive().optional().describe('Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition.'),
}, { error: bulkActionDefUnknownKeyError }).strict()
Expand Down
Loading