App Plugins: Allow to define experimental pages (#114232)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -21,6 +22,13 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/web"
|
"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 {
|
type AuthOptions struct {
|
||||||
@@ -146,6 +154,12 @@ func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, log
|
|||||||
return
|
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
|
permitted := true
|
||||||
path := normalizeIncludePath(c.Req.URL.Path)
|
path := normalizeIncludePath(c.Req.URL.Path)
|
||||||
hasAccess := ac.HasAccess(accessControl, c)
|
hasAccess := ac.HasAccess(accessControl, c)
|
||||||
@@ -294,3 +308,18 @@ func shouldForceLogin(c *contextmodel.ReqContext) bool {
|
|||||||
|
|
||||||
return forceLogin
|
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.<path>"
|
||||||
|
func PageIsFeatureToggleEnabled(ctx context.Context, path string) bool {
|
||||||
|
flagKey := pluginPageFeatureFlagPrefix + filepath.Clean(path)
|
||||||
|
enabled := openfeatureClient.Boolean(
|
||||||
|
ctx,
|
||||||
|
flagKey,
|
||||||
|
true,
|
||||||
|
openfeature.TransactionContext(ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
"testing"
|
"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/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -28,6 +33,8 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/web"
|
"github.com/grafana/grafana/pkg/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var openfeatureTestMutex sync.Mutex
|
||||||
|
|
||||||
func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr error) *contexthandler.ContextHandler {
|
func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr error) *contexthandler.ContextHandler {
|
||||||
return contexthandler.ProvideService(setting.NewCfg(), &authntest.FakeService{
|
return contexthandler.ProvideService(setting.NewCfg(), &authntest.FakeService{
|
||||||
ExpectedErr: authErr,
|
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 {
|
func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler {
|
||||||
return func(c *web.Context) {
|
return func(c *web.Context) {
|
||||||
reqCtx := &contextmodel.ReqContext{
|
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))
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/middleware"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
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 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{
|
link := &navtree.NavLink{
|
||||||
Text: include.Name,
|
Text: include.Name,
|
||||||
Icon: include.Icon,
|
Icon: include.Icon,
|
||||||
|
|||||||
Reference in New Issue
Block a user