mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +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:
@@ -311,7 +311,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
hasUnsavedChanges,
|
||||
} = props;
|
||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||
const canEditCompose = can('stack:edit', 'stack', stackName);
|
||||
const canEditCompose = can('stack:edit', 'stack', stackName, activeNode?.id);
|
||||
|
||||
// Dispose the underlying Monaco model when EditorView unmounts. The
|
||||
// @monaco-editor/react wrapper reuses a single model per editor instance
|
||||
@@ -358,7 +358,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
const safeContent = content || '';
|
||||
const safeEnvContent = envContent || '';
|
||||
const isRunning = safeContainers.some(c => c.State === 'running');
|
||||
const canRead = can('stack:read', 'stack', stackName);
|
||||
const canRead = can('stack:read', 'stack', stackName, activeNode?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'files' && !canRead) {
|
||||
@@ -469,7 +469,7 @@ export function EditorView(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}
|
||||
@@ -663,7 +663,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{activeTab === 'files' && canRead ? (
|
||||
<StackFileExplorer
|
||||
stackName={stackName}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
canEdit={can('stack:edit', 'stack', stackName, activeNode?.id)}
|
||||
isDarkMode={isDarkMode}
|
||||
onNavigateToCompose={() => setActiveTab('compose')}
|
||||
onNavigateToEnv={() => setActiveTab('env')}
|
||||
@@ -732,7 +732,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
onOpenGitSource={() => setGitSourceOpen(true)}
|
||||
onApplyUpdate={() => { void updateStack(); }}
|
||||
applying={loadingAction === 'update'}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
canEdit={can('stack:edit', 'stack', stackName, activeNode?.id)}
|
||||
notifications={notifications}
|
||||
requestedTab={props.requestedAnatomyTab}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -25,9 +25,10 @@ interface ShellOverlaysProps {
|
||||
stackActions: StackActionsHook;
|
||||
isDarkMode: boolean;
|
||||
isAdmin: boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
|
||||
selectedFile: string | null;
|
||||
stackName: string;
|
||||
activeNodeId: number | null;
|
||||
gitSourceOpen: boolean;
|
||||
setGitSourceOpen: (open: boolean) => void;
|
||||
canSelfUpdate: boolean;
|
||||
@@ -45,6 +46,7 @@ export function ShellOverlays({
|
||||
can,
|
||||
selectedFile,
|
||||
stackName,
|
||||
activeNodeId,
|
||||
gitSourceOpen,
|
||||
setGitSourceOpen,
|
||||
canSelfUpdate,
|
||||
@@ -210,7 +212,7 @@ export function ShellOverlays({
|
||||
open={gitSourceOpen}
|
||||
onOpenChange={setGitSourceOpen}
|
||||
stackName={stackName}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
canEdit={can('stack:edit', 'stack', stackName, activeNodeId)}
|
||||
isDarkMode={isDarkMode}
|
||||
onSourceChanged={stackActions.refreshGitSourcePending}
|
||||
/>
|
||||
|
||||
@@ -203,8 +203,8 @@ export function StackIdentityHeader({
|
||||
backend permissions so a delete-only or deploy-only persona sees
|
||||
exactly what they can act on. */}
|
||||
{(() => {
|
||||
const canDeploy = can('stack:deploy', 'stack', stackName);
|
||||
const canDelete = can('stack:delete', 'stack', stackName);
|
||||
const canDeploy = can('stack:deploy', 'stack', stackName, activeNode?.id);
|
||||
const canDelete = can('stack:delete', 'stack', stackName, activeNode?.id);
|
||||
const canRollback = canDeploy && backupInfo.exists;
|
||||
const canScan = trivy.available && isAdmin;
|
||||
const canMute = stackMuteActions?.canMute ?? false;
|
||||
|
||||
@@ -31,7 +31,7 @@ interface UseSidebarContextMenuOptions {
|
||||
stackActions: StackActionsHook;
|
||||
activeNode: Node | null | undefined;
|
||||
isAdmin: boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
|
||||
}
|
||||
|
||||
export function useSidebarContextMenu({
|
||||
@@ -61,9 +61,9 @@ export function useSidebarContextMenu({
|
||||
canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null,
|
||||
isBusy: stackListState.isStackBusy(file),
|
||||
isAdmin,
|
||||
canDelete: can('stack:delete', 'stack', sName),
|
||||
canDeploy: can('stack:deploy', 'stack', sName),
|
||||
canEditLabels: can('stack:edit', 'stack', sName),
|
||||
canDelete: can('stack:delete', 'stack', sName, nodeId),
|
||||
canDeploy: can('stack:deploy', 'stack', sName, nodeId),
|
||||
canEditLabels: can('stack:edit', 'stack', sName, nodeId),
|
||||
// POST /api/labels (the inline "New label" entry) is guarded by the
|
||||
// unscoped requirePermission('stack:edit'); a user with only per-stack
|
||||
// scoped edit can toggle existing labels but cannot create new ones.
|
||||
|
||||
Reference in New Issue
Block a user