From add3abaecee7104d54699cc04864f0ae2a890a8b Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 27 Apr 2026 00:29:38 -0400 Subject: [PATCH] refactor(backend): replica guard helper for security routes (#797) * refactor(backend): extract replica guard helper for security routes Adds blockIfReplica(res, resource) in middleware/fleetSyncGuards.ts and replaces six inline FleetSyncService.getRole() === 'replica' checks across the security policies and CVE suppressions endpoints. Error responses now use a uniform shape: 403 { error: 'Cannot modify on a replica instance. Connect to the primary.', code: 'REPLICA_READ_ONLY' } The new code field gives callers a stable discriminator without matching prose. Closes #750 * test(suppressions): match stable REPLICA_READ_ONLY code instead of prose The replica guard helper exposes a stable code field for callers to discriminate without grepping the human-readable error string. Switch the replica-rejection assertion to use that code so the test no longer breaks when the unified error template wording is tuned. --- .../src/__tests__/suppression-routes.test.ts | 2 +- backend/src/middleware/fleetSyncGuards.ts | 23 ++++++++++++++ backend/src/routes/security.ts | 31 +++++-------------- 3 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 backend/src/middleware/fleetSyncGuards.ts diff --git a/backend/src/__tests__/suppression-routes.test.ts b/backend/src/__tests__/suppression-routes.test.ts index a9e3c51e..6bd504c3 100644 --- a/backend/src/__tests__/suppression-routes.test.ts +++ b/backend/src/__tests__/suppression-routes.test.ts @@ -140,7 +140,7 @@ describe('POST /api/security/suppressions', () => { .set('Authorization', adminAuthHeader) .send(validBody); expect(res.status).toBe(403); - expect(res.body.error).toMatch(/control node/i); + expect(res.body.code).toBe('REPLICA_READ_ONLY'); }); it('rejects malformed CVE identifiers', async () => { diff --git a/backend/src/middleware/fleetSyncGuards.ts b/backend/src/middleware/fleetSyncGuards.ts new file mode 100644 index 00000000..0d6e2d11 --- /dev/null +++ b/backend/src/middleware/fleetSyncGuards.ts @@ -0,0 +1,23 @@ +import type { Response } from 'express'; +import { FleetSyncService } from '../services/FleetSyncService'; + +/** + * Reject mutation attempts on a replica Sencho. Returns true (and writes a + * 403 response) when this instance is acting as a replica; the caller + * should `return` immediately. Returns false on a control instance so the + * route handler can continue. + * + * Replicas mirror security configuration (scan policies, CVE suppressions) + * from the control node and reject local writes to keep the fleet + * authoritative source single. + */ +export function blockIfReplica(res: Response, resource: string): boolean { + if (FleetSyncService.getRole() === 'replica') { + res.status(403).json({ + error: `Cannot modify ${resource} on a replica instance. Connect to the primary.`, + code: 'REPLICA_READ_ONLY', + }); + return true; + } + return false; +} diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index e9c6bc94..5c9a095c 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -12,6 +12,7 @@ import { applySuppressions } from '../utils/suppression-filter'; import { generateSarif } from '../services/SarifExporter'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; +import { blockIfReplica } from '../middleware/fleetSyncGuards'; import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity'; const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/; @@ -418,10 +419,7 @@ securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): v securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'Security policies are managed from the control node.' }); - return; - } + if (blockIfReplica(res, 'security policies')) return; const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled } = req.body ?? {}; if (!name || typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'Policy name is required' }); return; @@ -452,10 +450,7 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'Security policies are managed from the control node.' }); - return; - } + if (blockIfReplica(res, 'security policies')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'Invalid policy id' }); return; @@ -488,10 +483,7 @@ securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response securityRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'Security policies are managed from the control node.' }); - return; - } + if (blockIfReplica(res, 'security policies')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'Invalid policy id' }); return; @@ -514,10 +506,7 @@ securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'CVE suppressions are managed from the control node.' }); - return; - } + if (blockIfReplica(res, 'CVE suppressions')) return; const body = req.body ?? {}; const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : ''; if (!CVE_ID_RE.test(cveId)) { @@ -570,10 +559,7 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'CVE suppressions are managed from the control node.' }); - return; - } + if (blockIfReplica(res, 'CVE suppressions')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'Invalid suppression id' }); return; @@ -611,10 +597,7 @@ securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Resp securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'CVE suppressions are managed from the control node.' }); - return; - } + if (blockIfReplica(res, 'CVE suppressions')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'Invalid suppression id' }); return;