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
+7
View File
@@ -16,6 +16,7 @@ export const CAPABILITIES = [
'notification-suppression',
'notification-suppression-schedule',
'host-console',
'host-console-community',
'container-exec',
'audit-log',
'scheduled-ops',
@@ -40,6 +41,12 @@ export const CAPABILITIES = [
export type Capability = (typeof CAPABILITIES)[number];
/** Legacy Host Console advertisement (Admiral hubs still accept this on remotes). */
export const HOST_CONSOLE_CAPABILITY = 'host-console' as const satisfies Capability;
/** Host Console works without a paid license on this node. */
export const HOST_CONSOLE_COMMUNITY_CAPABILITY = 'host-console-community' as const satisfies Capability;
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability;
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
@@ -77,19 +77,31 @@ describe('buildNavigationModel', () => {
expect(values).not.toContain('audit-log');
});
it('omits Console until experimental discovery is ready and enabled via reachCtx only', () => {
it('includes Console for system:console regardless of experimental discovery', () => {
expect(
buildNavigationModel(makeCtx({ experimentalReady: false, experimental: false }))
.allPageItems.map((i) => i.value),
).not.toContain('host-console');
expect(
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: false }))
.allPageItems.map((i) => i.value),
).not.toContain('host-console');
expect(
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: true }))
buildNavigationModel(makeCtx({
experimentalReady: true,
experimental: false,
isPaid: false,
can: (a) => a === 'system:console' || a === 'node:read',
}))
.allPageItems.map((i) => i.value),
).toContain('host-console');
expect(
buildNavigationModel(makeCtx({
experimentalReady: false,
experimental: false,
can: (a) => a === 'system:console' || a === 'node:read',
}))
.allPageItems.map((i) => i.value),
).toContain('host-console');
});
it('omits Console without system:console', () => {
expect(
buildNavigationModel(makeCtx({ can: () => false, isAdmin: false }))
.allPageItems.map((i) => i.value),
).not.toContain('host-console');
});
it('excludes hidden views from quick-link candidates', () => {
@@ -28,12 +28,6 @@ function isVisuallyDiscoverable(item: AppNavItem, reachCtx: ReachabilityContext)
// Settings is always discoverable in the launcher when the operator can open Settings.
return true;
}
// Console: fail-closed visual discovery until /meta settles and the flag is on.
// URL normalization still uses isViewHidden cold-load deferral separately.
if (item.value === 'host-console') {
if (!reachCtx.experimentalReady || !reachCtx.experimental) return false;
return !isViewHidden(item.value, reachCtx);
}
return !isViewHidden(item.value, reachCtx);
}
+13 -3
View File
@@ -19,11 +19,21 @@ const DEFAULT: UrlRouteState = {
filterNodeId: null,
};
/** True when the current URL is a stack workspace deep link (detail or editor). */
export function isStackEditorDeepLink(): boolean {
/** True when the URL is a stack-scoped deep link for the given view. */
function isStackScopedDeepLink(view: ActiveView): boolean {
if (typeof window === 'undefined') return false;
const parsed = parsePath(window.location.pathname, window.location.search);
return parsed.view === 'editor' && parsed.stackName != null;
return parsed.view === view && parsed.stackName != null;
}
/** True when the current URL is a stack workspace deep link (detail or editor). */
export function isStackEditorDeepLink(): boolean {
return isStackScopedDeepLink('editor');
}
/** True when the URL targets Host Console rooted in a stack directory. */
export function isHostConsoleStackDeepLink(): boolean {
return isStackScopedDeepLink('host-console');
}
/** Read shell navigation fields from the current browser URL (cold-load bootstrap). */
@@ -165,4 +165,21 @@ describe('senchoRoute', () => {
expect(parsed.view).toBe('networking');
expect(parsed.nodeSlug).toBe('local');
});
it('round-trips Host Console without a stack', () => {
const path = buildPath({ ...base, activeView: 'host-console', stackName: null });
expect(path).toBe('/nodes/local/host-console');
const parsed = parsePath(path, '');
expect(parsed.view).toBe('host-console');
expect(parsed.nodeSlug).toBe('local');
expect(parsed.stackName).toBeNull();
});
it('round-trips Host Console rooted in a stack directory', () => {
const path = buildPath({ ...base, activeView: 'host-console', stackName: 'radarr' });
expect(path).toBe('/nodes/local/host-console/radarr');
const parsed = parsePath(path, '');
expect(parsed.view).toBe('host-console');
expect(parsed.stackName).toBe('radarr');
});
});
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import { resolveHostConsoleCapability } from './hostConsoleCapability';
describe('resolveHostConsoleCapability', () => {
it('returns loading when the active node is unresolved', () => {
expect(resolveHostConsoleCapability({
nodeResolved: false,
isRemote: false,
isPaid: false,
licenseReady: true,
activeNodeMeta: null,
})).toBe('loading');
});
it('allows local nodes without waiting for meta', () => {
expect(resolveHostConsoleCapability({
nodeResolved: true,
isRemote: false,
isPaid: false,
licenseReady: true,
activeNodeMeta: null,
})).toBe('allowed');
});
it('returns loading when remote meta is absent', () => {
expect(resolveHostConsoleCapability({
nodeResolved: true,
isRemote: true,
isPaid: false,
licenseReady: true,
activeNodeMeta: null,
})).toBe('loading');
});
it('allows Community when remote advertises host-console-community', () => {
expect(resolveHostConsoleCapability({
nodeResolved: true,
isRemote: true,
isPaid: false,
licenseReady: true,
activeNodeMeta: { capabilities: ['host-console', 'host-console-community'] },
})).toBe('allowed');
});
it('locks Community when remote only has legacy host-console', () => {
expect(resolveHostConsoleCapability({
nodeResolved: true,
isRemote: true,
isPaid: false,
licenseReady: true,
activeNodeMeta: { capabilities: ['host-console'] },
})).toBe('locked');
});
it('allows Admiral when remote only has legacy host-console', () => {
expect(resolveHostConsoleCapability({
nodeResolved: true,
isRemote: true,
isPaid: true,
licenseReady: true,
activeNodeMeta: { capabilities: ['host-console'] },
})).toBe('allowed');
});
it('returns loading for legacy-only remote while license is not ready', () => {
expect(resolveHostConsoleCapability({
nodeResolved: true,
isRemote: true,
isPaid: false,
licenseReady: false,
activeNodeMeta: { capabilities: ['host-console'] },
})).toBe('loading');
});
it('allows community-capable remote without waiting on license', () => {
expect(resolveHostConsoleCapability({
nodeResolved: true,
isRemote: true,
isPaid: false,
licenseReady: false,
activeNodeMeta: { capabilities: ['host-console-community'] },
})).toBe('allowed');
});
it('locks Pilot / empty capability lists', () => {
expect(resolveHostConsoleCapability({
nodeResolved: true,
isRemote: true,
isPaid: true,
licenseReady: true,
activeNodeMeta: { capabilities: ['stacks', 'fleet'] },
})).toBe('locked');
});
});
@@ -0,0 +1,49 @@
import {
HOST_CONSOLE_CAPABILITY,
HOST_CONSOLE_COMMUNITY_CAPABILITY,
} from '@/lib/capabilities';
export type HostConsoleCapabilityState = 'loading' | 'allowed' | 'locked';
export interface HostConsoleCapabilityInput {
/** False until NodeContext resolves an active node (cold load). Never treat as local. */
nodeResolved: boolean;
/** True when the resolved active node is a remote Distributed API Proxy or Pilot node. */
isRemote: boolean;
/** Hub license: Admiral may accept legacy `host-console` on remotes. */
isPaid: boolean;
/**
* False while LicenseContext is still loading. Legacy-remote allowance
* must wait so a cold load does not flash LockCard as Community.
*/
licenseReady: boolean;
/**
* Cached `/api/meta` for the active node. Null means metadata has not been
* fetched yet (or the node is unresolved). Must not be confused with
* optimistic `hasCapability()` which returns true while meta is absent.
*/
activeNodeMeta: { capabilities: readonly string[] } | null;
}
/**
* Whether Host Console content may mount for the active node.
*
* Unresolved nodes stay in `loading`. Local nodes are treated as compatible
* once RBAC passed (same build). Remote nodes wait for metadata, then require
* `host-console-community`, or (Admiral only) legacy `host-console`.
*/
export function resolveHostConsoleCapability(
input: HostConsoleCapabilityInput,
): HostConsoleCapabilityState {
const { nodeResolved, isRemote, isPaid, licenseReady, activeNodeMeta } = input;
if (!nodeResolved) return 'loading';
if (!isRemote) return 'allowed';
if (!activeNodeMeta) return 'loading';
const caps = activeNodeMeta.capabilities;
if (caps.includes(HOST_CONSOLE_COMMUNITY_CAPABILITY)) return 'allowed';
if (!caps.includes(HOST_CONSOLE_CAPABILITY)) return 'locked';
// Legacy host-console only: Admiral hubs may open it; wait for license first.
if (!licenseReady) return 'loading';
return isPaid ? 'allowed' : 'locked';
}
+14 -14
View File
@@ -49,25 +49,25 @@ describe('reachability', () => {
expect(isViewHidden('fleet', noFleet)).toBe(true);
});
it('preserves paid views when license metadata failed', () => {
const licenseError = ctx({ licenseStatus: 'error', experimental: true });
it('preserves host-console when authz is not ready', () => {
const licenseError = ctx({ licenseStatus: 'error', can: (a) => a === 'system:console' });
expect(isViewHidden('host-console', licenseError)).toBe(false);
});
it('does not apply experimental hide to host-console until experimentalReady', () => {
const loading = ctx({ experimental: false, experimentalReady: false, isPaid: true, isAdmin: true });
expect(isViewHidden('host-console', loading)).toBe(false);
it('hides host-console without system:console when ready', () => {
const noConsole = ctx({ can: () => false, isPaid: false, experimental: false });
expect(isViewHidden('host-console', noConsole)).toBe(true);
expect(normalizeHiddenView('host-console', noConsole)).toBe('dashboard');
});
it('hides host-console when experimental is ready and off even for paid admin', () => {
const off = ctx({ experimental: false, experimentalReady: true, isPaid: true, isAdmin: true });
expect(isViewHidden('host-console', off)).toBe(true);
expect(normalizeHiddenView('host-console', off)).toBe('dashboard');
});
it('keeps host-console when experimental is on for paid admin', () => {
const on = ctx({ experimental: true, experimentalReady: true, isPaid: true, isAdmin: true });
expect(isViewHidden('host-console', on)).toBe(false);
it('keeps host-console for system:console regardless of tier or experimental', () => {
const community = ctx({
isPaid: false,
experimental: false,
experimentalReady: true,
can: (a) => a === 'system:console',
});
expect(isViewHidden('host-console', community)).toBe(false);
});
it('hides routing and secrets fleet tabs only after experimentalReady when off', () => {
+2 -6
View File
@@ -43,11 +43,7 @@ export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolea
if (!ctx.isAdmin && (view === 'auto-updates' || view === 'scheduled-ops')) return true;
if (!ctx.can('node:read') && view === 'fleet') return true;
if (view === 'host-console') {
// Defer experimental hide until ready so enabled deep links survive cold load.
if (experimentalDiscoveryReady(ctx) && !ctx.experimental) return true;
if (!ctx.isPaid) return true;
if (!ctx.isAdmin) return true;
return false;
return !ctx.can('system:console');
}
if (!ctx.isPaid) {
if (view === 'audit-log') return true;
@@ -67,7 +63,7 @@ export function isViewCapabilityLocked(view: ActiveView, ctx: ReachabilityContex
export function isFleetTabHidden(tab: FleetTab, ctx: ReachabilityContext): boolean {
if (!authzReady(ctx)) return false;
if (tab === 'container-labels' && !ctx.containerLabelsEnabled) return true;
// Defer experimental hide until ready (same cold-load contract as host-console).
// Defer experimental hide until ready so deep links survive cold load.
if ((tab === 'routing' || tab === 'secrets') && experimentalDiscoveryReady(ctx) && !ctx.experimental) {
return true;
}