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

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

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

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

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

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

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

* fix(rbac): preserve node-qualified grants during repair
This commit is contained in:
Anso
2026-07-29 09:42:14 -04:00
committed by GitHub
parent d698eb46f9
commit 9922d8e765
37 changed files with 2487 additions and 143 deletions
@@ -263,7 +263,7 @@ describe('BlueprintService remote deploy', () => {
expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)?.status).toBe('failed');
});
it('does not clear hub role assignments when withdrawing a remote deployment', async () => {
it('clears hub role assignments for the withdrawn remote stack tuple', async () => {
const bcrypt = await import('bcrypt');
const db = DatabaseService.getInstance();
const node = seedRemoteNode();
@@ -282,20 +282,20 @@ describe('BlueprintService remote deploy', () => {
username: `remote-wd-rbac-${counter}`, password_hash: hash, role: 'viewer',
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name,
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name, node_id: node.id,
});
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: { status: 'withdrawn' } });
const delSpy = vi.spyOn(axios, 'delete');
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource');
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack');
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj);
expect(result.status).toBe('withdrawn');
expect(delSpy).not.toHaveBeenCalled();
expect(rbacSpy).not.toHaveBeenCalled();
expect(rbacSpy).toHaveBeenCalledWith(node.id, bpObj.name);
expect(db.getAllRoleAssignments(userId)
.some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name)).toBe(true);
.some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name && a.node_id === node.id)).toBe(false);
db.deleteUser(userId);
});
+28 -19
View File
@@ -493,10 +493,10 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
const userId = db.addUser({ username: `bp-rbac-${counter}`, password_hash: hash, role: 'viewer' });
const otherNodeId = seedNode();
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name,
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name, node_id: nodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack',
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: nodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId),
@@ -552,33 +552,42 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
const { bp, node, nodeId, userId, deleteStackSpy, db } = await arrangeLocalWithdraw();
const fsErr = Object.assign(new Error('permission denied'), { code: 'EACCES' });
deleteStackSpy.mockRejectedValue(fsErr);
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource');
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack');
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
try {
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
expect(outcome.status).toBe('failed');
expect(rbacSpy).not.toHaveBeenCalled();
expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed');
expect(hasAssignment(userId, 'stack', bp.name)).toBe(true);
db.deleteUser(userId);
expect(outcome.status).toBe('failed');
expect(rbacSpy).not.toHaveBeenCalled();
expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed');
expect(hasAssignment(userId, 'stack', bp.name)).toBe(true);
} finally {
rbacSpy.mockRestore();
db.deleteUser(userId);
}
});
it('fails withdraw and keeps the deployment when role-assignment cleanup throws', async () => {
const { bp, node, nodeId, userId, db } = await arrangeLocalWithdraw();
vi.spyOn(db, 'deleteRoleAssignmentsByResource')
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack')
.mockImplementation(() => { throw new Error('simulated rbac cleanup failure'); });
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
try {
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
expect(outcome.status).toBe('failed');
expect(db.getDeployment(bp.id, nodeId)).toBeDefined();
expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed');
expect(errorSpy.mock.calls.some((args) =>
typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'),
)).toBe(true);
expect(hasAssignment(userId, 'stack', bp.name)).toBe(true);
db.deleteUser(userId);
expect(outcome.status).toBe('failed');
expect(db.getDeployment(bp.id, nodeId)).toBeDefined();
expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed');
expect(errorSpy.mock.calls.some((args) =>
typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'),
)).toBe(true);
expect(hasAssignment(userId, 'stack', bp.name)).toBe(true);
} finally {
rbacSpy.mockRestore();
errorSpy.mockRestore();
db.deleteUser(userId);
}
});
});
@@ -0,0 +1,236 @@
/**
* Unit tests for checkPermission evidence + scopedActionsForStack with
* node-qualified stack grants. Uses mocked Request objects where possible.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { Request } from 'express';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import {
checkPermission,
scopedActionsForStack,
type PermissionAction,
} from '../middleware/permissions';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let viewerId: number;
let defaultNodeId: number;
let otherNodeId: number;
function mockReq(partial: {
userId: number;
role: 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
username?: string;
nodeId?: number;
proxyTier?: 'paid' | 'community';
scopedStackEvidence?: {
stackName: string;
actions: ReadonlySet<PermissionAction>;
};
}): Request {
return {
user: {
username: partial.username ?? 'test-user',
role: partial.role,
userId: partial.userId,
},
nodeId: partial.nodeId ?? defaultNodeId,
proxyTier: partial.proxyTier ?? 'paid',
scopedStackEvidence: partial.scopedStackEvidence,
} as Request;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const db = DatabaseService.getInstance();
defaultNodeId = db.getDefaultNode()!.id!;
otherNodeId = db.addNode({
name: 'perm-other-node',
type: 'remote',
api_url: 'http://192.168.1.60:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
viewerId = db.addUser({ username: 'perm-viewer', password_hash: hash, role: 'viewer' });
});
afterAll(() => {
const db = DatabaseService.getInstance();
db.deleteRoleAssignmentsByUser(viewerId);
db.deleteUser(viewerId);
db.deleteNode(otherNodeId);
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
describe('scopedActionsForStack', () => {
it('includes edit and deploy for a node-admin stack assignment', () => {
const db = DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'stack',
resource_id: 'actions-stack',
node_id: defaultNodeId,
});
const actions = scopedActionsForStack(viewerId, defaultNodeId, 'actions-stack');
expect(actions).toContain('stack:edit');
expect(actions).toContain('stack:deploy');
expect(actions).toContain('stack:read');
expect(actions).toContain('stack:delete');
expect(actions).toContain('stack:create');
expect(actions).not.toContain('node:manage');
expect(actions).not.toContain('system:users');
expect(actions.every((a) => a.startsWith('stack:'))).toBe(true);
expect(scopedActionsForStack(viewerId, otherNodeId, 'actions-stack')).toEqual([]);
db.deleteRoleAssignmentsByStack(defaultNodeId, 'actions-stack');
});
});
describe('checkPermission with node-scoped stack grants', () => {
it('viewer + scoped deploy grant succeeds for stack:deploy when req.nodeId matches', () => {
const db = DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'deployer',
resource_type: 'stack',
resource_id: 'deploy-me',
node_id: defaultNodeId,
});
const req = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId });
expect(checkPermission(req, 'stack:deploy', 'stack', 'deploy-me')).toBe(true);
expect(checkPermission(req, 'stack:read', 'stack', 'deploy-me')).toBe(true);
expect(checkPermission(req, 'stack:edit', 'stack', 'deploy-me')).toBe(false);
const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId });
expect(checkPermission(wrongNode, 'stack:deploy', 'stack', 'deploy-me')).toBe(false);
db.deleteRoleAssignmentsByStack(defaultNodeId, 'deploy-me');
});
it('node-scoped Node Admin authorizes stack actions on that node only', () => {
const db = DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(defaultNodeId),
});
const sameNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId });
expect(checkPermission(sameNode, 'stack:edit', 'stack', 'any-stack')).toBe(true);
expect(checkPermission(sameNode, 'stack:deploy', 'stack', 'other-stack')).toBe(true);
expect(checkPermission(sameNode, 'node:manage', 'node', String(defaultNodeId))).toBe(true);
const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId });
expect(checkPermission(wrongNode, 'stack:edit', 'stack', 'any-stack')).toBe(false);
const assignments = db.getAllRoleAssignments(viewerId).filter(
(a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId),
);
for (const a of assignments) db.deleteRoleAssignment(a.id!);
});
});
describe('scopedActionsForStack with node-scoped grants', () => {
it('includes stack:* actions from a node-wide grant on the same node', () => {
const db = DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(defaultNodeId),
});
const actions = scopedActionsForStack(viewerId, defaultNodeId, 'fleet-wide');
expect(actions).toContain('stack:edit');
expect(actions).toContain('stack:deploy');
expect(actions).not.toContain('node:manage');
expect(scopedActionsForStack(viewerId, otherNodeId, 'fleet-wide')).toEqual([]);
const assignments = db.getAllRoleAssignments(viewerId).filter(
(a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId),
);
for (const a of assignments) db.deleteRoleAssignment(a.id!);
});
});
describe('checkPermission scopedStackEvidence', () => {
it('allows when action is a member of the evidenced set for the same stack', () => {
// Use actions the global viewer role does not already grant so the
// evidence path is what authorizes (not ROLE_PERMISSIONS.viewer).
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:deploy', 'stack:edit']),
},
});
expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true);
expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(true);
});
it('denies when the action is absent from the evidenced set', () => {
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:read']),
},
});
expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(false);
expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(false);
});
it('denies when the stack name does not match evidence', () => {
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:deploy']),
},
});
expect(checkPermission(req, 'stack:deploy', 'stack', 'other-stack')).toBe(false);
});
it('ignores evidence for unscoped checks (no resourceType/resourceId)', () => {
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:deploy', 'system:users']),
},
});
expect(checkPermission(req, 'stack:deploy')).toBe(false);
expect(checkPermission(req, 'system:users')).toBe(false);
});
it('does not honor non-stack actions even when present in the evidence set', () => {
const req = mockReq({
userId: 0,
role: 'viewer',
scopedStackEvidence: {
stackName: 'evidenced',
actions: new Set<PermissionAction>(['stack:deploy', 'system:users', 'node:manage']),
},
});
expect(checkPermission(req, 'system:users', 'stack', 'evidenced')).toBe(false);
expect(checkPermission(req, 'node:manage', 'stack', 'evidenced')).toBe(false);
expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true);
});
});
@@ -0,0 +1,327 @@
/**
* Orchestrated hub → remote proxy coverage for scoped stack elevation and
* DELETE tuple cleanup. Exercises createRemoteProxyMiddleware through
* live loopback remotes (not helper-only unit tests).
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import http from 'http';
import bcrypt from 'bcrypt';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import {
PROXY_SCOPED_STACK_NAME_HEADER,
PROXY_SCOPED_STACK_ACTIONS_HEADER,
} from '../services/license-headers';
let tmpDir: string;
let app: import('express').Express;
let viewerBearer: string;
let viewerId: number;
let evidenceServer: http.Server;
let noEvidenceServer: http.Server;
let failDeleteServer: http.Server;
let wrongNodeServer: http.Server;
let evidenceNodeId: number;
let noEvidenceNodeId: number;
let failDeleteNodeId: number;
let wrongNodeId: number;
interface CapturedHop {
method: string;
url: string;
stackNameHeader: string | undefined;
stackActionsHeader: string | undefined;
}
const evidenceHops: CapturedHop[] = [];
const failDeleteHops: CapturedHop[] = [];
function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void {
into.push({
method: req.method ?? '',
url: req.url ?? '',
stackNameHeader: req.headers[PROXY_SCOPED_STACK_NAME_HEADER] as string | undefined,
stackActionsHeader: req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER] as string | undefined,
});
}
function evidenceRemote(): http.Server {
return http.createServer((req, res) => {
if (req.url?.startsWith('/api/meta')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '0.93.0',
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'],
}));
return;
}
captureHop(req, evidenceHops);
if (req.method === 'DELETE') {
res.writeHead(204);
res.end();
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
function noEvidenceRemote(): http.Server {
return http.createServer((req, res) => {
if (req.url?.startsWith('/api/meta')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '0.93.0',
capabilities: ['cross-node-rbac'],
}));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
function failDeleteRemote(): http.Server {
return http.createServer((req, res) => {
if (req.url?.startsWith('/api/meta')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
version: '0.93.0',
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'],
}));
return;
}
captureHop(req, failDeleteHops);
if (req.method === 'DELETE') {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'upstream delete failed' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
async function listen(server: http.Server): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return (server.address() as import('net').AddressInfo).port;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
viewerId = db.addUser({ username: 'scoped-proxy-viewer', password_hash: hash, role: 'viewer' });
const viewer = db.getUserByUsername('scoped-proxy-viewer')!;
viewerBearer = jwt.sign(
{ username: 'scoped-proxy-viewer', role: 'viewer', tv: viewer.token_version },
TEST_JWT_SECRET,
{ expiresIn: '5m' },
);
evidenceServer = evidenceRemote();
noEvidenceServer = noEvidenceRemote();
failDeleteServer = failDeleteRemote();
wrongNodeServer = evidenceRemote();
const evidencePort = await listen(evidenceServer);
const noEvidencePort = await listen(noEvidenceServer);
const failDeletePort = await listen(failDeleteServer);
const wrongNodePort = await listen(wrongNodeServer);
evidenceNodeId = db.addNode({
name: 'evidence-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${evidencePort}`,
api_token: 'evidence-token',
});
noEvidenceNodeId = db.addNode({
name: 'no-evidence-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${noEvidencePort}`,
api_token: 'no-evidence-token',
});
failDeleteNodeId = db.addNode({
name: 'fail-delete-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${failDeletePort}`,
api_token: 'fail-delete-token',
});
wrongNodeId = db.addNode({
name: 'wrong-node-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${wrongNodePort}`,
api_token: 'wrong-node-token',
});
});
afterAll(async () => {
await new Promise<void>((resolve) => evidenceServer.close(() => resolve()));
await new Promise<void>((resolve) => noEvidenceServer.close(() => resolve()));
await new Promise<void>((resolve) => failDeleteServer.close(() => resolve()));
await new Promise<void>((resolve) => wrongNodeServer.close(() => resolve()));
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
describe('remote proxy scoped stack evidence and DELETE cleanup', () => {
it('elevates a matching stack grant and forwards bound evidence headers', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'deployer',
resource_type: 'stack',
resource_id: 'shared-name',
node_id: evidenceNodeId,
});
evidenceHops.length = 0;
const res = await request(app)
.post('/api/stacks/shared-name/deploy')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(evidenceNodeId));
expect(res.status).toBe(200);
const hop = evidenceHops.find((h) => h.url?.includes('/stacks/shared-name/deploy'));
expect(hop).toBeDefined();
expect(hop!.stackNameHeader).toBe('shared-name');
expect(hop!.stackActionsHeader).toContain('stack:deploy');
expect(hop!.stackActionsHeader).not.toContain('node:manage');
db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name');
});
it('denies the same stack name on a node without a grant', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'deployer',
resource_type: 'stack',
resource_id: 'shared-name',
node_id: evidenceNodeId,
});
const res = await request(app)
.post('/api/stacks/shared-name/deploy')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(wrongNodeId));
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name');
});
it('denies scoped elevation when the remote lacks scoped-stack-auth-evidence', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'deployer',
resource_type: 'stack',
resource_id: 'needs-evidence',
node_id: noEvidenceNodeId,
});
const res = await request(app)
.post('/api/stacks/needs-evidence/deploy')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(noEvidenceNodeId));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/scoped stack authorization/i);
db.deleteRoleAssignmentsByStack(noEvidenceNodeId, 'needs-evidence');
});
it('clears the hub grant tuple after a successful remote DELETE', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'stack',
resource_id: 'doomed',
node_id: evidenceNodeId,
});
expect(
db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId),
).toHaveLength(1);
const res = await request(app)
.delete('/api/stacks/doomed')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(evidenceNodeId));
expect(res.status).toBe(204);
expect(
db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId),
).toHaveLength(0);
});
it('preserves the hub grant tuple when remote DELETE is non-2xx', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'stack',
resource_id: 'keep-me',
node_id: failDeleteNodeId,
});
const res = await request(app)
.delete('/api/stacks/keep-me')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(failDeleteNodeId));
expect(res.status).toBe(500);
expect(
db.getRoleAssignments(viewerId, 'stack', 'keep-me', failDeleteNodeId),
).toHaveLength(1);
db.deleteRoleAssignmentsByStack(failDeleteNodeId, 'keep-me');
});
it('builds evidence from a node-scoped grant on the target node', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(evidenceNodeId),
});
evidenceHops.length = 0;
const res = await request(app)
.post('/api/stacks/node-wide-stack/deploy')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(evidenceNodeId));
expect(res.status).toBe(200);
const hop = evidenceHops.find((h) => h.url?.includes('/stacks/node-wide-stack/deploy'));
expect(hop).toBeDefined();
expect(hop!.stackNameHeader).toBe('node-wide-stack');
expect(hop!.stackActionsHeader).toContain('stack:deploy');
expect(hop!.stackActionsHeader).toContain('stack:edit');
const assignments = db.getAllRoleAssignments(viewerId).filter(
(a) => a.resource_type === 'node' && a.resource_id === String(evidenceNodeId),
);
for (const a of assignments) db.deleteRoleAssignment(a.id!);
});
});
@@ -0,0 +1,430 @@
/**
* migrateRoleAssignmentsNodeQualified: legacy rebuild, default remap,
* no-default omit, sqlite_master idempotency probe, unique indexes,
* deleteNode stack-grant cleanup, preserved ids/timestamps.
*/
import path from 'path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
function resetDatabaseSingleton(): void {
const holder = DatabaseService as unknown as { instance?: DatabaseService };
const existing = holder.instance;
if (existing) {
try {
existing.getDb().close();
} catch {
// already closed
}
holder.instance = undefined;
}
}
type IndexRow = { name: string; sql: string | null };
type AssignmentRow = {
id: number;
user_id: number;
role: string;
resource_type: string;
resource_id: string;
node_id: number | null;
created_at: number;
};
function roleAssignmentsTableSql(raw: Database.Database): string {
return (
(raw.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'",
).get() as { sql: string } | undefined)?.sql ?? ''
);
}
function roleAssignmentIndexes(raw: Database.Database): Map<string, string> {
const rows = raw.prepare(
"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'",
).all() as IndexRow[];
return new Map(rows.map((r) => [r.name, r.sql ?? '']));
}
/** Rewrite role_assignments to the pre-node_id schema and seed legacy rows. */
function seedLegacyRoleAssignments(
dbPath: string,
seed: {
userId: number;
stackRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>;
nodeRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>;
},
): void {
const raw = new Database(dbPath);
try {
raw.exec('PRAGMA foreign_keys = OFF');
raw.exec('DROP TABLE IF EXISTS role_assignments');
raw.exec('DROP TABLE IF EXISTS role_assignments_new');
raw.exec(`
CREATE TABLE role_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_role_assignments_resource
ON role_assignments(resource_type, resource_id);
`);
const insert = raw.prepare(`
INSERT INTO role_assignments (id, user_id, role, resource_type, resource_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const row of seed.stackRows) {
insert.run(row.id, seed.userId, row.role, 'stack', row.resource_id, row.created_at);
}
for (const row of seed.nodeRows) {
insert.run(row.id, seed.userId, row.role, 'node', row.resource_id, row.created_at);
}
} finally {
raw.close();
}
}
function removeRoleAssignmentsCheck(raw: Database.Database): void {
raw.exec(`
CREATE TABLE role_assignments_without_check (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
node_id INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
INSERT INTO role_assignments_without_check
SELECT * FROM role_assignments;
DROP TABLE role_assignments;
ALTER TABLE role_assignments_without_check RENAME TO role_assignments;
CREATE INDEX idx_role_assignments_user ON role_assignments(user_id);
CREATE INDEX idx_role_assignments_resource ON role_assignments(resource_type, resource_id);
CREATE UNIQUE INDEX idx_role_assignments_stack_unique
ON role_assignments(user_id, role, resource_type, resource_id, node_id)
WHERE resource_type = 'stack';
CREATE UNIQUE INDEX idx_role_assignments_node_unique
ON role_assignments(user_id, role, resource_type, resource_id)
WHERE resource_type = 'node';
`);
}
function expectFinalSchema(raw: Database.Database): void {
const tableSql = roleAssignmentsTableSql(raw);
expect(tableSql).toContain("resource_type = 'stack' AND node_id IS NOT NULL");
expect(tableSql).toContain("resource_type = 'node' AND node_id IS NULL");
const indexes = roleAssignmentIndexes(raw);
const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? '';
const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? '';
expect(stackUnique).toMatch(/user_id/i);
expect(stackUnique).toMatch(/role/i);
expect(stackUnique).toMatch(/resource_type/i);
expect(stackUnique).toMatch(/resource_id/i);
expect(stackUnique).toMatch(/node_id/i);
expect(stackUnique).toMatch(/WHERE\s+resource_type\s*=\s*'stack'/i);
expect(nodeUnique).toMatch(/user_id/i);
expect(nodeUnique).toMatch(/role/i);
expect(nodeUnique).toMatch(/resource_type/i);
expect(nodeUnique).toMatch(/resource_id/i);
expect(nodeUnique).toMatch(/WHERE\s+resource_type\s*=\s*'node'/i);
const nodeCols = nodeUnique.replace(/WHERE[\s\S]*/i, '');
expect(nodeCols).not.toMatch(/node_id/);
}
describe('migrateRoleAssignmentsNodeQualified', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await setupTestDb();
});
afterEach(() => {
resetDatabaseSingleton();
cleanupTestDb(tmpDir);
});
it('remaps legacy stack rows to the default node and preserves ids/timestamps', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const remoteNodeId = db.addNode({
name: 'mig-remote',
type: 'remote',
api_url: 'http://192.168.1.50:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-remap', password_hash: hash, role: 'viewer' });
const stackCreatedAt = 1_700_000_000_001;
const nodeCreatedAt = 1_700_000_000_002;
resetDatabaseSingleton();
seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), {
userId,
stackRows: [
{ id: 41, role: 'deployer', resource_id: 'web', created_at: stackCreatedAt },
{ id: 42, role: 'viewer', resource_id: 'api', created_at: stackCreatedAt + 1 },
],
nodeRows: [
{ id: 51, role: 'node-admin', resource_id: String(remoteNodeId), created_at: nodeCreatedAt },
],
});
process.env.DATA_DIR = tmpDir;
const migrated = DatabaseService.getInstance();
const raw = migrated.getDb();
expectFinalSchema(raw);
const rows = raw.prepare(
'SELECT * FROM role_assignments ORDER BY id',
).all() as AssignmentRow[];
expect(rows).toHaveLength(3);
const stackWeb = rows.find((r) => r.id === 41)!;
expect(stackWeb.resource_type).toBe('stack');
expect(stackWeb.resource_id).toBe('web');
expect(stackWeb.node_id).toBe(defaultNodeId);
expect(stackWeb.created_at).toBe(stackCreatedAt);
expect(stackWeb.role).toBe('deployer');
const stackApi = rows.find((r) => r.id === 42)!;
expect(stackApi.node_id).toBe(defaultNodeId);
expect(stackApi.created_at).toBe(stackCreatedAt + 1);
const nodeGrant = rows.find((r) => r.id === 51)!;
expect(nodeGrant.resource_type).toBe('node');
expect(nodeGrant.node_id).toBeNull();
expect(nodeGrant.created_at).toBe(nodeCreatedAt);
});
it('omits legacy stack rows when no default node exists', async () => {
const db = DatabaseService.getInstance();
const remoteNodeId = db.addNode({
name: 'mig-no-default-remote',
type: 'remote',
api_url: 'http://192.168.1.51:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-omit', password_hash: hash, role: 'viewer' });
db.getDb().prepare('UPDATE nodes SET is_default = 0').run();
expect(db.getDefaultNode()).toBeUndefined();
resetDatabaseSingleton();
seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), {
userId,
stackRows: [
{ id: 61, role: 'deployer', resource_id: 'orphan-stack', created_at: 99 },
],
nodeRows: [
{ id: 62, role: 'deployer', resource_id: String(remoteNodeId), created_at: 100 },
],
});
process.env.DATA_DIR = tmpDir;
const migrated = DatabaseService.getInstance();
const rows = migrated.getDb().prepare(
'SELECT * FROM role_assignments ORDER BY id',
).all() as AssignmentRow[];
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(62);
expect(rows[0].resource_type).toBe('node');
expect(rows[0].node_id).toBeNull();
});
it('second init is idempotent via sqlite_master CHECK and partial-index WHERE probes', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-idem', password_hash: hash, role: 'viewer' });
resetDatabaseSingleton();
seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), {
userId,
stackRows: [
{ id: 71, role: 'deployer', resource_id: 'idem-stack', created_at: 200 },
],
nodeRows: [],
});
process.env.DATA_DIR = tmpDir;
const first = DatabaseService.getInstance();
expectFinalSchema(first.getDb());
const before = first.getDb().prepare(
'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71',
).get() as { id: number; node_id: number; created_at: number };
expect(before.node_id).toBe(defaultNodeId);
resetDatabaseSingleton();
process.env.DATA_DIR = tmpDir;
const second = DatabaseService.getInstance();
expectFinalSchema(second.getDb());
const after = second.getDb().prepare(
'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71',
).get() as { id: number; node_id: number; created_at: number };
expect(after).toEqual(before);
const stale = second.getDb().prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments_new'",
).get();
expect(stale).toBeUndefined();
});
it('preserves node-qualified stack rows when repairing a missing index', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const remoteNodeId = db.addNode({
name: 'mig-repair-remote',
type: 'remote',
api_url: 'http://192.168.1.54:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-repair', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'shared-stack', node_id: defaultNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'shared-stack', node_id: remoteNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'node-admin', resource_type: 'node',
resource_id: String(remoteNodeId),
});
const before = db.getAllRoleAssignments(userId);
db.getDb().exec('DROP INDEX idx_role_assignments_stack_unique');
resetDatabaseSingleton();
process.env.DATA_DIR = tmpDir;
const repaired = DatabaseService.getInstance();
expectFinalSchema(repaired.getDb());
const rows = repaired.getAllRoleAssignments(userId);
expect(rows).toEqual(before);
});
it('preserves node-qualified rows without a default node when repairing the table check', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const remoteNodeId = db.addNode({
name: 'mig-repair-no-default',
type: 'remote',
api_url: 'http://192.168.1.55:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-repair-check', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'local-stack', node_id: defaultNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'admin', resource_type: 'stack',
resource_id: 'remote-stack', node_id: remoteNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'viewer', resource_type: 'node',
resource_id: String(remoteNodeId),
});
const before = db.getAllRoleAssignments(userId);
db.getDb().prepare('UPDATE nodes SET is_default = 0').run();
removeRoleAssignmentsCheck(db.getDb());
resetDatabaseSingleton();
process.env.DATA_DIR = tmpDir;
const repaired = DatabaseService.getInstance();
expectFinalSchema(repaired.getDb());
expect(repaired.getAllRoleAssignments(userId)).toEqual(before);
});
it('unique indexes use exact column sets including role', () => {
const raw = DatabaseService.getInstance().getDb();
const indexes = roleAssignmentIndexes(raw);
const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? '';
const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? '';
expect(stackUnique.replace(/\s+/g, ' ')).toMatch(
/ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*,\s*node_id\s*\)/i,
);
expect(nodeUnique.replace(/\s+/g, ' ')).toMatch(
/ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*\)/i,
);
});
it('deleteNode clears stack grants by node_id and preserves other nodes', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const doomedId = db.addNode({
name: 'mig-doomed',
type: 'remote',
api_url: 'http://192.168.1.52:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const survivorId = db.addNode({
name: 'mig-survivor',
type: 'remote',
api_url: 'http://192.168.1.53:1852',
api_token: '',
compose_dir: '/tmp',
is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'mig-delnode', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'shared-name', node_id: doomedId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'shared-name', node_id: survivorId,
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'local-only', node_id: defaultNodeId,
});
db.addRoleAssignment({
user_id: userId, role: 'node-admin', resource_type: 'node',
resource_id: String(doomedId),
});
db.deleteNode(doomedId);
const remaining = db.getAllRoleAssignments(userId);
expect(remaining.some((a) => a.node_id === doomedId)).toBe(false);
expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(doomedId))).toBe(false);
expect(remaining.some((a) => a.node_id === survivorId && a.resource_id === 'shared-name')).toBe(true);
expect(remaining.some((a) => a.node_id === defaultNodeId && a.resource_id === 'local-only')).toBe(true);
db.deleteUser(userId);
db.deleteNode(survivorId);
});
});
@@ -0,0 +1,127 @@
/**
* Machine-auth scoped stack evidence: headers are trusted only under
* node_proxy / pilot_tunnel, and only when the name + actions pair is valid.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import {
PROXY_SCOPED_STACK_NAME_HEADER,
PROXY_SCOPED_STACK_ACTIONS_HEADER,
PROXY_ROLE_HEADER,
} from '../services/license-headers';
import { checkPermission } from '../middleware/permissions';
let tmpDir: string;
let authMiddleware: typeof import('../middleware/auth').authMiddleware;
function runAuth(req: Partial<Request>): Promise<Request> {
return new Promise((resolve, reject) => {
const fullReq = Object.assign(
{ cookies: {} as Record<string, string>, headers: {} as Record<string, string | undefined> },
req,
{
headers: { ...(req.headers ?? {}) },
cookies: {},
},
) as Request;
let settled = false;
const res = {
status: () => res,
json: (body: unknown) => {
if (!settled) {
settled = true;
reject(new Error(`authMiddleware rejected: ${JSON.stringify(body)}`));
}
return res;
},
} as unknown as Response;
const next: NextFunction = (err?: unknown) => {
if (settled) return;
settled = true;
if (err) reject(err);
else resolve(fullReq);
};
void Promise.resolve(authMiddleware(fullReq, res, next)).catch(reject);
});
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ authMiddleware } = await import('../middleware/auth'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
describe('scoped stack evidence under machine auth', () => {
it('attaches evidence for node_proxy when headers are valid', async () => {
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const req = await runAuth({
headers: {
authorization: `Bearer ${token}`,
[PROXY_ROLE_HEADER]: 'viewer',
[PROXY_SCOPED_STACK_NAME_HEADER]: 'web',
[PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,stack:deploy',
},
});
expect(req.scopedStackEvidence?.stackName).toBe('web');
expect(req.scopedStackEvidence?.actions.has('stack:edit')).toBe(true);
expect(req.scopedStackEvidence?.actions.has('stack:deploy')).toBe(true);
expect(checkPermission(req, 'stack:deploy', 'stack', 'web')).toBe(true);
expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(true);
});
it('attaches evidence for pilot_tunnel the same way', async () => {
const token = jwt.sign({ scope: 'pilot_tunnel' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const req = await runAuth({
headers: {
authorization: `Bearer ${token}`,
[PROXY_ROLE_HEADER]: 'viewer',
[PROXY_SCOPED_STACK_NAME_HEADER]: 'api',
[PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:read',
},
});
expect(req.scopedStackEvidence?.stackName).toBe('api');
expect(checkPermission(req, 'stack:read', 'stack', 'api')).toBe(true);
});
it('treats malformed actions as absent evidence', async () => {
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const req = await runAuth({
headers: {
authorization: `Bearer ${token}`,
[PROXY_ROLE_HEADER]: 'viewer',
[PROXY_SCOPED_STACK_NAME_HEADER]: 'web',
[PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,not-real',
},
});
expect(req.scopedStackEvidence).toBeUndefined();
expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(false);
});
it('ignores evidence headers on a user session JWT', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const { TEST_USERNAME } = await import('./helpers/setupTestDb');
const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME);
expect(user).toBeDefined();
const token = jwt.sign(
{ username: user!.username, role: user!.role, tv: user!.token_version },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
const req = await runAuth({
headers: {
authorization: `Bearer ${token}`,
[PROXY_SCOPED_STACK_NAME_HEADER]: 'web',
[PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:deploy,stack:edit',
},
});
expect(req.scopedStackEvidence).toBeUndefined();
});
});
@@ -142,8 +142,9 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', (
name: 'stack-del-rbac-node', type: 'remote', api_url: 'http://test:1852',
api_token: '', compose_dir: '/tmp', is_default: false,
});
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api' });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack' });
const defaultNodeId = db.getDefaultNode()!.id;
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api', node_id: defaultNodeId });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: defaultNodeId });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId) });
const res = await request(app)
@@ -152,8 +153,8 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', (
expect(res.status).toBe(200);
const remaining = db.getAllRoleAssignments(userId);
expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api')).toBe(false);
expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack')).toBe(true);
expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api' && a.node_id === defaultNodeId)).toBe(false);
expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack' && a.node_id === defaultNodeId)).toBe(true);
expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(otherNodeId))).toBe(true);
db.deleteUser(userId);
@@ -0,0 +1,155 @@
/**
* Pure classifyStackApiPath coverage for hub stack RBAC gating.
*/
import { describe, it, expect } from 'vitest';
import {
classifyStackApiPath,
formatScopedStackActionsHeader,
parseScopedStackActionsHeader,
} from '../helpers/stackRouteAuth';
import type { PermissionAction } from '../middleware/permissions';
describe('classifyStackApiPath', () => {
describe('named-stack families', () => {
it('maps read routes to stack:read', () => {
expect(classifyStackApiPath('GET', '/stacks/web')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
expect(classifyStackApiPath('GET', '/stacks/web/env')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
expect(classifyStackApiPath('GET', '/stacks/web/git-source')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
expect(classifyStackApiPath('POST', '/stacks/web/drift/recheck')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
});
it('maps edit routes to stack:edit', () => {
expect(classifyStackApiPath('PUT', '/stacks/web')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
expect(classifyStackApiPath('PUT', '/stacks/web/env')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
expect(classifyStackApiPath('PUT', '/stacks/web/git-source')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
expect(classifyStackApiPath('DELETE', '/stacks/web/git-source')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
});
it('maps deploy routes and service lifecycle ops to stack:deploy', () => {
expect(classifyStackApiPath('POST', '/stacks/web/deploy')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
expect(classifyStackApiPath('POST', '/stacks/web/update')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
expect(classifyStackApiPath('POST', '/stacks/web/services/api/restart')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
expect(classifyStackApiPath('GET', '/stacks/web/services/api/recovery')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
});
it('maps stack DELETE to stack:delete', () => {
expect(classifyStackApiPath('DELETE', '/stacks/web')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:delete',
});
});
it('treats git-source/apply primary as stack:edit', () => {
expect(classifyStackApiPath('POST', '/stacks/web/git-source/apply')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
});
});
describe('static exclusions', () => {
it('classifies collection and create paths as static', () => {
expect(classifyStackApiPath('GET', '/stacks')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('GET', '/stacks/')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('GET', '/stacks/statuses')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('GET', '/stacks/discovery')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks/import/scan')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks/import/move')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks/bulk')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('POST', '/stacks/from-git')).toEqual({ kind: 'static' });
});
it('classifies non-/stacks paths as static', () => {
expect(classifyStackApiPath('GET', '/nodes')).toEqual({ kind: 'static' });
expect(classifyStackApiPath('GET', '/users')).toEqual({ kind: 'static' });
});
});
describe('encoding and trailing slashes', () => {
it('decodes percent-encoded stack names', () => {
expect(classifyStackApiPath('GET', '/stacks/my%2Dstack')).toEqual({
kind: 'named-stack', stackName: 'my-stack', action: 'stack:read',
});
expect(classifyStackApiPath('POST', '/stacks/web%5Fprod/deploy')).toEqual({
kind: 'named-stack', stackName: 'web_prod', action: 'stack:deploy',
});
});
it('strips trailing slashes before matching', () => {
expect(classifyStackApiPath('GET', '/stacks/web/')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
expect(classifyStackApiPath('POST', '/stacks/web/deploy/')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:deploy',
});
expect(classifyStackApiPath('GET', '/stacks/statuses/')).toEqual({ kind: 'static' });
});
it('ignores query strings', () => {
expect(classifyStackApiPath('GET', '/stacks/web?nodeId=1')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:read',
});
});
});
describe('fail-closed unknown-named', () => {
it('returns unknown-named for unrecognized /stacks/<name>/... suffixes', () => {
expect(classifyStackApiPath('GET', '/stacks/web/weird')).toEqual({ kind: 'unknown-named' });
expect(classifyStackApiPath('POST', '/stacks/web/not-a-real-action')).toEqual({
kind: 'unknown-named',
});
expect(classifyStackApiPath('POST', '/stacks/web/services/api/recovery')).toEqual({
kind: 'unknown-named',
});
});
it('returns unknown-named for invalid stack name segments', () => {
expect(classifyStackApiPath('GET', '/stacks/bad name')).toEqual({ kind: 'unknown-named' });
expect(classifyStackApiPath('GET', '/stacks/%2E%2E')).toEqual({ kind: 'unknown-named' });
});
});
});
describe('scoped stack actions header encode/decode', () => {
it('round-trips a PermissionAction set', () => {
const actions: PermissionAction[] = ['stack:edit', 'stack:deploy', 'stack:read'];
const encoded = formatScopedStackActionsHeader(actions);
expect(parseScopedStackActionsHeader(encoded)).toEqual(actions);
});
it('returns null for malformed tokens', () => {
expect(parseScopedStackActionsHeader('stack:edit,not-a-real-action')).toBeNull();
expect(parseScopedStackActionsHeader('')).toBeNull();
expect(parseScopedStackActionsHeader(' ')).toBeNull();
});
it('deduplicates while preserving first-seen order', () => {
expect(parseScopedStackActionsHeader('stack:edit,stack:deploy,stack:edit')).toEqual([
'stack:edit',
'stack:deploy',
]);
});
});
+143 -31
View File
@@ -9,11 +9,22 @@ import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { generateApiToken } from '../utils/apiTokenFormat';
import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode';
vi.mock('../helpers/assertStackExistsOnNode', () => ({
assertStackExistsOnNode: vi.fn(async () => ({ ok: true as const })),
}));
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
function defaultNodeId(): number {
const node = DatabaseService.getInstance().getDefaultNode();
if (!node) throw new Error('test default node missing');
return node.id;
}
/** Sign a JWT for a given user with optional token_version (tv). */
function authToken(username: string, role: string = 'admin', tv?: number): string {
const payload: Record<string, unknown> = { username, role };
@@ -383,20 +394,103 @@ describe('Scoped Role Assignments', () => {
});
it('POST /api/users/:id/roles creates assignment (201)', async () => {
const nodeId = defaultNodeId();
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' });
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: nodeId });
expect(res.status).toBe(201);
expect(res.body.role).toBe('deployer');
expect(res.body.resource_type).toBe('stack');
expect(res.body.node_id).toBe(nodeId);
});
it('POST /api/users/:id/roles rejects stack assignment without node_id (400)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'no-node-stack' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/node_id/i);
});
it('POST /api/users/:id/roles rejects when stack does not exist on node (400)', async () => {
vi.mocked(assertStackExistsOnNode).mockResolvedValueOnce({
ok: false,
error: 'Stack not found on node',
});
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'stack',
resource_id: 'missing-stack',
node_id: defaultNodeId(),
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not found/i);
});
it('POST /api/users/:id/roles rejects node_id qualifier on node assignments (400)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'node',
resource_id: String(defaultNodeId()),
node_id: defaultNodeId(),
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/must not be set/i);
});
it('POST /api/users/:id/roles rejects nonexistent numeric node resource_id (400)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'node',
resource_id: '999999',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Node not found/i);
});
it('POST /api/users/:id/roles creates node assignment without node_id (201)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'node',
resource_id: String(defaultNodeId()),
});
expect(res.status).toBe(201);
expect(res.body.resource_type).toBe('node');
expect(res.body.node_id).toBeNull();
});
it('POST /api/users/:id/roles rejects a noncanonical node resource_id (400)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
role: 'deployer',
resource_type: 'node',
resource_id: `0${defaultNodeId()}`,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/canonical/i);
});
it('POST /api/users/:id/roles rejects duplicate (409)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' });
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: defaultNodeId() });
expect(res.status).toBe(409);
});
@@ -420,7 +514,7 @@ describe('Scoped Role Assignments', () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack' });
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack', node_id: defaultNodeId() });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
} finally {
@@ -452,7 +546,8 @@ describe('GET /api/permissions/me', () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const id = db.addUser({ username: 'permcheck', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' });
const nodeId = defaultNodeId();
db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack', node_id: nodeId });
const user = db.getUserById(id)!;
const token = authToken('permcheck', 'viewer', user.token_version);
@@ -461,7 +556,7 @@ describe('GET /api/permissions/me', () => {
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.globalRole).toBe('viewer');
expect(res.body.scopedPermissions['stack:my-stack']).toBeDefined();
expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toBeDefined();
// Cleanup
db.deleteRoleAssignmentsByUser(id);
@@ -474,7 +569,13 @@ describe('GET /api/permissions/me', () => {
const svc = LicenseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const id = db.addUser({ username: 'permcheck-community', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' });
db.addRoleAssignment({
user_id: id,
role: 'deployer',
resource_type: 'stack',
resource_id: 'my-stack',
node_id: defaultNodeId(),
});
const user = db.getUserById(id)!;
const token = authToken('permcheck-community', 'viewer', user.token_version);
@@ -689,53 +790,64 @@ describe('Atomic last-admin guard', () => {
});
// ---- Orphaned Role Assignment Cleanup ----
// Proxied remote stack DELETE clears hub grants only on 2xx in
// remoteNodeProxy (deleteRoleAssignmentsByStack). Non-2xx preserves rows.
// Orchestrated proxyRes coverage lives in proxy-scoped-stack-evidence.test.ts;
// these cases lock the DB helper isolation that the proxy calls.
describe('Orphaned role assignment cleanup', () => {
it('deleting a node removes its role assignments', async () => {
it('deleting a node removes its node and stack role assignments', async () => {
const db = DatabaseService.getInstance();
// Create a test node
const nodeId = db.addNode({ name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false });
// Create a role assignment for this node
const nodeId = db.addNode({
name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852',
api_token: '', compose_dir: '/tmp', is_default: false,
});
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'nodeorphan', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) });
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId),
});
db.addRoleAssignment({
user_id: userId, role: 'deployer', resource_type: 'stack',
resource_id: 'on-doomed', node_id: nodeId,
});
// Verify assignment exists
const before = db.getAllRoleAssignments(userId);
expect(before.length).toBe(1);
expect(db.getAllRoleAssignments(userId)).toHaveLength(2);
// Delete the node
db.deleteNode(nodeId);
// Assignments should be gone
const after = db.getAllRoleAssignments(userId);
expect(after.length).toBe(0);
// Cleanup
expect(db.getAllRoleAssignments(userId)).toHaveLength(0);
db.deleteUser(userId);
});
it('deleteRoleAssignmentsByResource removes only the matching resource tuple', async () => {
it('deleteRoleAssignmentsByStack clears only that node+stack tuple', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'tupleorphan', password_hash: hash, role: 'viewer' });
const nodeId = db.addNode({
name: 'tuple-cleanup-node', type: 'remote', api_url: 'http://test:1852',
const nodeA = db.addNode({
name: 'tuple-cleanup-node-a', type: 'remote', api_url: 'http://test-a:1852',
api_token: '', compose_dir: '/tmp', is_default: false,
});
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'target-stack' });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack' });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) });
const nodeB = db.addNode({
name: 'tuple-cleanup-node-b', type: 'remote', api_url: 'http://test-b:1852',
api_token: '', compose_dir: '/tmp', is_default: false,
});
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeA });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeB });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack', node_id: nodeA });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeA) });
db.deleteRoleAssignmentsByResource('stack', 'target-stack');
db.deleteRoleAssignmentsByStack(nodeA, 'shared-name');
const after = db.getAllRoleAssignments(userId);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'target-stack')).toBe(false);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack')).toBe(true);
expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeId))).toBe(true);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeA)).toBe(false);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeB)).toBe(true);
expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack' && a.node_id === nodeA)).toBe(true);
expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeA))).toBe(true);
db.deleteUser(userId);
db.deleteNode(nodeId);
db.deleteNode(nodeA);
db.deleteNode(nodeB);
});
});