diff --git a/backend/src/__tests__/fleet-sync-routes.test.ts b/backend/src/__tests__/fleet-sync-routes.test.ts index 19466de8..0e43cf0a 100644 --- a/backend/src/__tests__/fleet-sync-routes.test.ts +++ b/backend/src/__tests__/fleet-sync-routes.test.ts @@ -273,3 +273,73 @@ describe('POST /api/fleet/role/reanchor', () => { expect(next.status).toBe(200); }); }); + +describe('POST /api/fleet/role/demote', () => { + it('returns 401 without auth', async () => { + const res = await request(app).post('/api/fleet/role/demote').send({ confirm: true }); + expect(res.status).toBe(401); + }); + + it('returns 400 without confirm:true', async () => { + const res = await request(app) + .post('/api/fleet/role/demote') + .set('Authorization', adminAuthHeader) + .send({}); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/confirmation/i); + }); + + it('demotes a replica back to control, drops mirrored rows, then re-allows local writes', async () => { + // Reanchor first so this test does not depend on whichever fingerprint + // an earlier test left in place; then self-promote into a replica via a + // sync push with at least one row so the demote has something to wipe. + const reset = await request(app) + .post('/api/fleet/role/reanchor') + .set('Authorization', adminAuthHeader) + .send({ override: true }); + expect(reset.status).toBe(200); + + const push = await request(app) + .post('/api/fleet/sync/scan_policies') + .set('Authorization', nodeProxyAuthHeader) + .send({ + rows: [ + { name: 'mirrored-policy', node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 0, enabled: 1 }, + ], + targetIdentity: 'https://me.example', + controlIdentity: 'fingerprint-demote-test', + }); + expect(push.status).toBe(200); + + const beforeRole = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader); + expect(beforeRole.body.role).toBe('replica'); + + const demote = await request(app) + .post('/api/fleet/role/demote') + .set('Authorization', adminAuthHeader) + .send({ confirm: true }); + expect(demote.status).toBe(200); + expect(demote.body.role).toBe('control'); + + const afterRole = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader); + expect(afterRole.body.role).toBe('control'); + }); + + it('returns 409 ALREADY_CONTROL when called on a control instance', async () => { + // Be explicit about the precondition so this test holds under --shuffle. + const role = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader); + if (role.body.role !== 'control') { + const cleanup = await request(app) + .post('/api/fleet/role/demote') + .set('Authorization', adminAuthHeader) + .send({ confirm: true }); + expect(cleanup.status).toBe(200); + } + const res = await request(app) + .post('/api/fleet/role/demote') + .set('Authorization', adminAuthHeader) + .send({ confirm: true }); + expect(res.status).toBe(409); + expect(res.body.code).toBe('ALREADY_CONTROL'); + }); +}); diff --git a/backend/src/__tests__/fleet-sync-service.test.ts b/backend/src/__tests__/fleet-sync-service.test.ts index 1881c4c0..eae8ebc1 100644 --- a/backend/src/__tests__/fleet-sync-service.test.ts +++ b/backend/src/__tests__/fleet-sync-service.test.ts @@ -11,6 +11,8 @@ const { mockGetLocalCveSuppressions, mockReplaceReplicatedScanPolicies, mockReplaceReplicatedCveSuppressions, + mockClearOrphanPolicyEvaluations, + mockClearReplicatedRows, mockRecordFleetSyncSuccess, mockRecordFleetSyncFailure, mockGetSystemState, @@ -25,6 +27,8 @@ const { mockGetLocalCveSuppressions: vi.fn().mockReturnValue([]), mockReplaceReplicatedScanPolicies: vi.fn(), mockReplaceReplicatedCveSuppressions: vi.fn(), + mockClearOrphanPolicyEvaluations: vi.fn(), + mockClearReplicatedRows: vi.fn(), mockRecordFleetSyncSuccess: vi.fn(), mockRecordFleetSyncFailure: vi.fn(), mockGetSystemState: vi.fn().mockReturnValue(null), @@ -42,6 +46,8 @@ vi.mock('../services/DatabaseService', () => ({ getLocalCveSuppressions: mockGetLocalCveSuppressions, replaceReplicatedScanPolicies: mockReplaceReplicatedScanPolicies, replaceReplicatedCveSuppressions: mockReplaceReplicatedCveSuppressions, + clearOrphanPolicyEvaluations: mockClearOrphanPolicyEvaluations, + clearReplicatedRows: mockClearReplicatedRows, recordFleetSyncSuccess: mockRecordFleetSyncSuccess, recordFleetSyncFailure: mockRecordFleetSyncFailure, getSystemState: mockGetSystemState, @@ -474,7 +480,7 @@ describe('FleetSyncService.reanchor', () => { expect(mockSetSystemState).toHaveBeenCalledWith('fleet_control_identity', ''); expect(mockSetSystemState).toHaveBeenCalledWith('received_pushed_at:scan_policies', ''); expect(mockSetSystemState).toHaveBeenCalledWith('received_pushed_at:cve_suppressions', ''); - expect(mockReplaceReplicatedScanPolicies).toHaveBeenCalledWith([]); + expect(mockClearReplicatedRows).toHaveBeenCalled(); }); }); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 91bc14ed..3993985b 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -371,6 +371,36 @@ fleetRouter.post('/sync/:resource', authMiddleware, (req: Request, res: Response } }); +// Demote this replica back to a standalone control. Wipes all replicated +// security rules and the cached fingerprint, then flips `fleet_role` to +// 'control'. The local UI's write controls become available again. +// `{confirm: true}` body is required so a misclick cannot destroy mirrored +// state. +fleetRouter.post('/role/demote', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + const body = req.body ?? {}; + if (body.confirm !== true) { + res.status(400).json({ + error: 'Demote requires explicit confirmation. Send { "confirm": true } to proceed.', + }); + return; + } + try { + const demoted = FleetSyncService.getInstance().demote(); + if (!demoted) { + res.status(409).json({ + error: 'This instance is already a control; nothing to demote.', + code: 'ALREADY_CONTROL', + }); + return; + } + res.json({ success: true, role: 'control' }); + } catch (error) { + console.error('[FleetSync] Demote failed:', error); + res.status(500).json({ error: 'Failed to demote replica' }); + } +}); + // Reset the control anchor on this replica. An admin must opt in explicitly // with `{override: true}` because reanchor wipes all replicated rows; the // next push from a different control will re-populate them. Used when a diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 700c6643..49431708 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -3872,6 +3872,38 @@ export class DatabaseService { txn(rows); } + /** + * Null out `vulnerability_scans.policy_evaluation` rows whose `$.policyId` + * no longer exists in `scan_policies`. Used after replicated rows are + * wiped (sync replace, demote) so cached scan banners do not reference + * deleted policies. + */ + public clearOrphanPolicyEvaluations(): void { + this.db + .prepare( + `UPDATE vulnerability_scans + SET policy_evaluation = NULL + WHERE policy_evaluation IS NOT NULL + AND CAST(json_extract(policy_evaluation, '$.policyId') AS INTEGER) + NOT IN (SELECT id FROM scan_policies)`, + ) + .run(); + } + + /** + * Atomically delete every replicated_from_control row from both + * scan_policies and cve_suppressions, then null out any orphaned + * policy_evaluation cache. Used by the demote endpoint and any future + * "drop replicated state" operation. + */ + public clearReplicatedRows(): void { + this.transaction(() => { + this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1').run(); + this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1').run(); + this.clearOrphanPolicyEvaluations(); + }); + } + // --- Stack Labels --- public getLabels(nodeId: number): Label[] { diff --git a/backend/src/services/FleetSyncService.ts b/backend/src/services/FleetSyncService.ts index 22ff1c9b..65190891 100644 --- a/backend/src/services/FleetSyncService.ts +++ b/backend/src/services/FleetSyncService.ts @@ -303,12 +303,44 @@ export class FleetSyncService { db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, ''); db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), ''); db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), ''); - db.replaceReplicatedScanPolicies([]); - db.replaceReplicatedCveSuppressions([]); + db.clearReplicatedRows(); }); FleetSyncService.cachedControlIdentity = null; } + /** + * Demote this replica back to a standalone control. Atomic transaction: + * - flips `fleet_role` to 'control' + * - clears `fleet_self_identity`, `fleet_control_identity`, and both + * `received_pushed_at:*` watermarks + * - drops every replicated_from_control row from scan_policies and + * cve_suppressions + * - nulls out any orphaned policy_evaluation cache + * + * After this returns, local writes to scan_policies and cve_suppressions + * succeed again (`blockIfReplica` no longer trips). + * + * Returns `false` if the instance is already a control (no work done); + * the route translates that to 409 so the UI doesn't show a misleading + * success toast. + */ + public demote(): boolean { + const db = DatabaseService.getInstance(); + if (FleetSyncService.getRole() === 'control') { + return false; + } + db.transaction(() => { + 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(); + }); + FleetSyncService.cachedControlIdentity = null; + return true; + } + /** * Chain a push behind any in-flight push for the same node. Different * nodes still run in parallel via the outer Promise.all. diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 7b41f2fa..23c2b73e 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -88,6 +88,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /fleet/nodes/*/update': 'Triggered fleet node update', 'POST /fleet/update-all': 'Triggered fleet-wide update', 'POST /fleet/role/reanchor': 'Re-anchored fleet replica', + 'POST /fleet/role/demote': 'Demoted fleet replica to control', // Cloud backup 'PUT /cloud-backup/config': 'Updated cloud backup config', diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx index e164858e..28e6d1cd 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -77,6 +77,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { const [trivyBusy, setTrivyBusy] = useState(null); const [uninstallConfirm, setUninstallConfirm] = useState(false); const [fleetRole, setFleetRole] = useState('control'); + const [fleetRoleProbeFailed, setFleetRoleProbeFailed] = useState(false); + const [demoteConfirm, setDemoteConfirm] = useState(false); + const [demoteBusy, setDemoteBusy] = useState(false); const isReplica = fleetRole === 'replica'; const runTrivyOp = async ( @@ -160,18 +163,48 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { (async () => { try { const res = await apiFetch('/fleet/role', { localOnly: true }); - if (!res.ok) return; + if (!res.ok) { + if (!cancelled) setFleetRoleProbeFailed(true); + return; + } const data = await res.json(); - if (!cancelled && (data?.role === 'control' || data?.role === 'replica')) { + if (cancelled) return; + if (data?.role === 'control' || data?.role === 'replica') { setFleetRole(data.role); + setFleetRoleProbeFailed(false); + } else { + setFleetRoleProbeFailed(true); } } catch { - /* fallback: treat as control if the check fails */ + if (!cancelled) setFleetRoleProbeFailed(true); } })(); return () => { cancelled = true; }; }, [isRemote]); + const handleDemote = async () => { + setDemoteBusy(true); + try { + const res = await apiFetch('/fleet/role/demote', { + method: 'POST', + localOnly: true, + body: JSON.stringify({ confirm: true }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Demote failed'); + } + toast.success('Replica demoted to control'); + setFleetRole('control'); + setDemoteConfirm(false); + fetchPolicies(); + } catch (err) { + toast.error((err as Error)?.message || 'Demote failed'); + } finally { + setDemoteBusy(false); + } + }; + const openCreate = () => { setEditingId(null); setForm(EMPTY_FORM); @@ -270,6 +303,33 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { )} {!isRemote && isReplica && ( +
+
+
+ +
+ )} + + {!isRemote && fleetRoleProbeFailed && !isReplica && (
@@ -547,6 +607,20 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.

+ + +

+ Removes every replicated scan policy and CVE suppression mirrored from the control. Local edits to security policies on this instance become available again. +

+
); }