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,8 +1,11 @@
import { Suspense, lazy, type ReactNode } from 'react';
import { Unplug } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { useExperimental } from '@/hooks/useExperimental';
import { PaidGate } from '../PaidGate';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { resolveHostConsoleCapability } from '@/lib/routing/hostConsoleCapability';
import { LockCard } from '../ui/LockCard';
import { CapabilityGate } from '../CapabilityGate';
import { HubOnlyGate } from '../HubOnlyGate';
import LazyBoundary from '../LazyBoundary';
@@ -17,7 +20,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
import type { ActiveView } from './hooks/useViewNavigationState';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import type { SecurityTab, FleetTab } from '@/lib/events';
import { isStackEditorDeepLink } from '@/lib/router/readUrlRouteState';
import { isStackEditorDeepLink, isHostConsoleStackDeepLink } from '@/lib/router/readUrlRouteState';
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
// Paid-tier views are loaded on demand. Their internal PaidGate /
@@ -147,7 +150,8 @@ export function ViewRouter({
quickLinkCandidates,
}: ViewRouterProps): ReactNode {
const { can } = useAuth();
const { experimental, experimentalReady } = useExperimental();
const { isPaid, licenseReady } = useLicense();
const { activeNode, activeNodeMeta } = useNodes();
if (activeView === 'settings') {
return (
<SettingsPage
@@ -184,20 +188,48 @@ export function ViewRouter({
);
}
if (activeView === 'host-console') {
// Discovery + paid/RBAC: hide until experimental discovery is on,
// then mirror backend gates (system:console admin-only + PaidGate +
// capability). Nav is already gated the same way; this stops a
// deep link from mounting a console the operator cannot use.
if (!experimentalReady || !experimental) return null;
// RBAC + mixed-version capability. Wait for a resolved active node and
// remote meta; null activeNode must not be treated as local (wrong-node
// or doomed WebSocket). Stack deep links hydrate selectedFile async:
// wait so we never open a compose-root shell, then reconnect into the stack.
if (!can('system:console')) return null;
if (urlHydratingStack != null || (isHostConsoleStackDeepLink() && !selectedFile)) {
return <ViewSkeleton />;
}
if (activeNode == null) return <ViewSkeleton />;
const capState = resolveHostConsoleCapability({
nodeResolved: true,
isRemote: activeNode.type === 'remote',
isPaid,
licenseReady,
activeNodeMeta,
});
if (capState === 'loading') return <ViewSkeleton />;
if (capState === 'locked') {
const nodeName = activeNode.name;
const version = activeNodeMeta?.version;
let versionHint = `${nodeName} does not advertise this capability.`;
if (version && version !== 'unknown' && version !== '0.0.0-dev') {
versionHint = `${nodeName} is running v${version}.`;
}
return (
<LockCard
icon={Unplug}
title="Host Console is not available on this node"
body={`${versionHint} Upgrade the node to use this feature.`}
/>
);
}
const nodeId = activeNode.id;
return (
<PaidGate>
<CapabilityGate capability="host-console" featureName="Host Console">
<LazyView>
<HostConsole stackName={selectedFile} onClose={onHostConsoleClose} />
</LazyView>
</CapabilityGate>
</PaidGate>
<LazyView>
<HostConsole
key={`${nodeId}:${selectedFile ?? ''}`}
nodeId={nodeId}
stackName={selectedFile}
onClose={onHostConsoleClose}
/>
</LazyView>
);
}
// Stack workspace: keep a loading shell while the stack URL hydrates.
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import * as AuthContext from '@/context/AuthContext';
import * as LicenseContext from '@/context/LicenseContext';
import * as NodeContext from '@/context/NodeContext';
import { ViewRouter } from '../ViewRouter';
vi.mock('@/context/AuthContext');
vi.mock('@/context/LicenseContext');
vi.mock('@/context/NodeContext');
vi.mock('../../HostConsole', () => ({
default: ({ nodeId, stackName }: { nodeId: number; stackName?: string | null }) => (
<div
data-testid="host-console"
data-node-id={String(nodeId)}
data-stack={stackName ?? ''}
>
Host Console
</div>
),
}));
vi.mock('../../LazyBoundary', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
const baseProps = {
activeView: 'host-console' as const,
selectedFile: null as string | null,
isLoading: false,
settingsSection: 'appearance' as const,
onSettingsSectionChange: vi.fn(),
onTemplateDeploySuccess: vi.fn(),
onHostConsoleClose: vi.fn(),
onFleetNavigateToNode: vi.fn(),
onOpenNodeNetworking: vi.fn(),
filterNodeId: null,
onClearScheduledOpsFilter: vi.fn(),
schedulePrefill: null,
onPrefillConsumed: vi.fn(),
muteRulePrefill: null,
onMutePrefillConsumed: vi.fn(),
notifications: [] as [],
onNavigateToStack: vi.fn(),
onOpenSettingsSection: vi.fn(),
onClearNotifications: vi.fn(),
securityTab: 'overview' as const,
onSecurityTabChange: vi.fn(),
renderEditor: () => null,
stackUpdates: {},
urlHydratingStack: null as string | null,
isFileLoading: false,
quickLinkCandidates: [],
};
describe('ViewRouter host-console', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/nodes/local/host-console');
vi.mocked(AuthContext.useAuth).mockReturnValue({
can: (p: string) => p === 'system:console',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseReady: true,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 1, name: 'Local', type: 'local' },
activeNodeMeta: { version: '0.96.0', capabilities: ['host-console', 'host-console-community'], fetchedAt: 1 },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
});
afterEach(() => {
window.history.replaceState({}, '', '/');
});
it('renders Host Console for a Community admin on the local node', async () => {
render(<ViewRouter {...baseProps} />);
const el = await screen.findByTestId('host-console');
expect(el.getAttribute('data-node-id')).toBe('1');
});
it('does not mount HostConsole while the active node is unresolved', () => {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: null,
activeNodeMeta: null,
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(screen.queryByTestId('host-console')).toBeNull();
});
it('does not mount HostConsole while a stack deep link is still hydrating', () => {
render(<ViewRouter {...baseProps} urlHydratingStack="radarr" selectedFile={null} />);
expect(screen.queryByTestId('host-console')).toBeNull();
});
it('does not mount a root shell while the URL targets a stack-scoped Console', () => {
window.history.replaceState({}, '', '/nodes/local/host-console/radarr');
render(<ViewRouter {...baseProps} selectedFile={null} urlHydratingStack={null} />);
expect(screen.queryByTestId('host-console')).toBeNull();
});
it('mounts stack-scoped Console only after selectedFile matches the route', async () => {
window.history.replaceState({}, '', '/nodes/local/host-console/radarr');
render(<ViewRouter {...baseProps} selectedFile="radarr" />);
const el = await screen.findByTestId('host-console');
expect(el.getAttribute('data-stack')).toBe('radarr');
expect(el.getAttribute('data-node-id')).toBe('1');
});
it('renders nothing without system:console', () => {
vi.mocked(AuthContext.useAuth).mockReturnValue({
can: () => false,
} as unknown as ReturnType<typeof AuthContext.useAuth>);
const { container } = render(<ViewRouter {...baseProps} />);
expect(container.querySelector('[data-testid="host-console"]')).toBeNull();
});
it('shows a skeleton while remote metadata is loading (does not mount HostConsole)', () => {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
activeNodeMeta: null,
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(screen.queryByTestId('host-console')).toBeNull();
});
it('shows a lock card for Community + legacy remote without mounting HostConsole', () => {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(screen.queryByTestId('host-console')).toBeNull();
expect(screen.getByText(/Host Console is not available on this node/i)).toBeTruthy();
});
it('mounts Host Console for Admiral + legacy remote after meta resolves', async () => {
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: true,
licenseReady: true,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
const el = await screen.findByTestId('host-console');
expect(el.getAttribute('data-node-id')).toBe('2');
});
it('shows a skeleton for legacy-only remote while license is still loading', () => {
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseReady: false,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(screen.queryByTestId('host-console')).toBeNull();
expect(screen.queryByText(/Host Console is not available on this node/i)).toBeNull();
});
it('mounts Host Console for Community + host-console-community remote', async () => {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 3, name: 'NewPeer', type: 'remote' },
activeNodeMeta: {
version: '0.96.0',
capabilities: ['host-console', 'host-console-community'],
fetchedAt: 1,
},
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(await screen.findByTestId('host-console')).toBeTruthy();
});
});
@@ -51,7 +51,7 @@ function mockDeployer() {
function mockPaidAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'system:audit' || p === 'node:read',
can: (p: string) => p === 'system:audit' || p === 'system:console' || p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
@@ -63,7 +63,7 @@ function mockPaidAdmin() {
function mockCommunityAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'node:read',
can: (p: string) => p === 'system:console' || p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
@@ -269,13 +269,13 @@ describe('useViewNavigationState', () => {
expect(result.current.navItems.map(i => i.value)).toContain('global-observability');
});
it('shows Update and Schedules for a community admin (now free) but hides paid Console and Audit', () => {
it('shows Update, Schedules, and Console for a community admin; Audit stays paid', () => {
mockCommunityAdmin();
const { result } = renderHook(() => useViewNavigationState());
const values = result.current.navItems.map(i => i.value);
expect(values).toContain('auto-updates');
expect(values).toContain('scheduled-ops');
expect(values).not.toContain('host-console');
expect(values).toContain('host-console');
expect(values).not.toContain('audit-log');
// The auto-updates nav item surfaces under the short label "Update".
expect(result.current.navItems.find(i => i.value === 'auto-updates')?.label).toBe('Update');
@@ -429,53 +429,29 @@ describe('useViewNavigationState', () => {
expect(result.current.securityTab).toBe('overview');
});
// ── experimental discovery ─────────────────────────────────────────────────
// ── host-console discovery (no longer experimental) ────────────────────────
it('hides Console from nav for a paid admin when experimental discovery is off', () => {
it('keeps Console in nav for a paid admin when experimental discovery is off', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
const { result } = renderHook(() => useViewNavigationState());
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
expect(result.current.navItems.map(i => i.value)).toContain('host-console');
});
it('hides Console from nav while experimental metadata is still loading', () => {
it('keeps Console in nav while experimental metadata is still loading', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
const { result } = renderHook(() => useViewNavigationState());
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
expect(result.current.navItems.map(i => i.value)).toContain('host-console');
});
it('does not normalize a host-console deep link before experimental readiness', () => {
it('keeps a host-console deep link selected when experimental is off', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
const onNavigateToDashboard = vi.fn();
const { result } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
act(() => result.current.setActiveView('host-console'));
expect(result.current.activeView).toBe('host-console');
expect(onNavigateToDashboard).not.toHaveBeenCalled();
});
it('keeps host-console selected when delayed experimental resolves enabled', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
const onNavigateToDashboard = vi.fn();
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
act(() => result.current.setActiveView('host-console'));
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
rerender();
expect(result.current.activeView).toBe('host-console');
expect(onNavigateToDashboard).not.toHaveBeenCalled();
});
it('normalizes host-console once when experimental resolves disabled', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
const onNavigateToDashboard = vi.fn();
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
act(() => result.current.setActiveView('host-console'));
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
rerender();
expect(result.current.activeView).toBe('dashboard');
expect(onNavigateToDashboard).toHaveBeenCalled();
});
});