diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 322ed9d02..7636f66c0 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -24,6 +24,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; +import { usePermissions } from '@object-ui/permissions'; import { useObjectLabel } from '@object-ui/i18n'; import { ActionProvider, useGlobalUndo } from '@object-ui/react'; import { toast } from 'sonner'; @@ -87,6 +88,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons const { dataSource, objects, objectName, onRefresh } = opts; const navigate = useNavigate(); const { user, activeOrganization } = useAuth(); + // [ADR-0066 D4] System capabilities for the action capability gate (fail-open + // when no PermissionProvider is mounted — usePermissions returns []). + const { systemPermissions } = usePermissions(); const { fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionDescription } = useObjectLabel(); const objectDef = useMemo( @@ -167,8 +171,8 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons }, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel]); const currentUser = user - ? { id: user.id, name: user.name, avatar: user.image, isPlatformAdmin: (user as any)?.isPlatformAdmin ?? false } - : FALLBACK_USER; + ? { id: user.id, name: user.name, avatar: user.image, isPlatformAdmin: (user as any)?.isPlatformAdmin ?? false, systemPermissions: systemPermissions ?? [] } + : { ...FALLBACK_USER, systemPermissions: systemPermissions ?? [] }; const toastHandler = useCallback((message, options) => { if (options?.type === 'error') { toast.error(message); return; } diff --git a/packages/core/src/actions/ActionEngine.ts b/packages/core/src/actions/ActionEngine.ts index 75f706e94..070f6df61 100644 --- a/packages/core/src/actions/ActionEngine.ts +++ b/packages/core/src/actions/ActionEngine.ts @@ -195,6 +195,19 @@ export class ActionEngine { const evaluator = this.runner.getEvaluator(); return Array.from(this.actions.values()) .filter(ra => ra.locations.includes(location)) + .filter(ra => { + // [ADR-0066 D4] Capability gate (UI half — the server enforces the + // source of truth). Hide an action whose `requiredPermissions` are not + // ALL held by the caller's `systemPermissions`. Fail-OPEN when + // systemPermissions is unknown (undefined): the server still 403s, and + // hiding on missing data is a worse regression than showing a button + // that errors clearly. Mirrors the App/nav requiredPermissions gate. + const required = (ra.action as any).requiredPermissions as string[] | undefined; + if (!Array.isArray(required) || required.length === 0) return true; + const held = (this.runner.getContext() as any)?.user?.systemPermissions as string[] | undefined; + if (!Array.isArray(held)) return true; + return required.every((p) => held.includes(p)); + }) .filter(ra => { const raw = (ra.action as any).visible; if (raw == null || raw === '' || raw === true) return true; diff --git a/packages/core/src/actions/__tests__/ActionEngine.requiredPermissions.test.ts b/packages/core/src/actions/__tests__/ActionEngine.requiredPermissions.test.ts new file mode 100644 index 000000000..749777ae9 --- /dev/null +++ b/packages/core/src/actions/__tests__/ActionEngine.requiredPermissions.test.ts @@ -0,0 +1,54 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * ADR-0066 D4 — UI half of the action dual-surface gate. The server is the + * source of truth (403); the ActionEngine derives the same gate from + * `action.requiredPermissions` so a button the user can't use is hidden. + * Fail-OPEN when the user's systemPermissions are unknown. + */ + +import { describe, it, expect } from 'vitest'; +import { ActionEngine } from '../ActionEngine'; + +const engineWith = (systemPermissions?: string[]) => + new ActionEngine({ + user: systemPermissions === undefined ? { id: 'u1' } : { id: 'u1', systemPermissions }, + } as any); + +describe('ActionEngine.getActionsForLocation — ADR-0066 D4 requiredPermissions filter', () => { + it('shows actions with no requiredPermissions', () => { + const e = engineWith([]); + e.registerAction({ name: 'open', type: 'api' } as any, { locations: ['record_section'] }); + expect(e.getActionsForLocation('record_section')).toHaveLength(1); + }); + + it('shows an action whose requiredPermissions are all held', () => { + const e = engineWith(['manage_platform_settings']); + e.registerAction({ name: 'issue', type: 'api', requiredPermissions: ['manage_platform_settings'] } as any, { locations: ['record_section'] }); + expect(e.getActionsForLocation('record_section')).toHaveLength(1); + }); + + it('hides an action whose requiredPermissions are NOT all held', () => { + const e = engineWith(['setup.access']); + e.registerAction({ name: 'issue', type: 'api', requiredPermissions: ['manage_platform_settings'] } as any, { locations: ['record_section'] }); + expect(e.getActionsForLocation('record_section')).toHaveLength(0); + }); + + it('requires ALL listed capabilities (AND, not OR)', () => { + const e = engineWith(['a']); + e.registerAction({ name: 'x', type: 'api', requiredPermissions: ['a', 'b'] } as any, { locations: ['record_section'] }); + expect(e.getActionsForLocation('record_section')).toHaveLength(0); + }); + + it('fails OPEN when systemPermissions is unknown (server still enforces)', () => { + const e = engineWith(undefined); + e.registerAction({ name: 'issue', type: 'api', requiredPermissions: ['manage_platform_settings'] } as any, { locations: ['record_section'] }); + expect(e.getActionsForLocation('record_section')).toHaveLength(1); + }); +}); diff --git a/packages/permissions/src/MePermissionsProvider.tsx b/packages/permissions/src/MePermissionsProvider.tsx index 518c55905..5ce2c906b 100644 --- a/packages/permissions/src/MePermissionsProvider.tsx +++ b/packages/permissions/src/MePermissionsProvider.tsx @@ -24,6 +24,8 @@ export interface MePermissionsResponse { tenantId: string | null; roles: string[]; permissionSets: string[]; + /** [ADR-0066] System capabilities (union of permission-set systemPermissions): manage_users, setup.access, … */ + systemPermissions?: string[]; /** object-level perms: { "*": {...}, "account": {...} } */ objects: Record { + const held = new Set(data?.systemPermissions ?? []); + return required.every((p) => held.has(p)); + }, isLoaded: !loading && !error && data !== null, }), [check, checkField, getFieldPermissions, getRowFilter, data, loading, error], diff --git a/packages/permissions/src/PermissionContext.ts b/packages/permissions/src/PermissionContext.ts index f5d676610..3ff45f92e 100644 --- a/packages/permissions/src/PermissionContext.ts +++ b/packages/permissions/src/PermissionContext.ts @@ -20,6 +20,10 @@ export interface PermissionContextValue { getRowFilter: (object: string) => string | undefined; /** Current user roles */ roles: string[]; + /** [ADR-0066] System capabilities held by the user (union of permission-set systemPermissions). */ + systemPermissions: string[]; + /** [ADR-0066] True when the user holds ALL of `required` capabilities (subset check). */ + hasCapabilities: (required: string[]) => boolean; /** Whether permissions are loaded */ isLoaded: boolean; } diff --git a/packages/permissions/src/PermissionProvider.tsx b/packages/permissions/src/PermissionProvider.tsx index 0880bb25f..c2128b5f7 100644 --- a/packages/permissions/src/PermissionProvider.tsx +++ b/packages/permissions/src/PermissionProvider.tsx @@ -120,6 +120,12 @@ export function PermissionProvider({ getFieldPermissions, getRowFilter, roles: userRoles, + // This role-based provider does not model ADR-0066 system capabilities; + // expose an empty set + a fail-open capability check to satisfy the + // contract. The console uses MePermissionsProvider, which wires the real + // systemPermissions from /me/permissions. + systemPermissions: [], + hasCapabilities: () => true, isLoaded: true, }), [check, checkField, getFieldPermissions, getRowFilter, userRoles], diff --git a/packages/permissions/src/usePermissions.ts b/packages/permissions/src/usePermissions.ts index 6721e281a..1e2969168 100644 --- a/packages/permissions/src/usePermissions.ts +++ b/packages/permissions/src/usePermissions.ts @@ -35,6 +35,8 @@ export function usePermissions(): PermissionContextValue & { getFieldPermissions: () => [], getRowFilter: () => undefined, roles: [], + systemPermissions: [], + hasCapabilities: () => true, isLoaded: false, can: () => true, cannot: () => false,