mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user