mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +00:00
test(rbac): add five-role parity coverage across proxy, WebSocket, and mixed-version surfaces
Adds a reusable five-role persona fixture and extends RBAC test matrix to cover proxy, WebSocket, Pilot-agent, and mixed-version transport surfaces.
This commit is contained in:
@@ -321,29 +321,35 @@ describe('WebSocket upgrade - exec auth enforcement', () => {
|
|||||||
expect(code).toBe(401);
|
expect(code).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects WebSocket upgrade with non-admin token (403)', async () => {
|
// All five built-in roles: container exec requires admin. Every non-admin
|
||||||
// Add a non-admin user
|
// role must be rejected at upgrade time by generic.ts's role === 'admin' gate.
|
||||||
const { DatabaseService } = await import('../services/DatabaseService');
|
const NON_ADMIN_EXEC_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const;
|
||||||
const bcrypt = await import('bcrypt');
|
|
||||||
const hash = await bcrypt.hash('viewerpass', 1);
|
|
||||||
try {
|
|
||||||
DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: hash, role: 'viewer' });
|
|
||||||
} catch {
|
|
||||||
// User may already exist
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = jwt.sign(
|
for (const role of NON_ADMIN_EXEC_ROLES) {
|
||||||
{ username: 'viewer', role: 'viewer' },
|
it(`rejects /ws upgrade with ${role} token (403)`, async () => {
|
||||||
TEST_JWT_SECRET,
|
const { DatabaseService } = await import('../services/DatabaseService');
|
||||||
{ expiresIn: '1m' },
|
const bcrypt = await import('bcrypt');
|
||||||
);
|
const username = `exec_${role.replace('-', '_')}`;
|
||||||
const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
|
const hash = bcrypt.hashSync('password123', 1);
|
||||||
const code = await new Promise<number>((resolve) => {
|
try {
|
||||||
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
|
DatabaseService.getInstance().addUser({ username, password_hash: hash, role });
|
||||||
ws.on('error', () => resolve(0));
|
} catch {
|
||||||
|
// User may already exist from a prior run in the same worker
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ username, role },
|
||||||
|
TEST_JWT_SECRET,
|
||||||
|
{ expiresIn: '1m' },
|
||||||
|
);
|
||||||
|
const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
|
||||||
|
const code = await new Promise<number>((resolve) => {
|
||||||
|
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
|
||||||
|
ws.on('error', () => resolve(0));
|
||||||
|
});
|
||||||
|
expect(code).toBe(403);
|
||||||
});
|
});
|
||||||
expect(code).toBe(403);
|
}
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects WebSocket upgrade with node_proxy token (403)', async () => {
|
it('rejects WebSocket upgrade with node_proxy token (403)', async () => {
|
||||||
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Reusable five-role persona fixtures for RBAC test suites.
|
||||||
|
*
|
||||||
|
* Usage (one-time setup per test file):
|
||||||
|
* const personas = seedPersonas(DatabaseService.getInstance());
|
||||||
|
* const viewerReq = request(app).get('/api/stacks').set('Authorization', personas.viewer.bearer);
|
||||||
|
*
|
||||||
|
* Each persona carries its own smoke test so a token_version mismatch
|
||||||
|
* (seeding vs signing) surfaces as a 401 before any permission assertion.
|
||||||
|
*/
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { TEST_JWT_SECRET } from '../helpers/setupTestDb';
|
||||||
|
import type { UserRole } from '../../services/DatabaseService';
|
||||||
|
|
||||||
|
export const FIVE_ROLES: readonly UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'] as const;
|
||||||
|
|
||||||
|
export interface Persona {
|
||||||
|
username: string;
|
||||||
|
role: UserRole;
|
||||||
|
tokenVersion: number;
|
||||||
|
bearer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonaMap = Record<UserRole, Persona>;
|
||||||
|
|
||||||
|
/** Minimal DB interface needed by seedPersonas — avoids InstanceType<T> issues with private constructors. */
|
||||||
|
interface PersonaDb {
|
||||||
|
addUser(u: { username: string; password_hash: string; role: string }): number;
|
||||||
|
getUserByUsername(username: string): { username: string; role: UserRole; token_version: number } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed one user per built-in global role and return signed JWTs for all five. */
|
||||||
|
export function seedPersonas(db: PersonaDb): PersonaMap {
|
||||||
|
const personas: Partial<PersonaMap> = {};
|
||||||
|
|
||||||
|
for (const role of FIVE_ROLES) {
|
||||||
|
const username = `persona-${role}`;
|
||||||
|
// Use a simple shared password since tests auth via Bearer, not login.
|
||||||
|
const passwordHash = bcrypt.hashSync('password123', 1);
|
||||||
|
db.addUser({ username, password_hash: passwordHash, role });
|
||||||
|
const user = db.getUserByUsername(username)!;
|
||||||
|
const tv = user.token_version;
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ username, role, tv },
|
||||||
|
TEST_JWT_SECRET,
|
||||||
|
{ expiresIn: '1m' },
|
||||||
|
);
|
||||||
|
personas[role] = { username, role, tokenVersion: tv, bearer: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return personas as PersonaMap;
|
||||||
|
}
|
||||||
@@ -65,18 +65,25 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
|
|||||||
expect(await expectRejected(ws)).toBe(403);
|
expect(await expectRejected(ws)).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a non-admin user without system:console (403)', async () => {
|
// All five built-in roles: only admin (system:console) is accepted on host
|
||||||
const { DatabaseService } = await import('../services/DatabaseService');
|
// console. Every other role must be rejected at upgrade time.
|
||||||
const hash = await bcrypt.hash('viewerpass', 1);
|
const NON_ADMIN_HOST_CONSOLE_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const;
|
||||||
try {
|
|
||||||
DatabaseService.getInstance().addUser({ username: 'hc_viewer', password_hash: hash, role: 'viewer' });
|
for (const role of NON_ADMIN_HOST_CONSOLE_ROLES) {
|
||||||
} catch {
|
it(`rejects ${role} user without system:console (403)`, async () => {
|
||||||
// already exists from a prior run in the same worker
|
const { DatabaseService } = await import('../services/DatabaseService');
|
||||||
}
|
const username = `hc_${role.replace('-', '_')}`;
|
||||||
const token = jwt.sign({ username: 'hc_viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
const hash = await bcrypt.hash('password123', 1);
|
||||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
|
try {
|
||||||
expect(await expectRejected(ws)).toBe(403);
|
DatabaseService.getInstance().addUser({ username, password_hash: hash, role });
|
||||||
});
|
} catch {
|
||||||
|
// already exists from a prior run in the same worker
|
||||||
|
}
|
||||||
|
const token = jwt.sign({ username, role }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||||
|
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
|
||||||
|
expect(await expectRejected(ws)).toBe(403);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('accepts a Community-tier admin', async () => {
|
it('accepts a Community-tier admin', async () => {
|
||||||
getTierSpy.mockReturnValueOnce('community');
|
getTierSpy.mockReturnValueOnce('community');
|
||||||
|
|||||||
@@ -3,34 +3,31 @@ import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissio
|
|||||||
import { classifyStackApiPath } from '../helpers/stackRouteAuth';
|
import { classifyStackApiPath } from '../helpers/stackRouteAuth';
|
||||||
|
|
||||||
describe('operational role matrix', () => {
|
describe('operational role matrix', () => {
|
||||||
const expectations: Record<string, { allow: PermissionAction[]; deny: PermissionAction[] }> = {
|
/**
|
||||||
admin: {
|
* Lockstep guard: every role's full permission set must match exactly.
|
||||||
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
|
* Any addition or removal to ROLE_PERMISSIONS must update this table;
|
||||||
deny: [],
|
* the full-set equality catches drift that a subset check would miss.
|
||||||
},
|
*/
|
||||||
'node-admin': {
|
const expectedRoleActions: Record<string, PermissionAction[]> = {
|
||||||
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'],
|
admin: [
|
||||||
deny: ['system:settings', 'system:users', 'system:license', 'system:registries'],
|
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||||
},
|
'node:read', 'node:manage',
|
||||||
deployer: {
|
'system:settings', 'system:users', 'system:license', 'system:webhooks',
|
||||||
allow: ['stack:read', 'stack:deploy'],
|
'system:tokens', 'system:console', 'system:audit', 'system:registries',
|
||||||
deny: ['stack:edit', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
|
],
|
||||||
},
|
'node-admin': [
|
||||||
viewer: {
|
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||||
allow: ['stack:read', 'node:read'],
|
'node:read', 'node:manage',
|
||||||
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:audit'],
|
],
|
||||||
},
|
deployer: ['stack:read', 'stack:deploy'],
|
||||||
auditor: {
|
viewer: ['stack:read', 'node:read'],
|
||||||
allow: ['stack:read', 'node:read', 'system:audit'],
|
auditor: ['stack:read', 'node:read', 'system:audit'],
|
||||||
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:settings'],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [role, expected] of Object.entries(expectations)) {
|
for (const [role, expected] of Object.entries(expectedRoleActions)) {
|
||||||
it(`${role} exposes only its intended operational actions`, () => {
|
it(`${role} has exactly the expected permission set`, () => {
|
||||||
const actions = ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS];
|
const actual = [...(ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS] ?? [])].sort();
|
||||||
for (const action of expected.allow) expect(actions).toContain(action);
|
expect(actual).toEqual([...expected].sort());
|
||||||
for (const action of expected.deny) expect(actions).not.toContain(action);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Persona fixture smoke tests: every persona must authenticate and carry
|
||||||
|
* the correct permission set before any downstream test relies on it.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import {
|
||||||
|
setupTestDb,
|
||||||
|
cleanupTestDb,
|
||||||
|
} from './helpers/setupTestDb';
|
||||||
|
import { seedPersonas, FIVE_ROLES, type PersonaMap } from './fixtures/personas';
|
||||||
|
import { ROLE_PERMISSIONS, checkPermission } from '../middleware/permissions';
|
||||||
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
|
|
||||||
|
describe('persona fixture integrity', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let app: import('express').Express;
|
||||||
|
let personas: PersonaMap;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = await setupTestDb();
|
||||||
|
({ app } = await import('../index'));
|
||||||
|
personas = seedPersonas(DatabaseService.getInstance());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
cleanupTestDb(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const role of FIVE_ROLES) {
|
||||||
|
describe(`${role} persona`, () => {
|
||||||
|
it('authenticates on the auth-status endpoint', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/auth/status')
|
||||||
|
.set('Authorization', personas[role].bearer);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Smoke: a 200 on /api/auth/status proves the JWT was accepted.
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the expected global permissions from ROLE_PERMISSIONS', () => {
|
||||||
|
const actions = ROLE_PERMISSIONS[role];
|
||||||
|
expect(actions).toBeDefined();
|
||||||
|
expect(actions).toContain('stack:read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkPermission matches global role grants for each owned action', () => {
|
||||||
|
const p = personas[role];
|
||||||
|
const actions = ROLE_PERMISSIONS[role];
|
||||||
|
for (const action of actions) {
|
||||||
|
const req = { user: { username: p.username, role: p.role, userId: 1 } } as any;
|
||||||
|
expect(checkPermission(req, action)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Pilot-agent-mode header parity: the createRemoteProxyMiddleware code path
|
||||||
|
* for pilot_agent remotes is identical to proxy mode (the only difference is
|
||||||
|
* an empty apiToken from NodeRegistry.getProxyTarget). Stub the singleton so
|
||||||
|
* a pilot_agent node routes through a loopback capture server and assert the
|
||||||
|
* same PROXY_ROLE_HEADER is forwarded.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import http from 'http';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||||
|
import { PROXY_ROLE_HEADER } from '../services/license-headers';
|
||||||
|
import { seedPersonas } from './fixtures/personas';
|
||||||
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
|
|
||||||
|
describe('pilot-agent-mode proxy role header parity', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let app: import('express').Express;
|
||||||
|
let server: http.Server;
|
||||||
|
let captured: http.IncomingHttpHeaders | null = null;
|
||||||
|
let pilotNodeId: number;
|
||||||
|
let personas: ReturnType<typeof seedPersonas>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = await setupTestDb();
|
||||||
|
({ app } = await import('../index'));
|
||||||
|
personas = seedPersonas(DatabaseService.getInstance());
|
||||||
|
|
||||||
|
// Loopback capture server that advertises cross-node-rbac.
|
||||||
|
server = http.createServer((req, res) => {
|
||||||
|
if (req.url?.startsWith('/api/meta')) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ version: '0.96.0', capabilities: ['cross-node-rbac'] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captured = req.headers;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end('[]');
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = (server.address() as import('net').AddressInfo).port;
|
||||||
|
|
||||||
|
pilotNodeId = DatabaseService.getInstance().addNode({
|
||||||
|
name: 'pilot-header-test',
|
||||||
|
type: 'remote',
|
||||||
|
mode: 'pilot_agent',
|
||||||
|
compose_dir: '/tmp',
|
||||||
|
is_default: false,
|
||||||
|
api_url: '',
|
||||||
|
api_token: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// getProxyTarget returns null for pilot_agent without a live bridge.
|
||||||
|
// Stub it to return the capture-server URL with an empty apiToken — the
|
||||||
|
// exact shape a real PilotTunnelBridge produces at runtime.
|
||||||
|
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||||
|
const registry = NodeRegistry.getInstance();
|
||||||
|
const orig = registry.getProxyTarget.bind(registry);
|
||||||
|
vi.spyOn(registry, 'getProxyTarget').mockImplementation((nid: number) => {
|
||||||
|
if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '' };
|
||||||
|
return orig(nid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
cleanupTestDb(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the deployer session role through the pilot-agent proxy path', async () => {
|
||||||
|
captured = null;
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/stacks')
|
||||||
|
.set('Authorization', personas.deployer.bearer)
|
||||||
|
.set('x-node-id', String(pilotNodeId));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// The capture server must have received the request.
|
||||||
|
expect(captured).not.toBeNull();
|
||||||
|
expect(captured?.[PROXY_ROLE_HEADER]).toBe('deployer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites a smuggled admin header with the real deployer role on pilot path', async () => {
|
||||||
|
captured = null;
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/stacks')
|
||||||
|
.set('Authorization', personas.deployer.bearer)
|
||||||
|
.set('x-node-id', String(pilotNodeId))
|
||||||
|
.set(PROXY_ROLE_HEADER, 'admin'); // smuggled
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(captured).not.toBeNull();
|
||||||
|
expect(captured?.[PROXY_ROLE_HEADER]).toBe('deployer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the admin session role through the pilot-agent proxy path', async () => {
|
||||||
|
captured = null;
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/stacks')
|
||||||
|
.set('Authorization', personas.admin.bearer)
|
||||||
|
.set('x-node-id', String(pilotNodeId));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(captured).not.toBeNull();
|
||||||
|
expect(captured?.[PROXY_ROLE_HEADER]).toBe('admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,22 +24,38 @@ import { PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADE
|
|||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let app: import('express').Express;
|
let app: import('express').Express;
|
||||||
let viewerBearer: string;
|
let viewerBearer: string;
|
||||||
|
let nodeAdminBearer: string;
|
||||||
|
let auditorBearer: string;
|
||||||
|
let deployerBearer: string;
|
||||||
|
|
||||||
const VIEWER_USER = 'proxy-role-viewer';
|
const VIEWER_USER = 'proxy-role-viewer';
|
||||||
|
const NODE_ADMIN_USER = 'proxy-role-node-admin';
|
||||||
|
const AUDITOR_USER = 'proxy-role-auditor';
|
||||||
|
const DEPLOYER_USER = 'proxy-role-deployer';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tmpDir = await setupTestDb();
|
tmpDir = await setupTestDb();
|
||||||
({ app } = await import('../index'));
|
({ app } = await import('../index'));
|
||||||
const { DatabaseService } = await import('../services/DatabaseService');
|
const { DatabaseService } = await import('../services/DatabaseService');
|
||||||
const db = DatabaseService.getInstance();
|
const db = DatabaseService.getInstance();
|
||||||
const hash = await bcrypt.hash('password123', 1);
|
|
||||||
db.addUser({ username: VIEWER_USER, password_hash: hash, role: 'viewer' });
|
// Seed bearer tokens for each non-admin role, matching the original
|
||||||
const viewer = db.getUserByUsername(VIEWER_USER)!;
|
// viewer pattern exactly to avoid drift.
|
||||||
viewerBearer = jwt.sign(
|
const seed = (username: string, role: string): string => {
|
||||||
{ username: VIEWER_USER, role: 'viewer', tv: viewer.token_version },
|
const hash = bcrypt.hashSync('password123', 1);
|
||||||
TEST_JWT_SECRET,
|
db.addUser({ username, password_hash: hash, role: role as any });
|
||||||
{ expiresIn: '1m' },
|
const user = db.getUserByUsername(username)!;
|
||||||
);
|
return jwt.sign(
|
||||||
|
{ username, role, tv: user.token_version },
|
||||||
|
TEST_JWT_SECRET,
|
||||||
|
{ expiresIn: '1m' },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
viewerBearer = seed(VIEWER_USER, 'viewer');
|
||||||
|
deployerBearer = seed(DEPLOYER_USER, 'deployer');
|
||||||
|
nodeAdminBearer = seed(NODE_ADMIN_USER, 'node-admin');
|
||||||
|
auditorBearer = seed(AUDITOR_USER, 'auditor');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -127,9 +143,65 @@ describe('authMiddleware - forwarded actor role (node_proxy)', () => {
|
|||||||
.set(PROXY_ROLE_HEADER, 'superadmin');
|
.set(PROXY_ROLE_HEADER, 'superadmin');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// All five built-in roles: denied on admin-only, allowed on read-only.
|
||||||
|
const NON_ADMIN_ROLES = [
|
||||||
|
{ role: 'viewer', desc: 'viewer' },
|
||||||
|
{ role: 'deployer', desc: 'deployer' },
|
||||||
|
{ role: 'node-admin', desc: 'node-admin' },
|
||||||
|
{ role: 'auditor', desc: 'auditor' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { role, desc } of NON_ADMIN_ROLES) {
|
||||||
|
it(`denies admin-only route for forwarded ${desc}`, async () => {
|
||||||
|
const token = signToken({ scope: 'node_proxy' });
|
||||||
|
const res = await request(app)
|
||||||
|
.get(ADMIN_ONLY)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set(PROXY_ROLE_HEADER, role);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`grants read access for forwarded ${desc}`, async () => {
|
||||||
|
const token = signToken({ scope: 'node_proxy' });
|
||||||
|
const res = await request(app)
|
||||||
|
.get(READ_ONLY)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set(PROXY_ROLE_HEADER, role);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`applies same trust to pilot_tunnel for forwarded ${desc}`, async () => {
|
||||||
|
const token = signToken({ scope: 'pilot_tunnel' });
|
||||||
|
const res = await request(app)
|
||||||
|
.get(ADMIN_ONLY)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set(PROXY_ROLE_HEADER, role);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Security - actor-role header cannot be smuggled by a user session', () => {
|
describe('Security - actor-role header cannot be smuggled by a user session', () => {
|
||||||
|
// Verify each non-admin bearer token authenticates locally before testing
|
||||||
|
// proxy paths. Catches token-version drift between seeding and signing.
|
||||||
|
// Bearer variables are set in beforeAll; resolve lazily inside each test.
|
||||||
|
const BEARER_REFS: Array<{ name: string; get: () => string }> = [
|
||||||
|
{ name: 'viewer', get: () => viewerBearer },
|
||||||
|
{ name: 'deployer', get: () => deployerBearer },
|
||||||
|
{ name: 'node-admin', get: () => nodeAdminBearer },
|
||||||
|
{ name: 'auditor', get: () => auditorBearer },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, get } of BEARER_REFS) {
|
||||||
|
it(`${name} token authenticates on /api/auth/status`, async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/auth/status')
|
||||||
|
.set('Authorization', `Bearer ${get()}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('ignores the role header on a cookie session (uses the DB role)', async () => {
|
it('ignores the role header on a cookie session (uses the DB role)', async () => {
|
||||||
const cookie = await loginAsTestAdmin(app);
|
const cookie = await loginAsTestAdmin(app);
|
||||||
// A browser session that tries to downgrade itself (or, by the same path,
|
// A browser session that tries to downgrade itself (or, by the same path,
|
||||||
@@ -235,4 +307,28 @@ describe('remote proxy gateway - actor role header forwarding', () => {
|
|||||||
expect(captured?.[PROXY_DEPLOY_SOURCE_HEADER]).toBe('manual');
|
expect(captured?.[PROXY_DEPLOY_SOURCE_HEADER]).toBe('manual');
|
||||||
expect(captured?.[PROXY_DEPLOY_ACTOR_HEADER]).toBe(VIEWER_USER);
|
expect(captured?.[PROXY_DEPLOY_ACTOR_HEADER]).toBe(VIEWER_USER);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Anti-smuggling extended to the remaining non-admin roles: each persona's
|
||||||
|
// real role must be forwarded even when an attacker sets the admin header.
|
||||||
|
// The bearer variables are set in beforeAll; resolve them lazily inside each
|
||||||
|
// test so we never capture undefined at describe-registration time.
|
||||||
|
const SMUGGLE_PERSONAS: Array<{ name: string; bearerRef: () => string; role: string }> = [
|
||||||
|
{ name: 'deployer', bearerRef: () => deployerBearer, role: 'deployer' },
|
||||||
|
{ name: 'node-admin', bearerRef: () => nodeAdminBearer, role: 'node-admin' },
|
||||||
|
{ name: 'auditor', bearerRef: () => auditorBearer, role: 'auditor' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, bearerRef, role } of SMUGGLE_PERSONAS) {
|
||||||
|
it(`overwrites smuggled admin header with ${name} session role`, async () => {
|
||||||
|
captured = null;
|
||||||
|
const res = await request(app)
|
||||||
|
.get(READ_ONLY)
|
||||||
|
.set('Authorization', `Bearer ${bearerRef()}`)
|
||||||
|
.set('x-node-id', String(remoteNodeId))
|
||||||
|
.set(PROXY_ROLE_HEADER, 'admin');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(captured?.[PROXY_ROLE_HEADER]).toBe(role);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* RBAC regression coverage: MFA reset gating, SSO user permission parity,
|
||||||
|
* and scoped-evidence fail-closed on remote.
|
||||||
|
*
|
||||||
|
* Tests for last-admin protection, immediate role-change effect, and
|
||||||
|
* API-token scope isolation are already covered by users-rbac.test.ts,
|
||||||
|
* api-tokens.test.ts, and api-token-ws-scope.test.ts respectively.
|
||||||
|
*
|
||||||
|
* Single describe + single setupTestDb()/cleanupTestDb() pair because the
|
||||||
|
* DatabaseService singleton is lazy-constructed on first getInstance() and
|
||||||
|
* never re-initializes; a second setupTestDb() after a first cleanupTestDb()
|
||||||
|
* would reuse a stale handle pointing at a deleted file, tripping
|
||||||
|
* SQLITE_READONLY_DBMOVED on Linux.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import {
|
||||||
|
setupTestDb,
|
||||||
|
cleanupTestDb,
|
||||||
|
TEST_JWT_SECRET,
|
||||||
|
} from './helpers/setupTestDb';
|
||||||
|
import { checkPermission } from '../middleware/permissions';
|
||||||
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
|
|
||||||
|
describe('RBAC regression coverage', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let app: import('express').Express;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = await setupTestDb();
|
||||||
|
({ app } = await import('../index'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
cleanupTestDb(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MFA reset gating ────────────────────────────────────────────
|
||||||
|
// POST /:id/mfa/reset requires system:users (admin only).
|
||||||
|
const TARGET_ID = 1; // Baseline admin is id 1, seeded by globalSetup.
|
||||||
|
|
||||||
|
it('rejects unauthenticated MFA reset (401)', async () => {
|
||||||
|
const res = await request(app).post(`/api/users/${TARGET_ID}/mfa/reset`);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-admin from resetting MFA (403)', async () => {
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
const bcrypt = await import('bcrypt');
|
||||||
|
const hash = await bcrypt.default.hash('password123', 1);
|
||||||
|
db.addUser({ username: 'mfa-viewer', password_hash: hash, role: 'viewer' });
|
||||||
|
const user = db.getUserByUsername('mfa-viewer')!;
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ username: 'mfa-viewer', role: 'viewer', tv: user.token_version },
|
||||||
|
TEST_JWT_SECRET,
|
||||||
|
{ expiresIn: '1m' },
|
||||||
|
);
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/users/${TARGET_ID}/mfa/reset`)
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SSO user permission parity ──────────────────────────────────
|
||||||
|
|
||||||
|
it('grants same permissions as a local user of the same role', () => {
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
db.addUser({
|
||||||
|
username: 'sso-node-admin',
|
||||||
|
password_hash: '$sso$fake',
|
||||||
|
role: 'node-admin',
|
||||||
|
auth_provider: 'oidc_google',
|
||||||
|
provider_id: 'google-456',
|
||||||
|
email: 'sso-na@test.com',
|
||||||
|
});
|
||||||
|
const ssoUser = db.getUserByUsername('sso-node-admin')!;
|
||||||
|
|
||||||
|
// permissions.ts never branches on auth_provider; only role matters.
|
||||||
|
const req = {
|
||||||
|
user: { username: ssoUser.username, role: ssoUser.role, userId: ssoUser.id },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
expect(checkPermission(req, 'stack:read')).toBe(true);
|
||||||
|
expect(checkPermission(req, 'stack:edit')).toBe(true);
|
||||||
|
expect(checkPermission(req, 'stack:deploy')).toBe(true);
|
||||||
|
expect(checkPermission(req, 'node:manage')).toBe(true);
|
||||||
|
expect(checkPermission(req, 'system:settings')).toBe(false);
|
||||||
|
expect(checkPermission(req, 'system:users')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Scoped evidence fail-closed ─────────────────────────────────
|
||||||
|
|
||||||
|
it('denies a scoped-only user when scopedStackEvidence is absent', () => {
|
||||||
|
const remoteReq = {
|
||||||
|
user: { username: 'node-proxy', role: 'viewer', userId: 0 },
|
||||||
|
scopedStackEvidence: undefined,
|
||||||
|
} as any;
|
||||||
|
expect(checkPermission(remoteReq, 'stack:edit', 'stack', 'web')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the same action when evidence is present and matches', () => {
|
||||||
|
const remoteReq = {
|
||||||
|
user: { username: 'node-proxy', role: 'viewer', userId: 0 },
|
||||||
|
scopedStackEvidence: {
|
||||||
|
stackName: 'web',
|
||||||
|
actions: new Set(['stack:edit', 'stack:deploy']),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
expect(checkPermission(remoteReq, 'stack:edit', 'stack', 'web')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user