diff --git a/backend/src/__tests__/fleet.test.ts b/backend/src/__tests__/fleet.test.ts index af73a687..7cadb06e 100644 --- a/backend/src/__tests__/fleet.test.ts +++ b/backend/src/__tests__/fleet.test.ts @@ -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 () => { diff --git a/backend/src/__tests__/scan-compare.test.ts b/backend/src/__tests__/scan-compare.test.ts index 267c0011..9924fb05 100644 --- a/backend/src/__tests__/scan-compare.test.ts +++ b/backend/src/__tests__/scan-compare.test.ts @@ -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 () => { diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 0ae44ad9..cd8ffa8f 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -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'); diff --git a/backend/src/__tests__/suppression-routes.test.ts b/backend/src/__tests__/suppression-routes.test.ts index 6bd504c3..40eeb837 100644 --- a/backend/src/__tests__/suppression-routes.test.ts +++ b/backend/src/__tests__/suppression-routes.test.ts @@ -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 () => { diff --git a/backend/src/middleware/tierGates.ts b/backend/src/middleware/tierGates.ts index 81cafac8..e6bf992c 100644 --- a/backend/src/middleware/tierGates.ts +++ b/backend/src/middleware/tierGates.ts @@ -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); }; diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 7ce4594c..c3c3aa33 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -476,7 +476,6 @@ fleetRouter.get('/node/:nodeId/stacks/:stackName/containers', authMiddleware, as }); fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Response): Promise => { - 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 => { - 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 => { - 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 => { - 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 => { 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 => { - 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 => { - 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 => { 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 => { if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'snapshot ID'); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index ab9e6ce2..413c2c3f 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -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 => { - 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 => { - 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 => { - 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 => { - 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 => { 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)) { diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index b38027c3..2b153255 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -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; } diff --git a/docs/api-reference/security.mdx b/docs/api-reference/security.mdx index ce452caf..a58275f3 100644 --- a/docs/api-reference/security.mdx +++ b/docs/api-reference/security.mdx @@ -5,7 +5,7 @@ description: Automate scan policies, CVE suppressions, and vulnerability scans f The Security API lets you manage scan policies, CVE suppressions, and trigger vulnerability scans from CI pipelines and automation scripts. Every endpoint in this reference is intended for external automation; internal frontend-only endpoints (finding listings, SARIF downloads) are not documented here. -All endpoints require [Bearer token authentication](/api-reference/overview#authentication) and most are gated to Skipper or Admiral. See the per-endpoint **License** row for details. +All endpoints require [Bearer token authentication](/api-reference/overview#authentication). Manual scans, secret and misconfiguration results, scan comparison, and CVE suppressions are available on every tier. Scan policies (with `block_on_deploy` enforcement), SBOM, and SARIF stay on Skipper or Admiral. See the per-endpoint **License** row for details. ## Scan policies @@ -120,7 +120,7 @@ Suppressions let you mark individual CVEs as acknowledged so scan reads, compari **`GET /api/security/suppressions`** -**License:** Skipper or Admiral +**License:** Community Response rows include an `active` boolean computed from the `expires_at` timestamp. @@ -145,7 +145,7 @@ Response rows include an `active` boolean computed from the `expires_at` timesta **`POST /api/security/suppressions`** -**License:** Skipper or Admiral · **Role:** Admin +**License:** Community · **Role:** Admin | Field | Type | Required | Description | |-------|------|:--------:|-------------| @@ -176,7 +176,7 @@ curl -X POST https://your-sencho-instance:1852/api/security/suppressions \ **`DELETE /api/security/suppressions/{id}`** -**License:** Skipper or Admiral · **Role:** Admin +**License:** Community · **Role:** Admin ```bash curl -X DELETE https://your-sencho-instance:1852/api/security/suppressions/3 \ @@ -189,7 +189,7 @@ curl -X DELETE https://your-sencho-instance:1852/api/security/suppressions/3 \ **`POST /api/security/scan`** -**License:** Community for vulnerability-only scans. Skipper or Admiral when `scanners` includes `secret`. · **Role:** Admin +**License:** Community · **Role:** Admin Accepts an image reference and starts an asynchronous scan. The response returns immediately with a `scanId` that you can poll. @@ -198,7 +198,7 @@ Accepts an image reference and starts an asynchronous scan. The response returns | `imageRef` | string | yes | Image reference Trivy will scan (must match Sencho's validator; `/`, `:`, `@`, alphanumerics, `-`, `_`, `.`). | | `stackName` | string | no | Associates the scan with a stack for display purposes. | | `force` | boolean | no | Default `false`. When `true`, ignores the 24-hour digest cache and runs Trivy again. | -| `scanners` | `["vuln"]` or `["vuln","secret"]` | no | Omit for vuln-only. `secret` requires Skipper or Admiral. | +| `scanners` | `["vuln"]` or `["vuln","secret"]` | no | Omit for vuln-only. Pass `["vuln","secret"]` to include secret detection. | ```bash curl -X POST https://your-sencho-instance:1852/api/security/scan \ diff --git a/docs/features/cve-suppressions.mdx b/docs/features/cve-suppressions.mdx index 318ef38d..ae056a4a 100644 --- a/docs/features/cve-suppressions.mdx +++ b/docs/features/cve-suppressions.mdx @@ -6,7 +6,7 @@ description: "Accept known-benign vulnerabilities fleet-wide so your scan result Not every CVE that Trivy reports requires a response. Some are false positives on your base image, some have been accepted by your security review, and some are waiting on an upstream patch. CVE suppressions let you annotate these findings once so they stop competing for attention in every scan, comparison, and alert. - CVE suppressions require a **Skipper** or **Admiral** license. + CVE suppressions are available on every tier. Suppressions written on a control node replicate to its replicas at fleet scope. ## What suppressions do diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx index 38b13075..3a5f952e 100644 --- a/docs/features/fleet-backups.mdx +++ b/docs/features/fleet-backups.mdx @@ -4,7 +4,7 @@ description: Snapshot compose files across all nodes for disaster recovery and a --- - Fleet-Wide Backups require a Skipper or Admiral license. The feature is available to admins in the Fleet View. + Manual fleet snapshots are available on every tier (admin role required). Scheduled fleet snapshots are a Skipper or Admiral feature. Create point-in-time snapshots of every `compose.yaml` and `.env` file across your entire fleet, local and remote nodes alike. Snapshots are stored centrally in Sencho's database and can be browsed, previewed, and restored at any time. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index bf24061f..1838713c 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -84,10 +84,10 @@ Click the **Refresh** button in the top-right to re-fetch data from all nodes. T --- -## Paid features +## Fleet operations - The features below require a Skipper or Admiral license. Community users see an upgrade prompt in place of these controls. + The toolbar (search, sort, filters), stack drill-down, auto-refresh, and per-node update flow are available on every tier. The bulk **Update All** action inside the Node Updates modal is a Skipper or Admiral feature. ### Auto-refresh @@ -163,7 +163,7 @@ The modal shows: - **Filter** search box to find specific nodes - **Node table** with columns: Node name, Type, Current version, Latest version, and Status (either an "Up to date" badge or an "Update" button) - **Recheck** button to refresh the latest version from GitHub and re-scan for available updates -- **Update All** button to trigger updates on all remote nodes that have a pending update +- **Update All** button to trigger updates on all remote nodes that have a pending update (Skipper or Admiral) When you click **Update** on a remote node, Sencho sends the update command to the remote instance. The remote pulls the latest Docker image, then spawns a short-lived helper container that performs the compose recreate. The node restarts with the new version, and the status badge transitions from "Updating" to "Updated" once the gateway detects the version change. The "Updated" badge remains visible for 60 seconds before the node returns to "Up to date". diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 5d786f14..ebef393c 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -23,14 +23,19 @@ Lifetime pricing is an early-adopter offer available for a limited time only. **Community** includes: - Unlimited nodes, compose editor, global logs, app store, alerts, and more +- Fleet View with search, sort, filters, node-card expand, and topology +- Manual fleet snapshots (create, browse, restore, delete) +- Per-node Sencho updates and the Check Updates view +- Vulnerability scanning: install/update/uninstall Trivy, on-demand scans (vulnerabilities, secrets, misconfigurations), scan comparison, and CVE suppressions - Two-factor authentication (TOTP) - Custom OIDC single sign-on (works with Authelia, Keycloak, Authentik, Zitadel, Pocket ID, or any spec-compliant OIDC identity provider) **Skipper** includes everything in Community, plus: -- Fleet View with drill-down - Webhooks and stack labels -- Atomic deployments and fleet-wide backups -- Auto-update policies +- Atomic deployments +- Bulk **Update All** across the fleet, scheduled scans, scheduled updates, and scheduled fleet snapshots +- Scan policies with `block_on_deploy` enforcement, SBOM (SPDX, CycloneDX), and SARIF export +- Auto-update policies for stack images - One-click Google, GitHub, and Okta SSO presets **Admiral** includes everything in Skipper, plus: @@ -40,7 +45,8 @@ Lifetime pricing is an early-adopter offer available for a limited time only. - Audit log and host console - API tokens and private registries - Notification routing -- Scheduled operations +- Auto-update of the managed Trivy binary +- All other scheduled operations (restart, prune, etc.) **SSO is available on every tier.** Community users can integrate any OIDC-compliant identity provider through the Custom OIDC option. Paid tiers add turnkey presets (Google, GitHub, Okta) and LDAP / Active Directory. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index b319b999..aaf68756 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -57,7 +57,7 @@ Monitor your entire infrastructure from a single screen. The fleet dashboard sho ## Remote updates -Check for outdated nodes and trigger over-the-air updates from the Fleet View. When the gateway is running a newer version than a remote node, a one-click update pulls the latest image and recreates the container automatically. [Learn more →](/features/remote-updates) +Check for outdated nodes and trigger over-the-air updates from the Fleet View. When the gateway is running a newer version than a remote node, a one-click update pulls the latest image and recreates the container automatically. Per-node updates and the Check Updates view are available on every tier; the bulk **Update All** action is Skipper or Admiral. [Learn more →](/features/remote-updates) ## Alerts & notifications @@ -81,7 +81,7 @@ Define schedules for Sencho to automatically check your container images for upd ## Scheduled operations -Automate recurring maintenance tasks like stack restarts, fleet snapshots, and system prunes on a cron schedule. Every execution is logged with full history so you always know what ran and when. Admiral only. [Learn more →](/features/scheduled-operations) +Automate recurring maintenance tasks like stack restarts, fleet snapshots, and system prunes on a cron schedule. Every execution is logged with full history so you always know what ran and when. Scheduled scans, updates, and snapshots are available on Skipper and Admiral; other scheduled actions remain Admiral only. [Learn more →](/features/scheduled-operations) ## RBAC & user management @@ -97,7 +97,7 @@ Skipper and Admiral users get automatic backup and rollback on every deployment. ## Fleet-wide backups -Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy, even to remote nodes. [Learn more →](/features/fleet-backups) +Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy, even to remote nodes. Manual snapshots are available on every tier; scheduled fleet snapshots are Skipper or Admiral. [Learn more →](/features/fleet-backups) ## Private registries @@ -105,7 +105,7 @@ Store credentials for private Docker registries: Docker Hub organizations, GHCR, ## Vulnerability scanning -Scan container images for known CVEs with [Trivy](https://trivy.dev). On-demand scanning and severity badges are available on every tier; scheduled scans, scan policies that gate deploys, SBOM generation, and scan history are available on Skipper and Admiral. [Learn more →](/features/vulnerability-scanning) +Scan container images for known CVEs with [Trivy](https://trivy.dev). Manual scanning, secret and misconfiguration detection, scan comparison, and CVE suppressions are available on every tier; scheduled scans, scan policies that gate deploys, SBOM generation, and SARIF export are available on Skipper and Admiral. Auto-update of the managed Trivy binary is Admiral. [Learn more →](/features/vulnerability-scanning) ## Audit log diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx index ecf64053..38b51fad 100644 --- a/docs/features/remote-updates.mdx +++ b/docs/features/remote-updates.mdx @@ -6,7 +6,7 @@ description: Check for outdated nodes and trigger over-the-air Sencho updates fr Sencho can update remote nodes directly from the dashboard. When a node is running an older version than the latest available release, a one-click update pulls the latest image and recreates the container automatically. This includes the local (gateway) node itself. - Remote updates require a **Skipper** or **Admiral** license. + Per-node remote updates and the Check Updates view are available on every tier (admin role required). The bulk **Update All** action is a Skipper or Admiral feature. ## Prerequisites diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index f398b402..8a6ee77a 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -4,7 +4,7 @@ description: Automate recurring Docker operations like stack restarts, lifecycle --- - Scheduled Operations requires a Sencho **Admiral** license. Skipper users see only the **Auto-update Stack** action; Admiral users see every action. + Scheduled Operations is available to admins on Skipper and Admiral. Skipper unlocks **Auto-update Stack**, **Vulnerability Scan**, and **Fleet Snapshot**. All other actions (Restart Stack, System Prune, Backup Stack Files, Stop / Take Down / Start Stack) remain Admiral. ## Overview @@ -36,7 +36,7 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard |--------|--------|-------------| | **Restart Stack** | A specific stack (or specific services within it) on a specific node | Restarts all or selected containers in the stack | | **Auto-update Stack** | A specific stack on a specific node | Checks each image for updates and recreates the stack if any image has a newer version. See [Auto-Update Readiness](/features/auto-update-policies) for the companion board. Available on Skipper and Admiral. | -| **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files | +| **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files. Available on Skipper and Admiral. | | **System Prune** | The default node | Prunes selected resources, optionally filtered by Docker label | | **Vulnerability Scan** | All images on a specific node | Runs Trivy against every image on the target node and records the results. Requires Trivy to be installed, see [Installing Trivy](/operations/trivy-setup). Available on Skipper and Admiral. | | **Backup Stack Files** | A specific stack on a specific node | Backs up the stack's compose file and `.env` to `/backups//`. The most recent backup per stack is kept; each run overwrites the previous one. | @@ -46,7 +46,7 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard ## Creating a Scheduled Task -1. Navigate to the **Schedules** tab in the top navigation bar (visible to Admiral admins). +1. Navigate to the **Schedules** tab in the top navigation bar (visible to Skipper and Admiral admins). 2. Click **New Schedule**. 3. Fill in the form: - **Name**: A descriptive label (e.g. "Nightly staging restart"). @@ -212,7 +212,7 @@ Execution history is retained for 30 days. The Scheduler Service runs in the background and checks for due tasks every 60 seconds. When a task's next run time has passed: -1. The scheduler verifies your Admiral license is active. +1. The scheduler verifies your license tier matches the action (Skipper for update, scan, snapshot; Admiral for everything else). 2. It executes the configured action using the same internal services that power the UI buttons (restart, snapshot, prune). 3. Results are logged to the execution history. 4. On failure, an alert is dispatched via your configured notification channels. diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index d6a8b12a..7ad2daed 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -3,7 +3,7 @@ title: "Vulnerability Scanning" description: "Scan container images for known CVEs, surface severity badges in the Resources Hub, and alert on policy violations." --- -Sencho integrates with [Trivy](https://trivy.dev) to scan container images for known vulnerabilities (CVEs), surface severity badges next to your images, and alert when a scan result exceeds a configured threshold. On-demand scanning is available on every tier; automation, policies, and SBOM generation are Skipper and Admiral. +Sencho integrates with [Trivy](https://trivy.dev) to scan container images for known vulnerabilities (CVEs), surface severity badges next to your images, and alert when a scan result exceeds a configured threshold. Manual scanning, secret and misconfiguration detection, scan comparison, and CVE suppressions are all available on every tier. Skipper and Admiral add automation, policy enforcement, and compliance exports. Resources Hub showing vulnerability severity badges next to image tags @@ -21,17 +21,20 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund | Feature | Community | Skipper | Admiral | |---------|:---------:|:-------:|:-------:| -| On-demand image scanning | ✓ | ✓ | ✓ | +| Install / update / uninstall Trivy from Settings | ✓ | ✓ | ✓ | +| On-demand image scanning (vulnerabilities) | ✓ | ✓ | ✓ | | Severity badges in the Resources Hub | ✓ | ✓ | ✓ | | Scan results drawer with vulnerability table | ✓ | ✓ | ✓ | | Post-deploy automated scanning | ✓ | ✓ | ✓ | +| Secret detection in image filesystems | ✓ | ✓ | ✓ | +| Compose file misconfiguration scanning | ✓ | ✓ | ✓ | +| Scan history and comparison | ✓ | ✓ | ✓ | +| CVE suppressions | ✓ | ✓ | ✓ | | Scheduled fleet scans (all images on a node) | | ✓ | ✓ | -| Scan policies (warning and critical alerts) | | ✓ | ✓ | +| Scan policies with `block_on_deploy` enforcement | | ✓ | ✓ | | SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ | -| Scan history and comparison | | ✓ | ✓ | -| Secret detection in image filesystems | | ✓ | ✓ | -| Compose file misconfiguration scanning | | ✓ | ✓ | | SARIF export (code scanning integration) | | ✓ | ✓ | +| Auto-update of the managed Trivy binary | | | ✓ | ## On-demand scanning diff --git a/docs/operations/trivy-setup.mdx b/docs/operations/trivy-setup.mdx index c76875e3..8cbaf4e5 100644 --- a/docs/operations/trivy-setup.mdx +++ b/docs/operations/trivy-setup.mdx @@ -44,7 +44,7 @@ When a newer Trivy release is available, Settings → Security shows an **Update To update automatically instead, toggle **Auto-update Trivy** on. Sencho checks for new releases once a day and installs them in the background. You'll get an in-app notification each time a new version is installed, or when an update is available and auto-update is off. -The install, update, and uninstall buttons are Admiral-only. Skipper and Community instances see the scanner status, but install actions require an Admiral license. +The install, update, and uninstall buttons are available to admins on every tier. The **Auto-update Trivy** toggle is Admiral only. ### Removing the managed install @@ -212,11 +212,9 @@ This means the host binary is not ABI-compatible with the Sencho image. Use the The first scan after a Trivy install downloads the vulnerability database. Expect 10 to 30 seconds of additional latency. Subsequent scans are near-instant once the cache is warm and `TRIVY_CACHE_DIR` is persisted. -### Install button is disabled +### Install button is hidden -The install, update, and uninstall buttons require an Admiral license. If you hold a Skipper or Community license, the card shows current status only; use Option 2 or 3 to add Trivy manually. - -The install button is also hidden when a host-installed Trivy is already detected on `PATH`. Remove the host binary (or drop the bind mount) to switch to the managed install. +The install button is hidden when a host-installed Trivy is already detected on `PATH`. Remove the host binary (or drop the bind mount) to switch to the managed install. The button also requires the admin role; viewer accounts see the scanner status only. ### Private registry images fail to scan diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 2edb9222..edcc82fb 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -34,11 +34,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const experimental = useExperimental(); const { prefs, updatePrefs } = useFleetPreferences(); - const updateStatus = useFleetUpdateStatus({ isPaid }); + const updateStatus = useFleetUpdateStatus(); const overview = useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses }); useFleetPolling({ - isPaid, fetchOverview: overview.fetchOverview, fetchUpdateStatus: updateStatus.fetchUpdateStatus, updateStatuses: updateStatus.updateStatuses, @@ -69,13 +68,11 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { Overview - {isPaid && ( - - - Snapshots - - - )} + + + Snapshots + + {isAdmiral && experimental && ( @@ -114,17 +111,15 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
- {isPaid && ( - - )} +
- {/* Paid: Expandable Stack List with Container Drill-Down */} - {isOnline && isPaid && ( + {/* Expandable Stack List with Container Drill-Down */} + {isOnline && (
- {updatableRemoteCount > 0 && ( + {canBulkUpdate && updatableRemoteCount > 0 && ( - )} - {trivy.source === 'managed' && ( - - )} -
- )} +
+ {trivy.source === 'none' && ( + + {trivyBusy === 'install' ? ( + + ) : ( + + )} + Install Trivy + + )} + {trivy.source === 'managed' && updateCheck?.updateAvailable && ( + + )} + {trivy.source === 'managed' && ( + + )} +
{trivy.source === 'managed' && trivy.version && ( @@ -414,7 +398,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { )} - {!isRemote && !loading && policies.length === 0 && ( + {isPaid && !isRemote && !loading && policies.length === 0 && ( } title="No scan policies configured" @@ -422,7 +406,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { /> )} - {!isRemote && !loading && + {isPaid && !isRemote && !loading && policies.map((policy) => (
@@ -476,7 +460,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { {!isRemote && } - + {isPaid && ( + <> + {editingId ? 'Edit Policy' : 'New Policy'} @@ -548,25 +534,27 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { - !open && setDeleteId(null)}> - - - Delete scan policy? - - This removes the policy immediately. Existing scans are not affected. - - - - Cancel - - Delete - - - - + !open && setDeleteId(null)}> + + + Delete scan policy? + + This removes the policy immediately. Existing scans are not affected. + + + + Cancel + + Delete + + + + + + )} diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 1cb7be5b..1e162704 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -180,7 +180,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ label: 'Security', description: 'Image scanning, suppressions, and posture defaults.', keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'], - tier: 'skipper', + tier: null, scope: 'node', adminOnly: true, },