mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 12:49:03 +00:00
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 <resource> 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.
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user