mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
fix(rbac): enforce operational permission parity (#1736)
This commit is contained in:
@@ -132,7 +132,7 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
expect(res.body.pinned_node_id).toBe(node.id);
|
||||
});
|
||||
|
||||
it('rejects a non-admin on a paid license with ADMIN_REQUIRED', async () => {
|
||||
it('rejects a user without node:manage on a paid license', async () => {
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
|
||||
@@ -142,26 +142,26 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
.send({ nodeId: node.id });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blueprint mutation routes require admin role', () => {
|
||||
describe('Blueprint mutation routes require their operational permissions', () => {
|
||||
// The gate short-circuits before id parsing, so dummy ids are sufficient
|
||||
// to prove the role boundary.
|
||||
const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string }> = [
|
||||
{ name: 'create', method: 'post', path: '/api/blueprints' },
|
||||
{ name: 'update', method: 'put', path: '/api/blueprints/1' },
|
||||
{ name: 'delete', method: 'delete', path: '/api/blueprints/1' },
|
||||
{ name: 'apply', method: 'post', path: '/api/blueprints/1/apply' },
|
||||
{ name: 'withdraw', method: 'post', path: '/api/blueprints/1/withdraw/1' },
|
||||
{ name: 'accept', method: 'post', path: '/api/blueprints/1/accept/1' },
|
||||
const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string; status: number }> = [
|
||||
{ name: 'create', method: 'post', path: '/api/blueprints', status: 403 },
|
||||
{ name: 'update', method: 'put', path: '/api/blueprints/1', status: 403 },
|
||||
{ name: 'delete', method: 'delete', path: '/api/blueprints/1', status: 403 },
|
||||
{ name: 'apply', method: 'post', path: '/api/blueprints/1/apply', status: 403 },
|
||||
{ name: 'withdraw', method: 'post', path: '/api/blueprints/1/withdraw/1', status: 404 },
|
||||
{ name: 'accept', method: 'post', path: '/api/blueprints/1/accept/1', status: 400 },
|
||||
];
|
||||
|
||||
it.each(mutations)('rejects a non-admin on $name with ADMIN_REQUIRED', async ({ method, path }) => {
|
||||
it.each(mutations)('does not let a viewer perform $name', async ({ method, path, status }) => {
|
||||
const res = await request(app)[method](path).set('Cookie', viewerCookie).send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
expect(res.status).toBe(status);
|
||||
if (status === 403) expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
|
||||
it('lets a Community admin create when the body is valid (not PAID_REQUIRED)', async () => {
|
||||
|
||||
@@ -126,13 +126,13 @@ describe('Blueprints on Community tier', () => {
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects blueprint mutations for a viewer with ADMIN_REQUIRED', async () => {
|
||||
it('rejects blueprint mutations for a viewer without stack:create', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Authorization', viewerAuthHeader)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
|
||||
it('lets a Community viewer list blueprints', async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSION
|
||||
|
||||
const VIEWER = 'container-read-viewer';
|
||||
const READ_PATHS = ['/api/containers', '/api/containers/abc123/logs', '/api/ports/in-use'];
|
||||
const MUTATION_PATHS = ['/api/containers/abc123/start', '/api/containers/abc123/stop', '/api/containers/abc123/restart'];
|
||||
|
||||
/** Sign a viewer JWT using the live token_version so authMiddleware accepts it. */
|
||||
function viewerToken(): string {
|
||||
@@ -120,3 +121,13 @@ describe('container/ports reads reject unauthenticated requests', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic container-id mutations remain Admin-only', () => {
|
||||
it.each(MUTATION_PATHS)('POST %s rejects a non-admin before Docker work', async (path) => {
|
||||
const { docker } = stubDockerAndFs();
|
||||
const res = await request(app).post(path).set('Authorization', `Bearer ${viewerToken()}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
expect(docker).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -313,14 +313,14 @@ describe('GET /api/fleet/labels/suggestions', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for a non-admin (viewer) user', async () => {
|
||||
it('allows a viewer with node:read to load suggestions', async () => {
|
||||
const viewerName = `viewer-sugg-${++labelCounter}`;
|
||||
db.addUser({ username: viewerName, password_hash: 'x', role: 'viewer' });
|
||||
const viewerAuth = `Bearer ${jwt.sign({ username: viewerName }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/labels/suggestions')
|
||||
.set('Authorization', viewerAuth);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
|
||||
|
||||
@@ -10,22 +10,41 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let scopedAuthHeader: string;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let scopedUserId: number;
|
||||
let defaultNodeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
const db = DatabaseService.getInstance();
|
||||
defaultNodeId = db.getDefaultNode()?.id ?? 1;
|
||||
const hash = await bcrypt.hash('password123', 1);
|
||||
scopedUserId = db.addUser({ username: 'fleet-scoped-operator', password_hash: hash, role: 'viewer' });
|
||||
db.addRoleAssignment({ user_id: scopedUserId, role: 'node-admin', resource_type: 'stack', resource_id: 'allowed-edit', node_id: defaultNodeId });
|
||||
db.addRoleAssignment({ user_id: scopedUserId, role: 'deployer', resource_type: 'stack', resource_id: 'allowed-deploy', node_id: defaultNodeId });
|
||||
const scopedUser = db.getUserByUsername('fleet-scoped-operator')!;
|
||||
scopedAuthHeader = `Bearer ${jwt.sign({ username: scopedUser.username, role: scopedUser.role, tv: scopedUser.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
afterAll(() => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.deleteRoleAssignmentsByUser(scopedUserId);
|
||||
db.deleteUser(scopedUserId);
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
function mockTier(tier: 'paid' | 'community') {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
@@ -237,6 +256,60 @@ describe('Fleet Actions orchestration shape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fleet Actions authorize every target before mutation', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('rejects fleet stop when any confirmed stack lacks stack:deploy', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', scopedAuthHeader)
|
||||
.send({
|
||||
labelName: 'prod',
|
||||
targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-deploy', 'denied-deploy'] }],
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
|
||||
it('does not create a label when bulk assign contains a denied stack', async () => {
|
||||
mockTier('paid');
|
||||
const labelName = 'atomic-bulk-denied';
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', scopedAuthHeader)
|
||||
.send({
|
||||
label: { name: labelName, color: 'teal' },
|
||||
targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-edit', 'denied-edit'] }],
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not create a label when a local receiver target is denied', async () => {
|
||||
mockTier('paid');
|
||||
const labelName = 'atomic-local-denied';
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-assign')
|
||||
.set('Authorization', scopedAuthHeader)
|
||||
.send({ label: { name: labelName, color: 'teal' }, stackNames: ['allowed-edit', 'denied-edit'] });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects local stop before work when any confirmed stack is denied', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-stop')
|
||||
.set('Authorization', scopedAuthHeader)
|
||||
.send({ labelName: 'prod', stackNames: ['allowed-deploy', 'denied-deploy'] });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
});
|
||||
|
||||
// The per-node local-stop receiver is what a control instance calls on each
|
||||
// remote during a fleet-wide stop. It must be reachable on every license (only
|
||||
// admin-gated): the original fleet-stop fan-out hit the paid /api/labels/:id/action
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Gate coverage for the mesh router.
|
||||
*
|
||||
* Every /api/mesh route is tier-gated (requirePaid). The five operator
|
||||
* mutations are additionally role-gated (requireAdmin): node enable/disable,
|
||||
* stack opt-in/opt-out, and the override regen. The operator read routes
|
||||
* Every /api/mesh route is tier-gated (requirePaid). Node enable/disable and
|
||||
* override regeneration are permission-gated. Stack membership remains Admin-only
|
||||
* because a membership change redeploys every affected mesh stack. The read routes
|
||||
* (status, aliases, activity, diagnostics) stay reachable for any paid-tier
|
||||
* user regardless of role, which is what lets a non-admin see a read-only
|
||||
* Routing tab. The node-to-node routes that central calls over the proxy on the
|
||||
@@ -103,17 +103,29 @@ describe('mesh read routes are visible to a non-admin paid user', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mesh mutation routes require the admin role (requireAdmin)', () => {
|
||||
const mutationRoutes: { name: string; path: () => string }[] = [
|
||||
describe('mesh mutation authorization', () => {
|
||||
const permissionRoutes: { name: string; path: () => string }[] = [
|
||||
{ name: 'POST /regen-overrides', path: () => '/api/mesh/regen-overrides' },
|
||||
{ name: 'POST /nodes/:id/enable', path: () => `/api/mesh/nodes/${defaultNodeId}/enable` },
|
||||
{ name: 'POST /nodes/:id/disable', path: () => `/api/mesh/nodes/${defaultNodeId}/disable` },
|
||||
];
|
||||
const adminRoutes: { name: string; path: () => string }[] = [
|
||||
{ name: 'POST /nodes/:id/stacks/:stack/opt-in', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-in` },
|
||||
{ name: 'POST /nodes/:id/stacks/:stack/opt-out', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-out` },
|
||||
];
|
||||
|
||||
for (const route of mutationRoutes) {
|
||||
it(`${route.name} rejects a non-admin paid user with ADMIN_REQUIRED`, async () => {
|
||||
for (const route of permissionRoutes) {
|
||||
it(`${route.name} rejects a paid user without the required operational permission`, async () => {
|
||||
const res = await request(app)
|
||||
.post(route.path())
|
||||
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of adminRoutes) {
|
||||
it(`${route.name} remains Admin-only`, async () => {
|
||||
const res = await request(app)
|
||||
.post(route.path())
|
||||
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
|
||||
import { classifyStackApiPath } from '../helpers/stackRouteAuth';
|
||||
|
||||
describe('operational role matrix', () => {
|
||||
const expectations: Record<string, { allow: PermissionAction[]; deny: PermissionAction[] }> = {
|
||||
admin: {
|
||||
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
|
||||
deny: [],
|
||||
},
|
||||
'node-admin': {
|
||||
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'],
|
||||
deny: ['system:settings', 'system:users', 'system:license', 'system:registries'],
|
||||
},
|
||||
deployer: {
|
||||
allow: ['stack:read', 'stack:deploy'],
|
||||
deny: ['stack:edit', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
|
||||
},
|
||||
viewer: {
|
||||
allow: ['stack:read', 'node:read'],
|
||||
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:audit'],
|
||||
},
|
||||
auditor: {
|
||||
allow: ['stack:read', 'node:read', 'system:audit'],
|
||||
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:settings'],
|
||||
},
|
||||
};
|
||||
|
||||
for (const [role, expected] of Object.entries(expectations)) {
|
||||
it(`${role} exposes only its intended operational actions`, () => {
|
||||
const actions = ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS];
|
||||
for (const action of expected.allow) expect(actions).toContain(action);
|
||||
for (const action of expected.deny) expect(actions).not.toContain(action);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('named stack route permission inventory', () => {
|
||||
const routes: Array<[string, string, PermissionAction]> = [
|
||||
['GET', '/stacks/web', 'stack:read'],
|
||||
['GET', '/stacks/web/env', 'stack:read'],
|
||||
['GET', '/stacks/web/services', 'stack:read'],
|
||||
['GET', '/stacks/web/update-preview', 'stack:read'],
|
||||
['GET', '/stacks/web/files/content', 'stack:read'],
|
||||
['PUT', '/stacks/web', 'stack:edit'],
|
||||
['PUT', '/stacks/web/env', 'stack:edit'],
|
||||
['PUT', '/stacks/web/dossier', 'stack:edit'],
|
||||
['PUT', '/stacks/web/labels', 'stack:edit'],
|
||||
['POST', '/stacks/web/deploy', 'stack:deploy'],
|
||||
['POST', '/stacks/web/stop', 'stack:deploy'],
|
||||
['POST', '/stacks/web/services/api/update', 'stack:deploy'],
|
||||
['POST', '/stacks/web/rollback', 'stack:deploy'],
|
||||
['DELETE', '/stacks/web', 'stack:delete'],
|
||||
];
|
||||
|
||||
for (const [method, path, action] of routes) {
|
||||
it(`${method} ${path} requires ${action}`, () => {
|
||||
expect(classifyStackApiPath(method, path)).toEqual({
|
||||
kind: 'named-stack',
|
||||
stackName: 'web',
|
||||
action,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -141,6 +141,24 @@ describe('checkPermission with node-scoped stack grants', () => {
|
||||
);
|
||||
for (const a of assignments) db.deleteRoleAssignment(a.id!);
|
||||
});
|
||||
|
||||
it('uses an explicit target node for hub-orchestrated stack checks', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addRoleAssignment({
|
||||
user_id: viewerId,
|
||||
role: 'deployer',
|
||||
resource_type: 'stack',
|
||||
resource_id: 'remote-stack',
|
||||
node_id: otherNodeId,
|
||||
});
|
||||
|
||||
const hubRequest = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId });
|
||||
expect(checkPermission(hubRequest, 'stack:deploy', 'stack', 'remote-stack')).toBe(false);
|
||||
expect(checkPermission(hubRequest, 'stack:deploy', 'stack', 'remote-stack', otherNodeId)).toBe(true);
|
||||
expect(checkPermission(hubRequest, 'stack:edit', 'stack', 'remote-stack', otherNodeId)).toBe(false);
|
||||
|
||||
db.deleteRoleAssignmentsByStack(otherNodeId, 'remote-stack');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopedActionsForStack with node-scoped grants', () => {
|
||||
|
||||
@@ -459,9 +459,9 @@ describe('GET /api/security/vex/export (Community)', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
|
||||
it('lets a viewer with stack:read export VEX', async () => {
|
||||
const res = await request(app).get('/api/security/vex/export').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('exports an OpenVEX document from triage decisions', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Both scan-export endpoints are available on every tier (admin only, no tier gate):
|
||||
* Both scan-export endpoints are available on every tier with stack:read:
|
||||
* POST /api/security/sbom -> per-image SBOM artifact
|
||||
* GET /api/security/scans/:id/sarif -> SARIF for CI / code-scanning ingestion
|
||||
*/
|
||||
@@ -55,13 +55,16 @@ describe('POST /api/security/sbom (Community)', () => {
|
||||
expect(res.headers['content-disposition']).toContain('nginx_latest.cdx.json');
|
||||
});
|
||||
|
||||
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
|
||||
it('lets a Community viewer generate an SBOM with stack:read', async () => {
|
||||
mockTier('community');
|
||||
const svc = TrivyService.getInstance();
|
||||
vi.spyOn(svc, 'isTrivyAvailable').mockReturnValue(true);
|
||||
vi.spyOn(svc, 'generateSBOM').mockResolvedValue('{"bomFormat":"CycloneDX"}');
|
||||
const res = await request(app)
|
||||
.post('/api/security/sbom')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ imageRef: 'nginx:latest', format: 'cyclonedx' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,11 +80,11 @@ describe('GET /api/security/scans/:scanId/sarif (Community)', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
|
||||
it('lets a Community viewer reach SARIF export with stack:read', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.get('/api/security/scans/999999/sarif')
|
||||
.set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,13 +114,13 @@ describe('POST /api/security/suppressions', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects non-admin users with 403', async () => {
|
||||
it('rejects users without stack:edit with 403', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/security/suppressions')
|
||||
.set('Authorization', viewerAuthHeader)
|
||||
.send(validBody);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
|
||||
it('is accessible on community tier (admin still required)', async () => {
|
||||
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[] }>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }[] = [];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -431,7 +431,7 @@ export function AppStoreView({ onDeploySuccess, headerActions }: AppStoreViewPro
|
||||
label: isDeploying ? 'Deploying…' : `Deploy ${selectedTemplate.title}`,
|
||||
icon: isDeploying ? Loader2 : Rocket,
|
||||
onClick: handleDeploy,
|
||||
disabled: isDeploying || !stackName.trim() || !can('stack:create'),
|
||||
disabled: isDeploying || !stackName.trim() || !can('stack:create') || !can('stack:deploy'),
|
||||
} : undefined}
|
||||
footerContext={isDeploying ? 'This may take a few minutes for large images.' : undefined}
|
||||
size="md"
|
||||
|
||||
@@ -443,7 +443,6 @@ export function EditorView(props: EditorViewProps) {
|
||||
safeContainers={safeContainers}
|
||||
isRunning={isRunning}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
backupInfo={backupInfo}
|
||||
loadingAction={loadingAction}
|
||||
|
||||
@@ -147,7 +147,6 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
safeContainers={safeContainers}
|
||||
isRunning={isRunning}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
backupInfo={backupInfo}
|
||||
loadingAction={loadingAction}
|
||||
|
||||
@@ -222,7 +222,7 @@ export function ShellOverlays({
|
||||
<VulnerabilityScanSheet
|
||||
scanId={stackMisconfigScanId}
|
||||
onClose={() => setStackMisconfigScanId(null)}
|
||||
canManageSuppressions={isAdmin}
|
||||
canManageSuppressions={can('stack:edit')}
|
||||
/>
|
||||
|
||||
{/* Compose diff preview */}
|
||||
|
||||
@@ -39,7 +39,6 @@ function renderHeader(over: Partial<ComponentProps<typeof StackIdentityHeader>>
|
||||
safeContainers={CONTAINERS}
|
||||
isRunning
|
||||
can={() => true}
|
||||
isAdmin
|
||||
trivy={{ available: false }}
|
||||
backupInfo={{ exists: false, timestamp: null }}
|
||||
loadingAction={null}
|
||||
@@ -114,4 +113,15 @@ describe('StackIdentityHeader', () => {
|
||||
|
||||
expect(onOpenMonitor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows stack config scanning to a deployer without requiring Admin', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeader({
|
||||
trivy: { available: true },
|
||||
can: (action) => action === 'stack:deploy',
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'More actions' }));
|
||||
expect(screen.getByRole('menuitem', { name: 'Scan config' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,7 +127,6 @@ export interface StackIdentityHeaderProps {
|
||||
safeContainers: ContainerInfo[];
|
||||
isRunning: boolean;
|
||||
can: ReturnType<typeof useAuth>['can'];
|
||||
isAdmin: boolean;
|
||||
trivy: { available: boolean };
|
||||
backupInfo: { exists: boolean; timestamp: number | null };
|
||||
loadingAction: StackAction | null;
|
||||
@@ -156,7 +155,6 @@ export function StackIdentityHeader({
|
||||
safeContainers,
|
||||
isRunning,
|
||||
can,
|
||||
isAdmin,
|
||||
trivy,
|
||||
backupInfo,
|
||||
loadingAction,
|
||||
@@ -206,7 +204,7 @@ export function StackIdentityHeader({
|
||||
const canDeploy = can('stack:deploy', 'stack', stackName, activeNode?.id);
|
||||
const canDelete = can('stack:delete', 'stack', stackName, activeNode?.id);
|
||||
const canRollback = canDeploy && backupInfo.exists;
|
||||
const canScan = trivy.available && isAdmin;
|
||||
const canScan = trivy.available && canDeploy;
|
||||
const canMute = stackMuteActions?.canMute ?? false;
|
||||
const hasOverflowExtras = canRollback || canScan;
|
||||
const hasOverflow = hasOverflowExtras || canDelete || canMute || onOpenMonitor;
|
||||
|
||||
@@ -63,7 +63,9 @@ export function FleetView({
|
||||
onFleetActiveTabChange,
|
||||
}: FleetViewProps) {
|
||||
const { isPaid, licenseStatus } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canManageFleet = can('node:manage');
|
||||
const canExportDossier = can('node:read') && can('stack:read');
|
||||
const { hasCapability } = useNodes();
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
const containerLabelsEnabled = hasCapability('container-label-inventory');
|
||||
@@ -240,7 +242,7 @@ export function FleetView({
|
||||
<TooltipContent>Refresh</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAdmin && (
|
||||
{canExportDossier && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -289,8 +291,8 @@ export function FleetView({
|
||||
onRetryUpdate={updateStatus.retryNodeUpdate}
|
||||
onDismissUpdate={updateStatus.dismissNodeUpdate}
|
||||
onCordonChange={() => { void overview.fetchOverview(true); }}
|
||||
onEditNode={isAdmin ? openEdit : undefined}
|
||||
onDeleteNode={isAdmin ? openDelete : undefined}
|
||||
onEditNode={openEdit}
|
||||
onDeleteNode={openDelete}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
|
||||
onCheckUpdates={updateStatus.checkUpdates}
|
||||
@@ -324,12 +326,18 @@ export function FleetView({
|
||||
{canDiscoverRouting && (
|
||||
<TabsContent value="routing">
|
||||
<PaidGate>
|
||||
<RoutingTab canManage={isAdmin} />
|
||||
<RoutingTab
|
||||
canManageNode={(nodeId) => can('node:manage', 'node', String(nodeId))}
|
||||
canManageMembership={isAdmin}
|
||||
/>
|
||||
</PaidGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="federation">
|
||||
<FederationTab canManage={isAdmin} />
|
||||
<FederationTab
|
||||
canManage={canManageFleet}
|
||||
canManageNode={(nodeId) => can('node:manage', 'node', String(nodeId))}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="actions">
|
||||
{/* Fleet Actions runs against the whole fleet, so it takes the
|
||||
|
||||
@@ -79,10 +79,11 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
|
||||
const { nodes: registryNodes } = useNodes();
|
||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||
const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1;
|
||||
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
||||
const canManageNode = can('node:manage', 'node', String(node.id));
|
||||
const canEdit = Boolean(canManageNode && onEdit && registryNode);
|
||||
const canDelete = Boolean(canManageNode && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
||||
// Cordon is permission-gated only (node:manage), matching the backend route guard.
|
||||
const canCordon = can('node:manage', 'node', String(node.id));
|
||||
const canCordon = canManageNode;
|
||||
const nodeMuteActions = useNodeMuteActions(
|
||||
node.id,
|
||||
node.name,
|
||||
|
||||
@@ -62,6 +62,20 @@ describe('NodeCard', () => {
|
||||
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
|
||||
});
|
||||
|
||||
it('shows edit and delete controls to a scoped node manager who is not an admin', async () => {
|
||||
const node = onlineNode();
|
||||
const registryNode = { id: 2, name: 'Edge', type: 'remote', is_default: false };
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
useNodesMock.mockReturnValue({ nodes: [registryNode, { id: 1, type: 'local' }], hasCapability: vi.fn(() => false) });
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn((action: string) => action === 'node:manage') });
|
||||
render(<NodeCard {...baseProps(node)} onEdit={onEdit} onDelete={onDelete} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
expect(await screen.findByText('Edit node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the cordon control from a user lacking node:manage', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
|
||||
@@ -41,7 +41,6 @@ export interface SenchoNavigateDetail {
|
||||
export function NodeManager() {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canEditLabels = isAdmin;
|
||||
// Mirror the backend node:manage guard. This top-level flag checks the global
|
||||
// role only (admin or global node-admin); the per-row Test/Edit/Delete buttons
|
||||
// below additionally honor scoped per-node grants via can('node:manage', 'node', id).
|
||||
@@ -374,7 +373,7 @@ export function NodeManager() {
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(node.status)}</TableCell>
|
||||
<TableCell>
|
||||
<NodeLabelPicker nodeId={node.id} canEdit={canEditLabels} />
|
||||
<NodeLabelPicker nodeId={node.id} canEdit={can('node:manage', 'node', String(node.id))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
|
||||
@@ -367,7 +367,10 @@ interface ResourcesViewProps {
|
||||
export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged'>('images');
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canReadResources = can('stack:read');
|
||||
const canDeployResources = can('stack:deploy');
|
||||
const canEditSecurityPolicy = can('stack:edit');
|
||||
const { activeNode } = useNodes();
|
||||
const [usage, setUsage] = useState<UsageData | null>(null);
|
||||
const [images, setImages] = useState<DockerImage[]>([]);
|
||||
@@ -1076,7 +1079,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
<TooltipContent>Inspect image</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
||||
{trivy.available && canDeployResources && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
||||
<DropdownMenu>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -1214,7 +1217,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isAdmin && (
|
||||
{canReadResources && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1479,11 +1482,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
|
||||
canGenerateSbom={isAdmin}
|
||||
canExportSarif={isAdmin}
|
||||
onRescan={canDeployResources ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
|
||||
canGenerateSbom={canReadResources}
|
||||
canExportSarif={canReadResources}
|
||||
canCompare
|
||||
canManageSuppressions={isAdmin}
|
||||
canManageSuppressions={canEditSecurityPolicy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ const MOBILE_MASTHEAD_TONE: Record<MastheadTone, { dot: Tone; word: StateWordCla
|
||||
};
|
||||
|
||||
export function SecurityView({ activeTab, onTabChange, headerActions }: SecurityViewProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const isMobile = useIsMobile();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
@@ -104,7 +104,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
|
||||
// Scanner readiness gates the Images Actions column; an admin on a node whose
|
||||
// scanner is available can trigger scans inline.
|
||||
const canScan = isAdmin && !!overview?.scanner.available;
|
||||
const canScanImages = can('stack:deploy') && !!overview?.scanner.available;
|
||||
const canScanNode = can('node:manage') && !!overview?.scanner.available;
|
||||
const canReadSecurityExports = can('stack:read');
|
||||
const canEditSecurityPolicy = can('stack:edit');
|
||||
const { scanningRef, scanImage } = useImageScan({
|
||||
onComplete: (scanId) => onInspect(scanId, 'vulns'),
|
||||
onSummaries: setSummaries,
|
||||
@@ -264,7 +267,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
exploitTruncated={exploitTruncated}
|
||||
onNavigate={handleNavigate}
|
||||
onInspect={onInspect}
|
||||
canScan={canScan}
|
||||
canScan={canScanNode}
|
||||
onScanComplete={() => setReloadToken((t) => t + 1)}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -276,7 +279,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
loading={summariesLoading}
|
||||
error={summariesError}
|
||||
onInspect={onInspect}
|
||||
canScan={canScan}
|
||||
canScan={canScanImages}
|
||||
scanningRef={scanningRef}
|
||||
onScan={scanImage}
|
||||
initialFilter={imagesFilter ?? undefined}
|
||||
@@ -336,10 +339,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
scanId={inspectScanId}
|
||||
initialTab={inspectInitialTab}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
canGenerateSbom={isAdmin}
|
||||
canExportSarif={isAdmin}
|
||||
canGenerateSbom={canReadSecurityExports}
|
||||
canExportSarif={canReadSecurityExports}
|
||||
canCompare
|
||||
canManageSuppressions={isAdmin}
|
||||
canManageSuppressions={canEditSecurityPolicy}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => ({ isPaid: true }),
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
useAuth: () => ({ isAdmin: true, can: () => true }),
|
||||
}));
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ hasCapability: () => false }),
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
|
||||
const licenseState = { isPaid: true };
|
||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => licenseState }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true, can: () => true }) }));
|
||||
|
||||
const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } };
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState }));
|
||||
|
||||
@@ -12,7 +12,9 @@ import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
interface BlueprintDeploymentTableProps {
|
||||
deployments: BlueprintDeployment[];
|
||||
classification: BlueprintClassification;
|
||||
canEdit: boolean;
|
||||
canDeploy: (nodeId: number) => boolean;
|
||||
canWithdraw: (nodeId: number) => boolean;
|
||||
canRetry: boolean;
|
||||
busyNodeId: number | null;
|
||||
onWithdraw: (nodeId: number) => void;
|
||||
onAcceptStateReview: (nodeId: number) => void;
|
||||
@@ -51,7 +53,7 @@ function statusDotClass(status: BlueprintDeploymentStatus): string {
|
||||
}
|
||||
|
||||
export function BlueprintDeploymentTable({
|
||||
deployments, classification, canEdit, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null,
|
||||
deployments, classification, canDeploy, canWithdraw, canRetry, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null,
|
||||
}: BlueprintDeploymentTableProps) {
|
||||
const { nodes } = useNodes();
|
||||
const nodesById = new Map(nodes.map(n => [n.id, n]));
|
||||
@@ -122,7 +124,7 @@ export function BlueprintDeploymentTable({
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{dep.status === 'pending_state_review' && canEdit && (
|
||||
{dep.status === 'pending_state_review' && canDeploy(dep.node_id) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
@@ -138,7 +140,7 @@ export function BlueprintDeploymentTable({
|
||||
Resolve manually
|
||||
</span>
|
||||
)}
|
||||
{dep.status === 'failed' && canEdit && (
|
||||
{dep.status === 'failed' && canRetry && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -148,7 +150,7 @@ export function BlueprintDeploymentTable({
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{(dep.status === 'active' || dep.status === 'drifted' || dep.status === 'evict_blocked' || dep.status === 'failed') && canEdit && (
|
||||
{(dep.status === 'active' || dep.status === 'drifted' || dep.status === 'evict_blocked' || dep.status === 'failed') && canWithdraw(dep.node_id) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Render-gate coverage for BlueprintDetail's action bar.
|
||||
*
|
||||
* The Apply / Edit / Disable / Delete actions all hit admin-only routes
|
||||
* (e.g. POST /api/blueprints/:id/apply requires admin). This locks the UI gate:
|
||||
* an admin (canEdit) sees the action affordances; a non-admin viewer sees none
|
||||
* of them, so the sheet can never issue a request the API answers with 403.
|
||||
* Blueprint actions use distinct stack permissions. These tests lock the UI
|
||||
* gates so each role sees only actions accepted by the API.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
@@ -149,4 +147,24 @@ describe('BlueprintDetail action gating', () => {
|
||||
// The detail is still viewable: the compose source and deployment table render.
|
||||
expect(screen.getByTestId('deployment-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets a deployer apply without exposing edit or delete', async () => {
|
||||
const can = vi.fn((action: string) => action === 'stack:create' || action === 'stack:deploy');
|
||||
render(
|
||||
<BlueprintDetail
|
||||
blueprintId={1}
|
||||
open
|
||||
onOpenChange={noop}
|
||||
onChanged={noop}
|
||||
canEdit={false}
|
||||
can={can}
|
||||
distinctLabels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Show compose source')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /apply now/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^delete$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,9 @@ import { StateReviewDialog } from './StateReviewDialog';
|
||||
import { RolloutPreviewDialog } from './RolloutPreviewDialog';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { PermissionAction } from '@/context/AuthContext';
|
||||
|
||||
type PermissionResolver = (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
|
||||
|
||||
interface BlueprintDetailProps {
|
||||
blueprintId: number;
|
||||
@@ -33,10 +36,11 @@ interface BlueprintDetailProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onChanged: () => void;
|
||||
canEdit: boolean;
|
||||
can?: PermissionResolver;
|
||||
distinctLabels: string[];
|
||||
}
|
||||
|
||||
export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, canEdit, distinctLabels }: BlueprintDetailProps) {
|
||||
export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, canEdit, can, distinctLabels }: BlueprintDetailProps) {
|
||||
const [summary, setSummary] = useState<BlueprintSummary | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -78,6 +82,12 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
|
||||
if (!open) return null;
|
||||
|
||||
const blueprint = summary?.blueprint;
|
||||
const canApply = !!blueprint && (can ? can('stack:create') && can('stack:deploy') : canEdit);
|
||||
const canDeleteBlueprint = !!blueprint && (can ? can('stack:delete') : canEdit);
|
||||
const canDeployOnNode = (nodeId: number) => !!blueprint
|
||||
&& (can ? can('stack:deploy', 'stack', blueprint.name, nodeId) : canEdit);
|
||||
const canWithdrawFromNode = (nodeId: number) => !!blueprint
|
||||
&& (can ? can('stack:delete', 'stack', blueprint.name, nodeId) : canEdit);
|
||||
|
||||
async function handleRolloutApplied() {
|
||||
await refresh();
|
||||
@@ -236,14 +246,14 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
|
||||
crumb={['Blueprints', blueprint?.name ?? '…']}
|
||||
name={blueprint?.name ?? <Skeleton className="h-7 w-40 inline-block" />}
|
||||
meta={meta}
|
||||
primaryAction={blueprint && canEdit ? {
|
||||
primaryAction={canApply ? {
|
||||
label: 'Apply now',
|
||||
icon: Play,
|
||||
onClick: () => setPreviewOpen(true),
|
||||
disabled: submitting || !blueprint.enabled || editMode,
|
||||
} : undefined}
|
||||
secondaryActions={secondaryActions}
|
||||
destructiveAction={blueprint && canEdit ? {
|
||||
destructiveAction={canDeleteBlueprint ? {
|
||||
label: 'Delete',
|
||||
icon: Trash2,
|
||||
onClick: () => setDeleteOpen(true),
|
||||
@@ -294,7 +304,9 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
|
||||
<BlueprintDeploymentTable
|
||||
deployments={summary.deployments}
|
||||
classification={blueprint.classification}
|
||||
canEdit={canEdit}
|
||||
canDeploy={canDeployOnNode}
|
||||
canWithdraw={canWithdrawFromNode}
|
||||
canRetry={canApply}
|
||||
busyNodeId={busyNodeId}
|
||||
onWithdraw={openWithdraw}
|
||||
onAcceptStateReview={openAcceptStateReview}
|
||||
|
||||
@@ -19,8 +19,9 @@ import { BlueprintEditor } from './BlueprintEditor';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export function DeploymentsTab() {
|
||||
const { isAdmin } = useAuth();
|
||||
const canEdit = isAdmin;
|
||||
const { can } = useAuth();
|
||||
const canCreate = can('stack:create');
|
||||
const canEdit = can('stack:edit');
|
||||
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
|
||||
const [distinctLabels, setDistinctLabels] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -100,7 +101,7 @@ export function DeploymentsTab() {
|
||||
<FleetTabHeading
|
||||
title="Blueprints"
|
||||
subtitle="Declare compose templates once and keep matching nodes in sync."
|
||||
action={canEdit ? (
|
||||
action={canCreate ? (
|
||||
<Button size="sm" className="gap-1.5" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4" strokeWidth={1.5} />
|
||||
New Blueprint
|
||||
@@ -108,7 +109,7 @@ export function DeploymentsTab() {
|
||||
) : undefined}
|
||||
/>
|
||||
<FleetEmptyState>
|
||||
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} canCreate={canEdit} />
|
||||
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} canCreate={canCreate} />
|
||||
</FleetEmptyState>
|
||||
</>
|
||||
) : (
|
||||
@@ -116,7 +117,7 @@ export function DeploymentsTab() {
|
||||
blueprints={blueprints}
|
||||
onSelect={setSelectedId}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
canCreate={canEdit}
|
||||
canCreate={canCreate}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -127,6 +128,7 @@ export function DeploymentsTab() {
|
||||
onOpenChange={(o) => { if (!o) setSelectedId(null); }}
|
||||
onChanged={refresh}
|
||||
canEdit={canEdit}
|
||||
can={can}
|
||||
distinctLabels={distinctLabels}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Render-gate coverage for FederationTab's pin control.
|
||||
*
|
||||
* Pinning a blueprint to a node is admin-only on the backend
|
||||
* (PUT /api/blueprints/:id/pin requires admin). This test locks the matching UI
|
||||
* gate: an admin sees an editable Select, a non-admin sees the placement
|
||||
* Pinning a blueprint to a node is permission-gated on the backend. This test
|
||||
* locks the matching UI gate: a manager sees an editable Select, while a user
|
||||
* without permission sees the placement
|
||||
* read-only with an explanatory hint. Without this the affordance can drift
|
||||
* back to rendering an enabled control that the API rejects with 403.
|
||||
*/
|
||||
@@ -67,7 +67,7 @@ describe('FederationTab pin gating', () => {
|
||||
|
||||
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Pin changes require an administrator/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/do not have permission to change pin placement/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the pin placement read-only for a non-admin', async () => {
|
||||
@@ -75,9 +75,9 @@ describe('FederationTab pin gating', () => {
|
||||
|
||||
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Pin changes require an administrator/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/do not have permission to change pin placement/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('(unpinned)')).toBeInTheDocument();
|
||||
// The read-only branch must never be able to issue the admin-only pin request.
|
||||
// The read-only branch must never be able to issue the pin request.
|
||||
expect(vi.mocked(pinBlueprint)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -91,4 +91,12 @@ describe('FederationTab pin gating', () => {
|
||||
// "Effective" column, so getAllByText (not getByText) is required.
|
||||
expect(screen.getAllByText('node-alpha').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows pin controls for a scoped node manager', async () => {
|
||||
render(<FederationTab canManage={false} canManageNode={(nodeId) => nodeId === 1} />);
|
||||
|
||||
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/do not have permission to change pin placement/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,12 +26,12 @@ function formatTimestamp(ms: number | null): string {
|
||||
}
|
||||
|
||||
interface FederationTabProps {
|
||||
/** Whether the current user may change pin placement. Pinning is admin-only on the backend
|
||||
* (PUT /api/blueprints/:id/pin requires admin); non-admins see the placement read-only. */
|
||||
/** Whether the current user may change pin placement. */
|
||||
canManage: boolean;
|
||||
canManageNode?: (nodeId: number) => boolean;
|
||||
}
|
||||
|
||||
export function FederationTab({ canManage }: FederationTabProps) {
|
||||
export function FederationTab({ canManage, canManageNode }: FederationTabProps) {
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -61,6 +61,7 @@ export function FederationTab({ canManage }: FederationTabProps) {
|
||||
for (const node of nodes) map.set(node.id, node.name);
|
||||
return map;
|
||||
}, [nodes]);
|
||||
const canManageAnyNode = canManage || nodes.some(node => canManageNode?.(node.id));
|
||||
|
||||
const handlePinChange = useCallback(async (blueprintId: number, value: string) => {
|
||||
const nodeId = value === UNPINNED ? null : Number.parseInt(value, 10);
|
||||
@@ -149,7 +150,7 @@ export function FederationTab({ canManage }: FederationTabProps) {
|
||||
<h3 className="text-sm font-medium">Pin policy</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Force a blueprint onto a specific node, overriding its selector.
|
||||
{!canManage && ' Pin changes require an administrator.'}
|
||||
{!canManageAnyNode && ' You do not have permission to change pin placement.'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
@@ -188,7 +189,7 @@ export function FederationTab({ canManage }: FederationTabProps) {
|
||||
{describeSelector(bp.selector)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 align-top">
|
||||
{canManage ? (
|
||||
{canManageAnyNode ? (
|
||||
<Select
|
||||
value={bp.pinned_node_id !== null ? String(bp.pinned_node_id) : UNPINNED}
|
||||
onValueChange={(value) => void handlePinChange(bp.id, value)}
|
||||
@@ -198,9 +199,13 @@ export function FederationTab({ canManage }: FederationTabProps) {
|
||||
<SelectValue placeholder="(unpinned)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNPINNED}>(unpinned)</SelectItem>
|
||||
<SelectItem value={UNPINNED} disabled={!canManage}>(unpinned)</SelectItem>
|
||||
{nodes.map(node => (
|
||||
<SelectItem key={node.id} value={String(node.id)}>
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
value={String(node.id)}
|
||||
disabled={!canManage && !canManageNode?.(node.id)}
|
||||
>
|
||||
{node.name}
|
||||
{node.cordoned ? ' · cordoned' : ''}
|
||||
</SelectItem>
|
||||
|
||||
@@ -2,12 +2,14 @@ import type { FleetNode } from '@/components/FleetView/types';
|
||||
import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
|
||||
import { BulkLabelAssignCard } from './cards/BulkLabelAssignCard';
|
||||
import { FleetPruneCard } from './cards/FleetPruneCard';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
interface Props {
|
||||
nodes: FleetNode[];
|
||||
}
|
||||
|
||||
export function FleetActionsTab({ nodes }: Props) {
|
||||
const { isAdmin } = useAuth();
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-stat-subtitle">Add a node to the fleet to use bulk actions.</div>
|
||||
@@ -22,7 +24,7 @@ export function FleetActionsTab({ nodes }: Props) {
|
||||
// Order: Prune top-left, Bulk top-right, Stop on row 2.
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px] auto-rows-fr">
|
||||
<FleetPruneCard nodes={nodes} />
|
||||
{isAdmin && <FleetPruneCard nodes={nodes} />}
|
||||
<BulkLabelAssignCard nodes={nodes} />
|
||||
<LabelFleetStopCard />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => true }) }));
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import { type Label, type LabelColor, LABEL_COLORS } from '@/components/label-types';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string }
|
||||
interface AssignNodeResult {
|
||||
@@ -50,6 +51,7 @@ type PreviewNode = { nodeId: number; nodeName: string; willCreate: boolean; stac
|
||||
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
|
||||
|
||||
export function BulkLabelAssignCard({ nodes }: Props) {
|
||||
const { can } = useAuth();
|
||||
const [nodeData, setNodeData] = useState<NodeData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<LabelTemplate | null>(null);
|
||||
@@ -144,6 +146,10 @@ export function BulkLabelAssignCard({ nodes }: Props) {
|
||||
() => Array.from(selected.values()).filter(s => s.size > 0).length,
|
||||
[selected],
|
||||
);
|
||||
const canApplySelection = useMemo(() =>
|
||||
[...selected].every(([nodeId, stackNames]) =>
|
||||
[...stackNames].every(stackName => can('stack:edit', 'stack', stackName, nodeId))),
|
||||
[selected, can]);
|
||||
|
||||
function toggleStack(nodeId: number, stackName: string) {
|
||||
setSelected(prev => {
|
||||
@@ -180,7 +186,7 @@ export function BulkLabelAssignCard({ nodes }: Props) {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!selectedTemplate || totalSelected === 0) return;
|
||||
if (!selectedTemplate || totalSelected === 0 || !canApplySelection) return;
|
||||
const targets = Array.from(selected.entries())
|
||||
.filter(([, set]) => set.size > 0)
|
||||
.map(([nodeId, set]) => ({ nodeId, stackNames: Array.from(set) }));
|
||||
@@ -282,7 +288,7 @@ export function BulkLabelAssignCard({ nodes }: Props) {
|
||||
label: 'Apply',
|
||||
onClick: () => setConfirmOpen(true),
|
||||
variant: 'primary',
|
||||
disabled: running || !selectedTemplate || totalSelected === 0,
|
||||
disabled: running || !selectedTemplate || totalSelected === 0 || !canApplySelection,
|
||||
}}
|
||||
footerContext="Reversible · yes · reassign anytime"
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
fetchForNode: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => true }) }));
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string; dryRun?: boolean }
|
||||
interface FleetStopNodeResult {
|
||||
@@ -73,6 +74,7 @@ function isMatchPreviewResponse(value: unknown): value is MatchPreviewResponse {
|
||||
}
|
||||
|
||||
export function LabelFleetStopCard() {
|
||||
const { can } = useAuth();
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<FleetStopLabelSuggestion[]>([]);
|
||||
const [suggestUnreachable, setSuggestUnreachable] = useState(0);
|
||||
@@ -193,6 +195,8 @@ export function LabelFleetStopCard() {
|
||||
async function run(opts: { dryRun: boolean; targets?: { nodeId: number; stackNames: string[] }[] }) {
|
||||
const trimmed = labelName.trim();
|
||||
if (!trimmed) return;
|
||||
if (opts.targets?.some(target => target.stackNames.some(stackName =>
|
||||
!can('stack:deploy', 'stack', stackName, target.nodeId)))) return;
|
||||
const verb = opts.dryRun ? 'Dry-running' : 'Stopping';
|
||||
const toastId = toast.loading(`${verb} stacks with the stack label "${trimmed}" across the fleet…`);
|
||||
setRunning(true);
|
||||
@@ -303,7 +307,11 @@ export function LabelFleetStopCard() {
|
||||
// The real Stop is enabled only once the blast radius is resolved to at least
|
||||
// one stack, and never while a run is in flight. Loading/unavailable previews,
|
||||
// 0-match results, and an invalidated dry-run snapshot all leave it disabled.
|
||||
const canStopFleet = !running && resolvedTargets !== null && resolvedTargets.some(t => t.stackNames.length > 0);
|
||||
const canStopFleet = !running
|
||||
&& resolvedTargets !== null
|
||||
&& resolvedTargets.some(t => t.stackNames.length > 0)
|
||||
&& resolvedTargets.every(target => target.stackNames.every(stackName =>
|
||||
can('stack:deploy', 'stack', stackName, target.nodeId)));
|
||||
|
||||
const previewSection = renderPreviewSection(preview, trimmed);
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* Render-gate coverage for MeshOptInSheet's opt-in/out controls.
|
||||
*
|
||||
* Opting a stack in or out is admin-only on the backend
|
||||
* (POST /api/mesh/nodes/:id/stacks/:stack/opt-in|opt-out require admin). This
|
||||
* test locks the matching UI gate: a manager sees Add/Remove buttons, a
|
||||
* Opting a stack in or out is Admin-only because it cascades across mesh stacks.
|
||||
* This test locks the matching UI gate: an Admin sees Add/Remove buttons, while a user without
|
||||
* non-manager sees the membership read-only with a hint. The read-only branch
|
||||
* must never issue the admin-only mutation.
|
||||
* must never issue the mutation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
@@ -51,7 +50,7 @@ describe('MeshOptInSheet canManage gate', () => {
|
||||
expect(await screen.findByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Remove from mesh/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Add to mesh/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Changing mesh membership requires an administrator/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/changing mesh membership requires an administrator/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the membership read-only for a non-manager', async () => {
|
||||
@@ -59,7 +58,7 @@ describe('MeshOptInSheet canManage gate', () => {
|
||||
expect(await screen.findByText('web')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Remove from mesh/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Add to mesh/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Changing mesh membership requires an administrator/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/changing mesh membership requires an administrator/i)).toBeInTheDocument();
|
||||
// The read-only branch must never issue an opt-in/opt-out request.
|
||||
expect(vi.mocked(apiFetch)).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('/opt-'),
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Props {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
onChanged: () => void;
|
||||
/** Opt-in/out is admin-only on the backend; non-admins see the list read-only. */
|
||||
/** Whether the user may start the mesh-wide membership cascade. */
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Render-gate coverage for the alias-detail Remove control and transport line.
|
||||
*
|
||||
* Removing a route opts its owning stack out of the mesh, an admin-only mutation
|
||||
* (POST /api/mesh/nodes/:id/stacks/:stack/opt-out requires admin). This locks the
|
||||
* Removing a route opts its owning stack out of the mesh, an Admin-only cascade.
|
||||
* This locks the
|
||||
* matching UI gate: a manager sees "Remove from mesh", a non-manager does not,
|
||||
* while the read-only route detail stays available to both. It also pins the
|
||||
* transport line to the node's actual transport so a proxy peer never reports a
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
alias: string | null;
|
||||
/** Removing a route opts its owning stack out of the mesh; admin-only, mirrors the backend gate. */
|
||||
/** Whether the user may start the mesh-wide opt-out cascade. */
|
||||
canManage: boolean;
|
||||
status: MeshNodeStatus[];
|
||||
aliases: MeshAlias[];
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
onTestUpstream: (alias: string) => Promise<void>;
|
||||
onChanged: () => void;
|
||||
canManage: boolean;
|
||||
canManageMembership?: boolean;
|
||||
}
|
||||
|
||||
const REVERSE_BRIDGE: Record<MeshNodeStatus['reverseCallbackStatus'], RoutingNodeCardMeta['reverseBridge']> = {
|
||||
@@ -60,7 +61,7 @@ function buildFooterContext(
|
||||
}
|
||||
|
||||
export function RoutingNodeCard({
|
||||
status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage,
|
||||
status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage, canManageMembership = canManage,
|
||||
}: Props) {
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [testingAlias, setTestingAlias] = useState<string | null>(null);
|
||||
@@ -200,6 +201,7 @@ export function RoutingNodeCard({
|
||||
footerContext={footerContext}
|
||||
offlineReason={status.reachableReason}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ function readStoredEdgeMode(): MeshGraphEdgeMode {
|
||||
}
|
||||
}
|
||||
|
||||
export function RoutingTab({ canManage }: { canManage: boolean }) {
|
||||
export function RoutingTab({ canManageNode, canManageMembership }: {
|
||||
canManageNode: (nodeId: number) => boolean;
|
||||
canManageMembership: boolean;
|
||||
}) {
|
||||
const [status, setStatus] = useState<MeshNodeStatus[]>([]);
|
||||
const [localDataPlane, setLocalDataPlane] = useState<MeshDataPlaneStatus | null>(null);
|
||||
const [aliases, setAliases] = useState<MeshAlias[]>([]);
|
||||
@@ -177,13 +180,14 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
|
||||
onShowAlias={(alias) => setRouteDetailAlias(alias)}
|
||||
onTestUpstream={testUpstream}
|
||||
onChanged={() => { void refresh(); }}
|
||||
canManage={canManage}
|
||||
canManage={canManageNode(s.nodeId)}
|
||||
canManageMembership={canManageMembership}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SheetsRoot
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
optInNode={optInNode} setOptInNode={setOptInNode}
|
||||
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
|
||||
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
|
||||
@@ -234,7 +238,8 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
|
||||
onShowAlias={(alias) => setRouteDetailAlias(alias)}
|
||||
onTestUpstream={testUpstream}
|
||||
onChanged={() => { void refresh(); }}
|
||||
canManage={canManage}
|
||||
canManage={canManageNode(s.nodeId)}
|
||||
canManageMembership={canManageMembership}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -247,7 +252,7 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
|
||||
/>
|
||||
)}
|
||||
<SheetsRoot
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
optInNode={optInNode} setOptInNode={setOptInNode}
|
||||
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
|
||||
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
|
||||
@@ -287,7 +292,7 @@ function RoutingMasthead({ meshedNodes, reachableNodes, totalAliases, onShowActi
|
||||
}
|
||||
|
||||
function SheetsRoot(props: {
|
||||
canManage: boolean;
|
||||
canManageMembership: boolean;
|
||||
optInNode: { id: number; name: string } | null;
|
||||
setOptInNode: (v: { id: number; name: string } | null) => void;
|
||||
diagnosticsNode: { id: number; name: string } | null;
|
||||
@@ -313,7 +318,7 @@ function SheetsRoot(props: {
|
||||
onOpenChange={(open) => { if (!open) props.setOptInNode(null); }}
|
||||
nodeId={optInNode.id}
|
||||
nodeName={optInNode.name}
|
||||
canManage={props.canManage}
|
||||
canManage={props.canManageMembership}
|
||||
onChanged={props.onChanged}
|
||||
/>
|
||||
)}
|
||||
@@ -328,7 +333,7 @@ function SheetsRoot(props: {
|
||||
open={!!props.routeDetailAlias}
|
||||
onOpenChange={(open) => { if (!open) props.setRouteDetailAlias(null); }}
|
||||
alias={props.routeDetailAlias}
|
||||
canManage={props.canManage}
|
||||
canManage={props.canManageMembership}
|
||||
status={props.status}
|
||||
aliases={props.aliases}
|
||||
onChanged={props.onChanged}
|
||||
|
||||
@@ -57,7 +57,8 @@ const EMPTY_FORM: PolicyFormState = {
|
||||
* mirroring how the rest of the fleet-governance UI behaves.
|
||||
*/
|
||||
export function ScanPolicyManager() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canManagePolicies = can('stack:edit');
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const { status: trivy, refresh: refreshTrivy } = useTrivyStatus();
|
||||
@@ -264,7 +265,7 @@ export function ScanPolicyManager() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Deploy enforcement policies</h3>
|
||||
{isAdmin && !isRemote && !isReplica && (
|
||||
{canManagePolicies && !isRemote && !isReplica && (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add policy
|
||||
@@ -387,7 +388,7 @@ export function ScanPolicyManager() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && !isReplica && (
|
||||
{canManagePolicies && !isReplica && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -28,7 +28,7 @@ function jsonResponse(status: number, body: unknown): Response {
|
||||
}
|
||||
|
||||
function setup() {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true, can: () => true } as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({
|
||||
status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, preDeployScanAdvisory: false, cveIntelEnabled: true, busy: false },
|
||||
|
||||
@@ -35,7 +35,8 @@ interface MisconfigAckPanelProps {
|
||||
}
|
||||
|
||||
export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can } = useAuth();
|
||||
const canManage = can('stack:edit');
|
||||
const [rows, setRows] = useState<MisconfigAcknowledgement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -179,7 +180,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
title="Misconfig acknowledgements"
|
||||
subtitle="Accept known-benign misconfigurations so they stop triggering alerts. Acknowledgements apply at read time across the fleet and never modify stored scan data."
|
||||
action={
|
||||
isAdmin && !isReplica ? (
|
||||
canManage && !isReplica ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add acknowledgement
|
||||
@@ -255,7 +256,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
by {row.created_by} · expires {formatExpiry(row)}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
|
||||
{canManage && !isReplica && row.replicated_from_control === 0 && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -52,7 +52,9 @@ interface SuppressionsPanelProps {
|
||||
}
|
||||
|
||||
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can } = useAuth();
|
||||
const canRead = can('stack:read');
|
||||
const canManage = can('stack:edit');
|
||||
const [rows, setRows] = useState<CveSuppression[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -228,16 +230,20 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
title="CVE suppressions"
|
||||
subtitle="Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across the fleet and never modify stored scan data."
|
||||
action={
|
||||
isAdmin && !isReplica ? (
|
||||
!isReplica && (canRead || canManage) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleExportVex}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
Export VEX
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add suppression
|
||||
</Button>
|
||||
{canRead && (
|
||||
<Button size="sm" variant="outline" onClick={handleExportVex}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
Export VEX
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add suppression
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -325,7 +331,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
by {row.created_by} - expires {formatExpiry(row)}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
|
||||
{canManage && !isReplica && row.replicated_from_control === 0 && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
useAuth: () => ({ isAdmin: true, can: () => true }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
useAuth: () => ({ isAdmin: true, can: () => true }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface RoutingNodeCardProps {
|
||||
* management view it did not intend.
|
||||
*/
|
||||
canManage: boolean;
|
||||
/** Whether mesh membership actions may start their fleet-wide cascade. */
|
||||
canManageMembership?: boolean;
|
||||
}
|
||||
|
||||
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
|
||||
@@ -89,6 +91,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
|
||||
onAddStack, onRetry, footerContext, offlineReason,
|
||||
canManage,
|
||||
} = props;
|
||||
const canManageMembership = props.canManageMembership ?? canManage;
|
||||
const [density] = useDensity();
|
||||
const compact = density === 'compact';
|
||||
|
||||
@@ -120,6 +123,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onShowDiagnostics={onShowDiagnostics}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
footerContext={footerContext}
|
||||
onAddStack={onAddStack}
|
||||
onRetry={onRetry}
|
||||
@@ -137,6 +141,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onShowDiagnostics={onShowDiagnostics}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
aliases={aliases}
|
||||
onShowAlias={onShowAlias}
|
||||
onTestAlias={onTestAlias}
|
||||
@@ -161,6 +166,7 @@ interface BodyChrome {
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
onShowDiagnostics: () => void;
|
||||
canManage: boolean;
|
||||
canManageMembership: boolean;
|
||||
footerContext: string;
|
||||
onAddStack?: () => void;
|
||||
onRetry?: () => void;
|
||||
@@ -179,7 +185,7 @@ function ComfortableBody(props: ComfortableProps) {
|
||||
crumb, name, isLocal, chip, meta, nodeState, isEnabled,
|
||||
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
|
||||
aliases, onShowAlias, onTestAlias, onAddStack, onRetry, footerContext, offlineReason,
|
||||
canManage,
|
||||
canManage, canManageMembership,
|
||||
} = props;
|
||||
const published = aliases.filter((a) => a.kind === 'alias').length;
|
||||
const showAliases = aliases.length > 0;
|
||||
@@ -228,7 +234,7 @@ function ComfortableBody(props: ComfortableProps) {
|
||||
onShowAlias={onShowAlias}
|
||||
onTestAlias={onTestAlias}
|
||||
onAddStack={onAddStack}
|
||||
canManage={canManage}
|
||||
canManage={canManageMembership}
|
||||
/>
|
||||
: <EmptyState
|
||||
nodeState={nodeState}
|
||||
@@ -238,6 +244,7 @@ function ComfortableBody(props: ComfortableProps) {
|
||||
onRetry={onRetry}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
@@ -250,7 +257,7 @@ function CompactBody(props: BodyChrome) {
|
||||
const {
|
||||
name, isLocal, chip, meta, nodeState, isEnabled,
|
||||
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
|
||||
footerContext, onAddStack, onRetry, canManage,
|
||||
footerContext, onAddStack, onRetry, canManage, canManageMembership,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -314,6 +321,7 @@ function CompactBody(props: BodyChrome) {
|
||||
onRetry={onRetry}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
canManage={canManage}
|
||||
canManageMembership={canManageMembership}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -478,14 +486,15 @@ interface EmptyStateProps {
|
||||
onRetry?: () => void;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
canManage: boolean;
|
||||
canManageMembership: boolean;
|
||||
}
|
||||
|
||||
function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled, canManage }: EmptyStateProps) {
|
||||
function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled, canManage, canManageMembership }: EmptyStateProps) {
|
||||
const { headline, sub, cta } = emptyStateCopy(nodeState, name, offlineReason);
|
||||
// The idle and meshed CTAs (enable mesh, add stack) are management actions
|
||||
// the backend gates on the admin role, so a non-admin viewer sees a hint
|
||||
// instead. The degraded/offline retry is a read-only refresh and stays.
|
||||
// Enabling a node uses node:manage. Adding a stack starts an Admin-only
|
||||
// mesh membership cascade. The degraded/offline retry stays available.
|
||||
const isManagementState = nodeState === 'idle' || nodeState === 'meshed';
|
||||
const canManageState = nodeState === 'meshed' ? canManageMembership : canManage;
|
||||
// `connecting` is transient while the mesh bridge dials; show the headline
|
||||
// only, with no retry button (it clears on its own once the bridge is up).
|
||||
const isConnecting = nodeState === 'connecting';
|
||||
@@ -507,7 +516,7 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog
|
||||
</Button>
|
||||
);
|
||||
if (isConnecting) action = null;
|
||||
else if (!canManage && isManagementState) {
|
||||
else if (!canManageState && isManagementState) {
|
||||
action = (
|
||||
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
|
||||
Managing the mesh requires an administrator.
|
||||
@@ -546,13 +555,12 @@ function ctaToneFor(state: RoutingNodeState): string {
|
||||
return CTA_TONE[state];
|
||||
}
|
||||
|
||||
// Management CTAs (enable mesh / add stack) need admin; the read-only retry on a
|
||||
// degraded/offline node stays available to everyone. `meshed` always offers "add
|
||||
// stack" to admins so a node with aliases is never a dead end, and the transient
|
||||
// `connecting` state shows no CTA.
|
||||
function shouldShowCta(state: RoutingNodeState, canManage: boolean): boolean {
|
||||
// Enabling mesh uses node management, while adding a stack starts an Admin-only
|
||||
// membership cascade. Read-only retry stays available to everyone.
|
||||
function shouldShowCta(state: RoutingNodeState, canManage: boolean, canManageMembership: boolean): boolean {
|
||||
if (state === 'connecting') return false;
|
||||
if (state === 'idle' || state === 'meshed') return canManage;
|
||||
if (state === 'idle') return canManage;
|
||||
if (state === 'meshed') return canManageMembership;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -615,10 +623,11 @@ interface CompactFooterProps {
|
||||
onRetry?: () => void;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
canManage: boolean;
|
||||
canManageMembership: boolean;
|
||||
}
|
||||
|
||||
function CompactFooter({ context, nodeState, name, onAddStack, onRetry, onToggleEnabled, canManage }: CompactFooterProps) {
|
||||
const showCta = shouldShowCta(nodeState, canManage);
|
||||
function CompactFooter({ context, nodeState, name, onAddStack, onRetry, onToggleEnabled, canManage, canManageMembership }: CompactFooterProps) {
|
||||
const showCta = shouldShowCta(nodeState, canManage, canManageMembership);
|
||||
const { cta } = emptyStateCopy(nodeState, name);
|
||||
const handleClick = () => {
|
||||
if (nodeState === 'idle') onToggleEnabled(true);
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('reachability', () => {
|
||||
it('hides fleet without node:read when ready', () => {
|
||||
const noFleet = ctx({ can: () => false });
|
||||
expect(isViewHidden('fleet', noFleet)).toBe(true);
|
||||
expect(isViewHidden('networking', noFleet)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves host-console when authz is not ready', () => {
|
||||
|
||||
@@ -42,6 +42,7 @@ export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolea
|
||||
if (!ctx.isAdmin && view === 'global-observability') return true;
|
||||
if (!ctx.isAdmin && (view === 'auto-updates' || view === 'scheduled-ops')) return true;
|
||||
if (!ctx.can('node:read') && view === 'fleet') return true;
|
||||
if (!ctx.can('node:read') && view === 'networking') return true;
|
||||
if (view === 'host-console') {
|
||||
return !ctx.can('system:console');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user