Description
The health check subresource did not propagate the apiserver request context to the health-check path. It used req.Context() to derive the tracing span and health context, which lacks apiserver-request-scoped values such as the NamespaceValue(ctx). As a result, tenant isolation could be violated: health checks for a datasource could execute under an empty or incorrect namespace, potentially leaking or mixing tenant data across requests. The patch propagates the outer apiserver context (ctx) into the health check path, ensuring the namespace is preserved for proper multi-tenant isolation.
Proof of Concept
PoC (reproducible against pre-fix behavior; demonstrates that the apiserver namespace is not propagated to the health check before the fix):
// Minimal reproduction leveraging the test-style scaffolding from the commit
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/config"
"k8s.io/apiserver/pkg/endpoints/request"
)
func TestHealthCheckNamespacePropagation_PreFixLike(t *testing.T) {
var gotNamespace string
shr := subHealthREST{
builder: &DataSourceAPIBuilder{
client: mockHealthClient{
checkHealthFunc: func(ctx context.Context, _ *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
// capture the namespace from the health check's context
gotNamespace = request.NamespaceValue(ctx)
return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "ok"}, nil
},
},
datasources: &mockHealthDatasourceProvider{instanceSettings: &backend.DataSourceInstanceSettings{}},
contextProvider: &mockHealthContextProvider{pluginCtx: backend.PluginContext{GrafanaConfig: config.NewGrafanaCfg(map[string]string{})}},
},
}
// apiserver context with a namespace, which should reach the plugin health check
ctx := request.WithNamespace(context.Background(), "stacks-10782")
responder := &mockHealthResponder{}
handler, err := shr.Connect(ctx, "dsname", nil, responder)
if err != nil {
t.Fatalf("connect error: %v", err)
}
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
if gotNamespace != "stacks-10782" {
t.Fatalf("expected namespace to be propagated to health check: got=%q want=%q", gotNamespace, "stacks-10782")
}
}
// Note: This PoC mirrors the propagation check that the patch fixed; in pre-fix behavior, gotNamespace would be empty.
Code Diff
diff --git a/pkg/registry/apis/datasource/sub_health.go b/pkg/registry/apis/datasource/sub_health.go
index ae430b27609d2..0e44dd8ccf2d1 100644
--- a/pkg/registry/apis/datasource/sub_health.go
+++ b/pkg/registry/apis/datasource/sub_health.go
@@ -78,7 +78,7 @@ func (r *subHealthREST) Connect(ctx context.Context, name string, opts runtime.O
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
defer m.Record()
- _, reqSpan := tracing.Start(req.Context(), "datasource.health.request",
+ _, reqSpan := tracing.Start(ctx, "datasource.health.request",
attribute.String("namespace", namespace),
attribute.String("plugin_id", r.builder.pluginJSON.ID),
attribute.String("datasource_uid", name),
@@ -102,7 +102,7 @@ func (r *subHealthREST) Connect(ctx context.Context, name string, opts runtime.O
return
}
- healthCtx := config.WithGrafanaConfig(req.Context(), pluginCtx.GrafanaConfig)
+ healthCtx := config.WithGrafanaConfig(ctx, pluginCtx.GrafanaConfig)
healthCtx = contextualMiddlewares(healthCtx)
checkHealthCtx, checkHealthSpan := tracing.Start(healthCtx, "datasource.health.pluginClient.CheckHealth")
diff --git a/pkg/registry/apis/datasource/sub_health_test.go b/pkg/registry/apis/datasource/sub_health_test.go
index 6c10ec8258d32..0c5d4f835e7ec 100644
--- a/pkg/registry/apis/datasource/sub_health_test.go
+++ b/pkg/registry/apis/datasource/sub_health_test.go
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/endpoints/request"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/config"
@@ -393,6 +394,36 @@ func TestSubHealthREST_HandlerMapsResponse(t *testing.T) {
require.Equal(t, "Test Datasource", capturedRequest.PluginContext.DataSourceInstanceSettings.Name)
})
+ t.Run("CheckHealth context carries the request namespace", func(t *testing.T) {
+ var gotNamespace string
+ shr := subHealthREST{
+ builder: &DataSourceAPIBuilder{
+ client: mockHealthClient{
+ checkHealthFunc: func(ctx context.Context, _ *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
+ gotNamespace = request.NamespaceValue(ctx)
+ return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "ok"}, nil
+ },
+ },
+ datasources: &mockHealthDatasourceProvider{instanceSettings: &backend.DataSourceInstanceSettings{}},
+ contextProvider: &mockHealthContextProvider{pluginCtx: backend.PluginContext{GrafanaConfig: config.NewGrafanaCfg(map[string]string{})}},
+ },
+ }
+
+ // The apiserver puts the request namespace in the Connect context. It must reach the
+ // plugin CheckHealth call so the multi-tenant middleware can resolve stack settings;
+ // the inner req.Context() does not carry it (httptest requests use context.Background).
+ ctx := request.WithNamespace(context.Background(), "stacks-10782")
+ responder := &mockHealthResponder{}
+ handler, err := shr.Connect(ctx, "dsname", nil, responder)
+ require.NoError(t, err)
+
+ handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
+
+ require.NoError(t, responder.err)
+ require.Equal(t, "stacks-10782", gotNamespace,
+ "the namespace from the apiserver request context must reach the plugin CheckHealth call")
+ })
+
t.Run("handler succeeds with full request URL", func(t *testing.T) {
shr := subHealthREST{
builder: &DataSourceAPIBuilder{