mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 08:06:42 +00:00
fix: forward scoped node-admin permission for Settings writes through remote proxy (#1748)
* fix: forward scoped node-admin permission for Settings writes through remote proxy The proxy forwards only the user's global role via PROXY_ROLE_HEADER to remote nodes. Scoped role assignments live only on the hub's role_assignments table and are never transmitted, so a scoped Node Admin could not save settings on their granted remote node through the hub proxy. Add a settings-write pre-authorization gate in runGatedProxy that buffers the body, extracts required permission buckets from SETTING_WRITE_PERMISSIONS, checks them hub-side, and elevates PROXY_ROLE_HEADER to 'node-admin' when the scoped check passes. The gate is fail-closed: empty or unparseable bodies require checkNodeManage on the hub, matching the existing requireSettingsWritePermission empty-keys branch. Fixes the gate-parity gap where scoped node:manage worked locally but not through the proxy for Settings writes. * fix: remove unused UserRole import from remoteNodeProxy.ts * test(self-update): poll instead of a fixed delay in triggerUpdate assertion The 600ms sleep raced the route's 500ms post-response timer plus the persist/watch work executeClaimedCommunityUpdate does before calling triggerUpdate, leaving too little margin under CI's forked test pool. Poll with vi.waitFor instead, matching the pattern already used elsewhere in this suite.
This commit is contained in:
@@ -34,6 +34,8 @@ import {
|
||||
ROLE_PERMISSIONS,
|
||||
scopedActionsForStack,
|
||||
} from '../middleware/permissions';
|
||||
import type { PermissionAction } from '../middleware/permissions';
|
||||
import { SETTING_WRITE_PERMISSIONS } from '../routes/settings';
|
||||
|
||||
/**
|
||||
* Per-request hop timing for the critical hydration GETs, kept off the Request
|
||||
@@ -420,6 +422,59 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Settings-write pre-auth gate: when a non-admin, non-global-node-admin
|
||||
// user writes settings on a remote node, the remote only sees the global
|
||||
// role header and cannot verify scoped assignments. Check hub-side first
|
||||
// and elevate PROXY_ROLE_HEADER to node-admin when the scoped check passes.
|
||||
if (isSettingsWrite(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
||||
if (hasNonIdentityContentEncoding(req)) {
|
||||
await drainRequestBody(req);
|
||||
res.status(415).json({
|
||||
error: 'Compressed request bodies are not supported for remote settings writes',
|
||||
code: 'encoding_unsupported',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
req.rawBody = await bufferRequestBody(req, SETTINGS_PROXY_BODY_LIMIT);
|
||||
} catch (err) {
|
||||
const status = Number((err as { status?: number }).status);
|
||||
if (status === 413) {
|
||||
res.status(413).json({ error: 'Settings payload too large', code: 'entity_too_large' });
|
||||
return;
|
||||
}
|
||||
if (status === 400) {
|
||||
res.status(400).json({ error: 'Incomplete request body' });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const needed = settingsBodyPermissions(req.rawBody);
|
||||
// Fail-closed on empty/unparseable body: require hub-side node:manage on
|
||||
// the target node (mirrors requireSettingsWritePermission's empty-keys
|
||||
// branch in routes/settings.ts:62-68). A user with no scoped grant is
|
||||
// denied; a user with a scoped grant passes through elevated.
|
||||
let preAuthOk = true;
|
||||
if (needed.length === 0) {
|
||||
preAuthOk = checkNodeManageOnHub(req);
|
||||
} else {
|
||||
for (const action of needed) {
|
||||
const ok = action === 'node:manage'
|
||||
? checkNodeManageOnHub(req)
|
||||
: checkPermission(req, action);
|
||||
if (!ok) {
|
||||
preAuthOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!preAuthOk) {
|
||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||
return;
|
||||
}
|
||||
req.proxyElevatedRole = 'node-admin';
|
||||
}
|
||||
|
||||
// Alerts POST scoped-evidence gate: when a non-admin, non-node-admin user
|
||||
// creates a stack-scoped alert on a remote node, forward the scoped grant
|
||||
// as evidence so the remote can authorize the write. The body was already
|
||||
@@ -539,6 +594,50 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/** Max request body size for buffered settings writes (same as ALERT_PROXY_BODY_LIMIT). */
|
||||
const SETTINGS_PROXY_BODY_LIMIT = 100 * 1024;
|
||||
|
||||
/** True when the request is a settings write destined for a remote node (path is post-/api strip). */
|
||||
function isSettingsWrite(req: Request): boolean {
|
||||
if (req.method !== 'POST' && req.method !== 'PATCH') return false;
|
||||
return /^\/settings\/?$/.test(req.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub-side `node:manage` resolve against the active node so scoped Node Admin
|
||||
* grants on the target remote node are detected before the hop.
|
||||
*/
|
||||
function checkNodeManageOnHub(req: Request): boolean {
|
||||
if (typeof req.nodeId === 'number') {
|
||||
return checkPermission(req, 'node:manage', 'node', String(req.nodeId));
|
||||
}
|
||||
return checkPermission(req, 'node:manage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the set of required PermissionAction values from a buffered settings
|
||||
* body. Returns the distinct actions for a valid body, or an empty array when
|
||||
* the body is empty or JSON.parse fails (caller must then fall back to requiring
|
||||
* checkNodeManageOnHub, fail-closed).
|
||||
*/
|
||||
function settingsBodyPermissions(rawBody: Buffer): PermissionAction[] {
|
||||
if (rawBody.length === 0) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(rawBody.toString('utf-8')) as Record<string, unknown>;
|
||||
// POST /api/settings sends { key, value }; PATCH sends a flat key/value map.
|
||||
const keys = typeof parsed.key === 'string' ? [parsed.key] : Object.keys(parsed);
|
||||
if (keys.length === 0) return [];
|
||||
const needed = new Set<PermissionAction>();
|
||||
for (const key of keys) {
|
||||
const action = SETTING_WRITE_PERMISSIONS[key];
|
||||
if (action) needed.add(action);
|
||||
}
|
||||
return [...needed];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /stacks/:stackName/down with ?removeVolumes=true (path is post-/api strip). */
|
||||
function isStackDownWithRemoveVolumes(req: Request): boolean {
|
||||
if (req.method !== 'POST') return false;
|
||||
@@ -578,17 +677,6 @@ function isImageRefreshNodeWide(req: Request): boolean {
|
||||
return req.method === 'POST' && /^\/image-updates\/refresh\/?$/.test(req.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub-side node:manage resolve against the active node so scoped Node Admin
|
||||
* grants on the target remote node are detected before the hop.
|
||||
*/
|
||||
function checkNodeManageOnHub(req: Request): boolean {
|
||||
if (typeof req.nodeId === 'number') {
|
||||
return checkPermission(req, 'node:manage', 'node', String(req.nodeId));
|
||||
}
|
||||
return checkPermission(req, 'node:manage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the stack_name from a buffered JSON POST body.
|
||||
* Returns a valid stack name, `null` when the field is absent or
|
||||
|
||||
Reference in New Issue
Block a user