mirror of
https://github.com/grafana/grafana.git
synced 2026-02-03 20:49:50 -05:00
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:
parent
c5573038ea
commit
aefcbed25d
8 changed files with 600 additions and 44 deletions
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
94
pkg/services/frontend/request_config_test.go
Normal file
94
pkg/services/frontend/request_config_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
73
pkg/services/frontend/settings_service.go
Normal file
73
pkg/services/frontend/settings_service.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue