mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user