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
8 changes: 6 additions & 2 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<ToastHandler>((message, options) => {
if (options?.type === 'error') { toast.error(message); return; }
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/actions/ActionEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 7 additions & 0 deletions packages/permissions/src/MePermissionsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
allowCreate?: boolean;
Expand Down Expand Up @@ -168,6 +170,11 @@ export function MePermissionsProvider({
getFieldPermissions,
getRowFilter,
roles: data?.roles ?? [],
systemPermissions: data?.systemPermissions ?? [],
hasCapabilities: (required: string[]) => {
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],
Expand Down
4 changes: 4 additions & 0 deletions packages/permissions/src/PermissionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions packages/permissions/src/PermissionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
2 changes: 2 additions & 0 deletions packages/permissions/src/usePermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export function usePermissions(): PermissionContextValue & {
getFieldPermissions: () => [],
getRowFilter: () => undefined,
roles: [],
systemPermissions: [],
hasCapabilities: () => true,
isLoaded: false,
can: () => true,
cannot: () => false,
Expand Down
Loading