Compare commits

...

3 Commits

Author SHA1 Message Date
Gabriel Mabille
b97061c651 Merge remote-tracking branch 'origin/main' into gamab/chore/use-roundtripper 2026-01-13 10:56:08 +01:00
Gabriel Mabille
11ecebbd4d small refactor 2026-01-12 14:03:55 +01:00
Gabriel Mabille
92210d1c42 provisioning: Use the clientauth roundtripper 2026-01-12 10:32:45 +01:00
4 changed files with 18 additions and 218 deletions

View File

@@ -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)
}

View File

@@ -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))
})
}
}

View File

@@ -73,8 +73,8 @@ func NewStaticTokenExchangeTransportWrapper(
// NewTokenExchangeTransportWrapperWithStrategies creates a transport.WrapperFunc with custom strategies.
func NewTokenExchangeTransportWrapper(
exchanger authnlib.TokenExchanger,
namespaceProvider NamespaceProvider,
audienceProvider AudienceProvider,
namespaceProvider NamespaceProvider,
) transport.WrapperFunc {
return func(rt http.RoundTripper) http.RoundTripper {
return newTokenExchangeRoundTripperWithStrategies(

View File

@@ -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.NewStaticAudienceProvider(audiences...),
clientauth.NewStaticNamespaceProvider(clientauth.WildcardNamespace),
),
Transport: &http.Transport{
MaxConnsPerHost: 100,
MaxIdleConns: 100,