mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Secure configuration transfer authorization (#1714)
Co-authored-by: Pulse Autonomous Maintainer <rcourtman@users.noreply.github.com>
This commit is contained in:
+11
-2
@@ -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 <token>`, 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<br>
|
||||
**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)
|
||||
|
||||
+6
-2
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 <token>`, 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<br>
|
||||
**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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user