mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Fix authenticated platform bootstrap for proxy and SSO
This commit is contained in:
@@ -251,6 +251,13 @@ avoids a cloud-control-plane report data path across clients.
|
||||
describing relay transport mechanics alone.
|
||||
2. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` shared with `agent-lifecycle`: the monitored-system impact preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary.
|
||||
3. `frontend-modern/src/useAppRuntimeState.ts` shared with `performance-and-scalability`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary.
|
||||
Every recognized authenticated browser session -- local, proxy-auth, or
|
||||
SSO -- must enter the runtime through the same protected `/api/state`
|
||||
bootstrap before organization hydration and WebSocket startup. Proxy-auth
|
||||
and SSO security-status shortcuts may supply visible identity and logout
|
||||
context, but they must not bypass that canonical server-owned resource
|
||||
snapshot or leave platform navigation dependent on the first WebSocket
|
||||
delivery.
|
||||
The app runtime may use `ssoSessionDisplayName` from security status for
|
||||
visible signed-in chrome, but hosted/commercial organization context must
|
||||
remain bound to the stable authenticated principal carried separately in
|
||||
|
||||
@@ -158,6 +158,12 @@ admission records a stable refusal without invoking executor or network code.
|
||||
or retained server-owned runtime state. Platform navigation may consume
|
||||
that signal without adding a second state fetch, websocket subscription,
|
||||
route preload, or browser-local cache read on the authenticated hot path.
|
||||
Local, proxy-auth, and SSO sessions must share exactly one protected
|
||||
`/api/state` hydration after authentication is established. That single
|
||||
request must resolve the cold app shell before organization hydration and
|
||||
WebSocket startup, so a delayed or blocked first stream snapshot cannot
|
||||
hide server-owned platform scopes; auth-mode branches must not fork
|
||||
additional state probes or perform the same hydration twice.
|
||||
17. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
|
||||
|
||||
Governed action decisions preserve SQLite and MemoryStore parity through one
|
||||
|
||||
@@ -466,7 +466,14 @@ describe('App architecture', () => {
|
||||
'const connectionStatus = createMemo<AppConnectionStatus>(() => {',
|
||||
);
|
||||
expect(appRuntimeStateSource).toContain('const showOrgSwitcher = createMemo(() => {');
|
||||
expect(appRuntimeStateSource).toContain('const beginAuthenticatedRuntime = async () =>');
|
||||
expect(appRuntimeStateSource).toContain(
|
||||
'const loadAuthenticatedBootstrapState = async (): Promise<boolean> => {',
|
||||
);
|
||||
expect(appRuntimeStateSource).toContain(
|
||||
'const beginAuthenticatedRuntime = async (): Promise<boolean> => {',
|
||||
);
|
||||
expect(appRuntimeStateSource).toContain('if (!(await loadAuthenticatedBootstrapState())) {');
|
||||
expect(appRuntimeStateSource.match(/apiFetch\('\/api\/state'/g)).toHaveLength(1);
|
||||
expect(appRuntimeStateSource).toContain(
|
||||
'const [backendHealthy, setBackendHealthy] = createSignal(false);',
|
||||
);
|
||||
|
||||
@@ -41,6 +41,19 @@ const makeWebSocketState = (overrides: Partial<State> = {}): State => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeTrueNASResource = (id = 'truenas-1'): Resource => ({
|
||||
id,
|
||||
name: id,
|
||||
displayName: id,
|
||||
type: 'agent',
|
||||
platformId: id,
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
sources: ['truenas'],
|
||||
status: 'online',
|
||||
lastSeen: 1_700_000_000_000,
|
||||
});
|
||||
|
||||
describe('useAppRuntimeState', () => {
|
||||
let useAppRuntimeState: UseAppRuntimeStateModule['useAppRuntimeState'];
|
||||
let apiFetchMock: ReturnType<typeof vi.fn>;
|
||||
@@ -333,8 +346,9 @@ describe('useAppRuntimeState', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('uses the SSO display name for app chrome without replacing the stable principal', async () => {
|
||||
it('uses the SSO display name while bootstrapping TrueNAS state before websocket data', async () => {
|
||||
const principal = 'sso:oidc:test-oidc:stable-principal';
|
||||
const bootstrapResource = makeTrueNASResource('truenas-sso');
|
||||
apiFetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/security/status') {
|
||||
return new Response(
|
||||
@@ -349,7 +363,13 @@ describe('useAppRuntimeState', () => {
|
||||
);
|
||||
}
|
||||
if (url === '/api/state') {
|
||||
return new Response('{}', { status: 200 });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
resources: [bootstrapResource],
|
||||
lastUpdate: 1_700_000_000_000,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === '/api/health') {
|
||||
return new Response('{}', { status: 200 });
|
||||
@@ -364,11 +384,94 @@ describe('useAppRuntimeState', () => {
|
||||
username: 'alice@example.com',
|
||||
logoutURL: '/api/oidc/test-oidc/logout',
|
||||
});
|
||||
expect(hookState.state().resources).toEqual([bootstrapResource]);
|
||||
});
|
||||
|
||||
expect(hookState.securityStatus()?.ssoSessionUsername).toBe(principal);
|
||||
expect(hookState.hasAuth()).toBe(true);
|
||||
expect(hookState.needsAuth()).toBe(false);
|
||||
expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false);
|
||||
expect(hookState.runtimeStateResolved()).toBe(true);
|
||||
expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1);
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('bootstraps proxy-auth TrueNAS state before websocket data', async () => {
|
||||
const bootstrapResource = makeTrueNASResource('truenas-proxy');
|
||||
apiFetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/security/status') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
hasAuthentication: true,
|
||||
hasProxyAuth: true,
|
||||
proxyAuthUsername: 'proxy-operator',
|
||||
proxyAuthLogoutURL: '/proxy/logout',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === '/api/state') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
resources: [bootstrapResource],
|
||||
lastUpdate: 1_700_000_000_000,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === '/api/health') {
|
||||
return new Response('{}', { status: 200 });
|
||||
}
|
||||
throw new Error(`Unhandled apiFetch URL: ${url}`);
|
||||
});
|
||||
|
||||
const { hookState, dispose } = mountHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.state().resources).toEqual([bootstrapResource]);
|
||||
});
|
||||
|
||||
expect(hookState.proxyAuthInfo()).toEqual({
|
||||
username: 'proxy-operator',
|
||||
logoutURL: '/proxy/logout',
|
||||
});
|
||||
expect(hookState.needsAuth()).toBe(false);
|
||||
expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false);
|
||||
expect(hookState.runtimeStateResolved()).toBe(true);
|
||||
expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1);
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('does not start the proxy-auth runtime when protected state rejects the session', async () => {
|
||||
apiFetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/security/status') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
hasAuthentication: true,
|
||||
hasProxyAuth: true,
|
||||
proxyAuthUsername: 'proxy-operator',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === '/api/state') {
|
||||
return new Response('{}', { status: 401 });
|
||||
}
|
||||
throw new Error(`Unhandled apiFetch URL: ${url}`);
|
||||
});
|
||||
|
||||
const { hookState, dispose } = mountHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.isLoading()).toBe(false);
|
||||
});
|
||||
|
||||
expect(hookState.needsAuth()).toBe(true);
|
||||
expect(hookState.enhancedStore()).toBeNull();
|
||||
expect(orgsListMock).not.toHaveBeenCalled();
|
||||
expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1);
|
||||
|
||||
dispose();
|
||||
});
|
||||
@@ -412,6 +515,7 @@ describe('useAppRuntimeState', () => {
|
||||
});
|
||||
expect(hookState.runtimeStateResolved()).toBe(true);
|
||||
expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false);
|
||||
expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1);
|
||||
|
||||
dispose();
|
||||
});
|
||||
@@ -447,6 +551,9 @@ describe('useAppRuntimeState', () => {
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === '/api/state') {
|
||||
return new Response('{}', { status: 200 });
|
||||
}
|
||||
if (url === '/api/health') {
|
||||
return new Response('{}', { status: 200 });
|
||||
}
|
||||
@@ -463,7 +570,7 @@ describe('useAppRuntimeState', () => {
|
||||
expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false);
|
||||
expect(hookState.runtimeStateResolved()).toBe(true);
|
||||
expect(hookState.state().resources).toEqual([retainedResource]);
|
||||
expect(apiFetchMock.mock.calls.some(([url]) => url === '/api/state')).toBe(false);
|
||||
expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1);
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -338,7 +338,34 @@ export const useAppRuntimeState = () => {
|
||||
aiChatStore.setEnabled(securityData?.sessionCapabilities?.assistantEnabled === true);
|
||||
};
|
||||
|
||||
const beginAuthenticatedRuntime = async () => {
|
||||
const loadAuthenticatedBootstrapState = async (): Promise<boolean> => {
|
||||
const stateResponse = await apiFetch('/api/state', {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (stateResponse.status === 401) {
|
||||
setBootstrapState(null);
|
||||
setNeedsAuth(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
const protectedState = await stateResponse
|
||||
.clone()
|
||||
.json()
|
||||
.then(normalizeBootstrapState)
|
||||
.catch(() => null);
|
||||
setBootstrapState(protectedState);
|
||||
return true;
|
||||
};
|
||||
|
||||
const beginAuthenticatedRuntime = async (): Promise<boolean> => {
|
||||
if (!(await loadAuthenticatedBootstrapState())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setNeedsAuth(false);
|
||||
await loadOrganizations();
|
||||
setWsStore(acquireWsStore());
|
||||
@@ -348,6 +375,7 @@ export const useAppRuntimeState = () => {
|
||||
if (!presentationPolicyHidesUpgradePrompts()) {
|
||||
void loadCommercialPosture();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const checkBackendHealth = async () => {
|
||||
@@ -685,7 +713,10 @@ export const useAppRuntimeState = () => {
|
||||
username: securityData.proxyAuthUsername,
|
||||
logoutURL: securityData.proxyAuthLogoutURL,
|
||||
});
|
||||
await beginAuthenticatedRuntime();
|
||||
if (!(await beginAuthenticatedRuntime())) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
void syncVersionInfoFromUpdateStore();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -700,7 +731,10 @@ export const useAppRuntimeState = () => {
|
||||
username: ssoDisplayName,
|
||||
logoutURL: securityData.ssoLogoutURL,
|
||||
});
|
||||
await beginAuthenticatedRuntime();
|
||||
if (!(await beginAuthenticatedRuntime())) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
void syncVersionInfoFromUpdateStore();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -725,25 +759,7 @@ export const useAppRuntimeState = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateResponse = await apiFetch('/api/state', {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (stateResponse.status === 401) {
|
||||
setBootstrapState(null);
|
||||
setNeedsAuth(true);
|
||||
} else {
|
||||
const protectedState = await stateResponse
|
||||
.clone()
|
||||
.json()
|
||||
.then(normalizeBootstrapState)
|
||||
.catch(() => null);
|
||||
setBootstrapState(protectedState);
|
||||
await beginAuthenticatedRuntime();
|
||||
}
|
||||
await beginAuthenticatedRuntime();
|
||||
} catch (error) {
|
||||
logger.error('Auth check error', error);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user