mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 08:06:42 +00:00
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:
@@ -0,0 +1,19 @@
|
||||
import type { PermissionAction } from '@/context/AuthContext';
|
||||
|
||||
type CanFn = (
|
||||
action: PermissionAction,
|
||||
resourceType?: string,
|
||||
resourceId?: string,
|
||||
nodeId?: number | null,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Resolve node:manage for the active node so scoped Node Admin grants apply.
|
||||
* When nodeId is missing, falls back to the unscoped check (same as Auth.can()).
|
||||
*/
|
||||
export function canManageNode(can: CanFn, nodeId: number | null | undefined): boolean {
|
||||
if (nodeId != null) {
|
||||
return can('node:manage', 'node', String(nodeId), nodeId);
|
||||
}
|
||||
return can('node:manage');
|
||||
}
|
||||
@@ -95,4 +95,37 @@ describe('reachability', () => {
|
||||
const off = ctx({ experimental: false, experimentalReady: true, isAdmin: true });
|
||||
expect(isSettingsSectionHidden('fleet-mesh', off)).toBe(false);
|
||||
});
|
||||
|
||||
it('defers settings permission hides until authz is ready', () => {
|
||||
const loading = ctx({
|
||||
permissionsStatus: 'loading',
|
||||
isAdmin: false,
|
||||
can: () => false,
|
||||
});
|
||||
expect(isSettingsSectionHidden('webhooks', loading)).toBe(false);
|
||||
expect(isSettingsSectionHidden('license', loading)).toBe(false);
|
||||
});
|
||||
|
||||
it('hides requiredPermission sections when the operator lacks the permission', () => {
|
||||
const nodeAdmin = ctx({
|
||||
isAdmin: false,
|
||||
can: (a) => a === 'node:read' || a === 'node:manage',
|
||||
});
|
||||
expect(isSettingsSectionHidden('webhooks', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('license', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('users', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('api-tokens', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('registries', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('nodes', nodeAdmin)).toBe(false);
|
||||
expect(isSettingsSectionHidden('host-alerts', nodeAdmin)).toBe(false);
|
||||
expect(isSettingsSectionHidden('developer', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('data-retention', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('image-updates', nodeAdmin)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides adminOnly settings sections for non-admins', () => {
|
||||
const nodeAdmin = ctx({ isAdmin: false, can: () => true });
|
||||
expect(isSettingsSectionHidden('sso', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('recovery', nodeAdmin)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FleetTab } from '@/lib/events';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
import { getSettingsItem } from '@/components/settings/registry';
|
||||
import { getSettingsItem, isItemVisible, isItemLocked } from '@/components/settings/registry';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
import { HUB_ONLY_VIEWS } from '@/lib/router/routeTypes';
|
||||
|
||||
@@ -75,9 +75,14 @@ export function isSettingsSectionHidden(section: SectionId, ctx: ReachabilityCon
|
||||
if (!authzReady(ctx)) return false;
|
||||
const item = getSettingsItem(section);
|
||||
if (!item) return true;
|
||||
if (ctx.isRemote && item.hiddenOnRemote) return true;
|
||||
if (item.adminOnly && !ctx.isAdmin) return true;
|
||||
if (item.tier === 'paid' && !ctx.isPaid) return true;
|
||||
const visibility = {
|
||||
isRemote: ctx.isRemote,
|
||||
isAdmin: ctx.isAdmin,
|
||||
isPaid: ctx.isPaid,
|
||||
can: ctx.can,
|
||||
};
|
||||
if (!isItemVisible(item, visibility)) return true;
|
||||
if (isItemLocked(item, visibility)) return true;
|
||||
// fleet-mesh stays reachable: snapshot_documentation lives there even when
|
||||
// Mesh discovery is off.
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user