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.
This commit is contained in:
Anso
2026-05-25 18:33:04 -04:00
committed by GitHub
parent 19c28b77b5
commit 117f590332
7 changed files with 43 additions and 14 deletions
@@ -129,6 +129,7 @@ export function ShellOverlays({
<VulnerabilityScanSheet
scanId={stackMisconfigScanId}
onClose={() => setStackMisconfigScanId(null)}
canManageSuppressions={isAdmin}
/>
{/* Compose diff preview */}
+2 -2
View File
@@ -1343,8 +1343,8 @@ export default function ResourcesView() {
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => 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}
/>
@@ -314,7 +314,7 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isPaid}
canGenerateSbom={isPaid && isAdmin}
canCompare={false}
canManageSuppressions={isAdmin}
/>
@@ -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<VulnerabilityScan | null>(null);
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
const [totalDetails, setTotalDetails] = useState(0);
@@ -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<MisconfigAcknowledgement[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -179,7 +181,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
</Button>
</>
)}
{!isReplica && (
{isAdmin && !isReplica && (
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add Acknowledgement
@@ -231,7 +233,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
by {row.created_by} · expires {formatExpiry(row)}
</div>
</div>
{!isReplica && row.replicated_from_control === 0 && (
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
<Button
variant="ghost"
size="icon"
@@ -18,6 +18,7 @@ import { useNodes } from '@/context/NodeContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { SuppressionsPanel } from './SuppressionsPanel';
import { MisconfigAckPanel } from './MisconfigAckPanel';
import { useAuth } from '@/context/AuthContext';
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
{ value: 'CRITICAL', label: 'Critical' },
@@ -61,6 +62,7 @@ const TRIVY_OP_LABELS: Record<'install' | 'update' | 'uninstall', { loading: str
};
export function SecuritySection({ isPaid }: { isPaid: boolean }) {
const { isAdmin } = useAuth();
const [policies, setPolicies] = useState<ScanPolicy[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -291,7 +293,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
return (
<div className="space-y-6">
{isPaid && !isRemote && !isReplica && (
{isPaid && isAdmin && !isRemote && !isReplica && (
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={openCreate}>
<Plus className="w-4 h-4" />
@@ -358,7 +360,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{trivy.source === 'none' && (
{isAdmin && trivy.source === 'none' && (
<SettingsPrimaryButton size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
{trivyBusy === 'install' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
@@ -368,7 +370,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
Install Trivy
</SettingsPrimaryButton>
)}
{trivy.source === 'managed' && updateCheck?.updateAvailable && (
{isAdmin && trivy.source === 'managed' && updateCheck?.updateAvailable && (
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
{trivyBusy === 'update' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
@@ -378,7 +380,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
Update
</Button>
)}
{trivy.source === 'managed' && (
{isAdmin && trivy.source === 'managed' && (
<Button
size="sm"
variant="ghost"
@@ -399,7 +401,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
<div className="text-xs text-stat-subtitle">{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}</div>
)}
{trivy.source === 'managed' && isPaid && (
{trivy.source === 'managed' && isPaid && isAdmin && (
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Auto-update Trivy</Label>
@@ -468,7 +470,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</Badge>
)}
</div>
{!isReplica && (
{isAdmin && !isReplica && (
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
@@ -10,6 +10,7 @@ import { ChevronLeft, ChevronRight, Plus, ShieldOff, Trash2 } from 'lucide-react
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import type { CveSuppression } from '@/types/security';
import { useAuth } from '@/context/AuthContext';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
const PAGE_SIZE = 8;
@@ -35,6 +36,7 @@ interface SuppressionsPanelProps {
}
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const { isAdmin } = useAuth();
const [rows, setRows] = useState<CveSuppression[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -182,7 +184,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
</Button>
</>
)}
{!isReplica && (
{isAdmin && !isReplica && (
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add Suppression
@@ -239,7 +241,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
by {row.created_by} - expires {formatExpiry(row)}
</div>
</div>
{!isReplica && row.replicated_from_control === 0 && (
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
<Button
variant="ghost"
size="icon"