Files
sencho/backend/src/__tests__/scoped-stack-auth-evidence.test.ts
T
Anso 9922d8e765 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
2026-07-29 09:42:14 -04:00

128 lines
4.6 KiB
TypeScript

/**
* 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();
});
});