mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
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:
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user