mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +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:
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveCan, type PermissionsSnapshot } from '../resolveCan';
|
||||
|
||||
const viewerBase: PermissionsSnapshot = {
|
||||
globalRole: 'viewer',
|
||||
globalPermissions: ['stack:read', 'node:read'],
|
||||
scopedPermissions: {},
|
||||
};
|
||||
|
||||
describe('resolveCan', () => {
|
||||
it('admin bypasses all checks', () => {
|
||||
const perms: PermissionsSnapshot = {
|
||||
globalRole: 'admin',
|
||||
globalPermissions: [],
|
||||
scopedPermissions: {},
|
||||
};
|
||||
expect(resolveCan(perms, 'system:users')).toBe(true);
|
||||
expect(resolveCan(perms, 'stack:delete', 'stack', 'app', 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('grants from the global matrix without needing a resource', () => {
|
||||
expect(resolveCan(viewerBase, 'stack:read')).toBe(true);
|
||||
expect(resolveCan(viewerBase, 'stack:deploy')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats same stack name on different nodes as independent grants', () => {
|
||||
const perms: PermissionsSnapshot = {
|
||||
...viewerBase,
|
||||
scopedPermissions: {
|
||||
'stack:1:frontend': ['stack:read', 'stack:deploy'],
|
||||
'stack:2:frontend': ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'],
|
||||
},
|
||||
};
|
||||
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true);
|
||||
expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 1)).toBe(false);
|
||||
expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 2)).toBe(true);
|
||||
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed for stack lookups when nodeId is missing', () => {
|
||||
const perms: PermissionsSnapshot = {
|
||||
...viewerBase,
|
||||
scopedPermissions: {
|
||||
'stack:1:frontend': ['stack:deploy'],
|
||||
},
|
||||
};
|
||||
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend')).toBe(false);
|
||||
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', null)).toBe(false);
|
||||
expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps node scopes keyed as node:id without a nodeId argument', () => {
|
||||
const perms: PermissionsSnapshot = {
|
||||
...viewerBase,
|
||||
scopedPermissions: {
|
||||
'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'],
|
||||
},
|
||||
};
|
||||
expect(resolveCan(perms, 'node:manage', 'node', '7')).toBe(true);
|
||||
expect(resolveCan(perms, 'stack:deploy', 'node', '7')).toBe(true);
|
||||
expect(resolveCan(perms, 'node:manage', 'node', '8')).toBe(false);
|
||||
});
|
||||
|
||||
it('node-scoped grants authorize stack actions on that node only', () => {
|
||||
const perms: PermissionsSnapshot = {
|
||||
...viewerBase,
|
||||
scopedPermissions: {
|
||||
'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'],
|
||||
},
|
||||
};
|
||||
expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 7)).toBe(true);
|
||||
expect(resolveCan(perms, 'stack:deploy', 'stack', 'other', 7)).toBe(true);
|
||||
expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 8)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when permissions are null', () => {
|
||||
expect(resolveCan(null, 'stack:read')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ export const CAPABILITIES = [
|
||||
'guided-external-network-preflight',
|
||||
'service-scoped-update',
|
||||
'service-scoped-stack-alert',
|
||||
'scoped-stack-auth-evidence',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
@@ -54,3 +55,4 @@ export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes'
|
||||
export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability;
|
||||
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
|
||||
export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY = 'service-scoped-stack-alert' as const satisfies Capability;
|
||||
export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY = 'scoped-stack-auth-evidence' as const satisfies Capability;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Mirrors AuthContext PermissionAction / UserRole for the pure resolver (no circular import). */
|
||||
export type ResolveCanRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
|
||||
|
||||
export type ResolveCanAction =
|
||||
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
|
||||
| 'node:read' | 'node:manage'
|
||||
| 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks'
|
||||
| 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries';
|
||||
|
||||
export interface PermissionsSnapshot {
|
||||
globalRole: ResolveCanRole;
|
||||
globalPermissions: ResolveCanAction[];
|
||||
scopedPermissions: Record<string, ResolveCanAction[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure permission resolver for AuthContext.can and unit tests.
|
||||
* Stack scopes are keyed `stack:${nodeId}:${stackName}`; missing nodeId
|
||||
* fails closed for stack lookups after the global matrix is checked.
|
||||
* Node scopes stay `node:${id}` and also authorize that role's stack
|
||||
* actions for every stack on the node (node-wide semantics).
|
||||
*/
|
||||
export function resolveCan(
|
||||
permissions: PermissionsSnapshot | null,
|
||||
action: ResolveCanAction,
|
||||
resourceType?: string,
|
||||
resourceId?: string,
|
||||
nodeId?: number | null,
|
||||
): boolean {
|
||||
if (!permissions) return false;
|
||||
|
||||
if (permissions.globalRole === 'admin') return true;
|
||||
|
||||
if (permissions.globalPermissions.includes(action)) return true;
|
||||
|
||||
if (!resourceType || !resourceId) return false;
|
||||
|
||||
if (resourceType === 'stack') {
|
||||
if (nodeId === undefined || nodeId === null) return false;
|
||||
const stackKey = `stack:${nodeId}:${resourceId}`;
|
||||
if (permissions.scopedPermissions[stackKey]?.includes(action)) return true;
|
||||
const nodeKey = `node:${nodeId}`;
|
||||
return permissions.scopedPermissions[nodeKey]?.includes(action) ?? false;
|
||||
}
|
||||
|
||||
const key = `${resourceType}:${resourceId}`;
|
||||
return permissions.scopedPermissions[key]?.includes(action) ?? false;
|
||||
}
|
||||
Reference in New Issue
Block a user