diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b297bea37..1e6732e72 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -970,6 +970,12 @@ surface and no new `internal/api/` lifecycle handler. 4. Keep shared agent-side TLS identity fail-closed across `cmd/pulse-agent/main.go`, `internal/hostagent/`, `internal/agentupdate/`, `internal/remoteconfig/client.go`, and `internal/agenttls/config.go`. Self-signed deployments may use a canonical pinned Pulse server certificate fingerprint, but lifecycle transport must route that pin through reporting, enrollment, command websocket, remote-config, and self-update clients instead of widening `PULSE_INSECURE_SKIP_VERIFY` into a blanket MITM carve-out. A configured custom CA bundle is part of that same trust boundary: if the bundle is unreadable or invalid, lifecycle transport must refuse the connection path rather than silently downgrading back to system roots. 5. Keep release-grade updater trust fail-closed across `internal/agentupdate/`, `internal/dockeragent/`, and the shared `internal/api/unified_agent.go` download helpers. When release builds embed trusted update signing keys, published agent binaries and installer assets must carry detached `.sig` plus `.sshsig` sidecars; updater/runtime paths must require `X-Signature-Ed25519` in addition to `X-Checksum-Sha256`, and installer-owned download flows must require the matching base64-encoded `X-Signature-SSHSIG`, instead of silently downgrading to checksum-only trust. 6. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice. + SSO browser-session display labels in shared auth/session helpers are + likewise API/security presentation state, not lifecycle identity. Agent + enrollment, installer reruns, update recovery, token binding, and fleet + control must continue to derive authority from agent tokens, setup tokens, + machine/report identity, and configured lifecycle state rather than from + `ssoSessionDisplayName` or other mutable SSO claim labels. The same isolation rule applies to AI settings payload work in `internal/api/ai_handlers.go`: provider auth fields, masked-secret echoes, provider-test model selection, and legacy Anthropic OAuth cleanup fields remain AI/runtime plus API-contract ownership and must not be reinterpreted as lifecycle setup, provider activation, or registration semantics just because they share backend helper layers. Patrol readiness labels on the same settings payload, including the user-facing Patrol control label for the stable `configuration` check ID, diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9d8ec474c..b099ba3d4 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1518,6 +1518,13 @@ payload shape change when the portal presents compact client rows. replacement can validate its retry. Logout, password-change, and explicit session revocation must still delete the full session token set rather than leaving any retained replacement token valid. + The same boundary owns SSO browser-session identity shape: + `ssoSessionUsername` in `/api/security/status` is the stable + provider-scoped principal used by authorization and organization context, + while `ssoSessionDisplayName` is the mutable human-readable label derived + from SSO claims for app chrome. Frontend consumers may display the label, + but API, organization, token, and permission checks must continue to bind + to the stable principal. 79. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary. Token owner identity is reserved for the server-authenticated principal: shared token-minting helpers must derive `owner_user_id` from the current @@ -2239,6 +2246,10 @@ a new API state machine, queue contract, or verification-accounting field. authorized-keys target before filtering Pulse-managed `# pulse-` lines, and `internal/api/contract_test.go` must pin the generated shell shape. 13. Keep `internal/api/session_store.go` on a fail-closed auth-persistence boundary: persisted OIDC refresh tokens may only round-trip through encrypted-at-rest session payloads, and any missing-crypto or invalid-ciphertext path must drop the token instead of preserving plaintext-at-rest session state. + SSO session persistence must also keep the stable session owner and the + display label as separate fields. Restart survival may restore both, but + display-claim changes must not rewrite the session owner used for RBAC, + tenant, organization, or token ownership checks. 14. Keep tenant AI handler wiring on canonical provider ownership: `internal/api/ai_handlers.go` may wire tenant `ReadState` and tenant-scoped unified-resource providers into AI services, but it must not revive tenant snapshot-provider bridges once Patrol can initialize and verify from those canonical providers directly. 15. Keep Patrol status transport semantics explicit in that same AI handler layer: the Patrol status endpoint must carry machine-readable runtime availability such as blocked, running, disabled, active, or unavailable rather than asking frontend consumers to infer operator state from stale summaries or run history. 16. Keep legacy Patrol quickstart transport semantics retired from the public v6 GA contract: ordinary AI settings and Patrol status payloads must not expose quickstart credit/status fields, and any stale hosted-model blocked copy that survives from compatibility state must normalize back to provider/local-model setup rather than presenting credit badges or acquisition prompts. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index dc9558a44..86388ce1e 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -223,6 +223,13 @@ Stripe-free and avoids a cloud-control-plane report data path across clients. into a generic paid-state prompt or create browser-owned relay credentials. 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. + 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 + `ssoSessionUsername` and backend request context. Display labels, contact + emails, and SSO name claims must not become hosted owner/member identity, + billing authority, entitlement state, or organization bootstrap source of + truth. 4. `internal/api/licensing_bridge.go` shared with `api-contracts`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership. 5. `internal/api/licensing_handlers.go` shared with `api-contracts`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership. That same shared licensing boundary also owns installation-version and @@ -822,6 +829,10 @@ or other self-hosted uncapped continuity plans. invitation flow must therefore refresh org bootstrap through the shared `organizations_changed` app-shell path instead of forking a second hosted org bootstrap or pricing-aware shell reload. + SSO display labels consumed during that same security-status bootstrap must + reuse the existing `/api/security/status` response and must not add another + hosted organization or commercial posture probe before the protected state + bootstrap. App-shell navigation rendered by `frontend-modern/src/AppLayout.tsx` must also keep decorative icon titles out of tab accessible names, so hosted and self-hosted chrome announce the canonical tab label plus meaningful badge diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 8717f9f26..4bbaa3224 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -142,6 +142,11 @@ regression protection. 11. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `unified-resources`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 15. `frontend-modern/src/routing/routePreload.ts` shared with `frontend-primitives`: the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary. 16. `frontend-modern/src/useAppRuntimeState.ts` shared with `cloud-paid`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary. + Security-status SSO display labels are part of the existing authenticated + bootstrap payload. The app shell may project `ssoSessionDisplayName` into + visible signed-in chrome, but it must not introduce an additional + pre-protected-state fetch, route preload, organization probe, or commercial + posture request just to resolve display identity. 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. ## Extension Points diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index b71d2270c..ca06d3307 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -123,6 +123,13 @@ controls as normal product settings. summaries, and revoke warnings so security-facing copy does not drift into page-local `container runtime` labels. 13. `internal/api/security.go` shared with `api-contracts`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary. + SSO session status must distinguish stable identity from presentation: + `ssoSessionUsername` remains the provider-scoped principal used for + authorization-sensitive comparisons, while `ssoSessionDisplayName` is + display/contact metadata for app chrome. Security/privacy surfaces may show + the display label, but they must not use mutable username, email, or name + claims as proof of admin, organization owner, token owner, or tenant + membership. 14. `internal/api/security_tokens.go` shared with `api-contracts`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary. Pulse Mobile relay token creation is a security token-management surface, but it is not a free API-token convenience. After admin and @@ -261,7 +268,8 @@ material, or unbounded container inspection output at the API boundary. email is contact metadata once a stable principal exists, and browser sessions must bind to the durable principal rather than a delivery address. For SSO, the durable principal is the provider-scoped subject, and mutable - username/email/display claims may not be written as the session owner. + username/email/display claims may not be written as the session owner. Those + mutable claims may persist only as display metadata for user-facing chrome. Live organization authorization follows the same trust boundary: contact email can support display, delivery, or migration, but request access must match the authenticated principal against stored `OwnerUserID` or member diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index f75da980b..39d39eeca 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -736,6 +736,12 @@ recovery scope, or a storage/recovery-owned secret source. surfaces may consume an already-authorized admin request, but they must not infer storage, restore, or protected-system authority from a proxy-auth user whose configured role header is missing or blank. + SSO session display labels in those shared helpers are also adjacent + security/API presentation state only. Storage and recovery surfaces may show + the current user's display label when a parent shell provides it, but backup + visibility, restore authority, protected-system ownership, and + recovery-local audit attribution must continue to use backend-authenticated + stable principals and storage/recovery resource identities. 8. Preserve canonical configured public endpoint selection in shared `internal/api/` helpers so recovery and storage links do not inherit loopback-local scheme drift from admin-originated setup/install flows. 9. Preserve trailing-slash normalization in those shared install-command helpers so recovery-adjacent transport and link surfaces do not inherit double-slash installer paths or slash-suffixed public endpoint drift from canonical backend install payloads. 10. Preserve canonical /api/auto-register token-action truth in shared `internal/api/` helpers so adjacent setup and recovery-adjacent transport flows stay on caller-supplied credential completion instead of reviving deleted alternate completion modes. diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 357a28625..22e8002c7 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -82,6 +82,11 @@ describe('App architecture', () => { expect(appRuntimeStateSource).not.toContain('fetchInfrastructureSummaryAndCache'); expect(appRuntimeStateSource).not.toContain('fetchWorkloadsSummaryAndCache'); expect(appRuntimeStateSource).not.toContain('requestIdleCallback'); + expect(appRuntimeStateSource).toContain('ssoSessionDisplayName?: string;'); + expect(appRuntimeStateSource).toContain( + 'securityData.ssoSessionDisplayName || securityData.ssoSessionUsername', + ); + expect(appRuntimeStateSource).toContain('username: ssoDisplayName'); expect(appSource).toContain("const StandalonePage = lazy(() => import('./pages/Standalone'));"); expect(appSource).toContain(''); expect(appSource).toContain( diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index 476c6e3ff..7fec4d190 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -320,6 +320,46 @@ describe('useAppRuntimeState', () => { dispose(); }); + it('uses the SSO display name for app chrome without replacing the stable principal', async () => { + const principal = 'sso:oidc:test-oidc:stable-principal'; + apiFetchMock.mockImplementation(async (url: string) => { + if (url === '/api/security/status') { + return new Response( + JSON.stringify({ + hasAuthentication: true, + ssoEnabled: true, + ssoSessionUsername: principal, + ssoSessionDisplayName: 'alice@example.com', + ssoLogoutURL: '/api/oidc/test-oidc/logout', + }), + { status: 200 }, + ); + } + if (url === '/api/state') { + return new Response('{}', { 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.proxyAuthInfo()).toEqual({ + username: 'alice@example.com', + logoutURL: '/api/oidc/test-oidc/logout', + }); + }); + + expect(hookState.securityStatus()?.ssoSessionUsername).toBe(principal); + expect(hookState.hasAuth()).toBe(true); + expect(hookState.needsAuth()).toBe(false); + + dispose(); + }); + it('uses the protected state response as shell state before websocket data arrives', async () => { const bootstrapResource: Resource = { id: 'pve-1', diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index 52349b5a3..4cae62981 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -142,6 +142,7 @@ export interface SecurityStatus { authLastModified?: string; message?: string; ssoSessionUsername?: string; + ssoSessionDisplayName?: string; ssoLogoutURL?: string; hideLocalLogin?: boolean; // Hide local login form agentUrl?: string; // URL for agent install commands (from PULSE_PUBLIC_URL or auto-detected) diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index b7210354e..c83ab7ae7 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -629,6 +629,7 @@ export const useAppRuntimeState = () => { proxyAuthLogoutURL?: string; ssoEnabled?: boolean; ssoSessionUsername?: string; + ssoSessionDisplayName?: string; ssoLogoutURL?: string; }; logger.debug('[App] Security status fetched', securityData); @@ -650,10 +651,11 @@ export const useAppRuntimeState = () => { } if (securityData.ssoEnabled && securityData.ssoSessionUsername) { - logger.info('[App] SSO session detected', { user: securityData.ssoSessionUsername }); + const ssoDisplayName = securityData.ssoSessionDisplayName || securityData.ssoSessionUsername; + logger.info('[App] SSO session detected', { user: ssoDisplayName }); setHasAuth(true); setProxyAuthInfo({ - username: securityData.ssoSessionUsername, + username: ssoDisplayName, logoutURL: securityData.ssoLogoutURL, }); await beginAuthenticatedRuntime(); diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 4a63e1bd7..0e7537f4c 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -1044,6 +1044,12 @@ func TestContract_SSOStablePrincipalProof(t *testing.T) { } } } + if !strings.Contains(string(oidcSource), "establishOIDCSession(w, req, principal, username, oidcTokens)") { + t.Fatal("oidc_handlers.go must establish OIDC sessions with a stable principal and separate display username") + } + if !strings.Contains(string(samlSource), "establishSAMLSession(w, req, principal, result.Username, samlSession)") { + t.Fatal("saml_handlers.go must establish SAML sessions with a stable principal and separate display username") + } for _, forbidden := range []string{ "establishOIDCSession(w, req, username, oidcTokens)", "UpdateUserRoles(username, rolesToAssign)", diff --git a/internal/api/identity_invariant_contract_test.go b/internal/api/identity_invariant_contract_test.go index 83cf664c9..e865d4eb4 100644 --- a/internal/api/identity_invariant_contract_test.go +++ b/internal/api/identity_invariant_contract_test.go @@ -61,12 +61,12 @@ func TestContract_HostedIdentityUsesStablePrincipals(t *testing.T) { "oidc_handlers.go": { "stableSSOPrincipal(config.SSOProviderTypeOIDC, providerID, idToken.Subject)", "applySSORoleAssignments(authManager, principal", - "establishOIDCSession(w, req, principal, oidcTokens)", + "establishOIDCSession(w, req, principal, username, oidcTokens)", }, "saml_handlers.go": { "stableSSOPrincipal(config.SSOProviderTypeSAML, providerID, result.NameID)", "applySSORoleAssignments(authManager, principal", - "establishSAMLSession(w, req, principal, samlSession)", + "establishSAMLSession(w, req, principal, result.Username, samlSession)", }, "auth_principal_identity.go": { "stableSSOPrincipal", diff --git a/internal/api/oidc_handlers.go b/internal/api/oidc_handlers.go index 9ca939cfd..2f5281139 100644 --- a/internal/api/oidc_handlers.go +++ b/internal/api/oidc_handlers.go @@ -616,7 +616,7 @@ func (r *Router) handleSSOOIDCCallback(w http.ResponseWriter, req *http.Request) } } - if err := r.establishOIDCSession(w, req, principal, oidcTokens); err != nil { + if err := r.establishOIDCSession(w, req, principal, username, oidcTokens); err != nil { log.Error().Err(err).Msg("Failed to establish session after SSO OIDC login") r.redirectOIDCError(w, req, entry.ReturnTo, "session_failed") return diff --git a/internal/api/router.go b/internal/api/router.go index 89deb21a7..42bfc4110 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -5007,8 +5007,8 @@ func (r *Router) establishRecoverySession(w http.ResponseWriter, req *http.Reque return nil } -// establishOIDCSession creates a session with OIDC token information for refresh token support -func (r *Router) establishOIDCSession(w http.ResponseWriter, req *http.Request, username string, oidcTokens *OIDCTokenInfo) error { +// establishOIDCSession creates a session with OIDC token information for refresh token support. +func (r *Router) establishOIDCSession(w http.ResponseWriter, req *http.Request, username, displayUsername string, oidcTokens *OIDCTokenInfo) error { // Invalidate any pre-existing session to prevent session fixation attacks. InvalidateOldSessionFromRequest(req) @@ -5020,8 +5020,8 @@ func (r *Router) establishOIDCSession(w http.ResponseWriter, req *http.Request, userAgent := req.Header.Get("User-Agent") clientIP := GetClientIP(req) - // Create session with OIDC tokens (including username for restart survival) - GetSessionStore().CreateOIDCSession(token, 24*time.Hour, userAgent, clientIP, username, oidcTokens) + // Create session with OIDC tokens (including principal and display label for restart survival) + GetSessionStore().CreateOIDCSessionWithDisplayName(token, 24*time.Hour, userAgent, clientIP, username, displayUsername, oidcTokens) if username != "" { TrackUserSession(username, token) diff --git a/internal/api/router_misc_additional_test.go b/internal/api/router_misc_additional_test.go index 4ee7b8141..0a00d773c 100644 --- a/internal/api/router_misc_additional_test.go +++ b/internal/api/router_misc_additional_test.go @@ -1622,7 +1622,7 @@ func TestEstablishOIDCSession(t *testing.T) { ClientID: "client", } - if err := router.establishOIDCSession(rec, req, "admin", oidc); err != nil { + if err := router.establishOIDCSession(rec, req, "admin", "admin", oidc); err != nil { t.Fatalf("establishOIDCSession error: %v", err) } diff --git a/internal/api/router_routes_auth_security.go b/internal/api/router_routes_auth_security.go index 2467a9880..54e706c29 100644 --- a/internal/api/router_routes_auth_security.go +++ b/internal/api/router_routes_auth_security.go @@ -251,10 +251,15 @@ func (r *Router) registerAuthSecurityInstallRoutes() { // Check for SSO-backed session ssoSessionUsername := "" + ssoSessionDisplayName := "" if hasEnabledSSO { if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" { if ValidateSession(cookie.Value) { - ssoSessionUsername = GetSessionUsername(cookie.Value) + session := GetSessionStore().GetSession(cookie.Value) + if session != nil && (strings.TrimSpace(session.OIDCIssuer) != "" || strings.TrimSpace(session.SAMLProviderID) != "") { + ssoSessionUsername = GetSessionUsername(cookie.Value) + ssoSessionDisplayName = GetSessionDisplayUsername(cookie.Value) + } } } } @@ -291,6 +296,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() { "authLastModified": "", "ssoEnabled": hasEnabledSSO, "ssoSessionUsername": ssoSessionUsername, + "ssoSessionDisplayName": ssoSessionDisplayName, "hideLocalLogin": r.config.HideLocalLogin, "agentUrl": agentURL, "sessionCapabilities": r.securityStatusSessionCapabilities(req.Context()), diff --git a/internal/api/saml_handlers.go b/internal/api/saml_handlers.go index f640ead5f..1d298f0df 100644 --- a/internal/api/saml_handlers.go +++ b/internal/api/saml_handlers.go @@ -304,7 +304,7 @@ func (r *Router) handleSAMLACS(w http.ResponseWriter, req *http.Request) { SessionIndex: result.SessionIdx, } - if err := r.establishSAMLSession(w, req, principal, samlSession); err != nil { + if err := r.establishSAMLSession(w, req, principal, result.Username, samlSession); err != nil { log.Error().Err(err).Msg("Failed to establish session after SAML login") LogAuditEventForTenant(GetOrgID(req.Context()), "saml_login", principal, GetClientIP(req), req.URL.Path, false, "Session creation failed") r.redirectSAMLError(w, req, relayState, "session_failed") @@ -501,8 +501,8 @@ type SAMLSessionInfo struct { SessionIndex string `json:"sessionIndex"` } -// establishSAMLSession creates a session for a SAML-authenticated user -func (r *Router) establishSAMLSession(w http.ResponseWriter, req *http.Request, username string, samlInfo *SAMLSessionInfo) error { +// establishSAMLSession creates a session for a SAML-authenticated user. +func (r *Router) establishSAMLSession(w http.ResponseWriter, req *http.Request, username, displayUsername string, samlInfo *SAMLSessionInfo) error { // Invalidate any pre-existing session to prevent session fixation attacks. InvalidateOldSessionFromRequest(req) @@ -525,7 +525,7 @@ func (r *Router) establishSAMLSession(w http.ResponseWriter, req *http.Request, } // Create session with SAML info for SLO support - GetSessionStore().CreateSAMLSession(token, 24*time.Hour, userAgent, clientIP, username, samlTokens) + GetSessionStore().CreateSAMLSessionWithDisplayName(token, 24*time.Hour, userAgent, clientIP, username, displayUsername, samlTokens) if username != "" { TrackUserSession(username, token) diff --git a/internal/api/saml_handlers_additional_test.go b/internal/api/saml_handlers_additional_test.go index b8a0c3543..ceafb985b 100644 --- a/internal/api/saml_handlers_additional_test.go +++ b/internal/api/saml_handlers_additional_test.go @@ -99,7 +99,7 @@ func TestEstablishSAMLSession(t *testing.T) { rec := httptest.NewRecorder() samlInfo := &SAMLSessionInfo{ProviderID: "okta", NameID: "user", SessionIndex: "sess-1"} - if err := router.establishSAMLSession(rec, req, "admin", samlInfo); err != nil { + if err := router.establishSAMLSession(rec, req, "admin", "admin", samlInfo); err != nil { t.Fatalf("establishSAMLSession error: %v", err) } diff --git a/internal/api/security.go b/internal/api/security.go index 042786a4c..a2661a88c 100644 --- a/internal/api/security.go +++ b/internal/api/security.go @@ -779,6 +779,18 @@ func GetSessionUsername(sessionID string) string { return "" } +// GetSessionDisplayUsername returns the human-readable label associated with a +// session, falling back to the stable principal for older sessions. +func GetSessionDisplayUsername(sessionID string) string { + if session := GetSessionStore().GetSession(sessionID); session != nil { + if session.DisplayUsername != "" { + return session.DisplayUsername + } + return session.Username + } + return "" +} + // InvalidateUserSessions invalidates all sessions for a user (e.g., on password change) func InvalidateUserSessions(user string) { sessionsMu.Lock() diff --git a/internal/api/security_status_additional_test.go b/internal/api/security_status_additional_test.go index 63f1d6899..b232c2bd0 100644 --- a/internal/api/security_status_additional_test.go +++ b/internal/api/security_status_additional_test.go @@ -176,6 +176,68 @@ func TestSecurityStatusExposesLegacyOIDCEnvProvider(t *testing.T) { } } +func TestSecurityStatusExposesSSOSessionPrincipalAndDisplayName(t *testing.T) { + resetSessionStoreForTests() + resetSessionTracking() + t.Cleanup(resetSessionStoreForTests) + t.Cleanup(resetSessionTracking) + + cfg := newTestConfigWithTokens(t) + ssoCfg := config.NewSSOConfig() + if err := ssoCfg.AddProvider(config.SSOProvider{ + ID: "test-oidc", + Name: "Test OIDC", + Type: config.SSOProviderTypeOIDC, + Enabled: true, + OIDC: &config.OIDCProviderConfig{ + IssuerURL: "https://id.example.test", + ClientID: "pulse-client", + ClientSecret: "secret", + }, + }); err != nil { + t.Fatalf("failed to add SSO provider: %v", err) + } + if err := config.NewConfigPersistence(cfg.DataPath).SaveSSOConfig(ssoCfg); err != nil { + t.Fatalf("failed to persist SSO config: %v", err) + } + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + sessionToken := "status-sso-display-token" + principal := "sso:oidc:test-oidc:stable-principal" + displayUsername := "alice@example.com" + GetSessionStore().CreateOIDCSessionWithDisplayName(sessionToken, time.Hour, "agent", "127.0.0.1", principal, displayUsername, &OIDCTokenInfo{ + Issuer: "https://id.example.test", + ClientID: "pulse-client", + }) + + req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) + req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken}) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for security status, got %d: %s", rec.Code, rec.Body.String()) + } + + var payload struct { + SSOEnabled bool `json:"ssoEnabled"` + SSOSessionUsername string `json:"ssoSessionUsername"` + SSOSessionDisplayName string `json:"ssoSessionDisplayName"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if !payload.SSOEnabled { + t.Fatal("expected SSO to be enabled") + } + if payload.SSOSessionUsername != principal { + t.Fatalf("ssoSessionUsername = %q, want %q", payload.SSOSessionUsername, principal) + } + if payload.SSOSessionDisplayName != displayUsername { + t.Fatalf("ssoSessionDisplayName = %q, want %q", payload.SSOSessionDisplayName, displayUsername) + } +} + func TestSecurityStatusExposesSettingsCapabilitiesForScopedToken(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&allowRulesAuthorizer{ diff --git a/internal/api/session_oidc_test.go b/internal/api/session_oidc_test.go index e5044cc66..4f0d0b554 100644 --- a/internal/api/session_oidc_test.go +++ b/internal/api/session_oidc_test.go @@ -150,6 +150,33 @@ func TestOIDCSessionPersistence(t *testing.T) { } } +func TestOIDCSessionPersistsDisplayUsernameSeparatelyFromPrincipal(t *testing.T) { + tmpDir := t.TempDir() + token := "test-session-token-display" + principal := "sso:oidc:test-oidc:stable-principal" + displayUsername := "alice@example.com" + + store1 := NewSessionStore(tmpDir) + store1.CreateOIDCSessionWithDisplayName(token, 24*time.Hour, "TestAgent", "127.0.0.1", principal, displayUsername, &OIDCTokenInfo{ + RefreshToken: "display-refresh-token", + AccessTokenExp: time.Now().Add(1 * time.Hour), + Issuer: "https://example.com", + ClientID: "test-client-id", + }) + + store2 := NewSessionStore(tmpDir) + session := store2.GetSession(token) + if session == nil { + t.Fatal("Session should be restored after reload") + } + if session.Username != principal { + t.Fatalf("session principal = %q, want %q", session.Username, principal) + } + if session.DisplayUsername != displayUsername { + t.Fatalf("session display username = %q, want %q", session.DisplayUsername, displayUsername) + } +} + func TestCreateOIDCSession_NilTokenInfo(t *testing.T) { tmpDir, err := os.MkdirTemp("", "pulse-session-test-*") if err != nil { diff --git a/internal/api/session_store.go b/internal/api/session_store.go index 16213831a..b57e87486 100644 --- a/internal/api/session_store.go +++ b/internal/api/session_store.go @@ -33,6 +33,7 @@ func sessionHash(token string) string { type sessionPersisted struct { Key string `json:"key"` Username string `json:"username,omitempty"` + DisplayUsername string `json:"display_username,omitempty"` RecoveryBypass bool `json:"recovery_bypass,omitempty"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` @@ -54,7 +55,8 @@ type sessionPersisted struct { // SessionData represents a user session type SessionData struct { - Username string `json:"username,omitempty"` // The authenticated user + Username string `json:"username,omitempty"` // Stable authenticated principal + DisplayUsername string `json:"display_username,omitempty"` // Human-readable session label RecoveryBypass bool `json:"recovery_bypass,omitempty"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` @@ -103,6 +105,7 @@ func (s *SessionStore) loadHashedSessions(persisted []sessionPersisted, now time s.sessions[entry.Key] = &SessionData{ Username: entry.Username, + DisplayUsername: entry.DisplayUsername, RecoveryBypass: entry.RecoveryBypass, ExpiresAt: entry.ExpiresAt, CreatedAt: entry.CreatedAt, @@ -214,6 +217,7 @@ func (s *SessionStore) CreateSession(token string, duration time.Duration, userA key := sessionHash(token) s.sessions[key] = &SessionData{ Username: username, + DisplayUsername: username, RecoveryBypass: false, ExpiresAt: time.Now().Add(duration), CreatedAt: time.Now(), @@ -235,6 +239,7 @@ func (s *SessionStore) CreateRecoverySession(token string, duration time.Duratio key := sessionHash(token) s.sessions[key] = &SessionData{ Username: username, + DisplayUsername: username, RecoveryBypass: true, ExpiresAt: time.Now().Add(duration), CreatedAt: time.Now(), @@ -257,13 +262,23 @@ type OIDCTokenInfo struct { // CreateOIDCSession creates a new session with OIDC token information func (s *SessionStore) CreateOIDCSession(token string, duration time.Duration, userAgent, ip, username string, oidc *OIDCTokenInfo) { + s.CreateOIDCSessionWithDisplayName(token, duration, userAgent, ip, username, username, oidc) +} + +// CreateOIDCSessionWithDisplayName creates an OIDC session with a stable +// principal and a separate human-readable label for UI display. +func (s *SessionStore) CreateOIDCSessionWithDisplayName(token string, duration time.Duration, userAgent, ip, username, displayUsername string, oidc *OIDCTokenInfo) { s.mu.Lock() defer s.mu.Unlock() + if displayUsername == "" { + displayUsername = username + } now := time.Now() key := sessionHash(token) session := &SessionData{ Username: username, + DisplayUsername: displayUsername, RecoveryBypass: false, ExpiresAt: now.Add(duration), CreatedAt: now, @@ -299,15 +314,26 @@ type SAMLTokenInfo struct { // CreateSAMLSession creates a new session with SAML session information func (s *SessionStore) CreateSAMLSession(token string, duration time.Duration, userAgent, ip, username string, saml *SAMLTokenInfo) { + s.CreateSAMLSessionWithDisplayName(token, duration, userAgent, ip, username, username, saml) +} + +// CreateSAMLSessionWithDisplayName creates a SAML session with a stable +// principal and a separate human-readable label for UI display. +func (s *SessionStore) CreateSAMLSessionWithDisplayName(token string, duration time.Duration, userAgent, ip, username, displayUsername string, saml *SAMLTokenInfo) { s.mu.Lock() defer s.mu.Unlock() + if displayUsername == "" { + displayUsername = username + } key := sessionHash(token) + now := time.Now() session := &SessionData{ Username: username, + DisplayUsername: displayUsername, RecoveryBypass: false, - ExpiresAt: time.Now().Add(duration), - CreatedAt: time.Now(), + ExpiresAt: now.Add(duration), + CreatedAt: now, UserAgent: userAgent, IP: ip, OriginalDuration: duration, @@ -500,6 +526,7 @@ func (s *SessionStore) saveUnsafe() { persisted = append(persisted, sessionPersisted{ Key: key, Username: session.Username, + DisplayUsername: session.DisplayUsername, RecoveryBypass: session.RecoveryBypass, ExpiresAt: session.ExpiresAt, CreatedAt: session.CreatedAt, diff --git a/internal/api/session_store_test.go b/internal/api/session_store_test.go index df4fc96d3..9cf447aa9 100644 --- a/internal/api/session_store_test.go +++ b/internal/api/session_store_test.go @@ -236,6 +236,35 @@ func TestSessionStore_CreateOIDCSession_PersistsAccessTokenIssuedAt(t *testing.T } } +func TestSessionStore_CreateSAMLSession_PersistsDisplayUsernameSeparatelyFromPrincipal(t *testing.T) { + tmpDir := t.TempDir() + token := "saml-display-token" + principal := "sso:saml:test-saml:stable-principal" + displayUsername := "Alice Example" + + store := NewSessionStore(tmpDir) + store.CreateSAMLSessionWithDisplayName(token, time.Hour, "TestAgent", "127.0.0.1", principal, displayUsername, &SAMLTokenInfo{ + ProviderID: "test-saml", + NameID: "name-id-123", + SessionIndex: "session-index-123", + }) + store.Shutdown() + + reloaded := NewSessionStore(tmpDir) + defer reloaded.Shutdown() + + session := reloaded.GetSession(token) + if session == nil { + t.Fatal("expected reloaded SAML session to exist") + } + if session.Username != principal { + t.Fatalf("session principal = %q, want %q", session.Username, principal) + } + if session.DisplayUsername != displayUsername { + t.Fatalf("session display username = %q, want %q", session.DisplayUsername, displayUsername) + } +} + func TestSessionStore_ValidateSession_NonExistent(t *testing.T) { store := &SessionStore{ sessions: make(map[string]*SessionData),