Skip to content

Commit 3e4d75d

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(authz): ADR-0066 D4 UI half — hide actions the user lacks the capability for (#1920)
The server already enforces action requiredPermissions (403, source of truth); this derives the same gate in the UI so a button the user can't use is hidden. - ActionEngine.getActionsForLocation: filter out actions whose requiredPermissions are not ALL held by the caller's systemPermissions. Fail-OPEN when systemPermissions is unknown (the server still 403s; hiding on missing data is a worse regression than a button that errors clearly). Mirrors the App/nav gate. - permissions: expose `systemPermissions` + `hasCapabilities(required)` on the PermissionContext; MePermissionsProvider wires them from /me/permissions (which already returns systemPermissions). usePermissions fallback + the legacy PermissionProvider fail-open. - app-shell: useConsoleActionRuntime injects user.systemPermissions into the action runtime context so the engine gate can read it. - Test: ActionEngine.requiredPermissions.test.ts (show/hide/AND/fail-open). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent aa50dc0 commit 3e4d75d

7 files changed

Lines changed: 92 additions & 2 deletions

File tree

packages/app-shell/src/hooks/useConsoleActionRuntime.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import React, { useCallback, useMemo, useRef, useState } from 'react';
2525
import { useNavigate } from 'react-router-dom';
2626
import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
27+
import { usePermissions } from '@object-ui/permissions';
2728
import { useObjectLabel } from '@object-ui/i18n';
2829
import { ActionProvider, useGlobalUndo } from '@object-ui/react';
2930
import { toast } from 'sonner';
@@ -87,6 +88,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
8788
const { dataSource, objects, objectName, onRefresh } = opts;
8889
const navigate = useNavigate();
8990
const { user, activeOrganization } = useAuth();
91+
// [ADR-0066 D4] System capabilities for the action capability gate (fail-open
92+
// when no PermissionProvider is mounted — usePermissions returns []).
93+
const { systemPermissions } = usePermissions();
9094
const { fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionDescription } = useObjectLabel();
9195

9296
const objectDef = useMemo(
@@ -167,8 +171,8 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
167171
}, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel]);
168172

169173
const currentUser = user
170-
? { id: user.id, name: user.name, avatar: user.image, isPlatformAdmin: (user as any)?.isPlatformAdmin ?? false }
171-
: FALLBACK_USER;
174+
? { id: user.id, name: user.name, avatar: user.image, isPlatformAdmin: (user as any)?.isPlatformAdmin ?? false, systemPermissions: systemPermissions ?? [] }
175+
: { ...FALLBACK_USER, systemPermissions: systemPermissions ?? [] };
172176

173177
const toastHandler = useCallback<ToastHandler>((message, options) => {
174178
if (options?.type === 'error') { toast.error(message); return; }

packages/core/src/actions/ActionEngine.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,19 @@ export class ActionEngine {
195195
const evaluator = this.runner.getEvaluator();
196196
return Array.from(this.actions.values())
197197
.filter(ra => ra.locations.includes(location))
198+
.filter(ra => {
199+
// [ADR-0066 D4] Capability gate (UI half — the server enforces the
200+
// source of truth). Hide an action whose `requiredPermissions` are not
201+
// ALL held by the caller's `systemPermissions`. Fail-OPEN when
202+
// systemPermissions is unknown (undefined): the server still 403s, and
203+
// hiding on missing data is a worse regression than showing a button
204+
// that errors clearly. Mirrors the App/nav requiredPermissions gate.
205+
const required = (ra.action as any).requiredPermissions as string[] | undefined;
206+
if (!Array.isArray(required) || required.length === 0) return true;
207+
const held = (this.runner.getContext() as any)?.user?.systemPermissions as string[] | undefined;
208+
if (!Array.isArray(held)) return true;
209+
return required.every((p) => held.includes(p));
210+
})
198211
.filter(ra => {
199212
const raw = (ra.action as any).visible;
200213
if (raw == null || raw === '' || raw === true) return true;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* ADR-0066 D4 — UI half of the action dual-surface gate. The server is the
11+
* source of truth (403); the ActionEngine derives the same gate from
12+
* `action.requiredPermissions` so a button the user can't use is hidden.
13+
* Fail-OPEN when the user's systemPermissions are unknown.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import { ActionEngine } from '../ActionEngine';
18+
19+
const engineWith = (systemPermissions?: string[]) =>
20+
new ActionEngine({
21+
user: systemPermissions === undefined ? { id: 'u1' } : { id: 'u1', systemPermissions },
22+
} as any);
23+
24+
describe('ActionEngine.getActionsForLocation — ADR-0066 D4 requiredPermissions filter', () => {
25+
it('shows actions with no requiredPermissions', () => {
26+
const e = engineWith([]);
27+
e.registerAction({ name: 'open', type: 'api' } as any, { locations: ['record_section'] });
28+
expect(e.getActionsForLocation('record_section')).toHaveLength(1);
29+
});
30+
31+
it('shows an action whose requiredPermissions are all held', () => {
32+
const e = engineWith(['manage_platform_settings']);
33+
e.registerAction({ name: 'issue', type: 'api', requiredPermissions: ['manage_platform_settings'] } as any, { locations: ['record_section'] });
34+
expect(e.getActionsForLocation('record_section')).toHaveLength(1);
35+
});
36+
37+
it('hides an action whose requiredPermissions are NOT all held', () => {
38+
const e = engineWith(['setup.access']);
39+
e.registerAction({ name: 'issue', type: 'api', requiredPermissions: ['manage_platform_settings'] } as any, { locations: ['record_section'] });
40+
expect(e.getActionsForLocation('record_section')).toHaveLength(0);
41+
});
42+
43+
it('requires ALL listed capabilities (AND, not OR)', () => {
44+
const e = engineWith(['a']);
45+
e.registerAction({ name: 'x', type: 'api', requiredPermissions: ['a', 'b'] } as any, { locations: ['record_section'] });
46+
expect(e.getActionsForLocation('record_section')).toHaveLength(0);
47+
});
48+
49+
it('fails OPEN when systemPermissions is unknown (server still enforces)', () => {
50+
const e = engineWith(undefined);
51+
e.registerAction({ name: 'issue', type: 'api', requiredPermissions: ['manage_platform_settings'] } as any, { locations: ['record_section'] });
52+
expect(e.getActionsForLocation('record_section')).toHaveLength(1);
53+
});
54+
});

packages/permissions/src/MePermissionsProvider.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface MePermissionsResponse {
2424
tenantId: string | null;
2525
roles: string[];
2626
permissionSets: string[];
27+
/** [ADR-0066] System capabilities (union of permission-set systemPermissions): manage_users, setup.access, … */
28+
systemPermissions?: string[];
2729
/** object-level perms: { "*": {...}, "account": {...} } */
2830
objects: Record<string, {
2931
allowCreate?: boolean;
@@ -168,6 +170,11 @@ export function MePermissionsProvider({
168170
getFieldPermissions,
169171
getRowFilter,
170172
roles: data?.roles ?? [],
173+
systemPermissions: data?.systemPermissions ?? [],
174+
hasCapabilities: (required: string[]) => {
175+
const held = new Set(data?.systemPermissions ?? []);
176+
return required.every((p) => held.has(p));
177+
},
171178
isLoaded: !loading && !error && data !== null,
172179
}),
173180
[check, checkField, getFieldPermissions, getRowFilter, data, loading, error],

packages/permissions/src/PermissionContext.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export interface PermissionContextValue {
2020
getRowFilter: (object: string) => string | undefined;
2121
/** Current user roles */
2222
roles: string[];
23+
/** [ADR-0066] System capabilities held by the user (union of permission-set systemPermissions). */
24+
systemPermissions: string[];
25+
/** [ADR-0066] True when the user holds ALL of `required` capabilities (subset check). */
26+
hasCapabilities: (required: string[]) => boolean;
2327
/** Whether permissions are loaded */
2428
isLoaded: boolean;
2529
}

packages/permissions/src/PermissionProvider.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ export function PermissionProvider({
120120
getFieldPermissions,
121121
getRowFilter,
122122
roles: userRoles,
123+
// This role-based provider does not model ADR-0066 system capabilities;
124+
// expose an empty set + a fail-open capability check to satisfy the
125+
// contract. The console uses MePermissionsProvider, which wires the real
126+
// systemPermissions from /me/permissions.
127+
systemPermissions: [],
128+
hasCapabilities: () => true,
123129
isLoaded: true,
124130
}),
125131
[check, checkField, getFieldPermissions, getRowFilter, userRoles],

packages/permissions/src/usePermissions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export function usePermissions(): PermissionContextValue & {
3535
getFieldPermissions: () => [],
3636
getRowFilter: () => undefined,
3737
roles: [],
38+
systemPermissions: [],
39+
hasCapabilities: () => true,
3840
isLoaded: false,
3941
can: () => true,
4042
cannot: () => false,

0 commit comments

Comments
 (0)