diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 719d2ab5cb5..f013d9d2bfa 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "errors" "net/http" "net/url" @@ -21,6 +22,13 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" + "github.com/open-feature/go-sdk/openfeature" +) + +var openfeatureClient = openfeature.NewDefaultClient() + +const ( + pluginPageFeatureFlagPrefix = "plugin-page-visible." ) type AuthOptions struct { @@ -146,6 +154,12 @@ func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, log return } + if !PageIsFeatureToggleEnabled(c.Req.Context(), c.Req.URL.Path) { + logger.Debug("Forbidden experimental plugin page", "plugin", pluginID, "path", c.Req.URL.Path) + accessForbidden(c) + return + } + permitted := true path := normalizeIncludePath(c.Req.URL.Path) hasAccess := ac.HasAccess(accessControl, c) @@ -294,3 +308,18 @@ func shouldForceLogin(c *contextmodel.ReqContext) bool { return forceLogin } + +// PageIsFeatureToggleEnabled checks if a page is enabled via OpenFeature feature flags. +// It returns false if the feature flag is set and set to false. +// The feature flag key format is: "plugin-page-visible." +func PageIsFeatureToggleEnabled(ctx context.Context, path string) bool { + flagKey := pluginPageFeatureFlagPrefix + filepath.Clean(path) + enabled := openfeatureClient.Boolean( + ctx, + flagKey, + true, + openfeature.TransactionContext(ctx), + ) + + return enabled +} diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index fdca1d04ee3..19a7d68559e 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -1,12 +1,17 @@ package middleware import ( + "context" "errors" "fmt" "net/http" "net/http/httptest" + "sync" "testing" + "github.com/open-feature/go-sdk/openfeature" + "github.com/open-feature/go-sdk/openfeature/memprovider" + oftesting "github.com/open-feature/go-sdk/openfeature/testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,6 +33,8 @@ import ( "github.com/grafana/grafana/pkg/web" ) +var openfeatureTestMutex sync.Mutex + func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr error) *contexthandler.ContextHandler { return contexthandler.ProvideService(setting.NewCfg(), &authntest.FakeService{ ExpectedErr: authErr, @@ -422,6 +429,60 @@ func TestCanAdminPlugin(t *testing.T) { } } +func TestPageIsFeatureToggleEnabled(t *testing.T) { + type testCase struct { + desc string + path string + flags map[string]bool + expectedResult bool + } + + tests := []testCase{ + { + desc: "returns true when feature flag is enabled", + path: "/a/my-plugin/settings", + flags: map[string]bool{ + pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": true, + }, + expectedResult: true, + }, + { + desc: "returns false when feature flag is disabled", + path: "/a/my-plugin/settings", + flags: map[string]bool{ + pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false, + }, + expectedResult: false, + }, + { + desc: "returns false when feature flag is disabled with trailing slash", + path: "/a/my-plugin/settings/", + flags: map[string]bool{ + pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false, + }, + expectedResult: false, + }, + { + desc: "returns true when feature flag does not exist", + path: "/a/my-plugin/settings", + flags: map[string]bool{}, + expectedResult: true, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ctx := context.Background() + + setupTestProvider(t, tt.flags) + + result := PageIsFeatureToggleEnabled(ctx, tt.path) + + assert.Equal(t, tt.expectedResult, result) + }) + } +} + func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler { return func(c *web.Context) { reqCtx := &contextmodel.ReqContext{ @@ -437,3 +498,38 @@ func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler c.Req = c.Req.WithContext(ctxkey.Set(c.Req.Context(), reqCtx)) } } + +// setupTestProvider creates a test OpenFeature provider with the given flags. +// Uses a global lock to prevent concurrent provider changes across tests. +func setupTestProvider(t *testing.T, flags map[string]bool) oftesting.TestProvider { + t.Helper() + + // Lock to prevent concurrent provider changes + openfeatureTestMutex.Lock() + + testProvider := oftesting.NewTestProvider() + flagsMap := map[string]memprovider.InMemoryFlag{} + + for key, value := range flags { + flagsMap[key] = memprovider.InMemoryFlag{ + DefaultVariant: "defaultVariant", + Variants: map[string]any{ + "defaultVariant": value, + }, + } + } + + testProvider.UsingFlags(t, flagsMap) + + err := openfeature.SetProviderAndWait(testProvider) + require.NoError(t, err) + + t.Cleanup(func() { + testProvider.Cleanup() + _ = openfeature.SetProviderAndWait(openfeature.NoopProvider{}) + // Unlock after cleanup to allow other tests to run + openfeatureTestMutex.Unlock() + }) + + return testProvider +} diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 4f8015f5687..b9b0edac970 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -128,6 +129,10 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode } if include.Type == "page" { + if !middleware.PageIsFeatureToggleEnabled(c.Req.Context(), include.Path) { + s.log.Debug("Skipping page", "plugin", plugin.ID, "path", include.Path) + continue + } link := &navtree.NavLink{ Text: include.Name, Icon: include.Icon,