diff --git a/backend/src/__tests__/nodes-cordon-authz.test.ts b/backend/src/__tests__/nodes-cordon-authz.test.ts new file mode 100644 index 00000000..a649edb0 --- /dev/null +++ b/backend/src/__tests__/nodes-cordon-authz.test.ts @@ -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 = {}; +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 { + 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); + }); +}); diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts index 68d7f6ff..544ffbc4 100644 --- a/backend/src/routes/blueprints.ts +++ b/backend/src/routes/blueprints.ts @@ -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 { 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) { diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index bfc359ec..6d014c17 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -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'; diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index 0c896e0b..c4bc67a1 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -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(); - // 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(); + + 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(); + // 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(); + + 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,