mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
57139c65c5
commit
eb99d7a6b3
23 changed files with 284 additions and 20 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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('<Route path={STANDALONE_PATH} component={StandalonePage} />');
|
||||
expect(appSource).toContain(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue