mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user