fix(rbac): enforce operational permission parity (#1736)

This commit is contained in:
Anso
2026-07-29 22:02:53 -04:00
committed by GitHub
parent a65bf4d46a
commit c704cb54d2
68 changed files with 708 additions and 232 deletions
+13 -13
View File
@@ -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 () => {
+74 -1
View File
@@ -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 () => {