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
+32
View File
@@ -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[] {
+34 -2
View File
@@ -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.