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
@@ -20,6 +20,7 @@ import type { useViewNavigationState } from './useViewNavigationState';
import type { Node } from '@/context/NodeContext';
import { useNodes } from '@/context/NodeContext';
import type { PermissionAction } from '@/context/AuthContext';
import { canManageNode } from '@/lib/canManageNode';
type StackListState = ReturnType<typeof useStackListState>;
type NavState = ReturnType<typeof useViewNavigationState>;
@@ -74,6 +75,7 @@ export function useSidebarContextMenu({
menuVisibility: stackActions.getStackMenuVisibility(file),
openAlertSheet: () => overlayState.openAlertSheet(file),
openAutoHeal: () => overlayState.openAutoHeal(file),
canCheckUpdates: canManageNode(can, nodeId),
checkUpdates: () => stackActions.checkUpdatesForStack(),
openStackApp: () => stackActions.openStackApp(file),
deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'),
@@ -179,7 +181,7 @@ export function useSidebarContextMenu({
// deps would force a rebuild on every parent render and defeat the memo.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
stackListState.stackStatuses, stackListState.stackPorts, stackListState.stackSelfFlags, isAdmin,
stackListState.stackStatuses, stackListState.stackPorts, stackListState.stackSelfFlags, isAdmin, can,
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id,
hasCapability, navState.openMuteRulesWithPrefill,
@@ -1,7 +1,5 @@
import type { ReactNode } from 'react';
import { ChevronRight } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import {
SETTINGS_GROUPS,
@@ -13,6 +11,7 @@ import {
} from '@/components/settings';
import type { SectionId } from '@/components/settings';
import { SettingsSectionContent } from '@/components/settings/SettingsSectionContent';
import { useSettingsVisibility } from '@/components/settings/useSettingsVisibility';
import { BackChip, Kicker, Masthead } from './mobile-ui';
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
@@ -31,12 +30,9 @@ export function MobileSettings({
onSelectedSectionChange,
quickLinkCandidates,
}: MobileSettingsProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const nodeName = activeNode?.name ?? 'local';
const visibility = { isRemote, isAdmin, isPaid };
const visibility = useSettingsVisibility();
const visibleItems = SETTINGS_ITEMS.filter(
item => isItemVisible(item, visibility) && !isItemLocked(item, visibility),
@@ -6,6 +6,7 @@ import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { canManageNode } from '@/lib/canManageNode';
import { RefreshCw } from 'lucide-react';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
@@ -23,9 +24,9 @@ function SectionSkeleton() {
}
export function AppStoreSection() {
const { isAdmin } = useAuth();
const { can } = useAuth();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const readOnly = !canManageNode(can, activeNode?.id);
const [templateRegistryUrl, setTemplateRegistryUrl] = useState('');
const serverUrl = useRef('');
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
@@ -115,7 +116,7 @@ export function AppStoreSection() {
/>
</SettingsField>
<SettingsActions align="between" hint={readOnly ? 'Read-only · admin access required to edit' : (templateRegistryUrl ? 'using custom registry' : 'using default')}>
<SettingsActions align="between" hint={readOnly ? 'Read-only · permission required to edit' : (templateRegistryUrl ? 'using custom registry' : 'using default')}>
{!readOnly && (
<div className="flex items-center gap-2">
<Button
@@ -4,6 +4,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { canManageNode } from '@/lib/canManageNode';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { DEFAULT_SETTINGS } from './types';
@@ -35,9 +36,9 @@ const DEFAULT_CONTAINER_ALERTS: ContainerAlertFields = {
};
export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSectionProps) {
const { isAdmin } = useAuth();
const { can } = useAuth();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const readOnly = !canManageNode(can, activeNode?.id);
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<ContainerAlertFields>({ ...DEFAULT_CONTAINER_ALERTS });
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
@@ -121,7 +122,7 @@ export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSection
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
@@ -45,10 +45,10 @@ const DEFAULT_DATA_RETENTION: DataRetentionFields = {
};
export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProps) {
const { isAdmin } = useAuth();
const { can, permissionsReady } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const readOnly = !permissionsReady || !can('system:settings');
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<DataRetentionFields>({ ...DEFAULT_DATA_RETENTION });
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
@@ -222,7 +222,7 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
)}
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
@@ -37,9 +37,9 @@ const DEFAULT_DEVELOPER: DeveloperFields = {
};
export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const { isAdmin } = useAuth();
const { can, permissionsReady } = useAuth();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const readOnly = !permissionsReady || !can('system:settings');
const { settings, setSettings, hasChanges, reset, markSaved } = useSettingsDirty<DeveloperFields>({ ...DEFAULT_DEVELOPER });
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
@@ -130,7 +130,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
@@ -5,6 +5,7 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { canManageNode } from '@/lib/canManageNode';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
@@ -41,8 +42,8 @@ const DEFAULT_DOCKER_STORAGE: DockerStorageFields = {
export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const { can } = useAuth();
const readOnly = !canManageNode(can, activeNode?.id);
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<DockerStorageFields>({ ...DEFAULT_DOCKER_STORAGE });
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
@@ -153,7 +154,7 @@ export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProp
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
@@ -42,6 +42,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
const { isAdmin } = useAuth();
const { experimental, experimentalReady } = useExperimental();
const showMesh = experimentalReady && experimental;
// Admin role only (section is adminOnly in the registry). Do not swap to can().
const readOnly = !isAdmin;
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<FleetMeshFields>({ ...DEFAULT_FLEET_MESH });
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
@@ -5,6 +5,7 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { canManageNode } from '@/lib/canManageNode';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
@@ -44,8 +45,8 @@ const DEFAULT_HOST_ALERTS: HostAlertFields = {
export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const { can } = useAuth();
const readOnly = !canManageNode(can, activeNode?.id);
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<HostAlertFields>({ ...DEFAULT_HOST_ALERTS });
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
@@ -188,7 +189,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
@@ -17,6 +17,7 @@ import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { NumberChip } from './SystemControls';
import { classifyAppriseEndpoint, isKeyedAppriseEndpoint, isStatelessAppriseEndpoint } from '@/lib/appriseEndpoint';
import { canManageNode } from '@/lib/canManageNode';
import { parseNotificationDispatchRetries } from '@/lib/notificationDispatchRetries';
type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise';
@@ -53,9 +54,12 @@ interface NotificationsSectionProps {
}
export function NotificationsSection({ onDirtyChange }: NotificationsSectionProps) {
// This section configures outbound notification *channels* (/api/agents), which
// require node:manage. Alert routing and mute rules live in separate Settings
// sections and stay Admin-only via notifications.ts / adminOnly registry flags.
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const { can } = useAuth();
const readOnly = !canManageNode(can, activeNode?.id);
const activeNodeIdRef = useRef(activeNode?.id);
useEffect(() => { activeNodeIdRef.current = activeNode?.id; }, [activeNode?.id]);
@@ -1,9 +1,7 @@
import React from 'react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { getSettingsItem, isItemVisible, isItemLocked } from './registry';
import type { VisibilityContext } from './registry';
import { useSettingsVisibility } from './useSettingsVisibility';
import type { SectionId } from './types';
interface SectionGateProps {
@@ -18,17 +16,8 @@ interface SectionGateProps {
* guards remain the authoritative enforcement.
*/
export function SectionGate({ sectionId, children }: SectionGateProps) {
const { isAdmin, permissionsStatus } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const visibility: VisibilityContext = {
isAdmin,
isPaid,
isRemote,
};
const { permissionsStatus } = useAuth();
const visibility = useSettingsVisibility();
const item = getSettingsItem(sectionId);
@@ -12,7 +12,6 @@ import {
} from '@/components/ui/command';
import { PageMasthead, type MastheadMetadataItem } from '@/components/ui/PageMasthead';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import {
SETTINGS_ITEMS,
@@ -23,11 +22,12 @@ import {
isItemLocked,
scopeLabel,
} from './index';
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
import type { SectionId, SettingsItemMeta } from './index';
import type { MuteRuleDraft } from '@/lib/muteRules';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsSectionContent } from './SettingsSectionContent';
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
import { useSettingsVisibility } from './useSettingsVisibility';
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
interface SettingsPageProps {
@@ -55,10 +55,9 @@ function SettingsPageInner({
onOpenMuteRulesWithPrefill,
quickLinkCandidates,
}: SettingsPageProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const visibility = useSettingsVisibility();
const { permissionsStatus } = useAuth();
// Mobile master/detail: below md the nav rail and the section content cannot
// sit side by side, so the rail is a full-screen list and choosing a section
@@ -72,22 +71,21 @@ function SettingsPageInner({
// Desktop shows both panes; mobile shows exactly one (the rail or the section).
const showSidebar = !isMobile || !mobileSectionOpen;
const showSection = !isMobile || mobileSectionOpen;
const visibility: VisibilityContext = useMemo(
() => ({ isRemote, isAdmin, isPaid }),
[isRemote, isAdmin, isPaid],
);
// Resolve the rendered section: must be a registry id and must be visible to the
// current operator. If the current selection points to a hidden section (e.g.,
// node-scoped item on a remote, or admin-only item for a non-admin), fall back to
// the first visible item.
// the first visible item. Defer until permission metadata is ready so deep links
// to requiredPermission sections (e.g. license) are not rewritten while can() is
// still fail-closed during cold load.
const safeSection: SectionId = useMemo(() => {
if (permissionsStatus !== 'ready') return currentSection;
const reachable = (i: SettingsItemMeta) => isItemVisible(i, visibility) && !isItemLocked(i, visibility);
const direct = SETTINGS_ITEMS.find(i => i.id === currentSection);
if (direct && reachable(direct)) return direct.id;
const fallback = SETTINGS_ITEMS.find(reachable);
return fallback?.id ?? 'appearance';
}, [currentSection, visibility]);
}, [currentSection, visibility, permissionsStatus]);
useEffect(() => {
if (safeSection !== currentSection) onSectionChange(safeSection);
}, [safeSection, currentSection, onSectionChange]);
@@ -1,10 +1,8 @@
import { Search } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { SETTINGS_GROUPS, SETTINGS_ITEMS, isItemVisible, isItemLocked } from './registry';
import type { VisibilityContext, SettingsItemMeta } from './registry';
import type { SettingsItemMeta } from './registry';
import { useSettingsVisibility } from './useSettingsVisibility';
import type { SectionId } from './types';
import { cn } from '@/lib/utils';
@@ -16,17 +14,7 @@ interface SettingsSidebarProps {
}
export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, onOpenPalette }: SettingsSidebarProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const visibility: VisibilityContext = {
isAdmin,
isPaid,
isRemote,
};
const visibility = useSettingsVisibility();
// An item appears in the sidebar only if its registry visibility predicate
// passes AND the operator has the entitlement for it. Tier-locked items
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { canManageNode } from '@/lib/canManageNode';
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
@@ -58,8 +59,8 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
// Node-scoped deploy guardrails
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const { can } = useAuth();
const readOnly = !canManageNode(can, activeNode?.id);
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<GuardrailFields>({ ...DEFAULT_GUARDRAILS });
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
@@ -238,7 +239,7 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveGuardrails} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
@@ -46,8 +46,8 @@ function SectionSkeleton() {
export function UpdatesSection() {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const { can, permissionsReady } = useAuth();
const readOnly = !permissionsReady || !can('system:settings');
const [status, setStatus] = useState<ImageUpdateStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -43,7 +43,8 @@ interface WebhookExecution {
}
export function WebhooksSection() {
const { isAdmin } = useAuth();
const { can } = useAuth();
const canManageWebhooks = can('system:webhooks');
const { activeNode, nodes } = useNodes();
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -74,7 +75,7 @@ export function WebhooksSection() {
};
useEffect(() => { fetchWebhooks(); fetchStacks(); }, [activeNode?.id]);
useEffect(() => { if (!isAdmin) setShowForm(false); }, [isAdmin]);
useEffect(() => { if (!canManageWebhooks) setShowForm(false); }, [canManageWebhooks]);
const enabledCount = webhooks.filter(w => w.enabled).length;
useMastheadStats(
@@ -160,7 +161,7 @@ export function WebhooksSection() {
return (
<div className="flex flex-col gap-10">
{isAdmin && (
{canManageWebhooks && (
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4" /> Create webhook
@@ -168,7 +169,7 @@ export function WebhooksSection() {
</div>
)}
{isAdmin && showForm && (
{canManageWebhooks && showForm && (
<SettingsSection title="New webhook">
<SettingsField label="Name" helper="Shown in execution history and notifications." htmlFor="webhook-name">
<Input id="webhook-name" placeholder="Deploy on push" value={formName} onChange={e => setFormName(e.target.value)} />
@@ -243,9 +244,9 @@ export function WebhooksSection() {
<SettingsCallout
icon={<Webhook className="h-4 w-4" />}
title="No webhooks yet"
subtitle={isAdmin
subtitle={canManageWebhooks
? 'Create one to trigger stack actions from CI/CD.'
: 'An admin operator can create webhooks for this instance.'}
: 'An operator with webhook permission can create webhooks for this instance.'}
/>
)}
@@ -274,7 +275,7 @@ export function WebhooksSection() {
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{isAdmin ? (
{canManageWebhooks ? (
<>
<TogglePill checked={wh.enabled} onChange={(c) => handleToggle(wh.id!, c)} />
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleDelete(wh.id!)}>
@@ -25,7 +25,12 @@ const { masthead, nodeState } = vi.hoisted(() => ({
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ activeNode: nodeState.activeNode }),
}));
const authState = { isAdmin: true };
const authState = {
isAdmin: true,
permissionsReady: true,
permissionsStatus: 'ready' as const,
can: (action: string) => authState.isAdmin || action === 'never',
};
vi.mock('@/context/AuthContext', () => ({
useAuth: () => authState,
}));
@@ -14,7 +14,14 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({
isAdmin: true,
permissionsReady: true,
permissionsStatus: 'ready',
can: () => true,
}),
}));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
@@ -22,7 +22,14 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({
isAdmin: true,
permissionsReady: true,
permissionsStatus: 'ready',
can: () => true,
}),
}));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
vi.mock('../MastheadStatsContext', () => ({
@@ -9,7 +9,14 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({
isAdmin: true,
permissionsReady: true,
permissionsStatus: 'ready',
can: () => true,
}),
}));
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true }));
@@ -20,7 +20,18 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
const useAuthMock = vi.fn(() => ({ isAdmin: true }));
type AuthMock = {
isAdmin: boolean;
permissionsReady: boolean;
permissionsStatus: 'ready';
can: (action?: string) => boolean;
};
const useAuthMock = vi.fn((): AuthMock => ({
isAdmin: true,
permissionsReady: true,
permissionsStatus: 'ready',
can: () => true,
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
@@ -40,7 +51,12 @@ beforeEach(() => {
window.localStorage.clear();
mockedFetch.mockReset();
mockedFetch.mockResolvedValue({ ok: true, json: async () => ({ ...FULL_SETTINGS }) });
useAuthMock.mockReturnValue({ isAdmin: true });
useAuthMock.mockReturnValue({
isAdmin: true,
permissionsReady: true,
permissionsStatus: 'ready',
can: () => true,
});
});
afterEach(() => {
@@ -109,8 +125,13 @@ describe('StacksSection', () => {
expect(screen.getByText('Save settings')).toBeInTheDocument();
});
it('disables guardrails for non-admin while Workflow controls remain enabled', async () => {
useAuthMock.mockReturnValue({ isAdmin: false });
it('disables guardrails without node:manage while Workflow controls remain enabled', async () => {
useAuthMock.mockReturnValue({
isAdmin: false,
permissionsReady: true,
permissionsStatus: 'ready',
can: () => false,
});
render(<StacksSection />);
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
@@ -12,7 +12,12 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
const authState = { isAdmin: true };
const authState = {
isAdmin: true,
permissionsReady: true,
permissionsStatus: 'ready' as const,
can: (action: string) => authState.isAdmin || action === 'never',
};
vi.mock('@/context/AuthContext', () => ({ useAuth: () => authState }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
@@ -91,10 +91,11 @@ describe('settings registry', () => {
expect(SETTINGS_ITEMS.some(i => (i.id as string) === 'security')).toBe(false);
});
it('opens Registries to Community while keeping it admin-only', () => {
it('opens Registries to Community behind system:registries', () => {
const registries = SETTINGS_ITEMS.find(i => i.id === 'registries');
expect(registries?.tier).toBeNull();
expect(registries?.adminOnly).toBe(true);
expect(registries?.adminOnly).toBeUndefined();
expect(registries?.requiredPermission).toBe('system:registries');
});
it('registers the Stacks section under Infrastructure with searchable workflow keywords', () => {
@@ -141,3 +142,33 @@ describe('scopeLabel', () => {
expect(scopeLabel(item({ scope: 'global', group: 'access' }))).toBe('global');
});
});
describe('requiredPermission registry mapping', () => {
it('declares matrix permissions for access and credential sections', () => {
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
expect(byId.get('users')?.requiredPermission).toBe('system:users');
expect(byId.get('users')?.adminOnly).toBeUndefined();
expect(byId.get('license')?.requiredPermission).toBe('system:license');
expect(byId.get('api-tokens')?.requiredPermission).toBe('system:tokens');
expect(byId.get('api-tokens')?.adminOnly).toBeUndefined();
expect(byId.get('webhooks')?.requiredPermission).toBe('system:webhooks');
expect(byId.get('nodes')?.requiredPermission).toBe('node:read');
expect(byId.get('developer')?.requiredPermission).toBe('system:settings');
expect(byId.get('data-retention')?.requiredPermission).toBe('system:settings');
expect(byId.get('image-updates')?.requiredPermission).toBe('system:settings');
});
it('keeps adminOnly on identity, credentials, and emergency surfaces', () => {
for (const id of [
'sso',
'cloud-backup',
'recovery',
'fleet-mesh',
'notification-routing',
'notification-suppression',
] as const) {
expect(SETTINGS_ITEMS.find(i => i.id === id)?.adminOnly, id).toBe(true);
expect(SETTINGS_ITEMS.find(i => i.id === id)?.requiredPermission, id).toBeUndefined();
}
});
});
@@ -0,0 +1,24 @@
/** Mirrors backend ROLE_PERMISSIONS for Settings visibility matrix tests. */
import type { PermissionAction } from '@/context/AuthContext';
export const ROLE_PERMISSIONS: Record<string, PermissionAction[]> = {
admin: [
'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',
],
'node-admin': [
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
'node:read', 'node:manage',
],
deployer: [
'stack:read', 'stack:deploy',
],
viewer: [
'stack:read', 'node:read',
],
auditor: [
'stack:read', 'node:read', 'system:audit',
],
};
@@ -0,0 +1,77 @@
/**
* Settings visibility: requiredPermission and adminOnly for five built-in roles.
*/
import { describe, it, expect } from 'vitest';
import { SETTINGS_ITEMS, isItemVisible, type VisibilityContext } from '../registry';
import type { PermissionAction } from '@/context/AuthContext';
import { ROLE_PERMISSIONS } from './rolePermissionsFixture';
function visibilityFor(role: keyof typeof ROLE_PERMISSIONS, over: Partial<VisibilityContext> = {}): VisibilityContext {
const perms = new Set(ROLE_PERMISSIONS[role]);
return {
isRemote: false,
isAdmin: role === 'admin',
isPaid: true,
can: (action: PermissionAction) => role === 'admin' || perms.has(action),
...over,
};
}
describe('settings section visibility by role', () => {
const permissionSections = [
'users',
'license',
'api-tokens',
'registries',
'webhooks',
'nodes',
] as const;
it('shows permission-gated sections only to roles that hold the permission', () => {
for (const sectionId of permissionSections) {
const item = SETTINGS_ITEMS.find(i => i.id === sectionId)!;
const perm = item.requiredPermission!;
for (const role of Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]) {
const visible = isItemVisible(item, visibilityFor(role));
const expected = role === 'admin' || ROLE_PERMISSIONS[role].includes(perm);
expect(visible, `${sectionId} for ${role}`).toBe(expected);
}
}
});
it('hides license and webhooks from non-admin roles (visibility correction)', () => {
for (const role of ['node-admin', 'deployer', 'viewer', 'auditor'] as const) {
const ctx = visibilityFor(role);
expect(isItemVisible(SETTINGS_ITEMS.find(i => i.id === 'license')!, ctx)).toBe(false);
expect(isItemVisible(SETTINGS_ITEMS.find(i => i.id === 'webhooks')!, ctx)).toBe(false);
}
});
it('keeps host-alerts visible to all authenticated roles (editability is separate)', () => {
const item = SETTINGS_ITEMS.find(i => i.id === 'host-alerts')!;
for (const role of Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]) {
expect(isItemVisible(item, visibilityFor(role)), role).toBe(true);
}
});
it('hides system:settings sections from roles without that permission', () => {
for (const sectionId of ['developer', 'data-retention', 'image-updates'] as const) {
const item = SETTINGS_ITEMS.find(i => i.id === sectionId)!;
expect(isItemVisible(item, visibilityFor('admin'))).toBe(true);
for (const role of ['node-admin', 'deployer', 'viewer', 'auditor'] as const) {
expect(isItemVisible(item, visibilityFor(role)), `${sectionId} for ${role}`).toBe(false);
}
}
});
it('hides adminOnly sections from every non-admin role', () => {
const adminOnly = SETTINGS_ITEMS.filter(i => i.adminOnly);
expect(adminOnly.length).toBeGreaterThan(0);
for (const item of adminOnly) {
for (const role of ['node-admin', 'deployer', 'viewer', 'auditor'] as const) {
expect(isItemVisible(item, visibilityFor(role)), `${item.id} for ${role}`).toBe(false);
}
expect(isItemVisible(item, visibilityFor('admin'))).toBe(true);
}
});
});
+16 -3
View File
@@ -1,3 +1,4 @@
import type { PermissionAction } from '@/context/AuthContext';
import type { SectionId } from './types';
export type SettingsGroupId =
@@ -41,7 +42,10 @@ export interface SettingsItemMeta {
keywords: string[];
tier: TierGate;
scope: Scope;
/** Built-in Admin role only (credentials, identity, emergency). Not a matrix permission. */
adminOnly?: boolean;
/** Matrix permission required to see the section. Independent of adminOnly. */
requiredPermission?: PermissionAction;
hiddenOnRemote?: boolean;
}
@@ -75,6 +79,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
keywords: ['admiral', 'assurance', 'hardened', 'agpl', 'license', 'activation', 'subscription', 'billing'],
tier: null,
scope: 'global',
requiredPermission: 'system:license',
hiddenOnRemote: true,
},
{
@@ -85,7 +90,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions', 'session', 'sliding refresh', 'stay signed in', 'sign out', 'logout'],
tier: null,
scope: 'global',
adminOnly: true,
requiredPermission: 'system:users',
hiddenOnRemote: true,
},
{
@@ -106,7 +111,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
keywords: ['bearer', 'automation', 'ci', 'scripts', 'scopes'],
tier: null,
scope: 'global',
adminOnly: true,
requiredPermission: 'system:tokens',
hiddenOnRemote: true,
},
// Infrastructure
@@ -118,6 +123,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
keywords: ['fleet', 'remote', 'proxy', 'node', 'cluster'],
tier: null,
scope: 'global',
requiredPermission: 'node:read',
hiddenOnRemote: true,
},
{
@@ -147,7 +153,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'],
tier: null,
scope: 'global',
adminOnly: true,
requiredPermission: 'system:registries',
hiddenOnRemote: true,
},
{
@@ -239,6 +245,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck', 'sidebar', 'badge', 'dot', 'indicator', 'status'],
tier: null,
scope: 'node',
requiredPermission: 'system:settings',
},
{
id: 'webhooks',
@@ -248,6 +255,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
keywords: ['webhook', 'incoming', 'trigger', 'ci', 'cd', 'pipeline', 'deploy', 'hmac', 'signature', 'action'],
tier: null,
scope: 'global',
requiredPermission: 'system:webhooks',
hiddenOnRemote: true,
},
// Organization
@@ -269,6 +277,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
keywords: ['retention', 'metrics', 'logs', 'scans', 'audit', 'history', 'prune', 'window'],
tier: null,
scope: 'node',
requiredPermission: 'system:settings',
},
{
id: 'developer',
@@ -278,6 +287,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
keywords: ['developer', 'debug', 'diagnostics', 'metrics', 'verbose'],
tier: null,
scope: 'node',
requiredPermission: 'system:settings',
},
{
id: 'recovery',
@@ -323,11 +333,14 @@ export interface VisibilityContext {
isRemote: boolean;
isAdmin: boolean;
isPaid: boolean;
/** Required so construction sites cannot omit permission checks. */
can: (action: PermissionAction) => boolean;
}
export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
if (ctx.isRemote && item.hiddenOnRemote) return false;
if (item.adminOnly && !ctx.isAdmin) return false;
if (item.requiredPermission && !ctx.can(item.requiredPermission)) return false;
return true;
}
@@ -0,0 +1,18 @@
import { useMemo } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import type { VisibilityContext } from './registry';
/** Shared Settings visibility context for sidebar, gate, and page navigation. */
export function useSettingsVisibility(): VisibilityContext {
const { isAdmin, can } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
return useMemo(
() => ({ isRemote, isAdmin, isPaid, can }),
[isRemote, isAdmin, isPaid, can],
);
}
@@ -38,6 +38,8 @@ export interface StackMenuCtx {
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean; showTakeDown: boolean };
openAlertSheet: () => void;
openAutoHeal: () => void;
/** True when the caller may trigger a stack image-update check (node:manage). */
canCheckUpdates: boolean;
checkUpdates: () => void;
openStackApp: () => void;
deploy: () => void;