feat: graduate Host Console to Community admins (#1669)

* feat: graduate Host Console to Community admins

Make Host Console available to Community and Admiral admins (system:console), add host-console-community for mixed fleets, and keep opaque API tokens off the host shell.

* docs: document Host Console deep links

Cover root and stack-scoped Console URLs, correct the phone treatment note, and pin parse/build round-trips in senchoRoute tests.

* fix: bind Host Console socket to the resolved node

Treat unresolved activeNode as loading, target the WebSocket with an explicit nodeId, and wait for stack deep-link hydration so the shell cannot open on the wrong node or compose root. Add regression coverage for node/stack retargeting and fail-closed directory resolution.

* fix: harden Host Console node binding, audit acting_as, and console_session tokens

Reject unknown or malformed nodeIds before spawning a PTY. Record hub operators in audit_log.acting_as for remote console_session bridges. Path-scope and one-time-consume console_session JWTs so Host Console mints cannot open container exec or be replayed.

* test: expect acting_as in audit CSV export header

Align the CSV export assertion with the P0-2B acting_as column added to audit log exports.
This commit is contained in:
Anso
2026-07-23 12:59:53 -04:00
committed by GitHub
parent ed5ca9c4f6
commit dd54a2e483
43 changed files with 1230 additions and 199 deletions
+33 -37
View File
@@ -2,22 +2,16 @@ import type { IncomingMessage } from 'http';
import type { Duplex } from 'stream';
import WebSocket, { WebSocketServer } from 'ws';
import { FileSystemService } from '../services/FileSystemService';
import { NodeRegistry } from '../services/NodeRegistry';
import { HostTerminalService } from '../services/HostTerminalService';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import {
isLicenseTier,
normalizeTier,
} from '../services/license-normalize';
import { LicenseService } from '../services/LicenseService';
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
import { ROLE_PERMISSIONS } from '../middleware/permissions';
import type { UserRole } from '../services/DatabaseService';
import { getErrorMessage } from '../utils/errors';
import { rejectUpgrade as reject } from './reject';
import { isConsoleSessionScope } from '../helpers/consoleSession';
interface HostConsoleContext {
nodeId: number;
decoded: { scope?: string; username?: string };
decoded: { scope?: string; username?: string; acting_as?: string };
isProxyToken: boolean;
wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
stackParam: string | null;
@@ -26,15 +20,16 @@ interface HostConsoleContext {
/**
* Handle `/api/system/host-console` WebSocket upgrades.
*
* Enforces three gates before spawning the host PTY:
* Enforces two gates before spawning the host PTY:
* 1. Machine-credential rejection: node_proxy tokens cannot reach an
* interactive host shell.
* interactive host shell directly (remote forwarding mints a
* console_session via POST /console-token instead).
* 2. RBAC: user session tokens require the `system:console` permission.
* console_session tokens are pre-gated at issuance (see
* `routes/console.ts`) and skip this check.
* 3. License: host console requires the paid tier. For console_session
* tokens the tier is trusted from the gateway-supplied header;
* otherwise the local LicenseService is consulted.
*
* Path scoping and one-time jti consumption are enforced in upgradeHandler
* before this handler runs.
*/
export function handleHostConsoleWs(
req: IncomingMessage,
@@ -46,11 +41,10 @@ export function handleHostConsoleWs(
if (isProxyToken) return reject(socket, 403, 'Forbidden');
const isConsoleSession = decoded.scope === 'console_session';
const isConsoleSession = isConsoleSessionScope(decoded.scope);
if (!isConsoleSession) {
const userRole = wsResolvedUser?.role;
const consolePermission: PermissionAction = 'system:console';
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes(consolePermission)) {
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes('system:console')) {
console.log('[HostConsole] Access denied: insufficient permissions', {
username: wsResolvedUser?.username || decoded.username,
role: userRole,
@@ -59,18 +53,18 @@ export function handleHostConsoleWs(
}
}
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
const ls = LicenseService.getInstance();
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
? normalizeTier(consoleTierHeader)
: ls.getTier();
if (consoleTier !== 'paid') {
return reject(socket, 403, 'Forbidden');
}
// Option B: console_session principal stays "console_session"; hub operator
// is recorded separately as acting_as. Browser sessions use the real user.
const consoleUsername = isConsoleSession
? 'console_session'
: (wsResolvedUser?.username || decoded.username || 'unknown');
const actingAs = isConsoleSession && typeof decoded.acting_as === 'string' && decoded.acting_as
? decoded.acting_as
: null;
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
console.log('[HostConsole] WebSocket upgrade accepted', {
username: consoleUsername,
actingAs,
nodeId,
stack: stackParam || '(root)',
});
@@ -86,10 +80,6 @@ export function handleHostConsoleWs(
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
hostConsoleWss.close();
let targetDirectory: string;
// The shell may end up rooted at a different node than requested if the
// requested node's directory cannot be resolved; the audit row must name
// the node the shell actually runs in, so track it alongside the directory.
let auditNodeId: number = nodeId;
try {
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
const resolved = HostTerminalService.resolveConsoleDirectory(baseDir, stackParam);
@@ -100,22 +90,28 @@ export function handleHostConsoleWs(
}
targetDirectory = resolved;
} catch (error) {
const fallbackNodeId = NodeRegistry.getInstance().getDefaultNodeId();
console.error('[HostConsole] Failed to resolve console directory; falling back to the default node base dir', {
console.error('[HostConsole] Failed to resolve console directory', {
user: consoleUsername,
actingAs,
nodeId,
fallbackNodeId,
stack: stackParam || '(root)',
error: getErrorMessage(error, 'unknown'),
});
targetDirectory = FileSystemService.getInstance(fallbackNodeId).getBaseDir();
auditNodeId = fallbackNodeId;
if (ws.readyState === WebSocket.OPEN) {
ws.send('Error: Failed to resolve console directory.\r\n');
ws.close();
}
return;
}
const auditCtx = { username: consoleUsername, nodeId: auditNodeId, ipAddress };
const auditCtx = { username: consoleUsername, actingAs, nodeId, ipAddress };
try {
HostTerminalService.spawnTerminal(ws, targetDirectory, auditCtx);
} catch (error) {
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: getErrorMessage(error, 'unknown') });
console.error('[HostConsole] Unhandled spawn error:', {
user: consoleUsername,
actingAs,
error: getErrorMessage(error, 'unknown'),
});
if (ws.readyState === WebSocket.OPEN) {
ws.send('Error: Failed to start terminal session.\r\n');
ws.close();