From 54119be0c21253a00f1aeee74eafe0be3741e25e Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 8 Jun 2026 08:58:14 -0400 Subject: [PATCH] feat: move trivy auto-update, node labels, fleet topology, and single-scan SBOM to Community (#1336) Rebalance several capabilities from the paid tier to the free Community tier: - Managed Trivy auto-update toggle is admin-only, no longer tier-gated. - Node labels (assign, view, manage) are available on every tier; cordon and FleetSync anchor reset stay paid. - Fleet topology layout modes (Hub, Grouped, Free) are available on Community. - Single-scan SBOM export (SPDX and CycloneDX) is admin-only on Community; SARIF export stays paid via a dedicated canExportSarif capability split out from the former shared SBOM flag. Backend route guards and frontend affordances are updated together, with tier and admin-role tests covering the Community-allowed and still-paid paths. --- .../node-labels-community-tier.test.ts | 87 ++++++++++++++++++ .../security-sbom-sarif-tier.test.ts | 91 +++++++++++++++++++ .../security-trivy-auto-update-route.test.ts | 11 ++- backend/src/routes/nodeLabels.ts | 7 +- backend/src/routes/security.ts | 2 - frontend/src/components/FleetView.tsx | 3 +- .../src/components/FleetView/OverviewTab.tsx | 3 - .../FleetView/__tests__/OverviewTab.test.tsx | 1 - .../hooks/__tests__/useFleetOverview.test.tsx | 2 +- .../hooks/__tests__/useNodeLabels.test.tsx | 18 +--- .../FleetView/hooks/useFleetOverview.ts | 6 +- .../FleetView/hooks/useNodeLabels.ts | 23 ++--- frontend/src/components/NodeManager.tsx | 8 +- frontend/src/components/ResourcesView.tsx | 3 +- .../src/components/SecurityHistoryView.tsx | 3 +- .../src/components/VulnerabilityScanSheet.tsx | 4 +- .../src/components/fleet/FleetTopology.tsx | 14 +-- .../components/settings/SecuritySection.tsx | 2 +- 18 files changed, 214 insertions(+), 74 deletions(-) create mode 100644 backend/src/__tests__/node-labels-community-tier.test.ts create mode 100644 backend/src/__tests__/security-sbom-sarif-tier.test.ts diff --git a/backend/src/__tests__/node-labels-community-tier.test.ts b/backend/src/__tests__/node-labels-community-tier.test.ts new file mode 100644 index 00000000..66e28a1e --- /dev/null +++ b/backend/src/__tests__/node-labels-community-tier.test.ts @@ -0,0 +1,87 @@ +/** + * Confirms the node-label routes (list, distinct, per-node read, add, remove) + * are reachable on the Community tier. Node labels drive fleet grouping and are + * a free organizational primitive; only the admin role is required for writes. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let viewerAuthHeader: string; +let nodeId: number; +let LicenseService: typeof import('../services/LicenseService').LicenseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ LicenseService } = await import('../services/LicenseService')); + const { NodeRegistry } = await import('../services/NodeRegistry'); + const { DatabaseService } = await import('../services/DatabaseService'); + nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + DatabaseService.getInstance().addUser({ username: 'node-labels-viewer', password_hash: 'hash', role: 'viewer' }); + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; + viewerAuthHeader = `Bearer ${jwt.sign({ username: 'node-labels-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +function mockTier(tier: 'paid' | 'community') { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); +} + +describe('Node labels on Community tier', () => { + afterEach(() => vi.restoreAllMocks()); + + it('GET /api/node-labels returns 200 on community', async () => { + mockTier('community'); + const res = await request(app).get('/api/node-labels').set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(typeof res.body).toBe('object'); + }); + + it('GET /api/node-labels/all returns 200 on community', async () => { + mockTier('community'); + const res = await request(app).get('/api/node-labels/all').set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.labels)).toBe(true); + }); + + it('POST then DELETE a node label as a Community admin', async () => { + mockTier('community'); + const add = await request(app) + .post(`/api/node-labels/${nodeId}`) + .set('Authorization', authHeader) + .send({ label: 'community-edge' }); + expect(add.status).toBe(201); + expect(add.body.label).toBe('community-edge'); + + const read = await request(app) + .get(`/api/node-labels/${nodeId}`) + .set('Authorization', authHeader); + expect(read.status).toBe(200); + expect(read.body.labels).toContain('community-edge'); + + const remove = await request(app) + .delete(`/api/node-labels/${nodeId}/community-edge`) + .set('Authorization', authHeader); + expect(remove.status).toBe(204); + }); + + it('denies a non-admin (viewer) the write routes with 403', async () => { + mockTier('community'); + const add = await request(app) + .post(`/api/node-labels/${nodeId}`) + .set('Authorization', viewerAuthHeader) + .send({ label: 'viewer-denied' }); + expect(add.status).toBe(403); + + const remove = await request(app) + .delete(`/api/node-labels/${nodeId}/viewer-denied`) + .set('Authorization', viewerAuthHeader); + expect(remove.status).toBe(403); + }); +}); diff --git a/backend/src/__tests__/security-sbom-sarif-tier.test.ts b/backend/src/__tests__/security-sbom-sarif-tier.test.ts new file mode 100644 index 00000000..70061c24 --- /dev/null +++ b/backend/src/__tests__/security-sbom-sarif-tier.test.ts @@ -0,0 +1,91 @@ +/** + * Tier split for the two scan-export endpoints: + * POST /api/security/sbom -> Community (admin only, no tier gate) + * GET /api/security/scans/:id/sarif -> Admiral (paid) only + * + * SBOM is a per-image artifact useful to any self-hoster; SARIF (CI/security + * pipeline ingestion) stays a paid governance export. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let adminCookie: string; +let viewerCookie: string; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let TrivyService: typeof import('../services/TrivyService').default; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ LicenseService } = await import('../services/LicenseService')); + TrivyService = (await import('../services/TrivyService')).default; + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const { DatabaseService } = await import('../services/DatabaseService'); + const viewerHash = await bcrypt.hash('sbomviewer1', 1); + DatabaseService.getInstance().addUser({ username: 'sbom-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'sbom-viewer', password: 'sbomviewer1' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +function mockTier(tier: 'paid' | 'community') { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); +} + +describe('POST /api/security/sbom (Community)', () => { + afterEach(() => { vi.restoreAllMocks(); mockTier('paid'); }); + + it('lets a Community admin generate an SBOM (no PAID_REQUIRED gate)', async () => { + mockTier('community'); + const svc = TrivyService.getInstance(); + vi.spyOn(svc, 'isTrivyAvailable').mockReturnValue(true); + vi.spyOn(svc, 'generateSBOM').mockResolvedValue('{"bomFormat":"CycloneDX"}'); + + const res = await request(app) + .post('/api/security/sbom') + .set('Cookie', adminCookie) + .send({ imageRef: 'nginx:latest', format: 'cyclonedx' }); + + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toContain('nginx_latest.cdx.json'); + }); + + it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => { + mockTier('community'); + const res = await request(app) + .post('/api/security/sbom') + .set('Cookie', viewerCookie) + .send({ imageRef: 'nginx:latest', format: 'cyclonedx' }); + expect(res.status).toBe(403); + }); +}); + +describe('GET /api/security/scans/:scanId/sarif (Admiral only)', () => { + afterEach(() => { vi.restoreAllMocks(); mockTier('paid'); }); + + it('rejects a Community admin with 403 PAID_REQUIRED', async () => { + mockTier('community'); + const res = await request(app) + .get('/api/security/scans/999999/sarif') + .set('Cookie', adminCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('passes the tier gate for a paid admin (404 for a missing scan, not 403)', async () => { + mockTier('paid'); + const res = await request(app) + .get('/api/security/scans/999999/sarif') + .set('Cookie', adminCookie); + expect(res.status).not.toBe(403); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/src/__tests__/security-trivy-auto-update-route.test.ts b/backend/src/__tests__/security-trivy-auto-update-route.test.ts index dd91273b..b1ecc413 100644 --- a/backend/src/__tests__/security-trivy-auto-update-route.test.ts +++ b/backend/src/__tests__/security-trivy-auto-update-route.test.ts @@ -1,9 +1,10 @@ /** - * Tests for the role + tier gate on PUT /api/security/trivy-auto-update. + * Tests for the admin-role gate on PUT /api/security/trivy-auto-update. * * The route flips the global `trivy_auto_update` setting that the scheduler * reads every 24h to decide whether to pull newer Trivy binary releases. - * It must be reachable only by an admin on a paid (Admiral) tier. + * It must be reachable by any admin, on Community as well as Admiral; only the + * admin role is required (no tier gate). */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; @@ -51,7 +52,7 @@ describe('PUT /api/security/trivy-auto-update', () => { expect(res.status).toBe(403); }); - it('rejects Community tier with 403', async () => { + it('accepts a Community admin (no tier gate) and persists the setting', async () => { const { LicenseService } = await import('../services/LicenseService'); const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); try { @@ -59,7 +60,9 @@ describe('PUT /api/security/trivy-auto-update', () => { .put('/api/security/trivy-auto-update') .set('Cookie', adminCookie) .send({ enabled: true }); - expect(res.status).toBe(403); + expect(res.status).toBe(200); + expect(res.body.autoUpdate).toBe(true); + expect(DatabaseService.getInstance().getGlobalSettings().trivy_auto_update).toBe('1'); } finally { spy.mockReturnValue('paid'); } diff --git a/backend/src/routes/nodeLabels.ts b/backend/src/routes/nodeLabels.ts index 286947ea..08edb4ea 100644 --- a/backend/src/routes/nodeLabels.ts +++ b/backend/src/routes/nodeLabels.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates'; +import { requireAdmin, requireBody } from '../middleware/tierGates'; import { DatabaseService } from '../services/DatabaseService'; import { NodeLabelService } from '../services/NodeLabelService'; import { parseIntParam } from '../utils/parseIntParam'; @@ -10,7 +10,6 @@ export const nodeLabelsRouter = Router(); nodeLabelsRouter.use(authMiddleware); nodeLabelsRouter.get('/', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; try { const map = NodeLabelService.getInstance().listAll(); res.json(map); @@ -21,7 +20,6 @@ nodeLabelsRouter.get('/', (req: Request, res: Response): void => { }); nodeLabelsRouter.get('/all', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; try { const labels = NodeLabelService.getInstance().listDistinct(); res.json({ labels }); @@ -32,7 +30,6 @@ nodeLabelsRouter.get('/all', (req: Request, res: Response): void => { }); nodeLabelsRouter.get('/:nodeId', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) return; try { @@ -50,7 +47,6 @@ nodeLabelsRouter.get('/:nodeId', (req: Request, res: Response): void => { }); nodeLabelsRouter.post('/:nodeId', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const nodeId = parseIntParam(req, res, 'nodeId'); @@ -75,7 +71,6 @@ nodeLabelsRouter.post('/:nodeId', (req: Request, res: Response): void => { }); nodeLabelsRouter.delete('/:nodeId/:label', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) return; diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index e00d2e1f..1182569f 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -205,7 +205,6 @@ securityRouter.post('/trivy-update', trivyInstallLimiter, authMiddleware, async securityRouter.put('/trivy-auto-update', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; const enabled = req.body?.enabled === true; try { DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', enabled ? '1' : '0'); @@ -440,7 +439,6 @@ securityRouter.get('/image-summaries', authMiddleware, (req: Request, res: Respo securityRouter.post('/sbom', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { res.status(503).json({ error: 'Trivy is not available on this host' }); return; diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index b5db47d6..233f4855 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -41,7 +41,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); - const overview = useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses }); + const overview = useFleetOverview({ prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses }); const topology = useTopologyPreferences(); const { exporting, exportDossier } = useFleetDossierExport(); @@ -205,7 +205,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { onCordonChange={() => { void overview.fetchOverview(true); }} onEditNode={isAdmin ? openEdit : undefined} onDeleteNode={isAdmin ? openDelete : undefined} - isPaid={isPaid} topologyMode={topology.prefs.mode} onTopologyModeChange={topology.setMode} topologyPositions={topology.prefs.positions} diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index f4ff49ec..723c2251 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -35,7 +35,6 @@ interface OverviewTabProps { onCordonChange?: () => void; onEditNode?: (node: Node) => void; onDeleteNode?: (node: Node) => void; - isPaid: boolean; topologyMode: LayoutMode; onTopologyModeChange: (mode: LayoutMode) => void; topologyPositions: SavedPositions; @@ -68,7 +67,6 @@ export function OverviewTab({ onCordonChange, onEditNode, onDeleteNode, - isPaid, topologyMode, onTopologyModeChange, topologyPositions, @@ -121,7 +119,6 @@ export function OverviewTab({ onNavigateToNode(id, '')} - isPaid={isPaid} mode={topologyMode} onModeChange={onTopologyModeChange} savedPositions={topologyPositions} diff --git a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx index c8b095d1..fc5824e2 100644 --- a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx +++ b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx @@ -36,7 +36,6 @@ function props(overrides: Partial> = {} updateStatusMap: new Map(), onNavigateToNode: vi.fn(), updatingNodeId: null, - isPaid: false, topologyMode: 'hub' as const, onTopologyModeChange: vi.fn(), topologyPositions: {}, diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx index 99c9f525..86d126cb 100644 --- a/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx @@ -36,7 +36,7 @@ function setup(prefs: Partial = {}) { const updatePrefs = vi.fn(); const merged = { ...DEFAULT_PREFS, ...prefs }; const hook = renderHook( - (p: { prefs: FleetPreferences }) => useFleetOverview({ isPaid: false, prefs: p.prefs, updatePrefs, updateStatuses: [] }), + (p: { prefs: FleetPreferences }) => useFleetOverview({ prefs: p.prefs, updatePrefs, updateStatuses: [] }), { initialProps: { prefs: merged } }, ); return { ...hook, updatePrefs }; diff --git a/frontend/src/components/FleetView/hooks/__tests__/useNodeLabels.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useNodeLabels.test.tsx index 01932227..43d86fb0 100644 --- a/frontend/src/components/FleetView/hooks/__tests__/useNodeLabels.test.tsx +++ b/frontend/src/components/FleetView/hooks/__tests__/useNodeLabels.test.tsx @@ -25,23 +25,13 @@ function node(id: number): FleetNode { beforeEach(() => apiFetchMock.mockReset()); afterEach(() => vi.clearAllMocks()); -describe('useNodeLabels (node-tag aggregation stays paid)', () => { - it('does not fetch and reports unavailable when not paid', async () => { - const { result } = renderHook(() => useNodeLabels({ isPaid: false, nodes: [node(2)] })); - expect(result.current.isAvailable).toBe(false); - expect(result.current.labelsByNodeId).toEqual({}); - // Allow any effect to flush; still no call. - await Promise.resolve(); - expect(apiFetchMock).not.toHaveBeenCalled(); - }); - - it('fetches and normalizes string keys to numbers when paid', async () => { +describe('useNodeLabels (Community node-tag aggregation)', () => { + it('fetches and normalizes string keys to numbers on every tier', async () => { apiFetchMock.mockResolvedValue(okJson({ '2': ['prod', 'edge'], '3': ['db'] })); - const { result } = renderHook(() => useNodeLabels({ isPaid: true, nodes: [node(2), node(3)] })); + const { result } = renderHook(() => useNodeLabels({ nodes: [node(2), node(3)] })); await waitFor(() => expect(Object.keys(result.current.labelsByNodeId).length).toBe(2)); - expect(result.current.isAvailable).toBe(true); expect(result.current.labelsByNodeId[2]).toEqual(['prod', 'edge']); expect(result.current.labelsByNodeId[3]).toEqual(['db']); // distinctLabels is the sorted union. @@ -51,7 +41,7 @@ describe('useNodeLabels (node-tag aggregation stays paid)', () => { it('clears the map on a non-ok response', async () => { apiFetchMock.mockResolvedValue(new Response('nope', { status: 403 })); - const { result } = renderHook(() => useNodeLabels({ isPaid: true, nodes: [node(2)] })); + const { result } = renderHook(() => useNodeLabels({ nodes: [node(2)] })); await waitFor(() => expect(apiFetchMock).toHaveBeenCalled()); expect(result.current.labelsByNodeId).toEqual({}); }); diff --git a/frontend/src/components/FleetView/hooks/useFleetOverview.ts b/frontend/src/components/FleetView/hooks/useFleetOverview.ts index 434f7a0f..a5b21825 100644 --- a/frontend/src/components/FleetView/hooks/useFleetOverview.ts +++ b/frontend/src/components/FleetView/hooks/useFleetOverview.ts @@ -18,13 +18,12 @@ interface MastheadStats { } interface UseFleetOverviewOptions { - isPaid: boolean; prefs: FleetPreferences; updatePrefs: (updates: Partial) => void; updateStatuses: NodeUpdateStatus[]; } -export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }: UseFleetOverviewOptions) { +export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFleetOverviewOptions) { const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -35,7 +34,7 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }: const abortRef = useRef(null); const { fleetPalette, fleetStackLabelMap } = useFleetLabels({ nodes }); - const { labelsByNodeId, distinctLabels, isAvailable: nodeLabelsAvailable } = useNodeLabels({ isPaid, nodes }); + const { labelsByNodeId, distinctLabels } = useNodeLabels({ nodes }); const fetchOverview = useCallback(async (showRefresh = false) => { abortRef.current?.abort(); @@ -217,7 +216,6 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }: fleetStackLabelMap, labelsByNodeId, distinctNodeLabels: distinctLabels, - nodeLabelsAvailable, activeFilterCount, clearFilters, }; diff --git a/frontend/src/components/FleetView/hooks/useNodeLabels.ts b/frontend/src/components/FleetView/hooks/useNodeLabels.ts index 75c901fe..ec587dea 100644 --- a/frontend/src/components/FleetView/hooks/useNodeLabels.ts +++ b/frontend/src/components/FleetView/hooks/useNodeLabels.ts @@ -5,28 +5,23 @@ import type { FleetNode } from '../types'; export interface UseNodeLabelsResult { labelsByNodeId: Record; distinctLabels: string[]; - isAvailable: boolean; } interface UseNodeLabelsOptions { - isPaid: boolean; nodes: FleetNode[]; } -export function useNodeLabels({ isPaid, nodes }: UseNodeLabelsOptions): UseNodeLabelsResult { +export function useNodeLabels({ nodes }: UseNodeLabelsOptions): UseNodeLabelsResult { const [labelsByNodeId, setLabelsByNodeId] = useState>({}); const fetchLabels = useCallback(async () => { - if (!isPaid) { - setLabelsByNodeId({}); - return; - } try { const res = await apiFetch('/node-labels', { localOnly: true, signal: AbortSignal.timeout(5000), }); if (!res.ok) { + console.error(`[useNodeLabels] node-label fetch failed: ${res.status}`); setLabelsByNodeId({}); return; } @@ -38,10 +33,11 @@ export function useNodeLabels({ isPaid, nodes }: UseNodeLabelsOptions): UseNodeL if (!Number.isNaN(id) && Array.isArray(v)) map[id] = v; } setLabelsByNodeId(map); - } catch { + } catch (err) { + console.error('[useNodeLabels] node-label fetch errored:', err); setLabelsByNodeId({}); } - }, [isPaid]); + }, []); // Refetch only when the set of node ids actually changes, not on every poll // (poll mints a fresh nodes array reference). @@ -51,13 +47,8 @@ export function useNodeLabels({ isPaid, nodes }: UseNodeLabelsOptions): UseNodeL ); useEffect(() => { - if (!isPaid) { - setLabelsByNodeId({}); - return; - } void fetchLabels(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isPaid, nodeIdKey, fetchLabels]); + }, [nodeIdKey, fetchLabels]); const distinctLabels = useMemo(() => { const set = new Set(); @@ -67,5 +58,5 @@ export function useNodeLabels({ isPaid, nodes }: UseNodeLabelsOptions): UseNodeL return Array.from(set).sort((a, b) => a.localeCompare(b)); }, [labelsByNodeId]); - return { labelsByNodeId, distinctLabels, isAvailable: isPaid }; + return { labelsByNodeId, distinctLabels }; } diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index afc5ef45..cd780d7c 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -36,7 +36,7 @@ export interface SenchoNavigateDetail { export function NodeManager() { const { isPaid } = useLicense(); const { isAdmin, can } = useAuth(); - const canEditLabels = isPaid && isAdmin; + const canEditLabels = isAdmin; // Mirror the backend node:manage guard. This top-level flag checks the global // role only (admin or global node-admin); the per-row Test/Edit/Delete buttons // below additionally honor scoped per-node grants via can('node:manage', 'node', id). @@ -368,11 +368,7 @@ export function NodeManager() { {getStatusBadge(node.status)} - {isPaid ? ( - - ) : ( - - )} + {(() => { diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 00caf5b2..7b6d4b4b 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -1534,7 +1534,8 @@ export default function ResourcesView() { scanId={inspectScanId} onClose={() => setInspectScanId(null)} onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined} - canGenerateSbom={isPaid && isAdmin} + canGenerateSbom={isAdmin} + canExportSarif={isPaid && isAdmin} canCompare canManageSuppressions={isAdmin} /> diff --git a/frontend/src/components/SecurityHistoryView.tsx b/frontend/src/components/SecurityHistoryView.tsx index 0744ae10..5cb7f7aa 100644 --- a/frontend/src/components/SecurityHistoryView.tsx +++ b/frontend/src/components/SecurityHistoryView.tsx @@ -338,7 +338,8 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) setInspectScanId(null)} - canGenerateSbom={isPaid && isAdmin} + canGenerateSbom={isAdmin} + canExportSarif={isPaid && isAdmin} canCompare={false} canManageSuppressions={isAdmin} /> diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index f47a59d6..b5734a92 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -59,6 +59,7 @@ interface VulnerabilityScanSheetProps { onClose: () => void; onRescan?: (imageRef: string) => void; canGenerateSbom?: boolean; + canExportSarif?: boolean; canCompare?: boolean; canManageSuppressions?: boolean; } @@ -109,6 +110,7 @@ export function VulnerabilityScanSheet({ onClose, onRescan, canGenerateSbom = false, + canExportSarif = false, canCompare = false, canManageSuppressions: canManageSuppressionsProp = false, }: VulnerabilityScanSheetProps) { @@ -475,7 +477,7 @@ export function VulnerabilityScanSheet({ icon: Download, onClick: exportCsv, }] : []), - ...(canGenerateSbom && scan.status === 'completed' ? [{ + ...(canExportSarif && scan.status === 'completed' ? [{ label: 'SARIF', icon: Download, onClick: () => { void exportSarif(); }, diff --git a/frontend/src/components/fleet/FleetTopology.tsx b/frontend/src/components/fleet/FleetTopology.tsx index 7fe0fb5f..0a403b48 100644 --- a/frontend/src/components/fleet/FleetTopology.tsx +++ b/frontend/src/components/fleet/FleetTopology.tsx @@ -28,7 +28,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp interface FleetTopologyProps { nodes: FleetTopologyNode[]; onNodeClick?: (nodeId: number) => void; - isPaid: boolean; mode: LayoutMode; onModeChange: (mode: LayoutMode) => void; savedPositions: SavedPositions; @@ -274,7 +273,6 @@ function TopologyToolbar({ export function FleetTopology({ nodes: fleetNodes, onNodeClick, - isPaid, mode, onModeChange, savedPositions, @@ -289,13 +287,7 @@ export function FleetTopology({ const savedPositionsRef = useRef(savedPositions); savedPositionsRef.current = savedPositions; - // Defensive: a Community user who somehow lands in a paid mode (older - // localStorage value, manual edit) gets snapped back to Hub. The toolbar - // also hides these modes for them, so this is a belt-and-suspenders guard. - const effectiveMode: LayoutMode = useMemo(() => { - if ((mode === 'grouped' || mode === 'free') && !isPaid) return 'hub'; - return mode; - }, [mode, isPaid]); + const effectiveMode: LayoutMode = mode; // Only re-layout when the topology *shape* changes (nodes added/removed, // type/status/label flips, mode flips). Metric value changes alone must @@ -397,8 +389,8 @@ export function FleetTopology({ return (
- {isPaid && } - {isPaid && allRemotesUnlabeled && ( + + {allRemotesUnlabeled && (
No node labels assigned. Add labels in Settings · Nodes to see remotes cluster.
diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx index 02c0d573..1c83666d 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -420,7 +420,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}
)} - {trivy.source === 'managed' && isPaid && isAdmin && ( + {trivy.source === 'managed' && isAdmin && (