mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29: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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user