feat(rbac): make Settings authorization permission-aware (#1738)

* feat(rbac): make Settings authorization permission-aware

Align Settings visibility and mutations with the existing permission matrix so Node Admin can edit node-scoped operational settings while system and credential surfaces stay Admin-protected.

* fix(rbac): tighten settings permission buckets and tests

Collapse settings key permission maps into one source of truth, and cover mixed PATCH atomicity plus image-update enabled writes.

* fix(rbac): tighten Settings scoped grants and CI assertions

Empty settings PATCH fails closed, node:manage is scoped to the active
node, system-only Settings stay hidden without system:settings, and
Check updates / webhooks mutate gates follow the permission matrix.

* fix(rbac): defer Settings section fallback until authz is ready

Keep deep links to permission-gated sections (e.g. license) intact while
can() is still fail-closed during permission metadata load.

* docs(settings): clarify Notifications channels vs routing authz

Channels use node:manage via /api/agents; routing and mute stay Admin-only.
This commit is contained in:
Anso
2026-07-30 10:25:13 -04:00
committed by GitHub
parent c704cb54d2
commit a3026f47a8
46 changed files with 812 additions and 180 deletions
@@ -22,6 +22,7 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
openAlertSheet: vi.fn(),
openAutoHeal: vi.fn(),
checkUpdates: vi.fn(),
canCheckUpdates: true,
openStackApp: vi.fn(),
deploy: vi.fn(),
stop: vi.fn(),
@@ -84,6 +85,18 @@ describe('useStackMenuItems', () => {
expect(inspect.items.find(i => i.id === 'open-app')).toBeUndefined();
});
it('shows Check updates when canCheckUpdates', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canCheckUpdates: true })));
const inspect = result.current.find(g => g.id === 'inspect')!;
expect(inspect.items.find(i => i.id === 'check-updates')).toBeDefined();
});
it('hides Check updates when !canCheckUpdates', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canCheckUpdates: false })));
const inspect = result.current.find(g => g.id === 'inspect')!;
expect(inspect.items.find(i => i.id === 'check-updates')).toBeUndefined();
});
it('toggles Pin / Unpin label based on isPinned', () => {
const pinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: true })));
const unpinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: false })));
@@ -60,6 +60,7 @@ export function useStackKeyboardShortcuts(
e.preventDefault();
ctx.openAutoHeal();
} else if (key === 'u') {
if (!ctx.canCheckUpdates) return;
e.preventDefault();
ctx.checkUpdates();
} else if (key === 'p') {
+5 -3
View File
@@ -21,7 +21,7 @@ import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sid
export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] {
const {
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
openAlertSheet, openAutoHeal, canCheckUpdates, checkUpdates, openStackApp,
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel,
menuVisibility, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
@@ -35,7 +35,9 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
{ id: 'alerts', label: 'Alerts', icon: BellRing, shortcut: 'A', onSelect: openAlertSheet },
{ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal },
];
inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
if (canCheckUpdates) {
inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
}
if (stackStatus === 'running' && canOpenApp) {
inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp });
}
@@ -108,7 +110,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
}, [
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate, showTakeDown,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
openAlertSheet, openAutoHeal, canCheckUpdates, checkUpdates, openStackApp,
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
]);