From 117f590332f1ed53dde4399f479b0484b5d9e3c5 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 25 May 2026 18:33:04 -0400 Subject: [PATCH] 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. --- .../components/EditorLayout/ShellOverlays.tsx | 1 + frontend/src/components/ResourcesView.tsx | 4 ++-- .../src/components/SecurityHistoryView.tsx | 2 +- .../src/components/VulnerabilityScanSheet.tsx | 24 ++++++++++++++++++- .../components/settings/MisconfigAckPanel.tsx | 6 +++-- .../components/settings/SecuritySection.tsx | 14 ++++++----- .../components/settings/SuppressionsPanel.tsx | 6 +++-- 7 files changed, 43 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index 5de73521..eb5f654e 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -129,6 +129,7 @@ export function ShellOverlays({ setStackMisconfigScanId(null)} + canManageSuppressions={isAdmin} /> {/* Compose diff preview */} diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 0ea89846..da2b11ee 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -1343,8 +1343,8 @@ export default function ResourcesView() { setInspectScanId(null)} - onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); }} - canGenerateSbom={isPaid} + onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined} + canGenerateSbom={isPaid && isAdmin} canCompare canManageSuppressions={isAdmin} /> diff --git a/frontend/src/components/SecurityHistoryView.tsx b/frontend/src/components/SecurityHistoryView.tsx index 9196f074..10feb134 100644 --- a/frontend/src/components/SecurityHistoryView.tsx +++ b/frontend/src/components/SecurityHistoryView.tsx @@ -314,7 +314,7 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) setInspectScanId(null)} - canGenerateSbom={isPaid} + canGenerateSbom={isPaid && isAdmin} canCompare={false} canManageSuppressions={isAdmin} /> diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index 620264eb..f47a59d6 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -110,8 +110,30 @@ export function VulnerabilityScanSheet({ onRescan, canGenerateSbom = false, canCompare = false, - canManageSuppressions = false, + canManageSuppressions: canManageSuppressionsProp = false, }: VulnerabilityScanSheetProps) { + const [isReplica, setIsReplica] = useState(false); + useEffect(() => { + // Reset on every probe so a stale `true` from a previous replica view + // does not survive switching to a control instance with the sheet kept + // mounted by its parent. Defense in depth: if the probe never resolves + // the UI stays permissive and the backend blockIfReplica guard runs. + setIsReplica(false); + if (!canManageSuppressionsProp || scanId == null) return; + let cancelled = false; + (async () => { + try { + const res = await apiFetch('/fleet/role', { localOnly: true }); + if (cancelled || !res.ok) return; + const data = await res.json(); + if (!cancelled) setIsReplica(data?.role === 'replica'); + } catch (err) { + console.warn('Failed to probe fleet role for replica gate:', err); + } + })(); + return () => { cancelled = true; }; + }, [canManageSuppressionsProp, scanId]); + const canManageSuppressions = canManageSuppressionsProp && !isReplica; const [scan, setScan] = useState(null); const [details, setDetails] = useState([]); const [totalDetails, setTotalDetails] = useState(0); diff --git a/frontend/src/components/settings/MisconfigAckPanel.tsx b/frontend/src/components/settings/MisconfigAckPanel.tsx index 8b48cfca..2e72602a 100644 --- a/frontend/src/components/settings/MisconfigAckPanel.tsx +++ b/frontend/src/components/settings/MisconfigAckPanel.tsx @@ -10,6 +10,7 @@ import { ChevronLeft, ChevronRight, Plus, ShieldCheck, Trash2 } from 'lucide-rea import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import type { MisconfigAcknowledgement } from '@/types/security'; +import { useAuth } from '@/context/AuthContext'; const RULE_RE = /^[A-Z0-9][A-Z0-9_-]{0,199}$/i; const PAGE_SIZE = 8; @@ -33,6 +34,7 @@ interface MisconfigAckPanelProps { } export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) { + const { isAdmin } = useAuth(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); @@ -179,7 +181,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) { )} - {!isReplica && ( + {isAdmin && !isReplica && ( )} - {trivy.source === 'managed' && ( + {isAdmin && trivy.source === 'managed' && ( )} - {!isReplica && ( + {isAdmin && !isReplica && (