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:
Anso
2026-08-01 23:37:35 -04:00
committed by GitHub
parent 15801318d6
commit 2e2b095b00
7 changed files with 575 additions and 10 deletions
-1
View File
@@ -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');
});
});
+19 -4
View File
@@ -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' };
+5 -2
View File
@@ -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;
}
+156 -1
View File
@@ -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;
+7
View File
@@ -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';
}
}
}
+2 -2
View File
@@ -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",