mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 00:18:00 +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);
|
||||
});
|
||||
|
||||
it('rejects WebSocket upgrade with non-admin token (403)', async () => {
|
||||
// Add a non-admin user
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
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
|
||||
}
|
||||
// All five built-in roles: container exec requires admin. Every non-admin
|
||||
// role must be rejected at upgrade time by generic.ts's role === 'admin' gate.
|
||||
const NON_ADMIN_EXEC_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const;
|
||||
|
||||
const token = jwt.sign(
|
||||
{ username: 'viewer', role: 'viewer' },
|
||||
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));
|
||||
for (const role of NON_ADMIN_EXEC_ROLES) {
|
||||
it(`rejects /ws upgrade with ${role} token (403)`, async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const bcrypt = await import('bcrypt');
|
||||
const username = `exec_${role.replace('-', '_')}`;
|
||||
const hash = bcrypt.hashSync('password123', 1);
|
||||
try {
|
||||
DatabaseService.getInstance().addUser({ username, password_hash: hash, role });
|
||||
} 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 () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('rejects a non-admin user without system:console (403)', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const hash = await bcrypt.hash('viewerpass', 1);
|
||||
try {
|
||||
DatabaseService.getInstance().addUser({ username: 'hc_viewer', password_hash: hash, role: 'viewer' });
|
||||
} catch {
|
||||
// already exists from a prior run in the same worker
|
||||
}
|
||||
const token = jwt.sign({ username: 'hc_viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
// All five built-in roles: only admin (system:console) is accepted on host
|
||||
// console. Every other role must be rejected at upgrade time.
|
||||
const NON_ADMIN_HOST_CONSOLE_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const;
|
||||
|
||||
for (const role of NON_ADMIN_HOST_CONSOLE_ROLES) {
|
||||
it(`rejects ${role} user without system:console (403)`, async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const username = `hc_${role.replace('-', '_')}`;
|
||||
const hash = await bcrypt.hash('password123', 1);
|
||||
try {
|
||||
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 () => {
|
||||
getTierSpy.mockReturnValueOnce('community');
|
||||
|
||||
@@ -3,34 +3,31 @@ import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissio
|
||||
import { classifyStackApiPath } from '../helpers/stackRouteAuth';
|
||||
|
||||
describe('operational role matrix', () => {
|
||||
const expectations: Record<string, { allow: PermissionAction[]; deny: PermissionAction[] }> = {
|
||||
admin: {
|
||||
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
|
||||
deny: [],
|
||||
},
|
||||
'node-admin': {
|
||||
allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'],
|
||||
deny: ['system:settings', 'system:users', 'system:license', 'system:registries'],
|
||||
},
|
||||
deployer: {
|
||||
allow: ['stack:read', 'stack:deploy'],
|
||||
deny: ['stack:edit', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'],
|
||||
},
|
||||
viewer: {
|
||||
allow: ['stack:read', 'node:read'],
|
||||
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:audit'],
|
||||
},
|
||||
auditor: {
|
||||
allow: ['stack:read', 'node:read', 'system:audit'],
|
||||
deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:settings'],
|
||||
},
|
||||
/**
|
||||
* Lockstep guard: every role's full permission set must match exactly.
|
||||
* Any addition or removal to ROLE_PERMISSIONS must update this table;
|
||||
* the full-set equality catches drift that a subset check would miss.
|
||||
*/
|
||||
const expectedRoleActions: Record<string, PermissionAction[]> = {
|
||||
admin: [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
'system:settings', 'system:users', 'system:license', 'system:webhooks',
|
||||
'system:tokens', 'system:console', 'system:audit', 'system:registries',
|
||||
],
|
||||
'node-admin': [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
],
|
||||
deployer: ['stack:read', 'stack:deploy'],
|
||||
viewer: ['stack:read', 'node:read'],
|
||||
auditor: ['stack:read', 'node:read', 'system:audit'],
|
||||
};
|
||||
|
||||
for (const [role, expected] of Object.entries(expectations)) {
|
||||
it(`${role} exposes only its intended operational actions`, () => {
|
||||
const actions = ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS];
|
||||
for (const action of expected.allow) expect(actions).toContain(action);
|
||||
for (const action of expected.deny) expect(actions).not.toContain(action);
|
||||
for (const [role, expected] of Object.entries(expectedRoleActions)) {
|
||||
it(`${role} has exactly the expected permission set`, () => {
|
||||
const actual = [...(ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS] ?? [])].sort();
|
||||
expect(actual).toEqual([...expected].sort());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 app: import('express').Express;
|
||||
let viewerBearer: string;
|
||||
let nodeAdminBearer: string;
|
||||
let auditorBearer: string;
|
||||
let deployerBearer: string;
|
||||
|
||||
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 () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
const hash = await bcrypt.hash('password123', 1);
|
||||
db.addUser({ username: VIEWER_USER, password_hash: hash, role: 'viewer' });
|
||||
const viewer = db.getUserByUsername(VIEWER_USER)!;
|
||||
viewerBearer = jwt.sign(
|
||||
{ username: VIEWER_USER, role: 'viewer', tv: viewer.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
|
||||
// Seed bearer tokens for each non-admin role, matching the original
|
||||
// viewer pattern exactly to avoid drift.
|
||||
const seed = (username: string, role: string): string => {
|
||||
const hash = bcrypt.hashSync('password123', 1);
|
||||
db.addUser({ username, password_hash: hash, role: role as any });
|
||||
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(() => {
|
||||
@@ -127,9 +143,65 @@ describe('authMiddleware - forwarded actor role (node_proxy)', () => {
|
||||
.set(PROXY_ROLE_HEADER, 'superadmin');
|
||||
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', () => {
|
||||
// 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 () => {
|
||||
const cookie = await loginAsTestAdmin(app);
|
||||
// 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_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