diff --git a/backend/src/__tests__/templates-deploy-rbac.test.ts b/backend/src/__tests__/templates-deploy-rbac.test.ts new file mode 100644 index 00000000..4e7ae7ff --- /dev/null +++ b/backend/src/__tests__/templates-deploy-rbac.test.ts @@ -0,0 +1,91 @@ +/** + * RBAC tests for POST /api/templates/deploy. + * + * The deploy gate must match the RBAC permission matrix in + * `backend/src/middleware/permissions.ts`: any role that holds + * `stack:create` (admin, node-admin) can deploy a template; viewer, + * deployer, and auditor cannot. This locks in the fix where the route + * previously gated on `requireAdmin` and silently rejected node-admins + * who could create stacks through every other path. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +type SeedRole = 'admin' | 'node-admin' | 'deployer' | 'viewer' | 'auditor'; + +async function seedUser(username: string, role: SeedRole): Promise { + const db = DatabaseService.getInstance(); + const passwordHash = await bcrypt.hash('password123', 1); + const id = db.addUser({ username, password_hash: passwordHash, role }); + const user = db.getUserById(id)!; + return jwt.sign({ username, role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + +const minimalTemplate = { + title: 'rbac-probe', + description: 'placeholder', + image: 'nginx:alpine', +}; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('POST /api/templates/deploy permission gate', () => { + it.each(['viewer', 'deployer', 'auditor'] as const)( + 'rejects %s with 403 PERMISSION_DENIED', + async (role) => { + const token = await seedUser(`probe-${role}`, role); + const res = await request(app) + .post('/api/templates/deploy') + .set('Authorization', `Bearer ${token}`) + .send({ stackName: `probe-${role}-stack`, template: minimalTemplate }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }, + ); + + it.each(['admin', 'node-admin'] as const)( + 'lets %s pass the permission gate', + async (role) => { + const token = await seedUser(`probe-${role}`, role); + const res = await request(app) + .post('/api/templates/deploy') + .set('Authorization', `Bearer ${token}`) + .send({ stackName: `probe-${role}-stack`, template: minimalTemplate }); + // The deploy itself may fail downstream (no Docker daemon in tests), + // but the request must clear the permission gate. PERMISSION_DENIED + // is the only code emitted by the gate, so its absence proves the + // request reached the deploy logic. + expect(res.body.code).not.toBe('PERMISSION_DENIED'); + expect(res.status).not.toBe(403); + }, + ); + + it('returns 401 without a token', async () => { + const res = await request(app) + .post('/api/templates/deploy') + .send({ stackName: 'no-auth', template: minimalTemplate }); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts index f9af25c9..f7891857 100644 --- a/backend/src/routes/templates.ts +++ b/backend/src/routes/templates.ts @@ -3,6 +3,7 @@ import path from 'path'; import { promises as fsPromises } from 'fs'; import { authMiddleware } from '../middleware/auth'; import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { templateService } from '../services/TemplateService'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; @@ -65,7 +66,7 @@ templatesRouter.post('/refresh-cache', authMiddleware, (req: Request, res: Respo }); templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; + if (!requirePermission(req, res, 'stack:create')) return; try { const { stackName, template, envVars, skip_scan } = req.body;