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
13 changes: 13 additions & 0 deletions .changeset/10058-quick-actions-capability-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,16 @@ REPORTED empty array is a real answer and gates strictly.

`perms.can()` itself is untouched: no other caller moves, and the full
before/after truth table of both stock providers is pinned as unchanged.

**Correction, 2026-10-01 (objectui#10224).** The phrase "hides the whole bar"
above is wrong about what the reader sees. When a declared capability is unheld or
unrecognised, no action is drawn and an insufficient-permissions notice
(`role="status"`) renders where the bar would be. That is what the contract's
shared record-block `requiredPermissions` describe says: "this block does not
render its content; wherever it would otherwise render, an
insufficient-permissions notice takes its place". The `record:quick_actions`
registration now publishes that describe verbatim, in place of "Hide the whole
bar unless the user holds these permissions", and the contract wording quoted
in the first paragraph is retired upstream (objectstack#18159). Everything
else above stands: the capabilities the gate asks for, its fail-closed
verdict, and the fail-open when capabilities are unreported.
19 changes: 19 additions & 0 deletions .changeset/10224-quick-actions-permissions-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/plugin-detail': patch
---

fix(plugin-detail): `record:quick_actions.requiredPermissions` publishes what the gate does — the contract's shared record-block describe, verbatim

The `record:quick_actions` registration described `requiredPermissions` as "Hide
the whole bar unless the user holds these permissions". The renderer does not
hide the bar. It reads the ADR-0066 capability set, and when a declared
capability is missing it draws an insufficient-permissions notice where the bar
would be. When the client cannot resolve capabilities (no permission provider,
or a backend that does not report `systemPermissions`) it renders the bar.

`@objectstack/spec` 17.5.0 gives this key one describe, shared with
`record:details`, `record:highlights` and `record:related_list`, and those three
already publish it. The quick-actions input now publishes the same text. It is
carried by `sdui.manifest.json`, and at runtime by
`ComponentRegistry.getConfig('record:quick_actions').inputs`. No input name,
type or shape changes, and no rendering or gating behaviour changes.
15 changes: 9 additions & 6 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2439,11 +2439,14 @@ const MULTI_KIND_MEMBER_CONTRACTS: Record<string, string> = {
// (the shape this card's own dispatch names, over an arbitrary four). Neither
// key needed `NO_READ_SITE_TO_PIN`: `actionNames` drives a real
// `useMetadataItem` lookup against the object's own `actions`, and
// `requiredPermissions` is a block-level gate (`required.every((p) =>
// perms.can(objectName, p))`) that hides the whole bar before anything is
// drawn — a DIFFERENT mechanism from an `ActionDef`'s own per-action field of
// the same name, which a pre-existing fixture already exercised without
// touching this key at all. Measured over this file's own ledger, before and
// `requiredPermissions` is a block-level gate that puts an
// insufficient-permissions notice in place of the whole bar before any action
// is drawn. It reads the capability set (`perms.hasCapabilities(required)`)
// since objectui#10058; slice 4 found it reading `perms.can(objectName, p)`
// (sentence corrected by objectui#10224). It is a DIFFERENT mechanism from an
// `ActionDef`'s own per-action field of the same name, which a pre-existing
// fixture already exercised without touching this key at all. Measured over
// this file's own ledger, before and
// after: 90 array/object-armed inputs, 40 pinned, 50 exempt -> 90, 42 pinned,
// 48 exempt, and `MEMBER_PIN_EXEMPTION_CEILING` follows 50 -> 48 in the same
// commit. One of the two pins PROMOTES a pre-existing file
Expand Down Expand Up @@ -3036,7 +3039,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'record:quick_actions.requiredPermissions': {
file: 'packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx',
pins: 'The BLOCK-LEVEL gate (`required.every((p) => perms.can(objectName, p))`) that hides the whole bar before any action is drawn — distinct from an `ActionDef`\'s own per-action `requiredPermissions`, which `record-quick-actions.declared-action-ids-7182.test.tsx`\'s `gated` fixture already covers. No key at all: the bar renders on the (empty) grant set, so the gate is provably driven by the key\'s PRESENCE. A single held permission gates as expected, and the discriminating row is a PARTIAL grant on a two-entry array — gated only when read as `.every` over the WHOLE array rather than its first element — with the all-granted case as that row\'s own positive control. New file: no existing test drove `schema.requiredPermissions` itself, only the unrelated per-action field of the same name (objectui#8071).',
pins: 'The BLOCK-LEVEL ADR-0066 capability gate, read through `perms.hasCapabilities(required)` (objectui#10058) — distinct from an `ActionDef`\'s own per-action `requiredPermissions`, which `record-quick-actions.declared-action-ids-7182.test.tsx`\'s `gated` fixture already covers. Members are CAPABILITY names and the bar needs ALL of them, driven through the real renderer under a REAL stock `MePermissionsProvider`: an unheld capability and an unrecognised one each put the insufficient-permissions notice in place of the whole bar with no action drawn, a held one renders the bar, an EMPTY array and an ABSENT key are no gate at all, and the discriminating row is a PARTIAL grant on a two-entry array — gated only when the members are read as `.every` over the WHOLE array rather than its first element — with the all-granted case as that row\'s own positive control. Discriminators pin that a member is a capability and not an object action (`allowRead` on the object does not open the gate, a held capability opens it with `allowRead: false`, the enum member `manage` is gated rather than resolved to the read bit, and `read` / `update` are gated despite `allowRead` / `allowEdit`), a detector pins that the object-permission path is never asked about a member, and the gate holds with no `objectName` in the record context. A client that cannot resolve capabilities fails open: the role-based `PermissionProvider`, no provider at all, and a stock provider whose backend omits `systemPermissions`. The same file pins the registration\'s description as the installed spec\'s shared record-block describe, verbatim, and as the text the three sibling record blocks publish (objectui#10224). New file at objectui#8071 slice 4: no existing test drove `schema.requiredPermissions` itself, only the unrelated per-action field of the same name.',
},
'record:related_list.actions': {
file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.authoredActions-11163.test.tsx',
Expand Down
14 changes: 13 additions & 1 deletion packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,19 @@ ComponentRegistry.register('quick_actions', RecordQuickActionsRenderer, {
// Implementing the fallback would be a behaviour expansion and needs its own
// card; pinned by `recordQuickActionsInputs.actionNamesFallback.test.tsx`.
{ name: 'actionNames', type: 'array', of: 'string', description: 'Action names to expose, in order — resolved from the actions declared on the object. With no names (and no host-supplied actions) nothing is looked up and the bar renders its empty placeholder' },
{ name: 'requiredPermissions', type: 'array', of: 'string', description: 'Hide the whole bar unless the user holds these permissions' },
// The contract's shared record-block describe, verbatim (objectstack#18159):
// `record:quick_actions` carries the one text `record:details`,
// `record:highlights` and `record:related_list` share, and objectui#8649
// already publishes it on those three. This input used to read
// "Hide the whole bar unless the user holds these permissions", which is not
// what the renderer does: the gate reads the CAPABILITY set
// (`perms.hasCapabilities`, objectui#10058), puts a `role="status"`
// insufficient-permissions notice where the bar would be, and fails open when
// capabilities are unreported (objectui#10224). Each clause is pinned on this
// block by `record-quick-actions.requiredPermissions-gate.test.tsx`, which
// also re-reads the installed describe every run, so a spec that rewords it
// turns that file red rather than leaving this text to drift.
{ name: 'requiredPermissions', type: 'array', of: 'string', description: '[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object\'s read or edit permission. When the client has resolved the user\'s capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user\'s capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open.' },
// Derived from the spec's own vocabulary rather than restated — #3019.
{ name: 'location', type: 'enum', enum: [...ACTION_LOCATIONS], description: 'Which declared action location this bar renders' },
{ name: 'align', type: 'enum', enum: ['start', 'center', 'end'] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
* The key is an **ADR-0066 system capability set** — the one meaning the word
* carries on `action`, `app`, `field` and `bulkAction` — read through the
* permission context's capability path and gating **fail-closed**: an unheld
* or unrecognised capability hides the block. objectui#10058 settled that for
* `record:quick_actions`; these three carried the identical call and were
* untouched by it.
* or unrecognised capability withholds the block's content, and an
* insufficient-permissions notice (`role="status"`) renders in its place — the
* block is not hidden (wording corrected by objectui#10224). objectui#10058
* settled the capability read for `record:quick_actions`; these three carried
* the identical call and were untouched by it.
*
* All three used to read `perms.can(objectName, name)`, whose second argument
* is the closed object-action enum. Under the stock `/me/permissions` provider
Expand Down Expand Up @@ -175,7 +177,7 @@ describe.each(BLOCKS)('$key — the `requiredPermissions` capability gate on MeP
const noBody = () => expect(screen.queryByTestId(block.shown)).not.toBeInTheDocument();
const noRefusal = () => expect(screen.queryByText(block.refusal)).not.toBeInTheDocument();

it('hides the WHOLE block when the declared capability is not held (REPORTED-empty capability set)', async () => {
it('puts the notice in place of the WHOLE block when the declared capability is not held (REPORTED-empty capability set)', async () => {
render(<MePermissionsProvider initialPermissions={me([])}>{bound(block, { requiredPermissions: ['crm.manage'] })}</MePermissionsProvider>);
expect(await refused()).toBeInTheDocument();
noBody();
Expand All @@ -187,7 +189,7 @@ describe.each(BLOCKS)('$key — the `requiredPermissions` capability gate on MeP
noRefusal();
});

it('an UNKNOWN capability name hides the block — unrecognised is refused, not waved through', async () => {
it('an UNKNOWN capability name gets the notice, not the block — unrecognised is refused, not waved through', async () => {
render(<MePermissionsProvider initialPermissions={me(['crm.manage'])}>{bound(block, { requiredPermissions: ['not.a.real.capability'] })}</MePermissionsProvider>);
expect(await refused()).toBeInTheDocument();
noBody();
Expand Down
Loading
Loading