fix(fleet-sync): open status and reset-anchor to Community admins (#1792)

Baseline Fleet Sync already replicates security policy on Community
code paths, but status and anchor recovery still required a paid
entitlement. Drop the residual paid gates while keeping admin and
node:manage authorization boundaries.
This commit is contained in:
Anso
2026-08-07 19:52:12 -04:00
committed by GitHub
parent 10b3cb6746
commit b9e73f7bd8
8 changed files with 100 additions and 99 deletions
+4 -9
View File
@@ -14,7 +14,6 @@ import { formatTimeUntil, formatTimeAgo } from '@/lib/relativeTime';
import { SettingsPrimaryButton } from './settings/SettingsActions';
import { useMastheadStats } from './settings/MastheadStatsContext';
import { NodeLabelPicker } from './blueprints/NodeLabelPicker';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodeActions, type NodeTestInfo } from './nodes/useNodeActions';
import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus';
@@ -39,14 +38,10 @@ export interface SenchoNavigateDetail {
}
export function NodeManager() {
const { isPaid } = useLicense();
const { isAdmin, can } = useAuth();
// Mirror the backend node:manage guard. This top-level flag checks the global
// role only (admin or global node-admin); the per-row Test/Edit/Delete buttons
// below additionally honor scoped per-node grants via can('node:manage', 'node', id).
// Admins resolve immediately via isAdmin; node-admins once /permissions/me lands.
// Generate-token and reset-anchor below stay admin-only to match their stricter
// backend guards (requireAdmin, and requireAdmin + requirePaid).
// Global node:manage only (admin or global node-admin). Per-row actions also
// check scoped can('node:manage', 'node', id). Generate-token and reset-anchor
// stay isAdmin-only to match their requireAdmin backend guards.
const canManageNodes = isAdmin || can('node:manage');
const { nodes, refreshNodeMeta } = useNodes();
useMastheadStats([
@@ -277,7 +272,7 @@ export function NodeManager() {
{' '}Reset the anchor on the peer to resume sync, or remove the node from this fleet.
</div>
<div className="flex flex-wrap items-center gap-2 pt-1">
{isAdmin && isPaid && (
{isAdmin && (
<Button
size="sm"
variant="destructive"
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { Node } from '@/context/NodeContext';
@@ -6,7 +6,6 @@ import type { Node } from '@/context/NodeContext';
// the test renders the panel in isolation and can drive the role/permission
// inputs that gate the write affordances.
const useAuthMock = vi.fn();
const useLicenseMock = vi.fn();
const testNode: Node = {
id: 2,
@@ -25,7 +24,6 @@ vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ nodes: [testNode], refreshNodeMeta: vi.fn() }),
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock() }));
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
}));
@@ -53,9 +51,6 @@ function canFor(...granted: string[]) {
return (action: string) => granted.includes(action);
}
beforeEach(() => {
useLicenseMock.mockReturnValue({ isPaid: false });
});
afterEach(() => vi.clearAllMocks());
describe('NodeManager write-affordance gating', () => {
@@ -2,15 +2,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
const fetchStatusesMock = vi.fn();
let mockIsPaid = false;
let mockIsAdmin = false;
vi.mock('@/lib/fleetSyncApi', () => ({
fetchFleetSyncStatuses: (...args: unknown[]) => fetchStatusesMock(...args),
}));
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => ({ isPaid: mockIsPaid }),
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: mockIsAdmin }),
}));
@@ -24,14 +20,12 @@ import { useFleetSyncStatus } from '../useFleetSyncStatus';
beforeEach(() => {
fetchStatusesMock.mockReset().mockResolvedValue([{ node_id: 1, resource: 'scan_policies' }]);
mockIsPaid = false;
mockIsAdmin = false;
});
afterEach(() => vi.clearAllMocks());
describe('useFleetSyncStatus gate parity', () => {
it('fetches for a paid admin (mirrors requireAdmin + requirePaid)', async () => {
mockIsPaid = true;
describe('useFleetSyncStatus admin parity', () => {
it('fetches for an admin (mirrors requireAdmin on sync-status)', async () => {
mockIsAdmin = true;
const { result } = renderHook(() => useFleetSyncStatus());
@@ -40,8 +34,7 @@ describe('useFleetSyncStatus gate parity', () => {
expect(result.current.loading).toBe(false);
});
it('does not fetch for a paid non-admin (the 403-loop this fix prevents)', async () => {
mockIsPaid = true;
it('does not fetch for a non-admin (avoids a 403 poll loop)', async () => {
mockIsAdmin = false;
const { result } = renderHook(() => useFleetSyncStatus());
@@ -50,28 +43,10 @@ describe('useFleetSyncStatus gate parity', () => {
expect(result.current.statuses).toEqual([]);
});
it('does not fetch for an admin on a community license', async () => {
mockIsPaid = false;
mockIsAdmin = true;
const { result } = renderHook(() => useFleetSyncStatus());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fetchStatusesMock).not.toHaveBeenCalled();
expect(result.current.statuses).toEqual([]);
});
it('does not fetch when neither paid nor admin', async () => {
const { result } = renderHook(() => useFleetSyncStatus());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fetchStatusesMock).not.toHaveBeenCalled();
});
it('ignores rows from a fetch that resolves after the gate flips ineligible', async () => {
it('ignores rows from a fetch that resolves after admin is lost', async () => {
let resolveFetch!: (rows: Array<{ node_id: number; resource: string }>) => void;
const pending = new Promise<Array<{ node_id: number; resource: string }>>((res) => { resolveFetch = res; });
fetchStatusesMock.mockReturnValue(pending);
mockIsPaid = true;
mockIsAdmin = true;
const { result, rerender } = renderHook(() => useFleetSyncStatus());
expect(fetchStatusesMock).toHaveBeenCalled();
@@ -82,7 +57,7 @@ describe('useFleetSyncStatus gate parity', () => {
act(() => { rerender(); });
expect(result.current.statuses).toEqual([]);
// The late resolve must not republish rows to the now-ineligible client.
// Late resolve must not republish rows to a non-admin client.
await act(async () => {
resolveFetch([{ node_id: 9, resource: 'scan_policies' }]);
await Promise.resolve();
@@ -91,12 +66,11 @@ describe('useFleetSyncStatus gate parity', () => {
expect(result.current.statuses).toEqual([]);
});
it('starts fetching once the user becomes a paid admin', async () => {
it('starts fetching once the user becomes an admin', async () => {
const { result, rerender } = renderHook(() => useFleetSyncStatus());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fetchStatusesMock).not.toHaveBeenCalled();
mockIsPaid = true;
mockIsAdmin = true;
act(() => { rerender(); });
+12 -19
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { visibilityInterval } from '@/lib/utils';
import { fetchFleetSyncStatuses, type FleetSyncStatus } from '@/lib/fleetSyncApi';
@@ -8,48 +7,42 @@ const REFRESH_INTERVAL_MS = 30_000;
/**
* Polls `/api/fleet/sync-status` and exposes the rows plus a manual
* `refresh()`. The endpoint requires a paid admin, so the hook only fetches
* for paid admins and returns an empty array otherwise. This mirrors the
* route guard exactly: gating on tier alone would leave a paid non-admin
* polling an endpoint that always 403s.
* `refresh()`. Admin-only, matching the route's requireAdmin guard: non-admins
* get an empty array instead of polling an endpoint that always 403s.
*/
export function useFleetSyncStatus(): {
statuses: FleetSyncStatus[];
loading: boolean;
refresh: () => void;
} {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const canQuery = isPaid && isAdmin;
// Reflects the latest gate state so a fetch begun while eligible cannot
// publish its rows after the user loses eligibility mid-flight (role or
// tier change). Without this, a late resolve would re-populate statuses
// that the gate-false path already cleared.
const canQueryRef = useRef(canQuery);
canQueryRef.current = canQuery;
// Latest eligibility for in-flight fetches: a request started while admin
// must not publish rows after a mid-flight demotion already cleared state.
const isAdminRef = useRef(isAdmin);
isAdminRef.current = isAdmin;
const [statuses, setStatuses] = useState<FleetSyncStatus[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(() => {
if (!canQuery) {
if (!isAdmin) {
setStatuses([]);
setLoading(false);
return;
}
fetchFleetSyncStatuses()
.then((rows) => { if (canQueryRef.current) setStatuses(rows); })
.then((rows) => { if (isAdminRef.current) setStatuses(rows); })
.catch((err) => {
// Stale data stays visible to avoid flicker; log so the failure isn't completely silent.
console.warn('[FleetSync] sync-status fetch failed:', err);
})
.finally(() => { if (canQueryRef.current) setLoading(false); });
}, [canQuery]);
.finally(() => { if (isAdminRef.current) setLoading(false); });
}, [isAdmin]);
useEffect(() => {
refresh();
if (!canQuery) return undefined;
if (!isAdmin) return undefined;
return visibilityInterval(refresh, REFRESH_INTERVAL_MS);
}, [canQuery, refresh]);
}, [isAdmin, refresh]);
return { statuses, loading, refresh };
}