mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
feat(fleet-sync): anchor replicas to a control fingerprint (#968)
A replica now binds to the first control that pushes to it. Subsequent
pushes from a different control are rejected with 409
CONTROL_IDENTITY_MISMATCH until an admin explicitly reanchors. Closes
the cross-control hijack window where any node_proxy bearer signed
against the replica's secret could overwrite security policies.
Wire protocol:
- Sender includes a stable 16-hex-char `controlIdentity` derived by
SHA-256-truncating `system_state.instance_id` (the local UUID written
once by LicenseService.initialize on first boot). Hostname rotations
do not flag drift; only a SQLite reset or explicit reanchor breaks
the binding.
- Receiver caches the fingerprint inside the same transaction that
applies the row replacement and watermark write. Three states:
null (fresh install), '' (post-reanchor), '<fingerprint>' (anchored).
- Empty `controlIdentity` is treated as legacy and accepted, so older
controls keep working during rollout.
New endpoint:
- POST /api/fleet/role/reanchor (admin, requires `{override: true}`):
clears the cached fingerprint, both `received_pushed_at:*` watermarks,
and replicated rows of both resources, all in one transaction. The
static cached fingerprint is also flushed defensively.
Public surface additions:
- `FleetSyncService.getControlIdentity()`: stable fingerprint for the
outgoing push body.
- `FleetSyncService.reanchor()`: admin-driven anchor reset.
- `ControlIdentityMismatchError`: typed sentinel the route translates
to 409 with structured body `{error, code, expected, got}`.
Tests:
- 9 new vitest cases covering first-sync persistence, mismatch
rejection, matching acceptance, empty-incoming back-compat,
post-reanchor un-anchored state, fingerprint stability, missing
instance_id fallback, route-level mismatch, route-level reanchor
with override gating.
- One ordered route-level scenario instead of cross-dependent it()
blocks so test reordering cannot silently break the suite.
- Full backend suite: 1769 pass / 5 skipped.
This commit is contained in:
@@ -10,6 +10,7 @@ const {
|
||||
mockGetLocalScanPolicies,
|
||||
mockGetLocalCveSuppressions,
|
||||
mockReplaceReplicatedScanPolicies,
|
||||
mockReplaceReplicatedCveSuppressions,
|
||||
mockRecordFleetSyncSuccess,
|
||||
mockRecordFleetSyncFailure,
|
||||
mockGetSystemState,
|
||||
@@ -23,6 +24,7 @@ const {
|
||||
mockGetLocalScanPolicies: vi.fn().mockReturnValue([]),
|
||||
mockGetLocalCveSuppressions: vi.fn().mockReturnValue([]),
|
||||
mockReplaceReplicatedScanPolicies: vi.fn(),
|
||||
mockReplaceReplicatedCveSuppressions: vi.fn(),
|
||||
mockRecordFleetSyncSuccess: vi.fn(),
|
||||
mockRecordFleetSyncFailure: vi.fn(),
|
||||
mockGetSystemState: vi.fn().mockReturnValue(null),
|
||||
@@ -39,6 +41,7 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
getLocalScanPolicies: mockGetLocalScanPolicies,
|
||||
getLocalCveSuppressions: mockGetLocalCveSuppressions,
|
||||
replaceReplicatedScanPolicies: mockReplaceReplicatedScanPolicies,
|
||||
replaceReplicatedCveSuppressions: mockReplaceReplicatedCveSuppressions,
|
||||
recordFleetSyncSuccess: mockRecordFleetSyncSuccess,
|
||||
recordFleetSyncFailure: mockRecordFleetSyncFailure,
|
||||
getSystemState: mockGetSystemState,
|
||||
@@ -372,3 +375,123 @@ describe('FleetSyncService.applyIncomingSync transactional', () => {
|
||||
expect(watermarkWrites).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** Reset the static fingerprint cache between tests; the static field is private. */
|
||||
function resetControlIdentityCache(): void {
|
||||
(FleetSyncService as unknown as { cachedControlIdentity: string | null }).cachedControlIdentity = null;
|
||||
}
|
||||
|
||||
describe('FleetSyncService control anchor', () => {
|
||||
it('persists controlIdentity on first sync (no cached fingerprint)', () => {
|
||||
mockGetSystemState.mockImplementation((key: string) => {
|
||||
if (key === 'fleet_control_identity') return null;
|
||||
return null;
|
||||
});
|
||||
FleetSyncService.getInstance().applyIncomingSync(
|
||||
'scan_policies',
|
||||
[],
|
||||
'https://me.example',
|
||||
undefined,
|
||||
'fingerprint-aaa',
|
||||
);
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_control_identity', 'fingerprint-aaa');
|
||||
});
|
||||
|
||||
it('rejects with ControlIdentityMismatchError when cached fingerprint differs', async () => {
|
||||
const { ControlIdentityMismatchError } = await import('../services/FleetSyncService');
|
||||
mockGetSystemState.mockImplementation((key: string) => {
|
||||
if (key === 'fleet_control_identity') return 'fingerprint-original';
|
||||
return null;
|
||||
});
|
||||
expect(() => {
|
||||
FleetSyncService.getInstance().applyIncomingSync(
|
||||
'scan_policies',
|
||||
[],
|
||||
'https://me.example',
|
||||
undefined,
|
||||
'fingerprint-different',
|
||||
);
|
||||
}).toThrow(ControlIdentityMismatchError);
|
||||
});
|
||||
|
||||
it('accepts subsequent push when controlIdentity matches the cached fingerprint', () => {
|
||||
mockGetSystemState.mockImplementation((key: string) => {
|
||||
if (key === 'fleet_control_identity') return 'fingerprint-aaa';
|
||||
return null;
|
||||
});
|
||||
expect(() => {
|
||||
FleetSyncService.getInstance().applyIncomingSync(
|
||||
'scan_policies',
|
||||
[],
|
||||
'https://me.example',
|
||||
undefined,
|
||||
'fingerprint-aaa',
|
||||
);
|
||||
}).not.toThrow();
|
||||
// Cached identity is not re-written when matching to avoid noisy churn.
|
||||
const writes = mockSetSystemState.mock.calls.filter((c) => c[0] === 'fleet_control_identity');
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats empty incoming controlIdentity as legacy and accepts (back-compat)', () => {
|
||||
mockGetSystemState.mockImplementation((key: string) => {
|
||||
if (key === 'fleet_control_identity') return 'fingerprint-aaa';
|
||||
return null;
|
||||
});
|
||||
expect(() => {
|
||||
FleetSyncService.getInstance().applyIncomingSync(
|
||||
'scan_policies',
|
||||
[],
|
||||
'https://me.example',
|
||||
undefined,
|
||||
'',
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('treats empty cached fingerprint (post-reanchor) as un-anchored', () => {
|
||||
mockGetSystemState.mockImplementation((key: string) => {
|
||||
if (key === 'fleet_control_identity') return '';
|
||||
return null;
|
||||
});
|
||||
expect(() => {
|
||||
FleetSyncService.getInstance().applyIncomingSync(
|
||||
'scan_policies',
|
||||
[],
|
||||
'https://me.example',
|
||||
undefined,
|
||||
'fingerprint-new-control',
|
||||
);
|
||||
}).not.toThrow();
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_control_identity', 'fingerprint-new-control');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService.reanchor', () => {
|
||||
it('clears cached fingerprint, watermarks, and replicated rows in one transaction', () => {
|
||||
FleetSyncService.getInstance().reanchor();
|
||||
expect(mockTransaction).toHaveBeenCalled();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService.getControlIdentity', () => {
|
||||
it('returns a stable 16-hex-char fingerprint derived from instance_id', () => {
|
||||
mockGetSystemState.mockImplementation((key: string) => (key === 'instance_id' ? 'uuid-abc-def' : null));
|
||||
resetControlIdentityCache();
|
||||
const fp1 = FleetSyncService.getControlIdentity();
|
||||
expect(fp1).toMatch(/^[0-9a-f]{16}$/);
|
||||
resetControlIdentityCache();
|
||||
const fp2 = FleetSyncService.getControlIdentity();
|
||||
expect(fp2).toBe(fp1);
|
||||
});
|
||||
|
||||
it('returns empty string when instance_id is missing', () => {
|
||||
resetControlIdentityCache();
|
||||
mockGetSystemState.mockImplementation(() => null);
|
||||
expect(FleetSyncService.getControlIdentity()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user