feat(security): move managed Trivy auto-update to Skipper tier (#1150)

* feat(security): move managed Trivy auto-update to Skipper tier

Drop the gate on the managed Trivy auto-update toggle from Admiral to
Skipper so it lives alongside the rest of Sencho's automation features
(auto-heal, scheduled ops, per-stack image auto-update, scan policies)
instead of behind the enterprise-control tier.

Backend: `PUT /api/security/trivy-auto-update` switches from
`requireAdmiral` to `requirePaid`. The 24h scheduler tick in
SchedulerService that reads the setting is tier-neutral and picks the
new gate up automatically.

Frontend: SecuritySection.tsx swaps the inline `isAdmiral` conditional
on the toggle render for `isPaid`. The local `isAdmiral` derivation and
the `useLicense` import become unused and are removed.

Docs: licensing, overview, vulnerability-scanning matrix, trivy-setup,
and the settings reference now read Skipper consistently for this
feature.

* fix(security): require admin role on trivy-auto-update toggle

Independent audit of the prior commit flagged that PUT
/api/security/trivy-auto-update had no admin-role guard. The route was
authenticated and tier-gated, but the global /api authGate only
authenticates and `requirePaid` only checks tier. Any paid viewer could
flip the global trivy_auto_update setting via a direct API call.

Add `requireAdmin` ahead of `requirePaid`, matching the pattern used by
every other mutating route in this file (sbom, policies, suppressions,
misconfig-acks).

Add route tests covering paid admin allowed, paid viewer rejected,
community admin rejected, and unauthenticated rejected.
This commit is contained in:
Anso
2026-05-21 23:54:01 -04:00
committed by GitHub
parent b740dd1078
commit 8a3889dc67
8 changed files with 99 additions and 12 deletions
@@ -0,0 +1,89 @@
/**
* Tests for the role + tier 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 (Skipper or Admiral) tier.
*/
import { describe, it, expect, beforeAll, afterAll, 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 DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let adminCookie: string;
let viewerCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
const viewerHash = await bcrypt.hash('viewerpass3', 1);
DatabaseService.getInstance().addUser({ username: 'trivy-viewer', password_hash: viewerHash, role: 'viewer' });
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'trivy-viewer', password: 'viewerpass3' });
const cookies = viewerRes.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
});
afterAll(() => cleanupTestDb(tmpDir));
describe('PUT /api/security/trivy-auto-update', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.put('/api/security/trivy-auto-update')
.send({ enabled: true });
expect(res.status).toBe(401);
});
it('rejects authenticated viewer with 403', async () => {
const res = await request(app)
.put('/api/security/trivy-auto-update')
.set('Cookie', viewerCookie)
.send({ enabled: true });
expect(res.status).toBe(403);
});
it('rejects Community tier with 403', async () => {
const { LicenseService } = await import('../services/LicenseService');
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
try {
const res = await request(app)
.put('/api/security/trivy-auto-update')
.set('Cookie', adminCookie)
.send({ enabled: true });
expect(res.status).toBe(403);
} finally {
spy.mockReturnValue('paid');
}
});
it('accepts paid admin and persists the setting (enable)', async () => {
const res = await request(app)
.put('/api/security/trivy-auto-update')
.set('Cookie', adminCookie)
.send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.autoUpdate).toBe(true);
expect(DatabaseService.getInstance().getGlobalSettings().trivy_auto_update).toBe('1');
});
it('accepts paid admin and persists the setting (disable)', async () => {
const res = await request(app)
.put('/api/security/trivy-auto-update')
.set('Cookie', adminCookie)
.send({ enabled: false });
expect(res.status).toBe(200);
expect(res.body.autoUpdate).toBe(false);
expect(DatabaseService.getInstance().getGlobalSettings().trivy_auto_update).toBe('0');
});
});
+3 -2
View File
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireAdmiral, requirePaid } from '../middleware/tierGates';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { trivyInstallLimiter } from '../middleware/rateLimiters';
import TrivyService, { SbomFormat } from '../services/TrivyService';
import TrivyInstaller from '../services/TrivyInstaller';
@@ -203,7 +203,8 @@ securityRouter.post('/trivy-update', trivyInstallLimiter, authMiddleware, async
});
securityRouter.put('/trivy-auto-update', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmiral(req, res)) return;
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');