feat(rbac): make stack-scoped grants node-specific (#1727)

* feat(rbac): make stack-scoped grants node-specific

Qualify stack role assignments as (nodeId, stackName), migrate legacy rows to the default node, and forward bound multi-action evidence on Proxy/Pilot hops so scoped users keep least-privilege remote access without shipping the full grant table.

* fix: mirror scoped-stack-auth-evidence capability to frontend, sanitize node id in role assignment log

Backend added the scoped-stack-auth-evidence capability without the
matching frontend entry, failing the capability parity test. The role
assignment log also interpolated the node id without sanitizeForLog,
unlike the rest of the line.

* fix(rbac): honor node-wide scopes and fix proxied DELETE cleanup

Node-scoped grants now authorize that role's stack actions on the same node in the backend resolver, frontend can(), and remote evidence. Proxied DELETE cleanup uses the gate-stashed route because pathRewrite mutates req.path before proxyRes. Add proxy integration coverage and drop the stale scoped-permissions screenshot.

* fix(rbac): preserve node-qualified grants during repair
This commit is contained in:
Anso
2026-07-29 09:42:14 -04:00
committed by GitHub
parent d698eb46f9
commit 9922d8e765
37 changed files with 2487 additions and 143 deletions
@@ -263,7 +263,7 @@ describe('BlueprintService remote deploy', () => {
expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)?.status).toBe('failed');
});
it('does not clear hub role assignments when withdrawing a remote deployment', async () => {
it('clears hub role assignments for the withdrawn remote stack tuple', async () => {
const bcrypt = await import('bcrypt');
const db = DatabaseService.getInstance();
const node = seedRemoteNode();
@@ -282,20 +282,20 @@ describe('BlueprintService remote deploy', () => {
username: `remote-wd-rbac-${counter}`, password_hash: hash, role: 'viewer',
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name,
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name, node_id: node.id,
});
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: { status: 'withdrawn' } });
const delSpy = vi.spyOn(axios, 'delete');
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource');
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack');
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj);
expect(result.status).toBe('withdrawn');
expect(delSpy).not.toHaveBeenCalled();
expect(rbacSpy).not.toHaveBeenCalled();
expect(rbacSpy).toHaveBeenCalledWith(node.id, bpObj.name);
expect(db.getAllRoleAssignments(userId)
.some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name)).toBe(true);
.some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name && a.node_id === node.id)).toBe(false);
db.deleteUser(userId);
});
+28 -19
View File
@@ -493,10 +493,10 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
const userId = db.addUser({ username: `bp-rbac-${counter}`, password_hash: hash, role: 'viewer' });
const otherNodeId = seedNode();
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name,
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name, node_id: nodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack',
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: nodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId),
@@ -552,33 +552,42 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
const { bp, node, nodeId, userId, deleteStackSpy, db } = await arrangeLocalWithdraw();
const fsErr = Object.assign(new Error('permission denied'), { code: 'EACCES' });
deleteStackSpy.mockRejectedValue(fsErr);
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource');
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack');
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
try {
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
expect(outcome.status).toBe('failed');
expect(rbacSpy).not.toHaveBeenCalled();
expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed');
expect(hasAssignment(userId, 'stack', bp.name)).toBe(true);
db.deleteUser(userId);
expect(outcome.status).toBe('failed');
expect(rbacSpy).not.toHaveBeenCalled();
expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed');
expect(hasAssignment(userId, 'stack', bp.name)).toBe(true);
} finally {
rbacSpy.mockRestore();
db.deleteUser(userId);
}
});
it('fails withdraw and keeps the deployment when role-assignment cleanup throws', async () => {
const { bp, node, nodeId, userId, db } = await arrangeLocalWithdraw();
vi.spyOn(db, 'deleteRoleAssignmentsByResource')
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack')
.mockImplementation(() => { throw new Error('simulated rbac cleanup failure'); });
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
try {
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
expect(outcome.status).toBe('failed');
expect(db.getDeployment(bp.id, nodeId)).toBeDefined();
expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed');
expect(errorSpy.mock.calls.some((args) =>
typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'),
)).toBe(true);
expect(hasAssignment(userId, 'stack', bp.name)).toBe(true);
db.deleteUser(userId);
expect(outcome.status).toBe('failed');
expect(db.getDeployment(bp.id, nodeId)).toBeDefined();
expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed');
expect(errorSpy.mock.calls.some((args) =>
typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'),
)).toBe(true);
expect(hasAssignment(userId, 'stack', bp.name)).toBe(true);
} finally {
rbacSpy.mockRestore();
errorSpy.mockRestore();
db.deleteUser(userId);
}
});
});
@@ -0,0 +1,236 @@
/**
* Unit tests for checkPermission evidence + scopedActionsForStack with
* node-qualified stack grants. Uses mocked Request objects where possible.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { Request } from 'express';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import {
checkPermission,
scopedActionsForStack,
type PermissionAction,
} from '../middleware/permissions';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let viewerId: number;
let defaultNodeId: number;
let otherNodeId: number;
function mockReq(partial: {
userId: number;
role: 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
username?: string;
nodeId?: number;
proxyTier?: 'paid' | 'community';
scopedStackEvidence?: {
stackName: string;
actions: ReadonlySet<PermissionAction>;
};
}): Request {
return {
user: {
username: partial.username ?? 'test-user',
role: partial.role,
userId: partial.userId,
},
nodeId: partial.nodeId ?? defaultNodeId,
proxyTier: partial.proxyTier ?? 'paid',
scopedStackEvidence: partial.scopedStackEvidence,
} as Request;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const db = DatabaseService.getInstance();
defaultNodeId = db.getDefaultNode()!.id!;
otherNodeId = db.addNode({
name: 'perm-other-node',
type: 'remote',
api_url: 'http://192.168.1.60:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
viewerId = db.addUser({ username: 'perm-viewer', password_hash: hash, role: 'viewer' });
});
afterAll(() => {
const db = DatabaseService.getInstance();
db.deleteRoleAssignmentsByUser(viewerId);
db.deleteUser(viewerId);
db.deleteNode(otherNodeId);
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
describe('scopedActionsForStack', () => {
it('includes edit and deploy for a node-admin stack assignment', () => {
const db = DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'stack',
resource_id: 'actions-stack',
node_id: defaultNodeId,
});
const actions = scopedActionsForStack(viewerId, defaultNodeId, 'actions-stack');
expect(actions).toContain('stack:edit');
expect(actions).toContain('stack:deploy');
expect(actions).toContain('stack:read');
expect(actions).toContain('stack:delete');
expect(actions).toContain('stack:create');
expect(actions).not.toContain('node:manage');
expect(actions).not.toContain('system:users');
expect(actions.every((a) => a.startsWith('stack:'))).toBe(true);
expect(scopedActionsForStack(viewerId, otherNodeId, 'actions-stack')).toEqual([]);
db.deleteRoleAssignmentsByStack(defaultNodeId, 'actions-stack');
});
});
describe('checkPermission with node-scoped stack grants', () => {
it('viewer + scoped deploy grant succeeds for stack:deploy when req.nodeId matches', () => {
const db = DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'deployer',
resource_type: 'stack',
resource_id: 'deploy-me',
node_id: defaultNodeId,
});
const req = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId });
expect(checkPermission(req, 'stack:deploy', 'stack', 'deploy-me')).toBe(true);
expect(checkPermission(req, 'stack:read', 'stack', 'deploy-me')).toBe(true);
expect(checkPermission(req, 'stack:edit', 'stack', 'deploy-me')).toBe(false);
const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId });
expect(checkPermission(wrongNode, 'stack:deploy', 'stack', 'deploy-me')).toBe(false);
db.deleteRoleAssignmentsByStack(defaultNodeId, 'deploy-me');
});
it('node-scoped Node Admin authorizes stack actions on that node only', () => {
const db = DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(defaultNodeId),
});
const sameNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId });
expect(checkPermission(sameNode, 'stack:edit', 'stack', 'any-stack')).toBe(true);
expect(checkPermission(sameNode, 'stack:deploy', 'stack', 'other-stack')).toBe(true);
expect(checkPermission(sameNode, 'node:manage', 'node', String(defaultNodeId))).toBe(true);
const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId });
expect(checkPermission(wrongNode, 'stack:edit', 'stack', 'any-stack')).toBe(false);
const assignments = db.getAllRoleAssignments(viewerId).filter(
(a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId),
);
for (const a of assignments) db.deleteRoleAssignment(a.id!);
});
});
describe('scopedActionsForStack with node-scoped grants', () => {
it('includes stack:* actions from a node-wide grant on the same node', () => {
const db = DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(defaultNodeId),
});
const actions = scopedActionsForStack(viewerId, defaultNodeId, 'fleet-wide');
expect(actions).toContain('stack:edit');
expect(actions).toContain('stack:deploy');
expect(actions).not.toContain('node:manage');
expect(scopedActionsForStack(viewerId, otherNodeId, 'fleet-wide')).toEqual([]);
const assignments = db.getAllRoleAssignments(viewerId).filter(
(a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId),
);
for (const a of assignments) db.deleteRoleAssignment(a.id!);
});
});
describe('checkPermission scopedStackEvidence', () => {
it('allows when action is a member of the evidenced set for the same stack', () => {
// Use actions the global viewer role does not already grant so the
// evidence path is what authorizes (not ROLE_PERMISSIONS.viewer).
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:deploy', 'stack:edit']),
},
});
expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true);
expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(true);
});
it('denies when the action is absent from the evidenced set', () => {
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:read']),
},
});
expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(false);
expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(false);
});
it('denies when the stack name does not match evidence', () => {
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:deploy']),
},
});
expect(checkPermission(req, 'stack:deploy', 'stack', 'other-stack')).toBe(false);
});
it('ignores evidence for unscoped checks (no resourceType/resourceId)', () => {
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:deploy', 'system:users']),
},
});
expect(checkPermission(req, 'stack:deploy')).toBe(false);
expect(checkPermission(req, 'system:users')).toBe(false);
});
it('does not honor non-stack actions even when present in the evidence set', () => {
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:deploy', 'system:users', 'node:manage']),
},
});
expect(checkPermission(req, 'system:users', 'stack', 'evidenced')).toBe(false);
expect(checkPermission(req, 'node:manage', 'stack', 'evidenced')).toBe(false);
expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true);
});
});
@@ -0,0 +1,327 @@
/**
* Orchestrated hub → remote proxy coverage for scoped stack elevation and
* DELETE tuple cleanup. Exercises createRemoteProxyMiddleware through
* live loopback remotes (not helper-only unit tests).
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import http from 'http';
import bcrypt from 'bcrypt';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import {
PROXY_SCOPED_STACK_NAME_HEADER,
PROXY_SCOPED_STACK_ACTIONS_HEADER,
} from '../services/license-headers';
let tmpDir: string;
let app: import('express').Express;
let viewerBearer: string;
let viewerId: number;
let evidenceServer: http.Server;
let noEvidenceServer: http.Server;
let failDeleteServer: http.Server;
let wrongNodeServer: http.Server;
let evidenceNodeId: number;
let noEvidenceNodeId: number;
let failDeleteNodeId: number;
let wrongNodeId: number;
interface CapturedHop {
method: string;
url: string;
stackNameHeader: string | undefined;
stackActionsHeader: string | undefined;
}
const evidenceHops: CapturedHop[] = [];
const failDeleteHops: CapturedHop[] = [];
function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void {
into.push({
method: req.method ?? '',
url: req.url ?? '',
stackNameHeader: req.headers[PROXY_SCOPED_STACK_NAME_HEADER] as string | undefined,
stackActionsHeader: req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER] as string | undefined,
});
}
function evidenceRemote(): http.Server {
return http.createServer((req, res) => {
if (req.url?.startsWith('/api/meta')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '0.93.0',
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'],
}));
return;
}
captureHop(req, evidenceHops);
if (req.method === 'DELETE') {
res.writeHead(204);
res.end();
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
function noEvidenceRemote(): http.Server {
return http.createServer((req, res) => {
if (req.url?.startsWith('/api/meta')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '0.93.0',
capabilities: ['cross-node-rbac'],
}));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
function failDeleteRemote(): http.Server {
return http.createServer((req, res) => {
if (req.url?.startsWith('/api/meta')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '0.93.0',
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'],
}));
return;
}
captureHop(req, failDeleteHops);
if (req.method === 'DELETE') {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'upstream delete failed' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
async function listen(server: http.Server): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return (server.address() as import('net').AddressInfo).port;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
viewerId = db.addUser({ username: 'scoped-proxy-viewer', password_hash: hash, role: 'viewer' });
const viewer = db.getUserByUsername('scoped-proxy-viewer')!;
viewerBearer = jwt.sign(
{ username: 'scoped-proxy-viewer', role: 'viewer', tv: viewer.token_version },
TEST_JWT_SECRET,
{ expiresIn: '5m' },
);
evidenceServer = evidenceRemote();
noEvidenceServer = noEvidenceRemote();
failDeleteServer = failDeleteRemote();
wrongNodeServer = evidenceRemote();
const evidencePort = await listen(evidenceServer);
const noEvidencePort = await listen(noEvidenceServer);
const failDeletePort = await listen(failDeleteServer);
const wrongNodePort = await listen(wrongNodeServer);
evidenceNodeId = db.addNode({
name: 'evidence-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${evidencePort}`,
api_token: 'evidence-token',
});
noEvidenceNodeId = db.addNode({
name: 'no-evidence-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${noEvidencePort}`,
api_token: 'no-evidence-token',
});
failDeleteNodeId = db.addNode({
name: 'fail-delete-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${failDeletePort}`,
api_token: 'fail-delete-token',
});
wrongNodeId = db.addNode({
name: 'wrong-node-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${wrongNodePort}`,
api_token: 'wrong-node-token',
});
});
afterAll(async () => {
await new Promise<void>((resolve) => evidenceServer.close(() => resolve()));
await new Promise<void>((resolve) => noEvidenceServer.close(() => resolve()));
await new Promise<void>((resolve) => failDeleteServer.close(() => resolve()));
await new Promise<void>((resolve) => wrongNodeServer.close(() => resolve()));
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
describe('remote proxy scoped stack evidence and DELETE cleanup', () => {
it('elevates a matching stack grant and forwards bound evidence headers', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'deployer',
resource_type: 'stack',
resource_id: 'shared-name',
node_id: evidenceNodeId,
});
evidenceHops.length = 0;
const res = await request(app)
.post('/api/stacks/shared-name/deploy')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(evidenceNodeId));
expect(res.status).toBe(200);
const hop = evidenceHops.find((h) => h.url?.includes('/stacks/shared-name/deploy'));
expect(hop).toBeDefined();
expect(hop!.stackNameHeader).toBe('shared-name');
expect(hop!.stackActionsHeader).toContain('stack:deploy');
expect(hop!.stackActionsHeader).not.toContain('node:manage');
db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name');
});
it('denies the same stack name on a node without a grant', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'deployer',
resource_type: 'stack',
resource_id: 'shared-name',
node_id: evidenceNodeId,
});
const res = await request(app)
.post('/api/stacks/shared-name/deploy')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(wrongNodeId));
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name');
});
it('denies scoped elevation when the remote lacks scoped-stack-auth-evidence', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'deployer',
resource_type: 'stack',
resource_id: 'needs-evidence',
node_id: noEvidenceNodeId,
});
const res = await request(app)
.post('/api/stacks/needs-evidence/deploy')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(noEvidenceNodeId));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/scoped stack authorization/i);
db.deleteRoleAssignmentsByStack(noEvidenceNodeId, 'needs-evidence');
});
it('clears the hub grant tuple after a successful remote DELETE', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'stack',
resource_id: 'doomed',
node_id: evidenceNodeId,
});
expect(
db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId),
).toHaveLength(1);
const res = await request(app)
.delete('/api/stacks/doomed')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(evidenceNodeId));
expect(res.status).toBe(204);
expect(
db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId),
).toHaveLength(0);
});
it('preserves the hub grant tuple when remote DELETE is non-2xx', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'stack',
resource_id: 'keep-me',
node_id: failDeleteNodeId,
});
const res = await request(app)
.delete('/api/stacks/keep-me')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(failDeleteNodeId));
expect(res.status).toBe(500);
expect(
db.getRoleAssignments(viewerId, 'stack', 'keep-me', failDeleteNodeId),
).toHaveLength(1);
db.deleteRoleAssignmentsByStack(failDeleteNodeId, 'keep-me');
});
it('builds evidence from a node-scoped grant on the target node', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(evidenceNodeId),
});
evidenceHops.length = 0;
const res = await request(app)
.post('/api/stacks/node-wide-stack/deploy')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(evidenceNodeId));
expect(res.status).toBe(200);
const hop = evidenceHops.find((h) => h.url?.includes('/stacks/node-wide-stack/deploy'));
expect(hop).toBeDefined();
expect(hop!.stackNameHeader).toBe('node-wide-stack');
expect(hop!.stackActionsHeader).toContain('stack:deploy');
expect(hop!.stackActionsHeader).toContain('stack:edit');
const assignments = db.getAllRoleAssignments(viewerId).filter(
(a) => a.resource_type === 'node' && a.resource_id === String(evidenceNodeId),
);
for (const a of assignments) db.deleteRoleAssignment(a.id!);
});
});
@@ -0,0 +1,430 @@
/**
* migrateRoleAssignmentsNodeQualified: legacy rebuild, default remap,
* no-default omit, sqlite_master idempotency probe, unique indexes,
* deleteNode stack-grant cleanup, preserved ids/timestamps.
*/
import path from 'path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
function resetDatabaseSingleton(): void {
const holder = DatabaseService as unknown as { instance?: DatabaseService };
const existing = holder.instance;
if (existing) {
try {
existing.getDb().close();
} catch {
// already closed
}
holder.instance = undefined;
}
}
type IndexRow = { name: string; sql: string | null };
type AssignmentRow = {
id: number;
user_id: number;
role: string;
resource_type: string;
resource_id: string;
node_id: number | null;
created_at: number;
};
function roleAssignmentsTableSql(raw: Database.Database): string {
return (
(raw.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'",
).get() as { sql: string } | undefined)?.sql ?? ''
);
}
function roleAssignmentIndexes(raw: Database.Database): Map<string, string> {
const rows = raw.prepare(
"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'",
).all() as IndexRow[];
return new Map(rows.map((r) => [r.name, r.sql ?? '']));
}
/** Rewrite role_assignments to the pre-node_id schema and seed legacy rows. */
function seedLegacyRoleAssignments(
dbPath: string,
seed: {
userId: number;
stackRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>;
nodeRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>;
},
): void {
const raw = new Database(dbPath);
try {
raw.exec('PRAGMA foreign_keys = OFF');
raw.exec('DROP TABLE IF EXISTS role_assignments');
raw.exec('DROP TABLE IF EXISTS role_assignments_new');
raw.exec(`
CREATE TABLE role_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_role_assignments_resource
ON role_assignments(resource_type, resource_id);
`);
const insert = raw.prepare(`
INSERT INTO role_assignments (id, user_id, role, resource_type, resource_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const row of seed.stackRows) {
insert.run(row.id, seed.userId, row.role, 'stack', row.resource_id, row.created_at);
}
for (const row of seed.nodeRows) {
insert.run(row.id, seed.userId, row.role, 'node', row.resource_id, row.created_at);
}
} finally {
raw.close();
}
}
function removeRoleAssignmentsCheck(raw: Database.Database): void {
raw.exec(`
CREATE TABLE role_assignments_without_check (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
node_id INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
INSERT INTO role_assignments_without_check
SELECT * FROM role_assignments;
DROP TABLE role_assignments;
ALTER TABLE role_assignments_without_check RENAME TO role_assignments;
CREATE INDEX idx_role_assignments_user ON role_assignments(user_id);
CREATE INDEX idx_role_assignments_resource ON role_assignments(resource_type, resource_id);
CREATE UNIQUE INDEX idx_role_assignments_stack_unique
ON role_assignments(user_id, role, resource_type, resource_id, node_id)
WHERE resource_type = 'stack';
CREATE UNIQUE INDEX idx_role_assignments_node_unique
ON role_assignments(user_id, role, resource_type, resource_id)
WHERE resource_type = 'node';
`);
}
function expectFinalSchema(raw: Database.Database): void {
const tableSql = roleAssignmentsTableSql(raw);
expect(tableSql).toContain("resource_type = 'stack' AND node_id IS NOT NULL");
expect(tableSql).toContain("resource_type = 'node' AND node_id IS NULL");
const indexes = roleAssignmentIndexes(raw);
const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? '';
const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? '';
expect(stackUnique).toMatch(/user_id/i);
expect(stackUnique).toMatch(/role/i);
expect(stackUnique).toMatch(/resource_type/i);
expect(stackUnique).toMatch(/resource_id/i);
expect(stackUnique).toMatch(/node_id/i);
expect(stackUnique).toMatch(/WHERE\s+resource_type\s*=\s*'stack'/i);
expect(nodeUnique).toMatch(/user_id/i);
expect(nodeUnique).toMatch(/role/i);
expect(nodeUnique).toMatch(/resource_type/i);
expect(nodeUnique).toMatch(/resource_id/i);
expect(nodeUnique).toMatch(/WHERE\s+resource_type\s*=\s*'node'/i);
const nodeCols = nodeUnique.replace(/WHERE[\s\S]*/i, '');
expect(nodeCols).not.toMatch(/node_id/);
}
describe('migrateRoleAssignmentsNodeQualified', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await setupTestDb();
});
afterEach(() => {
resetDatabaseSingleton();
cleanupTestDb(tmpDir);
});
it('remaps legacy stack rows to the default node and preserves ids/timestamps', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const remoteNodeId = db.addNode({
name: 'mig-remote',
type: 'remote',
api_url: 'http://192.168.1.50:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-remap', password_hash: hash, role: 'viewer' });
const stackCreatedAt = 1_700_000_000_001;
const nodeCreatedAt = 1_700_000_000_002;
resetDatabaseSingleton();
seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), {
userId,
stackRows: [
{ id: 41, role: 'deployer', resource_id: 'web', created_at: stackCreatedAt },
{ id: 42, role: 'viewer', resource_id: 'api', created_at: stackCreatedAt + 1 },
],
nodeRows: [
{ id: 51, role: 'node-admin', resource_id: String(remoteNodeId), created_at: nodeCreatedAt },
],
});
process.env.DATA_DIR = tmpDir;
const migrated = DatabaseService.getInstance();
const raw = migrated.getDb();
expectFinalSchema(raw);
const rows = raw.prepare(
'SELECT * FROM role_assignments ORDER BY id',
).all() as AssignmentRow[];
expect(rows).toHaveLength(3);
const stackWeb = rows.find((r) => r.id === 41)!;
expect(stackWeb.resource_type).toBe('stack');
expect(stackWeb.resource_id).toBe('web');
expect(stackWeb.node_id).toBe(defaultNodeId);
expect(stackWeb.created_at).toBe(stackCreatedAt);
expect(stackWeb.role).toBe('deployer');
const stackApi = rows.find((r) => r.id === 42)!;
expect(stackApi.node_id).toBe(defaultNodeId);
expect(stackApi.created_at).toBe(stackCreatedAt + 1);
const nodeGrant = rows.find((r) => r.id === 51)!;
expect(nodeGrant.resource_type).toBe('node');
expect(nodeGrant.node_id).toBeNull();
expect(nodeGrant.created_at).toBe(nodeCreatedAt);
});
it('omits legacy stack rows when no default node exists', async () => {
const db = DatabaseService.getInstance();
const remoteNodeId = db.addNode({
name: 'mig-no-default-remote',
type: 'remote',
api_url: 'http://192.168.1.51:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-omit', password_hash: hash, role: 'viewer' });
db.getDb().prepare('UPDATE nodes SET is_default = 0').run();
expect(db.getDefaultNode()).toBeUndefined();
resetDatabaseSingleton();
seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), {
userId,
stackRows: [
{ id: 61, role: 'deployer', resource_id: 'orphan-stack', created_at: 99 },
],
nodeRows: [
{ id: 62, role: 'deployer', resource_id: String(remoteNodeId), created_at: 100 },
],
});
process.env.DATA_DIR = tmpDir;
const migrated = DatabaseService.getInstance();
const rows = migrated.getDb().prepare(
'SELECT * FROM role_assignments ORDER BY id',
).all() as AssignmentRow[];
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(62);
expect(rows[0].resource_type).toBe('node');
expect(rows[0].node_id).toBeNull();
});
it('second init is idempotent via sqlite_master CHECK and partial-index WHERE probes', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-idem', password_hash: hash, role: 'viewer' });
resetDatabaseSingleton();
seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), {
userId,
stackRows: [
{ id: 71, role: 'deployer', resource_id: 'idem-stack', created_at: 200 },
],
nodeRows: [],
});
process.env.DATA_DIR = tmpDir;
const first = DatabaseService.getInstance();
expectFinalSchema(first.getDb());
const before = first.getDb().prepare(
'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71',
).get() as { id: number; node_id: number; created_at: number };
expect(before.node_id).toBe(defaultNodeId);
resetDatabaseSingleton();
process.env.DATA_DIR = tmpDir;
const second = DatabaseService.getInstance();
expectFinalSchema(second.getDb());
const after = second.getDb().prepare(
'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71',
).get() as { id: number; node_id: number; created_at: number };
expect(after).toEqual(before);
const stale = second.getDb().prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments_new'",
).get();
expect(stale).toBeUndefined();
});
it('preserves node-qualified stack rows when repairing a missing index', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const remoteNodeId = db.addNode({
name: 'mig-repair-remote',
type: 'remote',
api_url: 'http://192.168.1.54:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-repair', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'shared-stack', node_id: defaultNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'shared-stack', node_id: remoteNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'node-admin', resource_type: 'node',
resource_id: String(remoteNodeId),
});
const before = db.getAllRoleAssignments(userId);
db.getDb().exec('DROP INDEX idx_role_assignments_stack_unique');
resetDatabaseSingleton();
process.env.DATA_DIR = tmpDir;
const repaired = DatabaseService.getInstance();
expectFinalSchema(repaired.getDb());
const rows = repaired.getAllRoleAssignments(userId);
expect(rows).toEqual(before);
});
it('preserves node-qualified rows without a default node when repairing the table check', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const remoteNodeId = db.addNode({
name: 'mig-repair-no-default',
type: 'remote',
api_url: 'http://192.168.1.55:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-repair-check', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'local-stack', node_id: defaultNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'admin', resource_type: 'stack',
resource_id: 'remote-stack', node_id: remoteNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'viewer', resource_type: 'node',
resource_id: String(remoteNodeId),
});
const before = db.getAllRoleAssignments(userId);
db.getDb().prepare('UPDATE nodes SET is_default = 0').run();
removeRoleAssignmentsCheck(db.getDb());
resetDatabaseSingleton();
process.env.DATA_DIR = tmpDir;
const repaired = DatabaseService.getInstance();
expectFinalSchema(repaired.getDb());
expect(repaired.getAllRoleAssignments(userId)).toEqual(before);
});
it('unique indexes use exact column sets including role', () => {
const raw = DatabaseService.getInstance().getDb();
const indexes = roleAssignmentIndexes(raw);
const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? '';
const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? '';
expect(stackUnique.replace(/\s+/g, ' ')).toMatch(
/ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*,\s*node_id\s*\)/i,
);
expect(nodeUnique.replace(/\s+/g, ' ')).toMatch(
/ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*\)/i,
);
});
it('deleteNode clears stack grants by node_id and preserves other nodes', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const doomedId = db.addNode({
name: 'mig-doomed',
type: 'remote',
api_url: 'http://192.168.1.52:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const survivorId = db.addNode({
name: 'mig-survivor',
type: 'remote',
api_url: 'http://192.168.1.53:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-delnode', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'shared-name', node_id: doomedId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'shared-name', node_id: survivorId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'local-only', node_id: defaultNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'node-admin', resource_type: 'node',
resource_id: String(doomedId),
});
db.deleteNode(doomedId);
const remaining = db.getAllRoleAssignments(userId);
expect(remaining.some((a) => a.node_id === doomedId)).toBe(false);
expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(doomedId))).toBe(false);
expect(remaining.some((a) => a.node_id === survivorId && a.resource_id === 'shared-name')).toBe(true);
expect(remaining.some((a) => a.node_id === defaultNodeId && a.resource_id === 'local-only')).toBe(true);
db.deleteUser(userId);
db.deleteNode(survivorId);
});
});
@@ -0,0 +1,127 @@
/**
* Machine-auth scoped stack evidence: headers are trusted only under
* node_proxy / pilot_tunnel, and only when the name + actions pair is valid.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import {
PROXY_SCOPED_STACK_NAME_HEADER,
PROXY_SCOPED_STACK_ACTIONS_HEADER,
PROXY_ROLE_HEADER,
} from '../services/license-headers';
import { checkPermission } from '../middleware/permissions';
let tmpDir: string;
let authMiddleware: typeof import('../middleware/auth').authMiddleware;
function runAuth(req: Partial<Request>): Promise<Request> {
return new Promise((resolve, reject) => {
const fullReq = Object.assign(
{ cookies: {} as Record<string, string>, headers: {} as Record<string, string | undefined> },
req,
{
headers: { ...(req.headers ?? {}) },
cookies: {},
},
) as Request;
let settled = false;
const res = {
status: () => res,
json: (body: unknown) => {
if (!settled) {
settled = true;
reject(new Error(`authMiddleware rejected: ${JSON.stringify(body)}`));
}
return res;
},
} as unknown as Response;
const next: NextFunction = (err?: unknown) => {
if (settled) return;
settled = true;
if (err) reject(err);
else resolve(fullReq);
};
void Promise.resolve(authMiddleware(fullReq, res, next)).catch(reject);
});
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ authMiddleware } = await import('../middleware/auth'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
describe('scoped stack evidence under machine auth', () => {
it('attaches evidence for node_proxy when headers are valid', async () => {
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const req = await runAuth({
headers: {
authorization: `Bearer ${token}`,
[PROXY_ROLE_HEADER]: 'viewer',
[PROXY_SCOPED_STACK_NAME_HEADER]: 'web',
[PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,stack:deploy',
},
});
expect(req.scopedStackEvidence?.stackName).toBe('web');
expect(req.scopedStackEvidence?.actions.has('stack:edit')).toBe(true);
expect(req.scopedStackEvidence?.actions.has('stack:deploy')).toBe(true);
expect(checkPermission(req, 'stack:deploy', 'stack', 'web')).toBe(true);
expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(true);
});
it('attaches evidence for pilot_tunnel the same way', async () => {
const token = jwt.sign({ scope: 'pilot_tunnel' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const req = await runAuth({
headers: {
authorization: `Bearer ${token}`,
[PROXY_ROLE_HEADER]: 'viewer',
[PROXY_SCOPED_STACK_NAME_HEADER]: 'api',
[PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:read',
},
});
expect(req.scopedStackEvidence?.stackName).toBe('api');
expect(checkPermission(req, 'stack:read', 'stack', 'api')).toBe(true);
});
it('treats malformed actions as absent evidence', async () => {
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const req = await runAuth({
headers: {
authorization: `Bearer ${token}`,
[PROXY_ROLE_HEADER]: 'viewer',
[PROXY_SCOPED_STACK_NAME_HEADER]: 'web',
[PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,not-real',
},
});
expect(req.scopedStackEvidence).toBeUndefined();
expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(false);
});
it('ignores evidence headers on a user session JWT', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const { TEST_USERNAME } = await import('./helpers/setupTestDb');
const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME);
expect(user).toBeDefined();
const token = jwt.sign(
{ username: user!.username, role: user!.role, tv: user!.token_version },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
const req = await runAuth({
headers: {
authorization: `Bearer ${token}`,
[PROXY_SCOPED_STACK_NAME_HEADER]: 'web',
[PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:deploy,stack:edit',
},
});
expect(req.scopedStackEvidence).toBeUndefined();
});
});
@@ -142,8 +142,9 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', (
name: 'stack-del-rbac-node', type: 'remote', api_url: 'http://test:1852',
api_token: '', compose_dir: '/tmp', is_default: false,
});
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api' });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack' });
const defaultNodeId = db.getDefaultNode()!.id;
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api', node_id: defaultNodeId });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: defaultNodeId });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId) });
const res = await request(app)
@@ -152,8 +153,8 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', (
expect(res.status).toBe(200);
const remaining = db.getAllRoleAssignments(userId);
expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api')).toBe(false);
expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack')).toBe(true);
expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api' && a.node_id === defaultNodeId)).toBe(false);
expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack' && a.node_id === defaultNodeId)).toBe(true);
expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(otherNodeId))).toBe(true);
db.deleteUser(userId);
@@ -0,0 +1,155 @@
/**
* Pure classifyStackApiPath coverage for hub stack RBAC gating.
*/
import { describe, it, expect } from 'vitest';
import {
classifyStackApiPath,
formatScopedStackActionsHeader,
parseScopedStackActionsHeader,
} from '../helpers/stackRouteAuth';
import type { PermissionAction } from '../middleware/permissions';
describe('classifyStackApiPath', () => {
describe('named-stack families', () => {
it('maps read routes to stack:read', () => {
expect(classifyStackApiPath('GET', '/stacks/web')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
expect(classifyStackApiPath('GET', '/stacks/web/env')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
expect(classifyStackApiPath('GET', '/stacks/web/git-source')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
expect(classifyStackApiPath('POST', '/stacks/web/drift/recheck')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
});
it('maps edit routes to stack:edit', () => {
expect(classifyStackApiPath('PUT', '/stacks/web')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
expect(classifyStackApiPath('PUT', '/stacks/web/env')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
expect(classifyStackApiPath('PUT', '/stacks/web/git-source')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
expect(classifyStackApiPath('DELETE', '/stacks/web/git-source')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
});
it('maps deploy routes and service lifecycle ops to stack:deploy', () => {
expect(classifyStackApiPath('POST', '/stacks/web/deploy')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
expect(classifyStackApiPath('POST', '/stacks/web/update')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
expect(classifyStackApiPath('POST', '/stacks/web/services/api/restart')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
expect(classifyStackApiPath('GET', '/stacks/web/services/api/recovery')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
});
it('maps stack DELETE to stack:delete', () => {
expect(classifyStackApiPath('DELETE', '/stacks/web')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:delete',
});
});
it('treats git-source/apply primary as stack:edit', () => {
expect(classifyStackApiPath('POST', '/stacks/web/git-source/apply')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
});
});
describe('static exclusions', () => {
it('classifies collection and create paths as static', () => {
expect(classifyStackApiPath('GET', '/stacks')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('GET', '/stacks/')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('GET', '/stacks/statuses')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('GET', '/stacks/discovery')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks/import/scan')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks/import/move')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks/bulk')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks/from-git')).toEqual({ kind: 'static' });
});
it('classifies non-/stacks paths as static', () => {
expect(classifyStackApiPath('GET', '/nodes')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('GET', '/users')).toEqual({ kind: 'static' });
});
});
describe('encoding and trailing slashes', () => {
it('decodes percent-encoded stack names', () => {
expect(classifyStackApiPath('GET', '/stacks/my%2Dstack')).toEqual({
kind: 'named-stack', stackName: 'my-stack', action: 'stack:read',
});
expect(classifyStackApiPath('POST', '/stacks/web%5Fprod/deploy')).toEqual({
kind: 'named-stack', stackName: 'web_prod', action: 'stack:deploy',
});
});
it('strips trailing slashes before matching', () => {
expect(classifyStackApiPath('GET', '/stacks/web/')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
expect(classifyStackApiPath('POST', '/stacks/web/deploy/')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
expect(classifyStackApiPath('GET', '/stacks/statuses/')).toEqual({ kind: 'static' });
});
it('ignores query strings', () => {
expect(classifyStackApiPath('GET', '/stacks/web?nodeId=1')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
});
});
describe('fail-closed unknown-named', () => {
it('returns unknown-named for unrecognized /stacks/<name>/... suffixes', () => {
expect(classifyStackApiPath('GET', '/stacks/web/weird')).toEqual({ kind: 'unknown-named' });
expect(classifyStackApiPath('POST', '/stacks/web/not-a-real-action')).toEqual({
kind: 'unknown-named',
});
expect(classifyStackApiPath('POST', '/stacks/web/services/api/recovery')).toEqual({
kind: 'unknown-named',
});
});
it('returns unknown-named for invalid stack name segments', () => {
expect(classifyStackApiPath('GET', '/stacks/bad name')).toEqual({ kind: 'unknown-named' });
expect(classifyStackApiPath('GET', '/stacks/%2E%2E')).toEqual({ kind: 'unknown-named' });
});
});
});
describe('scoped stack actions header encode/decode', () => {
it('round-trips a PermissionAction set', () => {
const actions: PermissionAction[] = ['stack:edit', 'stack:deploy', 'stack:read'];
const encoded = formatScopedStackActionsHeader(actions);
expect(parseScopedStackActionsHeader(encoded)).toEqual(actions);
});
it('returns null for malformed tokens', () => {
expect(parseScopedStackActionsHeader('stack:edit,not-a-real-action')).toBeNull();
expect(parseScopedStackActionsHeader('')).toBeNull();
expect(parseScopedStackActionsHeader(' ')).toBeNull();
});
it('deduplicates while preserving first-seen order', () => {
expect(parseScopedStackActionsHeader('stack:edit,stack:deploy,stack:edit')).toEqual([
'stack:edit',
'stack:deploy',
]);
});
});
+143 -31
View File
@@ -9,11 +9,22 @@ import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { generateApiToken } from '../utils/apiTokenFormat';
import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode';
vi.mock('../helpers/assertStackExistsOnNode', () => ({
assertStackExistsOnNode: vi.fn(async () => ({ ok: true as const })),
}));
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
function defaultNodeId(): number {
const node = DatabaseService.getInstance().getDefaultNode();
if (!node) throw new Error('test default node missing');
return node.id;
}
/** Sign a JWT for a given user with optional token_version (tv). */
function authToken(username: string, role: string = 'admin', tv?: number): string {
const payload: Record<string, unknown> = { username, role };
@@ -383,20 +394,103 @@ describe('Scoped Role Assignments', () => {
});
it('POST /api/users/:id/roles creates assignment (201)', async () => {
const nodeId = defaultNodeId();
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' });
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: nodeId });
expect(res.status).toBe(201);
expect(res.body.role).toBe('deployer');
expect(res.body.resource_type).toBe('stack');
expect(res.body.node_id).toBe(nodeId);
});
it('POST /api/users/:id/roles rejects stack assignment without node_id (400)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'no-node-stack' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/node_id/i);
});
it('POST /api/users/:id/roles rejects when stack does not exist on node (400)', async () => {
vi.mocked(assertStackExistsOnNode).mockResolvedValueOnce({
ok: false,
error: 'Stack not found on node',
});
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'stack',
resource_id: 'missing-stack',
node_id: defaultNodeId(),
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not found/i);
});
it('POST /api/users/:id/roles rejects node_id qualifier on node assignments (400)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'node',
resource_id: String(defaultNodeId()),
node_id: defaultNodeId(),
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/must not be set/i);
});
it('POST /api/users/:id/roles rejects nonexistent numeric node resource_id (400)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'node',
resource_id: '999999',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Node not found/i);
});
it('POST /api/users/:id/roles creates node assignment without node_id (201)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'node',
resource_id: String(defaultNodeId()),
});
expect(res.status).toBe(201);
expect(res.body.resource_type).toBe('node');
expect(res.body.node_id).toBeNull();
});
it('POST /api/users/:id/roles rejects a noncanonical node resource_id (400)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'node',
resource_id: `0${defaultNodeId()}`,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/canonical/i);
});
it('POST /api/users/:id/roles rejects duplicate (409)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' });
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: defaultNodeId() });
expect(res.status).toBe(409);
});
@@ -420,7 +514,7 @@ describe('Scoped Role Assignments', () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack' });
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack', node_id: defaultNodeId() });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
} finally {
@@ -452,7 +546,8 @@ describe('GET /api/permissions/me', () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const id = db.addUser({ username: 'permcheck', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' });
const nodeId = defaultNodeId();
db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack', node_id: nodeId });
const user = db.getUserById(id)!;
const token = authToken('permcheck', 'viewer', user.token_version);
@@ -461,7 +556,7 @@ describe('GET /api/permissions/me', () => {
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.globalRole).toBe('viewer');
expect(res.body.scopedPermissions['stack:my-stack']).toBeDefined();
expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toBeDefined();
// Cleanup
db.deleteRoleAssignmentsByUser(id);
@@ -474,7 +569,13 @@ describe('GET /api/permissions/me', () => {
const svc = LicenseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const id = db.addUser({ username: 'permcheck-community', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' });
db.addRoleAssignment({
user_id: id,
role: 'deployer',
resource_type: 'stack',
resource_id: 'my-stack',
node_id: defaultNodeId(),
});
const user = db.getUserById(id)!;
const token = authToken('permcheck-community', 'viewer', user.token_version);
@@ -689,53 +790,64 @@ describe('Atomic last-admin guard', () => {
});
// ---- Orphaned Role Assignment Cleanup ----
// Proxied remote stack DELETE clears hub grants only on 2xx in
// remoteNodeProxy (deleteRoleAssignmentsByStack). Non-2xx preserves rows.
// Orchestrated proxyRes coverage lives in proxy-scoped-stack-evidence.test.ts;
// these cases lock the DB helper isolation that the proxy calls.
describe('Orphaned role assignment cleanup', () => {
it('deleting a node removes its role assignments', async () => {
it('deleting a node removes its node and stack role assignments', async () => {
const db = DatabaseService.getInstance();
// Create a test node
const nodeId = db.addNode({ name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false });
// Create a role assignment for this node
const nodeId = db.addNode({
name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852',
api_token: '', compose_dir: '/tmp', is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'nodeorphan', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) });
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId),
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'on-doomed', node_id: nodeId,
});
// Verify assignment exists
const before = db.getAllRoleAssignments(userId);
expect(before.length).toBe(1);
expect(db.getAllRoleAssignments(userId)).toHaveLength(2);
// Delete the node
db.deleteNode(nodeId);
// Assignments should be gone
const after = db.getAllRoleAssignments(userId);
expect(after.length).toBe(0);
// Cleanup
expect(db.getAllRoleAssignments(userId)).toHaveLength(0);
db.deleteUser(userId);
});
it('deleteRoleAssignmentsByResource removes only the matching resource tuple', async () => {
it('deleteRoleAssignmentsByStack clears only that node+stack tuple', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'tupleorphan', password_hash: hash, role: 'viewer' });
const nodeId = db.addNode({
name: 'tuple-cleanup-node', type: 'remote', api_url: 'http://test:1852',
const nodeA = db.addNode({
name: 'tuple-cleanup-node-a', type: 'remote', api_url: 'http://test-a:1852',
api_token: '', compose_dir: '/tmp', is_default: false,
});
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'target-stack' });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack' });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) });
const nodeB = db.addNode({
name: 'tuple-cleanup-node-b', type: 'remote', api_url: 'http://test-b:1852',
api_token: '', compose_dir: '/tmp', is_default: false,
});
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeA });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeB });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack', node_id: nodeA });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeA) });
db.deleteRoleAssignmentsByResource('stack', 'target-stack');
db.deleteRoleAssignmentsByStack(nodeA, 'shared-name');
const after = db.getAllRoleAssignments(userId);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'target-stack')).toBe(false);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack')).toBe(true);
expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeId))).toBe(true);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeA)).toBe(false);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeB)).toBe(true);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack' && a.node_id === nodeA)).toBe(true);
expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeA))).toBe(true);
db.deleteUser(userId);
db.deleteNode(nodeId);
db.deleteNode(nodeA);
db.deleteNode(nodeB);
});
});
@@ -0,0 +1,81 @@
import axios from 'axios';
import { DatabaseService } from '../services/DatabaseService';
import { FileSystemService } from '../services/FileSystemService';
import { NodeRegistry } from '../services/NodeRegistry';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
import { isValidStackName } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
const REMOTE_STACKS_TIMEOUT_MS = 30_000;
export type AssertStackExistsResult =
| { ok: true }
| { ok: false; error: string };
/**
* Verify that `stackName` exists on `nodeId` before inserting a stack-scoped
* role assignment. Local nodes use FileSystemService; remotes use a machine
* GET /api/stacks via NodeRegistry.getProxyTarget.
*/
export async function assertStackExistsOnNode(
nodeId: number,
stackName: string,
): Promise<AssertStackExistsResult> {
if (!isValidStackName(stackName)) {
return { ok: false, error: 'Invalid stack name' };
}
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
return { ok: false, error: 'Node not found' };
}
if (node.type === 'local') {
try {
const stacks = await FileSystemService.getInstance(nodeId).getStacks();
if (!stacks.includes(stackName)) {
return { ok: false, error: 'Stack not found on node' };
}
return { ok: true };
} catch (err) {
console.error('[assertStackExistsOnNode] Local stack list failed:', getErrorMessage(err, 'unknown'));
return { ok: false, error: 'Failed to verify stack on node' };
}
}
const target = NodeRegistry.getInstance().getProxyTarget(nodeId);
if (!target) {
return { ok: false, error: 'Remote node is unreachable' };
}
const baseUrl = target.apiUrl.replace(/\/$/, '');
const headers: Record<string, string> = {
[PROXY_TIER_HEADER]: LicenseService.getInstance().getProxyHeaders().tier,
};
if (target.apiToken) {
headers.Authorization = `Bearer ${target.apiToken}`;
}
try {
const res = await axios.get(`${baseUrl}/api/stacks`, {
headers,
timeout: REMOTE_STACKS_TIMEOUT_MS,
validateStatus: () => true,
});
if (res.status < 200 || res.status >= 300) {
return { ok: false, error: 'Failed to verify stack on remote node' };
}
if (!Array.isArray(res.data)) {
return { ok: false, error: 'Failed to verify stack on remote node' };
}
const names = res.data.filter((n): n is string => typeof n === 'string');
if (!names.includes(stackName)) {
return { ok: false, error: 'Stack not found on node' };
}
return { ok: true };
} catch (err) {
console.error('[assertStackExistsOnNode] Remote stack list failed:', getErrorMessage(err, 'unknown'));
return { ok: false, error: 'Failed to verify stack on remote node' };
}
}
+224
View File
@@ -0,0 +1,224 @@
import { isPermissionAction, type PermissionAction } from '../middleware/permissions';
import { isValidStackName } from '../utils/validation';
export type StackRouteClassify =
| { kind: 'named-stack'; stackName: string; action: PermissionAction }
| { kind: 'static' }
| { kind: 'unknown-named' };
/** Static collection / create paths under /stacks (no stack-scoped resource). */
const STATIC_STACK_PATHS = new Set([
'/stacks',
'/stacks/',
'/stacks/statuses',
'/stacks/discovery',
'/stacks/import/scan',
'/stacks/import/move',
'/stacks/bulk',
'/stacks/from-git',
]);
/**
* Exact relative suffixes under `/stacks/:name` mapped to the primary hub
* pre-check action. Service-name paths are matched separately via regex.
*/
type SuffixRule = { method: string; suffix: string; action: PermissionAction };
const EXACT_SUFFIX_RULES: readonly SuffixRule[] = [
// Read
{ method: 'GET', suffix: '', action: 'stack:read' },
{ method: 'GET', suffix: '/envs', action: 'stack:read' },
{ method: 'GET', suffix: '/env', action: 'stack:read' },
{ method: 'GET', suffix: '/project-env-files', action: 'stack:read' },
{ method: 'GET', suffix: '/project-env-files/candidates', action: 'stack:read' },
{ method: 'GET', suffix: '/dossier', action: 'stack:read' },
{ method: 'GET', suffix: '/containers', action: 'stack:read' },
{ method: 'GET', suffix: '/services', action: 'stack:read' },
{ method: 'GET', suffix: '/drift', action: 'stack:read' },
{ method: 'GET', suffix: '/preflight', action: 'stack:read' },
{ method: 'GET', suffix: '/missing-external-networks', action: 'stack:read' },
{ method: 'GET', suffix: '/preflight/acknowledgements', action: 'stack:read' },
{ method: 'GET', suffix: '/networking', action: 'stack:read' },
{ method: 'GET', suffix: '/storage', action: 'stack:read' },
{ method: 'GET', suffix: '/effective-anatomy', action: 'stack:read' },
{ method: 'GET', suffix: '/effective-services', action: 'stack:read' },
{ method: 'GET', suffix: '/env-inventory', action: 'stack:read' },
{ method: 'GET', suffix: '/label-inventory', action: 'stack:read' },
{ method: 'GET', suffix: '/exposure', action: 'stack:read' },
{ method: 'GET', suffix: '/update-readiness', action: 'stack:read' },
{ method: 'GET', suffix: '/rollback-readiness', action: 'stack:read' },
{ method: 'GET', suffix: '/health-gate', action: 'stack:read' },
{ method: 'GET', suffix: '/update-preview', action: 'stack:read' },
{ method: 'GET', suffix: '/backup', action: 'stack:read' },
{ method: 'GET', suffix: '/scan-status', action: 'stack:read' },
{ method: 'GET', suffix: '/file-roots', action: 'stack:read' },
{ method: 'GET', suffix: '/files', action: 'stack:read' },
{ method: 'GET', suffix: '/files/content', action: 'stack:read' },
{ method: 'GET', suffix: '/files/download', action: 'stack:read' },
{ method: 'GET', suffix: '/files/bulk-download', action: 'stack:read' },
{ method: 'GET', suffix: '/files/permissions', action: 'stack:read' },
{ method: 'GET', suffix: '/activity', action: 'stack:read' },
{ method: 'GET', suffix: '/git-source', action: 'stack:read' },
// Edit
{ method: 'PUT', suffix: '', action: 'stack:edit' },
{ method: 'PUT', suffix: '/env', action: 'stack:edit' },
{ method: 'PUT', suffix: '/project-env-files', action: 'stack:edit' },
{ method: 'PUT', suffix: '/dossier', action: 'stack:edit' },
{ method: 'POST', suffix: '/drift/recheck', action: 'stack:read' },
{ method: 'POST', suffix: '/preflight/run', action: 'stack:read' },
{ method: 'POST', suffix: '/preflight/acknowledgements', action: 'stack:edit' },
{ method: 'PUT', suffix: '/exposure', action: 'stack:edit' },
{ method: 'POST', suffix: '/files/upload', action: 'stack:edit' },
{ method: 'PUT', suffix: '/files/content', action: 'stack:edit' },
{ method: 'DELETE', suffix: '/files', action: 'stack:edit' },
{ method: 'POST', suffix: '/files/folder', action: 'stack:edit' },
{ method: 'PATCH', suffix: '/files/rename', action: 'stack:edit' },
{ method: 'POST', suffix: '/files/copy', action: 'stack:edit' },
{ method: 'POST', suffix: '/files/bulk-delete', action: 'stack:edit' },
{ method: 'POST', suffix: '/files/bulk-move', action: 'stack:edit' },
{ method: 'PUT', suffix: '/files/permissions', action: 'stack:edit' },
{ method: 'PUT', suffix: '/labels', action: 'stack:edit' },
{ method: 'PUT', suffix: '/git-source', action: 'stack:edit' },
{ method: 'DELETE', suffix: '/git-source', action: 'stack:edit' },
{ method: 'POST', suffix: '/git-source/pull', action: 'stack:edit' },
{ method: 'POST', suffix: '/git-source/apply', action: 'stack:edit' },
{ method: 'POST', suffix: '/git-source/webhook-pull', action: 'stack:edit' },
{ method: 'POST', suffix: '/git-source/dismiss-pending', action: 'stack:edit' },
{ method: 'POST', suffix: '/git-source/browse', action: 'stack:edit' },
// Deploy
{ method: 'POST', suffix: '/deploy', action: 'stack:deploy' },
{ method: 'POST', suffix: '/down', action: 'stack:deploy' },
{ method: 'POST', suffix: '/restart', action: 'stack:deploy' },
{ method: 'POST', suffix: '/stop', action: 'stack:deploy' },
{ method: 'POST', suffix: '/start', action: 'stack:deploy' },
{ method: 'POST', suffix: '/update-preview', action: 'stack:deploy' },
{ method: 'POST', suffix: '/update', action: 'stack:deploy' },
{ method: 'POST', suffix: '/rollback', action: 'stack:deploy' },
{ method: 'POST', suffix: '/backup', action: 'stack:deploy' },
// Delete
{ method: 'DELETE', suffix: '', action: 'stack:delete' },
];
const EXACT_SUFFIX_INDEX = new Map<string, PermissionAction>(
EXACT_SUFFIX_RULES.map((r) => [`${r.method} ${r.suffix}`, r.action]),
);
/** `/services/:serviceName/{restart|stop|start|update|restore|recovery}` */
const SERVICE_SUFFIX_RE =
/^\/services\/[^/]+\/(restart|stop|start|update|restore|recovery)$/;
/** `/preflight/acknowledgements/:id` */
const PREFLIGHT_ACK_DELETE_RE = /^\/preflight\/acknowledgements\/[^/]+$/;
function normalizePath(pathAfterApiStrip: string): string {
const withoutQuery = pathAfterApiStrip.split('?')[0] ?? pathAfterApiStrip;
if (withoutQuery.length > 1 && withoutQuery.endsWith('/')) {
return withoutQuery.slice(0, -1);
}
return withoutQuery;
}
function decodeStackSegment(raw: string): string | null {
let decoded: string;
try {
decoded = decodeURIComponent(raw);
} catch {
return null;
}
if (!isValidStackName(decoded)) return null;
return decoded;
}
/**
* Classify a post-/api path for hub stack RBAC gating and evidence.
* Paths outside `/stacks` (and static `/stacks` collection routes) are
* `static`. Known named-stack families return the primary pre-check action.
* An unrecognized `/stacks/<name>/...` path fails closed as `unknown-named`.
*/
export function classifyStackApiPath(method: string, pathAfterApiStrip: string): StackRouteClassify {
const methodUpper = method.toUpperCase();
const path = normalizePath(pathAfterApiStrip);
if (!path.startsWith('/stacks')) {
return { kind: 'static' };
}
if (STATIC_STACK_PATHS.has(path) || (methodUpper === 'POST' && path === '/stacks')) {
return { kind: 'static' };
}
// Reserved first segments that look like names but are collection routes.
if (
path === '/stacks/statuses'
|| path === '/stacks/discovery'
|| path.startsWith('/stacks/import/')
|| path === '/stacks/bulk'
|| path === '/stacks/from-git'
) {
return { kind: 'static' };
}
const match = /^\/stacks\/([^/]+)(.*)$/.exec(path);
if (!match) {
return { kind: 'static' };
}
const stackName = decodeStackSegment(match[1]);
if (!stackName) {
return { kind: 'unknown-named' };
}
const suffix = match[2] ?? '';
const exact = EXACT_SUFFIX_INDEX.get(`${methodUpper} ${suffix}`);
if (exact) {
return { kind: 'named-stack', stackName, action: exact };
}
if (methodUpper === 'POST' && SERVICE_SUFFIX_RE.test(suffix)) {
const op = SERVICE_SUFFIX_RE.exec(suffix)?.[1];
if (op === 'recovery') {
// recovery is GET-only in stacks.ts; POST recovery is unknown
return { kind: 'unknown-named' };
}
return { kind: 'named-stack', stackName, action: 'stack:deploy' };
}
if (methodUpper === 'GET' && /^\/services\/[^/]+\/recovery$/.test(suffix)) {
return { kind: 'named-stack', stackName, action: 'stack:deploy' };
}
if (methodUpper === 'DELETE' && PREFLIGHT_ACK_DELETE_RE.test(suffix)) {
return { kind: 'named-stack', stackName, action: 'stack:edit' };
}
return { kind: 'unknown-named' };
}
/**
* Parse the comma-separated scoped-actions header. Returns null when empty
* or when any token is not a known PermissionAction.
*/
export function parseScopedStackActionsHeader(value: string): PermissionAction[] | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parts = trimmed.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
if (parts.length === 0) return null;
const actions: PermissionAction[] = [];
const seen = new Set<PermissionAction>();
for (const part of parts) {
if (!isPermissionAction(part)) return null;
if (seen.has(part)) continue;
seen.add(part);
actions.push(part);
}
return actions;
}
/** Serialize PermissionAction values for the scoped-actions proxy header. */
export function formatScopedStackActionsHeader(actions: Iterable<PermissionAction>): string {
return [...new Set(actions)].join(',');
}
+24 -1
View File
@@ -8,10 +8,20 @@ import {
type ApiTokenScope,
} from '../services/DatabaseService';
import { getErrorMessage } from '../utils/errors';
import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER, isDeploySourceHeader } from '../services/license-headers';
import {
PROXY_TIER_HEADER,
PROXY_ROLE_HEADER,
PROXY_DEPLOY_SOURCE_HEADER,
PROXY_DEPLOY_ACTOR_HEADER,
PROXY_SCOPED_STACK_NAME_HEADER,
PROXY_SCOPED_STACK_ACTIONS_HEADER,
isDeploySourceHeader,
} from '../services/license-headers';
import type { DeployInvocationContext } from '../services/network/missingExternalNetworksError';
import { isLicenseTier, normalizeTier } from '../services/license-normalize';
import { isDebugEnabled } from '../utils/debug';
import { parseScopedStackActionsHeader } from '../helpers/stackRouteAuth';
import { isValidStackName } from '../utils/validation';
import {
COOKIE_NAME,
MFA_PENDING_COOKIE_NAME,
@@ -150,6 +160,19 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
req.deployContext = ctx;
}
// Scoped stack auth evidence: only trust on this machine-auth path.
// Malformed or incomplete pairs are treated as absent (never as auth).
const scopedNameRaw = req.headers[PROXY_SCOPED_STACK_NAME_HEADER];
const scopedActionsRaw = req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER];
const scopedName = typeof scopedNameRaw === 'string' ? scopedNameRaw.trim() : '';
const scopedActionsStr = typeof scopedActionsRaw === 'string' ? scopedActionsRaw : '';
if (scopedName && isValidStackName(scopedName) && scopedActionsStr) {
const actions = parseScopedStackActionsHeader(scopedActionsStr);
if (actions && actions.length > 0) {
req.scopedStackEvidence = { stackName: scopedName, actions: new Set(actions) };
}
}
next();
return;
}
+80 -1
View File
@@ -34,6 +34,50 @@ export const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
],
};
/** Canonical PermissionAction set (admin matrix covers every action). */
export const ALL_PERMISSION_ACTIONS: readonly PermissionAction[] = ROLE_PERMISSIONS.admin;
export function isPermissionAction(value: string): value is PermissionAction {
return (ALL_PERMISSION_ACTIONS as readonly string[]).includes(value);
}
/**
* Collect stack:* actions from role matrices. Used for remote evidence so
* node:/system: never leave the hub on a machine-auth hop.
*/
function addStackActionsFromRole(
actions: Set<PermissionAction>,
role: UserRole,
): void {
for (const action of ROLE_PERMISSIONS[role] ?? []) {
if (action.startsWith('stack:')) {
actions.add(action);
}
}
}
/**
* Union of PermissionAction values conferred by the user's exact stack
* grant for (nodeId, stackName), plus any node-scoped grant on that node
* (node-wide roles cover every stack on the node). Used when the hub
* builds bound evidence for a remote hop.
*/
export function scopedActionsForStack(
userId: number,
nodeId: number,
stackName: string,
): PermissionAction[] {
const db = DatabaseService.getInstance();
const actions = new Set<PermissionAction>();
for (const assignment of db.getRoleAssignments(userId, 'stack', stackName, nodeId)) {
addStackActionsFromRole(actions, assignment.role);
}
for (const assignment of db.getRoleAssignments(userId, 'node', String(nodeId))) {
addStackActionsFromRole(actions, assignment.role);
}
return [...actions];
}
/** Core permission resolver. Admin bypasses all checks; scoped assignments only apply on the paid tier. */
export function checkPermission(
req: Request,
@@ -51,14 +95,49 @@ export function checkPermission(
if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true;
if (!resourceType || !resourceId) return false;
// Bound machine evidence from the hub (node_proxy / pilot_tunnel only).
// Authorizes exact stack + action members of the evidenced set without a
// local role_assignments row (remote userId is 0).
const evidence = req.scopedStackEvidence;
if (
evidence
&& resourceType === 'stack'
&& resourceId === evidence.stackName
&& action.startsWith('stack:')
&& evidence.actions.has(action)
) {
return true;
}
if (effectiveTier(req) !== 'paid') return false;
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
const db = DatabaseService.getInstance();
const nodeId = resourceType === 'stack' ? req.nodeId : null;
const assignments = db.getRoleAssignments(
req.user.userId,
resourceType,
resourceId,
nodeId,
);
if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId);
for (const assignment of assignments) {
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
}
// 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) {
const nodeAssignments = db.getRoleAssignments(
req.user.userId,
'node',
String(req.nodeId),
);
for (const assignment of nodeAssignments) {
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
}
}
return false;
}
+94 -2
View File
@@ -1,17 +1,38 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { NodeRegistry } from '../services/NodeRegistry';
import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER } from '../services/license-headers';
import {
PROXY_TIER_HEADER,
PROXY_ROLE_HEADER,
PROXY_DEPLOY_SOURCE_HEADER,
PROXY_DEPLOY_ACTOR_HEADER,
PROXY_SCOPED_STACK_NAME_HEADER,
PROXY_SCOPED_STACK_ACTIONS_HEADER,
} from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities';
import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, SERVICE_SCOPED_UPDATE_CAPABILITY, SERVICE_SCOPED_STACK_ALERT_CAPABILITY } from '../services/CapabilityRegistry';
import {
STACK_DOWN_REMOVE_VOLUMES_CAPABILITY,
SERVICE_SCOPED_UPDATE_CAPABILITY,
SERVICE_SCOPED_STACK_ALERT_CAPABILITY,
SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY,
} from '../services/CapabilityRegistry';
import { getErrorMessage } from '../utils/errors';
import { DatabaseService } from '../services/DatabaseService';
import { redactSensitiveText } from '../utils/safeLog';
import { isDebugEnabled } from '../utils/debug';
import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming';
import { invalidateFleetUpdateCache, isFullStackUpdatePath, isUpdatePreviewPath } from '../helpers/fleetUpdateCache';
import {
classifyStackApiPath,
formatScopedStackActionsHeader,
} from '../helpers/stackRouteAuth';
import {
checkPermission,
ROLE_PERMISSIONS,
scopedActionsForStack,
} from '../middleware/permissions';
/**
* Per-request hop timing for the critical hydration GETs, kept off the Request
@@ -132,6 +153,17 @@ export function createRemoteProxyMiddleware(): RequestHandler {
if (req.user?.username) {
proxyReq.setHeader(PROXY_DEPLOY_ACTOR_HEADER, req.user.username);
}
// Scoped stack evidence: always strip client-supplied values, then
// attach hub-built evidence when the gate stashed elevation for this hop.
proxyReq.removeHeader(PROXY_SCOPED_STACK_NAME_HEADER);
proxyReq.removeHeader(PROXY_SCOPED_STACK_ACTIONS_HEADER);
if (req.proxyScopedStackEvidence) {
proxyReq.setHeader(PROXY_SCOPED_STACK_NAME_HEADER, req.proxyScopedStackEvidence.stackName);
proxyReq.setHeader(
PROXY_SCOPED_STACK_ACTIONS_HEADER,
formatScopedStackActionsHeader(req.proxyScopedStackEvidence.actions),
);
}
// Strip the ?nodeId= query param so the remote's nodeContextMiddleware
// doesn't reject the request with 404 ("Node X not found") - the remote
// has no record of the gateway's node IDs and should treat the request
@@ -188,6 +220,23 @@ export function createRemoteProxyMiddleware(): RequestHandler {
) {
invalidateFleetUpdateCache();
}
// Successful remote stack DELETE: clear hub grants for this (node, stack)
// only. Failed / non-2xx responses must preserve assignments. Use the
// gate-stashed classification: pathRewrite mutates req.url before this
// callback, so re-running classifyStackApiPath(req.path) would miss.
if (req.method === 'DELETE' && status >= 200 && status < 300) {
const route = req.proxyNamedStackRoute;
if (route?.action === 'stack:delete') {
try {
DatabaseService.getInstance().deleteRoleAssignmentsByStack(req.nodeId, route.stackName);
} catch (cleanupErr) {
console.warn(
'[Proxy] Failed to clear role assignments after remote stack delete:',
getErrorMessage(cleanupErr, 'unknown'),
);
}
}
}
},
error: (err, req, proxyRes) => {
// Finalize the hop timing with an error outcome before the existing
@@ -282,6 +331,49 @@ export function createRemoteProxyMiddleware(): RequestHandler {
}
}
// Named-stack hub pre-check + optional scoped-evidence elevation.
const classified = classifyStackApiPath(req.method, req.path);
if (classified.kind === 'unknown-named') {
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return;
}
if (classified.kind === 'named-stack') {
if (!checkPermission(req, classified.action, 'stack', classified.stackName)) {
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return;
}
// Stash before pathRewrite so proxyRes DELETE cleanup can see the route.
req.proxyNamedStackRoute = {
stackName: classified.stackName,
action: classified.action,
};
const globalRole = req.user?.role;
const globalGrantsPrimary =
globalRole === 'admin'
|| (globalRole != null && (ROLE_PERMISSIONS[globalRole]?.includes(classified.action) ?? false));
if (!globalGrantsPrimary) {
const evidenceSupported = await remoteAdvertisesCapability(
req.nodeId,
SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY,
);
if (!evidenceSupported) {
res.status(403).json({
error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`,
});
return;
}
if (!req.user) {
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return;
}
const actions = scopedActionsForStack(req.user.userId, req.nodeId, classified.stackName);
req.proxyScopedStackEvidence = {
stackName: classified.stackName,
actions,
};
}
}
// POST /alerts bodies are not on req.body for remote hops (JSON parsing
// is skipped so the stream can be piped). Buffer once under the same
// 100 KB cap as express.json(), gate on service_name, then rewrite
+3 -1
View File
@@ -24,7 +24,9 @@ permissionsRouter.get('/me', authMiddleware, (req: Request, res: Response): void
const scopedPermissions: Record<string, PermissionAction[]> = {};
if (effectiveTier(req) === 'paid') {
for (const a of db.getAllRoleAssignments(req.user.userId)) {
const key = `${a.resource_type}:${a.resource_id}`;
const key = a.resource_type === 'stack'
? `stack:${a.node_id}:${a.resource_id}`
: `node:${a.resource_id}`;
const perms = ROLE_PERMISSIONS[a.role] || [];
const existing = scopedPermissions[key] || [];
scopedPermissions[key] = [...new Set([...existing, ...perms])];
+69 -5
View File
@@ -10,6 +10,8 @@ import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
import { validateUsername } from '../helpers/validateUsername';
import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode';
import { isValidStackName } from '../utils/validation';
const USERS_SCOPE_MESSAGE = 'API tokens cannot access user management.';
const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
@@ -247,13 +249,13 @@ usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): voi
}
});
usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): void => {
usersRouter.post('/:id/roles', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
const { role, resource_type, resource_id } = req.body;
const { role, resource_type, resource_id, node_id: rawNodeId } = req.body;
if (!VALID_ASSIGNMENT_ROLES.includes(role)) {
res.status(400).json({ error: 'Invalid role' });
@@ -274,10 +276,72 @@ usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): vo
return;
}
let nodeId: number | null = null;
if (resource_type === 'stack') {
if (typeof rawNodeId !== 'number' || !Number.isInteger(rawNodeId)) {
res.status(400).json({ error: 'node_id is required for stack role assignments' });
return;
}
if (!isValidStackName(resource_id)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
const exists = await assertStackExistsOnNode(rawNodeId, resource_id);
if (!exists.ok) {
res.status(400).json({ error: exists.error });
return;
}
nodeId = rawNodeId;
} else {
// node resource: reject a stack-style node_id qualifier
if (rawNodeId !== undefined && rawNodeId !== null) {
res.status(400).json({ error: 'node_id must not be set for node role assignments' });
return;
}
if (!/^\d+$/.test(resource_id)) {
res.status(400).json({ error: 'resource_id must be a numeric node id' });
return;
}
const parsedNodeId = parseInt(resource_id, 10);
if (String(parsedNodeId) !== resource_id) {
res.status(400).json({ error: 'resource_id must be a canonical node id' });
return;
}
if (!db.getNode(parsedNodeId)) {
res.status(400).json({ error: 'Node not found' });
return;
}
}
try {
const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id });
console.log('[Roles] Assigned', sanitizeForLog(role), 'on', sanitizeForLog(resource_type), sanitizeForLog(resource_id), 'to user', userId, 'by:', sanitizeForLog(req.user!.username));
res.status(201).json({ id, user_id: userId, role, resource_type, resource_id });
const id = db.addRoleAssignment({
user_id: userId,
role,
resource_type,
resource_id,
node_id: nodeId,
});
console.log(
'[Roles] Assigned',
sanitizeForLog(role),
'on',
sanitizeForLog(resource_type),
sanitizeForLog(resource_id),
sanitizeForLog(nodeId != null ? `node ${nodeId}` : ''),
'to user',
userId,
'by:',
sanitizeForLog(req.user!.username),
);
res.status(201).json({
id,
user_id: userId,
role,
resource_type,
resource_id,
node_id: nodeId,
});
} catch (err: unknown) {
if (isSqliteUniqueViolation(err)) {
res.status(409).json({ error: 'This role assignment already exists' });
+1
View File
@@ -657,6 +657,7 @@ export class BlueprintService {
);
}
if (res.status === 200) {
DatabaseService.getInstance().deleteRoleAssignmentsByStack(node.id, blueprint.name);
return { status: 'withdrawn' };
}
if (res.status === 409) {
@@ -62,6 +62,7 @@ export const CAPABILITIES = [
'guided-external-network-preflight',
'service-scoped-update',
'service-scoped-stack-alert',
'scoped-stack-auth-evidence',
] as const;
/**
@@ -103,6 +104,15 @@ export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const
export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY =
'service-scoped-stack-alert' as const satisfies Capability;
/**
* Remotes that consume hub-bound scoped stack auth evidence headers
* (`x-sencho-scoped-stack-name` / `x-sencho-scoped-stack-actions`) under
* machine auth. Hubs fail closed when scoped elevation is needed and the
* remote lacks this flag.
*/
export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY =
'scoped-stack-auth-evidence' as const satisfies Capability;
/** Returns true when the string is a usable semver version. */
export function isValidVersion(v: string | null | undefined): v is string {
return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v);
+147 -11
View File
@@ -508,6 +508,8 @@ export interface RoleAssignment {
role: UserRole;
resource_type: ResourceType;
resource_id: string;
/** Required for stack scopes; null for node scopes. */
node_id: number | null;
created_at: number;
}
@@ -2245,11 +2247,109 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id);
`);
try {
this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_unique ON role_assignments(user_id, role, resource_type, resource_id)');
} catch (e) {
console.warn('[DatabaseService] Could not create role_assignments unique index:', (e as Error).message);
}
this.migrateRoleAssignmentsNodeQualified();
}
/**
* Rebuild role_assignments with Mesh-style stack identity (node_id, resource_id).
* Idempotent: probes sqlite_master for the final CHECK and both partial unique indexes.
*/
private migrateRoleAssignmentsNodeQualified(): void {
const tableSql = (this.db.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'"
).get() as { sql: string } | undefined)?.sql ?? '';
const indexRows = this.db.prepare(
"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'"
).all() as Array<{ name: string; sql: string | null }>;
const hasNodeIdColumn = (this.db.prepare(
'PRAGMA table_info(role_assignments)'
).all() as Array<{ name: string }>).some((column) => column.name === 'node_id');
const indexSqlByName = new Map(indexRows.map((r) => [r.name, r.sql ?? '']));
const checkOk =
tableSql.includes("resource_type = 'stack' AND node_id IS NOT NULL") &&
tableSql.includes("resource_type = 'node' AND node_id IS NULL");
const stackUniqueSql = indexSqlByName.get('idx_role_assignments_stack_unique') ?? '';
const nodeUniqueSql = indexSqlByName.get('idx_role_assignments_node_unique') ?? '';
const stackUniqueOk =
stackUniqueSql.includes('user_id') &&
stackUniqueSql.includes('role') &&
stackUniqueSql.includes('resource_type') &&
stackUniqueSql.includes('resource_id') &&
stackUniqueSql.includes('node_id') &&
/WHERE\s+resource_type\s*=\s*'stack'/i.test(stackUniqueSql);
const nodeUniqueOk =
nodeUniqueSql.includes('user_id') &&
nodeUniqueSql.includes('role') &&
nodeUniqueSql.includes('resource_type') &&
nodeUniqueSql.includes('resource_id') &&
/WHERE\s+resource_type\s*=\s*'node'/i.test(nodeUniqueSql) &&
!/node_id/.test(nodeUniqueSql.replace(/WHERE[\s\S]*/i, ''));
if (checkOk && stackUniqueOk && nodeUniqueOk) return;
this.db.exec('DROP TABLE IF EXISTS role_assignments_new');
// Do not call getDefaultNode(): NODE_COLUMNS may include columns not
// yet added when this migration runs early in the constructor chain.
const defaultNodeId = (
this.db.prepare('SELECT id FROM nodes WHERE is_default = 1 LIMIT 1').get() as { id: number } | undefined
)?.id ?? null;
this.db.transaction(() => {
this.db.exec(`
CREATE TABLE role_assignments_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
node_id INTEGER,
created_at INTEGER NOT NULL,
CHECK (
(resource_type = 'stack' AND node_id IS NOT NULL)
OR (resource_type = 'node' AND node_id IS NULL)
),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
`);
this.db.exec(`
INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at)
SELECT id, user_id, role, resource_type, resource_id, NULL, created_at
FROM role_assignments
WHERE resource_type = 'node';
`);
if (hasNodeIdColumn) {
this.db.exec(`
INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at)
SELECT id, user_id, role, resource_type, resource_id, node_id, created_at
FROM role_assignments
WHERE resource_type = 'stack' AND node_id IS NOT NULL
`);
} else if (defaultNodeId !== null) {
this.db.prepare(`
INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at)
SELECT id, user_id, role, resource_type, resource_id, ?, created_at
FROM role_assignments
WHERE resource_type = 'stack'
`).run(defaultNodeId);
}
// No default node: legacy stack rows are intentionally omitted (fail closed).
this.db.exec(`
DROP TABLE role_assignments;
ALTER TABLE role_assignments_new RENAME TO role_assignments;
CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_stack_unique
ON role_assignments(user_id, role, resource_type, resource_id, node_id)
WHERE resource_type = 'stack';
CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_node_unique
ON role_assignments(user_id, role, resource_type, resource_id)
WHERE resource_type = 'node';
`);
})();
}
private migrateNotificationRoutes(): void {
@@ -4723,6 +4823,7 @@ export class DatabaseService {
this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ?').run(id);
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
this.deleteRoleAssignmentsByResource('node', String(id));
this.deleteRoleAssignmentsByStackNode(id);
this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM node_update_skips WHERE node_id = ?').run(id);
this.db.prepare(
@@ -5405,23 +5506,44 @@ export class DatabaseService {
// --- Role Assignments ---
public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] {
public getRoleAssignments(
userId: number,
resourceType: ResourceType,
resourceId: string,
nodeId?: number | null,
): RoleAssignment[] {
if (resourceType === 'stack') {
if (nodeId === undefined || nodeId === null) return [];
return this.db.prepare(
'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id = ?'
).all(userId, resourceType, resourceId, nodeId) as RoleAssignment[];
}
return this.db.prepare(
'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ?'
'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id IS NULL'
).all(userId, resourceType, resourceId) as RoleAssignment[];
}
public getAllRoleAssignments(userId: number): RoleAssignment[] {
return this.db.prepare(
'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id'
'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id, node_id'
).all(userId) as RoleAssignment[];
}
public addRoleAssignment(assignment: { user_id: number; role: UserRole; resource_type: ResourceType; resource_id: string }): number {
public addRoleAssignment(assignment: {
user_id: number;
role: UserRole;
resource_type: ResourceType;
resource_id: string;
node_id?: number | null;
}): number {
const now = Date.now();
const nodeId = assignment.resource_type === 'stack' ? assignment.node_id ?? null : null;
if (assignment.resource_type === 'stack' && (nodeId === null || nodeId === undefined)) {
throw new Error('node_id is required for stack role assignments');
}
const result = this.db.prepare(
'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, created_at) VALUES (?, ?, ?, ?, ?)'
).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, now);
'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, node_id, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, nodeId, now);
return result.lastInsertRowid as number;
}
@@ -5441,6 +5563,20 @@ export class DatabaseService {
this.db.prepare('DELETE FROM role_assignments WHERE resource_type = ? AND resource_id = ?').run(resourceType, resourceId);
}
/** Clear stack-scoped grants for one (nodeId, stackName) tuple. */
public deleteRoleAssignmentsByStack(nodeId: number, stackName: string): void {
this.db.prepare(
"DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ? AND resource_id = ?"
).run(nodeId, stackName);
}
/** Clear all stack-scoped grants for a node (explicit cleanup; FK CASCADE is not enforced). */
public deleteRoleAssignmentsByStackNode(nodeId: number): void {
this.db.prepare(
"DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ?"
).run(nodeId);
}
// --- SSO Config ---
public getSSOConfigs(): SSOConfig[] {
@@ -359,7 +359,7 @@ export class DeployedStackDeletionService {
try {
db.clearStackUpdateStatus(nodeId, stackName);
db.clearStackScanAttempts(nodeId, stackName);
db.deleteRoleAssignmentsByResource('stack', stackName);
db.deleteRoleAssignmentsByStack(nodeId, stackName);
db.deleteGitSource(stackName);
db.deleteStackDossier(nodeId, stackName);
db.deleteStackDriftFindings(nodeId, stackName);
+10
View File
@@ -27,6 +27,16 @@ export const PROXY_ROLE_HEADER = 'x-sencho-actor-role';
export const PROXY_DEPLOY_SOURCE_HEADER = 'x-sencho-deploy-source';
export const PROXY_DEPLOY_ACTOR_HEADER = 'x-sencho-deploy-actor';
/**
* Bound stack-scoped RBAC evidence for Proxy/Pilot hops. The hub strips any
* client-supplied values and, when scoped elevation is required, sets the
* exact stack name plus a comma-separated PermissionAction set conferred by
* that tuple's hub assignments. Remotes trust these only under node_proxy /
* pilot_tunnel machine auth.
*/
export const PROXY_SCOPED_STACK_NAME_HEADER = 'x-sencho-scoped-stack-name';
export const PROXY_SCOPED_STACK_ACTIONS_HEADER = 'x-sencho-scoped-stack-actions';
export const DEPLOY_SOURCES = [
'manual',
'rollback',
+17
View File
@@ -1,5 +1,6 @@
import type { UserRole, ApiTokenScope, ApiToken } from '../services/DatabaseService';
import type { LicenseTier } from '../services/license-types';
import type { PermissionAction } from '../middleware/permissions';
// Extend Express Request type for user and node context.
// This file is imported for its side effects only (ambient declaration).
@@ -27,6 +28,22 @@ declare global {
deployContext?: import('../services/network/missingExternalNetworksError').DeployInvocationContext;
/** Verified JWT scope for machine credentials (`node_proxy` / `pilot_tunnel`). */
machineAuthScope?: 'node_proxy' | 'pilot_tunnel';
/**
* Hub-bound stack-scoped action evidence, trusted only when set under
* machine auth (`node_proxy` / `pilot_tunnel`). Never set from browser sessions.
*/
scopedStackEvidence?: { stackName: string; actions: ReadonlySet<PermissionAction> };
/**
* Hub-side pending evidence to attach on the outbound proxy hop when
* the caller's global role alone would not grant the primary action.
*/
proxyScopedStackEvidence?: { stackName: string; actions: readonly PermissionAction[] };
/**
* Named-stack classification from the hub gate. Stashed because
* http-proxy pathRewrite mutates req.url before proxyRes, so
* re-classifying req.path there would miss DELETE cleanup.
*/
proxyNamedStackRoute?: { stackName: string; action: PermissionAction };
}
}
}
+11 -10
View File
@@ -115,27 +115,28 @@ Click **Update user** to save. Changing the role takes effect on the next API re
Scoped permissions let you grant a user a higher role on a specific stack or node without elevating them globally. A Viewer can be granted Deployer on one stack; a Deployer can be granted Node Admin on one server.
**Stack scopes are node-specific.** The same stack name on two different nodes is two independent grants. Assigning a stack scope means choosing the node first, then picking a stack that exists on that node. Display form conceptually: stack name @ node name (for example `frontend @ prod`).
**Node scopes are node-wide.** The resource is the node itself. There is no separate node qualifier on a node assignment row. Granting Node Admin (or Deployer, or Admin) on `staging-server` authorizes that role's stack and node operations for every stack on that node, without a separate per-stack grant.
The box appears below the user form whenever you are editing a user on Admiral.
<Frame>
<img src="/images/rbac/scoped-permissions.png" alt="Edit User form for the viewer account with a Scoped Permissions box below. The box contains an existing assignment row (a Deployer badge with the text on Stack: bazarr and a destructive trash icon on the right) and a three-column add-scope row underneath (Role combobox set to Deployer, Resource Type combobox set to Stack, Resource combobox showing Select..., and a disabled Add button)." />
</Frame>
The add-scope form has three controls and an **Add** button:
The add-scope form has these controls and an **Add** button:
| Control | Options |
|---------|---------|
| **Role** | Deployer, Node Admin, or Admin. The scoped role picker is narrower than the global role picker. Viewer and Auditor cannot be scoped (they are floor-only roles). |
| **Resource Type** | `Stack` or `Node`. |
| **Resource** | The picker shows stacks (when type is `Stack`) or remote nodes (when type is `Node`) the gateway knows about. Resource names match what you see in the sidebar. |
| **Node** | Shown when Resource Type is `Stack`. Choose the node that hosts the stack before the stack picker unlocks. |
| **Stack** or **Node** | When type is `Stack`, the picker lists stacks on the selected node. When type is `Node`, the picker lists nodes the gateway knows about. Names match what you see in the sidebar. |
Click **Add** to save the assignment. Existing scopes render as a row with the role badge, the line `on <type>: <resource>`, and a trash icon for removal. Removing an assignment is instant; the user's effective permissions are recomputed on their next request.
Click **Add** to save the assignment. Existing scopes render as a row with the role badge, the line `on <type>: <resource>`, and for stacks the node name after an `@`, plus a trash icon for removal. Removing an assignment is instant; the user's effective permissions are recomputed on their next request.
Scoped assignments are **additive only**. A Viewer with a scoped Deployer on `frontend` can deploy `frontend` but stays read-only on every other resource. Scopes never reduce the global role.
Scoped assignments are **additive only**. A Viewer with a scoped Deployer on `frontend` at a given node can deploy that stack on that node but stays read-only on every other resource. Scopes never reduce the global role.
### Example scenarios
- A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack can deploy, restart, and stop only that stack. They cannot edit compose or delete it.
- A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack at node `prod` can deploy, restart, and stop only that stack on `prod`. The same name on another node needs its own grant. They cannot edit compose or delete it.
- A **Deployer** with a scoped **Node Admin** assignment on node `staging-server` can manage every stack and node operation on that server, while keeping plain Deployer rights on the rest of the fleet.
- A **Node Admin** without any scoped assignments has full stack and node management across every node, but still cannot reach system settings, the user list, or the audit log.
@@ -237,7 +238,7 @@ Entries include the acting user, IP address, HTTP method and path, response stat
Check whether **Session policy > Keep active sessions alive** was turned off in **Settings > Users**. With it off, every session hits a strict, fixed 24-hour (or 30-day, with **Stay signed in**) ceiling regardless of activity. Turn it back on so an active session renews itself instead of hard-expiring, or have the user check **Stay signed in** at their next sign-in for a longer session between visits.
</Accordion>
<Accordion title="A scoped Deployer cannot deploy a stack they were granted">
Two causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or name on the assignment does not match the request's resource. Re-open the user in the edit form and check the existing-scope row matches the stack name (case-sensitive) exactly.
Three causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or stack name on the assignment does not match the request's resource (names are case-sensitive). **Three,** the stack grant is tied to a different node than the one the user is acting on: the same stack name on another node is a separate grant. Re-open the user in the edit form and confirm the existing-scope row shows the expected stack name at the expected node.
</Accordion>
<Accordion title="The shield (Reset 2FA) icon is missing on a user I expected to see it on">
The icon only appears for users with a finished TOTP enrollment. If the user started enrollment but never confirmed their first code, the enrollment is incomplete and the icon stays hidden. Ask the user to finish enrollment from their account settings, or, if they cannot, leave the row alone: there is nothing to reset.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

+2 -1
View File
@@ -296,7 +296,7 @@ export default function EditorLayout() {
hasServiceScopedUpdate: hasCapability('service-scoped-update'),
canEditStack: (stackNameOrFilename) => {
const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, '');
return can('stack:edit', 'stack', stackName);
return can('stack:edit', 'stack', stackName, activeNode?.id);
},
canOfferVolumeRemoval,
onDeletedOpenStack: () => onDeletedOpenStackRef.current(),
@@ -1059,6 +1059,7 @@ export default function EditorLayout() {
can={can}
selectedFile={selectedFile}
stackName={stackName}
activeNodeId={activeNode?.id ?? null}
gitSourceOpen={gitSourceOpen}
setGitSourceOpen={setGitSourceOpen}
canSelfUpdate={hasCapability('self-update')}
@@ -311,7 +311,7 @@ export function EditorView(props: EditorViewProps) {
hasUnsavedChanges,
} = props;
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
const canEditCompose = can('stack:edit', 'stack', stackName);
const canEditCompose = can('stack:edit', 'stack', stackName, activeNode?.id);
// Dispose the underlying Monaco model when EditorView unmounts. The
// @monaco-editor/react wrapper reuses a single model per editor instance
@@ -358,7 +358,7 @@ export function EditorView(props: EditorViewProps) {
const safeContent = content || '';
const safeEnvContent = envContent || '';
const isRunning = safeContainers.some(c => c.State === 'running');
const canRead = can('stack:read', 'stack', stackName);
const canRead = can('stack:read', 'stack', stackName, activeNode?.id);
useEffect(() => {
if (activeTab === 'files' && !canRead) {
@@ -469,7 +469,7 @@ export function EditorView(props: EditorViewProps) {
result={recoveryResult}
activeNode={activeNode}
backupInfo={backupInfo}
canDeploy={can('stack:deploy', 'stack', stackName)}
canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)}
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
onRestart={restartStack}
onRollback={rollbackStack}
@@ -663,7 +663,7 @@ export function EditorView(props: EditorViewProps) {
{activeTab === 'files' && canRead ? (
<StackFileExplorer
stackName={stackName}
canEdit={can('stack:edit', 'stack', stackName)}
canEdit={can('stack:edit', 'stack', stackName, activeNode?.id)}
isDarkMode={isDarkMode}
onNavigateToCompose={() => setActiveTab('compose')}
onNavigateToEnv={() => setActiveTab('env')}
@@ -732,7 +732,7 @@ export function EditorView(props: EditorViewProps) {
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
applying={loadingAction === 'update'}
canEdit={can('stack:edit', 'stack', stackName)}
canEdit={can('stack:edit', 'stack', stackName, activeNode?.id)}
notifications={notifications}
requestedTab={props.requestedAnatomyTab}
/>
@@ -93,7 +93,7 @@ export function MobileStackDetail(props: EditorViewProps) {
const safeContainers = containers || [];
const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1;
const isRunning = safeContainers.some(c => c.State === 'running');
const canEditStack = can('stack:edit', 'stack', stackName);
const canEditStack = can('stack:edit', 'stack', stackName, activeNode?.id);
// The writable editor layer renders only for an editor; a stale editingCompose
// while the user lacks stack:edit falls back to the read-only Compose segment.
@@ -182,7 +182,7 @@ export function MobileStackDetail(props: EditorViewProps) {
result={recoveryResult}
activeNode={activeNode}
backupInfo={backupInfo}
canDeploy={can('stack:deploy', 'stack', stackName)}
canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)}
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
onRestart={restartStack}
onRollback={rollbackStack}
@@ -25,9 +25,10 @@ interface ShellOverlaysProps {
stackActions: StackActionsHook;
isDarkMode: boolean;
isAdmin: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
selectedFile: string | null;
stackName: string;
activeNodeId: number | null;
gitSourceOpen: boolean;
setGitSourceOpen: (open: boolean) => void;
canSelfUpdate: boolean;
@@ -45,6 +46,7 @@ export function ShellOverlays({
can,
selectedFile,
stackName,
activeNodeId,
gitSourceOpen,
setGitSourceOpen,
canSelfUpdate,
@@ -210,7 +212,7 @@ export function ShellOverlays({
open={gitSourceOpen}
onOpenChange={setGitSourceOpen}
stackName={stackName}
canEdit={can('stack:edit', 'stack', stackName)}
canEdit={can('stack:edit', 'stack', stackName, activeNodeId)}
isDarkMode={isDarkMode}
onSourceChanged={stackActions.refreshGitSourcePending}
/>
@@ -203,8 +203,8 @@ export function StackIdentityHeader({
backend permissions so a delete-only or deploy-only persona sees
exactly what they can act on. */}
{(() => {
const canDeploy = can('stack:deploy', 'stack', stackName);
const canDelete = can('stack:delete', 'stack', stackName);
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 canMute = stackMuteActions?.canMute ?? false;
@@ -31,7 +31,7 @@ interface UseSidebarContextMenuOptions {
stackActions: StackActionsHook;
activeNode: Node | null | undefined;
isAdmin: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
}
export function useSidebarContextMenu({
@@ -61,9 +61,9 @@ export function useSidebarContextMenu({
canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null,
isBusy: stackListState.isStackBusy(file),
isAdmin,
canDelete: can('stack:delete', 'stack', sName),
canDeploy: can('stack:deploy', 'stack', sName),
canEditLabels: can('stack:edit', 'stack', sName),
canDelete: can('stack:delete', 'stack', sName, nodeId),
canDeploy: can('stack:deploy', 'stack', sName, nodeId),
canEditLabels: can('stack:edit', 'stack', sName, nodeId),
// POST /api/labels (the inline "New label" entry) is guarded by the
// unscoped requirePermission('stack:edit'); a user with only per-stack
// scoped edit can toggle existing labels but cannot create new ones.
@@ -342,7 +342,7 @@ export function NetworkingView({ headerActions }: NetworkingViewProps) {
<TableBody>
{topFindings.map((finding) => {
const primary = finding.recommendedActions.find((action) =>
isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack)),
isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack, nodeId)),
);
return (
<TableRow key={finding.id}>
+101 -19
View File
@@ -36,6 +36,7 @@ interface RoleAssignmentItem {
role: UserRole;
resource_type: 'stack' | 'node';
resource_id: string;
node_id: number | null;
created_at: number;
}
@@ -209,6 +210,11 @@ export function UsersSection() {
setFormRole('viewer');
setEditingUser(null);
setShowForm(false);
setRoleAssignments([]);
setScopeResourceType('stack');
setScopeNodeId('');
setScopeResourceId('');
setAvailableStacks([]);
};
const handleSave = async () => {
@@ -313,16 +319,18 @@ export function UsersSection() {
setFormConfirmPassword('');
setShowForm(true);
fetchRoleAssignments(u.id);
fetchScopeResources();
void fetchAvailableNodes();
};
// --- Scoped Role Assignments ---
const [roleAssignments, setRoleAssignments] = useState<RoleAssignmentItem[]>([]);
const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack');
const [scopeNodeId, setScopeNodeId] = useState<string>('');
const [scopeResourceId, setScopeResourceId] = useState('');
const [scopeRole, setScopeRole] = useState<UserRole>('deployer');
const [availableStacks, setAvailableStacks] = useState<string[]>([]);
const [availableNodes, setAvailableNodes] = useState<{ id: number; name: string }[]>([]);
const [loadingStacks, setLoadingStacks] = useState(false);
const [addingScope, setAddingScope] = useState(false);
const fetchRoleAssignments = async (userId: number) => {
@@ -333,16 +341,9 @@ export function UsersSection() {
} catch { setRoleAssignments([]); }
};
const fetchScopeResources = async () => {
const fetchAvailableNodes = async () => {
try {
const [stacksRes, nodesRes] = await Promise.all([
apiFetch('/stacks', { localOnly: true }),
apiFetch('/nodes', { localOnly: true }),
]);
if (stacksRes.ok) {
const data = await stacksRes.json();
setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []);
}
const nodesRes = await apiFetch('/nodes', { localOnly: true });
if (nodesRes.ok) {
const data = await nodesRes.json();
setAvailableNodes(Array.isArray(data) ? data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })) : []);
@@ -350,14 +351,51 @@ export function UsersSection() {
} catch { /* ignore */ }
};
const fetchStacksForNode = async (nodeIdStr: string) => {
if (!nodeIdStr) {
setAvailableStacks([]);
return;
}
const nodeId = parseInt(nodeIdStr, 10);
if (!Number.isInteger(nodeId)) {
setAvailableStacks([]);
return;
}
setLoadingStacks(true);
try {
const stacksRes = await apiFetch('/stacks', { nodeId });
if (stacksRes.ok) {
const data = await stacksRes.json();
setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []);
} else {
setAvailableStacks([]);
toast.error('Failed to load stacks for the selected node.');
}
} catch {
setAvailableStacks([]);
toast.error('Failed to load stacks for the selected node.');
} finally {
setLoadingStacks(false);
}
};
const addRoleAssignment = async () => {
if (!editingUser || !scopeResourceId) return;
if (scopeResourceType === 'stack' && !scopeNodeId) return;
setAddingScope(true);
try {
const body: Record<string, unknown> = {
role: scopeRole,
resource_type: scopeResourceType,
resource_id: scopeResourceId,
};
if (scopeResourceType === 'stack') {
body.node_id = parseInt(scopeNodeId, 10);
}
const res = await apiFetch(`/users/${editingUser.id}/roles`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ role: scopeRole, resource_type: scopeResourceType, resource_id: scopeResourceId }),
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json();
@@ -476,21 +514,29 @@ export function UsersSection() {
{roleAssignments.length > 0 && (
<div className="space-y-1">
{roleAssignments.map((a) => (
{roleAssignments.map((a) => {
const nodeLabel = a.resource_type === 'stack' && a.node_id != null
? (availableNodes.find((n) => n.id === a.node_id)?.name ?? `node ${a.node_id}`)
: null;
return (
<div key={a.id} className="flex items-center justify-between text-sm bg-muted/50 rounded px-3 py-1.5">
<span>
<Badge variant="outline" className="text-xs mr-2 capitalize">{a.role}</Badge>
on <span className="font-medium capitalize">{a.resource_type}</span>: <span className="font-mono text-xs">{a.resource_id}</span>
{nodeLabel != null && (
<span className="text-muted-foreground"> @ {nodeLabel}</span>
)}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => removeRoleAssignment(a.id)}>
<Trash2 className="w-3 h-3 text-destructive" strokeWidth={1.5} />
</Button>
</div>
))}
);
})}
</div>
)}
<div className="flex items-end gap-2">
<div className="flex items-end gap-2 flex-wrap">
<div className="space-y-1">
<Label className="text-xs">Role</Label>
<Combobox
@@ -513,13 +559,35 @@ export function UsersSection() {
{ value: 'node', label: 'Node' },
]}
value={scopeResourceType}
onValueChange={(v) => { setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }}
onValueChange={(v) => {
setScopeResourceType(v as 'stack' | 'node');
setScopeResourceId('');
setScopeNodeId('');
setAvailableStacks([]);
void fetchAvailableNodes();
}}
placeholder="Type..."
className="h-8 text-xs w-[100px]"
/>
</div>
<div className="space-y-1 flex-1">
<Label className="text-xs">Resource</Label>
{scopeResourceType === 'stack' && (
<div className="space-y-1">
<Label className="text-xs">Node</Label>
<Combobox
options={availableNodes.map((n) => ({ value: String(n.id), label: n.name }))}
value={scopeNodeId}
onValueChange={(v) => {
setScopeNodeId(v);
setScopeResourceId('');
void fetchStacksForNode(v);
}}
placeholder="Select node..."
className="h-8 text-xs w-[140px]"
/>
</div>
)}
<div className="space-y-1 flex-1 min-w-[140px]">
<Label className="text-xs">{scopeResourceType === 'stack' ? 'Stack' : 'Node'}</Label>
<Combobox
options={scopeResourceType === 'stack'
? availableStacks.map((s) => ({ value: s, label: s }))
@@ -527,11 +595,25 @@ export function UsersSection() {
}
value={scopeResourceId}
onValueChange={setScopeResourceId}
placeholder="Select..."
placeholder={
scopeResourceType === 'stack'
? (loadingStacks ? 'Loading stacks...' : (!scopeNodeId ? 'Select a node first...' : 'Select stack...'))
: 'Select...'
}
className="h-8 text-xs"
disabled={scopeResourceType === 'stack' && (!scopeNodeId || loadingStacks)}
/>
</div>
<Button size="sm" className="h-8" onClick={addRoleAssignment} disabled={addingScope || !scopeResourceId}>
<Button
size="sm"
className="h-8"
onClick={addRoleAssignment}
disabled={
addingScope
|| !scopeResourceId
|| (scopeResourceType === 'stack' && !scopeNodeId)
}
>
<Plus className="w-3 h-3 mr-1" strokeWidth={1.5} />
Add
</Button>
@@ -110,7 +110,7 @@ export default function EnvironmentPanel({ stackName }: { stackName: string }) {
// Project env file selection
const projectEnvCapable = hasCapability('project-env-files');
const canEdit = can('stack:edit', 'stack', stackName);
const canEdit = can('stack:edit', 'stack', stackName, nodeId);
const [projectEnvFiles, setProjectEnvFiles] = useState<string[]>([]);
const [candidates, setCandidates] = useState<string[]>([]);
const [savingProjectEnv, setSavingProjectEnv] = useState(false);
+8 -15
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { markMilestone } from '@/lib/hydrationTiming';
import { resolveCan } from '@/lib/resolveCan';
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge' | 'authenticated';
@@ -33,7 +34,7 @@ interface AuthContextType {
permissions: PermissionsData | null;
permissionsStatus: PermissionsStatus;
permissionsReady: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
ssoLdapLogin: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>;
@@ -128,20 +129,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized);
}, []);
const can = useCallback((action: PermissionAction, resourceType?: string, resourceId?: string): boolean => {
if (!permissions) return false;
if (permissions.globalRole === 'admin') return true;
if (permissions.globalPermissions.includes(action)) return true;
if (resourceType && resourceId) {
const key = `${resourceType}:${resourceId}`;
return permissions.scopedPermissions[key]?.includes(action) ?? false;
}
return false;
}, [permissions]);
const can = useCallback((
action: PermissionAction,
resourceType?: string,
resourceId?: string,
nodeId?: number | null,
): boolean => resolveCan(permissions, action, resourceType, resourceId, nodeId), [permissions]);
const login = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
try {
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import { resolveCan, type PermissionsSnapshot } from '../resolveCan';
const viewerBase: PermissionsSnapshot = {
globalRole: 'viewer',
globalPermissions: ['stack:read', 'node:read'],
scopedPermissions: {},
};
describe('resolveCan', () => {
it('admin bypasses all checks', () => {
const perms: PermissionsSnapshot = {
globalRole: 'admin',
globalPermissions: [],
scopedPermissions: {},
};
expect(resolveCan(perms, 'system:users')).toBe(true);
expect(resolveCan(perms, 'stack:delete', 'stack', 'app', 1)).toBe(true);
});
it('grants from the global matrix without needing a resource', () => {
expect(resolveCan(viewerBase, 'stack:read')).toBe(true);
expect(resolveCan(viewerBase, 'stack:deploy')).toBe(false);
});
it('treats same stack name on different nodes as independent grants', () => {
const perms: PermissionsSnapshot = {
...viewerBase,
scopedPermissions: {
'stack:1:frontend': ['stack:read', 'stack:deploy'],
'stack:2:frontend': ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'],
},
};
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true);
expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 1)).toBe(false);
expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 2)).toBe(true);
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 2)).toBe(true);
});
it('fails closed for stack lookups when nodeId is missing', () => {
const perms: PermissionsSnapshot = {
...viewerBase,
scopedPermissions: {
'stack:1:frontend': ['stack:deploy'],
},
};
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend')).toBe(false);
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', null)).toBe(false);
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true);
});
it('keeps node scopes keyed as node:id without a nodeId argument', () => {
const perms: PermissionsSnapshot = {
...viewerBase,
scopedPermissions: {
'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'],
},
};
expect(resolveCan(perms, 'node:manage', 'node', '7')).toBe(true);
expect(resolveCan(perms, 'stack:deploy', 'node', '7')).toBe(true);
expect(resolveCan(perms, 'node:manage', 'node', '8')).toBe(false);
});
it('node-scoped grants authorize stack actions on that node only', () => {
const perms: PermissionsSnapshot = {
...viewerBase,
scopedPermissions: {
'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'],
},
};
expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 7)).toBe(true);
expect(resolveCan(perms, 'stack:deploy', 'stack', 'other', 7)).toBe(true);
expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 8)).toBe(false);
});
it('returns false when permissions are null', () => {
expect(resolveCan(null, 'stack:read')).toBe(false);
});
});
+2
View File
@@ -40,6 +40,7 @@ export const CAPABILITIES = [
'guided-external-network-preflight',
'service-scoped-update',
'service-scoped-stack-alert',
'scoped-stack-auth-evidence',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
@@ -54,3 +55,4 @@ export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes'
export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability;
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY = 'service-scoped-stack-alert' as const satisfies Capability;
export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY = 'scoped-stack-auth-evidence' as const satisfies Capability;
+48
View File
@@ -0,0 +1,48 @@
/** Mirrors AuthContext PermissionAction / UserRole for the pure resolver (no circular import). */
export type ResolveCanRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
export type ResolveCanAction =
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
| 'node:read' | 'node:manage'
| 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks'
| 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries';
export interface PermissionsSnapshot {
globalRole: ResolveCanRole;
globalPermissions: ResolveCanAction[];
scopedPermissions: Record<string, ResolveCanAction[]>;
}
/**
* Pure permission resolver for AuthContext.can and unit tests.
* Stack scopes are keyed `stack:${nodeId}:${stackName}`; missing nodeId
* fails closed for stack lookups after the global matrix is checked.
* Node scopes stay `node:${id}` and also authorize that role's stack
* actions for every stack on the node (node-wide semantics).
*/
export function resolveCan(
permissions: PermissionsSnapshot | null,
action: ResolveCanAction,
resourceType?: string,
resourceId?: string,
nodeId?: number | null,
): boolean {
if (!permissions) return false;
if (permissions.globalRole === 'admin') return true;
if (permissions.globalPermissions.includes(action)) return true;
if (!resourceType || !resourceId) return false;
if (resourceType === 'stack') {
if (nodeId === undefined || nodeId === null) return false;
const stackKey = `stack:${nodeId}:${resourceId}`;
if (permissions.scopedPermissions[stackKey]?.includes(action)) return true;
const nodeKey = `node:${nodeId}`;
return permissions.scopedPermissions[nodeKey]?.includes(action) ?? false;
}
const key = `${resourceType}:${resourceId}`;
return permissions.scopedPermissions[key]?.includes(action) ?? false;
}