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
@@ -70,17 +70,16 @@ describe('GET /api/dashboard/configuration', () => {
});
});
it('keeps freed rows unlocked and only scanPolicies locked for Community', async () => {
it('keeps every freed row unlocked for Community', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
expect(res.status).toBe(200);
// routing rules, webhooks, and scheduled tasks are free.
// routing rules, webhooks, scheduled tasks, and scan policies are all free.
expect(res.body.notifications.routingRules.locked).toBe(false);
expect(res.body.automation.webhooks.locked).toBe(false);
expect(res.body.automation.scheduledTasks.locked).toBe(false);
// Scan policies stay paid-gated.
expect(res.body.security.scanPolicies.locked).toBe(true);
expect(res.body.security.scanPolicies.locked).toBe(false);
});
it('unlocks every gated row for the paid tier', async () => {
@@ -123,3 +123,47 @@ describe('PUT /api/security/policies/:id risk inputs', () => {
expect(res.body).toMatchObject({ block_on_kev: 1, block_on_fixable: 0 });
});
});
describe('scan policies on Community (no tier gate)', () => {
beforeEach(() => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
});
it('lets a Community admin create, read, update, and delete a policy', async () => {
const created = await post({ name: 'community-gate', max_severity: 'CRITICAL', block_on_deploy: 1 });
expect(created.status).toBe(201);
const id = created.body.id as number;
const list = await request(app).get('/api/security/policies').set('Authorization', adminAuthHeader);
expect(list.status).toBe(200);
expect((list.body as Array<{ id: number }>).some((p) => p.id === id)).toBe(true);
const updated = await request(app)
.put(`/api/security/policies/${id}`)
.set('Authorization', adminAuthHeader)
.send({ name: 'community-gate-2' });
expect(updated.status).toBe(200);
const removed = await request(app)
.delete(`/api/security/policies/${id}`)
.set('Authorization', adminAuthHeader);
expect(removed.status).toBe(200);
});
it('lets a Community viewer read policies but denies a write (admin gate is the sole guard)', async () => {
const db = DatabaseService.getInstance();
if (!db.getUserByUsername('pol-viewer')) {
db.addUser({ username: 'pol-viewer', password_hash: 'x', role: 'viewer' });
}
const viewerHeader = `Bearer ${jwt.sign({ username: 'pol-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
const read = await request(app).get('/api/security/policies').set('Authorization', viewerHeader);
expect(read.status).toBe(200);
const write = await request(app)
.post('/api/security/policies')
.set('Authorization', viewerHeader)
.send({ name: 'viewer-blocked', max_severity: 'CRITICAL', block_on_deploy: 1 });
expect(write.status).toBe(403);
});
});
@@ -1,10 +1,10 @@
/**
* Tests for the role + tier gate on PUT /api/security/deploy-block-honor-suppressions.
* Tests for the role gate on PUT /api/security/deploy-block-honor-suppressions.
*
* The route flips the global `deploy_block_honor_suppressions` setting that the
* pre-deploy policy gate reads to decide whether a suppressed CVE still counts
* toward a block-on-deploy policy. It must be reachable only by an admin on a
* paid (Admiral) tier, matching the trivy-auto-update toggle.
* toward a block-on-deploy policy. It must be reachable by any admin on every
* tier; only the admin role is required.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
@@ -52,7 +52,7 @@ describe('PUT /api/security/deploy-block-honor-suppressions', () => {
expect(res.status).toBe(403);
});
it('rejects Community tier with 403', async () => {
it('accepts a Community tier admin (no tier gate)', async () => {
const { LicenseService } = await import('../services/LicenseService');
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
try {
@@ -60,7 +60,8 @@ describe('PUT /api/security/deploy-block-honor-suppressions', () => {
.put('/api/security/deploy-block-honor-suppressions')
.set('Cookie', adminCookie)
.send({ enabled: true });
expect(res.status).toBe(403);
expect(res.status).toBe(200);
expect(res.body.honorSuppressionsOnDeploy).toBe(true);
} finally {
spy.mockReturnValue('paid');
}
@@ -445,7 +445,7 @@ describe('GET /api/security/scans/:scanId/vulnerabilities', () => {
});
});
describe('GET /api/security/vex/export (Admiral)', () => {
describe('GET /api/security/vex/export (Community)', () => {
beforeEach(() => {
resetSecurity();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
@@ -454,13 +454,17 @@ describe('GET /api/security/vex/export (Admiral)', () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
});
it('is gated to Admiral: 403 for Community', async () => {
it('lets a Community admin export (no tier gate)', async () => {
const res = await request(app).get('/api/security/vex/export').set('Cookie', adminCookie);
expect(res.status).toBe(200);
});
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
const res = await request(app).get('/api/security/vex/export').set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
it('exports an OpenVEX document from triage decisions for Admiral', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
it('exports an OpenVEX document from triage decisions', async () => {
db().createCveSuppression({
cve_id: 'CVE-2024-2222', pkg_name: null, image_pattern: 'nginx*', reason: 'not present in build',
created_by: 'admin', created_at: Date.now(), expires_at: null, replicated_from_control: 0,
@@ -1,10 +1,7 @@
/**
* 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.
* Both scan-export endpoints are available on every tier (admin only, no tier gate):
* POST /api/security/sbom -> per-image SBOM artifact
* GET /api/security/scans/:id/sarif -> SARIF for CI / code-scanning ingestion
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
@@ -68,24 +65,23 @@ describe('POST /api/security/sbom (Community)', () => {
});
});
describe('GET /api/security/scans/:scanId/sarif (Admiral only)', () => {
describe('GET /api/security/scans/:scanId/sarif (Community)', () => {
afterEach(() => { vi.restoreAllMocks(); mockTier('paid'); });
it('rejects a Community admin with 403 PAID_REQUIRED', async () => {
it('lets a Community admin reach the route (404 for a missing scan, not 403)', 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);
});
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
mockTier('community');
const res = await request(app)
.get('/api/security/scans/999999/sarif')
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
});
+2 -3
View File
@@ -52,7 +52,6 @@ export function buildLocalConfigurationStatus(
tier: LicenseTier,
): ConfigurationStatus {
const db = DatabaseService.getInstance();
const isPaid = tier === 'paid';
const agents = db.getAgents(nodeId);
const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => {
@@ -129,11 +128,11 @@ export function buildLocalConfigurationStatus(
mfaEnabled: mfaRow ? mfaRow.enabled === 1 : null,
ssoEnabled: !!enabledSso,
ssoProvider: enabledSso?.provider ?? null,
// Scan policies (deploy enforcement) require a paid license.
// Scan policies are available on every tier.
scanPolicies: {
total: scanPolicies.length,
enabled: scanPolicies.filter(p => p.enabled === 1).length,
locked: !isPaid,
locked: false,
},
},
thresholds: {
+6 -12
View File
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { requireAdmin } from '../middleware/tierGates';
import { trivyInstallLimiter } from '../middleware/rateLimiters';
import TrivyService, { SbomFormat } from '../services/TrivyService';
import TrivyInstaller from '../services/TrivyInstaller';
@@ -320,7 +320,6 @@ securityRouter.put('/cve-intel-enabled', authMiddleware, (req: Request, res: Res
// deploys, against that node's own replicated suppression copy. Default off.
securityRouter.put('/deploy-block-honor-suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
// Require an explicit boolean so a stringy `"1"` cannot silently disable the
// gate (this toggle weakens a deploy block, so intent must be unambiguous).
if (typeof req.body?.enabled !== 'boolean') {
@@ -340,7 +339,7 @@ securityRouter.put('/deploy-block-honor-suppressions', authMiddleware, (req: Req
// Pre-deploy scan advisory toggle. When on, a manual stack deploy first shows
// the latest cached scan severity for each image so the operator can review it
// before deploying. Visibility only: it never blocks (that is the paid
// before deploying. Visibility only: it never blocks (that is the
// deploy-block policy). Per-instance, admin-only, all tiers. Default off.
securityRouter.put('/pre-deploy-scan-advisory', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
@@ -1031,7 +1030,6 @@ securityRouter.get(
authMiddleware,
(req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
@@ -1092,12 +1090,10 @@ securityRouter.get(
},
);
// Export the instance's CVE triage decisions as an OpenVEX document. Authoring
// fleet VEX is a governance feature, so it is Admiral (paid) + admin, mirroring
// the SARIF export gate.
// Export the instance's CVE triage decisions as an OpenVEX document. Admin-only,
// mirroring the SARIF export.
securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const suppressions = DatabaseService.getInstance().getCveSuppressions();
const doc = generateOpenVex(suppressions, req.user?.username || 'sencho', new Date().toISOString());
@@ -1111,7 +1107,8 @@ securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response):
});
securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
// Reading policies is not privileged, so this stays auth-only; only the
// mutation routes below require admin.
// Replicas see only policies that apply to themselves: local-only rows plus
// fleet-wide and self-identity-matched replicated rows. Identity-scoped
// rows targeting other replicas are filtered out at the SQL boundary.
@@ -1122,7 +1119,6 @@ securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): v
securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) return;
const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable } = req.body ?? {};
if (!name || typeof name !== 'string' || !name.trim()) {
@@ -1173,7 +1169,6 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response):
securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
@@ -1232,7 +1227,6 @@ securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response
securityRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {