mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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(',');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])];
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user