From 7dde257e1fab84bbfcafbcd78ec2ba4597781398 Mon Sep 17 00:00:00 2001
From: Anso
Date: Thu, 7 May 2026 13:08:21 -0400
Subject: [PATCH] feat(fleet-sync): replica self-demote endpoint and role UX
(#969)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../src/__tests__/fleet-sync-routes.test.ts | 70 ++++++++++++++++
.../src/__tests__/fleet-sync-service.test.ts | 8 +-
backend/src/routes/fleet.ts | 30 +++++++
backend/src/services/DatabaseService.ts | 32 +++++++
backend/src/services/FleetSyncService.ts | 36 +++++++-
backend/src/utils/audit-summaries.ts | 1 +
.../components/settings/SecuritySection.tsx | 84 +++++++++++++++++--
7 files changed, 253 insertions(+), 8 deletions(-)
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 && (
+
+
+
+
+
Managed by control node
+
+ Security policies replicate from the control Sencho instance. View them here for audit; edit them on the control.
+
+
+
+
setDemoteConfirm(true)}
+ disabled={demoteBusy}
+ >
+ Demote to control
+
+
+ )}
+
+ {!isRemote && fleetRoleProbeFailed && !isReplica && (
-
Managed by control node
+
Fleet role could not be determined
- Security policies replicate from the control Sencho instance. View them here for audit; edit them on the control.
+ Treating this instance as a control. Refresh the page to retry.
@@ -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.
+
+
);
}