Files
sencho/frontend/src/components/EditorLayout/ShellOverlays.tsx
T
Anso 117f590332 fix(security): gate admin-only scan affordances on isAdmin (#1230)
* fix(security): gate admin-only scan affordances on isAdmin

Backend already required admin for SBOM, SARIF, scan policies, Trivy
install/update/uninstall, the auto-update toggle, CVE suppressions, and
misconfig acknowledgements. The matching frontend surfaces were gated
only on isPaid (or only on isReplica), so non-admin users at the same
tier saw buttons that returned 403 on click.

Threads isAdmin from useAuth() into SecuritySection, SuppressionsPanel,
and MisconfigAckPanel. Updates the scan-result sheet caller in
ResourcesView so SBOM and SARIF render only when paid AND admin; passes
canManageSuppressions to the stack-misconfig sheet so admins can ack
misconfigs from that surface too.

Read paths remain visible to non-admins (policy list, suppression list,
ack list, scan history) since the GET routes are auth-only on both
sides.

* fix(security): close 3 remaining scan-sheet parity gaps from review

Code review surfaced three sites missed in the first pass:

1. SecurityHistoryView opened the scan sheet with canGenerateSbom set
   only on isPaid, so Skipper non-admins saw SBOM and SARIF buttons
   even though the backend requires admin+paid. Now ANDed with isAdmin.

2. ResourcesView passed onRescan unconditionally, and the sheet
   renders a Re-scan primary action whenever onRescan is defined.
   Non-admins reaching the sheet via the severity-badge shortcut saw
   the button; clicking it called POST /security/scan, which the
   backend requires admin for. onRescan is now undefined for
   non-admins.

3. ShellOverlays and ResourcesView passed canManageSuppressions=isAdmin
   without considering the replica gate, so a replica admin saw
   suppress and ack columns whose backend writes blockIfReplica. The
   sheet now probes /fleet/role internally and ANDs !isReplica into
   the effective canManageSuppressions, so the column hides on a
   replica regardless of how the caller wired the prop.

* fix(security): clear isReplica state on every scan-sheet probe

The previous probe only flipped the state to true on a replica response
and never wrote false on a control, non-OK, or skipped probe. With the
sheet kept mounted by ResourcesView, SecurityHistoryView, and
ShellOverlays, an admin who first viewed a scan on a replica would
keep suppress/ack controls hidden even after switching to a control
instance, because the stale true value persisted across re-opens.

The effect now resets isReplica to false at the start of every probe
and assigns the result of /fleet/role directly. Probe failures and
skips leave the state at false, so the UI is permissive and the
backend blockIfReplica guard remains the source of truth.
2026-05-25 18:33:04 -04:00

182 lines
6.1 KiB
TypeScript

import { lazy, Suspense } from 'react';
import BashExecModal from '../BashExecModal';
import LazyBoundary from '../LazyBoundary';
import { PolicyBlockDialog } from '../stack/PolicyBlockDialog';
import { DeleteStackDialog } from './DeleteStackDialog';
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
import { StackAlertSheet } from '../StackAlertSheet';
import { GitSourcePanel } from '../stack/GitSourcePanel';
import { LogViewer } from '../LogViewer';
import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet';
import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog';
import type { OverlayState } from './hooks/useOverlayState';
import type { StackActionsHook } from './hooks/useStackActions';
import type { PermissionAction } from '@/context/AuthContext';
// SecurityHistoryView is the only lazy-loaded view that lives outside
// the ViewRouter switch -- it renders as an overlay sheet wired into the
// settings flow, not as a top-level tab. The other tab-level lazy views
// (HostConsole, FleetView, AuditLogView, etc.) live inside ViewRouter.
const SecurityHistoryView = lazy(() =>
import('../SecurityHistoryView').then(m => ({ default: m.SecurityHistoryView })),
);
interface ShellOverlaysProps {
overlayState: OverlayState;
stackActions: StackActionsHook;
isDarkMode: boolean;
isAdmin: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
selectedFile: string | null;
stackName: string;
gitSourceOpen: boolean;
setGitSourceOpen: (open: boolean) => void;
securityHistoryOpen: boolean;
setSecurityHistoryOpen: (open: boolean) => void;
}
export function ShellOverlays({
overlayState,
stackActions,
isDarkMode,
isAdmin,
can,
selectedFile,
stackName,
gitSourceOpen,
setGitSourceOpen,
securityHistoryOpen,
setSecurityHistoryOpen,
}: ShellOverlaysProps) {
const {
deleteDialogOpen, closeDeleteDialog, stackToDelete,
pendingUnsavedLoad,
bashModalOpen, selectedContainer,
logViewerOpen, logContainer,
stackMonitor, closeStackMonitor,
policyBlock, setPolicyBlock, policyBypassing,
stackMisconfigScanId, setStackMisconfigScanId,
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} = overlayState;
return (
<>
<DeleteStackDialog
open={deleteDialogOpen}
onOpenChange={(open) => { if (!open) closeDeleteDialog(); }}
stackName={stackToDelete}
onConfirm={stackActions.deleteStack}
/>
<UnsavedChangesDialog
open={!!pendingUnsavedLoad}
onCancel={stackActions.cancelPendingUnsavedLoad}
onConfirm={stackActions.discardAndLoadPending}
/>
{/* Bash Exec Modal */}
{selectedContainer && (
<BashExecModal
isOpen={bashModalOpen}
onClose={stackActions.closeBashModal}
containerId={selectedContainer.id}
containerName={selectedContainer.name}
/>
)}
{/* LogViewer Modal */}
{logContainer && (
<LogViewer
isOpen={logViewerOpen}
onClose={stackActions.closeLogViewer}
containerId={logContainer.id}
containerName={logContainer.name}
/>
)}
{/* Stack monitor (alerts + auto-heal as tabs) */}
<StackAlertSheet
open={stackMonitor !== null}
onOpenChange={(open) => { if (!open) closeStackMonitor(); }}
stackName={stackMonitor?.stackName ?? ''}
initialTab={stackMonitor?.tab ?? 'alerts'}
/>
{/* Pre-deploy policy block */}
<PolicyBlockDialog
open={policyBlock !== null}
payload={policyBlock?.payload ?? null}
stackName={policyBlock?.stackName ?? ''}
canBypass={isAdmin}
bypassing={policyBypassing}
onClose={() => setPolicyBlock(null)}
onBypass={stackActions.bypassPolicyAndDeploy}
/>
{/* Git Source Panel */}
{stackName && (
<GitSourcePanel
open={gitSourceOpen}
onOpenChange={setGitSourceOpen}
stackName={stackName}
canEdit={can('stack:edit', 'stack', stackName)}
isDarkMode={isDarkMode}
onSourceChanged={stackActions.refreshGitSourcePending}
/>
)}
{/* Stack config misconfig scan results */}
<VulnerabilityScanSheet
scanId={stackMisconfigScanId}
onClose={() => setStackMisconfigScanId(null)}
canManageSuppressions={isAdmin}
/>
{/* Compose diff preview */}
<ComposeDiffPreviewDialog
open={diffPreview !== null}
onOpenChange={(open) => { if (!open && !diffPreviewConfirming) setDiffPreview(null); }}
stackName={selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : ''}
fileName={diffPreview?.fileName ?? ''}
language={diffPreview?.language ?? 'yaml'}
original={diffPreview?.original ?? ''}
modified={diffPreview?.modified ?? ''}
actionLabel={diffPreview?.mode === 'save-and-deploy' ? 'Save & deploy' : 'Save'}
confirming={diffPreviewConfirming}
isDarkMode={isDarkMode}
onConfirm={async () => {
const snapshot = diffPreview;
setDiffPreviewConfirming(true);
try {
if (snapshot?.mode === 'save-and-deploy') {
const saved = await stackActions.saveFile();
if (saved) await stackActions.deployStack();
} else {
await stackActions.saveFile();
}
} finally {
setDiffPreviewConfirming(false);
setDiffPreview(null);
}
}}
/>
{/* Scan history overlay. Conditionally mounted so the lazy chunk
only fetches when the user opens the overlay; an always-mounted
lazy component would fetch on EditorLayout's first render and
defeat the split. The overlay has no internal state that needs
to persist across opens. */}
{securityHistoryOpen ? (
<LazyBoundary>
<Suspense fallback={null}>
<SecurityHistoryView
open
onClose={() => setSecurityHistoryOpen(false)}
/>
</Suspense>
</LazyBoundary>
) : null}
</>
);
}