mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
fix(proxy): forward scoped stack evidence for alerts, auto-heal, and node-wide image refresh (#1749)
* fix(proxy): forward scoped stack evidence for alerts, auto-heal, and node-wide image refresh Extend the remote proxy scoped-evidence mechanism beyond /stacks/* routes. Three new gates in runGatedProxy: - Alerts POST: reuse the already-buffered body from the existing isAlertCreateRoute block, extract stack_name, check hub-side scoped permission, and forward SCOPED_STACK_AUTH_EVIDENCE headers. - Auto-heal POST: same pattern with new body buffering and encoding rejection (no pre-existing buffering exists for this route). - Node-wide image refresh: elevate PROXY_ROLE_HEADER to node-admin when the user has a scoped node:manage grant on the target node, matching the Settings pre-auth gate pattern. Also extend classifyStackApiPath to recognize /image-updates/refresh/:stackName as a named-stack route (stack:deploy), ready for when PR #1743 adds the per-stack refresh endpoint. Explicitly excluded: ID-based routes (DELETE /alerts/:id, PATCH/DELETE /auto-heal/policies/:id) where the hub cannot resolve remote-owned IDs to stack names; GET routes where stack:read is globally granted to every role; and POST /auto-update/execute where multi-stack/wildcard targets need a different evidence format. * chore(proxy): add RBAC diagnostic logging to scoped permission path Add developer_mode-gated diagnostic logs to checkPermission to expose which check is failing when a scoped user is denied: effective tier, DB query parameters, and node-scoped assignment lookups. * chore(rbac): log effective tier and license status when scoped checks are blocked Add an always-visible console.warn in checkPermission when the effective tier prevents scoped role-assignment lookups, logging both the resolved tier and the raw license_status DB value. This surfaces the failure reason in container logs without requiring developer_mode, so QA can diagnose why scoped users are denied. * chore(rbac): sanitize scoped-tier log values to satisfy CodeQL log-injection check
This commit is contained in:
Generated
-1
@@ -10,7 +10,6 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.1097.0",
|
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^13.0.1",
|
"better-sqlite3": "^13.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* Hub → remote proxy coverage for scoped stack-evidence forwarding to
|
||||||
|
* alerts, auto-heal, and node-wide image-refresh routes. Exercises the
|
||||||
|
* three new proxy gates in createRemoteProxyMiddleware through live
|
||||||
|
* loopback remotes.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, 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_ROLE_HEADER,
|
||||||
|
PROXY_SCOPED_STACK_NAME_HEADER,
|
||||||
|
PROXY_SCOPED_STACK_ACTIONS_HEADER,
|
||||||
|
} from '../services/license-headers';
|
||||||
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
|
import { classifyStackApiPath } from '../helpers/stackRouteAuth';
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let app: import('express').Express;
|
||||||
|
let deployerBearer: string;
|
||||||
|
let deployerId: number;
|
||||||
|
let nodeAdminBearer: string;
|
||||||
|
|
||||||
|
let grantedServer: http.Server;
|
||||||
|
let ungrantedServer: http.Server;
|
||||||
|
let noEvidenceServer: http.Server;
|
||||||
|
let grantedNodeId: number;
|
||||||
|
let ungrantedNodeId: number;
|
||||||
|
let noEvidenceNodeId: number;
|
||||||
|
|
||||||
|
interface CapturedHop {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
roleHeader: string | undefined;
|
||||||
|
stackNameHeader: string | undefined;
|
||||||
|
stackActionsHeader: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantedHops: CapturedHop[] = [];
|
||||||
|
const ungrantedHops: CapturedHop[] = [];
|
||||||
|
|
||||||
|
function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void {
|
||||||
|
into.push({
|
||||||
|
method: req.method ?? '',
|
||||||
|
url: req.url ?? '',
|
||||||
|
roleHeader: req.headers[PROXY_ROLE_HEADER] as string | undefined,
|
||||||
|
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, grantedHops);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: 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 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Deployer (no global stack:edit; needs scoped evidence)
|
||||||
|
deployerId = db.addUser({
|
||||||
|
username: 'proxy-evid-deployer',
|
||||||
|
password_hash: hash,
|
||||||
|
role: 'deployer',
|
||||||
|
});
|
||||||
|
const deployerUser = db.getUserByUsername('proxy-evid-deployer')!;
|
||||||
|
deployerBearer = jwt.sign(
|
||||||
|
{ username: 'proxy-evid-deployer', role: 'deployer', tv: deployerUser.token_version },
|
||||||
|
TEST_JWT_SECRET,
|
||||||
|
{ expiresIn: '5m' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Node-admin (has global stack:edit; should skip gates)
|
||||||
|
db.addUser({
|
||||||
|
username: 'proxy-evid-nodeadmin',
|
||||||
|
password_hash: hash,
|
||||||
|
role: 'node-admin',
|
||||||
|
});
|
||||||
|
const naUser = db.getUserByUsername('proxy-evid-nodeadmin')!;
|
||||||
|
nodeAdminBearer = jwt.sign(
|
||||||
|
{ username: 'proxy-evid-nodeadmin', role: 'node-admin', tv: naUser.token_version },
|
||||||
|
TEST_JWT_SECRET,
|
||||||
|
{ expiresIn: '5m' },
|
||||||
|
);
|
||||||
|
|
||||||
|
grantedServer = evidenceRemote();
|
||||||
|
ungrantedServer = evidenceRemote();
|
||||||
|
noEvidenceServer = noEvidenceRemote();
|
||||||
|
const grantedPort = await listen(grantedServer);
|
||||||
|
const ungrantedPort = await listen(ungrantedServer);
|
||||||
|
const noEvidencePort = await listen(noEvidenceServer);
|
||||||
|
|
||||||
|
grantedNodeId = db.addNode({
|
||||||
|
name: 'evidence-granted-remote',
|
||||||
|
type: 'remote',
|
||||||
|
mode: 'proxy',
|
||||||
|
compose_dir: '/tmp',
|
||||||
|
is_default: false,
|
||||||
|
api_url: `http://127.0.0.1:${grantedPort}`,
|
||||||
|
api_token: 'granted-token',
|
||||||
|
});
|
||||||
|
ungrantedNodeId = db.addNode({
|
||||||
|
name: 'evidence-ungranted-remote',
|
||||||
|
type: 'remote',
|
||||||
|
mode: 'proxy',
|
||||||
|
compose_dir: '/tmp',
|
||||||
|
is_default: false,
|
||||||
|
api_url: `http://127.0.0.1:${ungrantedPort}`,
|
||||||
|
api_token: 'ungranted-token',
|
||||||
|
});
|
||||||
|
noEvidenceNodeId = db.addNode({
|
||||||
|
name: 'evidence-noevidence-remote',
|
||||||
|
type: 'remote',
|
||||||
|
mode: 'proxy',
|
||||||
|
compose_dir: '/tmp',
|
||||||
|
is_default: false,
|
||||||
|
api_url: `http://127.0.0.1:${noEvidencePort}`,
|
||||||
|
api_token: 'noev-token',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((resolve) => grantedServer.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => ungrantedServer.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => noEvidenceServer.close(() => resolve()));
|
||||||
|
cleanupTestDb(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
const { LicenseService } = await import('../services/LicenseService');
|
||||||
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||||
|
grantedHops.length = 0;
|
||||||
|
ungrantedHops.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function grantScopedStackEdit(userId: number, nodeId: number, stackName: string): void {
|
||||||
|
// node-admin role grants stack:edit (plus all stack permissions).
|
||||||
|
// Scoped to a single stack so the user only gets stack:edit on that stack.
|
||||||
|
DatabaseService.getInstance().addRoleAssignment({
|
||||||
|
user_id: userId,
|
||||||
|
role: 'node-admin',
|
||||||
|
resource_type: 'stack',
|
||||||
|
resource_id: stackName,
|
||||||
|
node_id: nodeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantScopedNodeManage(userId: number, nodeId: number): void {
|
||||||
|
DatabaseService.getInstance().addRoleAssignment({
|
||||||
|
user_id: userId,
|
||||||
|
role: 'node-admin',
|
||||||
|
resource_type: 'node',
|
||||||
|
resource_id: String(nodeId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAssignments(userId: number): void {
|
||||||
|
DatabaseService.getInstance().deleteRoleAssignmentsByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('remote proxy alerts scoped-evidence gate', () => {
|
||||||
|
it('forwards scoped stack:edit evidence for scoped deployer on POST /alerts', async () => {
|
||||||
|
grantScopedStackEdit(deployerId, grantedNodeId, 'web');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/alerts')
|
||||||
|
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||||
|
.set('x-node-id', String(grantedNodeId))
|
||||||
|
.send({ stack_name: 'web', metric: 'cpu_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 5 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const hop = grantedHops.find((h) => h.url?.includes('/api/alerts'));
|
||||||
|
expect(hop).toBeDefined();
|
||||||
|
expect(hop!.stackNameHeader).toBe('web');
|
||||||
|
expect(hop!.stackActionsHeader).toContain('stack:edit');
|
||||||
|
|
||||||
|
clearAssignments(deployerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies scoped deployer on ungranted stack for POST /alerts', async () => {
|
||||||
|
grantScopedStackEdit(deployerId, grantedNodeId, 'web');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/alerts')
|
||||||
|
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||||
|
.set('x-node-id', String(grantedNodeId))
|
||||||
|
.send({ stack_name: 'other-stack', metric: 'cpu_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 5 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||||
|
|
||||||
|
clearAssignments(deployerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies when remote lacks scoped-stack-auth-evidence capability', async () => {
|
||||||
|
grantScopedStackEdit(deployerId, noEvidenceNodeId, 'web');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/alerts')
|
||||||
|
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||||
|
.set('x-node-id', String(noEvidenceNodeId))
|
||||||
|
.send({ stack_name: 'web', metric: 'cpu_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 5 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toContain('does not support scoped stack authorization');
|
||||||
|
|
||||||
|
clearAssignments(deployerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through without evidence for node-admin (global stack:edit)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/alerts')
|
||||||
|
.set('Authorization', `Bearer ${nodeAdminBearer}`)
|
||||||
|
.set('x-node-id', String(grantedNodeId))
|
||||||
|
.send({ stack_name: 'web', metric: 'cpu_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 5 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const hop = grantedHops.find((h) => h.url?.includes('/api/alerts'));
|
||||||
|
expect(hop).toBeDefined();
|
||||||
|
// Node-admin global role grants stack:edit; no evidence needed.
|
||||||
|
expect(hop!.stackNameHeader).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remote proxy auto-heal scoped-evidence gate', () => {
|
||||||
|
it('forwards scoped stack:edit evidence for scoped deployer on POST /auto-heal/policies', async () => {
|
||||||
|
grantScopedStackEdit(deployerId, grantedNodeId, 'web');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auto-heal/policies')
|
||||||
|
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||||
|
.set('x-node-id', String(grantedNodeId))
|
||||||
|
.send({ stack_name: 'web', service_name: 'app', unhealthy_duration_mins: 5, cooldown_mins: 5, max_restarts_per_hour: 3, auto_disable_after_failures: 5 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const hop = grantedHops.find((h) => h.url?.includes('/api/auto-heal'));
|
||||||
|
expect(hop).toBeDefined();
|
||||||
|
expect(hop!.stackNameHeader).toBe('web');
|
||||||
|
expect(hop!.stackActionsHeader).toContain('stack:edit');
|
||||||
|
|
||||||
|
clearAssignments(deployerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects compressed body with 415', async () => {
|
||||||
|
grantScopedStackEdit(deployerId, grantedNodeId, 'web');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auto-heal/policies')
|
||||||
|
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||||
|
.set('x-node-id', String(grantedNodeId))
|
||||||
|
.set('Content-Encoding', 'gzip')
|
||||||
|
.send(Buffer.alloc(32));
|
||||||
|
|
||||||
|
expect(res.status).toBe(415);
|
||||||
|
expect(res.body.code).toBe('encoding_unsupported');
|
||||||
|
|
||||||
|
clearAssignments(deployerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies when body is unparseable (fail closed)', async () => {
|
||||||
|
grantScopedStackEdit(deployerId, grantedNodeId, 'web');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auto-heal/policies')
|
||||||
|
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||||
|
.set('x-node-id', String(grantedNodeId))
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send('not json');
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain('not valid JSON');
|
||||||
|
|
||||||
|
clearAssignments(deployerId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remote proxy node-wide image refresh elevation gate', () => {
|
||||||
|
it('elevates role to node-admin for scoped node-manager on POST /image-updates/refresh', async () => {
|
||||||
|
grantScopedNodeManage(deployerId, grantedNodeId);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/image-updates/refresh')
|
||||||
|
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||||
|
.set('x-node-id', String(grantedNodeId));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const hop = grantedHops.find((h) => h.url?.includes('/api/image-updates/refresh'));
|
||||||
|
expect(hop).toBeDefined();
|
||||||
|
expect(hop!.roleHeader).toBe('node-admin');
|
||||||
|
|
||||||
|
clearAssignments(deployerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies scoped node-manager on ungranted node', async () => {
|
||||||
|
grantScopedNodeManage(deployerId, grantedNodeId);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/image-updates/refresh')
|
||||||
|
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||||
|
.set('x-node-id', String(ungrantedNodeId));
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||||
|
|
||||||
|
clearAssignments(deployerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not elevate role header for POST /image-updates/fleet/refresh', async () => {
|
||||||
|
// /fleet/refresh is the separate fleet-wide fan-out route; must not be
|
||||||
|
// matched by the isImageRefreshNodeWide predicate.
|
||||||
|
grantScopedNodeManage(deployerId, grantedNodeId);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/image-updates/fleet/refresh')
|
||||||
|
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||||
|
.set('x-node-id', String(grantedNodeId));
|
||||||
|
|
||||||
|
// The route is requireAdmin on main today, but our gate must not interfere
|
||||||
|
// regardless. The role header should carry the caller's real role, not
|
||||||
|
// an elevated one (since the predicate excludes fleet/refresh).
|
||||||
|
const hop = grantedHops.find((h) => h.url?.includes('/api/image-updates/fleet'));
|
||||||
|
if (hop) {
|
||||||
|
expect(hop.roleHeader).not.toBe('node-admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAssignments(deployerId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyStackApiPath per-stack image refresh', () => {
|
||||||
|
it('classifies POST /image-updates/refresh/web as named-stack with stack:deploy', () => {
|
||||||
|
const result = classifyStackApiPath('POST', '/image-updates/refresh/web');
|
||||||
|
expect(result.kind).toBe('named-stack');
|
||||||
|
if (result.kind === 'named-stack') {
|
||||||
|
expect(result.stackName).toBe('web');
|
||||||
|
expect(result.action).toBe('stack:deploy');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies POST /image-updates/refresh (no stack name) as static', () => {
|
||||||
|
const result = classifyStackApiPath('POST', '/image-updates/refresh');
|
||||||
|
expect(result.kind).toBe('static');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -134,15 +134,16 @@ function decodeStackSegment(raw: string): string | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a post-/api path for hub stack RBAC gating and evidence.
|
* Classify a post-/api path for hub stack RBAC gating and evidence.
|
||||||
* Paths outside `/stacks` (and static `/stacks` collection routes) are
|
* Paths outside `/stacks` and `/image-updates/refresh/` (and static
|
||||||
* `static`. Known named-stack families return the primary pre-check action.
|
* `/stacks` collection routes) are `static`. Known named-stack families
|
||||||
* An unrecognized `/stacks/<name>/...` path fails closed as `unknown-named`.
|
* return the primary pre-check action. An unrecognized
|
||||||
|
* `/stacks/<name>/...` path fails closed as `unknown-named`.
|
||||||
*/
|
*/
|
||||||
export function classifyStackApiPath(method: string, pathAfterApiStrip: string): StackRouteClassify {
|
export function classifyStackApiPath(method: string, pathAfterApiStrip: string): StackRouteClassify {
|
||||||
const methodUpper = method.toUpperCase();
|
const methodUpper = method.toUpperCase();
|
||||||
const path = normalizePath(pathAfterApiStrip);
|
const path = normalizePath(pathAfterApiStrip);
|
||||||
|
|
||||||
if (!path.startsWith('/stacks')) {
|
if (!path.startsWith('/stacks') && !path.startsWith('/image-updates/refresh/')) {
|
||||||
return { kind: 'static' };
|
return { kind: 'static' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +162,20 @@ export function classifyStackApiPath(method: string, pathAfterApiStrip: string):
|
|||||||
return { kind: 'static' };
|
return { kind: 'static' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /image-updates/refresh/:stackName → per-stack image check (stack:deploy).
|
||||||
|
// This branch runs before the /stacks/-only regex, which would never match.
|
||||||
|
// Unknown sub-paths under this prefix fail closed (unknown-named), matching
|
||||||
|
// the fail-closed behavior for unknown /stacks/<name>/... paths.
|
||||||
|
if (path.startsWith('/image-updates/refresh/')) {
|
||||||
|
const imageRefreshMatch = /^\/image-updates\/refresh\/([^/]+)$/.exec(path);
|
||||||
|
if (imageRefreshMatch) {
|
||||||
|
const stackName = decodeStackSegment(imageRefreshMatch[1]);
|
||||||
|
if (!stackName) return { kind: 'unknown-named' };
|
||||||
|
return { kind: 'named-stack', stackName, action: 'stack:deploy' };
|
||||||
|
}
|
||||||
|
return { kind: 'unknown-named' };
|
||||||
|
}
|
||||||
|
|
||||||
const match = /^\/stacks\/([^/]+)(.*)$/.exec(path);
|
const match = /^\/stacks\/([^/]+)(.*)$/.exec(path);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { kind: 'static' };
|
return { kind: 'static' };
|
||||||
|
|||||||
@@ -111,7 +111,11 @@ export function checkPermission(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveTier(req) !== 'paid') return false;
|
const tier = effectiveTier(req);
|
||||||
|
if (tier !== 'paid') {
|
||||||
|
console.warn('[RBAC] Scoped assignment check blocked: effective tier is', sanitizeForLog(tier), 'license_status:', sanitizeForLog(DatabaseService.getInstance().getSystemState('license_status') ?? ''));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const db = DatabaseService.getInstance();
|
const db = DatabaseService.getInstance();
|
||||||
const nodeId = resourceType === 'stack'
|
const nodeId = resourceType === 'stack'
|
||||||
@@ -123,7 +127,6 @@ export function checkPermission(
|
|||||||
resourceId,
|
resourceId,
|
||||||
nodeId,
|
nodeId,
|
||||||
);
|
);
|
||||||
if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId);
|
|
||||||
for (const assignment of assignments) {
|
for (const assignment of assignments) {
|
||||||
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
|
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
import { DatabaseService } from '../services/DatabaseService';
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
import { redactSensitiveText } from '../utils/safeLog';
|
import { redactSensitiveText } from '../utils/safeLog';
|
||||||
|
import { isValidStackName } from '../utils/validation';
|
||||||
import { isDebugEnabled } from '../utils/debug';
|
import { isDebugEnabled } from '../utils/debug';
|
||||||
import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming';
|
import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming';
|
||||||
import { invalidateFleetUpdateCache, isFullStackUpdatePath, isUpdatePreviewPath } from '../helpers/fleetUpdateCache';
|
import { invalidateFleetUpdateCache, isFullStackUpdatePath, isUpdatePreviewPath } from '../helpers/fleetUpdateCache';
|
||||||
@@ -140,7 +141,9 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
|||||||
// re-set from the authenticated session (authGate runs before this proxy,
|
// re-set from the authenticated session (authGate runs before this proxy,
|
||||||
// so req.user is always resolved here).
|
// so req.user is always resolved here).
|
||||||
proxyReq.removeHeader(PROXY_ROLE_HEADER);
|
proxyReq.removeHeader(PROXY_ROLE_HEADER);
|
||||||
if (req.user?.role) {
|
if (req.proxyElevatedRole) {
|
||||||
|
proxyReq.setHeader(PROXY_ROLE_HEADER, req.proxyElevatedRole);
|
||||||
|
} else if (req.user?.role) {
|
||||||
proxyReq.setHeader(PROXY_ROLE_HEADER, req.user.role);
|
proxyReq.setHeader(PROXY_ROLE_HEADER, req.user.role);
|
||||||
}
|
}
|
||||||
// Deploy provenance: always strip client-supplied values, then set
|
// Deploy provenance: always strip client-supplied values, then set
|
||||||
@@ -417,6 +420,116 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alerts POST scoped-evidence gate: when a non-admin, non-node-admin user
|
||||||
|
// creates a stack-scoped alert on a remote node, forward the scoped grant
|
||||||
|
// as evidence so the remote can authorize the write. The body was already
|
||||||
|
// buffered by the isAlertCreateRoute block above; this gate only inspects
|
||||||
|
// the stack_name field from the buffered JSON. Non-stack-scoped creates
|
||||||
|
// (no stack_name in body) pass through without evidence.
|
||||||
|
if (isAlertCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
||||||
|
const globalGrantsEdit =
|
||||||
|
req.user?.role != null
|
||||||
|
&& (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false);
|
||||||
|
if (!globalGrantsEdit) {
|
||||||
|
const stackName = req.rawBody ? parseBodyStackName(req.rawBody) : null;
|
||||||
|
if (stackName === undefined) {
|
||||||
|
// Body is non-empty but not valid JSON; client error, not auth.
|
||||||
|
console.error('[remoteNodeProxy] alert body is not valid JSON');
|
||||||
|
res.status(400).json({ error: 'Request body is not valid JSON' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stackName) {
|
||||||
|
const evidenceSupported = await remoteAdvertisesCapability(
|
||||||
|
req.nodeId,
|
||||||
|
SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY,
|
||||||
|
);
|
||||||
|
if (!evidenceSupported) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!checkPermission(req, 'stack:edit', 'stack', stackName)) {
|
||||||
|
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-heal POST scoped-evidence gate: same pattern as alerts but
|
||||||
|
// auto-heal has no pre-existing body buffering, so this gate handles
|
||||||
|
// its own encoding rejection and buffering.
|
||||||
|
if (isAutoHealCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
||||||
|
const globalGrantsEdit =
|
||||||
|
req.user?.role != null
|
||||||
|
&& (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false);
|
||||||
|
if (!globalGrantsEdit) {
|
||||||
|
if (hasNonIdentityContentEncoding(req)) {
|
||||||
|
await drainRequestBody(req);
|
||||||
|
console.error('[remoteNodeProxy] auto-heal body rejected: compressed encoding');
|
||||||
|
res.status(415).json({
|
||||||
|
error: 'Compressed request bodies are not supported for remote auto-heal creates',
|
||||||
|
code: 'encoding_unsupported',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
req.rawBody = await bufferRequestBody(req, AUTO_HEAL_PROXY_BODY_LIMIT);
|
||||||
|
} catch (err) {
|
||||||
|
const status = Number((err as { status?: number }).status);
|
||||||
|
if (status === 413) {
|
||||||
|
console.error('[remoteNodeProxy] auto-heal body rejected as too large:', err);
|
||||||
|
res.status(413).json({ error: 'Auto-heal payload too large', code: 'entity_too_large' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === 400) {
|
||||||
|
console.error('[remoteNodeProxy] auto-heal body incomplete:', err);
|
||||||
|
res.status(400).json({ error: 'Incomplete request body' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const stackName = parseBodyStackName(req.rawBody);
|
||||||
|
if (stackName === undefined) {
|
||||||
|
console.error('[remoteNodeProxy] auto-heal body is not valid JSON');
|
||||||
|
res.status(400).json({ error: 'Request body is not valid JSON' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stackName) {
|
||||||
|
const evidenceSupported = await remoteAdvertisesCapability(
|
||||||
|
req.nodeId,
|
||||||
|
SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY,
|
||||||
|
);
|
||||||
|
if (!evidenceSupported) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!checkPermission(req, 'stack:edit', 'stack', stackName)) {
|
||||||
|
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node-wide image refresh elevation gate: when a non-admin, non-node-admin
|
||||||
|
// user triggers a manual refresh on a remote node, check the hub-side
|
||||||
|
// scoped node:manage grant and elevate PROXY_ROLE_HEADER so the remote
|
||||||
|
// sees the user as node-admin for this hop (matching the pattern used
|
||||||
|
// for scoped Settings writes).
|
||||||
|
if (isImageRefreshNodeWide(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
||||||
|
if (!checkNodeManageOnHub(req)) {
|
||||||
|
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.proxyElevatedRole = 'node-admin';
|
||||||
|
}
|
||||||
|
|
||||||
req.proxyTarget = target;
|
req.proxyTarget = target;
|
||||||
beginProxyTiming(req, res);
|
beginProxyTiming(req, res);
|
||||||
proxy(req, res, next);
|
proxy(req, res, next);
|
||||||
@@ -452,6 +565,48 @@ function isAlertCreateRoute(req: Request): boolean {
|
|||||||
/** Same default as express.json(); remote alert creates must not exceed it. */
|
/** Same default as express.json(); remote alert creates must not exceed it. */
|
||||||
const ALERT_PROXY_BODY_LIMIT = 100 * 1024;
|
const ALERT_PROXY_BODY_LIMIT = 100 * 1024;
|
||||||
|
|
||||||
|
/** Same limit for auto-heal policy creates. */
|
||||||
|
const AUTO_HEAL_PROXY_BODY_LIMIT = 100 * 1024;
|
||||||
|
|
||||||
|
/** POST /auto-heal/policies (path is post-/api strip). */
|
||||||
|
function isAutoHealCreateRoute(req: Request): boolean {
|
||||||
|
return req.method === 'POST' && /^\/auto-heal\/policies\/?$/.test(req.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /image-updates/refresh with no stack-name segment (node-wide, not per-stack). */
|
||||||
|
function isImageRefreshNodeWide(req: Request): boolean {
|
||||||
|
return req.method === 'POST' && /^\/image-updates\/refresh\/?$/.test(req.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hub-side node:manage resolve against the active node so scoped Node Admin
|
||||||
|
* grants on the target remote node are detected before the hop.
|
||||||
|
*/
|
||||||
|
function checkNodeManageOnHub(req: Request): boolean {
|
||||||
|
if (typeof req.nodeId === 'number') {
|
||||||
|
return checkPermission(req, 'node:manage', 'node', String(req.nodeId));
|
||||||
|
}
|
||||||
|
return checkPermission(req, 'node:manage');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the stack_name from a buffered JSON POST body.
|
||||||
|
* Returns a valid stack name, `null` when the field is absent or
|
||||||
|
* invalid (pass-through, remote enforces), or `undefined` when the
|
||||||
|
* body is not valid JSON (fail-closed, callers must 403).
|
||||||
|
*/
|
||||||
|
function parseBodyStackName(rawBody: Buffer): string | null | undefined {
|
||||||
|
if (rawBody.length === 0) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawBody.toString('utf-8')) as { stack_name?: unknown };
|
||||||
|
const raw = typeof parsed.stack_name === 'string' ? parsed.stack_name.trim() : '';
|
||||||
|
if (!raw || !isValidStackName(raw)) return null;
|
||||||
|
return raw;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Max time to wait for leftover body bytes after a size/encoding reject. */
|
/** Max time to wait for leftover body bytes after a size/encoding reject. */
|
||||||
const DRAIN_TIMEOUT_MS = 5_000;
|
const DRAIN_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ declare global {
|
|||||||
* re-classifying req.path there would miss DELETE cleanup.
|
* re-classifying req.path there would miss DELETE cleanup.
|
||||||
*/
|
*/
|
||||||
proxyNamedStackRoute?: { stackName: string; action: PermissionAction };
|
proxyNamedStackRoute?: { stackName: string; action: PermissionAction };
|
||||||
|
/**
|
||||||
|
* Hub-side role override for the outbound proxy hop. When set, the
|
||||||
|
* PROXY_ROLE_HEADER is elevated to node-admin instead of the caller's
|
||||||
|
* global role, so scoped node-level grants can be forwarded.
|
||||||
|
* Request-scoped; consumed only by remoteNodeProxy.ts.
|
||||||
|
*/
|
||||||
|
proxyElevatedRole?: 'node-admin';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -597,6 +597,7 @@
|
|||||||
"integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==",
|
"integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"env-paths": "^2.2.1",
|
"env-paths": "^2.2.1",
|
||||||
"import-fresh": "^3.3.0",
|
"import-fresh": "^3.3.0",
|
||||||
@@ -1092,8 +1093,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "9.0.2",
|
"version": "9.0.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user