mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user