mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
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:
@@ -129,6 +129,7 @@ export function ShellOverlays({
|
||||
<VulnerabilityScanSheet
|
||||
scanId={stackMisconfigScanId}
|
||||
onClose={() => setStackMisconfigScanId(null)}
|
||||
canManageSuppressions={isAdmin}
|
||||
/>
|
||||
|
||||
{/* Compose diff preview */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user