From e536664b150cd0fef0aead8d6d909d0e76362687 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:55:55 +0000 Subject: [PATCH] feat(spec): declare requiredPermissions on BulkActionDefSchema (#6257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: [''] 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). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PD7tZG1vENc5peMaLQC1uD --- .../bulk-action-required-permissions.md | 36 +++++++++++ content/docs/references/ui/bulk-action.mdx | 1 + content/docs/ui/views.mdx | 10 +++ .../e2e/bulk-capability-gate.spec.ts | 49 ++++++++++++++ .../app-showcase/src/security/capabilities.ts | 8 ++- .../app-showcase/src/ui/views/project.view.ts | 31 +++++++++ packages/spec/authorable-surface/ui.json | 1 + packages/spec/src/ui/bulk-action.test.ts | 64 +++++++++++++++++++ packages/spec/src/ui/bulk-action.zod.ts | 10 ++- 9 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 .changeset/bulk-action-required-permissions.md create mode 100644 examples/app-showcase/e2e/bulk-capability-gate.spec.ts diff --git a/.changeset/bulk-action-required-permissions.md b/.changeset/bulk-action-required-permissions.md new file mode 100644 index 0000000000..1530043f01 --- /dev/null +++ b/.changeset/bulk-action-required-permissions.md @@ -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: ['']` (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`). diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx index d2c43db4d5..3005017a7d 100644 --- a/content/docs/references/ui/bulk-action.mdx +++ b/content/docs/references/ui/bulk-action.mdx @@ -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: ['']` (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. | diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 8d4f6826cd..0c926d081a 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -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: ['']` (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. diff --git a/examples/app-showcase/e2e/bulk-capability-gate.spec.ts b/examples/app-showcase/e2e/bulk-capability-gate.spec.ts new file mode 100644 index 0000000000..dd52e27120 --- /dev/null +++ b/examples/app-showcase/e2e/bulk-capability-gate.spec.ts @@ -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); +}); diff --git a/examples/app-showcase/src/security/capabilities.ts b/examples/app-showcase/src/security/capabilities.ts index 7fcdc7935e..16ee6086e6 100644 --- a/examples/app-showcase/src/security/capabilities.ts +++ b/examples/app-showcase/src/security/capabilities.ts @@ -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. diff --git a/examples/app-showcase/src/ui/views/project.view.ts b/examples/app-showcase/src/ui/views/project.view.ts index dc8f533743..0b91826268 100644 --- a/examples/app-showcase/src/ui/views/project.view.ts +++ b/examples/app-showcase/src/ui/views/project.view.ts @@ -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', @@ -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: { diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 19654b61ac..f2d05d6260 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -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", diff --git a/packages/spec/src/ui/bulk-action.test.ts b/packages/spec/src/ui/bulk-action.test.ts index 091f319255..4574cf4ca1 100644 --- a/packages/spec/src/ui/bulk-action.test.ts +++ b/packages/spec/src/ui/bulk-action.test.ts @@ -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`'); + }); + }); }); diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index 7683ecc74c..e506e9db17 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -136,7 +136,8 @@ export type BulkActionParam = z.infer; /** 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({ @@ -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 @@ -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: ['']` (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()