FS: Use Settings Service for per-request config (#116822)

* Create settings service

* Apply request config overrides in middleware

* Add tlsSkipVerify setting for settings service client

* Abstract out ini interactions, add debug logs

* Use baggage header instead of Grafana-Namespace

* Exclude defaults.ini settings from the Settings Service

* Remove baseRequestConfig and instead create the structure new in each middleware call
This commit is contained in:
Josh Hunt 2026-02-03 13:53:44 +00:00 committed by GitHub
parent c5573038ea
commit aefcbed25d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 600 additions and 44 deletions

View file

@ -23,6 +23,7 @@ import (
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api"
settingservice "github.com/grafana/grafana/pkg/services/setting"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
@ -49,11 +50,12 @@ type frontendService struct {
tracer trace.Tracer
license licensing.Licensing
index *IndexProvider
baseRequestConfig FSRequestConfig
index *IndexProvider
settingsService settingservice.Service // nil if not configured
}
func ProvideFrontendService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, promGatherer prometheus.Gatherer, promRegister prometheus.Registerer, license licensing.Licensing, hooksService *hooks.HooksService) (*frontendService, error) {
logger := log.New("frontend-service")
assetsManifest, err := fswebassets.GetWebAssets(cfg, license)
if err != nil {
return nil, err
@ -64,20 +66,25 @@ func ProvideFrontendService(cfg *setting.Cfg, features featuremgmt.FeatureToggle
return nil, err
}
// Create base request config from global settings
// This is the default configuration that will be used for all requests
baseRequestConfig := NewFSRequestConfig(cfg, license)
// Initialize Settings Service client if configured
var settingsService settingservice.Service
if settingsSvc, err := setupSettingsService(cfg, promRegister); err != nil {
logger.Error("Settings Service failed to initialize", "err", err)
return nil, err
} else {
settingsService = settingsSvc
}
s := &frontendService{
cfg: cfg,
features: features,
log: log.New("frontend-server"),
promGatherer: promGatherer,
promRegister: promRegister,
tracer: tracer,
license: license,
index: index,
baseRequestConfig: baseRequestConfig,
cfg: cfg,
features: features,
log: logger,
promGatherer: promGatherer,
promRegister: promRegister,
tracer: tracer,
license: license,
index: index,
settingsService: settingsService,
}
s.BasicService = services.NewBasicService(s.start, s.running, s.stop)
return s, nil
@ -147,7 +154,7 @@ func (s *frontendService) addMiddlewares(m *web.Mux) {
m.UseMiddleware(loggermiddleware.Middleware())
// Must run before CSP middleware since CSP reads config from context
m.UseMiddleware(RequestConfigMiddleware(s.baseRequestConfig))
m.UseMiddleware(RequestConfigMiddleware(s.cfg, s.license, s.settingsService))
m.UseMiddleware(CSPMiddleware())

View file

@ -10,6 +10,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/services/contexthandler"
@ -46,6 +47,7 @@ func TestFrontendService_ServerCreation(t *testing.T) {
t.Run("should create HTTP server with correct configuration", func(t *testing.T) {
publicDir := setupTestWebAssets(t)
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
StaticRootPath: publicDir,
}
@ -64,6 +66,7 @@ func TestFrontendService_ServerCreation(t *testing.T) {
func TestFrontendService_Routes(t *testing.T) {
publicDir := setupTestWebAssets(t)
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
}
@ -139,6 +142,7 @@ func TestFrontendService_Routes(t *testing.T) {
func TestFrontendService_Middleware(t *testing.T) {
publicDir := setupTestWebAssets(t)
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
}
@ -190,6 +194,7 @@ func TestFrontendService_Middleware(t *testing.T) {
func TestFrontendService_LoginErrorCookie(t *testing.T) {
publicDir := setupTestWebAssets(t)
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
@ -262,6 +267,7 @@ func TestFrontendService_LoginErrorCookie(t *testing.T) {
t.Run("should handle custom OAuth error message from config", func(t *testing.T) {
customCfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
@ -296,6 +302,7 @@ func TestFrontendService_LoginErrorCookie(t *testing.T) {
func TestFrontendService_IndexHooks(t *testing.T) {
publicDir := setupTestWebAssets(t)
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
@ -351,6 +358,7 @@ func TestFrontendService_CSP(t *testing.T) {
t.Run("should set CSP headers when enabled", func(t *testing.T) {
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
@ -379,6 +387,7 @@ func TestFrontendService_CSP(t *testing.T) {
t.Run("should set CSP-Report-Only header when enabled", func(t *testing.T) {
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
@ -406,6 +415,7 @@ func TestFrontendService_CSP(t *testing.T) {
t.Run("should set both CSP headers when both enabled", func(t *testing.T) {
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
@ -436,6 +446,7 @@ func TestFrontendService_CSP(t *testing.T) {
t.Run("should not set CSP headers when disabled", func(t *testing.T) {
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
@ -463,6 +474,7 @@ func TestFrontendService_CSP(t *testing.T) {
t.Run("should store nonce in request context", func(t *testing.T) {
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
@ -496,6 +508,7 @@ func TestFrontendService_CSP(t *testing.T) {
t.Run("should use base config when Tenant-ID header is present", func(t *testing.T) {
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",

View file

@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
@ -74,6 +75,7 @@ func TestFrontendService_WebAssets(t *testing.T) {
t.Run("should serve index with proper assets", func(t *testing.T) {
publicDir := setupTestWebAssets(t)
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "3000",
StaticRootPath: publicDir,
Env: setting.Dev, // needs to be dev to bypass the cache

View file

@ -5,7 +5,10 @@ import (
"fmt"
"strings"
"gopkg.in/ini.v1"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/setting"
)
@ -108,3 +111,52 @@ func getShortCommitHash(commitHash string, maxLength int) string {
}
return commitHash
}
// ApplyOverrides merges tenant-specific settings from ini.File with this configuration.
// It mutates the existing config, so ensure this object is not reused across multiple requests.
func (c *FSRequestConfig) ApplyOverrides(settings *ini.File, logger log.Logger) {
// Apply overrides from the settings service ini to the config. Theoretically we could use setting.NewCfgFromINIFile, but
// because we only want overrides, and not default values, we need to manually get them out of the ini structure.
// TODO: We should apply all overrides for values in FSRequestConfig
applyBool(settings, "security", "content_security_policy", &c.CSPEnabled, logger)
applyString(settings, "security", "content_security_policy_template", &c.CSPTemplate, logger)
applyBool(settings, "security", "content_security_policy_report_only", &c.CSPReportOnlyEnabled, logger)
applyString(settings, "security", "content_security_policy_report_only_template", &c.CSPReportOnlyTemplate, logger)
}
func getValue(settings *ini.File, section, key string) *ini.Key {
if !settings.HasSection(section) {
return nil
}
sec := settings.Section(section)
if !sec.HasKey(key) {
return nil
}
return sec.Key(key)
}
// applyString applies a string value from ini settings to a target field if it exists.
func applyString(settings *ini.File, sectionName, keyName string, target *string, logger log.Logger) {
if key := getValue(settings, sectionName, keyName); key != nil {
*target = key.String()
logger.Debug("applying request config override",
"section", sectionName,
"key", keyName,
"value", *target)
}
}
// applyBool applies a boolean value from ini settings to a target field if it exists.
func applyBool(settings *ini.File, sectionName, keyName string, target *bool, logger log.Logger) {
if key := getValue(settings, sectionName, keyName); key != nil {
*target = key.MustBool(false)
logger.Debug("applying request config override",
"section", sectionName,
"key", keyName,
"value", *target)
}
}

View file

@ -3,18 +3,78 @@ package frontend
import (
"net/http"
"go.opentelemetry.io/otel/baggage"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/endpoints/request"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/services/licensing"
settingservice "github.com/grafana/grafana/pkg/services/setting"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
func RequestConfigMiddleware(baseConfig FSRequestConfig) web.Middleware {
// RequestConfigMiddleware manages per-request configuration.
//
// When Settings Service is configured and namespace is present in baggage header:
// - Fetches tenant-specific overrides from Settings Service
// - Merges overrides with base configuration
// - Stores final configuration in context
//
// Otherwise, uses base configuration for all requests.
func RequestConfigMiddleware(cfg *setting.Cfg, license licensing.Licensing, settingsService settingservice.Service) web.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: In the future fetch and override request-specific config here
finalConfig := baseConfig
ctx, span := tracing.Start(r.Context(), "frontend.RequestConfigMiddleware")
defer span.End()
// Store config in context
ctx := finalConfig.WithContext(r.Context())
// Parse namespace from W3C baggage header
var namespace string
if baggageHeader := r.Header.Get("baggage"); baggageHeader != "" {
if bag, err := baggage.Parse(baggageHeader); err == nil {
if member := bag.Member("namespace"); member.Value() != "" {
namespace = member.Value()
}
}
}
ctx = request.WithNamespace(ctx, namespace)
reqCtx := contexthandler.FromContext(ctx)
logger := reqCtx.Logger
// Create base request config from global settings
// This is the default configuration that will be used for all requests
requestConfig := NewFSRequestConfig(cfg, license)
// Fetch tenant-specific configuration if namespace is present
if namespace != "" && settingsService != nil {
// Fetch tenant overrides for relevant sections only
selector := metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{{
Key: "section",
Operator: metav1.LabelSelectorOpIn,
Values: []string{"security"}, // TODO: get correct list
}, {
// don't return values from defaults.ini as they conflict with the services's own defaults
Key: "source",
Operator: metav1.LabelSelectorOpNotIn,
Values: []string{"defaults"},
}},
}
settings, err := settingsService.ListAsIni(ctx, selector)
if err != nil {
logger.Error("failed to fetch tenant settings", "namespace", namespace, "err", err)
// Fall back to base config
} else {
// Merge tenant overrides with base config
requestConfig.ApplyOverrides(settings, logger)
}
}
// Store config in context for other middleware/handlers to use
ctx = requestConfig.WithContext(ctx)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View file

@ -1,31 +1,49 @@
package frontend
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana/pkg/api/dtos"
"gopkg.in/ini.v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/licensing"
settingservice "github.com/grafana/grafana/pkg/services/setting"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupTestContext creates a request with a proper context that includes a logger
func setupTestContext(r *http.Request) *http.Request {
logger := log.NewNopLogger()
reqCtx := &contextmodel.ReqContext{
Context: &web.Context{Req: r},
Logger: logger,
}
ctx := ctxkey.Set(r.Context(), reqCtx)
return r.WithContext(ctx)
}
func TestRequestConfigMiddleware(t *testing.T) {
t.Run("should store base config in request context", func(t *testing.T) {
baseConfig := FSRequestConfig{
FSFrontendSettings: FSFrontendSettings{
BuildInfo: dtos.FrontendSettingsBuildInfoDTO{
Version: "10.3.0",
Edition: "Open Source",
},
AnonymousEnabled: true,
},
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
CSPEnabled: true,
CSPTemplate: "default-src 'self'",
AppURL: "https://grafana.example.com",
}
middleware := RequestConfigMiddleware(baseConfig)
middleware := RequestConfigMiddleware(cfg, license, nil)
var capturedConfig FSRequestConfig
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -38,22 +56,28 @@ func TestRequestConfigMiddleware(t *testing.T) {
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, baseConfig.CSPEnabled, capturedConfig.CSPEnabled)
assert.Equal(t, baseConfig.CSPTemplate, capturedConfig.CSPTemplate)
assert.Equal(t, baseConfig.AppURL, capturedConfig.AppURL)
assert.Equal(t, baseConfig.AnonymousEnabled, capturedConfig.AnonymousEnabled)
assert.Equal(t, baseConfig.BuildInfo.Version, capturedConfig.BuildInfo.Version)
assert.Equal(t, baseConfig.BuildInfo.Edition, capturedConfig.BuildInfo.Edition)
assert.True(t, capturedConfig.CSPEnabled)
assert.Equal(t, capturedConfig.CSPTemplate, "default-src 'self'")
assert.Equal(t, capturedConfig.AppURL, "https://grafana.example.com")
})
t.Run("should call next handler", func(t *testing.T) {
baseConfig := FSRequestConfig{}
middleware := RequestConfigMiddleware(baseConfig)
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
CSPEnabled: true,
CSPTemplate: "default-src 'self'",
AppURL: "https://grafana.example.com",
}
middleware := RequestConfigMiddleware(cfg, license, nil)
nextCalled := false
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -64,6 +88,7 @@ func TestRequestConfigMiddleware(t *testing.T) {
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
@ -72,12 +97,25 @@ func TestRequestConfigMiddleware(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code)
})
t.Run("should work with minimal config", func(t *testing.T) {
baseConfig := FSRequestConfig{
FSFrontendSettings: FSFrontendSettings{},
t.Run("should fetch and apply tenant overrides from settings service", func(t *testing.T) {
// Create mock settings service that returns CSP overrides
mockSettingsService := &mockSettingsService{
settings: []*settingservice.Setting{
{Section: "security", Key: "content_security_policy", Value: "true"},
{Section: "security", Key: "content_security_policy_template", Value: "script-src 'self'"},
},
}
middleware := RequestConfigMiddleware(baseConfig)
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
CSPEnabled: true,
CSPTemplate: "default-src 'self'",
AppURL: "https://grafana.example.com",
}
middleware := RequestConfigMiddleware(cfg, license, mockSettingsService)
var capturedConfig FSRequestConfig
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -90,12 +128,229 @@ func TestRequestConfigMiddleware(t *testing.T) {
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("baggage", "namespace=stacks-123")
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, false, capturedConfig.CSPEnabled)
assert.Equal(t, "", capturedConfig.CSPTemplate)
// Verify CSP overrides were applied
assert.True(t, capturedConfig.CSPEnabled)
assert.Equal(t, "script-src 'self'", capturedConfig.CSPTemplate)
// Verify other settings remain at base values (not overridden)
assert.Equal(t, "https://grafana.example.com", capturedConfig.AppURL)
// Verify settings service was called
assert.True(t, mockSettingsService.called)
})
t.Run("should fallback to base config on settings service error", func(t *testing.T) {
// Create mock that returns an error
mockSettingsService := &mockSettingsService{
err: assert.AnError,
}
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
CSPEnabled: true,
CSPTemplate: "default-src 'self'",
AppURL: "https://base.example.com",
}
middleware := RequestConfigMiddleware(cfg, license, mockSettingsService)
var capturedConfig FSRequestConfig
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
capturedConfig, err = FSRequestConfigFromContext(r.Context())
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
})
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("baggage", "namespace=stacks-123")
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify base config was used (no overrides)
assert.Equal(t, "https://base.example.com", capturedConfig.AppURL)
assert.True(t, capturedConfig.CSPEnabled)
assert.Equal(t, "default-src 'self'", capturedConfig.CSPTemplate)
// Verify settings service was called
assert.True(t, mockSettingsService.called)
})
t.Run("should not call settings service without namespace header", func(t *testing.T) {
mockSettingsService := &mockSettingsService{}
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
AppURL: "https://base.example.com",
}
middleware := RequestConfigMiddleware(cfg, license, mockSettingsService)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req = setupTestContext(req)
// No baggage header
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.False(t, mockSettingsService.called)
})
t.Run("should parse namespace from baggage header with multiple values", func(t *testing.T) {
mockSettingsService := &mockSettingsService{
settings: []*settingservice.Setting{
{Section: "security", Key: "content_security_policy", Value: "true"},
},
}
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
CSPEnabled: false, // Base config has CSP disabled
}
middleware := RequestConfigMiddleware(cfg, license, mockSettingsService)
var capturedConfig FSRequestConfig
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
capturedConfig, err = FSRequestConfigFromContext(r.Context())
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
})
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
// Baggage header with multiple key-value pairs
req.Header.Set("baggage", "trace-id=abc123,namespace=tenant-456,user-id=xyz")
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.True(t, capturedConfig.CSPEnabled, "Should apply tenant overrides when namespace is present")
assert.True(t, mockSettingsService.called, "Should call settings service when namespace is in baggage")
})
t.Run("should not call settings service with malformed baggage header", func(t *testing.T) {
mockSettingsService := &mockSettingsService{}
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
}
middleware := RequestConfigMiddleware(cfg, license, mockSettingsService)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
// Malformed baggage header
req.Header.Set("baggage", "invalid baggage format;;;")
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.False(t, mockSettingsService.called, "Should not call settings service with malformed baggage")
})
t.Run("should not call settings service when baggage has no namespace", func(t *testing.T) {
mockSettingsService := &mockSettingsService{}
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
}
middleware := RequestConfigMiddleware(cfg, license, mockSettingsService)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
// Baggage header without namespace key
req.Header.Set("baggage", "trace-id=abc123,user-id=xyz")
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.False(t, mockSettingsService.called, "Should not call settings service when namespace is not in baggage")
})
}
// mockSettingsService is a simple mock for testing
type mockSettingsService struct {
called bool
settings []*settingservice.Setting
err error
}
func (m *mockSettingsService) ListAsIni(ctx context.Context, selector metav1.LabelSelector) (*ini.File, error) {
m.called = true
if m.err != nil {
return nil, m.err
}
// Convert settings to ini format (same logic as real service)
conf := ini.Empty()
for _, setting := range m.settings {
if !conf.HasSection(setting.Section) {
_, _ = conf.NewSection(setting.Section)
}
_, _ = conf.Section(setting.Section).NewKey(setting.Key, setting.Value)
}
return conf, nil
}
func (m *mockSettingsService) List(ctx context.Context, selector metav1.LabelSelector) ([]*settingservice.Setting, error) {
m.called = true
if m.err != nil {
return nil, m.err
}
return m.settings, nil
}
// Implement prometheus.Collector interface
func (m *mockSettingsService) Describe(ch chan<- *prometheus.Desc) {}
func (m *mockSettingsService) Collect(ch chan<- prometheus.Metric) {}

View file

@ -0,0 +1,94 @@
package frontend
import (
"testing"
"gopkg.in/ini.v1"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/stretchr/testify/assert"
)
func TestFSRequestConfig_ApplyOverrides(t *testing.T) {
t.Run("should handle empty ini file", func(t *testing.T) {
config := FSRequestConfig{
AppURL: "https://base.example.com",
CSPEnabled: true,
}
iniFile := ini.Empty()
config.ApplyOverrides(iniFile, log.New("test"))
assert.Equal(t, "https://base.example.com", config.AppURL)
assert.Equal(t, true, config.CSPEnabled)
})
t.Run("should preserve non-overridden fields", func(t *testing.T) {
config := FSRequestConfig{
FSFrontendSettings: FSFrontendSettings{
AnonymousEnabled: true,
DisableLoginForm: true,
LoginHint: "test@example.com",
BuildInfo: dtos.FrontendSettingsBuildInfoDTO{
Version: "10.3.0",
},
},
AppURL: "https://base.example.com",
CSPEnabled: false,
}
iniFile := ini.Empty()
securitySection, _ := iniFile.NewSection("security")
_, _ = securitySection.NewKey("content_security_policy", "true")
config.ApplyOverrides(iniFile, log.New("test"))
// CSP overridden field
assert.True(t, config.CSPEnabled)
// Non-overridden fields should be preserved
assert.Equal(t, "https://base.example.com", config.AppURL)
assert.True(t, config.AnonymousEnabled)
assert.True(t, config.DisableLoginForm)
assert.Equal(t, "test@example.com", config.LoginHint)
assert.Equal(t, "10.3.0", config.BuildInfo.Version)
})
t.Run("should apply multiple CSP overrides at once", func(t *testing.T) {
config := FSRequestConfig{
FSFrontendSettings: FSFrontendSettings{
AnonymousEnabled: false,
DisableLoginForm: false,
DisableUserSignUp: false,
},
AppURL: "https://base.example.com",
CSPEnabled: false,
CSPTemplate: "",
CSPReportOnlyEnabled: false,
CSPReportOnlyTemplate: "",
}
iniFile := ini.Empty()
securitySection, _ := iniFile.NewSection("security")
_, _ = securitySection.NewKey("content_security_policy", "true")
_, _ = securitySection.NewKey("content_security_policy_template", "script-src 'self'")
_, _ = securitySection.NewKey("content_security_policy_report_only", "true")
_, _ = securitySection.NewKey("content_security_policy_report_only_template", "default-src 'none'")
config.ApplyOverrides(iniFile, log.New("test"))
// All CSP settings should be overridden
assert.True(t, config.CSPEnabled)
assert.Equal(t, "script-src 'self'", config.CSPTemplate)
assert.True(t, config.CSPReportOnlyEnabled)
assert.Equal(t, "default-src 'none'", config.CSPReportOnlyTemplate)
// Other settings should remain unchanged
assert.Equal(t, "https://base.example.com", config.AppURL)
assert.False(t, config.DisableLoginForm)
assert.False(t, config.AnonymousEnabled)
})
}

View file

@ -0,0 +1,73 @@
package frontend
import (
"errors"
"fmt"
"github.com/grafana/authlib/authn"
"github.com/prometheus/client_golang/prometheus"
"k8s.io/client-go/rest"
settingservice "github.com/grafana/grafana/pkg/services/setting"
"github.com/grafana/grafana/pkg/setting"
)
// setupSettingsService initializes the Settings Service client if configured.
// Expected configuration:
// [grpc_client_authentication]
// token =
// token_exchange_url =
//
// [settings_service]
// url =
// tls_skip_verify = false
//
// Returns nil if not configured (this is not an error condition). Errors returned should
// be considered critical.
func setupSettingsService(cfg *setting.Cfg, promRegister prometheus.Registerer) (settingservice.Service, error) {
settingsSec := cfg.SectionWithEnvOverrides("settings_service")
settingsServiceURL := settingsSec.Key("url").String()
if settingsServiceURL == "" {
// If settings service URL is configured, return nil without error
return nil, nil
}
tlsSkipVerify := settingsSec.Key("tls_skip_verify").MustBool(false)
gRPCAuth := cfg.SectionWithEnvOverrides("grpc_client_authentication")
token := gRPCAuth.Key("token").String()
tokenExchangeURL := gRPCAuth.Key("token_exchange_url").String()
if token == "" {
return nil, fmt.Errorf("grpc_client_authentication.token is required for settings service")
}
if tokenExchangeURL == "" {
return nil, fmt.Errorf("grpc_client_authentication.token_exchange_url is required for settings service")
}
tokenClient, err := authn.NewTokenExchangeClient(authn.TokenExchangeConfig{
Token: token,
TokenExchangeURL: tokenExchangeURL,
})
if err != nil {
return nil, fmt.Errorf("failed to create token exchange client: %w", err)
}
settingsService, err := settingservice.New(settingservice.Config{
URL: settingsServiceURL,
TokenExchangeClient: tokenClient,
TLSClientConfig: rest.TLSClientConfig{
Insecure: tlsSkipVerify,
},
})
if err != nil {
return nil, fmt.Errorf("failed to create settings service client: %w", err)
}
if err := promRegister.Register(settingsService); err != nil {
var alreadyRegisteredErr prometheus.AlreadyRegisteredError
if !errors.As(err, &alreadyRegisteredErr) {
return nil, fmt.Errorf("failed to register settings service metrics: %w", err)
}
}
return settingsService, nil
}