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
@@ -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'));