feat(fleet-sync): replica self-demote endpoint and role UX (#969)

A replica admin can now demote the instance back to a standalone
control without raw SQLite access. The Settings → Security UI surfaces
a confirm-gated button when the role is replica; the role probe also
surfaces a soft banner when it cannot determine fleet role rather
than silently defaulting to control.

Backend:
- POST /api/fleet/role/demote (admin, requires `{confirm: true}`):
  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.
  Returns 409 ALREADY_CONTROL when invoked on a control.
- DatabaseService gains `clearOrphanPolicyEvaluations()` and
  `clearReplicatedRows()` helpers. Reanchor consolidates onto
  clearReplicatedRows so it shares the same code path.
- `FleetSyncService.demote()` returns boolean for the route to
  translate into 200 or 409.

Frontend:
- SecuritySection probes /fleet/role and now records explicit success
  vs failure rather than silently treating an error as control. A
  soft banner appears when probe fails.
- Replica banner gains a "Demote to control" button and a destructive
  ConfirmModal explaining the wipe.

Tests:
- 4 new route-level vitest cases (401, 400 without confirm,
  end-to-end demote with replica setup, 409 ALREADY_CONTROL with
  explicit precondition).
- Service unit test asserts the consolidated clearReplicatedRows path.
- Full backend suite: 1773 pass / 5 skipped. Frontend: 185 pass.
This commit is contained in:
Anso
2026-05-07 13:08:21 -04:00
committed by GitHub
parent f3757b43c6
commit 7dde257e1f
7 changed files with 253 additions and 8 deletions
@@ -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();
});
});