mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +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,81 @@
|
||||
import axios from 'axios';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
const REMOTE_STACKS_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type AssertStackExistsResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Verify that `stackName` exists on `nodeId` before inserting a stack-scoped
|
||||
* role assignment. Local nodes use FileSystemService; remotes use a machine
|
||||
* GET /api/stacks via NodeRegistry.getProxyTarget.
|
||||
*/
|
||||
export async function assertStackExistsOnNode(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
): Promise<AssertStackExistsResult> {
|
||||
if (!isValidStackName(stackName)) {
|
||||
return { ok: false, error: 'Invalid stack name' };
|
||||
}
|
||||
|
||||
const node = DatabaseService.getInstance().getNode(nodeId);
|
||||
if (!node) {
|
||||
return { ok: false, error: 'Node not found' };
|
||||
}
|
||||
|
||||
if (node.type === 'local') {
|
||||
try {
|
||||
const stacks = await FileSystemService.getInstance(nodeId).getStacks();
|
||||
if (!stacks.includes(stackName)) {
|
||||
return { ok: false, error: 'Stack not found on node' };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error('[assertStackExistsOnNode] Local stack list failed:', getErrorMessage(err, 'unknown'));
|
||||
return { ok: false, error: 'Failed to verify stack on node' };
|
||||
}
|
||||
}
|
||||
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(nodeId);
|
||||
if (!target) {
|
||||
return { ok: false, error: 'Remote node is unreachable' };
|
||||
}
|
||||
|
||||
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
||||
const headers: Record<string, string> = {
|
||||
[PROXY_TIER_HEADER]: LicenseService.getInstance().getProxyHeaders().tier,
|
||||
};
|
||||
if (target.apiToken) {
|
||||
headers.Authorization = `Bearer ${target.apiToken}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get(`${baseUrl}/api/stacks`, {
|
||||
headers,
|
||||
timeout: REMOTE_STACKS_TIMEOUT_MS,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
return { ok: false, error: 'Failed to verify stack on remote node' };
|
||||
}
|
||||
if (!Array.isArray(res.data)) {
|
||||
return { ok: false, error: 'Failed to verify stack on remote node' };
|
||||
}
|
||||
const names = res.data.filter((n): n is string => typeof n === 'string');
|
||||
if (!names.includes(stackName)) {
|
||||
return { ok: false, error: 'Stack not found on node' };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error('[assertStackExistsOnNode] Remote stack list failed:', getErrorMessage(err, 'unknown'));
|
||||
return { ok: false, error: 'Failed to verify stack on remote node' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { isPermissionAction, type PermissionAction } from '../middleware/permissions';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
|
||||
export type StackRouteClassify =
|
||||
| { kind: 'named-stack'; stackName: string; action: PermissionAction }
|
||||
| { kind: 'static' }
|
||||
| { kind: 'unknown-named' };
|
||||
|
||||
/** Static collection / create paths under /stacks (no stack-scoped resource). */
|
||||
const STATIC_STACK_PATHS = new Set([
|
||||
'/stacks',
|
||||
'/stacks/',
|
||||
'/stacks/statuses',
|
||||
'/stacks/discovery',
|
||||
'/stacks/import/scan',
|
||||
'/stacks/import/move',
|
||||
'/stacks/bulk',
|
||||
'/stacks/from-git',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Exact relative suffixes under `/stacks/:name` mapped to the primary hub
|
||||
* pre-check action. Service-name paths are matched separately via regex.
|
||||
*/
|
||||
type SuffixRule = { method: string; suffix: string; action: PermissionAction };
|
||||
|
||||
const EXACT_SUFFIX_RULES: readonly SuffixRule[] = [
|
||||
// Read
|
||||
{ method: 'GET', suffix: '', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/envs', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/env', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/project-env-files', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/project-env-files/candidates', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/dossier', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/containers', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/services', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/drift', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/preflight', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/missing-external-networks', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/preflight/acknowledgements', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/networking', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/storage', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/effective-anatomy', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/effective-services', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/env-inventory', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/label-inventory', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/exposure', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/update-readiness', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/rollback-readiness', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/health-gate', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/update-preview', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/backup', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/scan-status', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/file-roots', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/files', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/files/content', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/files/download', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/files/bulk-download', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/files/permissions', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/activity', action: 'stack:read' },
|
||||
{ method: 'GET', suffix: '/git-source', action: 'stack:read' },
|
||||
|
||||
// Edit
|
||||
{ method: 'PUT', suffix: '', action: 'stack:edit' },
|
||||
{ method: 'PUT', suffix: '/env', action: 'stack:edit' },
|
||||
{ method: 'PUT', suffix: '/project-env-files', action: 'stack:edit' },
|
||||
{ method: 'PUT', suffix: '/dossier', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/drift/recheck', action: 'stack:read' },
|
||||
{ method: 'POST', suffix: '/preflight/run', action: 'stack:read' },
|
||||
{ method: 'POST', suffix: '/preflight/acknowledgements', action: 'stack:edit' },
|
||||
{ method: 'PUT', suffix: '/exposure', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/files/upload', action: 'stack:edit' },
|
||||
{ method: 'PUT', suffix: '/files/content', action: 'stack:edit' },
|
||||
{ method: 'DELETE', suffix: '/files', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/files/folder', action: 'stack:edit' },
|
||||
{ method: 'PATCH', suffix: '/files/rename', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/files/copy', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/files/bulk-delete', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/files/bulk-move', action: 'stack:edit' },
|
||||
{ method: 'PUT', suffix: '/files/permissions', action: 'stack:edit' },
|
||||
{ method: 'PUT', suffix: '/labels', action: 'stack:edit' },
|
||||
{ method: 'PUT', suffix: '/git-source', action: 'stack:edit' },
|
||||
{ method: 'DELETE', suffix: '/git-source', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/git-source/pull', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/git-source/apply', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/git-source/webhook-pull', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/git-source/dismiss-pending', action: 'stack:edit' },
|
||||
{ method: 'POST', suffix: '/git-source/browse', action: 'stack:edit' },
|
||||
|
||||
// Deploy
|
||||
{ method: 'POST', suffix: '/deploy', action: 'stack:deploy' },
|
||||
{ method: 'POST', suffix: '/down', action: 'stack:deploy' },
|
||||
{ method: 'POST', suffix: '/restart', action: 'stack:deploy' },
|
||||
{ method: 'POST', suffix: '/stop', action: 'stack:deploy' },
|
||||
{ method: 'POST', suffix: '/start', action: 'stack:deploy' },
|
||||
{ method: 'POST', suffix: '/update-preview', action: 'stack:deploy' },
|
||||
{ method: 'POST', suffix: '/update', action: 'stack:deploy' },
|
||||
{ method: 'POST', suffix: '/rollback', action: 'stack:deploy' },
|
||||
{ method: 'POST', suffix: '/backup', action: 'stack:deploy' },
|
||||
|
||||
// Delete
|
||||
{ method: 'DELETE', suffix: '', action: 'stack:delete' },
|
||||
];
|
||||
|
||||
const EXACT_SUFFIX_INDEX = new Map<string, PermissionAction>(
|
||||
EXACT_SUFFIX_RULES.map((r) => [`${r.method} ${r.suffix}`, r.action]),
|
||||
);
|
||||
|
||||
/** `/services/:serviceName/{restart|stop|start|update|restore|recovery}` */
|
||||
const SERVICE_SUFFIX_RE =
|
||||
/^\/services\/[^/]+\/(restart|stop|start|update|restore|recovery)$/;
|
||||
|
||||
/** `/preflight/acknowledgements/:id` */
|
||||
const PREFLIGHT_ACK_DELETE_RE = /^\/preflight\/acknowledgements\/[^/]+$/;
|
||||
|
||||
function normalizePath(pathAfterApiStrip: string): string {
|
||||
const withoutQuery = pathAfterApiStrip.split('?')[0] ?? pathAfterApiStrip;
|
||||
if (withoutQuery.length > 1 && withoutQuery.endsWith('/')) {
|
||||
return withoutQuery.slice(0, -1);
|
||||
}
|
||||
return withoutQuery;
|
||||
}
|
||||
|
||||
function decodeStackSegment(raw: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isValidStackName(decoded)) return null;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a post-/api path for hub stack RBAC gating and evidence.
|
||||
* Paths outside `/stacks` (and static `/stacks` collection routes) are
|
||||
* `static`. Known named-stack families return the primary pre-check action.
|
||||
* An unrecognized `/stacks/<name>/...` path fails closed as `unknown-named`.
|
||||
*/
|
||||
export function classifyStackApiPath(method: string, pathAfterApiStrip: string): StackRouteClassify {
|
||||
const methodUpper = method.toUpperCase();
|
||||
const path = normalizePath(pathAfterApiStrip);
|
||||
|
||||
if (!path.startsWith('/stacks')) {
|
||||
return { kind: 'static' };
|
||||
}
|
||||
|
||||
if (STATIC_STACK_PATHS.has(path) || (methodUpper === 'POST' && path === '/stacks')) {
|
||||
return { kind: 'static' };
|
||||
}
|
||||
|
||||
// Reserved first segments that look like names but are collection routes.
|
||||
if (
|
||||
path === '/stacks/statuses'
|
||||
|| path === '/stacks/discovery'
|
||||
|| path.startsWith('/stacks/import/')
|
||||
|| path === '/stacks/bulk'
|
||||
|| path === '/stacks/from-git'
|
||||
) {
|
||||
return { kind: 'static' };
|
||||
}
|
||||
|
||||
const match = /^\/stacks\/([^/]+)(.*)$/.exec(path);
|
||||
if (!match) {
|
||||
return { kind: 'static' };
|
||||
}
|
||||
|
||||
const stackName = decodeStackSegment(match[1]);
|
||||
if (!stackName) {
|
||||
return { kind: 'unknown-named' };
|
||||
}
|
||||
|
||||
const suffix = match[2] ?? '';
|
||||
|
||||
const exact = EXACT_SUFFIX_INDEX.get(`${methodUpper} ${suffix}`);
|
||||
if (exact) {
|
||||
return { kind: 'named-stack', stackName, action: exact };
|
||||
}
|
||||
|
||||
if (methodUpper === 'POST' && SERVICE_SUFFIX_RE.test(suffix)) {
|
||||
const op = SERVICE_SUFFIX_RE.exec(suffix)?.[1];
|
||||
if (op === 'recovery') {
|
||||
// recovery is GET-only in stacks.ts; POST recovery is unknown
|
||||
return { kind: 'unknown-named' };
|
||||
}
|
||||
return { kind: 'named-stack', stackName, action: 'stack:deploy' };
|
||||
}
|
||||
|
||||
if (methodUpper === 'GET' && /^\/services\/[^/]+\/recovery$/.test(suffix)) {
|
||||
return { kind: 'named-stack', stackName, action: 'stack:deploy' };
|
||||
}
|
||||
|
||||
if (methodUpper === 'DELETE' && PREFLIGHT_ACK_DELETE_RE.test(suffix)) {
|
||||
return { kind: 'named-stack', stackName, action: 'stack:edit' };
|
||||
}
|
||||
|
||||
return { kind: 'unknown-named' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the comma-separated scoped-actions header. Returns null when empty
|
||||
* or when any token is not a known PermissionAction.
|
||||
*/
|
||||
export function parseScopedStackActionsHeader(value: string): PermissionAction[] | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parts = trimmed.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
|
||||
if (parts.length === 0) return null;
|
||||
const actions: PermissionAction[] = [];
|
||||
const seen = new Set<PermissionAction>();
|
||||
for (const part of parts) {
|
||||
if (!isPermissionAction(part)) return null;
|
||||
if (seen.has(part)) continue;
|
||||
seen.add(part);
|
||||
actions.push(part);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** Serialize PermissionAction values for the scoped-actions proxy header. */
|
||||
export function formatScopedStackActionsHeader(actions: Iterable<PermissionAction>): string {
|
||||
return [...new Set(actions)].join(',');
|
||||
}
|
||||
Reference in New Issue
Block a user