Feature-Flag service: signing middleware for cloud usecase (#107745)

pull/108262/head
Charandas 3 days ago committed by GitHub
parent 08e8a71ad6
commit d9e099d480
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      .github/CODEOWNERS
  2. 110
      pkg/clientauth/middleware/token-exchange.go
  3. 22
      pkg/services/featuremgmt/fake-token-exchange.go
  4. 7
      pkg/services/featuremgmt/goff_provider.go
  5. 61
      pkg/services/featuremgmt/openfeature.go
  6. 87
      pkg/services/featuremgmt/openfeature_test.go
  7. 1
      pkg/services/featuremgmt/static_provider_test.go

@ -89,6 +89,7 @@
/pkg/apis/query @grafana/grafana-datasources-core-services /pkg/apis/query @grafana/grafana-datasources-core-services
/pkg/apis/userstorage @grafana/grafana-app-platform-squad @grafana/plugins-platform-backend /pkg/apis/userstorage @grafana/grafana-app-platform-squad @grafana/plugins-platform-backend
/pkg/bus/ @grafana/grafana-search-and-storage /pkg/bus/ @grafana/grafana-search-and-storage
/pkg/clientauth/ @grafana/grafana-app-platform-squad
/pkg/cmd/ @grafana/grafana-backend-group /pkg/cmd/ @grafana/grafana-backend-group
/pkg/cmd/grafana-cli/commands/install_command.go @grafana/plugins-platform-backend /pkg/cmd/grafana-cli/commands/install_command.go @grafana/plugins-platform-backend
/pkg/cmd/grafana-cli/commands/install_command_test.go @grafana/plugins-platform-backend /pkg/cmd/grafana-cli/commands/install_command_test.go @grafana/plugins-platform-backend

@ -0,0 +1,110 @@
package middleware
import (
"fmt"
"net/http"
authlib "github.com/grafana/authlib/authn"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana/pkg/apimachinery/identity"
infralog "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
)
type TokenExchangeMiddleware struct {
tokenExchangeClient authlib.TokenExchanger
}
type tokenExchangeMiddlewareImpl struct {
tokenExchangeClient authlib.TokenExchanger
audiences []string
next http.RoundTripper
}
type signerSettings struct {
token string
tokenExchangeURL string
}
var _ http.RoundTripper = &tokenExchangeMiddlewareImpl{}
func TestingTokenExchangeMiddleware(tokenExchangeClient authlib.TokenExchanger) *TokenExchangeMiddleware {
return &TokenExchangeMiddleware{
tokenExchangeClient: tokenExchangeClient,
}
}
func NewTokenExchangeMiddleware(cfg *setting.Cfg) (*TokenExchangeMiddleware, error) {
clientCfg, err := readSignerSettings(cfg)
if err != nil {
return nil, err
}
tokenExchangeClient, err := authlib.NewTokenExchangeClient(authlib.TokenExchangeConfig{
Token: clientCfg.token,
TokenExchangeURL: clientCfg.tokenExchangeURL,
})
if err != nil {
return nil, err
}
return &TokenExchangeMiddleware{
tokenExchangeClient: tokenExchangeClient,
}, nil
}
func (p *TokenExchangeMiddleware) New(audiences []string) sdkhttpclient.MiddlewareFunc {
return func(opts sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper {
return &tokenExchangeMiddlewareImpl{
tokenExchangeClient: p.tokenExchangeClient,
audiences: audiences,
next: next,
}
}
}
func (m tokenExchangeMiddlewareImpl) RoundTrip(req *http.Request) (res *http.Response, e error) {
log := infralog.New("token-exchange-middleware")
user, err := identity.GetRequester(req.Context())
if err != nil {
return nil, err
}
namespace := user.GetNamespace()
if namespace == "" {
return nil, fmt.Errorf("cluster scoped resources are currently not supported")
}
log.Debug("signing request", "url", req.URL.Path, "audience", m.audiences, "namespace", namespace)
token, err := m.tokenExchangeClient.Exchange(req.Context(), authlib.TokenExchangeRequest{
Namespace: namespace,
Audiences: m.audiences,
})
if err != nil {
return nil, fmt.Errorf("failed to exchange token: %w", err)
}
req.Header.Set("X-Access-Token", "Bearer "+token.Token)
return m.next.RoundTrip(req)
}
// we exercise the below code path in OSS but would rather have it fail
// instead of documenting these non-pertinent settings and requiring mock values for them.
// hence, the error return is handled above as non-critical and a mock
// exchange client is returned.
func readSignerSettings(cfg *setting.Cfg) (*signerSettings, error) {
grpcClientAuthSection := cfg.SectionWithEnvOverrides("grpc_client_authentication")
s := &signerSettings{}
s.token = grpcClientAuthSection.Key("token").MustString("")
s.tokenExchangeURL = grpcClientAuthSection.Key("token_exchange_url").MustString("")
if s.token == "" || s.tokenExchangeURL == "" {
return nil, fmt.Errorf("authorization: missing token or tokenExchangeUrl")
}
return s, nil
}

@ -0,0 +1,22 @@
package featuremgmt
import (
"context"
"errors"
authlib "github.com/grafana/authlib/authn"
"github.com/stretchr/testify/mock"
)
type fakeTokenExchangeClient struct {
expectedErr error
*mock.Mock
}
func (c *fakeTokenExchangeClient) Exchange(ctx context.Context, r authlib.TokenExchangeRequest) (*authlib.TokenExchangeResponse, error) {
c.Called(ctx, r)
if c.expectedErr != nil {
return nil, errors.New("error signing token")
}
return &authlib.TokenExchangeResponse{Token: "signed-token"}, c.expectedErr
}

@ -2,19 +2,16 @@ package featuremgmt
import ( import (
"net/http" "net/http"
"time"
gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg" gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg"
"github.com/open-feature/go-sdk/openfeature" "github.com/open-feature/go-sdk/openfeature"
) )
func newGOFFProvider(url string) (openfeature.FeatureProvider, error) { func newGOFFProvider(url string, client *http.Client) (openfeature.FeatureProvider, error) {
options := gofeatureflag.ProviderOptions{ options := gofeatureflag.ProviderOptions{
Endpoint: url, Endpoint: url,
// consider using github.com/grafana/grafana/pkg/infra/httpclient/provider.go // consider using github.com/grafana/grafana/pkg/infra/httpclient/provider.go
HTTPClient: &http.Client{ HTTPClient: client,
Timeout: 10 * time.Second,
},
} }
provider, err := gofeatureflag.NewProvider(options) provider, err := gofeatureflag.NewProvider(options)
return provider, err return provider, err

@ -2,20 +2,41 @@ package featuremgmt
import ( import (
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"time"
clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/open-feature/go-sdk/openfeature" "github.com/open-feature/go-sdk/openfeature"
) )
const (
featuresProviderAudience = "features.grafana.app"
)
func InitOpenFeatureWithCfg(cfg *setting.Cfg) error { func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
confFlags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles")) confFlags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles"))
if err != nil { if err != nil {
return fmt.Errorf("failed to read feature flags from config: %w", err) return fmt.Errorf("failed to read feature flags from config: %w", err)
} }
err = initOpenFeature(cfg.OpenFeature.ProviderType, cfg.OpenFeature.URL, confFlags) var httpcli *http.Client
if cfg.OpenFeature.ProviderType == setting.GOFFProviderType {
m, err := clientauthmiddleware.NewTokenExchangeMiddleware(cfg)
if err != nil {
return fmt.Errorf("failed to create token exchange middleware: %w", err)
}
httpcli, err = goffHTTPClient(m)
if err != nil {
return err
}
}
err = initOpenFeature(cfg.OpenFeature.ProviderType, cfg.OpenFeature.URL, confFlags, httpcli)
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize OpenFeature: %w", err) return fmt.Errorf("failed to initialize OpenFeature: %w", err)
} }
@ -24,10 +45,15 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
return nil return nil
} }
func initOpenFeature(providerType string, u *url.URL, staticFlags map[string]bool) error { func initOpenFeature(
p, err := createProvider(providerType, u, staticFlags) providerType string,
u *url.URL,
staticFlags map[string]bool,
httpClient *http.Client,
) error {
p, err := createProvider(providerType, u, staticFlags, httpClient)
if err != nil { if err != nil {
return fmt.Errorf("failed to create feature provider: type %s, %w", providerType, err) return err
} }
if err := openfeature.SetProviderAndWait(p); err != nil { if err := openfeature.SetProviderAndWait(p); err != nil {
@ -37,7 +63,12 @@ func initOpenFeature(providerType string, u *url.URL, staticFlags map[string]boo
return nil return nil
} }
func createProvider(providerType string, u *url.URL, staticFlags map[string]bool) (openfeature.FeatureProvider, error) { func createProvider(
providerType string,
u *url.URL,
staticFlags map[string]bool,
httpClient *http.Client,
) (openfeature.FeatureProvider, error) {
if providerType != setting.GOFFProviderType { if providerType != setting.GOFFProviderType {
return newStaticProvider(staticFlags) return newStaticProvider(staticFlags)
} }
@ -46,5 +77,23 @@ func createProvider(providerType string, u *url.URL, staticFlags map[string]bool
return nil, fmt.Errorf("feature provider url is required for GOFFProviderType") return nil, fmt.Errorf("feature provider url is required for GOFFProviderType")
} }
return newGOFFProvider(u.String()) return newGOFFProvider(u.String(), httpClient)
}
func goffHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) {
httpcli, err := sdkhttpclient.NewProvider().New(sdkhttpclient.Options{
TLS: &sdkhttpclient.TLSOptions{InsecureSkipVerify: true},
Timeouts: &sdkhttpclient.TimeoutOptions{
Timeout: 10 * time.Second,
},
Middlewares: []sdkhttpclient.Middleware{
m.New([]string{featuresProviderAudience}),
},
})
if err != nil {
return nil, fmt.Errorf("failed to create http client for openfeature: %w", err)
}
return httpcli, nil
} }

@ -1,24 +1,33 @@
package featuremgmt package featuremgmt
import ( import (
"context"
"errors"
"net/url" "net/url"
"testing" "testing"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/clientauth/middleware"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
authlib "github.com/grafana/authlib/authn"
gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg" gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg"
"github.com/open-feature/go-sdk/openfeature"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestCreateProvider(t *testing.T) { func TestCreateProvider(t *testing.T) {
u, err := url.Parse("http://localhost:1031") u, err := url.Parse("http://localhost:10333")
require.NoError(t, err) require.NoError(t, err)
testCases := []struct { testCases := []struct {
name string name string
cfg setting.OpenFeatureSettings cfg setting.OpenFeatureSettings
expectedProvider string expectedProvider string
expectExchangeRequest *authlib.TokenExchangeRequest
failSigning bool
}{ }{
{ {
name: "static provider", name: "static provider",
@ -31,7 +40,25 @@ func TestCreateProvider(t *testing.T) {
URL: u, URL: u,
TargetingKey: "grafana", TargetingKey: "grafana",
}, },
expectExchangeRequest: &authlib.TokenExchangeRequest{
Namespace: "*",
Audiences: []string{"features.grafana.app"},
},
expectedProvider: setting.GOFFProviderType,
},
{
name: "goff provider with failing token exchange",
cfg: setting.OpenFeatureSettings{
ProviderType: setting.GOFFProviderType,
URL: u,
TargetingKey: "grafana",
},
expectExchangeRequest: &authlib.TokenExchangeRequest{
Namespace: "*",
Audiences: []string{"features.grafana.app"},
},
expectedProvider: setting.GOFFProviderType, expectedProvider: setting.GOFFProviderType,
failSigning: true,
}, },
{ {
name: "invalid provider", name: "invalid provider",
@ -42,14 +69,46 @@ func TestCreateProvider(t *testing.T) {
}, },
} }
require.NoError(t, err)
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
provider, err := createProvider(tc.cfg.ProviderType, tc.cfg.URL, nil) cfg := setting.NewCfg()
cfg.OpenFeature = tc.cfg
var tokenExchangeClient *fakeTokenExchangeClient
if tc.expectExchangeRequest != nil {
tokenExchangeClient = &fakeTokenExchangeClient{
Mock: &mock.Mock{
ExpectedCalls: []*mock.Call{
{
Method: "Exchange",
Arguments: mock.Arguments{mock.Anything, *tc.expectExchangeRequest},
},
},
},
}
if tc.failSigning {
tokenExchangeClient.expectedErr = errors.New("failed signing access token")
}
}
tokenExchangeMiddleware := middleware.TestingTokenExchangeMiddleware(tokenExchangeClient)
goffClient, err := goffHTTPClient(tokenExchangeMiddleware)
require.NoError(t, err, "failed to create goff http client")
provider, err := createProvider(tc.cfg.ProviderType, tc.cfg.URL, nil, goffClient)
require.NoError(t, err) require.NoError(t, err)
err = openfeature.SetProviderAndWait(provider)
require.NoError(t, err, "failed to set provider")
if tc.expectedProvider == setting.GOFFProviderType { if tc.expectedProvider == setting.GOFFProviderType {
_, ok := provider.(*gofeatureflag.Provider) _, ok := provider.(*gofeatureflag.Provider)
assert.True(t, ok, "expected provider to be of type goff.Provider") assert.True(t, ok, "expected provider to be of type goff.Provider")
testGoFFProvider(t, tc.failSigning)
} else { } else {
_, ok := provider.(*inMemoryBulkProvider) _, ok := provider.(*inMemoryBulkProvider)
assert.True(t, ok, "expected provider to be of type memprovider.InMemoryProvider") assert.True(t, ok, "expected provider to be of type memprovider.InMemoryProvider")
@ -57,3 +116,21 @@ func TestCreateProvider(t *testing.T) {
}) })
} }
} }
func testGoFFProvider(t *testing.T, failSigning bool) {
// this tests with a fake identity with * namespace access, but in any case, it proves what the requester
// is scoped to is what is used to sign the token with
ctx, _ := identity.WithServiceIdentity(context.Background(), 1)
// Test that the flag evaluation can be attempted (though it will fail due to non-existent service)
// The important thing is that the authentication middleware is properly integrated
_, err := openfeature.GetApiInstance().GetClient().BooleanValueDetails(ctx, "test", false, openfeature.NewEvaluationContext("test", map[string]interface{}{"test": "test"}))
// Error related to the token exchange should be returned if signing fails
// otherwise, it should return a connection refused error since the goff URL is not set
if failSigning {
assert.ErrorContains(t, err, "failed to exchange token: error signing token", "should return an error when signing fails")
} else {
assert.ErrorContains(t, err, "connect: connection refused", "should return an error when goff url is not set")
}
}

@ -51,6 +51,7 @@ func setup(t *testing.T, conf []byte) {
t.Helper() t.Helper()
cfg, err := setting.NewCfgFromBytes(conf) cfg, err := setting.NewCfgFromBytes(conf)
require.NoError(t, err) require.NoError(t, err)
err = InitOpenFeatureWithCfg(cfg) err = InitOpenFeatureWithCfg(cfg)
require.NoError(t, err) require.NoError(t, err)
} }

Loading…
Cancel
Save