fix: require node:read for fleet topology reads and hide Fleet without it (#1507)

The fleet overview, configuration, dependency-map, and networking-summary reads
were authentication-only, so a role without node:read (deployer) could read node
names, host stats, and cross-node topology. They now require node:read, matching
the role model where every role except deployer holds it.

For parity, the Fleet nav entry is gated on node:read (hiding it from the top
nav, mobile menu, and command palette), the Fleet view redirects to the
dashboard when reached without it, and the dashboard fleet heartbeat falls back
to the single-node restart map for a role that cannot read fleet data.
This commit is contained in:
Anso
2026-06-28 16:39:11 -04:00
committed by GitHub
parent 1dc12f7da8
commit dd76b13d55
7 changed files with 179 additions and 13 deletions
@@ -16,10 +16,23 @@ function mockActiveNode(type: 'local' | 'remote' | null) {
} as unknown as ReturnType<typeof NodeContext.useNodes>);
}
// A community non-admin user with node:read (e.g. a viewer): sees Fleet, no
// admin-only items.
function mockCommunityUser() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: false,
can: () => false,
can: (p: string) => p === 'node:read',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
// A deployer: stack permissions but no node:read, so no Fleet affordance.
function mockDeployer() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: false,
can: (p: string) => p === 'stack:read' || p === 'stack:deploy',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
@@ -29,7 +42,7 @@ function mockCommunityUser() {
function mockPaidAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'system:audit',
can: (p: string) => p === 'system:audit' || p === 'node:read',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: true,
@@ -39,7 +52,7 @@ function mockPaidAdmin() {
function mockCommunityAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: () => false,
can: (p: string) => p === 'node:read',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
@@ -209,6 +222,30 @@ describe('useViewNavigationState', () => {
expect(values).not.toContain('scheduled-ops');
});
it('hides Fleet from a user without node:read (deployer)', () => {
mockDeployer();
const { result } = renderHook(() => useViewNavigationState());
const values = result.current.navItems.map(i => i.value);
expect(values).not.toContain('fleet');
// The other base items remain reachable.
expect(values).toContain('dashboard');
expect(values).toContain('resources');
expect(values).toContain('templates');
});
it('redirects a user without node:read off the Fleet view reached via a deep-link event', () => {
const onNavigateToDashboard = vi.fn();
mockDeployer();
const { result } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet' } }),
);
});
expect(result.current.activeView).toBe('dashboard');
expect(onNavigateToDashboard).toHaveBeenCalled();
});
it('shows the admin-only Logs entry for an admin on any tier (role gate, not tier gate)', () => {
mockCommunityAdmin();
const { result } = renderHook(() => useViewNavigationState());
@@ -115,13 +115,18 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
const navItems = useMemo((): NavItem[] => {
const items: NavItem[] = [
{ value: 'dashboard', label: 'Home', icon: Home },
{ value: 'fleet', label: 'Fleet', icon: Radar },
];
// Fleet surfaces node topology and host stats, so it is gated on node:read
// (held by every role except deployer), matching the backend guard on the
// fleet overview / configuration / dependency / networking reads.
if (can('node:read')) items.push({ value: 'fleet', label: 'Fleet', icon: Radar });
items.push(
{ value: 'resources', label: 'Resources', icon: HardDrive },
// Security is a Community, node-scoped review surface (not hub-only), so
// it shows for every authenticated user and on remote nodes too.
{ value: 'security', label: 'Security', icon: ShieldCheck },
{ value: 'templates', label: 'App Store', icon: CloudDownload },
];
);
// The aggregated Logs feed crosses every managed stack, so it is an
// admin-only operator view (the backend gates the same routes on admin).
if (isAdmin) items.push({ value: 'global-observability', label: 'Logs', icon: Activity });
@@ -140,16 +145,19 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
useEffect(() => {
// Redirect off a view the active context can't reach: a hub-only view while
// a remote node is active, or the admin-only Logs view as a non-admin (e.g.
// arrived via a deep-link event rather than the now-hidden nav item).
// a remote node is active, the admin-only Logs view as a non-admin, or the
// Fleet view without node:read (e.g. arrived via a deep-link event rather
// than the now-hidden nav item).
const blockedByRemote = isRemote && HUB_ONLY_VIEWS.has(activeView);
const blockedByRole = !isAdmin && activeView === 'global-observability';
const blockedByRole =
(!isAdmin && activeView === 'global-observability')
|| (!can('node:read') && activeView === 'fleet');
if (blockedByRemote || blockedByRole) {
onNavigateToDashboard?.();
setActiveView('dashboard');
setFilterNodeId(null);
}
}, [isRemote, isAdmin, activeView, onNavigateToDashboard]);
}, [isRemote, isAdmin, can, activeView, onNavigateToDashboard]);
return {
activeView, setActiveView,