mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +00:00
fix(rbac): handle permission metadata failures (#1735)
This commit is contained in:
@@ -225,6 +225,9 @@ Entries include the acting user, IP address, HTTP method and path, response stat
|
|||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
<AccordionGroup>
|
<AccordionGroup>
|
||||||
|
<Accordion title="Permission controls are unavailable">
|
||||||
|
Sencho could not verify your current permissions. Existing pages stay open, but changes remain disabled until verification succeeds. Select **Retry** in the notification bar. If the notice returns, check that the Sencho instance is reachable and sign in again if your session has expired.
|
||||||
|
</Accordion>
|
||||||
<Accordion title="The Users entry is missing from the Settings sidebar">
|
<Accordion title="The Users entry is missing from the Settings sidebar">
|
||||||
The Users entry is hidden in two cases. **One,** you are signed in as a non-admin (Viewer, Deployer, Auditor): the entry is admin-only. **Two,** you have a remote node selected: the panel is hub-only and is hidden in the sidebar when any remote node is active. Switch back to the local node via the node switcher in the masthead.
|
The Users entry is hidden in two cases. **One,** you are signed in as a non-admin (Viewer, Deployer, Auditor): the entry is admin-only. **Two,** you have a remote node selected: the panel is hub-only and is hidden in the sidebar when any remote node is active. Switch back to the local node via the node switcher in the masthead.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|||||||
+15
-1
@@ -11,6 +11,8 @@ import { MfaChallenge } from './components/MfaChallenge';
|
|||||||
import { DeployFeedbackProvider } from './context/DeployFeedbackContext';
|
import { DeployFeedbackProvider } from './context/DeployFeedbackContext';
|
||||||
import { DeployFeedbackPortal } from './components/DeployFeedbackPortal';
|
import { DeployFeedbackPortal } from './components/DeployFeedbackPortal';
|
||||||
import { ToastContainer } from './components/ui/toast';
|
import { ToastContainer } from './components/ui/toast';
|
||||||
|
import { Button } from './components/ui/button';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
/** Gates framer-motion animations on the "Reduced motion" appearance setting.
|
/** Gates framer-motion animations on the "Reduced motion" appearance setting.
|
||||||
* 'always' suppresses transform/layout motion app-wide; 'user' defers to the OS
|
* 'always' suppresses transform/layout motion app-wide; 'user' defers to the OS
|
||||||
@@ -27,7 +29,7 @@ function MotionProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth();
|
const { appStatus, isAuthenticated, needsSetup, completeSetup, permissionsStatus, retryPermissions } = useAuth();
|
||||||
|
|
||||||
if (appStatus === 'loading') {
|
if (appStatus === 'loading') {
|
||||||
return (
|
return (
|
||||||
@@ -53,6 +55,18 @@ function AppContent() {
|
|||||||
<MotionProvider>
|
<MotionProvider>
|
||||||
<NodeProvider>
|
<NodeProvider>
|
||||||
<LicenseProvider>
|
<LicenseProvider>
|
||||||
|
{permissionsStatus === 'error' && (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-destructive/30 bg-destructive/[0.06] px-[var(--density-row-x)] py-2 text-sm text-destructive" role="alert">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
<span>Permission controls are unavailable. Changes remain disabled until access is verified.</span>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => void retryPermissions()}>
|
||||||
|
<RefreshCw aria-hidden />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<EditorLayout />
|
<EditorLayout />
|
||||||
{/* Portal lives inside LicenseProvider so the editor surface and its
|
{/* Portal lives inside LicenseProvider so the editor surface and its
|
||||||
portalled overlays can read license state via useLicense().
|
portalled overlays can read license state via useLicense().
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const NetworkingView = lazy(() => import('./networking/NetworkingView').then(m =
|
|||||||
const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView })));
|
const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView })));
|
||||||
|
|
||||||
export default function EditorLayout() {
|
export default function EditorLayout() {
|
||||||
const { isAdmin, can, permissions } = useAuth();
|
const { isAdmin, can, permissions, permissionsStatus } = useAuth();
|
||||||
const { status: trivy } = useTrivyStatus();
|
const { status: trivy } = useTrivyStatus();
|
||||||
const { runWithLog, panelState, logRows, healthGate } = useDeployFeedback();
|
const { runWithLog, panelState, logRows, healthGate } = useDeployFeedback();
|
||||||
|
|
||||||
@@ -837,12 +837,14 @@ export default function EditorLayout() {
|
|||||||
}
|
}
|
||||||
}, [permissions, can]);
|
}, [permissions, can]);
|
||||||
|
|
||||||
const createStackSlot = can('stack:create') ? (
|
const canCreateStack = can('stack:create');
|
||||||
|
const createStackSlot = (canCreateStack || permissionsStatus === 'loading') ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-lg w-full"
|
className="rounded-lg w-full"
|
||||||
onClick={() => openCreateDialog('empty')}
|
onClick={() => openCreateDialog('empty')}
|
||||||
|
disabled={!canCreateStack}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Create Stack
|
Create Stack
|
||||||
@@ -904,7 +906,7 @@ export default function EditorLayout() {
|
|||||||
createStackSlot={createStackSlot}
|
createStackSlot={createStackSlot}
|
||||||
onScan={handleScanStacks}
|
onScan={handleScanStacks}
|
||||||
isScanning={isScanning}
|
isScanning={isScanning}
|
||||||
canCreate={can('stack:create')}
|
canCreate={canCreateStack}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
filterChip={filterChip}
|
filterChip={filterChip}
|
||||||
@@ -935,10 +937,10 @@ export default function EditorLayout() {
|
|||||||
if (node) void stackActions.loadFileOnNode(node, file);
|
if (node) void stackActions.loadFileOnNode(node, file);
|
||||||
},
|
},
|
||||||
filterChip,
|
filterChip,
|
||||||
onOpenCreate: can('stack:create') ? () => openCreateDialog('empty') : undefined,
|
onOpenCreate: canCreateStack ? () => openCreateDialog('empty') : undefined,
|
||||||
onOpenAdopt: can('stack:read') ? openAdoptDialog : undefined,
|
onOpenAdopt: can('stack:read') ? openAdoptDialog : undefined,
|
||||||
onScan: handleScanStacks,
|
onScan: handleScanStacks,
|
||||||
canCreate: can('stack:create'),
|
canCreate: canCreateStack,
|
||||||
activeNodeId: activeNode?.id ?? null,
|
activeNodeId: activeNode?.id ?? null,
|
||||||
openMuteRulesWithPrefill,
|
openMuteRulesWithPrefill,
|
||||||
stacksLoadStatus,
|
stacksLoadStatus,
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function ViewRouter({
|
|||||||
isFileLoading,
|
isFileLoading,
|
||||||
quickLinkCandidates,
|
quickLinkCandidates,
|
||||||
}: ViewRouterProps): ReactNode {
|
}: ViewRouterProps): ReactNode {
|
||||||
const { can } = useAuth();
|
const { can, permissionsStatus } = useAuth();
|
||||||
const { isPaid, licenseReady } = useLicense();
|
const { isPaid, licenseReady } = useLicense();
|
||||||
const { activeNode, activeNodeMeta } = useNodes();
|
const { activeNode, activeNodeMeta } = useNodes();
|
||||||
if (activeView === 'settings') {
|
if (activeView === 'settings') {
|
||||||
@@ -192,6 +192,7 @@ export function ViewRouter({
|
|||||||
// remote meta; null activeNode must not be treated as local (wrong-node
|
// remote meta; null activeNode must not be treated as local (wrong-node
|
||||||
// or doomed WebSocket). Stack deep links hydrate selectedFile async:
|
// or doomed WebSocket). Stack deep links hydrate selectedFile async:
|
||||||
// wait so we never open a compose-root shell, then reconnect into the stack.
|
// wait so we never open a compose-root shell, then reconnect into the stack.
|
||||||
|
if (permissionsStatus === 'loading') return <ViewSkeleton />;
|
||||||
if (!can('system:console')) return null;
|
if (!can('system:console')) return null;
|
||||||
if (urlHydratingStack != null || (isHostConsoleStackDeepLink() && !selectedFile)) {
|
if (urlHydratingStack != null || (isHostConsoleStackDeepLink() && !selectedFile)) {
|
||||||
return <ViewSkeleton />;
|
return <ViewSkeleton />;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface SectionGateProps {
|
|||||||
* guards remain the authoritative enforcement.
|
* guards remain the authoritative enforcement.
|
||||||
*/
|
*/
|
||||||
export function SectionGate({ sectionId, children }: SectionGateProps) {
|
export function SectionGate({ sectionId, children }: SectionGateProps) {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin, permissionsStatus } = useAuth();
|
||||||
const { isPaid } = useLicense();
|
const { isPaid } = useLicense();
|
||||||
const { activeNode } = useNodes();
|
const { activeNode } = useNodes();
|
||||||
|
|
||||||
@@ -32,6 +32,10 @@ export function SectionGate({ sectionId, children }: SectionGateProps) {
|
|||||||
|
|
||||||
const item = getSettingsItem(sectionId);
|
const item = getSettingsItem(sectionId);
|
||||||
|
|
||||||
|
if (permissionsStatus === 'loading') {
|
||||||
|
return <div className="h-48 animate-pulse rounded-lg bg-card" aria-busy="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!item || !isItemVisible(item, visibility)) return null;
|
if (!item || !isItemVisible(item, visibility)) return null;
|
||||||
if (isItemLocked(item, visibility)) return null;
|
if (isItemLocked(item, visibility)) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AuthProvider, useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
const authenticated = { user: { username: 'operator', role: 'admin' } };
|
||||||
|
const permissionData = {
|
||||||
|
globalRole: 'viewer',
|
||||||
|
globalPermissions: ['stack:read'],
|
||||||
|
scopedPermissions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockFetch(...responses: Array<Response | Promise<Response>>) {
|
||||||
|
vi.stubGlobal('fetch', vi.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ needsSetup: false }), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify(authenticated), { status: 200 }))
|
||||||
|
.mockImplementationOnce(() => responses.shift()));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthContext permission metadata', () => {
|
||||||
|
it('keeps authorization unavailable until permissions load', async () => {
|
||||||
|
let resolvePermissions: (response: Response) => void;
|
||||||
|
const pendingPermissions = new Promise<Response>((resolve) => { resolvePermissions = resolve; });
|
||||||
|
mockFetch(pendingPermissions);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.appStatus).toBe('authenticated'));
|
||||||
|
expect(result.current.permissionsStatus).toBe('loading');
|
||||||
|
expect(result.current.isAdmin).toBe(false);
|
||||||
|
expect(result.current.can('stack:read')).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => resolvePermissions!(new Response(JSON.stringify(permissionData), { status: 200 })));
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.permissionsStatus).toBe('ready'));
|
||||||
|
expect(result.current.can('stack:read')).toBe(true);
|
||||||
|
expect(result.current.isAdmin).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed and recovers after a retry', async () => {
|
||||||
|
mockFetch(new Response(null, { status: 503 }));
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.permissionsStatus).toBe('error'));
|
||||||
|
expect(result.current.can('stack:read')).toBe(false);
|
||||||
|
expect(result.current.isAdmin).toBe(false);
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify(permissionData), { status: 200 }));
|
||||||
|
await act(async () => result.current.retryPermissions());
|
||||||
|
|
||||||
|
expect(result.current.permissionsStatus).toBe('ready');
|
||||||
|
expect(result.current.can('stack:read')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
||||||
import { markMilestone } from '@/lib/hydrationTiming';
|
import { markMilestone } from '@/lib/hydrationTiming';
|
||||||
import { resolveCan } from '@/lib/resolveCan';
|
import { resolveCan } from '@/lib/resolveCan';
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ interface AuthContextType {
|
|||||||
permissionsStatus: PermissionsStatus;
|
permissionsStatus: PermissionsStatus;
|
||||||
permissionsReady: boolean;
|
permissionsReady: boolean;
|
||||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
|
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
|
||||||
|
retryPermissions: () => Promise<void>;
|
||||||
login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
|
login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
|
||||||
ssoLdapLogin: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
|
ssoLdapLogin: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
|
||||||
submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>;
|
submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>;
|
||||||
@@ -51,12 +52,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [user, setUser] = useState<UserInfo | null>(null);
|
const [user, setUser] = useState<UserInfo | null>(null);
|
||||||
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
|
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
|
||||||
const [permissionsStatus, setPermissionsStatus] = useState<PermissionsStatus>('loading');
|
const [permissionsStatus, setPermissionsStatus] = useState<PermissionsStatus>('loading');
|
||||||
|
const permissionRequestRef = useRef(0);
|
||||||
|
|
||||||
const resetPermissions = useCallback(() => {
|
const resetPermissions = useCallback(() => {
|
||||||
|
permissionRequestRef.current += 1;
|
||||||
setPermissions(null);
|
setPermissions(null);
|
||||||
setPermissionsStatus('loading');
|
setPermissionsStatus('loading');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadPermissions = useCallback(async () => {
|
||||||
|
const requestId = ++permissionRequestRef.current;
|
||||||
|
setPermissions(null);
|
||||||
|
setPermissionsStatus('loading');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/permissions/me', { credentials: 'include' });
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('[Auth] Permission metadata request failed:', response.status);
|
||||||
|
if (requestId === permissionRequestRef.current) setPermissionsStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (requestId !== permissionRequestRef.current) return;
|
||||||
|
setPermissions(data);
|
||||||
|
setPermissionsStatus('ready');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Permission metadata request failed:', error);
|
||||||
|
if (requestId === permissionRequestRef.current) setPermissionsStatus('error');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
resetPermissions();
|
resetPermissions();
|
||||||
try {
|
try {
|
||||||
@@ -79,26 +104,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authPromise = fetch('/api/auth/check', { credentials: 'include' });
|
const authResponse = await fetch('/api/auth/check', { credentials: 'include' });
|
||||||
const permsPromise = fetch('/api/permissions/me', { credentials: 'include' });
|
|
||||||
|
|
||||||
const authResponse = await authPromise;
|
|
||||||
if (authResponse.ok) {
|
if (authResponse.ok) {
|
||||||
const data = await authResponse.json();
|
const data = await authResponse.json();
|
||||||
setUser(data.user ?? null);
|
setUser(data.user ?? null);
|
||||||
setAppStatus('authenticated');
|
setAppStatus('authenticated');
|
||||||
|
await loadPermissions();
|
||||||
try {
|
|
||||||
const res = await permsPromise;
|
|
||||||
if (res.ok) {
|
|
||||||
setPermissions(await res.json());
|
|
||||||
setPermissionsStatus('ready');
|
|
||||||
} else {
|
|
||||||
setPermissionsStatus('error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setPermissionsStatus('error');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
resetPermissions();
|
resetPermissions();
|
||||||
@@ -134,7 +145,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
resourceType?: string,
|
resourceType?: string,
|
||||||
resourceId?: string,
|
resourceId?: string,
|
||||||
nodeId?: number | null,
|
nodeId?: number | null,
|
||||||
): boolean => resolveCan(permissions, action, resourceType, resourceId, nodeId), [permissions]);
|
): boolean => {
|
||||||
|
if (permissionsStatus !== 'ready' || !permissions) return false;
|
||||||
|
return resolveCan(permissions, action, resourceType, resourceId, nodeId);
|
||||||
|
}, [permissions, permissionsStatus]);
|
||||||
|
|
||||||
const login = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
|
const login = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
|
||||||
try {
|
try {
|
||||||
@@ -252,11 +266,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
isAuthenticated: appStatus === 'authenticated',
|
isAuthenticated: appStatus === 'authenticated',
|
||||||
needsSetup: appStatus === 'needsSetup',
|
needsSetup: appStatus === 'needsSetup',
|
||||||
user,
|
user,
|
||||||
isAdmin: user?.role === 'admin',
|
isAdmin: permissionsStatus === 'ready' && permissions?.globalRole === 'admin',
|
||||||
permissions,
|
permissions,
|
||||||
permissionsStatus,
|
permissionsStatus,
|
||||||
permissionsReady: permissionsStatus !== 'loading',
|
permissionsReady: permissionsStatus === 'ready',
|
||||||
can,
|
can,
|
||||||
|
retryPermissions: loadPermissions,
|
||||||
login,
|
login,
|
||||||
ssoLdapLogin,
|
ssoLdapLogin,
|
||||||
submitMfa,
|
submitMfa,
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ describe('reachability', () => {
|
|||||||
expect(isViewHidden('audit-log', loading)).toBe(false);
|
expect(isViewHidden('audit-log', loading)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps deep links stable when permission metadata fails', () => {
|
||||||
|
const failed = ctx({ permissionsStatus: 'error', can: () => false, isAdmin: false });
|
||||||
|
expect(authzReady(failed)).toBe(false);
|
||||||
|
expect(isViewHidden('fleet', failed)).toBe(false);
|
||||||
|
expect(normalizeHiddenView('fleet', failed)).toBe('fleet');
|
||||||
|
});
|
||||||
|
|
||||||
it('hides hub-only views on remote nodes when ready', () => {
|
it('hides hub-only views on remote nodes when ready', () => {
|
||||||
const remote = ctx({ isRemote: true });
|
const remote = ctx({ isRemote: true });
|
||||||
expect(isViewHidden('audit-log', remote)).toBe(true);
|
expect(isViewHidden('audit-log', remote)).toBe(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user