From 92210d1c423377535d7791dc669f498b7fa05a4a Mon Sep 17 00:00:00 2001 From: Gabriel Mabille Date: Mon, 12 Jan 2026 10:32:45 +0100 Subject: [PATCH] `provisioning`: Use the clientauth roundtripper --- apps/provisioning/pkg/auth/round_tripper.go | 86 ------------ .../pkg/auth/round_tripper_test.go | 123 ------------------ pkg/operators/provisioning/config.go | 25 ++-- 3 files changed, 17 insertions(+), 217 deletions(-) delete mode 100644 apps/provisioning/pkg/auth/round_tripper.go delete mode 100644 apps/provisioning/pkg/auth/round_tripper_test.go diff --git a/apps/provisioning/pkg/auth/round_tripper.go b/apps/provisioning/pkg/auth/round_tripper.go deleted file mode 100644 index f5da0d778f0..00000000000 --- a/apps/provisioning/pkg/auth/round_tripper.go +++ /dev/null @@ -1,86 +0,0 @@ -// Package auth provides authentication utilities for the provisioning API. -package auth - -import ( - "context" - "fmt" - "net/http" - - "github.com/grafana/authlib/authn" - utilnet "k8s.io/apimachinery/pkg/util/net" -) - -// tokenExchanger abstracts the token exchange client for testability. -type tokenExchanger interface { - Exchange(ctx context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error) -} - -// RoundTripperOption configures optional behavior for the RoundTripper. -type RoundTripperOption func(*RoundTripper) - -// ExtraAudience appends an additional audience to the token exchange request. -// -// This is primarily used by operators connecting to the multitenant aggregator, -// where the token must include both the target API server's audience (e.g., dashboards, -// folders) and the provisioning group audience. The provisioning group audience is -// required so that the token passes the enforceManagerProperties check, which prevents -// unauthorized updates to provisioned resources. -// -// Example: -// -// authrt.NewRoundTripper(client, rt, "dashboards.grafana.app", authrt.ExtraAudience("provisioning.grafana.app")) -func ExtraAudience(audience string) RoundTripperOption { - return func(rt *RoundTripper) { - rt.extraAudience = audience - } -} - -// RoundTripper is an http.RoundTripper that performs token exchange before each request. -// It exchanges the service's credentials for an access token scoped to the configured -// audience(s), then injects that token into the outgoing request's X-Access-Token header. -type RoundTripper struct { - client tokenExchanger - transport http.RoundTripper - audience string - extraAudience string -} - -// NewRoundTripper creates a RoundTripper that exchanges tokens for each outgoing request. -// -// Parameters: -// - tokenExchangeClient: the client used to exchange credentials for access tokens -// - base: the underlying transport to delegate requests to after token injection -// - audience: the primary audience for the token (typically the target API server's group) -// - opts: optional configuration (e.g., ExtraAudience to include additional audiences) -func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string, opts ...RoundTripperOption) *RoundTripper { - rt := &RoundTripper{ - client: tokenExchangeClient, - transport: base, - audience: audience, - } - for _, opt := range opts { - opt(rt) - } - return rt -} - -// RoundTrip exchanges credentials for an access token and injects it into the request. -// The token is scoped to all configured audiences and the wildcard namespace ("*"). -func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - audiences := []string{t.audience} - if t.extraAudience != "" && t.extraAudience != t.audience { - audiences = append(audiences, t.extraAudience) - } - - tokenResponse, err := t.client.Exchange(req.Context(), authn.TokenExchangeRequest{ - Audiences: audiences, - Namespace: "*", - }) - if err != nil { - return nil, fmt.Errorf("failed to exchange token: %w", err) - } - - req = utilnet.CloneRequest(req) - req.Header.Set("X-Access-Token", "Bearer "+tokenResponse.Token) - return t.transport.RoundTrip(req) -} diff --git a/apps/provisioning/pkg/auth/round_tripper_test.go b/apps/provisioning/pkg/auth/round_tripper_test.go deleted file mode 100644 index c1b2b81e17f..00000000000 --- a/apps/provisioning/pkg/auth/round_tripper_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package auth - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "reflect" - "testing" - - "github.com/grafana/authlib/authn" - "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/stretchr/testify/require" -) - -type fakeExchanger struct { - resp *authn.TokenExchangeResponse - err error - gotReq *authn.TokenExchangeRequest -} - -func (f *fakeExchanger) Exchange(_ context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error) { - f.gotReq = &req - return f.resp, f.err -} - -// roundTripperFunc allows building a stub transport inline -type roundTripperFunc func(*http.Request) (*http.Response, error) - -func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } - -func TestRoundTripper_SetsAccessTokenHeader(t *testing.T) { - tr := NewRoundTripper(&fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "abc123"}}, roundTripperFunc(func(r *http.Request) (*http.Response, error) { - got := r.Header.Get("X-Access-Token") - if got != "Bearer abc123" { - t.Fatalf("expected X-Access-Token header 'Bearer abc123', got %q", got) - } - // Return a minimal response; body must be non-nil per http.RoundTripper contract - rr := httptest.NewRecorder() - rr.WriteHeader(http.StatusOK) - return rr.Result(), nil - }), "example-audience") - - req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil) - resp, err := tr.RoundTrip(req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // drain and close body - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() -} - -func TestRoundTripper_PropagatesExchangeError(t *testing.T) { - tr := NewRoundTripper(&fakeExchanger{err: io.EOF}, roundTripperFunc(func(_ *http.Request) (*http.Response, error) { - t.Fatal("transport should not be called on exchange error") - return nil, nil - }), "example-audience") - - req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil) - resp, err := tr.RoundTrip(req) - if err == nil { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - t.Fatalf("expected error, got nil") - } -} - -func TestRoundTripper_AudiencesAndNamespace(t *testing.T) { - tests := []struct { - name string - audience string - extraAudience string - wantAudiences []string - }{ - { - name: "uses only provided audience by default", - audience: "example-audience", - wantAudiences: []string{"example-audience"}, - }, - { - name: "uses only group audience by default", - audience: v0alpha1.GROUP, - wantAudiences: []string{v0alpha1.GROUP}, - }, - { - name: "extra audience adds provisioning group", - audience: "example-audience", - extraAudience: v0alpha1.GROUP, - wantAudiences: []string{"example-audience", v0alpha1.GROUP}, - }, - { - name: "extra audience no duplicate when same as primary", - audience: v0alpha1.GROUP, - extraAudience: v0alpha1.GROUP, - wantAudiences: []string{v0alpha1.GROUP}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fx := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "abc123"}} - var opts []RoundTripperOption - if tt.extraAudience != "" { - opts = append(opts, ExtraAudience(tt.extraAudience)) - } - tr := NewRoundTripper(fx, roundTripperFunc(func(_ *http.Request) (*http.Response, error) { - rr := httptest.NewRecorder() - rr.WriteHeader(http.StatusOK) - return rr.Result(), nil - }), tt.audience, opts...) - - req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil) - resp, err := tr.RoundTrip(req) - require.NoError(t, err) - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - require.NotNil(t, fx.gotReq) - require.True(t, reflect.DeepEqual(fx.gotReq.Audiences, tt.wantAudiences)) - }) - } -} diff --git a/pkg/operators/provisioning/config.go b/pkg/operators/provisioning/config.go index 868d2a8d717..41bccd7d004 100644 --- a/pkg/operators/provisioning/config.go +++ b/pkg/operators/provisioning/config.go @@ -11,9 +11,9 @@ import ( "github.com/grafana/authlib/authn" "github.com/prometheus/client_golang/prometheus" "k8s.io/client-go/rest" - "k8s.io/client-go/transport" "k8s.io/client-go/util/flowcontrol" + "github.com/grafana/grafana/pkg/clientauth" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/setting" @@ -21,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resource" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - authrt "github.com/grafana/grafana/apps/provisioning/pkg/auth" client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/repository/git" @@ -116,9 +115,11 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll config := &rest.Config{ APIPath: "/apis", Host: provisioningServerURL, - WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper { - return authrt.NewRoundTripper(tokenExchangeClient, rt, provisioning.GROUP) - }), + WrapTransport: clientauth.NewStaticTokenExchangeTransportWrapper( + tokenExchangeClient, + provisioning.GROUP, + clientauth.WildcardNamespace, + ), TLSClientConfig: tlsConfig, RateLimiter: flowcontrol.NewFakeAlwaysRateLimiter(), } @@ -163,12 +164,20 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll } for group, url := range apiServerURLs { + // Build audiences: always include the group, and add provisioning.GROUP only if different + audiences := []string{group} + if group != provisioning.GROUP { + audiences = append(audiences, provisioning.GROUP) + } + config := &rest.Config{ APIPath: "/apis", Host: url, - WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper { - return authrt.NewRoundTripper(tokenExchangeClient, rt, group, authrt.ExtraAudience(provisioning.GROUP)) - }), + WrapTransport: clientauth.NewTokenExchangeTransportWrapper( + tokenExchangeClient, + clientauth.NewStaticNamespaceProvider(clientauth.WildcardNamespace), + clientauth.NewStaticAudienceProvider(audiences...), + ), Transport: &http.Transport{ MaxConnsPerHost: 100, MaxIdleConns: 100,