mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
fix(templates): align App Store deploy gate with stack:create permission (#986)
The POST /api/templates/deploy handler gated on requireAdmin, while every
other create-stack endpoint in routes/stacks.ts uses
requirePermission('stack:create'). Per the role table in
middleware/permissions.ts, node-admin holds stack:create — so a node-admin
could create stacks the regular way but got 403 ADMIN_REQUIRED from the
App Store. The cockpit's Deploy button is gated on can('stack:create'),
so the button looked enabled and the click silently failed.
Swap the gate to requirePermission(req, res, 'stack:create'). Admin still
passes through the global bypass; node-admin now passes via the role
permissions table; deployer, viewer, and auditor stay denied. Cache-refresh
on the same router keeps requireAdmin since cache invalidation has no
per-resource scope.
Adds backend/src/__tests__/templates-deploy-rbac.test.ts with six
parameterised supertest cases (one per role plus an unauthenticated
case) so the matrix is locked in. The two passing-role cases assert
the request clears the gate (status !== 403, code !== PERMISSION_DENIED)
without depending on Docker being available in the test environment.
This commit is contained in:
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user