mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
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:
@@ -115,21 +115,23 @@ describe('GET /api/fleet/sync-status', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 PAID_REQUIRED on community tier', async () => {
|
||||
it('returns status rows for a Community admin (no paid gate)', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const res = await request(app).get('/api/fleet/sync-status').set('Authorization', adminAuthHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns an empty list for an admin on paid tier', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
const res = await request(app).get('/api/fleet/sync-status').set('Authorization', adminAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns 403 ADMIN_REQUIRED for a non-admin viewer', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addUser({ username: 'sync-status-viewer', password_hash: 'x', role: 'viewer' });
|
||||
const viewerAuth = `Bearer ${jwt.sign({ username: 'sync-status-viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
||||
const res = await request(app).get('/api/fleet/sync-status').set('Authorization', viewerAuth);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/sync/:resource pushedAt protocol', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Tests for POST /api/nodes/:id/fleet-sync/reset-anchor (F-16 fix).
|
||||
* Tests for POST /api/nodes/:id/fleet-sync/reset-anchor.
|
||||
*
|
||||
* The endpoint proxies the peer's reanchor endpoint and clears every
|
||||
* sticky-error row for the node on success. Covers:
|
||||
@@ -7,7 +7,8 @@
|
||||
* - peer 401/403 → 502 with helpful message.
|
||||
* - peer unreachable → 504.
|
||||
* - missing/non-proxy node → 400.
|
||||
* - non-paid tier → 403.
|
||||
* - Community admin success (no paid gate).
|
||||
* - non-admin viewer → 403.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -54,14 +55,9 @@ afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
globalThis.fetch = originalFetch;
|
||||
// Re-establish the paid-tier spy after restoreAllMocks. Individual tests
|
||||
// can override with `mockReturnValue('community')` to exercise the tier
|
||||
// gate's deny path.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
});
|
||||
|
||||
describe('POST /api/nodes/:id/fleet-sync/reset-anchor', () => {
|
||||
@@ -144,15 +140,66 @@ describe('POST /api/nodes/:id/fleet-sync/reset-anchor', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 403 (PAID_REQUIRED) when the license is community-tier', async () => {
|
||||
it('succeeds for a Community admin', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
DatabaseService.getInstance().setFleetSyncSticky(
|
||||
peerNodeId, 'scan_policies', 'CONTROL_IDENTITY_MISMATCH', 'aaa', 'bbb',
|
||||
);
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
||||
);
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${peerNodeId}/fleet-sync/reset-anchor`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 403 PERMISSION_DENIED for a viewer', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
DatabaseService.getInstance().addUser({
|
||||
username: 'reset-anchor-viewer',
|
||||
password_hash: 'x',
|
||||
role: 'viewer',
|
||||
});
|
||||
const viewerAuth = `Bearer ${jwt.sign(
|
||||
{ username: 'reset-anchor-viewer', role: 'viewer' },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
)}`;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${peerNodeId}/fleet-sync/reset-anchor`)
|
||||
.set('Authorization', viewerAuth)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
|
||||
it('returns 403 ADMIN_REQUIRED for a node-admin (status is admin-only)', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
DatabaseService.getInstance().addUser({
|
||||
username: 'reset-anchor-node-admin',
|
||||
password_hash: 'x',
|
||||
role: 'node-admin',
|
||||
});
|
||||
const nodeAdminAuth = `Bearer ${jwt.sign(
|
||||
{ username: 'reset-anchor-node-admin', role: 'node-admin' },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
)}`;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${peerNodeId}/fleet-sync/reset-anchor`)
|
||||
.set('Authorization', nodeAdminAuth)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import { StackOpLockService } from '../services/StackOpLockService';
|
||||
import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService';
|
||||
import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates';
|
||||
import { requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates';
|
||||
import { checkPermission, requirePermission } from '../middleware/permissions';
|
||||
import { respondSelfUpdatePreflight } from './license';
|
||||
import { ImageOperationService } from '../services/ImageOperationService';
|
||||
@@ -588,7 +588,6 @@ fleetRouter.post('/role/reanchor', authMiddleware, (req: Request, res: Response)
|
||||
|
||||
fleetRouter.get('/sync-status', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
res.json(DatabaseService.getInstance().getFleetSyncStatuses());
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'path';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { enrollmentLimiter } from '../middleware/rateLimiters';
|
||||
import { DeployedStackDeletionService } from '../services/DeployedStackDeletionService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
@@ -494,24 +494,20 @@ nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => {
|
||||
* Reset the FleetSync control anchor on a remote peer.
|
||||
*
|
||||
* Proxies POST /api/fleet/role/reanchor to the peer using its stored
|
||||
* Bearer token. A successful reanchor clears every sticky-error row for
|
||||
* this node so the next push (event-driven or via the 5-minute retry
|
||||
* service) re-attempts cleanly and the peer accepts the central's
|
||||
* Bearer token. On success, clears every sticky-error row for this node
|
||||
* so the next push re-attempts cleanly and the peer accepts the central's
|
||||
* fingerprint as the new anchor.
|
||||
*
|
||||
* Surfaces UI affordance for the F-16 audit (mesh-e2e-2026-05-17.md):
|
||||
* when a peer was previously enrolled by a different central, FleetSync
|
||||
* keeps 409'ing every reconcile tick; the sticky flag halts retries and
|
||||
* this endpoint is the single one-click recovery for the operator.
|
||||
* Used when a peer was previously enrolled by a different central:
|
||||
* FleetSync keeps 409'ing every reconcile tick, the sticky flag halts
|
||||
* retries, and this endpoint is the one-click operator recovery.
|
||||
*/
|
||||
nodesRouter.post('/:id/fleet-sync/reset-anchor', async (req: Request, res: Response) => {
|
||||
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
// Reset-anchor is symmetric with `/api/fleet/sync-status` (admin-only).
|
||||
// Keeping read and write gated at the same role avoids a banner-invisible-to-the-actor
|
||||
// gap where a node-admin could call reset without ever seeing why.
|
||||
// Also requireAdmin so reset stays symmetric with GET /api/fleet/sync-status.
|
||||
// A node-admin must not reset anchors they cannot observe (status is admin-only).
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(nodeIdParam, 10);
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user