fix: require node:read for fleet topology reads and hide Fleet without it (#1507)

The fleet overview, configuration, dependency-map, and networking-summary reads
were authentication-only, so a role without node:read (deployer) could read node
names, host stats, and cross-node topology. They now require node:read, matching
the role model where every role except deployer holds it.

For parity, the Fleet nav entry is gated on node:read (hiding it from the top
nav, mobile menu, and command palette), the Fleet view redirects to the
dashboard when reached without it, and the dashboard fleet heartbeat falls back
to the single-node restart map for a role that cannot read fleet data.
This commit is contained in:
Anso
2026-06-28 16:39:11 -04:00
committed by GitHub
parent 1dc12f7da8
commit dd76b13d55
7 changed files with 179 additions and 13 deletions
@@ -0,0 +1,64 @@
/**
* Authorization tests for the fleet topology reads. /overview, /configuration,
* /dependency-map, /networking-summary, and /update-status expose node names,
* host stats, versions, and cross-node topology, so they require node:read.
* Every shipped role carries node:read except deployer, the denial persona here.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let viewerToken: string;
let deployerToken: string;
const NODE_READ_ROUTES = [
'/api/fleet/overview',
'/api/fleet/configuration',
'/api/fleet/dependency-map',
'/api/fleet/networking-summary',
'/api/fleet/update-status',
];
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
db.addUser({ username: 'fleet-viewer', password_hash: hash, role: 'viewer' });
db.addUser({ username: 'fleet-deployer', password_hash: hash, role: 'deployer' });
const sign = (username: string, role: string): string => {
const user = db.getUserByUsername(username)!;
return jwt.sign({ username, role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' });
};
viewerToken = sign('fleet-viewer', 'viewer');
deployerToken = sign('fleet-deployer', 'deployer');
});
afterAll(() => cleanupTestDb(tmpDir));
describe('fleet topology reads require node:read', () => {
for (const route of NODE_READ_ROUTES) {
it(`denies ${route} for a role without node:read (deployer)`, async () => {
const res = await request(app).get(route).set('Authorization', `Bearer ${deployerToken}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it(`allows ${route} for a role with node:read (viewer)`, async () => {
const res = await request(app).get(route).set('Authorization', `Bearer ${viewerToken}`);
// The guard lets the request through; the body may be empty/offline in a
// Docker-less test env, but it must not be a 403.
expect(res.status).not.toBe(403);
});
}
it('rejects an unauthenticated request', async () => {
const res = await request(app).get('/api/fleet/overview');
expect(res.status).toBe(401);
});
});
+8 -3
View File
@@ -549,7 +549,8 @@ fleetRouter.get('/sync-status', authMiddleware, (req: Request, res: Response): v
res.json(DatabaseService.getInstance().getFleetSyncStatuses());
});
fleetRouter.get('/overview', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
fleetRouter.get('/overview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const debug = isDebugEnabled();
const db = DatabaseService.getInstance();
@@ -602,6 +603,7 @@ interface FleetNodeConfiguration {
}
fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
@@ -670,7 +672,8 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
* and via its auth-only per-node route for remotes, then merges with per-node
* attribution. Unreachable nodes degrade to nodeErrors so the rest still draws.
*/
fleetRouter.get('/dependency-map', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
fleetRouter.get('/dependency-map', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
@@ -748,7 +751,8 @@ function isNodeNetworkingSummary(v: unknown): v is NodeNetworkingSummary {
* 404 and degrades to a skip, so one unreachable or unsupported node never fails
* the filter for the rest.
*/
fleetRouter.get('/networking-summary', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
fleetRouter.get('/networking-summary', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
@@ -884,6 +888,7 @@ fleetRouter.get('/node/:nodeId/stacks/:stackName/containers', authMiddleware, as
});
fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();