fix(federation): gate node cordon control on node:manage to match backend (#1277)

* fix(federation): gate node cordon control on node:manage to match backend

The cordon and uncordon control on the node card rendered whenever the
instance held an Admiral license, but the backend route also requires the
node:manage permission. Non-admin users without that permission (deployer,
viewer, auditor) saw a Cordon action the API rejected with 403. Gate the
control on the same permission the route enforces, so it renders only for
users who can use it.

Add developer-mode diagnostic logging to the cordon and pin handlers, and
route-level tests covering the cordon and uncordon permission, tier,
API-token, and input-validation boundaries.

* fix(federation): reject non-numeric node ids in cordon/uncordon

The cordon and uncordon routes validated the node id with parseInt, which
accepts numeric-prefix strings (parseInt('1abc', 10) === 1), so a request to
/api/nodes/1abc/cordon would operate on node 1. Require a strict positive
integer before parsing, and add tests for both routes.

* fix(federation): sanitize user-derived values in cordon and pin diagnostics

Route the node id, blueprint id, target node id, and reason length through
the shared log sanitizer before they reach the developer-mode diagnostic
log lines, and switch the format specifiers to %s to match. The values are
already validated integers, so this is defense in depth at the log sink and
keeps the diagnostics on the same sanitize-every-interpolated-value pattern
used elsewhere. No runtime behavior change: the lines stay gated behind the
developer_mode setting, off by default.
This commit is contained in:
Anso
2026-06-02 09:48:19 -04:00
committed by GitHub
parent e60c1c0525
commit dd2c2b22ec
5 changed files with 355 additions and 9 deletions
@@ -0,0 +1,306 @@
/**
* Authorization and validation tests for the Federation cordon routes
* (POST /api/nodes/:id/cordon and /uncordon).
*
* These guard the same boundary the NodeCard cordon control renders against, so
* a UI gate and a route guard cannot silently drift apart. Cordon/uncordon
* require Admiral tier AND the node:manage permission (held by admin and
* node-admin roles). The guard order is:
* rejectApiTokenScope (SCOPE_DENIED) -> requirePermission (PERMISSION_DENIED)
* -> requireAdmiral (PAID_REQUIRED / ADMIRAL_REQUIRED) -> invalid-id 400
* -> reason 400 (cordon only) -> 404.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import type { LicenseTier, LicenseVariant } from '../services/license-types';
import type { UserRole } from '../services/DatabaseService';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_USERNAME } from './helpers/setupTestDb';
import { createTestApiToken } from './helpers/apiTokenTestHelper';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let adminCookie: string;
let adminUserId: number;
const roleCookie: Record<string, string> = {};
let counter = 0;
function setLicense(tier: LicenseTier, variant: LicenseVariant): void {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(variant);
}
function seedNode(): { id: number; name: string } {
counter += 1;
const name = `cordon-authz-node-${counter}`;
const db = DatabaseService.getInstance().getDb();
const result = db.prepare(
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`,
).run(name, Date.now());
return { id: result.lastInsertRowid as number, name };
}
async function seedAndLogin(role: UserRole): Promise<string> {
const bcrypt = (await import('bcrypt')).default;
const supertest = (await import('supertest')).default;
const username = `cordon-${role}`;
const password = `cordon-${role}-pass`;
const passwordHash = await bcrypt.hash(password, 1);
DatabaseService.getInstance().addUser({ username, password_hash: passwordHash, role });
const res = await supertest(app).post('/api/auth/login').send({ username, password });
const cookies = res.headers['set-cookie'] as string | string[];
return Array.isArray(cookies) ? cookies[0] : cookies;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ LicenseService } = await import('../services/LicenseService'));
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
for (const role of ['node-admin', 'viewer', 'deployer', 'auditor'] as const) {
roleCookie[role] = await seedAndLogin(role);
}
const adminRow = DatabaseService.getInstance().getDb()
.prepare('SELECT id FROM users WHERE username = ?')
.get(TEST_USERNAME) as { id: number } | undefined;
if (!adminRow) throw new Error('seeded admin user not found');
adminUserId = adminRow.id;
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
setLicense('paid', 'admiral');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
DatabaseService.getInstance().getDb().prepare('DELETE FROM nodes WHERE is_default = 0').run();
});
describe('POST /api/nodes/:id/cordon authorization', () => {
it('lets an admin cordon a node and stores the trimmed reason', async () => {
const node = seedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Cookie', adminCookie)
.send({ reason: ' patching kernel ' });
expect(res.status).toBe(200);
expect(res.body.cordoned).toBe(true);
expect(res.body.cordoned_reason).toBe('patching kernel');
});
it('lets a node-admin cordon a node', async () => {
const node = seedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Cookie', roleCookie['node-admin'])
.send({});
expect(res.status).toBe(200);
expect(res.body.cordoned).toBe(true);
});
it.each(['viewer', 'deployer', 'auditor'])(
'rejects a %s without node:manage with PERMISSION_DENIED',
async (role) => {
const node = seedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Cookie', roleCookie[role])
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
},
);
it('rejects an admin on a Skipper license with ADMIRAL_REQUIRED', async () => {
setLicense('paid', 'skipper');
const node = seedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
});
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
setLicense('community', null);
const node = seedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('rejects a full-admin API token with SCOPE_DENIED (API tokens cannot manage nodes)', async () => {
const node = seedNode();
const token = createTestApiToken({ db: DatabaseService, scope: 'full-admin', userId: adminUserId });
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Authorization', `Bearer ${token}`)
.send({ reason: 'ci' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
it('rejects a reason longer than 256 characters with 400', async () => {
const node = seedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Cookie', adminCookie)
.send({ reason: 'a'.repeat(257) });
expect(res.status).toBe(400);
});
it('rejects a non-string reason with 400', async () => {
const node = seedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Cookie', adminCookie)
.send({ reason: 123 });
expect(res.status).toBe(400);
});
it('rejects an invalid node id with 400', async () => {
const res = await request(app)
.post('/api/nodes/0/cordon')
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(400);
});
it('rejects a numeric-prefix node id with 400', async () => {
const res = await request(app)
.post('/api/nodes/1abc/cordon')
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(400);
});
it('returns 404 for a node that does not exist', async () => {
const res = await request(app)
.post('/api/nodes/999999/cordon')
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(404);
});
it('stores null when the reason is whitespace only', async () => {
const node = seedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Cookie', adminCookie)
.send({ reason: ' ' });
expect(res.status).toBe(200);
expect(res.body.cordoned).toBe(true);
expect(res.body.cordoned_reason).toBeNull();
});
it('checks node:manage before the tier gate (Skipper viewer gets PERMISSION_DENIED, not ADMIRAL_REQUIRED)', async () => {
setLicense('paid', 'skipper');
const node = seedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/cordon`)
.set('Cookie', roleCookie['viewer'])
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
});
describe('POST /api/nodes/:id/uncordon authorization', () => {
function seedCordonedNode(): { id: number; name: string } {
const node = seedNode();
DatabaseService.getInstance().setNodeCordoned(node.id, true, 'pre');
return node;
}
it('lets an admin uncordon a node', async () => {
const node = seedCordonedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/uncordon`)
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(200);
expect(res.body.cordoned).toBe(false);
expect(res.body.cordoned_reason).toBeNull();
});
it('lets a node-admin uncordon a node', async () => {
const node = seedCordonedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/uncordon`)
.set('Cookie', roleCookie['node-admin'])
.send({});
expect(res.status).toBe(200);
expect(res.body.cordoned).toBe(false);
});
it.each(['viewer', 'deployer', 'auditor'])(
'rejects a %s without node:manage with PERMISSION_DENIED',
async (role) => {
const node = seedCordonedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/uncordon`)
.set('Cookie', roleCookie[role])
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
},
);
it('rejects an admin on a Skipper license with ADMIRAL_REQUIRED', async () => {
setLicense('paid', 'skipper');
const node = seedCordonedNode();
const res = await request(app)
.post(`/api/nodes/${node.id}/uncordon`)
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
});
it('rejects a full-admin API token with SCOPE_DENIED', async () => {
const node = seedCordonedNode();
const token = createTestApiToken({ db: DatabaseService, scope: 'full-admin', userId: adminUserId });
const res = await request(app)
.post(`/api/nodes/${node.id}/uncordon`)
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
it('returns 404 for a node that does not exist', async () => {
const res = await request(app)
.post('/api/nodes/999999/uncordon')
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(404);
});
it('rejects an invalid node id with 400', async () => {
const res = await request(app)
.post('/api/nodes/0/uncordon')
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(400);
});
it('rejects a numeric-prefix node id with 400', async () => {
const res = await request(app)
.post('/api/nodes/1abc/uncordon')
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(400);
});
});
+3
View File
@@ -12,6 +12,8 @@ import { BlueprintAnalyzer } from '../services/BlueprintAnalyzer';
import { NodeLabelService } from '../services/NodeLabelService';
import { isValidStackName } from '../utils/validation';
import { parseIntParam } from '../utils/parseIntParam';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { isSqliteUniqueViolation, getErrorMessage } from '../utils/errors';
export const blueprintsRouter = Router();
@@ -477,6 +479,7 @@ blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<vo
}
const updated = DatabaseService.getInstance().setBlueprintPinnedNode(id, nodeId);
if (!updated) { res.status(404).json({ error: 'Blueprint not found' }); return; }
if (isDebugEnabled()) console.log('[Federation:diag] pinned blueprint=%s node=%s', sanitizeForLog(id), sanitizeForLog(nodeId));
// Trigger immediate reconciliation so the pin takes effect without
// waiting for the next 60s tick. Errors here are logged but do not
// fail the request: the pin is already persisted.
+7 -4
View File
@@ -18,6 +18,8 @@ import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService
import { FleetSyncService } from '../services/FleetSyncService';
import { isValidRemoteUrl } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.';
const REMOTE_META_CACHE_TTL = 3 * 60 * 1000;
@@ -328,11 +330,11 @@ nodesRouter.post('/:id/cordon', (req: Request, res: Response) => {
const nodeIdParam = req.params.id as string;
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
if (!requireAdmiral(req, res)) return;
const id = parseInt(nodeIdParam, 10);
if (!Number.isInteger(id) || id <= 0) {
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
res.status(400).json({ error: 'Invalid node id' });
return;
}
const id = parseInt(nodeIdParam, 10);
const rawReason = (req.body && typeof req.body === 'object') ? (req.body as { reason?: unknown }).reason : undefined;
let reason: string | null = null;
if (rawReason !== undefined && rawReason !== null) {
@@ -354,6 +356,7 @@ nodesRouter.post('/:id/cordon', (req: Request, res: Response) => {
return;
}
const updated = DatabaseService.getInstance().setNodeCordoned(id, true, reason);
if (isDebugEnabled()) console.log('[Federation:diag] cordoned node=%s reasonLen=%s', sanitizeForLog(id), sanitizeForLog(reason?.length ?? 0));
res.set('cache-control', 'no-store').json(updated);
} catch (error: unknown) {
console.error('Failed to cordon node:', error);
@@ -366,11 +369,11 @@ nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => {
const nodeIdParam = req.params.id as string;
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
if (!requireAdmiral(req, res)) return;
const id = parseInt(nodeIdParam, 10);
if (!Number.isInteger(id) || id <= 0) {
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
res.status(400).json({ error: 'Invalid node id' });
return;
}
const id = parseInt(nodeIdParam, 10);
try {
const existing = DatabaseService.getInstance().getNode(id);
if (!existing) {
@@ -68,13 +68,16 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
const [cordonSubmitting, setCordonSubmitting] = useState(false);
const { isPaid, license } = useLicense();
const { isAdmin } = useAuth();
const { isAdmin, can } = useAuth();
const { nodes: registryNodes } = useNodes();
const isAdmiral = isPaid && license?.variant === 'admiral';
const registryNode = registryNodes.find(n => n.id === node.id);
const canEdit = Boolean(isAdmin && onEdit && registryNode);
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default);
const canCordon = isAdmiral;
// Cordon is Admiral-tier AND requires node:manage, matching the backend guard
// (requirePermission('node:manage','node',id) + requireAdmiral). Gating on tier
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
const canCordon = isAdmiral && can('node:manage', 'node', String(node.id));
const showMenu = canEdit || canDelete || canCordon;
const isOnline = node.status === 'online';
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const useLicenseMock = vi.fn();
const useAuthMock = vi.fn();
@@ -34,7 +35,7 @@ function baseProps(node: FleetNode) {
beforeEach(() => {
useNodesMock.mockReturnValue({ nodes: [] });
useAuthMock.mockReturnValue({ isAdmin: true });
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
useLicenseMock.mockReturnValue({ isPaid: false, license: null });
});
afterEach(() => vi.clearAllMocks());
@@ -60,13 +61,43 @@ describe('NodeCard', () => {
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
});
it('exposes the actions menu (cordon entry point) for an admiral user', () => {
it('exposes the actions menu (cordon entry point) for an admiral admin', () => {
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
render(<NodeCard {...baseProps(onlineNode())} />);
// The actions menu only renders when cordon (admiral-only here) is available.
// With no edit/delete affordances wired, the menu renders iff cordon is
// allowed: isAdmiral && can('node:manage'). The admin's can() returns true.
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
});
it('exposes the cordon control for a node-admin via the node:manage permission', async () => {
const can = vi.fn((action: string) => action === 'node:manage');
useAuthMock.mockReturnValue({ isAdmin: false, can });
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
render(<NodeCard {...baseProps(onlineNode())} />);
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
expect(await screen.findByText('Cordon node')).toBeInTheDocument();
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
});
it('hides the cordon control from an admiral user lacking node:manage', () => {
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
render(<NodeCard {...baseProps(onlineNode())} />);
// Admiral tier alone must not surface cordon to a deployer/viewer/auditor.
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
});
it('shows Uncordon when the node is already cordoned', async () => {
const can = vi.fn((action: string) => action === 'node:manage');
useAuthMock.mockReturnValue({ isAdmin: false, can });
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
render(<NodeCard {...baseProps({ ...onlineNode(), cordoned: true, cordoned_reason: 'patching' })} />);
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
expect(await screen.findByText('Uncordon node')).toBeInTheDocument();
});
const updateAvailableStatus = {
nodeId: 2, name: 'Edge', type: 'remote' as const, version: '1.0.0', latestVersion: '1.1.0',
updateAvailable: true, updateStatus: null,