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
@@ -93,7 +93,7 @@ export function MobileStackDetail(props: EditorViewProps) {
const safeContainers = containers || [];
const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1;
const isRunning = safeContainers.some(c => c.State === 'running');
const canEditStack = can('stack:edit', 'stack', stackName);
const canEditStack = can('stack:edit', 'stack', stackName, activeNode?.id);
// The writable editor layer renders only for an editor; a stale editingCompose
// while the user lacks stack:edit falls back to the read-only Compose segment.
@@ -182,7 +182,7 @@ export function MobileStackDetail(props: EditorViewProps) {
result={recoveryResult}
activeNode={activeNode}
backupInfo={backupInfo}
canDeploy={can('stack:deploy', 'stack', stackName)}
canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)}
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
onRestart={restartStack}
onRollback={rollbackStack}