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
+1 -1
View File
@@ -103,7 +103,7 @@ auditLogRouter.get('/export', async (req: Request, res: Response): Promise<void>
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.csv"`);
const headers = ['id', 'timestamp', 'username', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
const headers = ['id', 'timestamp', 'username', 'acting_as', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
const rows = result.entries.map(e =>
headers.map(h => escapeCsvField(e[h as keyof typeof e])).join(','),
);
+21 -4
View File
@@ -1,8 +1,12 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { requireAdmin } from '../middleware/tierGates';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { mintConsoleSession } from '../helpers/consoleSession';
import {
isConsoleSessionPath,
mintConsoleSession,
sanitizeActingAs,
} from '../helpers/consoleSession';
/**
* Mint a short-lived `console_session` JWT. Used by the gateway when it
@@ -11,15 +15,28 @@ import { mintConsoleSession } from '../helpers/consoleSession';
* remote's upgrade handler on interactive paths, so the gateway authenticates
* with the long-lived token, asks for this short-lived one, then forwards
* the WS upgrade using it.
*
* Body:
* - `path` (required): `host-console` | `container-exec`
* - `acting_as` (optional): hub operator for remote audit. On node_proxy mints
* the body value is used (sanitized). On browser admin mints the signed-in
* username is stamped so a Bearer console_session cannot erase attribution.
*/
export const consoleRouter = Router();
consoleRouter.post('/console-token', authMiddleware, (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, 'API tokens cannot generate console tokens.')) return;
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
res.json({ token: mintConsoleSession() });
const path = req.body?.path;
if (!isConsoleSessionPath(path)) {
res.status(400).json({ error: 'Invalid or missing console path' });
return;
}
const actingAs = req.machineAuthScope === 'node_proxy'
? sanitizeActingAs(req.body?.acting_as)
: sanitizeActingAs(req.user?.username);
res.json({ token: mintConsoleSession({ path, actingAs }) });
} catch (error) {
console.error('Failed to issue console token:', error);
res.status(500).json({ error: 'Failed to issue console token' });