fix: gate cross-node HTTP and stop-by-label on remote RBAC capability (#1509)

An older remote node ignores the forwarded actor-role header (running proxied
requests as admin) and ignores the stop-by-label stack allowlist (stopping
every label-matched stack). The control could neither detect nor prevent this
on a mixed-version fleet.

Instances now advertise a cross-node-rbac capability, and the control refuses
to act when a remote lacks it:

- HTTP proxy: a non-admin user's request is not forwarded to a remote that does
  not advertise the capability (fails closed when it cannot be determined).
  Admins are unaffected.
- Stop-by-label: a real stop bound to a confirmed stack set is not sent to a
  remote lacking the capability; the node is reported as needing an upgrade. As
  defense in depth, a node whose results name stacks outside the confirmed set
  is failed rather than rendered as a clean stop.

Separately, the stop's lock-contention path now reports every confirmed stack
as a contention failure (including one that lost its label), so a confirmed
stack is never silently dropped and the result is never empty.
This commit is contained in:
Anso
2026-06-28 18:28:47 -04:00
committed by GitHub
parent ef164f0e5b
commit 997a6bb79a
11 changed files with 365 additions and 1 deletions
+25
View File
@@ -4,6 +4,7 @@ import { NodeRegistry } from '../services/NodeRegistry';
import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
import { remoteSupportsCrossNodeRbac } from '../helpers/remoteCapabilities';
import { getErrorMessage } from '../utils/errors';
import { DatabaseService } from '../services/DatabaseService';
import { redactSensitiveText } from '../utils/safeLog';
@@ -145,6 +146,30 @@ export function createRemoteProxyMiddleware(): RequestHandler {
return;
}
// Mixed-version RBAC gate. The forwarded actor role is enforced only by a
// remote that advertises cross-node-rbac; an older remote ignores the
// header and runs the proxied request as admin. So a non-admin must not be
// forwarded to a remote that does not advertise the capability. Admins are
// unaffected (they are admin on the remote regardless), and the check is
// skipped for them so it never adds latency to the admin path. Fails closed
// when the capability cannot be determined. Using `?.` so an unresolved user
// (not reachable past authGate, but defensive) is gated, never waved through.
if (req.user?.role !== 'admin') {
remoteSupportsCrossNodeRbac(req.nodeId)
.then((supported) => {
if (!supported) {
res.status(403).json({
error: `Remote node "${node.name}" is running a version that does not enforce per-user permissions. Upgrade it before non-admin users can act on it.`,
});
return;
}
req.proxyTarget = target;
proxy(req, res, next);
})
.catch(next);
return;
}
req.proxyTarget = target;
proxy(req, res, next);
};