feat: make all security features available on every tier (#1502)

Scan policies, deploy enforcement, the suppression-aware deploy-block
toggle, SARIF export, and OpenVEX export now work on Community, matching
the rest of the vulnerability-scanning surface that was already free.

Backend: drop the tier gate from the seven security routes and from the
dashboard configuration-status scan-policies row, so the Dashboard and
Fleet config cards stop hiding the Vulnerability scanning row. Reading
policies stays auth-only; mutations and exports stay admin-only.

Frontend: always show the Policies tab and panel, the SARIF and VEX
export actions, and the honor-suppressions toggle for admins.

Docs: move scan policies, SARIF, and OpenVEX to every tier across the
feature and API-reference pages; clarify that Fleet Sync's cross-node
replication remains the paid part.
This commit is contained in:
Anso
2026-06-28 08:14:21 -04:00
committed by GitHub
parent 05c483f213
commit 04e69021e0
27 changed files with 153 additions and 179 deletions
+1 -3
View File
@@ -21,7 +21,6 @@ import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from './NodeManager'
import type { ScanSummary } from '@/types/security';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { CapabilityGate } from './CapabilityGate';
import LazyBoundary from './LazyBoundary';
import { formatBytes } from '@/lib/utils';
@@ -326,7 +325,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'networks' | 'unmanaged'>('images');
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const { isPaid } = useLicense();
const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list');
const [usage, setUsage] = useState<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
@@ -1412,7 +1410,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
onClose={() => setInspectScanId(null)}
onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
canGenerateSbom={isAdmin}
canExportSarif={isPaid && isAdmin}
canExportSarif={isAdmin}
canCompare
canManageSuppressions={isAdmin}
/>
+10 -23
View File
@@ -9,7 +9,6 @@ import { deriveMasthead, SCANNER_DETECTIONS_NOTE } from './security/securityMast
import { springs } from '@/lib/motion';
import { apiFetch } from '@/lib/api';
import { formatTimeAgo } from '@/lib/relativeTime';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { useImageScan } from '@/hooks/useImageScan';
@@ -63,7 +62,6 @@ const MOBILE_MASTHEAD_TONE: Record<MastheadTone, { dot: Tone; word: StateWordCla
};
export function SecurityView({ activeTab, onTabChange, headerActions }: SecurityViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const isMobile = useIsMobile();
@@ -209,23 +207,17 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
return () => { cancelled = true; };
}, [isRemote, activeNode?.id]);
// The Policies tab hosts only the paid enforcement manager, so it is hidden for
// Community; redirect off it if a deep-link lands a Community user there.
useEffect(() => {
if (!isPaid && activeTab === 'policies') onTabChange('overview');
}, [isPaid, activeTab, onTabChange]);
const { state, tone } = deriveMasthead(overview, overviewLoadError !== null);
const pulsing = tone === 'live' && !!overview?.scanner.available;
// The mobile tab strip mirrors the desktop tab IA, including the paid-only
// Policies tab when licensed, so every section stays reachable by scroll.
// The mobile tab strip mirrors the desktop tab IA, so every section stays
// reachable by scroll.
const mobileTabs: SecurityMobileTab[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'images', label: 'Images' },
{ value: 'compose', label: 'Compose risks' },
{ value: 'secrets', label: 'Secrets' },
...(isPaid ? [{ value: 'policies', label: 'Policies' } as const] : []),
{ value: 'policies', label: 'Policies' },
{ value: 'suppressions', label: 'Suppressions' },
{ value: 'history', label: 'History' },
{ value: 'scanner', label: 'Scanner setup' },
@@ -270,7 +262,6 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
onInspect={onInspect}
canScan={canScan}
onScanComplete={() => setReloadToken((t) => t + 1)}
isPaid={isPaid}
/>
</TabsContent>
@@ -301,11 +292,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
</CapabilityGate>
</TabsContent>
{isPaid && (
<TabsContent value="policies">
<ScanPolicyManager />
</TabsContent>
)}
<TabsContent value="policies">
<ScanPolicyManager />
</TabsContent>
<TabsContent value="suppressions">
{isRemote ? (
@@ -344,7 +333,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
initialTab={inspectInitialTab}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isAdmin}
canExportSarif={isPaid && isAdmin}
canExportSarif={isAdmin}
canCompare
canManageSuppressions={isAdmin}
/>
@@ -422,11 +411,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
<TabsTrigger value="secrets"><KeyRound className="w-4 h-4 mr-1.5" />Secrets</TabsTrigger>
</TabsHighlightItem>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
{isPaid && (
<TabsHighlightItem value="policies">
<TabsTrigger value="policies"><BookCheck className="w-4 h-4 mr-1.5" />Policies</TabsTrigger>
</TabsHighlightItem>
)}
<TabsHighlightItem value="policies">
<TabsTrigger value="policies"><BookCheck className="w-4 h-4 mr-1.5" />Policies</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="suppressions">
<TabsTrigger value="suppressions"><EyeOff className="w-4 h-4 mr-1.5" />Suppressions</TabsTrigger>
</TabsHighlightItem>
@@ -31,7 +31,7 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): Confi
mfaEnabled: null,
ssoEnabled: false,
ssoProvider: null,
scanPolicies: { total: 0, enabled: 0, locked: true },
scanPolicies: { total: 0, enabled: 0, locked: false },
},
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true },
backup: { provider: 'disabled', autoUpload: false, locked: false },
@@ -71,7 +71,8 @@ describe('ConfigurationStatus row visibility', () => {
expect(screen.queryByText('Notification routing')).toBeNull();
expect(screen.queryByText('Webhooks')).toBeNull();
expect(screen.queryByText('Scheduled tasks')).toBeNull();
expect(screen.queryByText('Vulnerability scanning')).toBeNull();
// Scan policies are free, so the Vulnerability scanning row renders.
expect(screen.getByText('Vulnerability scanning')).toBeDefined();
// Cloud Backup row is universal (Custom S3 is open to every tier).
expect(screen.getByText('Cloud Backup')).toBeDefined();
});
@@ -49,7 +49,7 @@ beforeEach(() => {
mfaEnabled: null,
ssoEnabled: false,
ssoProvider: null,
scanPolicies: { total: 0, enabled: 0, locked: true },
scanPolicies: { total: 0, enabled: 0, locked: false },
},
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true },
backup: { provider: 'disabled', autoUpload: false, locked: false },
@@ -35,8 +35,6 @@ interface OverviewTabProps {
canScan: boolean;
/** Refresh the overview after a node-wide scan completes. */
onScanComplete: () => void;
/** Paid licensees can manage enforcement policies (the Policies tab is hidden otherwise). */
isPaid: boolean;
}
const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = {
@@ -131,7 +129,7 @@ function ReviewQueueCard({
);
}
export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitTruncated, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) {
export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitTruncated, onNavigate, onInspect, canScan, onScanComplete }: OverviewTabProps) {
const isMobile = useIsMobile();
if (loadError === 'unsupported') {
@@ -293,7 +291,7 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
tone="subtitle"
/>
<p className="mt-2 text-xs text-muted-foreground">
{isPaid ? 'Manage enforcement policies on the Policies tab. ' : ''}This is a read-only posture for the active node.
Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.
</p>
</div>
</div>
@@ -14,7 +14,6 @@ import { SettingsCallout } from '@/components/settings/SettingsCallout';
import { SettingsPrimaryButton } from '@/components/settings/SettingsActions';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
@@ -53,14 +52,11 @@ const EMPTY_FORM: PolicyFormState = {
/**
* Deploy-enforcement scan policies (block-on-deploy severity thresholds), the
* honor-suppressions toggle, and the replica "managed by control" state. This
* is the paid governance surface for the Security page Policies tab; it returns
* null for Community (no enforcement management) so the catalog is all a
* Community operator sees. Policies are control-governed: fetched localOnly and
* shown only on the local node, mirroring how the rest of the fleet-governance
* UI behaves.
* is the governance surface for the Security page Policies tab. Policies are
* control-governed: fetched localOnly and shown only on the local node,
* mirroring how the rest of the fleet-governance UI behaves.
*/
export function ScanPolicyManager() {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
@@ -103,17 +99,17 @@ export function ScanPolicyManager() {
};
useEffect(() => {
if (!isPaid || isRemote) { setLoading(false); return; }
if (isRemote) { setLoading(false); return; }
fetchPolicies();
}, [isPaid, isRemote]);
}, [isRemote]);
useEffect(() => {
if (!isPaid || isRemote) return;
if (isRemote) return;
void refreshTrivy();
}, [isPaid, isRemote, activeNode?.id, refreshTrivy]);
}, [isRemote, activeNode?.id, refreshTrivy]);
useEffect(() => {
if (!isPaid || isRemote) return;
if (isRemote) return;
let cancelled = false;
(async () => {
try {
@@ -135,7 +131,7 @@ export function ScanPolicyManager() {
}
})();
return () => { cancelled = true; };
}, [isPaid, isRemote]);
}, [isRemote]);
const handleHonorSuppressionsToggle = async (enabled: boolean) => {
setHonorBusy(true);
@@ -264,11 +260,6 @@ export function ScanPolicyManager() {
}
};
// Enforcement management is a paid governance surface; the Policies tab is
// hidden for Community entirely (gated in SecurityView), so this is a
// defensive guard.
if (!isPaid) return null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
@@ -1,15 +1,13 @@
/**
* ScanPolicyManager is the paid deploy-enforcement surface on the Security
* Policies tab. Key guards: it renders nothing for Community, and a failed
* policy fetch surfaces an error state instead of a false "No scan policies
* configured".
* ScanPolicyManager is the deploy-enforcement surface on the Security Policies
* tab. Key guard: a failed policy fetch surfaces an error state instead of a
* false "No scan policies configured".
*/
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
import { toast } from '@/components/ui/toast-store';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/context/LicenseContext');
vi.mock('@/context/AuthContext');
vi.mock('@/context/NodeContext');
vi.mock('@/hooks/useTrivyStatus');
@@ -18,7 +16,6 @@ vi.mock('@/components/ui/toast-store', () => ({
}));
import { apiFetch } from '@/lib/api';
import * as LicenseContext from '@/context/LicenseContext';
import * as AuthContext from '@/context/AuthContext';
import * as NodeContext from '@/context/NodeContext';
import * as TrivyStatus from '@/hooks/useTrivyStatus';
@@ -30,8 +27,7 @@ function jsonResponse(status: number, body: unknown): Response {
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response;
}
function setup({ isPaid }: { isPaid: boolean }) {
vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid } as unknown as ReturnType<typeof LicenseContext.useLicense>);
function setup() {
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType<typeof NodeContext.useNodes>);
vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({
@@ -50,14 +46,8 @@ beforeEach(() => {
);
});
it('renders nothing for a Community operator (paid surface)', () => {
setup({ isPaid: false });
const { container } = render(<ScanPolicyManager />);
expect(container).toBeEmptyDOMElement();
});
it('surfaces an error state when the policies fetch fails (no false "no policies")', async () => {
setup({ isPaid: true });
setup();
mockedFetch.mockImplementation((url: string) =>
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(500, {})),
);
@@ -67,7 +57,7 @@ it('surfaces an error state when the policies fetch fails (no false "no policies
});
it('shows the empty state when there are genuinely no policies', async () => {
setup({ isPaid: true });
setup();
render(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('No scan policies configured')).toBeInTheDocument());
});
@@ -80,7 +70,7 @@ const riskPolicy = {
};
it('renders a per-input badge for each active input (KEV/Fixable, no severity)', async () => {
setup({ isPaid: true });
setup();
mockedFetch.mockImplementation((url: string) =>
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(200, [riskPolicy])),
);
@@ -92,7 +82,7 @@ it('renders a per-input badge for each active input (KEV/Fixable, no severity)',
});
it('sends the risk-first defaults (KEV + fixable on, severity off) when creating a policy', async () => {
setup({ isPaid: true });
setup();
render(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument());
fireEvent.click(screen.getByText('Add policy'));
@@ -108,7 +98,7 @@ it('sends the risk-first defaults (KEV + fixable on, severity off) when creating
});
it('blocks a save that turns on block-on-deploy with no active input', async () => {
setup({ isPaid: true });
setup();
render(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument());
fireEvent.click(screen.getByText('Add policy'));
@@ -12,7 +12,6 @@ import { apiFetch } from '@/lib/api';
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
import type { CveSuppression } from '@/types/security';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
const PAGE_SIZE = 8;
@@ -39,7 +38,6 @@ interface SuppressionsPanelProps {
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const [rows, setRows] = useState<CveSuppression[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -205,12 +203,10 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
action={
isAdmin && !isReplica ? (
<div className="flex items-center gap-2">
{isPaid && (
<Button size="sm" variant="outline" onClick={handleExportVex}>
<Download className="w-4 h-4 mr-1.5" />
Export VEX
</Button>
)}
<Button size="sm" variant="outline" onClick={handleExportVex}>
<Download className="w-4 h-4 mr-1.5" />
Export VEX
</Button>
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add suppression
@@ -28,10 +28,6 @@ vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
}));
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => ({ isPaid: false }),
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SuppressionsPanel } from '../SuppressionsPanel';
@@ -21,7 +21,7 @@ interface PreDeployScanDialogProps {
* Advisory pre-deploy review. Shows the latest cached scan for each image in a
* manual deploy so the operator can review the security posture before
* proceeding. Unlike PolicyBlockDialog this never blocks: anyone can deploy or
* cancel, and there is no override gate (blocking is the paid deploy-block
* cancel, and there is no override gate (blocking is the deploy-block
* policy). Opened opt-in via the pre-deploy scan advisory setting.
*/
export function PreDeployScanDialog({ open, stackName, images, onCancel, onDeploy }: PreDeployScanDialogProps) {