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

This commit is contained in:
Anso
2026-07-29 22:02:53 -04:00
committed by GitHub
parent a65bf4d46a
commit c704cb54d2
68 changed files with 708 additions and 232 deletions
+13 -13
View File
@@ -132,7 +132,7 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
expect(res.body.pinned_node_id).toBe(node.id);
});
it('rejects a non-admin on a paid license with ADMIN_REQUIRED', async () => {
it('rejects a user without node:manage on a paid license', async () => {
const node = seedNode();
const bp = seedBlueprint([node.id]);
@@ -142,26 +142,26 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
.send({ nodeId: node.id });
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
expect(res.body.code).toBe('PERMISSION_DENIED');
});
});
describe('Blueprint mutation routes require admin role', () => {
describe('Blueprint mutation routes require their operational permissions', () => {
// The gate short-circuits before id parsing, so dummy ids are sufficient
// to prove the role boundary.
const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string }> = [
{ name: 'create', method: 'post', path: '/api/blueprints' },
{ name: 'update', method: 'put', path: '/api/blueprints/1' },
{ name: 'delete', method: 'delete', path: '/api/blueprints/1' },
{ name: 'apply', method: 'post', path: '/api/blueprints/1/apply' },
{ name: 'withdraw', method: 'post', path: '/api/blueprints/1/withdraw/1' },
{ name: 'accept', method: 'post', path: '/api/blueprints/1/accept/1' },
const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string; status: number }> = [
{ name: 'create', method: 'post', path: '/api/blueprints', status: 403 },
{ name: 'update', method: 'put', path: '/api/blueprints/1', status: 403 },
{ name: 'delete', method: 'delete', path: '/api/blueprints/1', status: 403 },
{ name: 'apply', method: 'post', path: '/api/blueprints/1/apply', status: 403 },
{ name: 'withdraw', method: 'post', path: '/api/blueprints/1/withdraw/1', status: 404 },
{ name: 'accept', method: 'post', path: '/api/blueprints/1/accept/1', status: 400 },
];
it.each(mutations)('rejects a non-admin on $name with ADMIN_REQUIRED', async ({ method, path }) => {
it.each(mutations)('does not let a viewer perform $name', async ({ method, path, status }) => {
const res = await request(app)[method](path).set('Cookie', viewerCookie).send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
expect(res.status).toBe(status);
if (status === 403) expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('lets a Community admin create when the body is valid (not PAID_REQUIRED)', async () => {
@@ -126,13 +126,13 @@ describe('Blueprints on Community tier', () => {
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('rejects blueprint mutations for a viewer with ADMIN_REQUIRED', async () => {
it('rejects blueprint mutations for a viewer without stack:create', async () => {
const res = await request(app)
.post('/api/blueprints')
.set('Authorization', viewerAuthHeader)
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('lets a Community viewer list blueprints', async () => {
@@ -21,6 +21,7 @@ let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSION
const VIEWER = 'container-read-viewer';
const READ_PATHS = ['/api/containers', '/api/containers/abc123/logs', '/api/ports/in-use'];
const MUTATION_PATHS = ['/api/containers/abc123/start', '/api/containers/abc123/stop', '/api/containers/abc123/restart'];
/** Sign a viewer JWT using the live token_version so authMiddleware accepts it. */
function viewerToken(): string {
@@ -120,3 +121,13 @@ describe('container/ports reads reject unauthenticated requests', () => {
expect(res.status).toBe(401);
});
});
describe('generic container-id mutations remain Admin-only', () => {
it.each(MUTATION_PATHS)('POST %s rejects a non-admin before Docker work', async (path) => {
const { docker } = stubDockerAndFs();
const res = await request(app).post(path).set('Authorization', `Bearer ${viewerToken()}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
expect(docker).not.toHaveBeenCalled();
});
});
@@ -313,14 +313,14 @@ describe('GET /api/fleet/labels/suggestions', () => {
expect(res.status).toBe(401);
});
it('returns 403 for a non-admin (viewer) user', async () => {
it('allows a viewer with node:read to load suggestions', async () => {
const viewerName = `viewer-sugg-${++labelCounter}`;
db.addUser({ username: viewerName, password_hash: 'x', role: 'viewer' });
const viewerAuth = `Bearer ${jwt.sign({ username: viewerName }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
const res = await request(app)
.get('/api/fleet/labels/suggestions')
.set('Authorization', viewerAuth);
expect(res.status).toBe(403);
expect(res.status).toBe(200);
});
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
+74 -1
View File
@@ -10,22 +10,41 @@ import fs from 'fs';
import path from 'path';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let scopedAuthHeader: string;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let scopedUserId: number;
let defaultNodeId: number;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
({ DatabaseService } = await import('../services/DatabaseService'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
const db = DatabaseService.getInstance();
defaultNodeId = db.getDefaultNode()?.id ?? 1;
const hash = await bcrypt.hash('password123', 1);
scopedUserId = db.addUser({ username: 'fleet-scoped-operator', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({ user_id: scopedUserId, role: 'node-admin', resource_type: 'stack', resource_id: 'allowed-edit', node_id: defaultNodeId });
db.addRoleAssignment({ user_id: scopedUserId, role: 'deployer', resource_type: 'stack', resource_id: 'allowed-deploy', node_id: defaultNodeId });
const scopedUser = db.getUserByUsername('fleet-scoped-operator')!;
scopedAuthHeader = `Bearer ${jwt.sign({ username: scopedUser.username, role: scopedUser.role, tv: scopedUser.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
afterAll(() => {
const db = DatabaseService.getInstance();
db.deleteRoleAssignmentsByUser(scopedUserId);
db.deleteUser(scopedUserId);
cleanupTestDb(tmpDir);
});
function mockTier(tier: 'paid' | 'community') {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
@@ -237,6 +256,60 @@ describe('Fleet Actions orchestration shape', () => {
});
});
describe('Fleet Actions authorize every target before mutation', () => {
afterEach(() => vi.restoreAllMocks());
it('rejects fleet stop when any confirmed stack lacks stack:deploy', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', scopedAuthHeader)
.send({
labelName: 'prod',
targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-deploy', 'denied-deploy'] }],
});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('does not create a label when bulk assign contains a denied stack', async () => {
mockTier('paid');
const labelName = 'atomic-bulk-denied';
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', scopedAuthHeader)
.send({
label: { name: labelName, color: 'teal' },
targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-edit', 'denied-edit'] }],
});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false);
});
it('does not create a label when a local receiver target is denied', async () => {
mockTier('paid');
const labelName = 'atomic-local-denied';
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', scopedAuthHeader)
.send({ label: { name: labelName, color: 'teal' }, stackNames: ['allowed-edit', 'denied-edit'] });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false);
});
it('rejects local stop before work when any confirmed stack is denied', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', scopedAuthHeader)
.send({ labelName: 'prod', stackNames: ['allowed-deploy', 'denied-deploy'] });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
});
// The per-node local-stop receiver is what a control instance calls on each
// remote during a fleet-wide stop. It must be reachable on every license (only
// admin-gated): the original fleet-stop fan-out hit the paid /api/labels/:id/action
@@ -1,9 +1,9 @@
/**
* Gate coverage for the mesh router.
*
* Every /api/mesh route is tier-gated (requirePaid). The five operator
* mutations are additionally role-gated (requireAdmin): node enable/disable,
* stack opt-in/opt-out, and the override regen. The operator read routes
* Every /api/mesh route is tier-gated (requirePaid). Node enable/disable and
* override regeneration are permission-gated. Stack membership remains Admin-only
* because a membership change redeploys every affected mesh stack. The read routes
* (status, aliases, activity, diagnostics) stay reachable for any paid-tier
* user regardless of role, which is what lets a non-admin see a read-only
* Routing tab. The node-to-node routes that central calls over the proxy on the
@@ -103,17 +103,29 @@ describe('mesh read routes are visible to a non-admin paid user', () => {
});
});
describe('mesh mutation routes require the admin role (requireAdmin)', () => {
const mutationRoutes: { name: string; path: () => string }[] = [
describe('mesh mutation authorization', () => {
const permissionRoutes: { name: string; path: () => string }[] = [
{ name: 'POST /regen-overrides', path: () => '/api/mesh/regen-overrides' },
{ name: 'POST /nodes/:id/enable', path: () => `/api/mesh/nodes/${defaultNodeId}/enable` },
{ name: 'POST /nodes/:id/disable', path: () => `/api/mesh/nodes/${defaultNodeId}/disable` },
];
const adminRoutes: { name: string; path: () => string }[] = [
{ name: 'POST /nodes/:id/stacks/:stack/opt-in', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-in` },
{ name: 'POST /nodes/:id/stacks/:stack/opt-out', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-out` },
];
for (const route of mutationRoutes) {
it(`${route.name} rejects a non-admin paid user with ADMIN_REQUIRED`, async () => {
for (const route of permissionRoutes) {
it(`${route.name} rejects a paid user without the required operational permission`, async () => {
const res = await request(app)
.post(route.path())
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
}
for (const route of adminRoutes) {
it(`${route.name} remains Admin-only`, async () => {
const res = await request(app)
.post(route.path())
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
import { classifyStackApiPath } from '../helpers/stackRouteAuth';
describe('operational role matrix', () => {
const expectations: Record<string, { allow: PermissionAction[]; deny: PermissionAction[] }> = {
admin: {
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
deny: [],
},
'node-admin': {
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'],
deny: ['system:settings', 'system:users', 'system:license', 'system:registries'],
},
deployer: {
allow: ['stack:read', 'stack:deploy'],
deny: ['stack:edit', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
},
viewer: {
allow: ['stack:read', 'node:read'],
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:audit'],
},
auditor: {
allow: ['stack:read', 'node:read', 'system:audit'],
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:settings'],
},
};
for (const [role, expected] of Object.entries(expectations)) {
it(`${role} exposes only its intended operational actions`, () => {
const actions = ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS];
for (const action of expected.allow) expect(actions).toContain(action);
for (const action of expected.deny) expect(actions).not.toContain(action);
});
}
});
describe('named stack route permission inventory', () => {
const routes: Array<[string, string, PermissionAction]> = [
['GET', '/stacks/web', 'stack:read'],
['GET', '/stacks/web/env', 'stack:read'],
['GET', '/stacks/web/services', 'stack:read'],
['GET', '/stacks/web/update-preview', 'stack:read'],
['GET', '/stacks/web/files/content', 'stack:read'],
['PUT', '/stacks/web', 'stack:edit'],
['PUT', '/stacks/web/env', 'stack:edit'],
['PUT', '/stacks/web/dossier', 'stack:edit'],
['PUT', '/stacks/web/labels', 'stack:edit'],
['POST', '/stacks/web/deploy', 'stack:deploy'],
['POST', '/stacks/web/stop', 'stack:deploy'],
['POST', '/stacks/web/services/api/update', 'stack:deploy'],
['POST', '/stacks/web/rollback', 'stack:deploy'],
['DELETE', '/stacks/web', 'stack:delete'],
];
for (const [method, path, action] of routes) {
it(`${method} ${path} requires ${action}`, () => {
expect(classifyStackApiPath(method, path)).toEqual({
kind: 'named-stack',
stackName: 'web',
action,
});
});
}
});
@@ -141,6 +141,24 @@ describe('checkPermission with node-scoped stack grants', () => {
);
for (const a of assignments) db.deleteRoleAssignment(a.id!);
});
it('uses an explicit target node for hub-orchestrated stack checks', () => {
const db = DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'deployer',
resource_type: 'stack',
resource_id: 'remote-stack',
node_id: otherNodeId,
});
const hubRequest = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId });
expect(checkPermission(hubRequest, 'stack:deploy', 'stack', 'remote-stack')).toBe(false);
expect(checkPermission(hubRequest, 'stack:deploy', 'stack', 'remote-stack', otherNodeId)).toBe(true);
expect(checkPermission(hubRequest, 'stack:edit', 'stack', 'remote-stack', otherNodeId)).toBe(false);
db.deleteRoleAssignmentsByStack(otherNodeId, 'remote-stack');
});
});
describe('scopedActionsForStack with node-scoped grants', () => {
@@ -459,9 +459,9 @@ describe('GET /api/security/vex/export (Community)', () => {
expect(res.status).toBe(200);
});
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
it('lets a viewer with stack:read export VEX', async () => {
const res = await request(app).get('/api/security/vex/export').set('Cookie', viewerCookie);
expect(res.status).toBe(403);
expect(res.status).toBe(200);
});
it('exports an OpenVEX document from triage decisions', async () => {
@@ -1,5 +1,5 @@
/**
* Both scan-export endpoints are available on every tier (admin only, no tier gate):
* Both scan-export endpoints are available on every tier with stack:read:
* POST /api/security/sbom -> per-image SBOM artifact
* GET /api/security/scans/:id/sarif -> SARIF for CI / code-scanning ingestion
*/
@@ -55,13 +55,16 @@ describe('POST /api/security/sbom (Community)', () => {
expect(res.headers['content-disposition']).toContain('nginx_latest.cdx.json');
});
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
it('lets a Community viewer generate an SBOM with stack:read', async () => {
mockTier('community');
const svc = TrivyService.getInstance();
vi.spyOn(svc, 'isTrivyAvailable').mockReturnValue(true);
vi.spyOn(svc, 'generateSBOM').mockResolvedValue('{"bomFormat":"CycloneDX"}');
const res = await request(app)
.post('/api/security/sbom')
.set('Cookie', viewerCookie)
.send({ imageRef: 'nginx:latest', format: 'cyclonedx' });
expect(res.status).toBe(403);
expect(res.status).toBe(200);
});
});
@@ -77,11 +80,11 @@ describe('GET /api/security/scans/:scanId/sarif (Community)', () => {
expect(res.status).toBe(404);
});
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
it('lets a Community viewer reach SARIF export with stack:read', async () => {
mockTier('community');
const res = await request(app)
.get('/api/security/scans/999999/sarif')
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
expect(res.status).toBe(404);
});
});
@@ -114,13 +114,13 @@ describe('POST /api/security/suppressions', () => {
expect(res.status).toBe(401);
});
it('rejects non-admin users with 403', async () => {
it('rejects users without stack:edit with 403', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', viewerAuthHeader)
.send(validBody);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('is accessible on community tier (admin still required)', async () => {
+8 -4
View File
@@ -84,6 +84,7 @@ export function checkPermission(
action: PermissionAction,
resourceType?: ResourceType,
resourceId?: string,
resourceNodeId?: number | null,
): boolean {
if (!req.user) return false;
@@ -113,7 +114,9 @@ export function checkPermission(
if (effectiveTier(req) !== 'paid') return false;
const db = DatabaseService.getInstance();
const nodeId = resourceType === 'stack' ? req.nodeId : null;
const nodeId = resourceType === 'stack'
? (resourceNodeId === undefined ? req.nodeId : resourceNodeId)
: null;
const assignments = db.getRoleAssignments(
req.user.userId,
resourceType,
@@ -127,11 +130,11 @@ export function checkPermission(
// Node-scoped grants are node-wide: a Node Admin / Deployer / Admin on
// node N authorizes that role's stack actions for every stack on N.
if (resourceType === 'stack' && req.nodeId != null) {
if (resourceType === 'stack' && nodeId != null) {
const nodeAssignments = db.getRoleAssignments(
req.user.userId,
'node',
String(req.nodeId),
String(nodeId),
);
for (const assignment of nodeAssignments) {
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
@@ -148,8 +151,9 @@ export function requirePermission(
action: PermissionAction,
resourceType?: ResourceType,
resourceId?: string,
resourceNodeId?: number | null,
): boolean {
if (checkPermission(req, action, resourceType, resourceId)) return true;
if (checkPermission(req, action, resourceType, resourceId, resourceNodeId)) return true;
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return false;
}
@@ -0,0 +1,52 @@
# Operational permission inventory
This inventory is the authority for ordinary operational API authorization. A
route marked `exact` must include the target resource identity in the permission
check. Bulk routes must authorize every valid target before starting any work.
| Route family | Read | Execute | Edit | Create | Delete | Scope |
| --- | --- | --- | --- | --- | --- | --- |
| `/api/stacks/:stackName` and named subroutes | `stack:read` | `stack:deploy` | `stack:edit` | n/a | `stack:delete` | exact stack and request node |
| `/api/stacks/bulk` | n/a | `stack:deploy` | n/a | n/a | n/a | every exact stack before execution |
| `/api/containers`, logs, and `/api/ports/in-use` | `stack:read` | n/a | n/a | n/a | n/a | global read |
| `/api/containers/:containerId/start|stop|restart` | n/a | Admin | n/a | n/a | n/a | arbitrary container IDs can include unmanaged or Sencho containers |
| `/api/volumes/browse/*` | `stack:read` | n/a | n/a | n/a | n/a | global read |
| `/api/templates` | `stack:read` | n/a | n/a | n/a | n/a | global read |
| `/api/templates/:id/deploy` | n/a | `stack:deploy` | n/a | `stack:create` | n/a | global create and deploy |
| `/api/blueprints` | `node:read` | n/a | `stack:edit` | `stack:create` | `stack:delete` | global blueprint definition |
| Blueprint apply | n/a | `stack:deploy` | n/a | `stack:create` | n/a | global create and deploy |
| Blueprint accept or withdraw | n/a | exact `stack:deploy` | n/a | n/a | exact `stack:delete` | blueprint stack name and target node |
| Blueprint pin | n/a | n/a | exact `node:manage` | n/a | n/a | target node; unpin uses global node manage |
| `/api/nodes`, labels, metadata, and scheduling reads | `node:read` | n/a | n/a | n/a | n/a | exact node when a node ID is present |
| Node metadata, labels, cordon, and mesh enablement | n/a | n/a | exact `node:manage` | n/a | n/a | target node |
| Dependency map and networking reads | `node:read` | n/a | n/a | n/a | n/a | global or exact node as exposed by the route |
| Fleet label suggestions and match preview | `node:read` | n/a | n/a | n/a | n/a | global fleet discovery |
| Fleet stop by confirmed labels | n/a | exact `stack:deploy` | n/a | n/a | n/a | every target stack and node before fanout |
| Fleet bulk label assignment | n/a | n/a | exact `stack:edit` | n/a | n/a | every target stack and node before fanout |
| `/api/labels/:id/action` | n/a | exact `stack:deploy` | n/a | n/a | n/a | every resolved stack before mutation |
| Mesh status, aliases, diagnostics, and activity | `node:read` | n/a | n/a | n/a | n/a | global read |
| Mesh stack and override reads | exact `stack:read` | n/a | n/a | n/a | n/a | target stack and node |
| Mesh local override writes | n/a | n/a | exact `stack:edit` | n/a | n/a | target stack and node |
| Mesh membership changes | n/a | Admin | n/a | n/a | n/a | membership changes cascade redeploys across mesh stacks |
| Security scans for an image or stack | n/a | `stack:deploy` | n/a | n/a | n/a | exact stack when named, otherwise global |
| Node-wide security scan | n/a | `node:manage` | n/a | n/a | n/a | global, including remote proxy parity |
| SBOM, SARIF, VEX, and predeploy security reports | `stack:read` | n/a | n/a | n/a | n/a | exact stack when named, otherwise global |
| Security policies, suppressions, and acknowledgements | `stack:read` | n/a | `stack:edit` | n/a | n/a | global collection |
| Docker resource inventory and orphan reads | `stack:read` | n/a | n/a | n/a | n/a | global read |
| Network topology and inspection | `node:read` | n/a | n/a | n/a | n/a | global read |
## Preserved system boundaries
Literal Admin or the existing `system:*` permission remains required for user,
license, credential, API token, recovery, self-update, and sensitive system
settings. Host-destructive Docker operations also remain Admin-only, including
image, volume, network, resource, and fleet pruning. Reset-anchor and mesh-wide
membership cascades remain Admin-only because their effects are broader than one
ordinary node or stack permission check can safely authorize.
## Frontend parity
Navigation and controls use `can()` with the same action and resource identity.
Exact stack checks pass the stack name and node ID. Exact node checks pass the
node ID. System-only controls continue to use the Admin or `system:*` gate. UI
visibility is advisory; every backend route in this inventory enforces its gate.
+12 -8
View File
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireBody } from '../middleware/tierGates';
import { requireBody } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import {
DatabaseService,
@@ -136,6 +136,7 @@ function summarizeBlueprint(blueprintId: number) {
}
blueprintsRouter.get('/', (req: Request, res: Response): void => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const blueprints = DatabaseService.getInstance().listBlueprints();
const summaries = blueprints.map(b => {
@@ -159,7 +160,7 @@ blueprintsRouter.get('/', (req: Request, res: Response): void => {
});
blueprintsRouter.post('/', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:create')) return;
if (!requireBody(req, res)) return;
const body = req.body as BlueprintBody;
const nameError = validateName(body.name);
@@ -198,6 +199,7 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => {
});
blueprintsRouter.get('/:id', (req: Request, res: Response): void => {
if (!requirePermission(req, res, 'node:read')) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
@@ -211,7 +213,7 @@ blueprintsRouter.get('/:id', (req: Request, res: Response): void => {
});
blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (!requireBody(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
@@ -293,7 +295,7 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
});
blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:delete')) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
@@ -461,7 +463,8 @@ blueprintsRouter.post('/withdraw-local', async (req: Request, res: Response): Pr
});
blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:create')) return;
if (!requirePermission(req, res, 'stack:deploy')) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
@@ -547,7 +550,6 @@ blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise
});
blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
const nodeId = parseIntParam(req, res, 'nodeId');
@@ -560,6 +562,7 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
if (!requirePermission(req, res, 'stack:delete', 'stack', blueprint.name, nodeId)) return;
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) { res.status(404).json({ error: 'Node not found' }); return; }
const isStateful = blueprint.classification === 'stateful' || blueprint.classification === 'unknown';
@@ -636,7 +639,6 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons
});
blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
const nodeId = parseIntParam(req, res, 'nodeId');
@@ -649,6 +651,7 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response)
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
if (!requirePermission(req, res, 'stack:deploy', 'stack', blueprint.name, nodeId)) return;
const guard = BlueprintReconciler.getInstance().validateGuardConfirmation(id, nodeId, 'accept');
if (!guard.ok) {
res.status(409).json({ error: guard.error, code: guard.code });
@@ -665,6 +668,7 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response)
});
blueprintsRouter.get('/:id/preview', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
@@ -678,7 +682,6 @@ blueprintsRouter.get('/:id/preview', async (req: Request, res: Response): Promis
});
blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
@@ -692,6 +695,7 @@ blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<vo
res.status(400).json({ error: 'nodeId must be a positive integer or null' });
return;
}
if (!requirePermission(req, res, 'node:manage', nodeId === null ? undefined : 'node', nodeId === null ? undefined : String(nodeId))) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
+1 -1
View File
@@ -2,8 +2,8 @@ import { Router, type Request, type Response } from 'express';
import DockerController from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import { excludeSelfContainers } from '../helpers/excludeSelfContainers';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { requireAdmin } from '../middleware/tierGates';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
export const containersRouter = Router();
+2
View File
@@ -1,5 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requirePermission } from '../middleware/permissions';
import { DatabaseService } from '../services/DatabaseService';
import { buildLocalGraph } from '../services/DependencyGraphService';
@@ -11,6 +12,7 @@ export const dependencyMapRouter = Router();
* remotes. Served against the local Docker of whichever node handles it.
*/
dependencyMapRouter.get('/node-graph', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const nodeId = req.nodeId;
const name = DatabaseService.getInstance().getNodes().find((n) => n.id === nodeId)?.name ?? 'This node';
+26 -8
View File
@@ -18,7 +18,7 @@ import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService';
import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { checkPermission, requirePermission } from '../middleware/permissions';
import { respondSelfUpdatePreflight } from './license';
import { ImageOperationService } from '../services/ImageOperationService';
import { classifyImageChannel } from '../helpers/imageChannel';
@@ -1888,9 +1888,9 @@ type FleetStopNodeResult = {
// on its own Docker via its local-stop receiver. Remote label rows are never
// mirrored to the control, so there is no central pre-check; unreachable nodes
// are reported at the node level and never block the reachable ones.
// Tier: requireAdmin (admin-only fleet plumbing; available on every license).
// Permission: every confirmed stack requires stack:deploy. Discovery-only dry
// runs require node:read.
fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
const body = req.body as { labelName?: unknown; dryRun?: unknown; targets?: unknown } | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'Request body is required' });
@@ -1934,6 +1934,19 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
}
const trimmed = labelName.trim();
const isDryRun = dryRun === true;
if (confirmedStacksByNode) {
const denied = [...confirmedStacksByNode].some(([nodeId, stackNames]) =>
[...stackNames].some(stackName =>
!checkPermission(req, 'stack:deploy', 'stack', stackName, nodeId)));
if (denied) {
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return;
}
} else if (isDryRun) {
if (!requirePermission(req, res, 'node:read')) return;
} else if (!requirePermission(req, res, 'stack:deploy')) {
return;
}
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
@@ -1967,7 +1980,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
}
}
// Remote node. Ask the remote authoritatively via its admin-only local-stop
// Remote node. Ask the remote authoritatively via its permission-checked local-stop
// receiver, which name-matches under the remote's own bulk lock. There is no
// control-side pre-check: remote label rows are never mirrored to the
// control, so a mirror lookup would skip every remote. A node we cannot
@@ -2087,9 +2100,8 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
// Per-node failures (unknown node, no proxy target, unreachable, mixed-version
// remote, malformed response) degrade that node only and never discard the rest
// of the fan-out.
// Tier: requireAdmin (admin-only fleet plumbing; available on every license).
// Permission: every target stack requires stack:edit.
fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
const body = req.body as { label?: unknown; targets?: unknown } | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'Request body is required' });
@@ -2136,6 +2148,12 @@ fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res
res.status(400).json({ error: `targets may not exceed ${MAX_ASSIGNMENTS} stack assignments` });
return;
}
const denied = targets.some(target => target.stackNames.some(stackName =>
!checkPermission(req, 'stack:edit', 'stack', stackName, target.nodeId)));
if (denied) {
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return;
}
const { template } = validated;
try {
const db = DatabaseService.getInstance();
@@ -2242,7 +2260,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
// uses these to distinguish "0 matching stacks" from "label exists but no
// stacks assigned" from "remote unavailable".
fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'node:read')) return;
const body = req.body as { labelName?: unknown } | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'Request body is required' });
@@ -2290,7 +2308,7 @@ fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, r
// them is a no-op). `unreachableNodes`/`partial` tell the card the counts cover
// only the nodes it could reach.
fleetRouter.get('/labels/suggestions', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'node:read')) return;
try {
const summaries = await collectFleetLabelSummaries();
const agg = new Map<string, { nodeCount: number; stackCount: number; nodes: string[] }>();
+32 -8
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireBody } from '../middleware/tierGates';
import { requireBody } from '../middleware/tierGates';
import { checkPermission, requirePermission, type PermissionAction } from '../middleware/permissions';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { MAX_ASSIGNMENTS } from '../helpers/constants';
@@ -14,10 +15,26 @@ import { runLocalLabelAssign, validateLabelTemplate, type LabelLocalAssignRespon
// because their path must sit behind the `/api/fleet/` proxy-exempt prefix.
export const fleetActionsRouter = Router();
function requireExactStacks(
req: Request,
res: Response,
action: PermissionAction,
stackNames: Iterable<string>,
nodeId: number,
): boolean {
for (const stackName of stackNames) {
if (!checkPermission(req, action, 'stack', stackName, nodeId)) {
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return false;
}
}
return true;
}
// Per-node label-matched stop. A control instance calls this on each remote
// node during a fleet-wide stop-by-label so the destructive work runs under the
// remote's own admin auth and per-node bulk lock. Admin-only and available on
// every license, matching the rest of the Fleet Actions surface. The paid
// remote's own auth and per-node bulk lock. Every confirmed stack requires
// stack:deploy. The paid
// label-driven action lives at `POST /api/labels/:id/action`; this receiver is
// the fleet-plumbing equivalent the control fans out to, so a fleet-stop on a
// Community fleet stops remote stacks instead of 403'ing on the remote leg.
@@ -25,7 +42,6 @@ fleetActionsRouter.post(
'/labels/local-stop',
authMiddleware,
async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const { labelName, dryRun, stackNames } = req.body as { labelName?: unknown; dryRun?: unknown; stackNames?: unknown };
if (typeof labelName !== 'string' || labelName.trim().length === 0) {
@@ -44,6 +60,12 @@ fleetActionsRouter.post(
allowedStacks = new Set(stackNames as string[]);
}
const nodeId = req.nodeId ?? 0;
if (allowedStacks) {
if (allowedStacks.size === 0 && !requirePermission(req, res, dryRun === true ? 'node:read' : 'stack:deploy')) return;
if (!requireExactStacks(req, res, 'stack:deploy', allowedStacks, nodeId)) return;
} else if (!requirePermission(req, res, dryRun === true ? 'node:read' : 'stack:deploy')) {
return;
}
const trimmedLabel = labelName.trim();
try {
const outcome = await runLocalLabelStop(nodeId, trimmedLabel, dryRun === true, allowedStacks);
@@ -60,15 +82,14 @@ fleetActionsRouter.post(
// Per-node label assign. A control instance calls this on each target node
// during a fleet-wide bulk label assign so the label is resolved or created
// under the node's own database, by name, and assigned to the given stacks while
// preserving their existing labels (add semantics). Admin-only and available on
// every license, matching the rest of the Fleet Actions surface. Labels are
// preserving their existing labels (add semantics). Every target stack requires
// stack:edit. Labels are
// node-local, so the control never reuses a local label id on a remote: the
// receiver owns label resolution for its own node.
fleetActionsRouter.post(
'/labels/local-assign',
authMiddleware,
async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const { label, stackNames } = req.body as { label?: unknown; stackNames?: unknown };
const validated = validateLabelTemplate(label);
@@ -85,8 +106,11 @@ fleetActionsRouter.post(
return;
}
const nodeId = req.nodeId ?? 0;
const uniqueStacks = new Set(stackNames as string[]);
if (uniqueStacks.size === 0 && !requirePermission(req, res, 'stack:edit')) return;
if (!requireExactStacks(req, res, 'stack:edit', uniqueStacks, nodeId)) return;
try {
const outcome = await runLocalLabelAssign(nodeId, validated.template, stackNames as string[]);
const outcome = await runLocalLabelAssign(nodeId, validated.template, [...uniqueStacks]);
if (isDebugEnabled()) console.debug('[FleetActions:debug] local-assign:', { nodeId, label: validated.template.name, created: outcome.created, stacks: outcome.stackResults.length });
const body: LabelLocalAssignResponse = { created: outcome.created, results: outcome.stackResults };
res.json(body);
+15 -8
View File
@@ -6,8 +6,8 @@ import { StackOpLockService, stackOpSkipMessage } from '../services/StackOpLockS
import DockerController from '../services/DockerController';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { authMiddleware } from '../middleware/auth';
import { requirePermission } from '../middleware/permissions';
import { requireAdmin, requireBody } from '../middleware/tierGates';
import { checkPermission, requirePermission } from '../middleware/permissions';
import { requireBody } from '../middleware/tierGates';
import { buildPolicyGateOptions, describePolicyBlock } from '../helpers/policyGate';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { VALID_LABEL_COLORS, MAX_LABELS_PER_NODE } from '../helpers/constants';
@@ -25,6 +25,7 @@ export { activeBulkActions };
export const labelsRouter = Router();
labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const nodeId = req.nodeId ?? 0;
const labels = DatabaseService.getInstance().getLabels(nodeId);
@@ -77,6 +78,7 @@ labelsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom
});
labelsRouter.get('/assignments', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const nodeId = req.nodeId ?? 0;
const db = DatabaseService.getInstance();
@@ -166,7 +168,6 @@ labelsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response):
});
labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'label ID');
@@ -187,6 +188,17 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
return;
}
const stackNames = DatabaseService.getInstance().getStacksForLabel(id, nodeId);
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
const fsStackNames = new Set(fsStacks);
const validStacks = stackNames.filter(name => fsStackNames.has(name));
const deniedStack = validStacks.find(stackName =>
!checkPermission(req, 'stack:deploy', 'stack', stackName, nodeId));
if (deniedStack) {
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return;
}
const lockKey = `bulk:${nodeId}`;
if (activeBulkActions.has(lockKey)) {
res.status(429).json({ error: 'A bulk action is already running for this node. Please wait.' });
@@ -195,11 +207,6 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
activeBulkActions.add(lockKey);
try {
const stackNames = DatabaseService.getInstance().getStacksForLabel(id, nodeId);
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
const fsStackNames = new Set(fsStacks);
const validStacks = stackNames.filter(name => fsStackNames.has(name));
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length, dryRun: isDryRun });
const results: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = [];
+18 -5
View File
@@ -3,6 +3,7 @@ import { DatabaseService } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
import { MeshError, MeshService, type MeshGlobalAlias, type MeshRegenSummary } from '../services/MeshService';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { sanitizeForLog } from '../utils/safeLog';
import { isValidStackName } from '../utils/validation';
@@ -15,6 +16,7 @@ function actorFor(req: Request): string {
meshRouter.get('/status', async (_req: Request, res: Response): Promise<void> => {
if (!requirePaid(_req, res)) return;
if (!requirePermission(_req, res, 'node:read')) return;
try {
const mesh = MeshService.getInstance();
const status = await mesh.getStatus();
@@ -34,7 +36,7 @@ meshRouter.get('/status', async (_req: Request, res: Response): Promise<void> =>
*/
meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'node:manage')) return;
const actor = actorFor(req);
let summary: MeshRegenSummary | null = null;
let outcome: 'success' | 'skipped' | 'partial' | 'error' = 'error';
@@ -68,9 +70,9 @@ meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise
meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return;
try {
await MeshService.getInstance().enableForNode(nodeId);
res.json({ ok: true });
@@ -81,9 +83,9 @@ meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Pr
meshRouter.post('/nodes/:nodeId/disable', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return;
try {
await MeshService.getInstance().disableForNode(nodeId, actorFor(req));
res.json({ ok: true });
@@ -103,6 +105,7 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response)
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
try {
const services = await MeshService.getInstance().inspectLocalStackServices(stackName);
res.json({ services });
@@ -121,6 +124,7 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response)
*/
meshRouter.get('/local-stacks', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requirePermission(req, res, 'stack:read')) return;
try {
const stacks = await MeshService.getInstance().listLocalStacks();
res.json({ stacks });
@@ -158,6 +162,7 @@ meshRouter.put('/local-override/:stackName', async (req: Request, res: Response)
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const body = req.body as { aliases?: unknown; portAliases?: unknown };
if (!Array.isArray(body?.aliases)) { res.status(400).json({ error: 'Missing aliases array in body' }); return; }
if (body.aliases.length > MAX_ALIASES_PER_PUSH) {
@@ -210,6 +215,7 @@ meshRouter.delete('/local-override/:stackName', async (req: Request, res: Respon
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
await MeshService.getInstance().removeLocalOverride(stackName);
res.json({ ok: true });
@@ -223,6 +229,7 @@ meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Pro
if (!requirePaid(req, res)) return;
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
if (!requirePermission(req, res, 'node:read', 'node', String(nodeId))) return;
try {
const db = DatabaseService.getInstance();
const optedIn = new Set(db.listMeshStacks(nodeId).map((s) => s.stack_name));
@@ -241,10 +248,10 @@ meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Pro
meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
const stackName = req.params.stackName as string;
if (!Number.isFinite(nodeId) || !stackName) { res.status(400).json({ error: 'Invalid params' }); return; }
if (!requireAdmin(req, res)) return;
try {
await MeshService.getInstance().optInStack(nodeId, stackName, actorFor(req));
res.json({ ok: true });
@@ -268,10 +275,10 @@ meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request,
meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-out', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
const stackName = req.params.stackName as string;
if (!Number.isFinite(nodeId) || !stackName) { res.status(400).json({ error: 'Invalid params' }); return; }
if (!requireAdmin(req, res)) return;
try {
await MeshService.getInstance().optOutStack(nodeId, stackName, actorFor(req));
res.json({ ok: true });
@@ -283,6 +290,7 @@ meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-out', async (req: Request,
meshRouter.get('/aliases', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requirePermission(req, res, 'node:read')) return;
try {
const aliases = await MeshService.getInstance().listAliases();
res.json({ aliases });
@@ -294,6 +302,7 @@ meshRouter.get('/aliases', async (req: Request, res: Response): Promise<void> =>
meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requirePermission(req, res, 'node:read')) return;
try {
const diag = await MeshService.getInstance().getRouteDiagnostic(req.params.alias as string);
res.json(diag);
@@ -304,6 +313,7 @@ meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response)
meshRouter.post('/aliases/:alias/test', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requirePermission(req, res, 'node:read')) return;
try {
const sourceNodeId = NodeRegistry.getInstance().getDefaultNodeId();
const result = await MeshService.getInstance().testUpstream(req.params.alias as string, sourceNodeId);
@@ -317,6 +327,7 @@ meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response):
if (!requirePaid(req, res)) return;
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
if (!requirePermission(req, res, 'node:read', 'node', String(nodeId))) return;
try {
const diag = await MeshService.getInstance().getNodeDiagnostic(nodeId);
res.json(diag);
@@ -327,6 +338,7 @@ meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response):
meshRouter.get('/activity', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requirePermission(req, res, 'node:read')) return;
const alias = typeof req.query.alias === 'string' ? req.query.alias : undefined;
const source = typeof req.query.source === 'string' ? (req.query.source as 'pilot' | 'mesh') : undefined;
const level = typeof req.query.level === 'string' ? (req.query.level as 'info' | 'warn' | 'error') : undefined;
@@ -337,6 +349,7 @@ meshRouter.get('/activity', (req: Request, res: Response): void => {
meshRouter.get('/activity/stream', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requirePermission(req, res, 'node:read')) return;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
+7 -6
View File
@@ -19,6 +19,7 @@ export const networkingRouter = Router();
// calling the underlying service in-process and reaches each remote through
// this route, so a remote is summarized on the node that owns its stacks.
networkingRouter.get('/summary', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
try {
res.json(await computeNodeNetworkingSummary(req.nodeId));
} catch (error) {
@@ -28,7 +29,7 @@ networkingRouter.get('/summary', async (req: Request, res: Response): Promise<vo
});
networkingRouter.get('/overview', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
if (!requirePermission(req, res, 'node:read')) return;
try {
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
res.json(okEnvelope(aggregate.runtimeAvailable, {
@@ -44,7 +45,7 @@ networkingRouter.get('/overview', async (req: Request, res: Response): Promise<v
});
networkingRouter.get('/networks', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
if (!requirePermission(req, res, 'node:read')) return;
try {
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
res.json(okEnvelope(aggregate.runtimeAvailable, { networks: aggregate.networks }));
@@ -55,7 +56,7 @@ networkingRouter.get('/networks', async (req: Request, res: Response): Promise<v
});
networkingRouter.get('/networks/:id', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
if (!requirePermission(req, res, 'node:read')) return;
const id = req.params.id as string;
if (!id || (!isValidDockerResourceId(id) && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id))) {
res.status(400).json({ error: 'Invalid network ID format' });
@@ -82,7 +83,7 @@ networkingRouter.get('/networks/:id', async (req: Request, res: Response): Promi
});
networkingRouter.get('/topology', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
if (!requirePermission(req, res, 'node:read')) return;
const includeSystem = req.query.includeSystem === 'true';
try {
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {
@@ -100,7 +101,7 @@ networkingRouter.get('/topology', async (req: Request, res: Response): Promise<v
});
networkingRouter.get('/findings', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
if (!requirePermission(req, res, 'node:read')) return;
try {
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
res.json(okEnvelope(aggregate.runtimeAvailable, { findings: aggregate.findings }));
@@ -111,7 +112,7 @@ networkingRouter.get('/findings', async (req: Request, res: Response): Promise<v
});
networkingRouter.get('/findings/:id', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
if (!requirePermission(req, res, 'node:read')) return;
const findingId = req.params.id as string;
if (!findingId) {
res.status(400).json({ error: 'Finding ID is required' });
+7 -3
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireBody } from '../middleware/tierGates';
import { requireBody } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { DatabaseService } from '../services/DatabaseService';
import { NodeLabelService } from '../services/NodeLabelService';
import { parseIntParam } from '../utils/parseIntParam';
@@ -10,6 +11,7 @@ export const nodeLabelsRouter = Router();
nodeLabelsRouter.use(authMiddleware);
nodeLabelsRouter.get('/', (req: Request, res: Response): void => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const map = NodeLabelService.getInstance().listAll();
res.json(map);
@@ -20,6 +22,7 @@ nodeLabelsRouter.get('/', (req: Request, res: Response): void => {
});
nodeLabelsRouter.get('/all', (req: Request, res: Response): void => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const labels = NodeLabelService.getInstance().listDistinct();
res.json({ labels });
@@ -32,6 +35,7 @@ nodeLabelsRouter.get('/all', (req: Request, res: Response): void => {
nodeLabelsRouter.get('/:nodeId', (req: Request, res: Response): void => {
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
if (!requirePermission(req, res, 'node:read', 'node', String(nodeId))) return;
try {
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
@@ -47,10 +51,10 @@ nodeLabelsRouter.get('/:nodeId', (req: Request, res: Response): void => {
});
nodeLabelsRouter.post('/:nodeId', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return;
const label = typeof req.body.label === 'string' ? req.body.label : '';
try {
const node = DatabaseService.getInstance().getNode(nodeId);
@@ -71,9 +75,9 @@ nodeLabelsRouter.post('/:nodeId', (req: Request, res: Response): void => {
});
nodeLabelsRouter.delete('/:nodeId/:label', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return;
const labelParam = req.params.label;
const label = typeof labelParam === 'string' ? labelParam : '';
if (!label) {
+5 -1
View File
@@ -118,6 +118,7 @@ function mintPilotEnrollment(nodeId: number, req: Request): { token: string; exp
export const nodesRouter = Router();
nodesRouter.get('/', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'node:read')) return;
const startedAt = Date.now();
let outcome: 'ok' | 'error' = 'ok';
let count = 0;
@@ -140,7 +141,8 @@ nodesRouter.get('/', async (req: Request, res: Response) => {
}
});
nodesRouter.get('/scheduling-summary', authMiddleware, (_req: Request, res: Response) => {
nodesRouter.get('/scheduling-summary', authMiddleware, (req: Request, res: Response) => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const db = DatabaseService.getInstance();
const scheduleSummary = db.getNodeSchedulingSummary();
@@ -182,6 +184,7 @@ nodesRouter.get('/scheduling-summary', authMiddleware, (_req: Request, res: Resp
});
nodesRouter.get('/:id', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'node:read', 'node', req.params.id as string)) return;
try {
const id = parseInt(req.params.id as string);
const node = DatabaseService.getInstance().getNode(id);
@@ -600,6 +603,7 @@ nodesRouter.post('/:id/test', async (req: Request, res: Response) => {
});
nodesRouter.get('/:id/meta', authMiddleware, async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'node:read', 'node', req.params.id as string)) return;
const startedAt = Date.now();
let outcome: 'ok' | 'error' = 'ok';
let id = NaN;
+19 -17
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { trivyInstallLimiter } from '../middleware/rateLimiters';
import TrivyService, { SbomFormat } from '../services/TrivyService';
import TrivyInstaller from '../services/TrivyInstaller';
@@ -391,6 +392,7 @@ securityRouter.get('/stacks/:stackName/pre-deploy-summary', authMiddleware, asyn
res.status(400).json({ error: 'Invalid stack name' });
return;
}
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
const nodeId = req.nodeId;
try {
const db = DatabaseService.getInstance();
@@ -425,7 +427,7 @@ securityRouter.get('/stacks/:stackName/pre-deploy-summary', authMiddleware, asyn
});
securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:deploy')) return;
const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) {
res.status(503).json({ error: 'Trivy is not available on this host' });
@@ -462,7 +464,6 @@ securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void
});
securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) {
res.status(503).json({ error: 'Trivy is not available on this host' }); return;
@@ -471,6 +472,7 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
res.status(400).json({ error: 'Invalid stack name' }); return;
}
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (svc.isScanningStack(req.nodeId, stackName)) {
res.status(409).json({ error: 'Already scanning this stack' }); return;
}
@@ -494,7 +496,7 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res
// when requested, every stack's compose config for misconfigurations. Streams
// sanitized progress to the deploy-feedback terminal when the client opened one.
securityRouter.post('/scan-node', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'node:manage')) return;
const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) {
res.status(503).json({ error: 'Trivy is not available on this host' });
@@ -1001,7 +1003,7 @@ securityRouter.get('/policy-packs', authMiddleware, (_req: Request, res: Respons
});
securityRouter.post('/sbom', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:read')) return;
const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) {
res.status(503).json({ error: 'Trivy is not available on this host' }); return;
@@ -1034,7 +1036,7 @@ securityRouter.get(
'/scans/:scanId/sarif',
authMiddleware,
(req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:read')) return;
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
@@ -1095,10 +1097,10 @@ securityRouter.get(
},
);
// Export the instance's CVE triage decisions as an OpenVEX document. Admin-only,
// mirroring the SARIF export.
// Export the instance's CVE triage decisions as an OpenVEX document. This is a
// read operation, mirroring the SARIF export permission.
securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:read')) return;
try {
const suppressions = DatabaseService.getInstance().getCveSuppressions();
const doc = generateOpenVex(suppressions, req.user?.username || 'sencho', new Date().toISOString());
@@ -1123,7 +1125,7 @@ securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): v
});
securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (blockIfReplica(res, 'security policies')) return;
const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable } = req.body ?? {};
if (!name || typeof name !== 'string' || !name.trim()) {
@@ -1173,7 +1175,7 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response):
});
securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (blockIfReplica(res, 'security policies')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
@@ -1231,7 +1233,7 @@ securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response
});
securityRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (blockIfReplica(res, 'security policies')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
@@ -1252,7 +1254,7 @@ securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response
});
securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (blockIfReplica(res, 'CVE suppressions')) return;
const body = req.body ?? {};
const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : '';
@@ -1316,7 +1318,7 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons
});
securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (blockIfReplica(res, 'CVE suppressions')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
@@ -1371,7 +1373,7 @@ securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Resp
});
securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (blockIfReplica(res, 'CVE suppressions')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
@@ -1403,7 +1405,7 @@ securityRouter.get('/misconfig-acks', authMiddleware, (req: Request, res: Respon
});
securityRouter.post('/misconfig-acks', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (blockIfReplica(res, 'misconfig acknowledgements')) return;
const body = req.body ?? {};
const ruleId = typeof body.rule_id === 'string' ? body.rule_id.trim() : '';
@@ -1459,7 +1461,7 @@ securityRouter.post('/misconfig-acks', authMiddleware, (req: Request, res: Respo
});
securityRouter.put('/misconfig-acks/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (blockIfReplica(res, 'misconfig acknowledgements')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
@@ -1511,7 +1513,7 @@ securityRouter.put('/misconfig-acks/:id', authMiddleware, (req: Request, res: Re
});
securityRouter.delete('/misconfig-acks/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:edit')) return;
if (blockIfReplica(res, 'misconfig acknowledgements')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
+24
View File
@@ -72,6 +72,7 @@ import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
import { isSelfStack, refuseIfSelfStack, selfStackProtectedBulkResult } from '../helpers/selfStackGuard';
import { getActiveCapabilities, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, SERVICE_SCOPED_UPDATE_CAPABILITY } from '../services/CapabilityRegistry';
import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService';
import { classifyStackApiPath } from '../helpers/stackRouteAuth';
// Authenticated users with edit permission can write arbitrarily large compose
// files. Refuse to YAML.parse anything beyond this bound so a malformed (or
@@ -278,6 +279,24 @@ function getRelPath(req: Request): string {
export const stacksRouter = Router();
stacksRouter.use((req: Request, res: Response, next: NextFunction): void => {
const classified = classifyStackApiPath(req.method, `/stacks${req.path}`);
if (classified.kind === 'static') {
next();
return;
}
if (classified.kind === 'unknown-named') {
if (req.user?.role === 'admin') {
next();
return;
}
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return;
}
if (!requirePermission(req, res, classified.action, 'stack', classified.stackName)) return;
next();
});
stacksRouter.param('stackName', (req, res, next, stackName) => {
if (typeof stackName !== 'string' || !isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
@@ -607,6 +626,11 @@ stacksRouter.post('/bulk', async (req: Request, res: Response) => {
const typedAction = action as BulkLifecycleAction;
const typedNames = Array.from(new Set(stackNames as string[]));
const denied = typedNames.some(name =>
isValidStackName(name) && !checkPermission(req, 'stack:deploy', 'stack', name));
if (denied) {
return res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
}
const results = await runWithBoundedParallelism(
typedNames,
+9
View File
@@ -59,6 +59,7 @@ function rejectIfSelf(kind: 'image' | 'volume' | 'network', id: string, res: Res
}
systemMaintenanceRouter.get('/orphans', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const dockerController = DockerController.getInstance(req.nodeId);
@@ -350,6 +351,7 @@ systemMaintenanceRouter.post('/prune/estimate', async (req: Request, res: Respon
});
systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks);
@@ -374,6 +376,7 @@ systemMaintenanceRouter.get('/container-labels', async (req: Request, res: Respo
});
systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
@@ -385,6 +388,7 @@ systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) =>
});
systemMaintenanceRouter.get('/images', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
@@ -396,6 +400,7 @@ systemMaintenanceRouter.get('/images', async (req: Request, res: Response) => {
});
systemMaintenanceRouter.get('/volumes', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
@@ -407,6 +412,7 @@ systemMaintenanceRouter.get('/volumes', async (req: Request, res: Response) => {
});
systemMaintenanceRouter.get('/networks', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
@@ -418,6 +424,7 @@ systemMaintenanceRouter.get('/networks', async (req: Request, res: Response) =>
});
systemMaintenanceRouter.get('/images/:id', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const rawId = req.params.id as string;
if (!rawId) return res.status(400).json({ error: 'Invalid image ID format' });
@@ -516,6 +523,7 @@ systemMaintenanceRouter.post('/networks/delete', async (req: Request, res: Respo
});
systemMaintenanceRouter.get('/networks/topology', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const includeSystem = req.query.includeSystem === 'true';
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
@@ -531,6 +539,7 @@ systemMaintenanceRouter.get('/networks/topology', async (req: Request, res: Resp
});
systemMaintenanceRouter.get('/networks/:id', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const id = req.params.id as string;
if (!id) return res.status(400).json({ error: 'Network ID is required' });
+2
View File
@@ -19,6 +19,7 @@ import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
export const templatesRouter = Router();
templatesRouter.get('/', authMiddleware, async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const templates = await templateService.getTemplates();
@@ -66,6 +67,7 @@ templatesRouter.post('/refresh-cache', authMiddleware, (req: Request, res: Respo
templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:create')) return;
if (!requirePermission(req, res, 'stack:deploy')) return;
try {
const { stackName, template, envVars, skip_scan } = req.body;
+4 -4
View File
@@ -1,7 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { VolumeBrowserService, isValidVolumeName, PathTraversalError, VolumeNotFoundError, HelperImageError, ExecError } from '../services/VolumeBrowserService';
import { DatabaseService } from '../services/DatabaseService';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { sanitizeForLog } from '../utils/safeLog';
import { isDebugEnabled } from '../utils/debug';
@@ -23,7 +23,7 @@ function mapServiceError(error: unknown, res: Response, fallback: string): Respo
}
volumesRouter.get('/:name/list', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:read')) return;
try {
const name = req.params.name as string;
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
@@ -42,7 +42,7 @@ volumesRouter.get('/:name/list', async (req: Request, res: Response) => {
});
volumesRouter.get('/:name/stat', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:read')) return;
try {
const name = req.params.name as string;
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
@@ -55,7 +55,7 @@ volumesRouter.get('/:name/stat', async (req: Request, res: Response) => {
});
volumesRouter.get('/:name/read', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:read')) return;
const name = req.params.name as string;
const requestPath = readPathParam(req);
let outcome: 'success' | 'error' = 'error';
+1 -1
View File
@@ -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;
+14 -6
View File
@@ -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())} />);
+1 -2
View File
@@ -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>
{(() => {
+10 -7
View File
@@ -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}
/>
</>
);
+10 -7
View File
@@ -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}
/>
);
}
+13 -8
View File
@@ -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', () => {
+1
View File
@@ -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');
}