feat(fleet-sync): gate sync-status polling on admin role (#1271)

* feat(fleet-sync): gate sync-status polling on admin role

The fleet sync status poll requires a paid admin on the server, but the
useFleetSyncStatus hook only checked the paid tier. A paid non-admin who
opened the Fleet Status tab or Settings, Nodes would poll the endpoint
every 30 seconds and get a 403 each time, with the policy sync rows
silently never loading. Gate the hook on both paid tier and admin role
so the client mirrors the server and the request only fires when it can
succeed.

Also add a one-time log when an instance transitions from control to
replica, and a real-database integration test covering the full receive
path: role flip, identity and watermark persistence, wholesale row
replacement with local rows preserved, replica read-only enforcement,
and the stale-watermark and control-anchor rejection branches. Includes
a hook test asserting the gate across the paid and admin matrix.

* fix(fleet-sync): drop sync-status rows that resolve after the gate flips

A sync-status fetch started while the user was an eligible paid admin
could resolve after the user lost eligibility mid-flight (role or tier
change), re-populating the status rows that the gate-false path had
already cleared. That briefly re-enabled the policy-sync rows and the
anchor-mismatch banner for a now-ineligible client.

Track the latest gate state in a ref and skip the state writes when the
fetch resolves after eligibility was lost, so the rows stay empty under
role and tier transitions. Adds a regression test for the late-resolve
path.

* fix(security): sanitize control identity before logging replica transition

The control to replica transition log interpolated the control fingerprint
straight from the inbound sync payload, so a sibling pushing a forged
controlIdentity with embedded CR/LF could split or spoof log lines. Wrap the
value in the shared sanitizeForLog helper, matching every other tainted log
sink, so control characters are stripped before the entry is written.
This commit is contained in:
Anso
2026-06-01 17:26:11 -04:00
committed by GitHub
parent 7e0cffa376
commit 7d7e0a6264
4 changed files with 334 additions and 10 deletions
@@ -0,0 +1,197 @@
/**
* End-to-end apply-flow integration test for Fleet Sync against a real
* in-process SQLite database.
*
* The unit suite (`fleet-sync-service.test.ts`) mocks every DatabaseService
* method, so it proves the service calls the right methods but not that the
* SQLite transaction actually flips the role, replaces the replicated rows,
* persists the watermark, and leaves local rows alone. This file drives the
* receive path on a real DB: build the wire payload a control would send,
* call `applyIncomingSync`, and assert the replica's observable state plus
* `blockIfReplica` enforcement. It also covers the two rejection branches
* (stale watermark, control-anchor mismatch): each throws before any row
* mutation, so the assertion is that the prior accepted state is preserved.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import type { Response } from 'express';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { SYNC_STATE_KEYS } from '../services/fleetSyncConstants';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let FleetSyncService: typeof import('../services/FleetSyncService').FleetSyncService;
let StaleSyncPushError: typeof import('../services/FleetSyncService').StaleSyncPushError;
let ControlIdentityMismatchError: typeof import('../services/FleetSyncService').ControlIdentityMismatchError;
let blockIfReplica: typeof import('../middleware/fleetSyncGuards').blockIfReplica;
type ScanPolicy = import('../services/DatabaseService').ScanPolicy;
function makeRow(name: string, overrides: Partial<ScanPolicy> = {}): ScanPolicy {
return {
id: 0,
name,
node_id: null,
node_identity: '',
stack_pattern: null,
max_severity: 'CRITICAL',
block_on_deploy: 1,
enabled: 1,
replicated_from_control: 1,
created_at: 1,
updated_at: 1,
...overrides,
};
}
/** Minimal Response double capturing the status/body blockIfReplica writes. */
function fakeRes(): { res: Response; captured: { statusCode: number; body: unknown } } {
const captured = { statusCode: 0, body: undefined as unknown };
const res = {
status(code: number) { captured.statusCode = code; return res; },
json(body: unknown) { captured.body = body; return res; },
};
return { res: res as unknown as Response, captured };
}
/** Force this instance back to a clean control state between cases. */
function resetToControl(): void {
const db = DatabaseService.getInstance();
db.setSystemState(SYNC_STATE_KEYS.fleetRole, 'control');
db.setSystemState(SYNC_STATE_KEYS.fleetSelfIdentity, '');
db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, '');
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), '');
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), '');
db.clearReplicatedRows();
// Drop any local scan policies seeded by a prior case.
db.getDb().prepare('DELETE FROM scan_policies WHERE replicated_from_control = 0').run();
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ FleetSyncService, StaleSyncPushError, ControlIdentityMismatchError } = await import('../services/FleetSyncService'));
({ blockIfReplica } = await import('../middleware/fleetSyncGuards'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
resetToControl();
});
describe('Fleet Sync apply flow (real DB round trip)', () => {
it('flips control -> replica, persists identity + watermark, and installs replicated rows', () => {
const db = DatabaseService.getInstance();
expect(FleetSyncService.getRole()).toBe('control');
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined);
const rows = [makeRow('block-critical'), makeRow('warn-high', { max_severity: 'HIGH', block_on_deploy: 0 })];
FleetSyncService.getInstance().applyIncomingSync(
'scan_policies',
rows,
'https://replica.example',
1_000,
'control-fp-1',
);
expect(FleetSyncService.getRole()).toBe('replica');
expect(db.getSystemState(SYNC_STATE_KEYS.fleetSelfIdentity)).toBe('https://replica.example');
expect(db.getSystemState(SYNC_STATE_KEYS.fleetControlIdentity)).toBe('control-fp-1');
expect(db.getSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'))).toBe('1000');
const installed = db.getScanPolicies();
expect(installed).toHaveLength(2);
expect(installed.every((p) => p.replicated_from_control === 1)).toBe(true);
expect(installed.map((p) => p.name).sort()).toEqual(['block-critical', 'warn-high']);
// The transition log fires exactly once on the actual flip.
expect(infoSpy.mock.calls.filter((c) => String(c[0]).includes('now a replica'))).toHaveLength(1);
infoSpy.mockRestore();
});
it('blocks local writes on a replica and allows them on a control', () => {
// Control: guard is a no-op.
const control = fakeRes();
expect(blockIfReplica(control.res, 'scan_policies')).toBe(false);
expect(control.captured.statusCode).toBe(0);
// After an apply, the same guard rejects with 403 REPLICA_READ_ONLY.
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [], 'https://replica.example', 1_000, 'fp');
const replica = fakeRes();
expect(blockIfReplica(replica.res, 'scan_policies')).toBe(true);
expect(replica.captured.statusCode).toBe(403);
expect(replica.captured.body).toMatchObject({ code: 'REPLICA_READ_ONLY' });
});
it('replaces the prior replicated set wholesale and leaves local rows untouched', () => {
const db = DatabaseService.getInstance();
// A local policy authored on this instance before it became a replica.
db.createScanPolicy({
name: 'local-keepme', node_id: null, node_identity: '', stack_pattern: null,
max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, replicated_from_control: 0,
});
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('first-gen')], 'https://r.example', 1_000, 'fp');
expect(db.getScanPolicies().filter((p) => p.replicated_from_control === 1).map((p) => p.name)).toEqual(['first-gen']);
// Second push with a higher watermark fully replaces the replicated set.
// The transition log must stay silent now that the instance is already a replica.
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined);
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('second-gen')], 'https://r.example', 2_000, 'fp');
expect(infoSpy.mock.calls.filter((c) => String(c[0]).includes('now a replica'))).toHaveLength(0);
infoSpy.mockRestore();
const replicated = db.getScanPolicies().filter((p) => p.replicated_from_control === 1);
expect(replicated.map((p) => p.name)).toEqual(['second-gen']);
expect(db.getSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'))).toBe('2000');
// The local row survives both pushes.
expect(db.getLocalScanPolicies().map((p) => p.name)).toEqual(['local-keepme']);
});
it('installs cve_suppressions through the real DB (multi-resource dispatch)', () => {
const db = DatabaseService.getInstance();
const rows = [{
cve_id: 'CVE-2024-0001', pkg_name: 'openssl', image_pattern: null,
reason: 'fixed upstream, not exploitable', created_by: 'control',
created_at: 1, expires_at: null, replicated_from_control: 1,
}];
FleetSyncService.getInstance().applyIncomingSync('cve_suppressions', rows, 'https://r.example', 1_000, 'fp');
expect(FleetSyncService.getRole()).toBe('replica');
const installed = db.getCveSuppressions();
expect(installed).toHaveLength(1);
expect(installed[0]).toMatchObject({ cve_id: 'CVE-2024-0001', pkg_name: 'openssl', replicated_from_control: 1 });
expect(db.getSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'))).toBe('1000');
});
it('rejects a stale push and rolls back so replica state is unchanged', () => {
const db = DatabaseService.getInstance();
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('current')], 'https://r.example', 5_000, 'fp');
expect(() => {
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('stale')], 'https://r.example', 4_999, 'fp');
}).toThrow(StaleSyncPushError);
// Watermark and rows reflect the accepted push, not the stale one.
expect(db.getSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'))).toBe('5000');
expect(db.getScanPolicies().map((p) => p.name)).toEqual(['current']);
});
it('rejects a push from a different control anchor and rolls back the rows', () => {
const db = DatabaseService.getInstance();
// First push anchors the replica to control A.
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('from-A')], 'https://r.example', 1_000, 'control-A');
expect(db.getSystemState(SYNC_STATE_KEYS.fleetControlIdentity)).toBe('control-A');
// A push from control B is rejected; control A's rows remain.
expect(() => {
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('from-B')], 'https://r.example', 2_000, 'control-B');
}).toThrow(ControlIdentityMismatchError);
expect(db.getSystemState(SYNC_STATE_KEYS.fleetControlIdentity)).toBe('control-A');
expect(db.getScanPolicies().map((p) => p.name)).toEqual(['from-A']);
});
});
+11
View File
@@ -4,6 +4,7 @@ import { CveSuppression, DatabaseService, MisconfigAcknowledgement, Node, ScanPo
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import {
FleetResource,
MAX_SYNC_ROWS,
@@ -294,6 +295,7 @@ export class FleetSyncService {
controlIdentity?: string,
): void {
const db = DatabaseService.getInstance();
let promotedToReplica = false;
db.transaction(() => {
if (controlIdentity && controlIdentity.length > 0) {
// The cached fingerprint has three possible states:
@@ -319,6 +321,7 @@ export class FleetSyncService {
}
db.setSystemState(watermarkKey, String(pushedAt));
}
promotedToReplica = db.getSystemState(SYNC_STATE_KEYS.fleetRole) !== 'replica';
db.setSystemState(SYNC_STATE_KEYS.fleetRole, 'replica');
if (targetIdentity) {
const cachedSelf = db.getSystemState(SYNC_STATE_KEYS.fleetSelfIdentity);
@@ -364,6 +367,14 @@ export class FleetSyncService {
summary: `Replicated ${resource} from ${controlIdentity || 'legacy control'}: replaced ${rows.length} row(s)`,
});
});
// Log the control -> replica transition once, only when it actually
// flips. The transaction sets fleet_role on every apply, so guarding on
// the prior role keeps this off the steady-state per-push path.
if (promotedToReplica) {
console.info(
`[FleetSync] Instance is now a replica, anchored to control ${sanitizeForLog(controlIdentity || 'legacy control')}.`,
);
}
}
/**
@@ -0,0 +1,106 @@
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 }),
}));
// Run the interval callback synchronously so we can assert it is (not) armed
// without faking timers.
vi.mock('@/lib/utils', () => ({
visibilityInterval: (fn: () => void) => { fn(); return () => undefined; },
}));
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;
mockIsAdmin = true;
const { result } = renderHook(() => useFleetSyncStatus());
await waitFor(() => expect(result.current.statuses).toHaveLength(1));
expect(fetchStatusesMock).toHaveBeenCalled();
expect(result.current.loading).toBe(false);
});
it('does not fetch for a paid non-admin (the 403-loop this fix prevents)', async () => {
mockIsPaid = true;
mockIsAdmin = false;
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 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 () => {
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();
expect(result.current.statuses).toEqual([]); // in-flight, not resolved yet
// Lose admin before the in-flight fetch resolves.
mockIsAdmin = false;
act(() => { rerender(); });
expect(result.current.statuses).toEqual([]);
// The late resolve must not republish rows to the now-ineligible client.
await act(async () => {
resolveFetch([{ node_id: 9, resource: 'scan_policies' }]);
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.statuses).toEqual([]);
});
it('starts fetching once the user becomes a paid 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(); });
await waitFor(() => expect(fetchStatusesMock).toHaveBeenCalled());
await waitFor(() => expect(result.current.statuses).toHaveLength(1));
});
});
+20 -10
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
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';
@@ -7,9 +8,10 @@ const REFRESH_INTERVAL_MS = 30_000;
/**
* Polls `/api/fleet/sync-status` and exposes the rows plus a manual
* `refresh()`. Skips fetching for community-tier users since the endpoint is
* paid-tier-gated; the hook returns an empty array in that case so consumers
* can render the `!isPaid` branch without conditionals.
* `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.
*/
export function useFleetSyncStatus(): {
statuses: FleetSyncStatus[];
@@ -17,29 +19,37 @@ export function useFleetSyncStatus(): {
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;
const [statuses, setStatuses] = useState<FleetSyncStatus[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(() => {
if (!isPaid) {
if (!canQuery) {
setStatuses([]);
setLoading(false);
return;
}
fetchFleetSyncStatuses()
.then((rows) => setStatuses(rows))
.then((rows) => { if (canQueryRef.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(() => setLoading(false));
}, [isPaid]);
.finally(() => { if (canQueryRef.current) setLoading(false); });
}, [canQuery]);
useEffect(() => {
refresh();
if (!isPaid) return undefined;
if (!canQuery) return undefined;
return visibilityInterval(refresh, REFRESH_INTERVAL_MS);
}, [isPaid, refresh]);
}, [canQuery, refresh]);
return { statuses, loading, refresh };
}