mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user