From c704cb54d20a41329af5319b04b3c58f1b58d3f6 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 29 Jul 2026 22:02:53 -0400 Subject: [PATCH] fix(rbac): enforce operational permission parity (#1736) --- .../src/__tests__/blueprints-authz.test.ts | 26 +++---- .../blueprints-community-tier.test.ts | 4 +- .../__tests__/containers-route-authz.test.ts | 11 +++ .../fleet-action-card-endpoints.test.ts | 4 +- backend/src/__tests__/fleet-actions.test.ts | 75 ++++++++++++++++++- .../src/__tests__/mesh-route-gating.test.ts | 26 +++++-- .../operational-permission-matrix.test.ts | 65 ++++++++++++++++ .../__tests__/permissions-stack-rbac.test.ts | 18 +++++ .../__tests__/security-overview-route.test.ts | 4 +- .../security-sbom-sarif-tier.test.ts | 13 ++-- .../src/__tests__/suppression-routes.test.ts | 4 +- backend/src/middleware/permissions.ts | 12 ++- backend/src/routes/OPERATIONAL_PERMISSIONS.md | 52 +++++++++++++ backend/src/routes/blueprints.ts | 20 +++-- backend/src/routes/containers.ts | 2 +- backend/src/routes/dependencyMap.ts | 2 + backend/src/routes/fleet.ts | 34 +++++++-- backend/src/routes/fleetActions.ts | 40 ++++++++-- backend/src/routes/labels.ts | 23 ++++-- backend/src/routes/mesh.ts | 23 ++++-- backend/src/routes/networking.ts | 13 ++-- backend/src/routes/nodeLabels.ts | 10 ++- backend/src/routes/nodes.ts | 6 +- backend/src/routes/security.ts | 36 ++++----- backend/src/routes/stacks.ts | 24 ++++++ backend/src/routes/systemMaintenance.ts | 9 +++ backend/src/routes/templates.ts | 2 + backend/src/routes/volumes.ts | 8 +- frontend/src/components/AppStoreView.tsx | 2 +- .../components/EditorLayout/EditorView.tsx | 1 - .../EditorLayout/MobileStackDetail.tsx | 1 - .../components/EditorLayout/ShellOverlays.tsx | 2 +- .../__tests__/StackIdentityHeader.test.tsx | 12 ++- .../EditorLayout/editor-view-blocks.tsx | 4 +- frontend/src/components/FleetView.tsx | 20 +++-- .../src/components/FleetView/NodeCard.tsx | 7 +- .../FleetView/__tests__/NodeCard.test.tsx | 14 ++++ frontend/src/components/NodeManager.tsx | 3 +- frontend/src/components/ResourcesView.tsx | 17 +++-- frontend/src/components/SecurityView.tsx | 17 +++-- .../__tests__/FleetView.experimental.test.tsx | 2 +- .../__tests__/ResourcesView.test.tsx | 2 +- .../blueprints/BlueprintDeploymentTable.tsx | 12 +-- .../blueprints/BlueprintDetail.test.tsx | 26 ++++++- .../components/blueprints/BlueprintDetail.tsx | 20 ++++- .../components/blueprints/DeploymentsTab.tsx | 12 +-- .../components/fleet/FederationTab.test.tsx | 20 +++-- .../src/components/fleet/FederationTab.tsx | 19 +++-- .../fleet/FleetActions/FleetActionsTab.tsx | 4 +- .../cards/BulkLabelAssignCard.test.tsx | 1 + .../cards/BulkLabelAssignCard.tsx | 10 ++- .../cards/LabelFleetStopCard.test.tsx | 1 + .../FleetActions/cards/LabelFleetStopCard.tsx | 10 ++- .../components/fleet/MeshOptInSheet.test.tsx | 11 ++- .../src/components/fleet/MeshOptInSheet.tsx | 2 +- .../fleet/MeshRouteDetailSheet.test.tsx | 4 +- .../components/fleet/MeshRouteDetailSheet.tsx | 2 +- .../src/components/fleet/RoutingNodeCard.tsx | 4 +- frontend/src/components/fleet/RoutingTab.tsx | 21 ++++-- .../components/security/ScanPolicyManager.tsx | 7 +- .../__tests__/ScanPolicyManager.test.tsx | 2 +- .../components/settings/MisconfigAckPanel.tsx | 7 +- .../components/settings/SuppressionsPanel.tsx | 28 ++++--- .../__tests__/MisconfigAckPanel.test.tsx | 2 +- .../__tests__/SuppressionsPanel.test.tsx | 2 +- .../src/components/ui/routing-node-card.tsx | 41 ++++++---- frontend/src/lib/routing/reachability.test.ts | 1 + frontend/src/lib/routing/reachability.ts | 1 + 68 files changed, 708 insertions(+), 232 deletions(-) create mode 100644 backend/src/__tests__/operational-permission-matrix.test.ts create mode 100644 backend/src/routes/OPERATIONAL_PERMISSIONS.md diff --git a/backend/src/__tests__/blueprints-authz.test.ts b/backend/src/__tests__/blueprints-authz.test.ts index 397dad65..1ee87829 100644 --- a/backend/src/__tests__/blueprints-authz.test.ts +++ b/backend/src/__tests__/blueprints-authz.test.ts @@ -132,7 +132,7 @@ describe('PUT /api/blueprints/:id/pin authorization', () => { expect(res.body.pinned_node_id).toBe(node.id); }); - it('rejects a non-admin on a paid license with ADMIN_REQUIRED', async () => { + it('rejects a user without node:manage on a paid license', async () => { const node = seedNode(); const bp = seedBlueprint([node.id]); @@ -142,26 +142,26 @@ describe('PUT /api/blueprints/:id/pin authorization', () => { .send({ nodeId: node.id }); expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.body.code).toBe('PERMISSION_DENIED'); }); }); -describe('Blueprint mutation routes require admin role', () => { +describe('Blueprint mutation routes require their operational permissions', () => { // The gate short-circuits before id parsing, so dummy ids are sufficient // to prove the role boundary. - const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string }> = [ - { name: 'create', method: 'post', path: '/api/blueprints' }, - { name: 'update', method: 'put', path: '/api/blueprints/1' }, - { name: 'delete', method: 'delete', path: '/api/blueprints/1' }, - { name: 'apply', method: 'post', path: '/api/blueprints/1/apply' }, - { name: 'withdraw', method: 'post', path: '/api/blueprints/1/withdraw/1' }, - { name: 'accept', method: 'post', path: '/api/blueprints/1/accept/1' }, + const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string; status: number }> = [ + { name: 'create', method: 'post', path: '/api/blueprints', status: 403 }, + { name: 'update', method: 'put', path: '/api/blueprints/1', status: 403 }, + { name: 'delete', method: 'delete', path: '/api/blueprints/1', status: 403 }, + { name: 'apply', method: 'post', path: '/api/blueprints/1/apply', status: 403 }, + { name: 'withdraw', method: 'post', path: '/api/blueprints/1/withdraw/1', status: 404 }, + { name: 'accept', method: 'post', path: '/api/blueprints/1/accept/1', status: 400 }, ]; - it.each(mutations)('rejects a non-admin on $name with ADMIN_REQUIRED', async ({ method, path }) => { + it.each(mutations)('does not let a viewer perform $name', async ({ method, path, status }) => { const res = await request(app)[method](path).set('Cookie', viewerCookie).send({}); - expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.status).toBe(status); + if (status === 403) expect(res.body.code).toBe('PERMISSION_DENIED'); }); it('lets a Community admin create when the body is valid (not PAID_REQUIRED)', async () => { diff --git a/backend/src/__tests__/blueprints-community-tier.test.ts b/backend/src/__tests__/blueprints-community-tier.test.ts index b28246e6..608afb2b 100644 --- a/backend/src/__tests__/blueprints-community-tier.test.ts +++ b/backend/src/__tests__/blueprints-community-tier.test.ts @@ -126,13 +126,13 @@ describe('Blueprints on Community tier', () => { expect(res.body.code).not.toBe('PAID_REQUIRED'); }); - it('rejects blueprint mutations for a viewer with ADMIN_REQUIRED', async () => { + it('rejects blueprint mutations for a viewer without stack:create', async () => { const res = await request(app) .post('/api/blueprints') .set('Authorization', viewerAuthHeader) .send({}); expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.body.code).toBe('PERMISSION_DENIED'); }); it('lets a Community viewer list blueprints', async () => { diff --git a/backend/src/__tests__/containers-route-authz.test.ts b/backend/src/__tests__/containers-route-authz.test.ts index 2b98ea23..05ccc45f 100644 --- a/backend/src/__tests__/containers-route-authz.test.ts +++ b/backend/src/__tests__/containers-route-authz.test.ts @@ -21,6 +21,7 @@ let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSION const VIEWER = 'container-read-viewer'; const READ_PATHS = ['/api/containers', '/api/containers/abc123/logs', '/api/ports/in-use']; +const MUTATION_PATHS = ['/api/containers/abc123/start', '/api/containers/abc123/stop', '/api/containers/abc123/restart']; /** Sign a viewer JWT using the live token_version so authMiddleware accepts it. */ function viewerToken(): string { @@ -120,3 +121,13 @@ describe('container/ports reads reject unauthenticated requests', () => { expect(res.status).toBe(401); }); }); + +describe('generic container-id mutations remain Admin-only', () => { + it.each(MUTATION_PATHS)('POST %s rejects a non-admin before Docker work', async (path) => { + const { docker } = stubDockerAndFs(); + const res = await request(app).post(path).set('Authorization', `Bearer ${viewerToken()}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(docker).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/__tests__/fleet-action-card-endpoints.test.ts b/backend/src/__tests__/fleet-action-card-endpoints.test.ts index 6765810f..9eb0bd3b 100644 --- a/backend/src/__tests__/fleet-action-card-endpoints.test.ts +++ b/backend/src/__tests__/fleet-action-card-endpoints.test.ts @@ -313,14 +313,14 @@ describe('GET /api/fleet/labels/suggestions', () => { expect(res.status).toBe(401); }); - it('returns 403 for a non-admin (viewer) user', async () => { + it('allows a viewer with node:read to load suggestions', async () => { const viewerName = `viewer-sugg-${++labelCounter}`; db.addUser({ username: viewerName, password_hash: 'x', role: 'viewer' }); const viewerAuth = `Bearer ${jwt.sign({ username: viewerName }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; const res = await request(app) .get('/api/fleet/labels/suggestions') .set('Authorization', viewerAuth); - expect(res.status).toBe(403); + expect(res.status).toBe(200); }); it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => { diff --git a/backend/src/__tests__/fleet-actions.test.ts b/backend/src/__tests__/fleet-actions.test.ts index a1f74bf5..3987fbf0 100644 --- a/backend/src/__tests__/fleet-actions.test.ts +++ b/backend/src/__tests__/fleet-actions.test.ts @@ -10,22 +10,41 @@ import fs from 'fs'; import path from 'path'; import request from 'supertest'; import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; let tmpDir: string; let app: import('express').Express; let authHeader: string; +let scopedAuthHeader: string; let LicenseService: typeof import('../services/LicenseService').LicenseService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let scopedUserId: number; +let defaultNodeId: number; beforeAll(async () => { tmpDir = await setupTestDb(); ({ app } = await import('../index')); ({ LicenseService } = await import('../services/LicenseService')); + ({ DatabaseService } = await import('../services/DatabaseService')); const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); authHeader = `Bearer ${token}`; + const db = DatabaseService.getInstance(); + defaultNodeId = db.getDefaultNode()?.id ?? 1; + const hash = await bcrypt.hash('password123', 1); + scopedUserId = db.addUser({ username: 'fleet-scoped-operator', password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ user_id: scopedUserId, role: 'node-admin', resource_type: 'stack', resource_id: 'allowed-edit', node_id: defaultNodeId }); + db.addRoleAssignment({ user_id: scopedUserId, role: 'deployer', resource_type: 'stack', resource_id: 'allowed-deploy', node_id: defaultNodeId }); + const scopedUser = db.getUserByUsername('fleet-scoped-operator')!; + scopedAuthHeader = `Bearer ${jwt.sign({ username: scopedUser.username, role: scopedUser.role, tv: scopedUser.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; }); -afterAll(() => cleanupTestDb(tmpDir)); +afterAll(() => { + const db = DatabaseService.getInstance(); + db.deleteRoleAssignmentsByUser(scopedUserId); + db.deleteUser(scopedUserId); + cleanupTestDb(tmpDir); +}); function mockTier(tier: 'paid' | 'community') { vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); @@ -237,6 +256,60 @@ describe('Fleet Actions orchestration shape', () => { }); }); +describe('Fleet Actions authorize every target before mutation', () => { + afterEach(() => vi.restoreAllMocks()); + + it('rejects fleet stop when any confirmed stack lacks stack:deploy', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/labels/fleet-stop') + .set('Authorization', scopedAuthHeader) + .send({ + labelName: 'prod', + targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-deploy', 'denied-deploy'] }], + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('does not create a label when bulk assign contains a denied stack', async () => { + mockTier('paid'); + const labelName = 'atomic-bulk-denied'; + const res = await request(app) + .post('/api/fleet/labels/bulk-assign') + .set('Authorization', scopedAuthHeader) + .send({ + label: { name: labelName, color: 'teal' }, + targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-edit', 'denied-edit'] }], + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false); + }); + + it('does not create a label when a local receiver target is denied', async () => { + mockTier('paid'); + const labelName = 'atomic-local-denied'; + const res = await request(app) + .post('/api/fleet-actions/labels/local-assign') + .set('Authorization', scopedAuthHeader) + .send({ label: { name: labelName, color: 'teal' }, stackNames: ['allowed-edit', 'denied-edit'] }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false); + }); + + it('rejects local stop before work when any confirmed stack is denied', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet-actions/labels/local-stop') + .set('Authorization', scopedAuthHeader) + .send({ labelName: 'prod', stackNames: ['allowed-deploy', 'denied-deploy'] }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); +}); + // The per-node local-stop receiver is what a control instance calls on each // remote during a fleet-wide stop. It must be reachable on every license (only // admin-gated): the original fleet-stop fan-out hit the paid /api/labels/:id/action diff --git a/backend/src/__tests__/mesh-route-gating.test.ts b/backend/src/__tests__/mesh-route-gating.test.ts index 9d1e79a2..0a3ff3e9 100644 --- a/backend/src/__tests__/mesh-route-gating.test.ts +++ b/backend/src/__tests__/mesh-route-gating.test.ts @@ -1,9 +1,9 @@ /** * Gate coverage for the mesh router. * - * Every /api/mesh route is tier-gated (requirePaid). The five operator - * mutations are additionally role-gated (requireAdmin): node enable/disable, - * stack opt-in/opt-out, and the override regen. The operator read routes + * Every /api/mesh route is tier-gated (requirePaid). Node enable/disable and + * override regeneration are permission-gated. Stack membership remains Admin-only + * because a membership change redeploys every affected mesh stack. The read routes * (status, aliases, activity, diagnostics) stay reachable for any paid-tier * user regardless of role, which is what lets a non-admin see a read-only * Routing tab. The node-to-node routes that central calls over the proxy on the @@ -103,17 +103,29 @@ describe('mesh read routes are visible to a non-admin paid user', () => { }); }); -describe('mesh mutation routes require the admin role (requireAdmin)', () => { - const mutationRoutes: { name: string; path: () => string }[] = [ +describe('mesh mutation authorization', () => { + const permissionRoutes: { name: string; path: () => string }[] = [ { name: 'POST /regen-overrides', path: () => '/api/mesh/regen-overrides' }, { name: 'POST /nodes/:id/enable', path: () => `/api/mesh/nodes/${defaultNodeId}/enable` }, { name: 'POST /nodes/:id/disable', path: () => `/api/mesh/nodes/${defaultNodeId}/disable` }, + ]; + const adminRoutes: { name: string; path: () => string }[] = [ { name: 'POST /nodes/:id/stacks/:stack/opt-in', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-in` }, { name: 'POST /nodes/:id/stacks/:stack/opt-out', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-out` }, ]; - for (const route of mutationRoutes) { - it(`${route.name} rejects a non-admin paid user with ADMIN_REQUIRED`, async () => { + for (const route of permissionRoutes) { + it(`${route.name} rejects a paid user without the required operational permission`, async () => { + const res = await request(app) + .post(route.path()) + .set('Authorization', `Bearer ${userToken('mesh-viewer')}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + } + + for (const route of adminRoutes) { + it(`${route.name} remains Admin-only`, async () => { const res = await request(app) .post(route.path()) .set('Authorization', `Bearer ${userToken('mesh-viewer')}`); diff --git a/backend/src/__tests__/operational-permission-matrix.test.ts b/backend/src/__tests__/operational-permission-matrix.test.ts new file mode 100644 index 00000000..972cedc9 --- /dev/null +++ b/backend/src/__tests__/operational-permission-matrix.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions'; +import { classifyStackApiPath } from '../helpers/stackRouteAuth'; + +describe('operational role matrix', () => { + const expectations: Record = { + admin: { + allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'], + deny: [], + }, + 'node-admin': { + allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'], + deny: ['system:settings', 'system:users', 'system:license', 'system:registries'], + }, + deployer: { + allow: ['stack:read', 'stack:deploy'], + deny: ['stack:edit', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'], + }, + viewer: { + allow: ['stack:read', 'node:read'], + deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:audit'], + }, + auditor: { + allow: ['stack:read', 'node:read', 'system:audit'], + deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:settings'], + }, + }; + + for (const [role, expected] of Object.entries(expectations)) { + it(`${role} exposes only its intended operational actions`, () => { + const actions = ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS]; + for (const action of expected.allow) expect(actions).toContain(action); + for (const action of expected.deny) expect(actions).not.toContain(action); + }); + } +}); + +describe('named stack route permission inventory', () => { + const routes: Array<[string, string, PermissionAction]> = [ + ['GET', '/stacks/web', 'stack:read'], + ['GET', '/stacks/web/env', 'stack:read'], + ['GET', '/stacks/web/services', 'stack:read'], + ['GET', '/stacks/web/update-preview', 'stack:read'], + ['GET', '/stacks/web/files/content', 'stack:read'], + ['PUT', '/stacks/web', 'stack:edit'], + ['PUT', '/stacks/web/env', 'stack:edit'], + ['PUT', '/stacks/web/dossier', 'stack:edit'], + ['PUT', '/stacks/web/labels', 'stack:edit'], + ['POST', '/stacks/web/deploy', 'stack:deploy'], + ['POST', '/stacks/web/stop', 'stack:deploy'], + ['POST', '/stacks/web/services/api/update', 'stack:deploy'], + ['POST', '/stacks/web/rollback', 'stack:deploy'], + ['DELETE', '/stacks/web', 'stack:delete'], + ]; + + for (const [method, path, action] of routes) { + it(`${method} ${path} requires ${action}`, () => { + expect(classifyStackApiPath(method, path)).toEqual({ + kind: 'named-stack', + stackName: 'web', + action, + }); + }); + } +}); diff --git a/backend/src/__tests__/permissions-stack-rbac.test.ts b/backend/src/__tests__/permissions-stack-rbac.test.ts index 33d5bae9..dc27241d 100644 --- a/backend/src/__tests__/permissions-stack-rbac.test.ts +++ b/backend/src/__tests__/permissions-stack-rbac.test.ts @@ -141,6 +141,24 @@ describe('checkPermission with node-scoped stack grants', () => { ); for (const a of assignments) db.deleteRoleAssignment(a.id!); }); + + it('uses an explicit target node for hub-orchestrated stack checks', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'remote-stack', + node_id: otherNodeId, + }); + + const hubRequest = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId }); + expect(checkPermission(hubRequest, 'stack:deploy', 'stack', 'remote-stack')).toBe(false); + expect(checkPermission(hubRequest, 'stack:deploy', 'stack', 'remote-stack', otherNodeId)).toBe(true); + expect(checkPermission(hubRequest, 'stack:edit', 'stack', 'remote-stack', otherNodeId)).toBe(false); + + db.deleteRoleAssignmentsByStack(otherNodeId, 'remote-stack'); + }); }); describe('scopedActionsForStack with node-scoped grants', () => { diff --git a/backend/src/__tests__/security-overview-route.test.ts b/backend/src/__tests__/security-overview-route.test.ts index eafebc30..ad02c13f 100644 --- a/backend/src/__tests__/security-overview-route.test.ts +++ b/backend/src/__tests__/security-overview-route.test.ts @@ -459,9 +459,9 @@ describe('GET /api/security/vex/export (Community)', () => { expect(res.status).toBe(200); }); - it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => { + it('lets a viewer with stack:read export VEX', async () => { const res = await request(app).get('/api/security/vex/export').set('Cookie', viewerCookie); - expect(res.status).toBe(403); + expect(res.status).toBe(200); }); it('exports an OpenVEX document from triage decisions', async () => { diff --git a/backend/src/__tests__/security-sbom-sarif-tier.test.ts b/backend/src/__tests__/security-sbom-sarif-tier.test.ts index 6ecdc547..fa85a47a 100644 --- a/backend/src/__tests__/security-sbom-sarif-tier.test.ts +++ b/backend/src/__tests__/security-sbom-sarif-tier.test.ts @@ -1,5 +1,5 @@ /** - * Both scan-export endpoints are available on every tier (admin only, no tier gate): + * Both scan-export endpoints are available on every tier with stack:read: * POST /api/security/sbom -> per-image SBOM artifact * GET /api/security/scans/:id/sarif -> SARIF for CI / code-scanning ingestion */ @@ -55,13 +55,16 @@ describe('POST /api/security/sbom (Community)', () => { expect(res.headers['content-disposition']).toContain('nginx_latest.cdx.json'); }); - it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => { + it('lets a Community viewer generate an SBOM with stack:read', async () => { mockTier('community'); + const svc = TrivyService.getInstance(); + vi.spyOn(svc, 'isTrivyAvailable').mockReturnValue(true); + vi.spyOn(svc, 'generateSBOM').mockResolvedValue('{"bomFormat":"CycloneDX"}'); const res = await request(app) .post('/api/security/sbom') .set('Cookie', viewerCookie) .send({ imageRef: 'nginx:latest', format: 'cyclonedx' }); - expect(res.status).toBe(403); + expect(res.status).toBe(200); }); }); @@ -77,11 +80,11 @@ describe('GET /api/security/scans/:scanId/sarif (Community)', () => { expect(res.status).toBe(404); }); - it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => { + it('lets a Community viewer reach SARIF export with stack:read', async () => { mockTier('community'); const res = await request(app) .get('/api/security/scans/999999/sarif') .set('Cookie', viewerCookie); - expect(res.status).toBe(403); + expect(res.status).toBe(404); }); }); diff --git a/backend/src/__tests__/suppression-routes.test.ts b/backend/src/__tests__/suppression-routes.test.ts index 1ed76e59..94421b04 100644 --- a/backend/src/__tests__/suppression-routes.test.ts +++ b/backend/src/__tests__/suppression-routes.test.ts @@ -114,13 +114,13 @@ describe('POST /api/security/suppressions', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { + it('rejects users without stack:edit with 403', async () => { const res = await request(app) .post('/api/security/suppressions') .set('Authorization', viewerAuthHeader) .send(validBody); expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.body.code).toBe('PERMISSION_DENIED'); }); it('is accessible on community tier (admin still required)', async () => { diff --git a/backend/src/middleware/permissions.ts b/backend/src/middleware/permissions.ts index 1ccbe215..115e75a5 100644 --- a/backend/src/middleware/permissions.ts +++ b/backend/src/middleware/permissions.ts @@ -84,6 +84,7 @@ export function checkPermission( action: PermissionAction, resourceType?: ResourceType, resourceId?: string, + resourceNodeId?: number | null, ): boolean { if (!req.user) return false; @@ -113,7 +114,9 @@ export function checkPermission( if (effectiveTier(req) !== 'paid') return false; const db = DatabaseService.getInstance(); - const nodeId = resourceType === 'stack' ? req.nodeId : null; + const nodeId = resourceType === 'stack' + ? (resourceNodeId === undefined ? req.nodeId : resourceNodeId) + : null; const assignments = db.getRoleAssignments( req.user.userId, resourceType, @@ -127,11 +130,11 @@ export function checkPermission( // Node-scoped grants are node-wide: a Node Admin / Deployer / Admin on // node N authorizes that role's stack actions for every stack on N. - if (resourceType === 'stack' && req.nodeId != null) { + if (resourceType === 'stack' && nodeId != null) { const nodeAssignments = db.getRoleAssignments( req.user.userId, 'node', - String(req.nodeId), + String(nodeId), ); for (const assignment of nodeAssignments) { if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true; @@ -148,8 +151,9 @@ export function requirePermission( action: PermissionAction, resourceType?: ResourceType, resourceId?: string, + resourceNodeId?: number | null, ): boolean { - if (checkPermission(req, action, resourceType, resourceId)) return true; + if (checkPermission(req, action, resourceType, resourceId, resourceNodeId)) return true; res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); return false; } diff --git a/backend/src/routes/OPERATIONAL_PERMISSIONS.md b/backend/src/routes/OPERATIONAL_PERMISSIONS.md new file mode 100644 index 00000000..8ba06238 --- /dev/null +++ b/backend/src/routes/OPERATIONAL_PERMISSIONS.md @@ -0,0 +1,52 @@ +# Operational permission inventory + +This inventory is the authority for ordinary operational API authorization. A +route marked `exact` must include the target resource identity in the permission +check. Bulk routes must authorize every valid target before starting any work. + +| Route family | Read | Execute | Edit | Create | Delete | Scope | +| --- | --- | --- | --- | --- | --- | --- | +| `/api/stacks/:stackName` and named subroutes | `stack:read` | `stack:deploy` | `stack:edit` | n/a | `stack:delete` | exact stack and request node | +| `/api/stacks/bulk` | n/a | `stack:deploy` | n/a | n/a | n/a | every exact stack before execution | +| `/api/containers`, logs, and `/api/ports/in-use` | `stack:read` | n/a | n/a | n/a | n/a | global read | +| `/api/containers/:containerId/start|stop|restart` | n/a | Admin | n/a | n/a | n/a | arbitrary container IDs can include unmanaged or Sencho containers | +| `/api/volumes/browse/*` | `stack:read` | n/a | n/a | n/a | n/a | global read | +| `/api/templates` | `stack:read` | n/a | n/a | n/a | n/a | global read | +| `/api/templates/:id/deploy` | n/a | `stack:deploy` | n/a | `stack:create` | n/a | global create and deploy | +| `/api/blueprints` | `node:read` | n/a | `stack:edit` | `stack:create` | `stack:delete` | global blueprint definition | +| Blueprint apply | n/a | `stack:deploy` | n/a | `stack:create` | n/a | global create and deploy | +| Blueprint accept or withdraw | n/a | exact `stack:deploy` | n/a | n/a | exact `stack:delete` | blueprint stack name and target node | +| Blueprint pin | n/a | n/a | exact `node:manage` | n/a | n/a | target node; unpin uses global node manage | +| `/api/nodes`, labels, metadata, and scheduling reads | `node:read` | n/a | n/a | n/a | n/a | exact node when a node ID is present | +| Node metadata, labels, cordon, and mesh enablement | n/a | n/a | exact `node:manage` | n/a | n/a | target node | +| Dependency map and networking reads | `node:read` | n/a | n/a | n/a | n/a | global or exact node as exposed by the route | +| Fleet label suggestions and match preview | `node:read` | n/a | n/a | n/a | n/a | global fleet discovery | +| Fleet stop by confirmed labels | n/a | exact `stack:deploy` | n/a | n/a | n/a | every target stack and node before fanout | +| Fleet bulk label assignment | n/a | n/a | exact `stack:edit` | n/a | n/a | every target stack and node before fanout | +| `/api/labels/:id/action` | n/a | exact `stack:deploy` | n/a | n/a | n/a | every resolved stack before mutation | +| Mesh status, aliases, diagnostics, and activity | `node:read` | n/a | n/a | n/a | n/a | global read | +| Mesh stack and override reads | exact `stack:read` | n/a | n/a | n/a | n/a | target stack and node | +| Mesh local override writes | n/a | n/a | exact `stack:edit` | n/a | n/a | target stack and node | +| Mesh membership changes | n/a | Admin | n/a | n/a | n/a | membership changes cascade redeploys across mesh stacks | +| Security scans for an image or stack | n/a | `stack:deploy` | n/a | n/a | n/a | exact stack when named, otherwise global | +| Node-wide security scan | n/a | `node:manage` | n/a | n/a | n/a | global, including remote proxy parity | +| SBOM, SARIF, VEX, and predeploy security reports | `stack:read` | n/a | n/a | n/a | n/a | exact stack when named, otherwise global | +| Security policies, suppressions, and acknowledgements | `stack:read` | n/a | `stack:edit` | n/a | n/a | global collection | +| Docker resource inventory and orphan reads | `stack:read` | n/a | n/a | n/a | n/a | global read | +| Network topology and inspection | `node:read` | n/a | n/a | n/a | n/a | global read | + +## Preserved system boundaries + +Literal Admin or the existing `system:*` permission remains required for user, +license, credential, API token, recovery, self-update, and sensitive system +settings. Host-destructive Docker operations also remain Admin-only, including +image, volume, network, resource, and fleet pruning. Reset-anchor and mesh-wide +membership cascades remain Admin-only because their effects are broader than one +ordinary node or stack permission check can safely authorize. + +## Frontend parity + +Navigation and controls use `can()` with the same action and resource identity. +Exact stack checks pass the stack name and node ID. Exact node checks pass the +node ID. System-only controls continue to use the Admin or `system:*` gate. UI +visibility is advisory; every backend route in this inventory enforces its gate. diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts index 84c113d8..76a53e59 100644 --- a/backend/src/routes/blueprints.ts +++ b/backend/src/routes/blueprints.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin, requireBody } from '../middleware/tierGates'; +import { requireBody } from '../middleware/tierGates'; import { requirePermission } from '../middleware/permissions'; import { DatabaseService, @@ -136,6 +136,7 @@ function summarizeBlueprint(blueprintId: number) { } blueprintsRouter.get('/', (req: Request, res: Response): void => { + if (!requirePermission(req, res, 'node:read')) return; try { const blueprints = DatabaseService.getInstance().listBlueprints(); const summaries = blueprints.map(b => { @@ -159,7 +160,7 @@ blueprintsRouter.get('/', (req: Request, res: Response): void => { }); blueprintsRouter.post('/', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:create')) return; if (!requireBody(req, res)) return; const body = req.body as BlueprintBody; const nameError = validateName(body.name); @@ -198,6 +199,7 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => { }); blueprintsRouter.get('/:id', (req: Request, res: Response): void => { + if (!requirePermission(req, res, 'node:read')) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -211,7 +213,7 @@ blueprintsRouter.get('/:id', (req: Request, res: Response): void => { }); blueprintsRouter.put('/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (!requireBody(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; @@ -293,7 +295,7 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => { }); blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:delete')) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -461,7 +463,8 @@ blueprintsRouter.post('/withdraw-local', async (req: Request, res: Response): Pr }); blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:create')) return; + if (!requirePermission(req, res, 'stack:deploy')) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -547,7 +550,6 @@ blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise }); blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; const nodeId = parseIntParam(req, res, 'nodeId'); @@ -560,6 +562,7 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons try { const blueprint = DatabaseService.getInstance().getBlueprint(id); if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; } + if (!requirePermission(req, res, 'stack:delete', 'stack', blueprint.name, nodeId)) return; const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { res.status(404).json({ error: 'Node not found' }); return; } const isStateful = blueprint.classification === 'stateful' || blueprint.classification === 'unknown'; @@ -636,7 +639,6 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons }); blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; const nodeId = parseIntParam(req, res, 'nodeId'); @@ -649,6 +651,7 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response) try { const blueprint = DatabaseService.getInstance().getBlueprint(id); if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; } + if (!requirePermission(req, res, 'stack:deploy', 'stack', blueprint.name, nodeId)) return; const guard = BlueprintReconciler.getInstance().validateGuardConfirmation(id, nodeId, 'accept'); if (!guard.ok) { res.status(409).json({ error: guard.error, code: guard.code }); @@ -665,6 +668,7 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response) }); blueprintsRouter.get('/:id/preview', async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'node:read')) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -678,7 +682,6 @@ blueprintsRouter.get('/:id/preview', async (req: Request, res: Response): Promis }); blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; @@ -692,6 +695,7 @@ blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'node:read')) return; try { const nodeId = req.nodeId; const name = DatabaseService.getInstance().getNodes().find((n) => n.id === nodeId)?.name ?? 'This node'; diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index be99ee00..af2ff7d5 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -18,7 +18,7 @@ import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService'; import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry'; import { authMiddleware } from '../middleware/auth'; import { requirePaid, requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates'; -import { requirePermission } from '../middleware/permissions'; +import { checkPermission, requirePermission } from '../middleware/permissions'; import { respondSelfUpdatePreflight } from './license'; import { ImageOperationService } from '../services/ImageOperationService'; import { classifyImageChannel } from '../helpers/imageChannel'; @@ -1888,9 +1888,9 @@ type FleetStopNodeResult = { // on its own Docker via its local-stop receiver. Remote label rows are never // mirrored to the control, so there is no central pre-check; unreachable nodes // are reported at the node level and never block the reachable ones. -// Tier: requireAdmin (admin-only fleet plumbing; available on every license). +// Permission: every confirmed stack requires stack:deploy. Discovery-only dry +// runs require node:read. fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; const body = req.body as { labelName?: unknown; dryRun?: unknown; targets?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); @@ -1934,6 +1934,19 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: } const trimmed = labelName.trim(); const isDryRun = dryRun === true; + if (confirmedStacksByNode) { + const denied = [...confirmedStacksByNode].some(([nodeId, stackNames]) => + [...stackNames].some(stackName => + !checkPermission(req, 'stack:deploy', 'stack', stackName, nodeId))); + if (denied) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + } else if (isDryRun) { + if (!requirePermission(req, res, 'node:read')) return; + } else if (!requirePermission(req, res, 'stack:deploy')) { + return; + } try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); @@ -1967,7 +1980,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: } } - // Remote node. Ask the remote authoritatively via its admin-only local-stop + // Remote node. Ask the remote authoritatively via its permission-checked local-stop // receiver, which name-matches under the remote's own bulk lock. There is no // control-side pre-check: remote label rows are never mirrored to the // control, so a mirror lookup would skip every remote. A node we cannot @@ -2087,9 +2100,8 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: // Per-node failures (unknown node, no proxy target, unreachable, mixed-version // remote, malformed response) degrade that node only and never discard the rest // of the fan-out. -// Tier: requireAdmin (admin-only fleet plumbing; available on every license). +// Permission: every target stack requires stack:edit. fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; const body = req.body as { label?: unknown; targets?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); @@ -2136,6 +2148,12 @@ fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res res.status(400).json({ error: `targets may not exceed ${MAX_ASSIGNMENTS} stack assignments` }); return; } + const denied = targets.some(target => target.stackNames.some(stackName => + !checkPermission(req, 'stack:edit', 'stack', stackName, target.nodeId))); + if (denied) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } const { template } = validated; try { const db = DatabaseService.getInstance(); @@ -2242,7 +2260,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res // uses these to distinguish "0 matching stacks" from "label exists but no // stacks assigned" from "remote unavailable". fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; const body = req.body as { labelName?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); @@ -2290,7 +2308,7 @@ fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, r // them is a no-op). `unreachableNodes`/`partial` tell the card the counts cover // only the nodes it could reach. fleetRouter.get('/labels/suggestions', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; try { const summaries = await collectFleetLabelSummaries(); const agg = new Map(); diff --git a/backend/src/routes/fleetActions.ts b/backend/src/routes/fleetActions.ts index 699196a6..69ab0728 100644 --- a/backend/src/routes/fleetActions.ts +++ b/backend/src/routes/fleetActions.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin, requireBody } from '../middleware/tierGates'; +import { requireBody } from '../middleware/tierGates'; +import { checkPermission, requirePermission, type PermissionAction } from '../middleware/permissions'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { MAX_ASSIGNMENTS } from '../helpers/constants'; @@ -14,10 +15,26 @@ import { runLocalLabelAssign, validateLabelTemplate, type LabelLocalAssignRespon // because their path must sit behind the `/api/fleet/` proxy-exempt prefix. export const fleetActionsRouter = Router(); +function requireExactStacks( + req: Request, + res: Response, + action: PermissionAction, + stackNames: Iterable, + nodeId: number, +): boolean { + for (const stackName of stackNames) { + if (!checkPermission(req, action, 'stack', stackName, nodeId)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return false; + } + } + return true; +} + // Per-node label-matched stop. A control instance calls this on each remote // node during a fleet-wide stop-by-label so the destructive work runs under the -// remote's own admin auth and per-node bulk lock. Admin-only and available on -// every license, matching the rest of the Fleet Actions surface. The paid +// remote's own auth and per-node bulk lock. Every confirmed stack requires +// stack:deploy. The paid // label-driven action lives at `POST /api/labels/:id/action`; this receiver is // the fleet-plumbing equivalent the control fans out to, so a fleet-stop on a // Community fleet stops remote stacks instead of 403'ing on the remote leg. @@ -25,7 +42,6 @@ fleetActionsRouter.post( '/labels/local-stop', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const { labelName, dryRun, stackNames } = req.body as { labelName?: unknown; dryRun?: unknown; stackNames?: unknown }; if (typeof labelName !== 'string' || labelName.trim().length === 0) { @@ -44,6 +60,12 @@ fleetActionsRouter.post( allowedStacks = new Set(stackNames as string[]); } const nodeId = req.nodeId ?? 0; + if (allowedStacks) { + if (allowedStacks.size === 0 && !requirePermission(req, res, dryRun === true ? 'node:read' : 'stack:deploy')) return; + if (!requireExactStacks(req, res, 'stack:deploy', allowedStacks, nodeId)) return; + } else if (!requirePermission(req, res, dryRun === true ? 'node:read' : 'stack:deploy')) { + return; + } const trimmedLabel = labelName.trim(); try { const outcome = await runLocalLabelStop(nodeId, trimmedLabel, dryRun === true, allowedStacks); @@ -60,15 +82,14 @@ fleetActionsRouter.post( // Per-node label assign. A control instance calls this on each target node // during a fleet-wide bulk label assign so the label is resolved or created // under the node's own database, by name, and assigned to the given stacks while -// preserving their existing labels (add semantics). Admin-only and available on -// every license, matching the rest of the Fleet Actions surface. Labels are +// preserving their existing labels (add semantics). Every target stack requires +// stack:edit. Labels are // node-local, so the control never reuses a local label id on a remote: the // receiver owns label resolution for its own node. fleetActionsRouter.post( '/labels/local-assign', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const { label, stackNames } = req.body as { label?: unknown; stackNames?: unknown }; const validated = validateLabelTemplate(label); @@ -85,8 +106,11 @@ fleetActionsRouter.post( return; } const nodeId = req.nodeId ?? 0; + const uniqueStacks = new Set(stackNames as string[]); + if (uniqueStacks.size === 0 && !requirePermission(req, res, 'stack:edit')) return; + if (!requireExactStacks(req, res, 'stack:edit', uniqueStacks, nodeId)) return; try { - const outcome = await runLocalLabelAssign(nodeId, validated.template, stackNames as string[]); + const outcome = await runLocalLabelAssign(nodeId, validated.template, [...uniqueStacks]); if (isDebugEnabled()) console.debug('[FleetActions:debug] local-assign:', { nodeId, label: validated.template.name, created: outcome.created, stacks: outcome.stackResults.length }); const body: LabelLocalAssignResponse = { created: outcome.created, results: outcome.stackResults }; res.json(body); diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index 35cbc991..c43b3a55 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -6,8 +6,8 @@ import { StackOpLockService, stackOpSkipMessage } from '../services/StackOpLockS import DockerController from '../services/DockerController'; import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { authMiddleware } from '../middleware/auth'; -import { requirePermission } from '../middleware/permissions'; -import { requireAdmin, requireBody } from '../middleware/tierGates'; +import { checkPermission, requirePermission } from '../middleware/permissions'; +import { requireBody } from '../middleware/tierGates'; import { buildPolicyGateOptions, describePolicyBlock } from '../helpers/policyGate'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { VALID_LABEL_COLORS, MAX_LABELS_PER_NODE } from '../helpers/constants'; @@ -25,6 +25,7 @@ export { activeBulkActions }; export const labelsRouter = Router(); labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'stack:read')) return; try { const nodeId = req.nodeId ?? 0; const labels = DatabaseService.getInstance().getLabels(nodeId); @@ -77,6 +78,7 @@ labelsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom }); labelsRouter.get('/assignments', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'stack:read')) return; try { const nodeId = req.nodeId ?? 0; const db = DatabaseService.getInstance(); @@ -166,7 +168,6 @@ labelsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): }); labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'label ID'); @@ -187,6 +188,17 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo return; } + const stackNames = DatabaseService.getInstance().getStacksForLabel(id, nodeId); + const fsStacks = await FileSystemService.getInstance(nodeId).getStacks(); + const fsStackNames = new Set(fsStacks); + const validStacks = stackNames.filter(name => fsStackNames.has(name)); + const deniedStack = validStacks.find(stackName => + !checkPermission(req, 'stack:deploy', 'stack', stackName, nodeId)); + if (deniedStack) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + const lockKey = `bulk:${nodeId}`; if (activeBulkActions.has(lockKey)) { res.status(429).json({ error: 'A bulk action is already running for this node. Please wait.' }); @@ -195,11 +207,6 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo activeBulkActions.add(lockKey); try { - const stackNames = DatabaseService.getInstance().getStacksForLabel(id, nodeId); - const fsStacks = await FileSystemService.getInstance(nodeId).getStacks(); - const fsStackNames = new Set(fsStacks); - const validStacks = stackNames.filter(name => fsStackNames.has(name)); - if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length, dryRun: isDryRun }); const results: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = []; diff --git a/backend/src/routes/mesh.ts b/backend/src/routes/mesh.ts index 9057e38a..d0d7447c 100644 --- a/backend/src/routes/mesh.ts +++ b/backend/src/routes/mesh.ts @@ -3,6 +3,7 @@ import { DatabaseService } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; import { MeshError, MeshService, type MeshGlobalAlias, type MeshRegenSummary } from '../services/MeshService'; import { requireAdmin, requirePaid } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { sanitizeForLog } from '../utils/safeLog'; import { isValidStackName } from '../utils/validation'; @@ -15,6 +16,7 @@ function actorFor(req: Request): string { meshRouter.get('/status', async (_req: Request, res: Response): Promise => { if (!requirePaid(_req, res)) return; + if (!requirePermission(_req, res, 'node:read')) return; try { const mesh = MeshService.getInstance(); const status = await mesh.getStatus(); @@ -34,7 +36,7 @@ meshRouter.get('/status', async (_req: Request, res: Response): Promise => */ meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'node:manage')) return; const actor = actorFor(req); let summary: MeshRegenSummary | null = null; let outcome: 'success' | 'skipped' | 'partial' | 'error' = 'error'; @@ -68,9 +70,9 @@ meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; } + if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return; try { await MeshService.getInstance().enableForNode(nodeId); res.json({ ok: true }); @@ -81,9 +83,9 @@ meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Pr meshRouter.post('/nodes/:nodeId/disable', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; } + if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return; try { await MeshService.getInstance().disableForNode(nodeId, actorFor(req)); res.json({ ok: true }); @@ -103,6 +105,7 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response) if (!requirePaid(req, res)) return; const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; try { const services = await MeshService.getInstance().inspectLocalStackServices(stackName); res.json({ services }); @@ -121,6 +124,7 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response) */ meshRouter.get('/local-stacks', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; try { const stacks = await MeshService.getInstance().listLocalStacks(); res.json({ stacks }); @@ -158,6 +162,7 @@ meshRouter.put('/local-override/:stackName', async (req: Request, res: Response) if (!requirePaid(req, res)) return; const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; const body = req.body as { aliases?: unknown; portAliases?: unknown }; if (!Array.isArray(body?.aliases)) { res.status(400).json({ error: 'Missing aliases array in body' }); return; } if (body.aliases.length > MAX_ALIASES_PER_PUSH) { @@ -210,6 +215,7 @@ meshRouter.delete('/local-override/:stackName', async (req: Request, res: Respon if (!requirePaid(req, res)) return; const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; try { await MeshService.getInstance().removeLocalOverride(stackName); res.json({ ok: true }); @@ -223,6 +229,7 @@ meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Pro if (!requirePaid(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; } + if (!requirePermission(req, res, 'node:read', 'node', String(nodeId))) return; try { const db = DatabaseService.getInstance(); const optedIn = new Set(db.listMeshStacks(nodeId).map((s) => s.stack_name)); @@ -241,10 +248,10 @@ meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Pro meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); const stackName = req.params.stackName as string; if (!Number.isFinite(nodeId) || !stackName) { res.status(400).json({ error: 'Invalid params' }); return; } + if (!requireAdmin(req, res)) return; try { await MeshService.getInstance().optInStack(nodeId, stackName, actorFor(req)); res.json({ ok: true }); @@ -268,10 +275,10 @@ meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request, meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-out', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); const stackName = req.params.stackName as string; if (!Number.isFinite(nodeId) || !stackName) { res.status(400).json({ error: 'Invalid params' }); return; } + if (!requireAdmin(req, res)) return; try { await MeshService.getInstance().optOutStack(nodeId, stackName, actorFor(req)); res.json({ ok: true }); @@ -283,6 +290,7 @@ meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-out', async (req: Request, meshRouter.get('/aliases', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; try { const aliases = await MeshService.getInstance().listAliases(); res.json({ aliases }); @@ -294,6 +302,7 @@ meshRouter.get('/aliases', async (req: Request, res: Response): Promise => meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; try { const diag = await MeshService.getInstance().getRouteDiagnostic(req.params.alias as string); res.json(diag); @@ -304,6 +313,7 @@ meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response) meshRouter.post('/aliases/:alias/test', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; try { const sourceNodeId = NodeRegistry.getInstance().getDefaultNodeId(); const result = await MeshService.getInstance().testUpstream(req.params.alias as string, sourceNodeId); @@ -317,6 +327,7 @@ meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response): if (!requirePaid(req, res)) return; const nodeId = Number.parseInt(req.params.nodeId as string, 10); if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; } + if (!requirePermission(req, res, 'node:read', 'node', String(nodeId))) return; try { const diag = await MeshService.getInstance().getNodeDiagnostic(nodeId); res.json(diag); @@ -327,6 +338,7 @@ meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response): meshRouter.get('/activity', (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; const alias = typeof req.query.alias === 'string' ? req.query.alias : undefined; const source = typeof req.query.source === 'string' ? (req.query.source as 'pilot' | 'mesh') : undefined; const level = typeof req.query.level === 'string' ? (req.query.level as 'info' | 'warn' | 'error') : undefined; @@ -337,6 +349,7 @@ meshRouter.get('/activity', (req: Request, res: Response): void => { meshRouter.get('/activity/stream', (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; + if (!requirePermission(req, res, 'node:read')) return; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); diff --git a/backend/src/routes/networking.ts b/backend/src/routes/networking.ts index fa6817d5..d052ef9e 100644 --- a/backend/src/routes/networking.ts +++ b/backend/src/routes/networking.ts @@ -19,6 +19,7 @@ export const networkingRouter = Router(); // calling the underlying service in-process and reaches each remote through // this route, so a remote is summarized on the node that owns its stacks. networkingRouter.get('/summary', async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'node:read')) return; try { res.json(await computeNodeNetworkingSummary(req.nodeId)); } catch (error) { @@ -28,7 +29,7 @@ networkingRouter.get('/summary', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; try { const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {}); res.json(okEnvelope(aggregate.runtimeAvailable, { @@ -44,7 +45,7 @@ networkingRouter.get('/overview', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; try { const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {}); res.json(okEnvelope(aggregate.runtimeAvailable, { networks: aggregate.networks })); @@ -55,7 +56,7 @@ networkingRouter.get('/networks', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; const id = req.params.id as string; if (!id || (!isValidDockerResourceId(id) && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id))) { res.status(400).json({ error: 'Invalid network ID format' }); @@ -82,7 +83,7 @@ networkingRouter.get('/networks/:id', async (req: Request, res: Response): Promi }); networkingRouter.get('/topology', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; const includeSystem = req.query.includeSystem === 'true'; try { const aggregate = await buildNodeNetworkingAggregate(req.nodeId, { @@ -100,7 +101,7 @@ networkingRouter.get('/topology', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; try { const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {}); res.json(okEnvelope(aggregate.runtimeAvailable, { findings: aggregate.findings })); @@ -111,7 +112,7 @@ networkingRouter.get('/findings', async (req: Request, res: Response): Promise => { - if (!requirePermission(req, res, 'stack:read')) return; + if (!requirePermission(req, res, 'node:read')) return; const findingId = req.params.id as string; if (!findingId) { res.status(400).json({ error: 'Finding ID is required' }); diff --git a/backend/src/routes/nodeLabels.ts b/backend/src/routes/nodeLabels.ts index 08edb4ea..8f4b8058 100644 --- a/backend/src/routes/nodeLabels.ts +++ b/backend/src/routes/nodeLabels.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin, requireBody } from '../middleware/tierGates'; +import { requireBody } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { DatabaseService } from '../services/DatabaseService'; import { NodeLabelService } from '../services/NodeLabelService'; import { parseIntParam } from '../utils/parseIntParam'; @@ -10,6 +11,7 @@ export const nodeLabelsRouter = Router(); nodeLabelsRouter.use(authMiddleware); nodeLabelsRouter.get('/', (req: Request, res: Response): void => { + if (!requirePermission(req, res, 'node:read')) return; try { const map = NodeLabelService.getInstance().listAll(); res.json(map); @@ -20,6 +22,7 @@ nodeLabelsRouter.get('/', (req: Request, res: Response): void => { }); nodeLabelsRouter.get('/all', (req: Request, res: Response): void => { + if (!requirePermission(req, res, 'node:read')) return; try { const labels = NodeLabelService.getInstance().listDistinct(); res.json({ labels }); @@ -32,6 +35,7 @@ nodeLabelsRouter.get('/all', (req: Request, res: Response): void => { nodeLabelsRouter.get('/:nodeId', (req: Request, res: Response): void => { const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) return; + if (!requirePermission(req, res, 'node:read', 'node', String(nodeId))) return; try { const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { @@ -47,10 +51,10 @@ nodeLabelsRouter.get('/:nodeId', (req: Request, res: Response): void => { }); nodeLabelsRouter.post('/:nodeId', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) return; + if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return; const label = typeof req.body.label === 'string' ? req.body.label : ''; try { const node = DatabaseService.getInstance().getNode(nodeId); @@ -71,9 +75,9 @@ nodeLabelsRouter.post('/:nodeId', (req: Request, res: Response): void => { }); nodeLabelsRouter.delete('/:nodeId/:label', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) return; + if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return; const labelParam = req.params.label; const label = typeof labelParam === 'string' ? labelParam : ''; if (!label) { diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index 61c36f5e..30b0eda5 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -118,6 +118,7 @@ function mintPilotEnrollment(nodeId: number, req: Request): { token: string; exp export const nodesRouter = Router(); nodesRouter.get('/', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read')) return; const startedAt = Date.now(); let outcome: 'ok' | 'error' = 'ok'; let count = 0; @@ -140,7 +141,8 @@ nodesRouter.get('/', async (req: Request, res: Response) => { } }); -nodesRouter.get('/scheduling-summary', authMiddleware, (_req: Request, res: Response) => { +nodesRouter.get('/scheduling-summary', authMiddleware, (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read')) return; try { const db = DatabaseService.getInstance(); const scheduleSummary = db.getNodeSchedulingSummary(); @@ -182,6 +184,7 @@ nodesRouter.get('/scheduling-summary', authMiddleware, (_req: Request, res: Resp }); nodesRouter.get('/:id', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read', 'node', req.params.id as string)) return; try { const id = parseInt(req.params.id as string); const node = DatabaseService.getInstance().getNode(id); @@ -600,6 +603,7 @@ nodesRouter.post('/:id/test', async (req: Request, res: Response) => { }); nodesRouter.get('/:id/meta', authMiddleware, async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read', 'node', req.params.id as string)) return; const startedAt = Date.now(); let outcome: 'ok' | 'error' = 'ok'; let id = NaN; diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 29a2392f..38e15385 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { trivyInstallLimiter } from '../middleware/rateLimiters'; import TrivyService, { SbomFormat } from '../services/TrivyService'; import TrivyInstaller from '../services/TrivyInstaller'; @@ -391,6 +392,7 @@ securityRouter.get('/stacks/:stackName/pre-deploy-summary', authMiddleware, asyn res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const nodeId = req.nodeId; try { const db = DatabaseService.getInstance(); @@ -425,7 +427,7 @@ securityRouter.get('/stacks/:stackName/pre-deploy-summary', authMiddleware, asyn }); securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:deploy')) return; const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { res.status(503).json({ error: 'Trivy is not available on this host' }); @@ -462,7 +464,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; const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { res.status(503).json({ error: 'Trivy is not available on this host' }); return; @@ -471,6 +472,7 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; if (svc.isScanningStack(req.nodeId, stackName)) { res.status(409).json({ error: 'Already scanning this stack' }); return; } @@ -494,7 +496,7 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res // when requested, every stack's compose config for misconfigurations. Streams // sanitized progress to the deploy-feedback terminal when the client opened one. securityRouter.post('/scan-node', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'node:manage')) return; const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { res.status(503).json({ error: 'Trivy is not available on this host' }); @@ -1001,7 +1003,7 @@ securityRouter.get('/policy-packs', authMiddleware, (_req: Request, res: Respons }); securityRouter.post('/sbom', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { res.status(503).json({ error: 'Trivy is not available on this host' }); return; @@ -1034,7 +1036,7 @@ securityRouter.get( '/scans/:scanId/sarif', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; const scanId = Number(req.params.scanId); if (!Number.isFinite(scanId)) { res.status(400).json({ error: 'Invalid scan id' }); return; @@ -1095,10 +1097,10 @@ securityRouter.get( }, ); -// Export the instance's CVE triage decisions as an OpenVEX document. Admin-only, -// mirroring the SARIF export. +// Export the instance's CVE triage decisions as an OpenVEX document. This is a +// read operation, mirroring the SARIF export permission. securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; try { const suppressions = DatabaseService.getInstance().getCveSuppressions(); const doc = generateOpenVex(suppressions, req.user?.username || 'sencho', new Date().toISOString()); @@ -1123,7 +1125,7 @@ securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): v }); securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) 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 +1175,7 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): }); securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'security policies')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1231,7 +1233,7 @@ 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 (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'security policies')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1252,7 +1254,7 @@ securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response }); securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'CVE suppressions')) return; const body = req.body ?? {}; const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : ''; @@ -1316,7 +1318,7 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons }); securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'CVE suppressions')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1371,7 +1373,7 @@ 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 (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'CVE suppressions')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1403,7 +1405,7 @@ securityRouter.get('/misconfig-acks', authMiddleware, (req: Request, res: Respon }); securityRouter.post('/misconfig-acks', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'misconfig acknowledgements')) return; const body = req.body ?? {}; const ruleId = typeof body.rule_id === 'string' ? body.rule_id.trim() : ''; @@ -1459,7 +1461,7 @@ securityRouter.post('/misconfig-acks', authMiddleware, (req: Request, res: Respo }); securityRouter.put('/misconfig-acks/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'misconfig acknowledgements')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { @@ -1511,7 +1513,7 @@ securityRouter.put('/misconfig-acks/:id', authMiddleware, (req: Request, res: Re }); securityRouter.delete('/misconfig-acks/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:edit')) return; if (blockIfReplica(res, 'misconfig acknowledgements')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 9e311177..05d46574 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -72,6 +72,7 @@ import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; import { isSelfStack, refuseIfSelfStack, selfStackProtectedBulkResult } from '../helpers/selfStackGuard'; import { getActiveCapabilities, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, SERVICE_SCOPED_UPDATE_CAPABILITY } from '../services/CapabilityRegistry'; import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; +import { classifyStackApiPath } from '../helpers/stackRouteAuth'; // Authenticated users with edit permission can write arbitrarily large compose // files. Refuse to YAML.parse anything beyond this bound so a malformed (or @@ -278,6 +279,24 @@ function getRelPath(req: Request): string { export const stacksRouter = Router(); +stacksRouter.use((req: Request, res: Response, next: NextFunction): void => { + const classified = classifyStackApiPath(req.method, `/stacks${req.path}`); + if (classified.kind === 'static') { + next(); + return; + } + if (classified.kind === 'unknown-named') { + if (req.user?.role === 'admin') { + next(); + return; + } + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + if (!requirePermission(req, res, classified.action, 'stack', classified.stackName)) return; + next(); +}); + stacksRouter.param('stackName', (req, res, next, stackName) => { if (typeof stackName !== 'string' || !isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); @@ -607,6 +626,11 @@ stacksRouter.post('/bulk', async (req: Request, res: Response) => { const typedAction = action as BulkLifecycleAction; const typedNames = Array.from(new Set(stackNames as string[])); + const denied = typedNames.some(name => + isValidStackName(name) && !checkPermission(req, 'stack:deploy', 'stack', name)); + if (denied) { + return res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + } const results = await runWithBoundedParallelism( typedNames, diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index 6926dfc5..2a5639fc 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -59,6 +59,7 @@ function rejectIfSelf(kind: 'image' | 'volume' | 'network', id: string, res: Res } systemMaintenanceRouter.get('/orphans', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const dockerController = DockerController.getInstance(req.nodeId); @@ -350,6 +351,7 @@ systemMaintenanceRouter.post('/prune/estimate', async (req: Request, res: Respon }); systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks); @@ -374,6 +376,7 @@ systemMaintenanceRouter.get('/container-labels', async (req: Request, res: Respo }); systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); @@ -385,6 +388,7 @@ systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => }); systemMaintenanceRouter.get('/images', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); @@ -396,6 +400,7 @@ systemMaintenanceRouter.get('/images', async (req: Request, res: Response) => { }); systemMaintenanceRouter.get('/volumes', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); @@ -407,6 +412,7 @@ systemMaintenanceRouter.get('/volumes', async (req: Request, res: Response) => { }); systemMaintenanceRouter.get('/networks', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); @@ -418,6 +424,7 @@ systemMaintenanceRouter.get('/networks', async (req: Request, res: Response) => }); systemMaintenanceRouter.get('/images/:id', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const rawId = req.params.id as string; if (!rawId) return res.status(400).json({ error: 'Invalid image ID format' }); @@ -516,6 +523,7 @@ systemMaintenanceRouter.post('/networks/delete', async (req: Request, res: Respo }); systemMaintenanceRouter.get('/networks/topology', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read')) return; try { const includeSystem = req.query.includeSystem === 'true'; const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); @@ -531,6 +539,7 @@ systemMaintenanceRouter.get('/networks/topology', async (req: Request, res: Resp }); systemMaintenanceRouter.get('/networks/:id', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read')) return; try { const id = req.params.id as string; if (!id) return res.status(400).json({ error: 'Network ID is required' }); diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts index cb15873a..364158e1 100644 --- a/backend/src/routes/templates.ts +++ b/backend/src/routes/templates.ts @@ -19,6 +19,7 @@ import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; export const templatesRouter = Router(); templatesRouter.get('/', authMiddleware, async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const templates = await templateService.getTemplates(); @@ -66,6 +67,7 @@ templatesRouter.post('/refresh-cache', authMiddleware, (req: Request, res: Respo templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Response) => { if (!requirePermission(req, res, 'stack:create')) return; + if (!requirePermission(req, res, 'stack:deploy')) return; try { const { stackName, template, envVars, skip_scan } = req.body; diff --git a/backend/src/routes/volumes.ts b/backend/src/routes/volumes.ts index 1392fdd0..5997c5df 100644 --- a/backend/src/routes/volumes.ts +++ b/backend/src/routes/volumes.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { VolumeBrowserService, isValidVolumeName, PathTraversalError, VolumeNotFoundError, HelperImageError, ExecError } from '../services/VolumeBrowserService'; import { DatabaseService } from '../services/DatabaseService'; -import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { sanitizeForLog } from '../utils/safeLog'; import { isDebugEnabled } from '../utils/debug'; @@ -23,7 +23,7 @@ function mapServiceError(error: unknown, res: Response, fallback: string): Respo } volumesRouter.get('/:name/list', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; try { const name = req.params.name as string; if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' }); @@ -42,7 +42,7 @@ volumesRouter.get('/:name/list', async (req: Request, res: Response) => { }); volumesRouter.get('/:name/stat', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; try { const name = req.params.name as string; if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' }); @@ -55,7 +55,7 @@ volumesRouter.get('/:name/stat', async (req: Request, res: Response) => { }); volumesRouter.get('/:name/read', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:read')) return; const name = req.params.name as string; const requestPath = readPathParam(req); let outcome: 'success' | 'error' = 'error'; diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 9a8f9756..0182cf5b 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -431,7 +431,7 @@ export function AppStoreView({ onDeploySuccess, headerActions }: AppStoreViewPro label: isDeploying ? 'Deploying…' : `Deploy ${selectedTemplate.title}`, icon: isDeploying ? Loader2 : Rocket, onClick: handleDeploy, - disabled: isDeploying || !stackName.trim() || !can('stack:create'), + disabled: isDeploying || !stackName.trim() || !can('stack:create') || !can('stack:deploy'), } : undefined} footerContext={isDeploying ? 'This may take a few minutes for large images.' : undefined} size="md" diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 7f14b579..f2941af3 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -443,7 +443,6 @@ export function EditorView(props: EditorViewProps) { safeContainers={safeContainers} isRunning={isRunning} can={can} - isAdmin={isAdmin} trivy={trivy} backupInfo={backupInfo} loadingAction={loadingAction} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 7614b0ef..3a02b445 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -147,7 +147,6 @@ export function MobileStackDetail(props: EditorViewProps) { safeContainers={safeContainers} isRunning={isRunning} can={can} - isAdmin={isAdmin} trivy={trivy} backupInfo={backupInfo} loadingAction={loadingAction} diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index 61dd0afe..64e87a4e 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -222,7 +222,7 @@ export function ShellOverlays({ setStackMisconfigScanId(null)} - canManageSuppressions={isAdmin} + canManageSuppressions={can('stack:edit')} /> {/* Compose diff preview */} diff --git a/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx b/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx index 43e98509..d26bc735 100644 --- a/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx @@ -39,7 +39,6 @@ function renderHeader(over: Partial> safeContainers={CONTAINERS} isRunning can={() => true} - isAdmin trivy={{ available: false }} backupInfo={{ exists: false, timestamp: null }} loadingAction={null} @@ -114,4 +113,15 @@ describe('StackIdentityHeader', () => { expect(onOpenMonitor).toHaveBeenCalledTimes(1); }); + + it('shows stack config scanning to a deployer without requiring Admin', async () => { + const user = userEvent.setup(); + renderHeader({ + trivy: { available: true }, + can: (action) => action === 'stack:deploy', + }); + + await user.click(screen.getByRole('button', { name: 'More actions' })); + expect(screen.getByRole('menuitem', { name: 'Scan config' })).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index bfb2a97b..3d284c6d 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -127,7 +127,6 @@ export interface StackIdentityHeaderProps { safeContainers: ContainerInfo[]; isRunning: boolean; can: ReturnType['can']; - isAdmin: boolean; trivy: { available: boolean }; backupInfo: { exists: boolean; timestamp: number | null }; loadingAction: StackAction | null; @@ -156,7 +155,6 @@ export function StackIdentityHeader({ safeContainers, isRunning, can, - isAdmin, trivy, backupInfo, loadingAction, @@ -206,7 +204,7 @@ export function StackIdentityHeader({ const canDeploy = can('stack:deploy', 'stack', stackName, activeNode?.id); const canDelete = can('stack:delete', 'stack', stackName, activeNode?.id); const canRollback = canDeploy && backupInfo.exists; - const canScan = trivy.available && isAdmin; + const canScan = trivy.available && canDeploy; const canMute = stackMuteActions?.canMute ?? false; const hasOverflowExtras = canRollback || canScan; const hasOverflow = hasOverflowExtras || canDelete || canMute || onOpenMonitor; diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 38487f35..6e104594 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -63,7 +63,9 @@ export function FleetView({ onFleetActiveTabChange, }: FleetViewProps) { const { isPaid, licenseStatus } = useLicense(); - const { isAdmin } = useAuth(); + const { isAdmin, can } = useAuth(); + const canManageFleet = can('node:manage'); + const canExportDossier = can('node:read') && can('stack:read'); const { hasCapability } = useNodes(); const { experimental, experimentalReady } = useExperimental(); const containerLabelsEnabled = hasCapability('container-label-inventory'); @@ -240,7 +242,7 @@ export function FleetView({ Refresh - {isAdmin && ( + {canExportDossier && ( @@ -289,8 +291,8 @@ export function FleetView({ onRetryUpdate={updateStatus.retryNodeUpdate} onDismissUpdate={updateStatus.dismissNodeUpdate} onCordonChange={() => { void overview.fetchOverview(true); }} - onEditNode={isAdmin ? openEdit : undefined} - onDeleteNode={isAdmin ? openDelete : undefined} + onEditNode={openEdit} + onDeleteNode={openDelete} onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined} onCheckUpdates={updateStatus.checkUpdates} @@ -324,12 +326,18 @@ export function FleetView({ {canDiscoverRouting && ( - + can('node:manage', 'node', String(nodeId))} + canManageMembership={isAdmin} + /> )} - + can('node:manage', 'node', String(nodeId))} + /> {/* Fleet Actions runs against the whole fleet, so it takes the diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 818b3e9c..32e47dd4 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -79,10 +79,11 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, const { nodes: registryNodes } = useNodes(); const registryNode = registryNodes.find(n => n.id === node.id); const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1; - const canEdit = Boolean(isAdmin && onEdit && registryNode); - const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal); + const canManageNode = can('node:manage', 'node', String(node.id)); + const canEdit = Boolean(canManageNode && onEdit && registryNode); + const canDelete = Boolean(canManageNode && onDelete && registryNode && !registryNode.is_default && !isLastLocal); // Cordon is permission-gated only (node:manage), matching the backend route guard. - const canCordon = can('node:manage', 'node', String(node.id)); + const canCordon = canManageNode; const nodeMuteActions = useNodeMuteActions( node.id, node.name, diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index a94ee6ee..b771228c 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -62,6 +62,20 @@ describe('NodeCard', () => { expect(can).toHaveBeenCalledWith('node:manage', 'node', '2'); }); + it('shows edit and delete controls to a scoped node manager who is not an admin', async () => { + const node = onlineNode(); + const registryNode = { id: 2, name: 'Edge', type: 'remote', is_default: false }; + const onEdit = vi.fn(); + const onDelete = vi.fn(); + useNodesMock.mockReturnValue({ nodes: [registryNode, { id: 1, type: 'local' }], hasCapability: vi.fn(() => false) }); + useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn((action: string) => action === 'node:manage') }); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); + expect(await screen.findByText('Edit node')).toBeInTheDocument(); + expect(screen.getByText('Delete node')).toBeInTheDocument(); + }); + it('hides the cordon control from a user lacking node:manage', () => { useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); render(); diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index d8a2da7e..c269db4f 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -41,7 +41,6 @@ export interface SenchoNavigateDetail { export function NodeManager() { const { isPaid } = useLicense(); const { isAdmin, can } = useAuth(); - const canEditLabels = isAdmin; // Mirror the backend node:manage guard. This top-level flag checks the global // role only (admin or global node-admin); the per-row Test/Edit/Delete buttons // below additionally honor scoped per-node grants via can('node:manage', 'node', id). @@ -374,7 +373,7 @@ export function NodeManager() { {getStatusBadge(node.status)} - + {(() => { diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index a0a8e513..1311f550 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -367,7 +367,10 @@ interface ResourcesViewProps { export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) { const isMobile = useIsMobile(); const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged'>('images'); - const { isAdmin } = useAuth(); + const { isAdmin, can } = useAuth(); + const canReadResources = can('stack:read'); + const canDeployResources = can('stack:deploy'); + const canEditSecurityPolicy = can('stack:edit'); const { activeNode } = useNodes(); const [usage, setUsage] = useState(null); const [images, setImages] = useState([]); @@ -1076,7 +1079,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} Inspect image - {trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( + {trivy.available && canDeployResources && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( @@ -1214,7 +1217,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
- {isAdmin && ( + {canReadResources && ( @@ -1479,11 +1482,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} setInspectScanId(null)} - onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined} - canGenerateSbom={isAdmin} - canExportSarif={isAdmin} + onRescan={canDeployResources ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined} + canGenerateSbom={canReadResources} + canExportSarif={canReadResources} canCompare - canManageSuppressions={isAdmin} + canManageSuppressions={canEditSecurityPolicy} /> ); diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index 531dc35b..8de0f0e7 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -63,7 +63,7 @@ const MOBILE_MASTHEAD_TONE: Record onInspect(scanId, 'vulns'), onSummaries: setSummaries, @@ -264,7 +267,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security exploitTruncated={exploitTruncated} onNavigate={handleNavigate} onInspect={onInspect} - canScan={canScan} + canScan={canScanNode} onScanComplete={() => setReloadToken((t) => t + 1)} /> @@ -276,7 +279,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security loading={summariesLoading} error={summariesError} onInspect={onInspect} - canScan={canScan} + canScan={canScanImages} scanningRef={scanningRef} onScan={scanImage} initialFilter={imagesFilter ?? undefined} @@ -336,10 +339,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security scanId={inspectScanId} initialTab={inspectInitialTab} onClose={() => setInspectScanId(null)} - canGenerateSbom={isAdmin} - canExportSarif={isAdmin} + canGenerateSbom={canReadSecurityExports} + canExportSarif={canReadSecurityExports} canCompare - canManageSuppressions={isAdmin} + canManageSuppressions={canEditSecurityPolicy} /> ); diff --git a/frontend/src/components/__tests__/FleetView.experimental.test.tsx b/frontend/src/components/__tests__/FleetView.experimental.test.tsx index aed3ca9b..bbf9fc43 100644 --- a/frontend/src/components/__tests__/FleetView.experimental.test.tsx +++ b/frontend/src/components/__tests__/FleetView.experimental.test.tsx @@ -11,7 +11,7 @@ vi.mock('@/context/LicenseContext', () => ({ useLicense: () => ({ isPaid: true }), })); vi.mock('@/context/AuthContext', () => ({ - useAuth: () => ({ isAdmin: true }), + useAuth: () => ({ isAdmin: true, can: () => true }), })); vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ hasCapability: () => false }), diff --git a/frontend/src/components/__tests__/ResourcesView.test.tsx b/frontend/src/components/__tests__/ResourcesView.test.tsx index 34324aa5..8725d0ec 100644 --- a/frontend/src/components/__tests__/ResourcesView.test.tsx +++ b/frontend/src/components/__tests__/ResourcesView.test.tsx @@ -28,7 +28,7 @@ vi.mock('@/components/ui/toast-store', () => ({ const licenseState = { isPaid: true }; vi.mock('@/context/LicenseContext', () => ({ useLicense: () => licenseState })); -vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) })); +vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true, can: () => true }) })); const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } }; vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState })); diff --git a/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx b/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx index 7814e028..e50ef45e 100644 --- a/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx +++ b/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx @@ -12,7 +12,9 @@ import { formatTimeAgo } from '@/lib/relativeTime'; interface BlueprintDeploymentTableProps { deployments: BlueprintDeployment[]; classification: BlueprintClassification; - canEdit: boolean; + canDeploy: (nodeId: number) => boolean; + canWithdraw: (nodeId: number) => boolean; + canRetry: boolean; busyNodeId: number | null; onWithdraw: (nodeId: number) => void; onAcceptStateReview: (nodeId: number) => void; @@ -51,7 +53,7 @@ function statusDotClass(status: BlueprintDeploymentStatus): string { } export function BlueprintDeploymentTable({ - deployments, classification, canEdit, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null, + deployments, classification, canDeploy, canWithdraw, canRetry, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null, }: BlueprintDeploymentTableProps) { const { nodes } = useNodes(); const nodesById = new Map(nodes.map(n => [n.id, n])); @@ -122,7 +124,7 @@ export function BlueprintDeploymentTable({
- {dep.status === 'pending_state_review' && canEdit && ( + {dep.status === 'pending_state_review' && canDeploy(dep.node_id) && (
@@ -188,7 +189,7 @@ export function FederationTab({ canManage }: FederationTabProps) { {describeSelector(bp.selector)} - {canManage ? ( + {canManageAnyNode ? (