From 2e2b095b0002ea09d5ce9e4c5209735ceb4d9a51 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 1 Aug 2026 23:37:35 -0400 Subject: [PATCH] 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 --- backend/package-lock.json | 1 - ...xy-scoped-alerts-autoheal-evidence.test.ts | 386 ++++++++++++++++++ backend/src/helpers/stackRouteAuth.ts | 23 +- backend/src/middleware/permissions.ts | 7 +- backend/src/proxy/remoteNodeProxy.ts | 157 ++++++- backend/src/types/express.ts | 7 + package-lock.json | 4 +- 7 files changed, 575 insertions(+), 10 deletions(-) create mode 100644 backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index d77b2932..30cc4c32 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts b/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts new file mode 100644 index 00000000..717a41c4 --- /dev/null +++ b/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts @@ -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 { + await new Promise((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((resolve) => grantedServer.close(() => resolve())); + await new Promise((resolve) => ungrantedServer.close(() => resolve())); + await new Promise((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'); + }); +}); diff --git a/backend/src/helpers/stackRouteAuth.ts b/backend/src/helpers/stackRouteAuth.ts index 76667df8..8d567a59 100644 --- a/backend/src/helpers/stackRouteAuth.ts +++ b/backend/src/helpers/stackRouteAuth.ts @@ -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//...` 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//...` 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//... 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' }; diff --git a/backend/src/middleware/permissions.ts b/backend/src/middleware/permissions.ts index 115e75a5..a72a41f0 100644 --- a/backend/src/middleware/permissions.ts +++ b/backend/src/middleware/permissions.ts @@ -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; } diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index 06f27a7b..37a8545e 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -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; diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts index a21b0c5c..1b04e5e4 100644 --- a/backend/src/types/express.ts +++ b/backend/src/types/express.ts @@ -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'; } } } diff --git a/package-lock.json b/package-lock.json index a2b08d01..670b47a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",