diff --git a/.changeset/9782-related-toolbar-capability-mirror.md b/.changeset/9782-related-toolbar-capability-mirror.md new file mode 100644 index 0000000000..786ba66d19 --- /dev/null +++ b/.changeset/9782-related-toolbar-capability-mirror.md @@ -0,0 +1,35 @@ +--- +'@object-ui/plugin-detail': patch +--- + +The related list's `list_toolbar` header now mirrors ADR-0066 D4 +`requiredPermissions` as a UI hide (objectui#9782). + +`@objectstack/spec` declares `requiredPermissions` as "enforced with 403 on the +platform action route (script/flow/modal + MCP) and **mirrored as a UI hide**". +`RelatedList` drew its header buttons from the set +`RelatedRecordActionsBridge.deriveActions` hands it, and that bridge filters on +`locations` alone — so the toolbar evaluated each action's `visible` CEL +predicate and nothing else. An action declaring a capability the caller does not +hold rendered anyway, one surface over from `EnvironmentListToolbar`, which +hides it. This is the third and last carrier of that one declaration, after the +data-table row menu (objectui#9623) and `DeclaredActionsBar` (objectui#9572). + +The shared `useCapabilityGate` is now applied **once** to the toolbar set, in +the list's own body. Placement is the point rather than an afterthought: the +hook resolves the held set from the nearest `ActionProvider` above its caller, +and objectui#9572 measured what a gate on the wrong side of that provider does — +it reads a different provider, or none at all, and fails open on every action +forever with a green suite. Unlike `DeclaredActionsBar`, this component mounts +no provider of its own, so the list body and the buttons it draws read the same +one, the provider `RecordDetailView` seeds with the `user.systemPermissions` the +action engine reads. The new suite supplies the held set through that provider +alone, so moving the filter out of the component fails it. + +Nothing is enforced in the renderer and no request changes shape: the server +remains the sole authority and still answers 403. Unknown capabilities fail +**open** (an absent `systemPermissions` is not a denial), an empty held set +means "holds nothing" and gates normally, and because the capability gate is +ANDed in front of the existing fail-closed `visible` predicate the composition +is monotone — it can only ever hide more than before, never expose something +that was hidden. diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 9d6421fa50..4c265bfe59 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -28,7 +28,7 @@ import { resolveIcon, useIsMobile, } from '@object-ui/components'; -import { SchemaRenderer, useCondition, toPredicateInput, type RelatedRowActionDef } from '@object-ui/react'; +import { SchemaRenderer, useCapabilityGate, useCondition, toPredicateInput, type RelatedRowActionDef } from '@object-ui/react'; import { Plus, ExternalLink, @@ -462,6 +462,58 @@ export const RelatedList: React.FC = ({ const { t } = useDetailTranslation(); const { fieldLabel: resolveFieldLabel } = useSafeFieldLabel(); + /** + * [ADR-0066 D4 / objectui#9782] The `list_toolbar` set this header may draw, + * with `requiredPermissions` mirrored as a UI hide. + * + * `@objectstack/spec` declares the key as "enforced with 403 on the platform + * action route (script/flow/modal + MCP) and **mirrored as a UI hide**". The + * same bridge (`RelatedRecordActionsBridge.deriveActions`) feeds the child + * object's `list_item` actions and these header buttons, and it filters on + * `locations` alone — so before this the toolbar honoured `visible` and + * nothing else, and an action declaring a capability the caller lacks + * rendered one surface over from `EnvironmentListToolbar`, which hides it. + * Third and last carrier of one declaration, after the data-table row menu + * (objectui#9623) and `DeclaredActionsBar` (objectui#9572). + * + * ⛔ A UI MIRROR of a decision the SERVER still enforces, and nothing more: + * the route still answers 403, the dispatch below is byte-identical either + * way, and ⛔ no enforcement moves into the renderer. Unknown capabilities + * fail OPEN (see `useCapabilityGate`) — an absent `systemPermissions` is not + * a denial — while an EMPTY held set means "holds nothing" and gates + * normally. + * + * ⭐ PLACEMENT is load-bearing, and it is why the filter sits in this body. + * `useCapabilityGate` resolves the held set from the nearest + * `ActionProvider` ABOVE its caller, and objectui#9572 measured what a gate + * on the wrong side of that provider does: it reads a different provider, or + * none, and fails open on every action forever with a green suite. Unlike + * `DeclaredActionsBar`, this component mounts NO provider of its own — its + * host chain is `RecordDetailView`'s `ActionProvider` → + * `RelatedRecordActionsBridge` → `SchemaRenderer` → here — so this body and + * the buttons it draws read one and the same provider, the one seeded with + * the `user.systemPermissions` the engine reads. + * `RelatedList.toolbarCapabilityGate-9782.test.tsx` supplies the held set + * through that provider ALONE, so moving this filter out of the component + * fails it. + * + * Applied ONCE over the set rather than inside `RelatedToolbarButton`, so + * whatever the header derives from these actions and the buttons themselves + * read one filtered source (the objectui#3562 invariant). ⚠️ Nothing in this + * header currently counts them — the action row draws Add/New too, so a + * wholly denied set leaves no orphan chrome today — and gating once is what + * keeps that true if something starts counting. + * + * Composes with, and never replaces, the fail-CLOSED `visible` CEL each + * button still evaluates: the two are ANDed, so the composition is MONOTONE + * and can only ever hide more than before. + */ + const mayInvoke = useCapabilityGate(); + const permittedToolbarActions = React.useMemo( + () => (toolbarActions ?? []).filter((a) => mayInvoke(a?.requiredPermissions)), + [toolbarActions, mayInvoke], + ); + const effectivePageSize = pageSize && pageSize > 0 ? pageSize : 0; // The built-in contains-filter is a CLIENT-side sweep over every field — // inexpressible as a generic server filter. While the user is typing in it @@ -1702,7 +1754,7 @@ export const RelatedList: React.FC = ({ {/* Child-object list_toolbar actions (e.g. Invite User) — the related-list equivalent of the object list's toolbar buttons. Rendered before Add/New so the domain action leads. */} - {onToolbarAction && (toolbarActions ?? []).map((a) => ( + {onToolbarAction && permittedToolbarActions.map((a) => ( { + originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => ({ json: async () => [] }) as any) as any; +}); +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** + * The live example named on the card and in the component's own docblock: + * `invite_user` on an organization's Invitations list. `requiredPermissions` is + * carried through `RelatedRecordActionsBridge.deriveActions` untouched — that + * bridge filters on `locations` alone and the localizer spreads the rest — so + * the declaration arrives at this component exactly as authored. + */ +const INVITE = { + name: 'invite_user', + label: 'Invite User', + requiredPermissions: ['manage_org_users'], +}; + +/** A sibling declaring no capability — the control that must survive the filter. */ +const EXPORT = { name: 'export', label: 'Export' }; + +/** Holds the declared capability. */ +const OWNER = { id: 'owner', name: 'Olga', systemPermissions: ['manage_org_users', 'setup.access'] }; +/** ⭐ EMPTY, not absent — "holds nothing", which gates normally. */ +const MEMBER = { id: 'member', name: 'Mel', systemPermissions: [] as string[] }; +/** No `systemPermissions` key at all — the unknown case, which fails OPEN. */ +const UNREPORTED = { id: 'member', name: 'Mel' }; + +/** + * The held set reaches the list through the `ActionProvider` and through + * NOTHING else: the predicate scope is deliberately empty, so a gate that ends + * up reading anything other than this provider resolves `undefined` and fails + * open — which is exactly how a misplaced gate announces itself here. + */ +function wrapper(user: unknown, scope: Record = {}) { + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +} + +function renderList(toolbarActions: unknown[], user: unknown, scope: Record = {}) { + const Wrapper = wrapper(user, scope); + return render( + + {}} + /> + , + ); +} + +/** The header button for a `list_toolbar` action, by the component's own testid. */ +const toolbarButton = (name: string) => screen.queryByTestId(`related-toolbar-action-${name}`); + +/** The list header is async (the fetch branch above settles first). */ +const headerReady = () => waitFor(() => expect(screen.getByText('Invitations')).toBeInTheDocument()); + +describe('RelatedList list_toolbar — ADR-0066 D4 capability mirror (objectui#9782)', () => { + it('resolves the caller capabilities AT THIS MOUNT, and keeps EMPTY apart from ABSENT', () => { + // The placement measurement, taken before any rendering claim rests on it: + // the held set really is reachable from where the list body sits, so the + // repair is this component consulting the gate — nothing has to be threaded + // through the bridge, and no new prop is needed. + const held = renderHook(() => useHeldCapabilities(), { wrapper: wrapper(MEMBER) }).result.current; + expect(held).toEqual([]); + expect(held).not.toBeUndefined(); + + const unknown = renderHook(() => useHeldCapabilities(), { wrapper: wrapper(UNREPORTED) }).result.current; + expect(unknown).toBeUndefined(); + }); + + it('DETECTOR — hides a toolbar action whose `requiredPermissions` the caller does not hold', async () => { + renderList([INVITE], MEMBER); + await headerReady(); + expect(toolbarButton('invite_user')).toBeNull(); + expect(screen.queryByText('Invite User')).toBeNull(); + }); + + it('shows the same action to a caller who holds the capability', async () => { + renderList([INVITE], OWNER); + await headerReady(); + expect(toolbarButton('invite_user')).toBeInTheDocument(); + }); + + it('fails OPEN when nothing reported `systemPermissions` (unknown is not denied)', async () => { + // ⭐ The arm a careless repair breaks. The doctrine `useCapabilityGate` + // states, pinned at this surface: the server is the authority either way, + // and hiding a permitted user's button on missing client data is the worse + // failure. Green before the repair too — it is a guard on the repair, not a + // detector of the defect. + renderList([INVITE], UNREPORTED); + await headerReady(); + expect(toolbarButton('invite_user')).toBeInTheDocument(); + }); + + it('removes only the gated action, leaving its ungated sibling', async () => { + renderList([INVITE, EXPORT], MEMBER); + await headerReady(); + expect(toolbarButton('export')).toBeInTheDocument(); + expect(toolbarButton('invite_user')).toBeNull(); + }); + + it('leaves an action declaring no capability untouched', async () => { + renderList([EXPORT], MEMBER); + await headerReady(); + expect(toolbarButton('export')).toBeInTheDocument(); + }); + + it('composes with the fail-CLOSED `visible` predicate rather than replacing it', async () => { + // The two gates are ANDed and the composition is MONOTONE: a capability + // gate in front of the existing `visible` CEL can only ever hide more than + // before, never expose something that was hidden. Pinned in the direction + // that a filter written as a REPLACEMENT for `visible` would break — the + // caller holds the capability, so only `visible` can still hide it. + renderList( + [{ ...INVITE, visible: 'features.organization != false' }], + OWNER, + // The scope is authored rather than left empty: `features` must RESOLVE + // to false, so the hide below is the predicate answering, never an + // unresolvable one failing closed for an unrelated reason. + { features: { organization: false } }, + ); + await headerReady(); + expect(toolbarButton('invite_user')).toBeNull(); + }); +});