fix(rbac): handle permission metadata failures (#1735)

This commit is contained in:
Anso
2026-07-29 15:51:27 -04:00
committed by GitHub
parent 44d6078241
commit a65bf4d46a
8 changed files with 127 additions and 28 deletions
+3
View File
@@ -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
View File
@@ -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().
+7 -5
View File
@@ -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;
+53
View File
@@ -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);
});
});
+35 -20
View File
@@ -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);