Skip to content

Commit d8e8d9c

Browse files
baozhoutaoclaude
andauthored
feat(spec): declare requiredPermissions on BulkActionDefSchema (#6257) (#6332)
The selection bar has filtered buttons on def.requiredPermissions since objectui#3492, but the .strict() schema never declared the key, so no legal metadata could reach that filter — enforced ≠ declarable. 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>'] they have nothing to inherit a gate from, and a declarative bulk delete stayed visible to every caller who could open the list, rejected only per record after the click. - BulkActionDefSchema: optional requiredPermissions: string[], with action.requiredPermissions semantics verbatim (absent/empty pass, entries AND, unresolvable caller capabilities fail open; on a data-plane def the gate governs visibility only — the write is still authorized by the data API). ActionSchema's near-miss aliases (permissions/capabilities/requiresPermissions/requiredCapabilities/acl) rename onto the key here too, and it joins the did-you-mean pool. - Regenerated authorable-surface/ui.json and the bulk-action reference page; new guide paragraph in content/docs/ui/views.mdx. - Showcase: the two inline gated defs the #6157 matrix could not pin, on showcase_project.default — 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) — plus a playwright spec pinning the admin-negative cells of the selection bar. - Schema tests: every def form accepts the gate, empty array and optionality pinned, non-array rejected, aliases rename. No renderer change: objectui's BulkActionDef type and BulkActionBar filter shipped in objectui 11 (objectui#3548). Claude-Session: https://claude.ai/code/session_01PD7tZG1vENc5peMaLQC1uD Co-authored-by: Claude <noreply@anthropic.com>
1 parent cfb549d commit d8e8d9c

9 files changed

Lines changed: 206 additions & 4 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `BulkActionDefSchema` accepts `requiredPermissions` — the capability gate the selection bar already enforces (#6257)
6+
7+
The renderer has filtered selection-bar buttons on `def.requiredPermissions`
8+
since objectui#3492 (`BulkActionBar` runs the same `useCapabilityGate` as the
9+
row kebab and record header), but the `.strict()` `BulkActionDefSchema` did not
10+
declare the key, so no legal metadata could ever reach that filter —
11+
`enforced ≠ declarable`, the mirror image of the "declared ≠ enforced" gap.
12+
The forms with no workaround were the INLINE data-plane defs
13+
(`operation: 'update' | 'delete'`): they dispatch no action, so unlike a def
14+
promoted from `bulkActions: ['<name>']` (or an aggregate def naming a declared
15+
action) they have nothing to inherit a gate from. In practice that meant a
16+
declarative bulk delete — the button that most needs a gate — was visible to
17+
every caller who could open the list, and rejected only per record, server-side,
18+
after the click.
19+
20+
`BulkActionDefSchema` now declares an optional `requiredPermissions: string[]`
21+
with `action.requiredPermissions` semantics verbatim: absent or empty always
22+
passes, several entries AND, a client that cannot resolve the caller's
23+
capabilities fails OPEN (the server stays the authority), and the platform-admin
24+
bit grants no exemption — the gate reads grants. On a data-plane def the key
25+
governs visibility only; the write is still authorized by the data API's object
26+
permissions and server hooks. The `ActionSchema` near-miss aliases
27+
(`permissions`, `capabilities`, `requiresPermissions`, `requiredCapabilities`,
28+
`acl`) rename onto the new key here too. No renderer change: objectui's
29+
`BulkActionDef` type and `BulkActionBar` filter shipped in objectui 11
30+
(objectui#3548).
31+
32+
Specimens: `examples/app-showcase` `showcase_project.default` gains the two
33+
inline gated defs the #6157 action-gating matrix could not pin — `relabel_ops`
34+
(`update` + `patch`, gated on the Ops-held `showcase.export_data`) and
35+
`purge_restricted` (`delete`, gated on the granted-to-nobody
36+
`showcase.restricted_ops`).

content/docs/references/ui/bulk-action.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const result = BulkActionDefSchema.parse(data);
5050
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
5151
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
5252
| **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. |
53+
| **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. |
5354
| **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. |
5455
| **batchSize** | `integer` | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. |
5556

content/docs/ui/views.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,16 @@ Aggregate is the def form, which is where `execution` lives. A def that says
198198
time: the renderer has no action attached to such a def, so it used to render a
199199
button that reported success for every selected record and did nothing.
200200

201+
**Gating a def by capability.** An inline def takes
202+
`requiredPermissions: string[]` with `action.requiredPermissions` semantics
203+
absent/empty always passes, several entries AND, unknown caller capabilities
204+
fail open (the server stays the authority). A def promoted from
205+
`bulkActions: ['<name>']` (or an aggregate def naming a declared action)
206+
inherits the action's own declaration instead, so this key matters chiefly for
207+
the `update`/`delete` data-plane forms, which dispatch no action and have
208+
nothing to inherit from. On those defs the gate governs **visibility only**
209+
the mass write itself is still authorized by the data API's object permissions.
210+
201211
A url or api action rendered on the list **toolbar** can also read the current
202212
selection through target interpolation`${ctx.selection.ids}` (comma-joined)
203213
and `${ctx.selection.count}`without any bulk wiring.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* Selection-bar capability gate over INLINE bulk defs (#6257; ADR-0066 D4).
5+
*
6+
* `showcase_project.default` declares two capability-gated inline defs next to
7+
* four ungated ones (src/ui/views/project.view.ts):
8+
*
9+
* • `relabel_ops` — requiredPermissions: ['showcase.export_data'],
10+
* granted to the Ops position only;
11+
* • `purge_restricted` — requiredPermissions: ['showcase.restricted_ops'],
12+
* granted to NOBODY (src/security/capabilities.ts).
13+
*
14+
* The signed-in caller is the seeded platform admin (e2e/global-setup.ts), who
15+
* holds NEITHER showcase capability: `admin_full_access` carries only the
16+
* platform capability set, and the gate reads GRANTS — the admin bit is not an
17+
* exemption. So with rows selected, the bar must show every ungated def and
18+
* neither gated one. Only the "absent" half is pinned here because it is the
19+
* half CI can assert without mutating grants; the positive Ops-held cell and
20+
* the declaration flip (`['showcase.restricted_ops']` → `[]` → back) are the
21+
* issue's real-machine protocol (#6257).
22+
*
23+
* Before spec #6257 the gated pair was not even declarable: `.strict()`
24+
* `BulkActionDefSchema` had no `requiredPermissions`, while the bar already
25+
* filtered on it (objectui#3492) — this spec is what notices either side
26+
* regressing.
27+
*/
28+
const APP = process.env.SHOWCASE_APP || 'com.example.showcase';
29+
30+
test('selection bar hides capability-gated inline defs from a caller without the grants', async ({ page }) => {
31+
await page.goto(`/_console/apps/${APP}/showcase_project`, { waitUntil: 'domcontentloaded' });
32+
await page.locator('main').first().waitFor({ state: 'visible', timeout: 25_000 });
33+
34+
// Tick the header select-all checkbox — the grid's first checkbox.
35+
const selectAll = page.getByRole('checkbox').first();
36+
await selectAll.waitFor({ state: 'visible', timeout: 25_000 });
37+
await selectAll.click();
38+
39+
// The bar offers the ungated defs…
40+
await expect(page.getByTestId('bulk-action-set_labels')).toBeVisible({ timeout: 15_000 });
41+
await expect(page.getByTestId('bulk-action-assign_team')).toBeVisible();
42+
await expect(page.getByTestId('bulk-action-reassign_account')).toBeVisible();
43+
await expect(page.getByTestId('bulk-action-reschedule')).toBeVisible();
44+
45+
// …and neither gated def — the ungated assertions above are what make these
46+
// two absences evidence of the GATE, not of a bar that failed to render.
47+
await expect(page.getByTestId('bulk-action-relabel_ops')).toHaveCount(0);
48+
await expect(page.getByTestId('bulk-action-purge_restricted')).toHaveCount(0);
49+
});

examples/app-showcase/src/security/capabilities.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,11 @@ export const ExportDataCapability = defineCapability({
6161
* the ADR-0066 three-way separation, and the one a demo usually leaves out.
6262
*
6363
* Its whole job is to be required and never held: `showcase_zoo_perm_missing`
64-
* (and the AND-gate specimen next to it, see ui/actions/predicate-matrix.action.ts)
65-
* lists it in `requiredPermissions`, so those buttons must be absent for every
66-
* caller on every one of the four action surfaces. Without a capability nobody
64+
* (and the AND-gate specimen next to it, see ui/actions/predicate-matrix.action.ts),
65+
* plus the INLINE data-plane bulk def `purge_restricted`
66+
* (ui/views/project.view.ts, #6257), list it in `requiredPermissions`, so those
67+
* buttons must be absent for every caller on every one of the four action
68+
* surfaces. Without a capability nobody
6769
* holds there is nothing to notice when a surface stops applying the gate —
6870
* which is exactly how the selection bar shipped ignoring `requiredPermissions`
6971
* outright (objectui#3492) while three other surfaces honoured it.

examples/app-showcase/src/ui/views/project.view.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export const ProjectViews = defineView({
4040
// • reassign_account → single-select on a `lookup` param (searchable
4141
// reference picker, not a bare dropdown)
4242
// • reschedule → the new `date` control + a single-select together
43+
// The last two defs are the CAPABILITY-GATED pair (ADR-0066 D4, #6257) —
44+
// see their own comment below.
4345
bulkActionDefs: [
4446
{
4547
name: 'set_labels',
@@ -117,6 +119,35 @@ export const ProjectViews = defineView({
117119
},
118120
],
119121
},
122+
// ── Capability-gated INLINE defs (ADR-0066 D4, #6257) ────────────────
123+
// The cell the #6157 action-gating matrix could not pin: an inline
124+
// DATA-PLANE def dispatches no action, so it has nothing to inherit a
125+
// gate from — before #6257 there was no legal spelling of
126+
// `requiredPermissions` here at all. Same specimen pair as the zoo's
127+
// `showcase_zoo_perm_held` / `showcase_zoo_perm_missing` (see
128+
// security/capabilities.ts): one capability Operations HOLDS, one that
129+
// is granted to NOBODY. The selection bar must therefore show
130+
// `relabel_ops` exactly to Ops-position callers, show `purge_restricted`
131+
// to no one (platform admin included — the gate reads GRANTS, not the
132+
// admin bit), and keep the four ungated defs above visible to everyone
133+
// who can open the list. The gate governs visibility only: the write
134+
// itself is still authorized by the data API's object permissions.
135+
{
136+
name: 'relabel_ops',
137+
label: 'Relabel (Ops)',
138+
operation: 'update',
139+
patch: { labels: ['qa'] },
140+
requiredPermissions: ['showcase.export_data'],
141+
confirmText: 'Reset the labels of every selected project to QA?',
142+
},
143+
{
144+
name: 'purge_restricted',
145+
label: 'Purge (Restricted)',
146+
operation: 'delete',
147+
variant: 'danger',
148+
requiredPermissions: ['showcase.restricted_ops'],
149+
confirmText: 'Permanently delete every selected project?',
150+
},
120151
],
121152
},
122153
listViews: {

packages/spec/authorable-surface/ui.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@
167167
"ui/BulkActionDef:operation",
168168
"ui/BulkActionDef:params",
169169
"ui/BulkActionDef:patch",
170+
"ui/BulkActionDef:requiredPermissions",
170171
"ui/BulkActionDef:variant",
171172
"ui/BulkActionDef:visible",
172173
"ui/BulkActionParam:default",

packages/spec/src/ui/bulk-action.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,68 @@ describe('BulkActionDefSchema (#4457)', () => {
212212
.toBeDefined();
213213
});
214214
});
215+
216+
describe('— `requiredPermissions` is declarable on every def form (ADR-0066 D4, #6257)', () => {
217+
// The renderer filtered on this key from objectui#3492 on, while the schema
218+
// rejected every attempt to write it — `enforced ≠ declarable`. The forms
219+
// below are the ones that USED to have no legal spelling: a data-plane def
220+
// dispatches no action, so there is nothing to inherit a gate from.
221+
it('accepts the gate on an inline `delete` def — the form with nowhere to inherit from', () => {
222+
const def = ok({ name: 'purge_projects', operation: 'delete', requiredPermissions: ['showcase.restricted_ops'] });
223+
expect(def.requiredPermissions).toEqual(['showcase.restricted_ops']);
224+
});
225+
226+
it('accepts the gate on a declarative `update` + `patch` def', () => {
227+
const def = ok({
228+
name: 'archive_projects',
229+
operation: 'update',
230+
patch: { status: 'archived' },
231+
requiredPermissions: ['plan.push_down'],
232+
});
233+
expect(def.requiredPermissions).toEqual(['plan.push_down']);
234+
});
235+
236+
it('accepts the gate on an aggregate custom def alongside the inherited route', () => {
237+
// Legal but usually redundant — an aggregate def already inherits the
238+
// named action's declaration. Authored here, the authored key wins the
239+
// renderer's merge, so it must parse rather than force the inherit path.
240+
const def = ok({
241+
name: 'recalc_selection',
242+
operation: 'custom',
243+
execution: 'aggregate',
244+
requiredPermissions: ['showcase.export_data'],
245+
});
246+
expect(def.requiredPermissions).toEqual(['showcase.export_data']);
247+
});
248+
249+
it('accepts several capabilities — the renderer ANDs them', () => {
250+
const def = ok({
251+
name: 'purge_projects',
252+
operation: 'delete',
253+
requiredPermissions: ['showcase.export_data', 'showcase.restricted_ops'],
254+
});
255+
expect(def.requiredPermissions).toHaveLength(2);
256+
});
257+
258+
it('accepts `requiredPermissions: []` — the explicit always-pass declaration', () => {
259+
expect(ok({ name: 'purge_projects', operation: 'delete', requiredPermissions: [] }).requiredPermissions)
260+
.toEqual([]);
261+
});
262+
263+
it('stays optional — a def without it parses exactly as before', () => {
264+
expect(ok({ name: 'purge_projects', operation: 'delete' }).requiredPermissions).toBeUndefined();
265+
});
266+
267+
it('rejects a bare-string declaration — the gate is a list, never a scalar', () => {
268+
expect(reject({ name: 'purge_projects', operation: 'delete', requiredPermissions: 'showcase.restricted_ops' })
269+
.join('\n')).toContain('requiredPermissions');
270+
});
271+
272+
it('renames the `ActionSchema` near-misses onto the canonical key', () => {
273+
expect(reject({ name: 'purge_projects', operation: 'delete', permissions: ['x'] }).join('\n'))
274+
.toContain('`permissions` → `requiredPermissions`');
275+
expect(reject({ name: 'purge_projects', operation: 'delete', requiredCapabilities: ['x'] }).join('\n'))
276+
.toContain('`requiredCapabilities` → `requiredPermissions`');
277+
});
278+
});
215279
});

packages/spec/src/ui/bulk-action.zod.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ export type BulkActionParam = z.infer<typeof BulkActionParamSchema>;
136136
/** Declared keys of a bulk-action def — the "did you mean" pool. */
137137
const BULK_ACTION_DEF_KEYS = [
138138
'name', 'label', 'icon', 'variant', 'operation', 'execution', 'patch',
139-
'params', 'confirmText', 'confirmLabel', 'visible', 'maxRecords', 'batchSize',
139+
'params', 'confirmText', 'confirmLabel', 'visible', 'requiredPermissions',
140+
'maxRecords', 'batchSize',
140141
] as const;
141142

142143
const bulkActionDefUnknownKeyError = strictUnknownKeyError({
@@ -153,6 +154,12 @@ const bulkActionDefUnknownKeyError = strictUnknownKeyError({
153154
limit: 'maxRecords',
154155
max: 'maxRecords',
155156
batch: 'batchSize',
157+
// The capability gate IS a declared key here too — `requiredPermissions`
158+
// (ADR-0066 D4, #6257) — so its near-misses RENAME onto it, exactly as
159+
// they do on `ActionSchema`.
160+
permissions: 'requiredPermissions', capabilities: 'requiredPermissions',
161+
requiresPermissions: 'requiredPermissions', requiredCapabilities: 'requiredPermissions',
162+
acl: 'requiredPermissions',
156163
},
157164
guidance: {
158165
// Not a typo — a real key the RENDERER attaches, which is exactly why an
@@ -208,6 +215,7 @@ export const BulkActionDefSchema = lazySchema(() => z.object({
208215
confirmText: z.string().optional().describe('Confirmation text shown above the affected-record summary.'),
209216
confirmLabel: z.string().optional().describe('Custom Confirm button label (default: "Run").'),
210217
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.'),
218+
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."),
211219
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.'),
212220
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.'),
213221
}, { error: bulkActionDefUnknownKeyError }).strict()

0 commit comments

Comments
 (0)