From 3adeb77d60620cdcc4c71431685725384bdc562a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 12 Aug 2026 07:32:50 +0100 Subject: [PATCH] Secure configuration transfer authorization (#1714) Co-authored-by: Pulse Autonomous Maintainer --- SECURITY.md | 13 +- docs/API.md | 8 +- .../v6/internal/subsystems/agent-lifecycle.md | 12 + .../v6/internal/subsystems/api-contracts.md | 25 + .../subsystems/performance-and-scalability.md | 10 + .../internal/subsystems/security-privacy.md | 20 + .../internal/subsystems/storage-recovery.md | 18 + frontend-modern/public/docs/API.md | 8 +- frontend-modern/public/docs/SECURITY.md | 13 +- .../src/utils/__tests__/docsLinks.test.ts | 22 + internal/api/config_transfer_authorization.go | 152 ++++++ .../api/config_transfer_authorization_test.go | 512 ++++++++++++++++++ internal/api/contract_test.go | 48 ++ internal/api/identity_sso_handlers.go | 9 + internal/api/router.go | 6 + internal/api/router_routes_auth_security.go | 29 +- internal/api/router_routes_registration.go | 255 +-------- .../api/system_settings_telemetry_test.go | 29 + 18 files changed, 908 insertions(+), 281 deletions(-) create mode 100644 internal/api/config_transfer_authorization.go create mode 100644 internal/api/config_transfer_authorization_test.go diff --git a/SECURITY.md b/SECURITY.md index 6f85540b7..81d907683 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -236,6 +236,12 @@ Environment="ALLOW_UNPROTECTED_EXPORT=true" docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest ``` +This exception applies only when Pulse has no configured authentication and +only to configuration export. It never enables import and never overrides +password, proxy, API-token, SSO, or hosted authentication. Without the +exception, unauthenticated export and import recovery is limited to a direct +loopback connection; private-network and forwarded requests are not loopback. + **Note:** for production, prefer Docker secrets or systemd environment files for sensitive data. @@ -419,7 +425,10 @@ curl -X POST \ http://localhost:7655/api/config/export ``` -Most API endpoints also accept `Authorization: Bearer `, but export/import uses the `X-API-Token` header. +Configuration export accepts a token with `settings:read`; import accepts a +token with `settings:write`. Both `X-API-Token` and `Authorization: Bearer` +forms are supported, and organization-bound tokens can transfer only the +organization selected by the request. ### Scoped API Tokens @@ -633,7 +642,7 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \ ## Troubleshooting **Account locked?** Wait 15 minutes or contact admin for manual reset -**Export blocked?** You're on a public network – login with password, create an API token, or set `ALLOW_UNPROTECTED_EXPORT=true` +**Export blocked?** Authenticate with management authority, use a correctly scoped API token, connect directly over loopback on a no-auth installation, or deliberately set `ALLOW_UNPROTECTED_EXPORT=true` for export only
**Rate limited?** Wait 1 minute and try again **Can't login?** Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables **API access denied?** Verify the token you supplied matches one of the values created in *Settings → API Tokens* (use the original token, not the hash) diff --git a/docs/API.md b/docs/API.md index deef6dbfb..0864f6b48 100644 --- a/docs/API.md +++ b/docs/API.md @@ -360,7 +360,9 @@ Validates node config without saving. `POST /api/config/nodes/{id}/refresh-cluster` ### Export Configuration -`POST /api/config/export` (admin or API token) +`POST /api/config/export` (instance admin for the default organization, tenant +manager for a selected tenant, or an API token bound to the selected +organization with `settings:read`) Request body: ```json { "passphrase": "use-a-strong-passphrase" } @@ -368,7 +370,9 @@ Request body: Returns an encrypted export bundle in `data`. Passphrases must be at least 12 characters. ### Import Configuration -`POST /api/config/import` (admin) +`POST /api/config/import` (instance admin for the default organization, tenant +manager for a selected tenant, or an API token bound to the selected +organization with `settings:write`) Request body: ```json { diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 93e23af50..2dc77ce72 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6197,3 +6197,15 @@ operator or external agent can explain current attention behavior, without changing registration or command authority. Retiring an agent-backed resource blocks automated remediation through the shared action policy while preserving its enrollment and history. + +### Configuration transfer authority does not derive from agent membership + +The shared `internal/api/` configuration export/import routes use browser, +proxy, Basic, or API-token administration authority for the resolved +organization before entering persistence. Agent registration, report, setup, +and command credentials do not become configuration-transfer authority merely +because they authenticate another lifecycle route. An API token must retain +organization binding and carry `settings:read` for export or `settings:write` +for import; tenant browser membership without management authority is denied. +This boundary neither enrolls nor mutates agent identity, tokens, command +sessions, fleet policy, or update state. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0887fff7d..ec672596b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -9306,3 +9306,28 @@ the canonical capability manifest project both new fields. Retired lifecycle is also an execution-time remediation lock in the shared action lifecycle. `internal/api/resources_operator_state_test.go`, agent-context tests, manifest tests, and the generated `cmd/pulse-mcp/README.md` pin this additive contract. + +### Configuration transfer has one organization-aware authorization boundary + +`POST /api/config/export` and `POST /api/config/import` remain route-local +public exceptions only so deliberate no-auth recovery can be evaluated. Before +either handler reads a body or resolves persistence, the shared transfer guard +classifies password, proxy, API-token, persisted or environment-backed SSO, +hosted, and SSO-load-failure state. Any configured or uncertain authentication +state requires a real credential. Sessions require instance-admin authority on +the default organization or `CanUserIDManage` on the resolved tenant; tenant +membership alone is insufficient. API tokens retain tenant-middleware +organization binding and require `settings:read` for export or `settings:write` +for import, including legacy unbound-token compatibility on the default +organization only. + +When no authentication exists, import and export recovery are limited to a +direct loopback peer with no forwarding headers. `ALLOW_UNPROTECTED_EXPORT=true` +may additionally admit export only; it cannot admit import or override any +authenticated or hosted mode. Denials precede archive decoding, export reads, +import transactions, live-config replacement, and runtime reload. The public +and privileged security-status projections use the same classification, and +the canonical and shipped API references describe the same scope and tenant +rules. `config_transfer_authorization_test.go`, the contract ratchet in +`contract_test.go`, and the shipped-doc assertions in `docsLinks.test.ts` pin +the boundary and its published shape. 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 c7e18c4bf..01ce3f37d 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2387,3 +2387,13 @@ operator-state read for that selected resource and does not add table-row, hover-preview, interval, or websocket work. Saving monitoring or lifecycle policy performs one mutation and one selected-resource refetch. The Workloads list, selectors, windowing, and polling budgets remain unchanged. + +### Configuration transfer authorization stays off persistence hot paths + +Persisted and environment-backed SSO state is loaded into the router's auth +snapshot during construction. Export/import authorization then uses in-memory +configuration, session, token-context, organization-context, and hosted-state +checks before handing the request to either body-decoding handler. Denied +requests perform no configuration export loads, import transaction work, +metadata replacement, or runtime reload. Authorized archive encryption, +decryption, transactional persistence, and reload costs are unchanged. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 9dcda6751..ad76844ea 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2163,3 +2163,23 @@ the tenant resource store. Runtime reconciliation may visit every live monitor, but each alert manager resolves policy through its own tenant-scoped store, so a matching provider ID in another organization cannot import the mutation. The existing route scopes and authenticated actor attribution remain unchanged. + +### Secret-bearing configuration transfer fails closed before data access + +Configuration archives may contain node credentials, notification secrets, +API-token hashes and metadata, OIDC client secrets, and SAML private keys. The +export/import router therefore authorizes the resolved organization before +request-body parsing or persistence access. Hosted mode, enabled persisted or +environment-backed SSO, and an SSO load failure all require authentication; +none may fall through to no-auth recovery. Browser sessions need instance-admin +or tenant-management authority, proxy identities need the configured admin +role, and API tokens preserve organization binding plus the operation-specific +`settings:read` or `settings:write` scope. + +No-auth recovery trusts only a direct loopback transport without forwarded +identity headers. `ALLOW_UNPROTECTED_EXPORT` is an export-only exception and is +ineffective once any authentication or hosted mode is active. Security status +reports that effective policy rather than the environment variable alone, and +the root and shipped security guides remain byte-for-byte synchronized. The +router matrix proves that malformed and otherwise valid denied requests read no +body and perform no export, import, config replacement, or runtime reload. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a02da100e..154239549 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -5188,3 +5188,21 @@ configuration, or provider inventory. A retired resource fails closed for automated remediation through the shared action lifecycle, while recovery evidence remains available for operator review and for restoration to active monitoring. + +### Configuration archive recovery is authorized before persistence + +Encrypted configuration export/import is a storage-recovery boundary only +after authorization succeeds. The API guard resolves the selected organization +and authenticating principal before either handler decodes an archive, reads +the tenant export persistence, begins an import transaction, replaces live +configuration, or reloads monitoring state. A tenant viewer or member cannot +transfer configuration; tenant managers and owners may operate only on their +resolved tenant, and scoped API tokens retain their organization binding. + +Truly unauthenticated recovery is limited to direct loopback for both +operations. The deliberate `ALLOW_UNPROTECTED_EXPORT` exception widens export +only and cannot authorize import. Existing encryption, archive versions, +transactional rollback, metadata replacement, and successful reload semantics +remain unchanged behind this boundary. The config-transfer router matrix and +existing archive compatibility/rollback tests jointly pin denial-before-access +and authorized recovery behavior. diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index deef6dbfb..0864f6b48 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -360,7 +360,9 @@ Validates node config without saving. `POST /api/config/nodes/{id}/refresh-cluster` ### Export Configuration -`POST /api/config/export` (admin or API token) +`POST /api/config/export` (instance admin for the default organization, tenant +manager for a selected tenant, or an API token bound to the selected +organization with `settings:read`) Request body: ```json { "passphrase": "use-a-strong-passphrase" } @@ -368,7 +370,9 @@ Request body: Returns an encrypted export bundle in `data`. Passphrases must be at least 12 characters. ### Import Configuration -`POST /api/config/import` (admin) +`POST /api/config/import` (instance admin for the default organization, tenant +manager for a selected tenant, or an API token bound to the selected +organization with `settings:write`) Request body: ```json { diff --git a/frontend-modern/public/docs/SECURITY.md b/frontend-modern/public/docs/SECURITY.md index 6f85540b7..81d907683 100644 --- a/frontend-modern/public/docs/SECURITY.md +++ b/frontend-modern/public/docs/SECURITY.md @@ -236,6 +236,12 @@ Environment="ALLOW_UNPROTECTED_EXPORT=true" docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest ``` +This exception applies only when Pulse has no configured authentication and +only to configuration export. It never enables import and never overrides +password, proxy, API-token, SSO, or hosted authentication. Without the +exception, unauthenticated export and import recovery is limited to a direct +loopback connection; private-network and forwarded requests are not loopback. + **Note:** for production, prefer Docker secrets or systemd environment files for sensitive data. @@ -419,7 +425,10 @@ curl -X POST \ http://localhost:7655/api/config/export ``` -Most API endpoints also accept `Authorization: Bearer `, but export/import uses the `X-API-Token` header. +Configuration export accepts a token with `settings:read`; import accepts a +token with `settings:write`. Both `X-API-Token` and `Authorization: Bearer` +forms are supported, and organization-bound tokens can transfer only the +organization selected by the request. ### Scoped API Tokens @@ -633,7 +642,7 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \ ## Troubleshooting **Account locked?** Wait 15 minutes or contact admin for manual reset -**Export blocked?** You're on a public network – login with password, create an API token, or set `ALLOW_UNPROTECTED_EXPORT=true` +**Export blocked?** Authenticate with management authority, use a correctly scoped API token, connect directly over loopback on a no-auth installation, or deliberately set `ALLOW_UNPROTECTED_EXPORT=true` for export only
**Rate limited?** Wait 1 minute and try again **Can't login?** Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables **API access denied?** Verify the token you supplied matches one of the values created in *Settings → API Tokens* (use the original token, not the hash) diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 6de6482eb..ad2d1bd14 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -130,6 +130,28 @@ describe('docsLinks', () => { expect(rbacGuide).toMatch(/Removal does not disable\s+the upstream IdP account/); }); + it('ships the configuration transfer authorization contract', () => { + const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); + const shippedAPIReference = readFileSync( + path.join(frontendRoot, 'public', 'docs', 'API.md'), + 'utf8', + ); + + expect(shippedAPIReference).toBe(apiReference); + const exportContract = apiReference + .split('### Export Configuration')[1] + ?.split('### Import Configuration')[0] + ?.replace(/\s+/g, ' '); + const importContract = apiReference + .split('### Import Configuration')[1] + ?.split('---')[0] + ?.replace(/\s+/g, ' '); + expect(exportContract).toContain('tenant manager'); + expect(exportContract).toContain('settings:read'); + expect(importContract).toContain('tenant manager'); + expect(importContract).toContain('settings:write'); + }); + it('routes runtime docs links through shipped local docs instead of GitHub main', () => { expect(apiAccessPanelSource).toContain('API_TOKEN_SCOPES_DOC_URL'); expect(apiAccessPanelSource).not.toContain( diff --git a/internal/api/config_transfer_authorization.go b/internal/api/config_transfer_authorization.go new file mode 100644 index 000000000..c2d17d38a --- /dev/null +++ b/internal/api/config_transfer_authorization.go @@ -0,0 +1,152 @@ +package api + +import ( + "net/http" + "os" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" + "github.com/rs/zerolog/log" +) + +type configTransferOperation string + +const ( + configTransferExport configTransferOperation = "export" + configTransferImport configTransferOperation = "import" +) + +func (op configTransferOperation) requiredScope() string { + if op == configTransferImport { + return config.ScopeSettingsWrite + } + return config.ScopeSettingsRead +} + +// configTransferAuthenticationConfigured is the canonical fail-closed view of +// whether configuration transfer must authenticate. It deliberately includes +// hosted operation and an uncertain SSO load: neither state may fall back to +// unauthenticated recovery. +func (r *Router) configTransferAuthenticationConfigured() bool { + if r == nil || r.config == nil { + return true + } + + config.Mu.RLock() + localAuthConfigured := strings.TrimSpace(r.config.AuthUser) != "" || strings.TrimSpace(r.config.AuthPass) != "" + tokenAuthConfigured := r.config.HasAPITokens() + proxyAuthConfigured := strings.TrimSpace(r.config.ProxyAuthSecret) != "" + config.Mu.RUnlock() + + return localAuthConfigured || + tokenAuthConfigured || + proxyAuthConfigured || + hasEnabledSSOProvidersForAuth(r.config) || + r.hostedMode || + r.ssoAuthenticationLoadFailed() +} + +func (r *Router) allowUnauthenticatedConfigTransfer(req *http.Request, op configTransferOperation) bool { + if r.configTransferAuthenticationConfigured() { + return false + } + if isDirectLoopbackRequest(req) { + return true + } + return op == configTransferExport && os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true" +} + +// authorizeConfigTransfer is the single route-local boundary for export and +// import. It runs before either handler receives the request, so denial cannot +// parse an archive, read export persistence, write import state, or reload the +// runtime. +func (r *Router) authorizeConfigTransfer(w http.ResponseWriter, req *http.Request, op configTransferOperation) bool { + if adminBypassEnabled() { + return true + } + if r == nil || r.config == nil { + http.Error(w, "Configuration authorization unavailable", http.StatusServiceUnavailable) + return false + } + + scope := op.requiredScope() + + // Explicit API-token credentials take precedence over every browser + // credential. AuthContextMiddleware has already validated the token against + // the resolved tenant config, and TenantMiddleware has enforced its org + // binding before this route can run. + if _, provided := explicitAPITokenFromRequest(req); provided { + record := getAPITokenRecordFromRequest(req) + if record == nil { + http.Error(w, "Invalid API token", http.StatusUnauthorized) + return false + } + if !record.HasScope(scope) { + respondMissingScope(w, scope) + return false + } + return true + } + + // A valid proxy identity is authoritative. Membership-like proxy roles are + // insufficient for a secret-bearing transfer. + if strings.TrimSpace(r.config.ProxyAuthSecret) != "" { + if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid { + if !isAdmin { + logAuthDenial(req, username, "Non-admin proxy user attempted configuration transfer", nil) + http.Error(w, "Admin privileges required for configuration transfer", http.StatusForbidden) + return false + } + return true + } + } + + // A presented valid session must itself carry management authority. For a + // tenant this is CanUserIDManage on the resolved organization; for the + // default organization it is the canonical instance-admin rule. + if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" { + session := GetSessionStore().GetSession(cookie.Value) + validSession := session != nil && ValidateSession(cookie.Value) + if validSession && session.RecoveryBypass { + validSession = requestMatchesRecoverySession(req, session) + } + if validSession { + if !ensureAdminSession(r.config, w, req) { + return false + } + return true + } + } + + // HTTP Basic represents the configured instance administrator. Validate it + // directly so this decision cannot accidentally inherit no-auth fallback. + if username, password, ok := req.BasicAuth(); ok { + config.Mu.RLock() + configuredUser := r.config.AuthUser + configuredHash := r.config.AuthPass + config.Mu.RUnlock() + if configuredUser != "" && configuredHash != "" && + constantTimeStringEqual(username, configuredUser) && + internalauth.CheckPasswordHash(password, configuredHash) { + return true + } + } + + if r.allowUnauthenticatedConfigTransfer(req, op) { + return true + } + + if r.configTransferAuthenticationConfigured() { + logAuthDenial(req, "", "Unauthenticated configuration transfer attempt", nil) + http.Error(w, "Unauthorized - please log in or provide an API token", http.StatusUnauthorized) + return false + } + + log.Warn(). + Str("ip", req.RemoteAddr). + Str("operation", string(op)). + Msg("Configuration transfer blocked outside direct loopback recovery policy") + http.Error(w, "Configuration transfer requires authentication outside direct loopback", http.StatusForbidden) + return false +} diff --git a/internal/api/config_transfer_authorization_test.go b/internal/api/config_transfer_authorization_test.go new file mode 100644 index 000000000..66f34f5be --- /dev/null +++ b/internal/api/config_transfer_authorization_test.go @@ -0,0 +1,512 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" +) + +type countingConfigTransferBody struct { + reader io.Reader + reads int +} + +func (b *countingConfigTransferBody) Read(p []byte) (int, error) { + b.reads++ + return b.reader.Read(p) +} + +func newConfigTransferTestRouter(t *testing.T, hosted bool, sso *config.SSOConfig) *Router { + t.Helper() + dataDir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", dataDir) + if hosted { + t.Setenv("PULSE_HOSTED_MODE", "true") + } else { + t.Setenv("PULSE_HOSTED_MODE", "false") + } + + cfg := &config.Config{DataPath: dataDir, ConfigPath: dataDir} + if sso != nil { + if err := config.NewConfigPersistence(dataDir).SaveSSOConfig(sso); err != nil { + t.Fatalf("save synthetic SSO config: %v", err) + } + } + return NewRouter(cfg, nil, nil, nil, nil, "test") +} + +func enabledConfigTransferSSO(providerType config.SSOProviderType) *config.SSOConfig { + provider := config.SSOProvider{ID: "synthetic", Name: "Synthetic", Type: providerType, Enabled: true} + if providerType == config.SSOProviderTypeOIDC { + provider.OIDC = &config.OIDCProviderConfig{IssuerURL: "https://idp.invalid", ClientID: "synthetic"} + } else { + provider.SAML = &config.SAMLProviderConfig{IDPEntityID: "https://idp.invalid"} + } + return &config.SSOConfig{Providers: []config.SSOProvider{provider}} +} + +func configTransferRequest(t *testing.T, router *Router, path, remoteAddr, body string) (*httptest.ResponseRecorder, *countingConfigTransferBody) { + t.Helper() + counted := &countingConfigTransferBody{reader: strings.NewReader(body)} + req := httptest.NewRequest(http.MethodPost, path, counted) + req.RemoteAddr = remoteAddr + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + return rec, counted +} + +func TestConfigTransferAnonymousAuthenticatedModesDenyBeforeBodyRead(t *testing.T) { + tests := []struct { + name string + hosted bool + sso *config.SSOConfig + }{ + {name: "persisted OIDC", sso: enabledConfigTransferSSO(config.SSOProviderTypeOIDC)}, + {name: "persisted SAML", sso: enabledConfigTransferSSO(config.SSOProviderTypeSAML)}, + {name: "hosted", hosted: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + router := newConfigTransferTestRouter(t, tc.hosted, tc.sso) + requests := []struct { + path string + body string + }{ + {path: "/api/config/export", body: `{not-json`}, + {path: "/api/config/export", body: `{"passphrase":"synthetic-passphrase"}`}, + {path: "/api/config/import", body: `{not-json`}, + {path: "/api/config/import", body: `{"passphrase":"synthetic-passphrase","data":"synthetic-archive"}`}, + } + for i, request := range requests { + remoteAddr := "127.0.0." + string(rune('1'+i)) + ":1234" + rec, body := configTransferRequest(t, router, request.path, remoteAddr, request.body) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s status = %d, want 401: %s", request.path, rec.Code, rec.Body.String()) + } + if body.reads != 0 { + t.Errorf("%s read denied request body %d times", request.path, body.reads) + } + } + }) + } +} + +func TestConfigTransferEnvironmentOIDCAndSSOLoadFailureFailClosed(t *testing.T) { + t.Run("environment OIDC", func(t *testing.T) { + t.Setenv("OIDC_ENABLED", "true") + t.Setenv("OIDC_ISSUER_URL", "https://idp.invalid") + t.Setenv("OIDC_CLIENT_ID", "synthetic") + router := newConfigTransferTestRouter(t, false, nil) + for _, path := range []string{"/api/config/export", "/api/config/import"} { + rec, body := configTransferRequest(t, router, path, "127.0.0.1:1234", `{not-json`) + if rec.Code != http.StatusUnauthorized || body.reads != 0 { + t.Errorf("%s environment OIDC denial = status %d, reads %d", path, rec.Code, body.reads) + } + } + }) + + t.Run("unreadable persisted SSO", func(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", dataDir) + t.Setenv("PULSE_HOSTED_MODE", "false") + // Establish the synthetic encryption key before introducing a corrupt + // encrypted payload; production persistence is never consulted. + config.NewConfigPersistence(dataDir) + if err := os.WriteFile(filepath.Join(dataDir, "sso.enc"), []byte("synthetic-corruption"), 0o600); err != nil { + t.Fatalf("write corrupt synthetic SSO: %v", err) + } + cfg := &config.Config{DataPath: dataDir, ConfigPath: dataDir} + router := NewRouter(cfg, nil, nil, nil, nil, "test") + if !router.ssoAuthenticationLoadFailed() { + t.Fatal("precondition: corrupt persisted SSO was not recorded as a load failure") + } + rec, body := configTransferRequest(t, router, "/api/config/export", "127.0.0.1:1234", `{not-json`) + if rec.Code != http.StatusUnauthorized || body.reads != 0 { + t.Fatalf("SSO load failure denial = status %d, reads %d", rec.Code, body.reads) + } + }) +} + +func TestConfigTransferNoAuthUsesDirectLoopbackPolicy(t *testing.T) { + t.Setenv("ALLOW_UNPROTECTED_EXPORT", "false") + router := newConfigTransferTestRouter(t, false, nil) + + for _, path := range []string{"/api/config/export", "/api/config/import"} { + rec, body := configTransferRequest(t, router, path, "192.168.1.50:1234", `{not-json`) + if rec.Code != http.StatusForbidden { + t.Errorf("%s private-peer status = %d, want 403: %s", path, rec.Code, rec.Body.String()) + } + if body.reads != 0 { + t.Errorf("%s read private-peer denied body %d times", path, body.reads) + } + } + + for _, path := range []string{"/api/config/export", "/api/config/import"} { + rec, body := configTransferRequest(t, router, path, "127.0.0.1:1234", `{not-json`) + if rec.Code != http.StatusBadRequest { + t.Errorf("%s loopback status = %d, want handler 400: %s", path, rec.Code, rec.Body.String()) + } + if body.reads == 0 { + t.Errorf("%s loopback request did not reach handler", path) + } + } +} + +func TestAllowUnprotectedExportIsExportOnly(t *testing.T) { + t.Setenv("ALLOW_UNPROTECTED_EXPORT", "true") + router := newConfigTransferTestRouter(t, false, nil) + + exportRec, exportBody := configTransferRequest(t, router, "/api/config/export", "203.0.113.10:1234", `{not-json`) + if exportRec.Code != http.StatusBadRequest || exportBody.reads == 0 { + t.Fatalf("unprotected export did not reach handler: status=%d reads=%d body=%s", exportRec.Code, exportBody.reads, exportRec.Body.String()) + } + + importRec, importBody := configTransferRequest(t, router, "/api/config/import", "203.0.113.10:1234", `{not-json`) + if importRec.Code != http.StatusForbidden { + t.Fatalf("import status = %d, want 403: %s", importRec.Code, importRec.Body.String()) + } + if importBody.reads != 0 { + t.Fatalf("import override denial read body %d times", importBody.reads) + } +} + +func TestAllowUnprotectedExportCannotOverrideAuthentication(t *testing.T) { + t.Setenv("ALLOW_UNPROTECTED_EXPORT", "true") + router := newConfigTransferTestRouter(t, false, enabledConfigTransferSSO(config.SSOProviderTypeOIDC)) + for _, path := range []string{"/api/config/export", "/api/config/import"} { + rec, body := configTransferRequest(t, router, path, "203.0.113.12:1234", `{not-json`) + if rec.Code != http.StatusUnauthorized || body.reads != 0 { + t.Errorf("%s authenticated override denial = status %d, reads %d", path, rec.Code, body.reads) + } + } +} + +func TestConfigTransferNoAuthRejectsForwardedLoopback(t *testing.T) { + t.Setenv("ALLOW_UNPROTECTED_EXPORT", "false") + router := newConfigTransferTestRouter(t, false, nil) + for _, header := range []string{"X-Forwarded-For", "Forwarded", "X-Real-IP"} { + body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)} + req := httptest.NewRequest(http.MethodPost, "/api/config/import", body) + req.RemoteAddr = "127.0.0.1:1234" + req.Header.Set(header, "127.0.0.1") + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || body.reads != 0 { + t.Errorf("%s forwarded loopback = status %d, reads %d", header, rec.Code, body.reads) + } + } +} + +func TestConfigTransferAuthorizedInstanceModesReachHandler(t *testing.T) { + hash, err := internalauth.HashPassword("SyntheticPassword!1") + if err != nil { + t.Fatalf("hash password: %v", err) + } + + t.Run("basic admin", func(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", dataDir) + cfg := &config.Config{DataPath: dataDir, ConfigPath: dataDir, AuthUser: "admin", AuthPass: hash} + router := NewRouter(cfg, nil, nil, nil, nil, "test") + for _, path := range []string{"/api/config/export", "/api/config/import"} { + body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)} + req := httptest.NewRequest(http.MethodPost, path, body) + req.SetBasicAuth("admin", "SyntheticPassword!1") + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || body.reads == 0 { + t.Errorf("%s basic admin = status %d, reads %d", path, rec.Code, body.reads) + } + } + }) + + for _, providerType := range []config.SSOProviderType{config.SSOProviderTypeOIDC, config.SSOProviderTypeSAML} { + t.Run("SSO session "+string(providerType), func(t *testing.T) { + router := newConfigTransferTestRouter(t, false, enabledConfigTransferSSO(providerType)) + sessionToken := "synthetic-sso-session-" + string(providerType) + GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "sso:owner@example.invalid") + csrf := generateCSRFToken(sessionToken) + for _, path := range []string{"/api/config/export", "/api/config/import"} { + body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)} + req := httptest.NewRequest(http.MethodPost, path, body) + req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken}) + req.Header.Set("X-CSRF-Token", csrf) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || body.reads == 0 { + t.Errorf("%s SSO admin = status %d, reads %d", path, rec.Code, body.reads) + } + } + }) + } + + t.Run("proxy admin", func(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", dataDir) + cfg := &config.Config{ + DataPath: dataDir, ConfigPath: dataDir, + ProxyAuthSecret: "synthetic-proxy-secret", ProxyAuthUserHeader: "X-Proxy-User", + ProxyAuthRoleHeader: "X-Proxy-Roles", ProxyAuthAdminRole: "admin", + } + router := NewRouter(cfg, nil, nil, nil, nil, "test") + for _, path := range []string{"/api/config/export", "/api/config/import"} { + body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)} + req := httptest.NewRequest(http.MethodPost, path, body) + req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) + req.Header.Set("X-Proxy-User", "proxy-admin") + req.Header.Set("X-Proxy-Roles", "viewer|admin") + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || body.reads == 0 { + t.Errorf("%s proxy admin = status %d, reads %d", path, rec.Code, body.reads) + } + } + }) +} + +func TestConfigTransferTokenScopesAndOrganizationBinding(t *testing.T) { + readRaw := "synthetic-read-token-123.12345678" + writeRaw := "synthetic-write-token-123.12345678" + readRecord := newTokenRecord(t, readRaw, []string{config.ScopeSettingsRead}, nil) + writeRecord := newTokenRecord(t, writeRaw, []string{config.ScopeSettingsWrite}, nil) + cfg := newTestConfigWithTokens(t, readRecord, writeRecord) + t.Setenv("PULSE_DATA_DIR", cfg.DataPath) + router := NewRouter(cfg, nil, nil, nil, nil, "test") + + tests := []struct { + name string + path string + token string + bearer bool + status int + reads bool + }{ + {name: "read scope exports", path: "/api/config/export", token: readRaw, status: http.StatusBadRequest, reads: true}, + {name: "read scope cannot import", path: "/api/config/import", token: readRaw, status: http.StatusForbidden}, + {name: "write scope imports with bearer", path: "/api/config/import", token: writeRaw, bearer: true, status: http.StatusBadRequest, reads: true}, + {name: "write scope cannot export", path: "/api/config/export", token: writeRaw, status: http.StatusForbidden}, + {name: "invalid token", path: "/api/config/export", token: "invalid-synthetic-token", status: http.StatusUnauthorized}, + } + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)} + req := httptest.NewRequest(http.MethodPost, tc.path, body) + req.RemoteAddr = "127.0.1." + string(rune('1'+i)) + ":1234" + if tc.bearer { + req.Header.Set("Authorization", "Bearer "+tc.token) + } else { + req.Header.Set("X-API-Token", tc.token) + } + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != tc.status { + t.Fatalf("status = %d, want %d: %s", rec.Code, tc.status, rec.Body.String()) + } + if (body.reads > 0) != tc.reads { + t.Fatalf("body reads = %d, want reached=%v", body.reads, tc.reads) + } + }) + } + + boundRaw := "synthetic-bound-token-123.12345678" + boundRecord := newTokenRecord(t, boundRaw, []string{config.ScopeSettingsRead}, nil) + boundRecord.OrgID = "tenant-a" + boundCfg := newTestConfigWithTokens(t, boundRecord) + t.Setenv("PULSE_DATA_DIR", boundCfg.DataPath) + boundRouter := NewRouter(boundCfg, nil, nil, nil, nil, "test") + body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)} + req := httptest.NewRequest(http.MethodPost, "/api/config/export", body) + req.Header.Set("X-API-Token", boundRaw) + req.Header.Set("X-Pulse-Org-ID", "default") + rec := httptest.NewRecorder() + boundRouter.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || body.reads != 0 { + t.Fatalf("cross-organization token denial = status %d, reads %d", rec.Code, body.reads) + } +} + +func TestConfigTransferTenantSessionsRequireManagement(t *testing.T) { + defer SetMultiTenantEnabled(false) + SetMultiTenantEnabled(true) + t.Setenv("PULSE_DEV", "true") + dataDir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", dataDir) + hash, err := internalauth.HashPassword("SyntheticPassword!1") + if err != nil { + t.Fatalf("hash password: %v", err) + } + tenantTokenRaw := "synthetic-tenant-token-123.12345678" + tenantToken := newTokenRecord(t, tenantTokenRaw, []string{config.ScopeSettingsRead}, nil) + tenantToken.OrgID = "tenant-a" + cfg := &config.Config{ + DataPath: dataDir, ConfigPath: dataDir, AuthUser: "instance-admin", AuthPass: hash, + APITokens: []config.APITokenRecord{tenantToken}, + } + org := &models.Organization{ + ID: "tenant-a", DisplayName: "Tenant A", OwnerUserID: "owner", + Members: []models.OrganizationMember{ + {UserID: "owner", Role: models.OrgRoleOwner, AddedAt: time.Now()}, + {UserID: "manager", Role: models.OrgRoleAdmin, AddedAt: time.Now()}, + {UserID: "viewer", Role: models.OrgRoleViewer, AddedAt: time.Now()}, + }, + } + mtp := config.NewMultiTenantPersistence(dataDir) + if err := mtp.SaveOrganization(org); err != nil { + t.Fatalf("save synthetic organization: %v", err) + } + if err := mtp.SaveOrganization(&models.Organization{ID: "tenant-b", DisplayName: "Tenant B", OwnerUserID: "other-owner"}); err != nil { + t.Fatalf("save cross-organization fixture: %v", err) + } + mtm := monitoring.NewMultiTenantMonitor(cfg, mtp, nil) + t.Cleanup(mtm.Stop) + router := NewRouter(cfg, nil, mtm, nil, nil, "test") + + tests := []struct { + user string + status int + reached bool + }{ + {user: "viewer", status: http.StatusForbidden}, + {user: "manager", status: http.StatusBadRequest, reached: true}, + {user: "owner", status: http.StatusBadRequest, reached: true}, + } + for i, tc := range tests { + t.Run(tc.user, func(t *testing.T) { + sessionToken := "tenant-transfer-session-" + tc.user + GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", tc.user) + body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)} + req := httptest.NewRequest(http.MethodPost, "/api/config/import", body) + req.RemoteAddr = "127.0.2." + string(rune('1'+i)) + ":1234" + req.Header.Set("X-Pulse-Org-ID", "tenant-a") + req.Header.Set("X-CSRF-Token", generateCSRFToken(sessionToken)) + req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken}) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != tc.status { + t.Fatalf("status = %d, want %d: %s", rec.Code, tc.status, rec.Body.String()) + } + if (body.reads > 0) != tc.reached { + t.Fatalf("body reads = %d, want reached=%v", body.reads, tc.reached) + } + }) + } + + t.Run("organization-bound token selects and reaches its tenant", func(t *testing.T) { + body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)} + req := httptest.NewRequest(http.MethodPost, "/api/config/export", body) + req.Header.Set("X-API-Token", tenantTokenRaw) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || body.reads == 0 { + t.Fatalf("bound tenant token = status %d, reads %d: %s", rec.Code, body.reads, rec.Body.String()) + } + }) + + t.Run("organization-bound token cannot cross tenants", func(t *testing.T) { + body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)} + req := httptest.NewRequest(http.MethodPost, "/api/config/export", body) + req.Header.Set("X-API-Token", tenantTokenRaw) + req.Header.Set("X-Pulse-Org-ID", "tenant-b") + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || body.reads != 0 { + t.Fatalf("cross-tenant token = status %d, reads %d: %s", rec.Code, body.reads, rec.Body.String()) + } + }) +} + +func TestSecurityStatusMatchesConfigTransferPolicy(t *testing.T) { + t.Run("hosted requires authentication", func(t *testing.T) { + router := newConfigTransferTestRouter(t, true, nil) + req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + var status map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil { + t.Fatalf("decode hosted security status: %v", err) + } + if status["requiresAuth"] != true || status["hasAuthentication"] != true { + t.Fatalf("hosted security status did not report required auth: %#v", status) + } + }) + + t.Run("export override is ineffective with SSO", func(t *testing.T) { + t.Setenv("ALLOW_UNPROTECTED_EXPORT", "true") + router := newConfigTransferTestRouter(t, false, enabledConfigTransferSSO(config.SSOProviderTypeOIDC)) + sessionToken := "synthetic-security-status-session" + GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "sso:owner@example.invalid") + 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) + var status map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil { + t.Fatalf("decode SSO security status: %v", err) + } + if status["detailLevel"] != securityStatusDetailPrivileged { + t.Fatalf("security status detail = %v, want privileged: %#v", status["detailLevel"], status) + } + if status["exportProtected"] != true || status["unprotectedExportAllowed"] != false { + t.Fatalf("security status misreported authenticated export override: %#v", status) + } + }) +} + +func TestDeniedConfigTransferDoesNotMutateOrReload(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", dataDir) + hash, err := internalauth.HashPassword("SyntheticPassword!1") + if err != nil { + t.Fatalf("hash password: %v", err) + } + cfg := &config.Config{ + DataPath: dataDir, ConfigPath: dataDir, + AuthUser: "synthetic-admin", AuthPass: hash, PublicURL: "https://before.invalid", + } + reloadCalls := 0 + router := NewRouter(cfg, nil, nil, nil, func() error { + reloadCalls++ + return nil + }, "test") + + requests := []struct { + path string + body string + }{ + {path: "/api/config/export", body: `{"passphrase":"synthetic-passphrase"}`}, + {path: "/api/config/import", body: `{"passphrase":"synthetic-passphrase","data":"synthetic-archive"}`}, + {path: "/api/config/import", body: `{not-json`}, + } + for i, request := range requests { + body := &countingConfigTransferBody{reader: strings.NewReader(request.body)} + req := httptest.NewRequest(http.MethodPost, request.path, body) + req.RemoteAddr = "203.0.113." + string(rune('1'+i)) + ":1234" + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s denied status = %d: %s", request.path, rec.Code, rec.Body.String()) + } + if body.reads != 0 { + t.Fatalf("%s denial read request body %d times", request.path, body.reads) + } + } + + if reloadCalls != 0 { + t.Fatalf("denied import reloaded runtime %d times", reloadCalls) + } + if cfg.PublicURL != "https://before.invalid" || cfg.AuthUser != "synthetic-admin" || cfg.AuthPass != hash { + t.Fatalf("denied import mutated live config: %+v", cfg) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 95b68966b..3458b809b 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -67,6 +67,54 @@ import ( tmock "github.com/stretchr/testify/mock" ) +func TestContract_ConfigTransferRoutesUseCanonicalPreHandlerAuthorization(t *testing.T) { + routesSource, err := os.ReadFile(filepath.Clean("router_routes_registration.go")) + if err != nil { + t.Fatalf("read config route registration: %v", err) + } + routes := string(routesSource) + if got := strings.Count(routes, "authorizeConfigTransfer(w, req,"); got != 2 { + t.Fatalf("config transfer authorization calls = %d, want exactly export and import", got) + } + if strings.Contains(routes, "ALLOW_UNPROTECTED_EXPORT") || strings.Contains(routes, "isPrivateIP(") { + t.Fatal("config transfer routes reintroduced a local override or private-network approximation") + } + for _, sequence := range []struct { + authorize string + handler string + }{ + {authorize: "authorizeConfigTransfer(w, req, configTransferExport)", handler: "HandleExportConfig(w, req)"}, + {authorize: "authorizeConfigTransfer(w, req, configTransferImport)", handler: "HandleImportConfig(w, req)"}, + } { + authorizeAt := strings.Index(routes, sequence.authorize) + handlerAt := strings.Index(routes, sequence.handler) + if authorizeAt < 0 || handlerAt < 0 || authorizeAt >= handlerAt { + t.Fatalf("%s must precede %s", sequence.authorize, sequence.handler) + } + } + + authoritySource, err := os.ReadFile(filepath.Clean("config_transfer_authorization.go")) + if err != nil { + t.Fatalf("read canonical config transfer authority: %v", err) + } + authority := string(authoritySource) + for _, invariant := range []string{ + "hasEnabledSSOProvidersForAuth(r.config)", + "r.hostedMode", + "r.ssoAuthenticationLoadFailed()", + "isDirectLoopbackRequest(req)", + "op == configTransferExport", + "getAPITokenRecordFromRequest(req)", + "ensureAdminSession(r.config, w, req)", + "config.ScopeSettingsRead", + "config.ScopeSettingsWrite", + } { + if !strings.Contains(authority, invariant) { + t.Fatalf("canonical config transfer authority missing %q", invariant) + } + } +} + type basicActionContractAuthorizer struct { wantUser string } diff --git a/internal/api/identity_sso_handlers.go b/internal/api/identity_sso_handlers.go index 3282d9ca1..56542571d 100644 --- a/internal/api/identity_sso_handlers.go +++ b/internal/api/identity_sso_handlers.go @@ -236,7 +236,10 @@ func (r *Router) ensureSSOConfig() *config.SSOConfig { if r.persistence != nil { cfg, err := r.persistence.LoadSSOConfig() if err != nil { + r.ssoAuthLoadFailed.Store(true) log.Error().Err(err).Msg("Failed to load SSO config from persistence") + } else { + r.ssoAuthLoadFailed.Store(false) } if cfg != nil { r.ssoConfig = cfg @@ -260,6 +263,10 @@ func (r *Router) ensureSSOConfig() *config.SSOConfig { return r.ssoConfig } +func (r *Router) ssoAuthenticationLoadFailed() bool { + return r != nil && r.ssoAuthLoadFailed.Load() +} + func (r *Router) handleListSSOProviders(w http.ResponseWriter, req *http.Request) { r.ensureSSOConfig() @@ -622,12 +629,14 @@ func (r *Router) handleDeleteSSOProvider(w http.ResponseWriter, req *http.Reques func (r *Router) saveSSOConfig() error { if r.persistence == nil { setSSOAuthSnapshot(r.config, r.ssoConfig) + r.ssoAuthLoadFailed.Store(false) return nil } if err := r.persistence.SaveSSOConfig(r.ssoConfig); err != nil { return err } setSSOAuthSnapshot(r.config, r.ssoConfig) + r.ssoAuthLoadFailed.Store(false) return nil } diff --git a/internal/api/router.go b/internal/api/router.go index 5d0961a1d..7f7307148 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -22,6 +22,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" @@ -149,6 +150,7 @@ type Router struct { oidcManager *OIDCServiceManager samlManager *SAMLServiceManager ssoConfig *config.SSOConfig + ssoAuthLoadFailed atomic.Bool sessionStore *SessionStore csrfStore *CSRFTokenStore recoveryTokenStore *RecoveryTokenStore @@ -335,6 +337,10 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, mtMonitor *monit // Initialize SSO service managers r.oidcManager = NewOIDCServiceManager() r.samlManager = NewSAMLServiceManager("") + // Load persisted and environment-backed SSO before routes begin serving. + // Configuration transfer and no-auth recovery must never observe the old + // lazy-loading window as an unauthenticated installation. + r.ensureSSOConfig() if err := r.syncSAMLPublicURL(); err != nil { log.Error().Err(err).Msg("Failed to initialize SAML public URL") } diff --git a/internal/api/router_routes_auth_security.go b/internal/api/router_routes_auth_security.go index d47c4ae7a..5febfefe6 100644 --- a/internal/api/router_routes_auth_security.go +++ b/internal/api/router_routes_auth_security.go @@ -82,17 +82,7 @@ type privilegedSecurityStatusResponse struct { } func (r *Router) securityAuthenticationConfigured() bool { - if r == nil || r.config == nil { - return false - } - if (r.config.AuthUser != "" && r.config.AuthPass != "") || - r.config.HasAPITokens() || - r.config.ProxyAuthSecret != "" || - r.hostedMode { - return true - } - ssoCfg := r.ensureSSOConfig() - return ssoCfg != nil && ssoCfg.HasEnabledProviders() + return r.configTransferAuthenticationConfigured() } func (r *Router) authorizeSecurityRestart(w http.ResponseWriter, req *http.Request) bool { @@ -342,18 +332,10 @@ func (r *Router) registerAuthSecurityInstallRoutes() { ssoProviders = append(ssoProviders, info) } + requiresAuth := r.securityAuthenticationConfigured() hasAuthentication := os.Getenv("PULSE_AUTH_USER") != "" || os.Getenv("REQUIRE_AUTH") == "true" || - r.config.AuthUser != "" || - r.config.AuthPass != "" || - r.config.HasAPITokens() || - r.config.ProxyAuthSecret != "" || - r.hostedMode || - hasEnabledSSO - requiresAuth := r.config.HasAPITokens() || - (r.config.AuthUser != "" && r.config.AuthPass != "") || - r.config.ProxyAuthSecret != "" || - hasEnabledSSO + requiresAuth publicStatus := publicSecurityStatusResponse{ DetailLevel: securityStatusDetailPublic, @@ -435,12 +417,13 @@ func (r *Router) registerAuthSecurityInstallRoutes() { authenticatedStatus.DetailLevel = securityStatusDetailPrivileged authenticatedStatus.HasProxyAuth = hasProxyAuth + unprotectedExportAllowed := !r.configTransferAuthenticationConfigured() && os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true" status := privilegedSecurityStatusResponse{ authenticatedSecurityStatusResponse: authenticatedStatus, APITokenConfigured: r.config.HasAPITokens(), APITokenHint: r.config.PrimaryAPITokenHint(), - ExportProtected: r.config.HasAPITokens() || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true", - UnprotectedExportAllowed: os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true", + ExportProtected: !unprotectedExportAllowed, + UnprotectedExportAllowed: unprotectedExportAllowed, ConfiguredButPendingRestart: configuredButPendingRestart, HasAuditLogging: os.Getenv("PULSE_AUDIT_LOG") == "true" || os.Getenv("AUDIT_LOG_ENABLED") == "true", CredentialsEncrypted: true, diff --git a/internal/api/router_routes_registration.go b/internal/api/router_routes_registration.go index 9f9cd1877..76565ed23 100644 --- a/internal/api/router_routes_registration.go +++ b/internal/api/router_routes_registration.go @@ -3,14 +3,12 @@ package api import ( "encoding/json" "net/http" - "os" "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" - "github.com/rs/zerolog/log" ) const featureAgentProfilesKey = "agent_profiles" @@ -428,135 +426,14 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } }) - // Config export/import routes (requires authentication) + // Config export/import routes. These stay globally public so the deliberate + // no-auth recovery policy can operate; authorizeConfigTransfer is the single + // fail-closed boundary for every authenticated, hosted, and tenant mode. r.mux.HandleFunc("/api/config/export", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodPost { - // Check proxy auth first - hasValidProxyAuth := false - proxyAuthIsAdmin := false - if r.config.ProxyAuthSecret != "" { - if valid, _, isAdmin := CheckProxyAuth(r.config, req); valid { - hasValidProxyAuth = true - proxyAuthIsAdmin = isAdmin - } - } - - // Check authentication - accept proxy auth, session auth or API token - hasValidSession := false - sessionUsername := "" - sessionIsAdmin := false - if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" { - hasValidSession = ValidateSession(cookie.Value) - if hasValidSession { - sessionUsername = strings.TrimSpace(GetSessionUsername(cookie.Value)) - // Same admin test the settings routes apply. Comparing - // against r.config.AuthUser alone cannot match on an - // instance whose only administrators are SSO principals, - // which locked those operators out of their own config - // export and import. - sessionIsAdmin = sessionUserCarriesAdminPrivileges(r.config, sessionUsername) - } - } - - validateAPIToken := func(token string) bool { - if token == "" || !r.config.HasAPITokens() { - return false - } - _, ok := r.config.ValidateAPIToken(token) - return ok - } - - token := req.Header.Get("X-API-Token") - if token == "" { - if authHeader := req.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") { - token = strings.TrimPrefix(authHeader, "Bearer ") - } - } - hasValidAPIToken := validateAPIToken(token) - - // Check if any valid auth method is present - hasValidAuth := hasValidProxyAuth || sessionIsAdmin || hasValidAPIToken - - // Determine if auth is required - authRequired := r.config.AuthUser != "" && r.config.AuthPass != "" || - r.config.HasAPITokens() || - r.config.ProxyAuthSecret != "" - - // Check admin privileges for proxy auth users - if hasValidProxyAuth && !proxyAuthIsAdmin { - log.Warn(). - Str("ip", req.RemoteAddr). - Str("path", req.URL.Path). - Msg("Non-admin proxy auth user attempted export/import") - http.Error(w, "Admin privileges required for export/import", http.StatusForbidden) + if !r.authorizeConfigTransfer(w, req, configTransferExport) { return } - if authRequired && hasValidSession && !sessionIsAdmin { - log.Warn(). - Str("ip", req.RemoteAddr). - Str("path", req.URL.Path). - Str("user", sessionUsername). - Msg("Non-admin session user attempted export/import") - http.Error(w, "Admin privileges required for export/import", http.StatusForbidden) - return - } - - if authRequired && !hasValidAuth { - log.Warn(). - Str("ip", req.RemoteAddr). - Str("path", req.URL.Path). - Bool("proxyAuth", hasValidProxyAuth). - Bool("session", sessionIsAdmin). - Bool("apiToken", hasValidAPIToken). - Msg("Unauthorized export attempt") - http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized) - return - } else if !authRequired { - // No auth configured - check if this is a homelab/private network - clientIP := GetClientIP(req) - - isPrivate := isPrivateIP(clientIP) - allowUnprotected := os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true" - - if !isPrivate && !allowUnprotected { - // Public network access without auth - definitely block - log.Warn(). - Str("ip", req.RemoteAddr). - Bool("private_network", isPrivate). - Msg("Export blocked - public network requires authentication") - http.Error(w, "Export requires authentication on public networks", http.StatusForbidden) - return - } else if isPrivate && !allowUnprotected { - // Private network but ALLOW_UNPROTECTED_EXPORT not set - show helpful message - log.Info(). - Str("ip", req.RemoteAddr). - Msg("Export allowed - private network with no auth") - // Continue - allow export on private networks for homelab users - } - } - - // SECURITY: Check settings:read scope for API token auth - if hasValidAPIToken && token != "" { - record, _ := r.config.ValidateAPIToken(token) - if record != nil && !record.HasScope(config.ScopeSettingsRead) { - log.Warn(). - Str("ip", req.RemoteAddr). - Str("path", req.URL.Path). - Str("token_id", record.ID). - Msg("API token missing settings:read scope for export") - http.Error(w, "API token missing required scope: settings:read", http.StatusForbidden) - return - } - } - - // Log successful export attempt - log.Info(). - Str("ip", req.RemoteAddr). - Bool("proxy_auth", hasValidProxyAuth). - Bool("session_auth", sessionIsAdmin). - Bool("api_token_auth", hasValidAPIToken). - Msg("Configuration export initiated") - r.configHandlers.HandleExportConfig(w, req) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -565,131 +442,9 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) { r.mux.HandleFunc("/api/config/import", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodPost { - // Check proxy auth first - hasValidProxyAuth := false - proxyAuthIsAdmin := false - if r.config.ProxyAuthSecret != "" { - if valid, _, isAdmin := CheckProxyAuth(r.config, req); valid { - hasValidProxyAuth = true - proxyAuthIsAdmin = isAdmin - } - } - - // Check authentication - accept proxy auth, session auth or API token - hasValidSession := false - sessionUsername := "" - sessionIsAdmin := false - if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" { - hasValidSession = ValidateSession(cookie.Value) - if hasValidSession { - sessionUsername = strings.TrimSpace(GetSessionUsername(cookie.Value)) - // Same admin test the settings routes apply. Comparing - // against r.config.AuthUser alone cannot match on an - // instance whose only administrators are SSO principals, - // which locked those operators out of their own config - // export and import. - sessionIsAdmin = sessionUserCarriesAdminPrivileges(r.config, sessionUsername) - } - } - - validateAPIToken := func(token string) bool { - if token == "" || !r.config.HasAPITokens() { - return false - } - _, ok := r.config.ValidateAPIToken(token) - return ok - } - - token := req.Header.Get("X-API-Token") - if token == "" { - if authHeader := req.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") { - token = strings.TrimPrefix(authHeader, "Bearer ") - } - } - hasValidAPIToken := validateAPIToken(token) - - // Check if any valid auth method is present - hasValidAuth := hasValidProxyAuth || sessionIsAdmin || hasValidAPIToken - - // Determine if auth is required - authRequired := r.config.AuthUser != "" && r.config.AuthPass != "" || - r.config.HasAPITokens() || - r.config.ProxyAuthSecret != "" - - // Check admin privileges for proxy auth users - if hasValidProxyAuth && !proxyAuthIsAdmin { - log.Warn(). - Str("ip", req.RemoteAddr). - Str("path", req.URL.Path). - Msg("Non-admin proxy auth user attempted export/import") - http.Error(w, "Admin privileges required for export/import", http.StatusForbidden) + if !r.authorizeConfigTransfer(w, req, configTransferImport) { return } - if authRequired && hasValidSession && !sessionIsAdmin { - log.Warn(). - Str("ip", req.RemoteAddr). - Str("path", req.URL.Path). - Str("user", sessionUsername). - Msg("Non-admin session user attempted export/import") - http.Error(w, "Admin privileges required for export/import", http.StatusForbidden) - return - } - - if authRequired && !hasValidAuth { - log.Warn(). - Str("ip", req.RemoteAddr). - Str("path", req.URL.Path). - Bool("proxyAuth", hasValidProxyAuth). - Bool("session", sessionIsAdmin). - Bool("apiToken", hasValidAPIToken). - Msg("Unauthorized import attempt") - http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized) - return - } else if !authRequired { - // No auth configured - check if this is a homelab/private network - clientIP := GetClientIP(req) - - isPrivate := isPrivateIP(clientIP) - allowUnprotected := os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true" - - if !isPrivate && !allowUnprotected { - // Public network access without auth - definitely block - log.Warn(). - Str("ip", req.RemoteAddr). - Bool("private_network", isPrivate). - Msg("Import blocked - public network requires authentication") - http.Error(w, "Import requires authentication on public networks", http.StatusForbidden) - return - } else if isPrivate && !allowUnprotected { - // Private network but ALLOW_UNPROTECTED_EXPORT not set - show helpful message - log.Info(). - Str("ip", req.RemoteAddr). - Msg("Import allowed - private network with no auth") - // Continue - allow import on private networks for homelab users - } - } - - // SECURITY: Check settings:write scope for API token auth - if hasValidAPIToken && token != "" { - record, _ := r.config.ValidateAPIToken(token) - if record != nil && !record.HasScope(config.ScopeSettingsWrite) { - log.Warn(). - Str("ip", req.RemoteAddr). - Str("path", req.URL.Path). - Str("token_id", record.ID). - Msg("API token missing settings:write scope for import") - http.Error(w, "API token missing required scope: settings:write", http.StatusForbidden) - return - } - } - - // Log successful import attempt - log.Info(). - Str("ip", req.RemoteAddr). - Bool("session_auth", sessionIsAdmin). - Bool("api_token_auth", hasValidAPIToken). - Msg("Configuration import initiated") - r.configHandlers.HandleImportConfig(w, req) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/api/system_settings_telemetry_test.go b/internal/api/system_settings_telemetry_test.go index 7f46bd61f..83d8f42b2 100644 --- a/internal/api/system_settings_telemetry_test.go +++ b/internal/api/system_settings_telemetry_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -30,6 +31,34 @@ func setupTelemetryTest(t *testing.T, cfg *config.Config) (*SystemSettingsHandle return handler, persistence, tokenVal } +func TestSecurityOperatorDocsPublishEffectiveConfigTransferPolicy(t *testing.T) { + rootSecurity, err := os.ReadFile(filepath.Clean("../../SECURITY.md")) + if err != nil { + t.Fatalf("read root security guide: %v", err) + } + shippedSecurity, err := os.ReadFile(filepath.Clean("../../frontend-modern/public/docs/SECURITY.md")) + if err != nil { + t.Fatalf("read shipped security guide: %v", err) + } + if !bytes.Equal(rootSecurity, shippedSecurity) { + t.Fatal("shipped security guide drifted from the canonical root guide") + } + guide := strings.Join(strings.Fields(string(rootSecurity)), " ") + for _, policy := range []string{ + "only to configuration export", + "It never enables import", + "direct loopback connection", + "private-network and forwarded requests are not loopback", + "settings:read", + "settings:write", + "organization-bound tokens", + } { + if !strings.Contains(guide, policy) { + t.Fatalf("security guide missing config transfer policy %q", policy) + } + } +} + func TestTelemetryUpdate_EnvLockRejects(t *testing.T) { tempDir := t.TempDir() cfg := &config.Config{