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 () => {
+8 -4
View File
@@ -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;
}
@@ -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.
+12 -8
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<vo
res.status(400).json({ error: 'nodeId must be a positive integer or null' });
return;
}
if (!requirePermission(req, res, 'node:manage', nodeId === null ? undefined : 'node', nodeId === null ? undefined : String(nodeId))) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
+1 -1
View File
@@ -2,8 +2,8 @@ import { Router, type Request, type Response } from 'express';
import DockerController from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import { excludeSelfContainers } from '../helpers/excludeSelfContainers';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { requireAdmin } from '../middleware/tierGates';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
export const containersRouter = Router();
+2
View File
@@ -1,5 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requirePermission } from '../middleware/permissions';
import { DatabaseService } from '../services/DatabaseService';
import { buildLocalGraph } from '../services/DependencyGraphService';
@@ -11,6 +12,7 @@ export const dependencyMapRouter = Router();
* remotes. Served against the local Docker of whichever node handles it.
*/
dependencyMapRouter.get('/node-graph', authMiddleware, async (req: Request, res: Response): Promise<void> => {
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';
+26 -8
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'node:read')) return;
try {
const summaries = await collectFleetLabelSummaries();
const agg = new Map<string, { nodeCount: number; stackCount: number; nodes: string[] }>();
+32 -8
View File
@@ -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<string>,
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<void> => {
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<void> => {
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);
+15 -8
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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 }[] = [];
+18 -5
View File
@@ -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<void> => {
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<void> =>
*/
meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> =>
meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response): Promise<void> => {
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<void> => {
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');
+7 -6
View File
@@ -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<void> => {
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<vo
});
networkingRouter.get('/overview', async (req: Request, res: Response): Promise<void> => {
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<v
});
networkingRouter.get('/networks', async (req: Request, res: Response): Promise<void> => {
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<v
});
networkingRouter.get('/networks/:id', async (req: Request, res: Response): Promise<void> => {
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<void> => {
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<v
});
networkingRouter.get('/findings', async (req: Request, res: Response): Promise<void> => {
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<v
});
networkingRouter.get('/findings/:id', async (req: Request, res: Response): Promise<void> => {
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' });
+7 -3
View File
@@ -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) {
+5 -1
View File
@@ -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;
+19 -17
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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)) {
+24
View File
@@ -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,
+9
View File
@@ -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' });
+2
View File
@@ -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;
+4 -4
View File
@@ -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';