mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
fix(rbac): enforce operational permission parity (#1736)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}`);
|
||||
|
||||
@@ -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<string, { allow: PermissionAction[]; deny: PermissionAction[] }> = {
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user