mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
dd54a2e483
* 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.
77 lines
3.9 KiB
TypeScript
77 lines
3.9 KiB
TypeScript
/**
|
|
* Unit coverage for the remote-WebSocket forward gate. The hub must enforce the
|
|
* originating user's role before forwarding a remote upgrade, because the
|
|
* forwarder authenticates the forwarded connection to the remote as an
|
|
* admin-gated console_session. Logs and notifications stay open to any
|
|
* authenticated user; every other path is an interactive terminal (container
|
|
* exec / host console) and is admin-only on the hub.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { remoteWsForwardAllowed } from '../websocket/upgradeHandler';
|
|
|
|
const viewer = { role: 'viewer' as const };
|
|
const admin = { role: 'admin' as const };
|
|
const base = { wsResolvedUser: undefined, wsApiTokenScope: null, isProxyToken: false, decoded: {} };
|
|
|
|
const LOGS = '/api/stacks/web/logs';
|
|
const NOTIF = '/ws/notifications';
|
|
const EXEC = '/ws';
|
|
const CONSOLE = '/api/system/host-console';
|
|
|
|
describe('remoteWsForwardAllowed', () => {
|
|
it('allows logs and notifications for any authenticated user', () => {
|
|
for (const p of [LOGS, NOTIF]) {
|
|
expect(remoteWsForwardAllowed(p, { ...base, wsResolvedUser: viewer })).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('denies interactive exec/console to every non-admin role (none holds system:console)', () => {
|
|
for (const role of ['viewer', 'deployer', 'node-admin', 'auditor'] as const) {
|
|
for (const p of [EXEC, CONSOLE]) {
|
|
expect(remoteWsForwardAllowed(p, { ...base, wsResolvedUser: { role } })).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('allows interactive exec/console to an admin user session', () => {
|
|
for (const p of [EXEC, CONSOLE]) {
|
|
expect(remoteWsForwardAllowed(p, { ...base, wsResolvedUser: admin })).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('gates host console on system:console, not merely role==admin (parity with handleHostConsoleWs)', async () => {
|
|
// node-admin is the meaningful case: broad stack/node permissions but no
|
|
// system:console, so it must be denied the remote host console.
|
|
const { ROLE_PERMISSIONS } = await import('../middleware/permissions');
|
|
for (const role of ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'] as const) {
|
|
const expected = ROLE_PERMISSIONS[role].includes('system:console');
|
|
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsResolvedUser: { role } })).toBe(expected);
|
|
}
|
|
});
|
|
|
|
it('denies an interactive path to a node_proxy (machine) token', () => {
|
|
expect(remoteWsForwardAllowed(EXEC, { ...base, isProxyToken: true, decoded: { scope: 'node_proxy' } })).toBe(false);
|
|
});
|
|
|
|
it('allows an interactive path for a full-admin api token but denies a restricted scope', () => {
|
|
expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(true);
|
|
expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false);
|
|
});
|
|
|
|
it('denies every API-token scope for Host Console while preserving full-admin for container exec', () => {
|
|
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(false);
|
|
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false);
|
|
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'read-only', decoded: { scope: 'api_token' } })).toBe(false);
|
|
expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(true);
|
|
});
|
|
|
|
it('allows an interactive path for a pre-gated console_session token', () => {
|
|
expect(remoteWsForwardAllowed(CONSOLE, { ...base, decoded: { scope: 'console_session' } })).toBe(true);
|
|
});
|
|
|
|
it('treats an unknown path as interactive (admin-only)', () => {
|
|
expect(remoteWsForwardAllowed('/ws/unknown', { ...base, wsResolvedUser: viewer })).toBe(false);
|
|
expect(remoteWsForwardAllowed('/ws/unknown', { ...base, wsResolvedUser: admin })).toBe(true);
|
|
});
|
|
});
|