mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +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,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1097.0",
|
||||
"axios": "^1.15.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"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.
|
||||
* Paths outside `/stacks` (and static `/stacks` collection routes) are
|
||||
* `static`. Known named-stack families return the primary pre-check action.
|
||||
* An unrecognized `/stacks/<name>/...` path fails closed as `unknown-named`.
|
||||
* Paths outside `/stacks` and `/image-updates/refresh/` (and static
|
||||
* `/stacks` collection routes) are `static`. Known named-stack families
|
||||
* return the primary pre-check action. An unrecognized
|
||||
* `/stacks/<name>/...` path fails closed as `unknown-named`.
|
||||
*/
|
||||
export function classifyStackApiPath(method: string, pathAfterApiStrip: string): StackRouteClassify {
|
||||
const methodUpper = method.toUpperCase();
|
||||
const path = normalizePath(pathAfterApiStrip);
|
||||
|
||||
if (!path.startsWith('/stacks')) {
|
||||
if (!path.startsWith('/stacks') && !path.startsWith('/image-updates/refresh/')) {
|
||||
return { kind: 'static' };
|
||||
}
|
||||
|
||||
@@ -161,6 +162,20 @@ export function classifyStackApiPath(method: string, pathAfterApiStrip: string):
|
||||
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);
|
||||
if (!match) {
|
||||
return { kind: 'static' };
|
||||
|
||||
@@ -111,7 +111,11 @@ export function checkPermission(
|
||||
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 nodeId = resourceType === 'stack'
|
||||
@@ -123,7 +127,6 @@ export function checkPermission(
|
||||
resourceId,
|
||||
nodeId,
|
||||
);
|
||||
if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId);
|
||||
for (const assignment of assignments) {
|
||||
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { redactSensitiveText } from '../utils/safeLog';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming';
|
||||
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,
|
||||
// so req.user is always resolved here).
|
||||
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);
|
||||
}
|
||||
// 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;
|
||||
beginProxyTiming(req, res);
|
||||
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. */
|
||||
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. */
|
||||
const DRAIN_TIMEOUT_MS = 5_000;
|
||||
|
||||
|
||||
@@ -44,6 +44,13 @@ declare global {
|
||||
* re-classifying req.path there would miss DELETE cleanup.
|
||||
*/
|
||||
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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"env-paths": "^2.2.1",
|
||||
"import-fresh": "^3.3.0",
|
||||
@@ -1092,8 +1093,7 @@
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
|
||||
Reference in New Issue
Block a user