feat(rbac): make stack-scoped grants node-specific (#1727)

* feat(rbac): make stack-scoped grants node-specific

Qualify stack role assignments as (nodeId, stackName), migrate legacy rows to the default node, and forward bound multi-action evidence on Proxy/Pilot hops so scoped users keep least-privilege remote access without shipping the full grant table.

* fix: mirror scoped-stack-auth-evidence capability to frontend, sanitize node id in role assignment log

Backend added the scoped-stack-auth-evidence capability without the
matching frontend entry, failing the capability parity test. The role
assignment log also interpolated the node id without sanitizeForLog,
unlike the rest of the line.

* fix(rbac): honor node-wide scopes and fix proxied DELETE cleanup

Node-scoped grants now authorize that role's stack actions on the same node in the backend resolver, frontend can(), and remote evidence. Proxied DELETE cleanup uses the gate-stashed route because pathRewrite mutates req.path before proxyRes. Add proxy integration coverage and drop the stale scoped-permissions screenshot.

* fix(rbac): preserve node-qualified grants during repair
This commit is contained in:
Anso
2026-07-29 09:42:14 -04:00
committed by GitHub
parent d698eb46f9
commit 9922d8e765
37 changed files with 2487 additions and 143 deletions
+24 -1
View File
@@ -8,10 +8,20 @@ import {
type ApiTokenScope,
} from '../services/DatabaseService';
import { getErrorMessage } from '../utils/errors';
import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER, isDeploySourceHeader } from '../services/license-headers';
import {
PROXY_TIER_HEADER,
PROXY_ROLE_HEADER,
PROXY_DEPLOY_SOURCE_HEADER,
PROXY_DEPLOY_ACTOR_HEADER,
PROXY_SCOPED_STACK_NAME_HEADER,
PROXY_SCOPED_STACK_ACTIONS_HEADER,
isDeploySourceHeader,
} from '../services/license-headers';
import type { DeployInvocationContext } from '../services/network/missingExternalNetworksError';
import { isLicenseTier, normalizeTier } from '../services/license-normalize';
import { isDebugEnabled } from '../utils/debug';
import { parseScopedStackActionsHeader } from '../helpers/stackRouteAuth';
import { isValidStackName } from '../utils/validation';
import {
COOKIE_NAME,
MFA_PENDING_COOKIE_NAME,
@@ -150,6 +160,19 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
req.deployContext = ctx;
}
// Scoped stack auth evidence: only trust on this machine-auth path.
// Malformed or incomplete pairs are treated as absent (never as auth).
const scopedNameRaw = req.headers[PROXY_SCOPED_STACK_NAME_HEADER];
const scopedActionsRaw = req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER];
const scopedName = typeof scopedNameRaw === 'string' ? scopedNameRaw.trim() : '';
const scopedActionsStr = typeof scopedActionsRaw === 'string' ? scopedActionsRaw : '';
if (scopedName && isValidStackName(scopedName) && scopedActionsStr) {
const actions = parseScopedStackActionsHeader(scopedActionsStr);
if (actions && actions.length > 0) {
req.scopedStackEvidence = { stackName: scopedName, actions: new Set(actions) };
}
}
next();
return;
}
+80 -1
View File
@@ -34,6 +34,50 @@ export const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
],
};
/** Canonical PermissionAction set (admin matrix covers every action). */
export const ALL_PERMISSION_ACTIONS: readonly PermissionAction[] = ROLE_PERMISSIONS.admin;
export function isPermissionAction(value: string): value is PermissionAction {
return (ALL_PERMISSION_ACTIONS as readonly string[]).includes(value);
}
/**
* Collect stack:* actions from role matrices. Used for remote evidence so
* node:/system: never leave the hub on a machine-auth hop.
*/
function addStackActionsFromRole(
actions: Set<PermissionAction>,
role: UserRole,
): void {
for (const action of ROLE_PERMISSIONS[role] ?? []) {
if (action.startsWith('stack:')) {
actions.add(action);
}
}
}
/**
* Union of PermissionAction values conferred by the user's exact stack
* grant for (nodeId, stackName), plus any node-scoped grant on that node
* (node-wide roles cover every stack on the node). Used when the hub
* builds bound evidence for a remote hop.
*/
export function scopedActionsForStack(
userId: number,
nodeId: number,
stackName: string,
): PermissionAction[] {
const db = DatabaseService.getInstance();
const actions = new Set<PermissionAction>();
for (const assignment of db.getRoleAssignments(userId, 'stack', stackName, nodeId)) {
addStackActionsFromRole(actions, assignment.role);
}
for (const assignment of db.getRoleAssignments(userId, 'node', String(nodeId))) {
addStackActionsFromRole(actions, assignment.role);
}
return [...actions];
}
/** Core permission resolver. Admin bypasses all checks; scoped assignments only apply on the paid tier. */
export function checkPermission(
req: Request,
@@ -51,14 +95,49 @@ export function checkPermission(
if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true;
if (!resourceType || !resourceId) return false;
// Bound machine evidence from the hub (node_proxy / pilot_tunnel only).
// Authorizes exact stack + action members of the evidenced set without a
// local role_assignments row (remote userId is 0).
const evidence = req.scopedStackEvidence;
if (
evidence
&& resourceType === 'stack'
&& resourceId === evidence.stackName
&& action.startsWith('stack:')
&& evidence.actions.has(action)
) {
return true;
}
if (effectiveTier(req) !== 'paid') return false;
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
const db = DatabaseService.getInstance();
const nodeId = resourceType === 'stack' ? req.nodeId : null;
const assignments = db.getRoleAssignments(
req.user.userId,
resourceType,
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;
}
// Node-scoped grants are node-wide: a Node Admin / Deployer / Admin on
// node N authorizes that role's stack actions for every stack on N.
if (resourceType === 'stack' && req.nodeId != null) {
const nodeAssignments = db.getRoleAssignments(
req.user.userId,
'node',
String(req.nodeId),
);
for (const assignment of nodeAssignments) {
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
}
}
return false;
}