feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)

Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.

Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh

Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)

The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.

Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.

Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
This commit is contained in:
Anso
2026-05-05 12:54:26 -04:00
committed by GitHub
parent f1a592372b
commit ecf4dd5d52
28 changed files with 234 additions and 280 deletions
+31 -37
View File
@@ -157,34 +157,7 @@ describe('GET /api/fleet/overview', () => {
describe('Fleet tier gating', () => {
afterEach(() => vi.restoreAllMocks());
it('GET /api/fleet/update-status returns 403 on free tier', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/update-status')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('GET /api/fleet/snapshots returns 403 on free tier', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/snapshots')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('POST /api/fleet/nodes/1/update returns 403 on free tier', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet/nodes/1/update')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('POST /api/fleet/update-all returns 403 on free tier', async () => {
it('POST /api/fleet/update-all returns 403 on community tier (bulk update is Skipper+)', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet/update-all')
@@ -193,22 +166,44 @@ describe('Fleet tier gating', () => {
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('DELETE /api/fleet/nodes/1/update-status returns 403 on free tier', async () => {
it('GET /api/fleet/update-status is accessible on community tier', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/update-status')
.set('Authorization', authHeader);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('GET /api/fleet/snapshots is accessible on community tier', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/snapshots')
.set('Authorization', authHeader);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('POST /api/fleet/nodes/1/update is accessible on community tier', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet/nodes/1/update')
.set('Authorization', authHeader);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('DELETE /api/fleet/nodes/1/update-status is accessible on community tier', async () => {
mockTier('community');
const res = await request(app)
.delete('/api/fleet/nodes/1/update-status')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('DELETE /api/fleet/update-status returns 403 on free tier', async () => {
it('DELETE /api/fleet/update-status is accessible on community tier', async () => {
mockTier('community');
const res = await request(app)
.delete('/api/fleet/update-status')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
});
@@ -384,14 +379,13 @@ describe('Fleet snapshot restore', () => {
expect(res.status).toBe(401);
});
it('POST /api/fleet/snapshots/:id/restore returns 403 on free tier', async () => {
it('POST /api/fleet/snapshots/:id/restore is accessible on community tier', async () => {
mockTier('community');
const res = await request(app)
.post(`/api/fleet/snapshots/${snapshotId}/restore`)
.set('Authorization', authHeader)
.send({ nodeId: 1, stackName: 'test' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('returns 400 with missing nodeId/stackName', async () => {
+2 -3
View File
@@ -116,7 +116,7 @@ beforeEach(() => {
});
describe('GET /api/security/compare', () => {
it('returns 403 for community tier', async () => {
it('is accessible on community tier', async () => {
tierSpy.mockReturnValue('community');
const a = seedScan();
const b = seedScan({ scannedAt: Date.now() + 1000 });
@@ -125,8 +125,7 @@ describe('GET /api/security/compare', () => {
.get(`/api/security/compare?scanId1=${a}&scanId2=${b}`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('returns 400 for non-finite scanId params', async () => {
@@ -281,7 +281,7 @@ describe('SchedulerService - license gating', () => {
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
});
it('skips non-update tasks for non-admiral pro', async () => {
it('skips non-update/scan/snapshot tasks for non-admiral pro', async () => {
mockGetTier.mockReturnValue('paid');
mockGetVariant.mockReturnValue('individual');
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
@@ -292,6 +292,18 @@ describe('SchedulerService - license gating', () => {
expect(mockCreateScheduledTaskRun).not.toHaveBeenCalled();
});
it('allows snapshot tasks for non-admiral pro (Skipper)', async () => {
mockGetTier.mockReturnValue('paid');
mockGetVariant.mockReturnValue('individual');
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'snapshot', target_type: 'fleet' })]);
const svc = SchedulerService.getInstance();
await (svc as any).tick();
await new Promise(r => setTimeout(r, 50));
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
});
it('allows all actions for admiral (pro + team)', async () => {
mockGetTier.mockReturnValue('paid');
mockGetVariant.mockReturnValue('admiral');
@@ -56,11 +56,11 @@ describe('GET /api/security/suppressions', () => {
expect(res.status).toBe(401);
});
it('requires paid tier', async () => {
it('is accessible on community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app).get('/api/security/suppressions').set('Authorization', adminAuthHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('returns an empty list when no suppressions exist', async () => {
@@ -123,14 +123,14 @@ describe('POST /api/security/suppressions', () => {
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('rejects community tier with 403', async () => {
it('is accessible on community tier (admin still required)', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
expect(res.status).toBe(201);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('rejects writes on replicas with 403', async () => {
+2 -2
View File
@@ -59,9 +59,9 @@ export const requireNodeProxy = (req: Request, res: Response): boolean => {
return true;
};
/** Tier gate for scheduled tasks: `update` and `scan` require Skipper+, everything else requires Admiral. */
/** Tier gate for scheduled tasks: `update`, `scan`, and `snapshot` require Skipper+, everything else requires Admiral. */
export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
if (action === 'update' || action === 'scan') return requirePaid(req, res);
if (action === 'update' || action === 'scan' || action === 'snapshot') return requirePaid(req, res);
return requireAdmiral(req, res);
};
+1 -12
View File
@@ -476,7 +476,6 @@ fleetRouter.get('/node/:nodeId/stacks/:stackName/containers', authMiddleware, as
});
fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
@@ -625,7 +624,6 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
});
fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
@@ -779,7 +777,6 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon
});
fleetRouter.delete('/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
if (nodeId === null) return;
@@ -797,7 +794,6 @@ fleetRouter.delete('/nodes/:nodeId/update-status', authMiddleware, async (req: R
});
fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
// Pre-fetch fresh latest version so the next GET has up-to-date data.
if (req.query.recheck === 'true') {
await getLatestVersion(true);
@@ -810,11 +806,10 @@ fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: R
res.status(204).send();
});
// ─── Fleet Snapshots (Skipper+) ───
// ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ───
fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const { description = '' } = req.body;
@@ -909,8 +904,6 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
});
fleetRouter.get('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const offset = parseInt(req.query.offset as string, 10) || 0;
@@ -926,8 +919,6 @@ fleetRouter.get('/snapshots', authMiddleware, async (req: Request, res: Response
});
fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'snapshot ID');
if (id === null) return;
@@ -972,7 +963,6 @@ fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Resp
fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID');
@@ -1085,7 +1075,6 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
fleetRouter.delete('/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'snapshot ID');
+3 -13
View File
@@ -67,7 +67,7 @@ securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Respons
});
securityRouter.post('/trivy-install', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requireAdmin(req, res)) return;
const svc = TrivyService.getInstance();
if (svc.getSource() === 'host') {
res.status(409).json({ error: 'Trivy is already installed on the host PATH. Remove the host binary before managing it from Sencho.' });
@@ -89,7 +89,7 @@ securityRouter.post('/trivy-install', trivyInstallLimiter, authMiddleware, async
});
securityRouter.delete('/trivy-install', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requireAdmin(req, res)) return;
const svc = TrivyService.getInstance();
if (svc.getSource() !== 'managed') {
res.status(409).json({ error: 'No managed Trivy install to remove' });
@@ -107,7 +107,6 @@ securityRouter.delete('/trivy-install', authMiddleware, async (req: Request, res
});
securityRouter.get('/trivy-update-check', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
const svc = TrivyService.getInstance();
if (svc.getSource() !== 'managed') {
res.status(409).json({ error: 'Update checks only apply to managed installs' });
@@ -124,7 +123,7 @@ securityRouter.get('/trivy-update-check', authMiddleware, async (req: Request, r
});
securityRouter.post('/trivy-update', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requireAdmin(req, res)) return;
const svc = TrivyService.getInstance();
if (svc.getSource() !== 'managed') {
res.status(409).json({ error: 'Update only applies to managed installs' });
@@ -178,7 +177,6 @@ securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void
res.status(400).json({ error: 'scanners must be an array of "vuln" or "secret"' });
return;
}
if (scanners?.includes('secret') && !requirePaid(req, res)) return;
const nodeId = req.nodeId;
if (svc.isScanning(nodeId, imageRef)) {
res.status(409).json({ error: 'Already scanning this image' });
@@ -194,7 +192,6 @@ securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void
securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
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;
@@ -288,7 +285,6 @@ securityRouter.get(
'/scans/:scanId/secrets',
authMiddleware,
(req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
@@ -314,7 +310,6 @@ securityRouter.get(
'/scans/:scanId/misconfigs',
authMiddleware,
(req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
@@ -495,7 +490,6 @@ securityRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Respo
});
securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const now = Date.now();
const rows = DatabaseService.getInstance().getCveSuppressions().map((s) => ({
...s,
@@ -506,7 +500,6 @@ securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response
securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'CVE suppressions')) return;
const body = req.body ?? {};
const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : '';
@@ -559,7 +552,6 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons
securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'CVE suppressions')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
@@ -597,7 +589,6 @@ securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Resp
securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'CVE suppressions')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
@@ -609,7 +600,6 @@ securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: R
});
securityRouter.get('/compare', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId1 = Number(req.query.scanId1);
const scanId2 = Number(req.query.scanId2);
if (!Number.isFinite(scanId1) || !Number.isFinite(scanId2)) {
+1 -1
View File
@@ -209,7 +209,7 @@ export class SchedulerService {
db.deleteOldScans(90 * 24 * 60 * 60 * 1000);
for (const task of dueTasks) {
if (!isAdmiral && task.action !== 'update' && task.action !== 'scan') {
if (!isAdmiral && task.action !== 'update' && task.action !== 'scan' && task.action !== 'snapshot') {
if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: action "${task.action}" requires Admiral tier`);
continue;
}