fix(fleet-sync): open status and reset-anchor to Community admins (#1792)

Baseline Fleet Sync already replicates security policy on Community
code paths, but status and anchor recovery still required a paid
entitlement. Drop the residual paid gates while keeping admin and
node:manage authorization boundaries.
This commit is contained in:
Anso
2026-08-07 19:52:12 -04:00
committed by GitHub
parent 10b3cb6746
commit b9e73f7bd8
8 changed files with 100 additions and 99 deletions
@@ -115,21 +115,23 @@ describe('GET /api/fleet/sync-status', () => {
expect(res.status).toBe(401);
});
it('returns 403 PAID_REQUIRED on community tier', async () => {
it('returns status rows for a Community admin (no paid gate)', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app).get('/api/fleet/sync-status').set('Authorization', adminAuthHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
vi.restoreAllMocks();
});
it('returns an empty list for an admin on paid tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const res = await request(app).get('/api/fleet/sync-status').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
vi.restoreAllMocks();
});
it('returns 403 ADMIN_REQUIRED for a non-admin viewer', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
db.addUser({ username: 'sync-status-viewer', password_hash: 'x', role: 'viewer' });
const viewerAuth = `Bearer ${jwt.sign({ username: 'sync-status-viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
const res = await request(app).get('/api/fleet/sync-status').set('Authorization', viewerAuth);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
});
describe('POST /api/fleet/sync/:resource pushedAt protocol', () => {
@@ -1,5 +1,5 @@
/**
* Tests for POST /api/nodes/:id/fleet-sync/reset-anchor (F-16 fix).
* Tests for POST /api/nodes/:id/fleet-sync/reset-anchor.
*
* The endpoint proxies the peer's reanchor endpoint and clears every
* sticky-error row for the node on success. Covers:
@@ -7,7 +7,8 @@
* - peer 401/403 → 502 with helpful message.
* - peer unreachable → 504.
* - missing/non-proxy node → 400.
* - non-paid tier → 403.
* - Community admin success (no paid gate).
* - non-admin viewer → 403.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
@@ -54,14 +55,9 @@ afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(async () => {
beforeEach(() => {
vi.restoreAllMocks();
globalThis.fetch = originalFetch;
// Re-establish the paid-tier spy after restoreAllMocks. Individual tests
// can override with `mockReturnValue('community')` to exercise the tier
// gate's deny path.
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
});
describe('POST /api/nodes/:id/fleet-sync/reset-anchor', () => {
@@ -144,15 +140,66 @@ describe('POST /api/nodes/:id/fleet-sync/reset-anchor', () => {
expect(res.status).toBe(404);
});
it('returns 403 (PAID_REQUIRED) when the license is community-tier', async () => {
it('succeeds for a Community admin', async () => {
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().setFleetSyncSticky(
peerNodeId, 'scan_policies', 'CONTROL_IDENTITY_MISMATCH', 'aaa', 'bbb',
);
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true }), { status: 200 }),
);
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const res = await request(app)
.post(`/api/nodes/${peerNodeId}/fleet-sync/reset-anchor`)
.set('Authorization', authHeader)
.send({});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('returns 403 PERMISSION_DENIED for a viewer', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().addUser({
username: 'reset-anchor-viewer',
password_hash: 'x',
role: 'viewer',
});
const viewerAuth = `Bearer ${jwt.sign(
{ username: 'reset-anchor-viewer', role: 'viewer' },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
)}`;
const res = await request(app)
.post(`/api/nodes/${peerNodeId}/fleet-sync/reset-anchor`)
.set('Authorization', viewerAuth)
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('returns 403 ADMIN_REQUIRED for a node-admin (status is admin-only)', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().addUser({
username: 'reset-anchor-node-admin',
password_hash: 'x',
role: 'node-admin',
});
const nodeAdminAuth = `Bearer ${jwt.sign(
{ username: 'reset-anchor-node-admin', role: 'node-admin' },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
)}`;
const res = await request(app)
.post(`/api/nodes/${peerNodeId}/fleet-sync/reset-anchor`)
.set('Authorization', nodeAdminAuth)
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
});
+1 -2
View File
@@ -17,7 +17,7 @@ import { StackOpLockService } from '../services/StackOpLockService';
import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService';
import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates';
import { requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates';
import { checkPermission, requirePermission } from '../middleware/permissions';
import { respondSelfUpdatePreflight } from './license';
import { ImageOperationService } from '../services/ImageOperationService';
@@ -588,7 +588,6 @@ fleetRouter.post('/role/reanchor', authMiddleware, (req: Request, res: Response)
fleetRouter.get('/sync-status', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
res.json(DatabaseService.getInstance().getFleetSyncStatuses());
});
+8 -12
View File
@@ -5,7 +5,7 @@ import path from 'path';
import { authMiddleware } from '../middleware/auth';
import { requirePermission } from '../middleware/permissions';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { requireAdmin } from '../middleware/tierGates';
import { enrollmentLimiter } from '../middleware/rateLimiters';
import { DeployedStackDeletionService } from '../services/DeployedStackDeletionService';
import { DatabaseService } from '../services/DatabaseService';
@@ -494,24 +494,20 @@ nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => {
* Reset the FleetSync control anchor on a remote peer.
*
* Proxies POST /api/fleet/role/reanchor to the peer using its stored
* Bearer token. A successful reanchor clears every sticky-error row for
* this node so the next push (event-driven or via the 5-minute retry
* service) re-attempts cleanly and the peer accepts the central's
* Bearer token. On success, clears every sticky-error row for this node
* so the next push re-attempts cleanly and the peer accepts the central's
* fingerprint as the new anchor.
*
* Surfaces UI affordance for the F-16 audit (mesh-e2e-2026-05-17.md):
* when a peer was previously enrolled by a different central, FleetSync
* keeps 409'ing every reconcile tick; the sticky flag halts retries and
* this endpoint is the single one-click recovery for the operator.
* Used when a peer was previously enrolled by a different central:
* FleetSync keeps 409'ing every reconcile tick, the sticky flag halts
* retries, and this endpoint is the one-click operator recovery.
*/
nodesRouter.post('/:id/fleet-sync/reset-anchor', async (req: Request, res: Response) => {
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
const nodeIdParam = req.params.id as string;
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
if (!requirePaid(req, res)) return;
// Reset-anchor is symmetric with `/api/fleet/sync-status` (admin-only).
// Keeping read and write gated at the same role avoids a banner-invisible-to-the-actor
// gap where a node-admin could call reset without ever seeing why.
// Also requireAdmin so reset stays symmetric with GET /api/fleet/sync-status.
// A node-admin must not reset anchors they cannot observe (status is admin-only).
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(nodeIdParam, 10);