Compare commits
3 Commits
ash/react-
...
gamab/chor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97061c651 | ||
|
|
11ecebbd4d | ||
|
|
92210d1c42 |
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user