Hide admin operations from public demo

This commit is contained in:
rcourtman
2026-04-11 17:20:58 +01:00
parent fb4fb55461
commit b33cc3ac60
17 changed files with 299 additions and 43 deletions
@@ -432,6 +432,11 @@ needs them, but `/api/license/commercial-posture`,
public demo mode and those lifecycle flows must not depend on licensed
identity, plan labels, upgrade reasons, checkout handoff state, or observed
usage counts surviving the public-demo contract.
That same demo-hidden API boundary also keeps runtime-admin operations out of
public lifecycle flows: `/api/diagnostics`,
`/api/diagnostics/docker/prepare-token`, and `/api/logs/*` must return `404`
in demo mode instead of exposing runtime bundles, log streams, or diagnostics
payloads through a nominally read-only preview account.
Lifecycle-owned browser shells must also defer any commercial helper reads
until that presentation policy resolves so demo suppression stays fail-closed
during first render instead of racing hidden commercial endpoints from shared
@@ -228,6 +228,10 @@ organization chrome: `frontend-modern/src/App.tsx` and
`frontend-modern/src/AppLayout.tsx` may hide org switchers or demo-only org
labels, but they must not couple assistant visibility, session reset, or
drawer-open behavior to that organization presentation state.
That same shell boundary also owns demo-only Operations suppression:
`frontend-modern/src/AppLayout.tsx` may remove the top-level Operations route
from the public demo shell, but assistant availability and reset behavior must
stay independent of that utility-tab presentation choice.
Authenticated `/login` recovery belongs to that same route shell boundary:
once login succeeds, `frontend-modern/src/App.tsx` must resolve `/login`
through the canonical post-auth landing route instead of leaving the
@@ -1348,6 +1348,12 @@ That same diagnostics boundary must also backfill canonical fallback reasons
when a raw snapshot reaches the API layer without one, so
`buildMemorySourceDiagnostics` stays self-consistent even if a caller bypasses
`GetDiagnosticSnapshots()` and hands diagnostics a legacy alias directly.
That same public-demo API boundary must also hide runtime-admin operations
surfaces instead of treating them as harmless reads. Demo sessions must receive
`404` for `/api/diagnostics`, `/api/diagnostics/docker/prepare-token`, and the
shared `/api/logs/*` endpoints, so the preview shell cannot expose runtime
diagnostics, log streams, or downloadable log bundles behind a supposedly
read-only demo account.
That shared `InfrastructureOperationsController.tsx` boundary now also preserves copied shell
command payload continuity: any privilege-escalation wrapper applied at the
settings surface must keep the full canonical installer argument list intact
@@ -228,6 +228,10 @@ trial urgency, observed usage counts, or checkout handoff state. The
commercial posture store and billing-entitlements store must also fail closed
locally until the shared presentation policy resolves, then stay fail-closed
in demo mode so hidden routes are not probed from the browser shell.
That same browser-shell boundary also owns utility-nav suppression:
`frontend-modern/src/AppLayout.tsx` must drop the top-level Operations tab in
public demo mode instead of leaving diagnostics or system-log shells
discoverable after commercial surfaces are hidden.
Deep-linkable commercial panels must consume the same resolved presentation
policy directly, not rely only on settings navigation to keep public-demo
browsers away from commercial routes. `ProLicensePanel` may instantiate its
@@ -179,6 +179,11 @@ work extends shared components instead of creating new local variants.
posture: `/alerts` may continue exposing reporting tabs such as overview and
history, but activation controls plus configuration routes must collapse out
of the public-demo shell instead of advertising blocked management actions.
That same public-demo presentation boundary also owns top-level Operations
posture: the authenticated demo shell must not advertise the Operations
utility tab, and `/operations` deep links must hand back to the dashboard
instead of surfacing diagnostics or system-log chrome that the backend hides
for demo sessions.
3. Add feature-specific presentation only when no shared primitive should own it
4. Add guardrail tests when a new shared pattern is introduced
5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary.
@@ -1183,7 +1188,11 @@ the tabbed operations surface, and
`frontend-modern/src/features/operations/operationsPageModel.ts` owns the tab
and path contract. The operations route must keep its navigation routed through
the shared `frontend-modern/src/components/shared/Subtabs.tsx` primitive rather
than rebuilding a bespoke page-local tab bar.
than rebuilding a bespoke page-local tab bar. When the session presentation
policy marks the operator as a public demo viewer, that same route owner must
suppress the surface entirely and hand the browser back to the canonical
dashboard route instead of rendering diagnostics, reporting, or logs shells
that are unavailable in demo mode.
The dashboard overview route now follows that same feature-owner pattern for
its dashboard-specific summary surfaces. `frontend-modern/src/pages/Dashboard.tsx`
@@ -104,6 +104,11 @@ querying, and the operator-facing storage health presentation layer.
payloads must read through `GetUnifiedReadStateOrSnapshot()` so storage and
recovery consumers stay aligned with the canonical mock unified snapshot
instead of slipping onto the live store graph.
That same `internal/api/` demo boundary must keep runtime-admin operations
hidden from public preview sessions: `/api/diagnostics`,
`/api/diagnostics/docker/prepare-token`, and `/api/logs/*` must not remain
readable side channels while storage or recovery demo routes are otherwise
presented as read-only product surfaces.
Storage and recovery consumers must also inherit the hook's canonical
`ResourceType` normalization for route/query filters, so storage subtypes
such as `physical_disk` stay on the same cache-backed snapshot instead of
+10 -4
View File
@@ -40,7 +40,10 @@ import { getKioskModePreference, setKioskMode } from '@/utils/url';
import { updateStore } from '@/stores/updates';
import { aiChatStore } from '@/stores/aiChat';
import { isPro } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import {
presentationPolicyHidesUpgradePrompts,
presentationPolicyIsDemoMode,
} from '@/stores/sessionPresentationPolicy';
import type { AppConnectionStatus } from '@/useAppRuntimeState';
const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath();
@@ -422,7 +425,10 @@ export function AppLayout(props: AppLayoutProps) {
breakdown: undefined,
icon: <PulsePatrolLogo class="w-4 h-4 shrink-0" />,
},
{
];
if (!presentationPolicyIsDemoMode()) {
tabs.push({
id: 'operations',
label: 'Operations',
route: '/operations',
@@ -431,8 +437,8 @@ export function AppLayout(props: AppLayoutProps) {
count: undefined,
breakdown: undefined,
icon: <ActivityIcon class="w-4 h-4 shrink-0" />,
},
];
});
}
if (hasSettingsAccess) {
tabs.push({
@@ -75,6 +75,8 @@ describe('App architecture', () => {
expect(appLayoutSource).not.toContain('sessionPresentationPolicyResolved');
expect(appLayoutSource).not.toContain('presentationPolicyHidesCommercialSurfaces');
expect(appLayoutSource).not.toContain('presentationPolicyHidesOrganizationSurfaces');
expect(appLayoutSource).toContain('presentationPolicyIsDemoMode');
expect(appLayoutSource).toContain("if (!presentationPolicyIsDemoMode()) {");
expect(appLayoutSource).toContain(
'aiChatStore.enabled === true && !aiChatStore.isOpenSignal() && !kioskMode()',
);
@@ -1,4 +1,4 @@
import { Suspense, createMemo, type Component, type JSX } from 'solid-js';
import { Show, Suspense, createEffect, createMemo, type Component, type JSX } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import ActivityIcon from 'lucide-solid/icons/activity';
import FileTextIcon from 'lucide-solid/icons/file-text';
@@ -7,9 +7,12 @@ import { DiagnosticsPanel } from '@/components/Settings/DiagnosticsPanel';
import { ReportingPanel } from '@/components/Settings/ReportingPanel';
import { SystemLogsPanel } from '@/components/Settings/SystemLogsPanel';
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
import { DASHBOARD_PATH } from '@/routing/resourceLinks';
import { presentationPolicyIsDemoMode } from '@/stores/sessionPresentationPolicy';
import {
buildOperationsPath,
getOperationsTabFromPath,
operationsSurfaceHiddenInDemoMode,
OPERATIONS_TABS,
type OperationsTabId,
} from '@/features/operations/operationsPageModel';
@@ -24,21 +27,32 @@ export function OperationsPageSurface() {
const location = useLocation();
const navigate = useNavigate();
const hiddenInDemoMode = createMemo(() =>
operationsSurfaceHiddenInDemoMode(presentationPolicyIsDemoMode()),
);
const activeTab = createMemo(() => getOperationsTabFromPath(location.pathname));
createEffect(() => {
if (hiddenInDemoMode()) {
navigate(DASHBOARD_PATH, { replace: true });
}
});
const tabs = createMemo<SubtabOption[]>(() =>
OPERATIONS_TABS.map((tab) => {
const Icon = operationsTabIcons[tab.id];
return {
value: tab.id,
label: (
<span class="inline-flex items-center gap-2.5" title={tab.description}>
<Icon class="h-4 w-4" />
<span>{tab.label}</span>
</span>
) satisfies JSX.Element,
};
}),
hiddenInDemoMode()
? []
: OPERATIONS_TABS.map((tab) => {
const Icon = operationsTabIcons[tab.id];
return {
value: tab.id,
label: (
<span class="inline-flex items-center gap-2.5" title={tab.description}>
<Icon class="h-4 w-4" />
<span>{tab.label}</span>
</span>
) satisfies JSX.Element,
};
}),
);
const handleTabChange = (tabId: string) => {
@@ -46,33 +60,35 @@ export function OperationsPageSurface() {
};
return (
<div class="space-y-6">
<div class="mb-6">
<Subtabs
value={activeTab()}
onChange={handleTabChange}
tabs={tabs()}
ariaLabel="Operations"
class="rounded-md border border-border bg-surface-alt p-1.5 sm:w-max"
listClass="gap-2 overflow-x-auto scrollbar-hide"
tabClass="min-h-10 whitespace-nowrap rounded-md border border-transparent px-4 py-2 text-sm"
/>
</div>
<Show when={!hiddenInDemoMode()}>
<div class="space-y-6">
<div class="mb-6">
<Subtabs
value={activeTab()}
onChange={handleTabChange}
tabs={tabs()}
ariaLabel="Operations"
class="rounded-md border border-border bg-surface-alt p-1.5 sm:w-max"
listClass="gap-2 overflow-x-auto scrollbar-hide"
tabClass="min-h-10 whitespace-nowrap rounded-md border border-transparent px-4 py-2 text-sm"
/>
</div>
<div class="mt-4 animate-fade-in animate-duration-200">
<Suspense
fallback={
<div class="flex justify-center p-6">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
</div>
}
>
{activeTab() === 'diagnostics' && <DiagnosticsPanel />}
{activeTab() === 'reporting' && <ReportingPanel />}
{activeTab() === 'logs' && <SystemLogsPanel />}
</Suspense>
<div class="mt-4 animate-fade-in animate-duration-200">
<Suspense
fallback={
<div class="flex justify-center p-6">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
</div>
}
>
{activeTab() === 'diagnostics' && <DiagnosticsPanel />}
{activeTab() === 'reporting' && <ReportingPanel />}
{activeTab() === 'logs' && <SystemLogsPanel />}
</Suspense>
</div>
</div>
</div>
</Show>
);
}
@@ -0,0 +1,74 @@
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OperationsPageSurface } from '@/features/operations/OperationsPageSurface';
const navigateSpy = vi.hoisted(() => vi.fn());
const presentationPolicyIsDemoModeMock = vi.hoisted(() => vi.fn(() => false));
const locationState = vi.hoisted(() => ({
pathname: '/operations',
hash: '',
search: '',
query: {},
}));
vi.mock('@solidjs/router', async () => {
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
return {
...actual,
useLocation: () => locationState,
useNavigate: () => navigateSpy,
};
});
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyIsDemoMode: () => presentationPolicyIsDemoModeMock(),
}));
vi.mock('@/components/Settings/DiagnosticsPanel', () => ({
DiagnosticsPanel: () => <div data-testid="diagnostics-panel">Diagnostics</div>,
}));
vi.mock('@/components/Settings/ReportingPanel', () => ({
ReportingPanel: () => <div data-testid="reporting-panel">Reporting</div>,
}));
vi.mock('@/components/Settings/SystemLogsPanel', () => ({
SystemLogsPanel: () => <div data-testid="system-logs-panel">Logs</div>,
}));
describe('OperationsPageSurface demo mode', () => {
beforeEach(() => {
cleanup();
navigateSpy.mockReset();
presentationPolicyIsDemoModeMock.mockReset();
presentationPolicyIsDemoModeMock.mockReturnValue(false);
locationState.pathname = '/operations';
locationState.hash = '';
locationState.search = '';
});
afterEach(() => cleanup());
it('redirects demo sessions back to the dashboard and hides operations chrome', async () => {
presentationPolicyIsDemoModeMock.mockReturnValue(true);
render(() => <OperationsPageSurface />);
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/dashboard', { replace: true });
});
expect(screen.queryByText('Diagnostics & Health')).not.toBeInTheDocument();
expect(screen.queryByTestId('diagnostics-panel')).not.toBeInTheDocument();
});
it('keeps operations tabs available outside demo mode', async () => {
render(() => <OperationsPageSurface />);
await waitFor(() => {
expect(screen.getByText('Diagnostics & Health')).toBeInTheDocument();
});
expect(screen.getByTestId('diagnostics-panel')).toBeInTheDocument();
expect(navigateSpy).not.toHaveBeenCalledWith('/dashboard', { replace: true });
});
});
@@ -24,6 +24,10 @@ export const OPERATIONS_TABS: readonly OperationsTabDefinition[] = [
},
];
export function operationsSurfaceHiddenInDemoMode(demoMode: boolean): boolean {
return demoMode;
}
export function getOperationsTabFromPath(pathname: string): OperationsTabId {
const lastPathSegment = pathname.split('/').pop() || '';
if (lastPathSegment === 'reporting') return 'reporting';
@@ -17,8 +17,10 @@ describe('operations page route shell', () => {
expect(operationsPageSurfaceSource).toContain('@/components/shared/Subtabs');
expect(operationsPageSurfaceSource).toContain('getOperationsTabFromPath');
expect(operationsPageSurfaceSource).toContain('buildOperationsPath');
expect(operationsPageSurfaceSource).toContain('operationsSurfaceHiddenInDemoMode');
expect(operationsPageSurfaceSource).not.toContain('-webkit-overflow-scrolling');
expect(operationsPageModelSource).toContain('export const OPERATIONS_TABS');
expect(operationsPageModelSource).toContain('export function operationsSurfaceHiddenInDemoMode');
expect(operationsPageModelSource).toContain('export function getOperationsTabFromPath');
expect(operationsPageModelSource).toContain('export function buildOperationsPath');
});
+6
View File
@@ -6193,6 +6193,12 @@ func TestContract_DemoModeCommercialSurfacePolicy(t *testing.T) {
{method: http.MethodPut, path: "/api/admin/orgs/t-tenant/billing-state"},
{method: http.MethodGet, path: "/api/upgrade-metrics/stats"},
{method: http.MethodPost, path: "/api/upgrade-metrics/events"},
{method: http.MethodGet, path: "/api/diagnostics"},
{method: http.MethodPost, path: "/api/diagnostics/docker/prepare-token"},
{method: http.MethodGet, path: "/api/logs/stream"},
{method: http.MethodGet, path: "/api/logs/download"},
{method: http.MethodGet, path: "/api/logs/level"},
{method: http.MethodPost, path: "/api/logs/level"},
{method: http.MethodGet, path: licensePurchaseStartPath},
{method: http.MethodGet, path: "/auth/trial-activate"},
}
+6
View File
@@ -26,6 +26,12 @@ func DemoModeMiddleware(cfg *config.Config, next http.Handler) http.Handler {
return
}
}
if exposure, ok := publicDemoAdminOperationsPolicyForRequest(r); ok {
if exposure == publicDemoCommercialExposureHidden {
http.NotFound(w, r)
return
}
}
// Allow GET and HEAD requests (read-only)
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
+6
View File
@@ -64,6 +64,12 @@ func TestDemoModeMiddleware(t *testing.T) {
{"demo on hidden truenas saved preview", true, http.MethodPost, "/api/truenas/connections/conn-1/preview", "", false, http.StatusNotFound, true},
{"demo on hidden vmware draft preview", true, http.MethodPost, "/api/vmware/connections/preview", "", false, http.StatusNotFound, true},
{"demo on hidden vmware saved preview", true, http.MethodPost, "/api/vmware/connections/conn-1/preview", "", false, http.StatusNotFound, true},
{"demo on hidden diagnostics", true, http.MethodGet, "/api/diagnostics", "", false, http.StatusNotFound, true},
{"demo on hidden diagnostics token prepare", true, http.MethodPost, "/api/diagnostics/docker/prepare-token", "", false, http.StatusNotFound, true},
{"demo on hidden logs stream", true, http.MethodGet, "/api/logs/stream", "", false, http.StatusNotFound, true},
{"demo on hidden logs download", true, http.MethodGet, "/api/logs/download", "", false, http.StatusNotFound, true},
{"demo on hidden logs level read", true, http.MethodGet, "/api/logs/level", "", false, http.StatusNotFound, true},
{"demo on hidden logs level write", true, http.MethodPost, "/api/logs/level", "", false, http.StatusNotFound, true},
{"demo on hidden checkout start", true, http.MethodGet, "/auth/license-purchase-start", "", false, http.StatusNotFound, true},
{"demo on hidden license activate", true, http.MethodPost, "/api/license/activate", "", false, http.StatusNotFound, true},
{"demo on hidden purchase start", true, http.MethodGet, licensePurchaseStartPath, "", false, http.StatusNotFound, true},
+47
View File
@@ -0,0 +1,47 @@
package api
import "net/http"
var publicDemoAdminOperationsPolicies = []publicDemoCommercialRoutePolicy{
{
route: "GET /api/diagnostics",
exposure: publicDemoCommercialExposureHidden,
matches: exactDemoCommercialMethodPath(http.MethodGet, "/api/diagnostics"),
},
{
route: "POST /api/diagnostics/docker/prepare-token",
exposure: publicDemoCommercialExposureHidden,
matches: exactDemoCommercialMethodPath(http.MethodPost, "/api/diagnostics/docker/prepare-token"),
},
{
route: "GET /api/logs/stream",
exposure: publicDemoCommercialExposureHidden,
matches: exactDemoCommercialMethodPath(http.MethodGet, "/api/logs/stream"),
},
{
route: "GET /api/logs/download",
exposure: publicDemoCommercialExposureHidden,
matches: exactDemoCommercialMethodPath(http.MethodGet, "/api/logs/download"),
},
{
route: "GET /api/logs/level",
exposure: publicDemoCommercialExposureHidden,
matches: exactDemoCommercialMethodPath(http.MethodGet, "/api/logs/level"),
},
{
route: "POST /api/logs/level",
exposure: publicDemoCommercialExposureHidden,
matches: exactDemoCommercialMethodPath(http.MethodPost, "/api/logs/level"),
},
}
func publicDemoAdminOperationsPolicyForRequest(
r *http.Request,
) (publicDemoCommercialExposure, bool) {
for _, policy := range publicDemoAdminOperationsPolicies {
if policy.matches != nil && policy.matches(r) {
return policy.exposure, true
}
}
return "", false
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestPublicDemoAdminOperationsPolicyForRequest(t *testing.T) {
hiddenRoutes := []struct {
name string
method string
path string
}{
{name: "diagnostics", method: http.MethodGet, path: "/api/diagnostics"},
{name: "diagnostics token prepare", method: http.MethodPost, path: "/api/diagnostics/docker/prepare-token"},
{name: "logs stream", method: http.MethodGet, path: "/api/logs/stream"},
{name: "logs download", method: http.MethodGet, path: "/api/logs/download"},
{name: "logs level read", method: http.MethodGet, path: "/api/logs/level"},
{name: "logs level write", method: http.MethodPost, path: "/api/logs/level"},
}
for _, tc := range hiddenRoutes {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
exposure, ok := publicDemoAdminOperationsPolicyForRequest(req)
if !ok {
t.Fatalf("%s %s did not match public demo admin operations policy", tc.method, tc.path)
}
if exposure != publicDemoCommercialExposureHidden {
t.Fatalf("%s %s exposure=%q, want %q", tc.method, tc.path, exposure, publicDemoCommercialExposureHidden)
}
})
}
allowedRoutes := []struct {
name string
method string
path string
}{
{name: "health", method: http.MethodGet, path: "/api/health"},
{name: "resources", method: http.MethodGet, path: "/api/resources"},
{name: "runtime capabilities", method: http.MethodGet, path: "/api/license/runtime-capabilities"},
}
for _, tc := range allowedRoutes {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
if exposure, ok := publicDemoAdminOperationsPolicyForRequest(req); ok {
t.Fatalf("%s %s unexpectedly matched public demo admin operations policy with exposure=%q", tc.method, tc.path, exposure)
}
})
}
}