Skip to content

Commit

Permalink
Feature Toggle Management: Define get feature toggles api
Browse files Browse the repository at this point in the history
  • Loading branch information
jcalisto committed Jul 21, 2023
1 parent 6ac664e commit 39dcbac
Show file tree
Hide file tree
Showing 11 changed files with 207 additions and 2 deletions.
8 changes: 8 additions & 0 deletions conf/defaults.ini
Expand Up @@ -1626,3 +1626,11 @@ server_name =
proxy_address =
# Determines if the secure socks proxy should be shown on the datasources page, defaults to true if the feature is enabled
show_ui = true

################################## Feature Management ##############################################
[feature_management]
# Hides specific feature toggles from the feature management page
hidden_toggles =

# Disables updating specific feature toggles in the feature management page
read_only_toggles =
5 changes: 5 additions & 0 deletions conf/sample.ini
Expand Up @@ -1517,3 +1517,8 @@
# The address of the socks5 proxy datasources should connect to
; proxy_address =
; show_ui = true

################################## Feature Management ##############################################
[feature_management]
hidden_toggles =
read_only_toggles =
15 changes: 14 additions & 1 deletion pkg/api/accesscontrol.go
Expand Up @@ -421,14 +421,27 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{"Admin"},
}

featuremgmtReaderRole := ac.RoleRegistration{
Role: ac.RoleDTO{
Name: "fixed:featuremgmt:reader",
DisplayName: "Feature Management reader",
Description: "Read feature toggles",
Group: "Feature Management",
Permissions: []ac.Permission{
{Action: ac.ActionFeatureManagementRead},
},
},
Grants: []string{"Admin"},
}

return hs.accesscontrolService.DeclareFixedRoles(
provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
datasourcesIdReaderRole, orgReaderRole, orgWriterRole,
orgMaintainerRole, teamsCreatorRole, teamsWriterRole, datasourcesExplorerRole,
annotationsReaderRole, dashboardAnnotationsWriterRole, annotationsWriterRole,
dashboardsCreatorRole, dashboardsReaderRole, dashboardsWriterRole,
foldersCreatorRole, foldersReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole,
publicDashboardsWriterRole,
publicDashboardsWriterRole, featuremgmtReaderRole,
)
}

Expand Down
6 changes: 6 additions & 0 deletions pkg/api/api.go
Expand Up @@ -421,6 +421,12 @@ func (hs *HTTPServer) registerRoutes() {
pluginRoute.Get("/:pluginId/metrics", reqOrgAdmin, routing.Wrap(hs.CollectPluginMetrics))
})

if hs.Features.IsEnabled(featuremgmt.FlagFeatureToggleAdminPage) {
apiRoute.Group("/featuremgmt", func(featuremgmtRoute routing.RouteRegister) {
featuremgmtRoute.Get("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureToggles)
})
}

apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
apiRoute.Any("/datasources/proxy/:id/*", authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest)
apiRoute.Any("/datasources/proxy/uid/:uid/*", authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequestWithUID)
Expand Down
30 changes: 30 additions & 0 deletions pkg/api/featuremgmt.go
@@ -0,0 +1,30 @@
package api

import (
"net/http"

"github.com/grafana/grafana/pkg/api/response"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
)

func (hs *HTTPServer) GetFeatureToggles(ctx *contextmodel.ReqContext) response.Response {
featureMgmtCfg := hs.Cfg.FeatureManagement

features := hs.Features.GetFlags()
enabledFeatures := hs.Features.GetEnabled(ctx.Req.Context())

for i := 0; i < len(features); {
ft := features[i]
if _, ok := featureMgmtCfg.HiddenToggles[ft.Name]; ok {
features = append(features[:i], features[i+1:]...) // remove feature
continue
}
if _, ok := featureMgmtCfg.ReadOnlyToggles[ft.Name]; ok {
features[i].ReadOnly = true
}
features[i].Enabled = enabledFeatures[ft.Name]
i++
}

return response.JSON(http.StatusOK, features)
}
98 changes: 98 additions & 0 deletions pkg/api/featuremgmt_test.go
@@ -0,0 +1,98 @@
package api

import (
"encoding/json"
"testing"

"net/http"

"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGetFeatureToggles(t *testing.T) {
type testCase struct {
desc string
permissions []accesscontrol.Permission
features []interface{}
expectedCode int
hiddenTogles map[string]struct{}
readOnlyToggles map[string]struct{}
}

tests := []testCase{
{
desc: "should not be able to get feature toggles without permissions",
permissions: []accesscontrol.Permission{},
features: []interface{}{},
expectedCode: http.StatusForbidden,
},
{
desc: "should be able to get feature toggles with correct permissions",
permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}},
features: []interface{}{"toggle1", true, "toggle2", false},
expectedCode: http.StatusOK,
},
{
desc: "hidden toggles are not present in the response",
permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}},
features: []interface{}{"toggle1", true, "toggle2", false},
expectedCode: http.StatusOK,
hiddenTogles: map[string]struct{}{"toggle1": {}},
},
{
desc: "read only toggles have the readOnly field set",
permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}},
features: []interface{}{"toggle1", true, "toggle2", false},
expectedCode: http.StatusOK,
hiddenTogles: map[string]struct{}{"toggle1": {}},
readOnlyToggles: map[string]struct{}{"toggle2": {}},
},
}

for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
cfg := setting.NewCfg()
cfg.FeatureManagement.HiddenToggles = tt.hiddenTogles
cfg.FeatureManagement.ReadOnlyToggles = tt.readOnlyToggles
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = cfg
hs.Features = featuremgmt.WithFeatures(append([]interface{}{"featureToggleAdminPage", true}, tt.features...)...)
hs.orgService = orgtest.NewOrgServiceFake()
hs.userService = &usertest.FakeUserService{
ExpectedUser: &user.User{ID: 1},
}
})

req := webtest.RequestWithSignedInUser(server.NewGetRequest("/api/featuremgmt"), userWithPermissions(1, tt.permissions))
res, err := server.SendJSON(req)
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, tt.expectedCode, res.StatusCode)

if tt.expectedCode == http.StatusOK {
var result []featuremgmt.FeatureFlag
err := json.NewDecoder(res.Body).Decode(&result)
require.NoError(t, err)

for _, ft := range result {
if _, ok := tt.hiddenTogles[ft.Name]; ok {
t.Fail()
}
if _, ok := tt.readOnlyToggles[ft.Name]; ok {
assert.True(t, ft.ReadOnly, tt.desc)
}
}
assert.Equal(t, 3-len(tt.hiddenTogles), len(result), tt.desc)
}
require.NoError(t, res.Body.Close())
})
}
}
3 changes: 3 additions & 0 deletions pkg/services/accesscontrol/models.go
Expand Up @@ -436,6 +436,9 @@ const (
// Alerting provisioning actions
ActionAlertingProvisioningRead = "alert.provisioning:read"
ActionAlertingProvisioningWrite = "alert.provisioning:write"

// Feature Management actions
ActionFeatureManagementRead = "featuremgmt.read"
)

var (
Expand Down
3 changes: 3 additions & 0 deletions pkg/services/featuremgmt/features.go
Expand Up @@ -113,4 +113,7 @@ type FeatureFlag struct {
RequiresLicense bool `json:"requiresLicense,omitempty"` // Must be enabled in the license
FrontendOnly bool `json:"frontend,omitempty"` // change is only seen in the frontend
HideFromDocs bool `json:"hideFromDocs,omitempty"` // don't add the values to docs

Enabled bool `json:"enabled,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
}
4 changes: 3 additions & 1 deletion pkg/services/featuremgmt/manager.go
Expand Up @@ -153,6 +153,7 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
// WithFeatures([]interface{}{"my_feature", "other_feature"}) or WithFeatures([]interface{}{"my_feature", true})
func WithFeatures(spec ...interface{}) *FeatureManager {
count := len(spec)
features := make(map[string]*FeatureFlag, count)
enabled := make(map[string]bool, count)

idx := 0
Expand All @@ -165,10 +166,11 @@ func WithFeatures(spec ...interface{}) *FeatureManager {
idx++
}

features[key] = &FeatureFlag{Name: key, Enabled: val}
if val {
enabled[key] = true
}
}

return &FeatureManager{enabled: enabled}
return &FeatureManager{enabled: enabled, flags: features}
}
5 changes: 5 additions & 0 deletions pkg/setting/setting.go
Expand Up @@ -548,6 +548,9 @@ type Cfg struct {
// This needs to be on the global object since its used in the
// sqlstore package and HTTP middlewares.
DatabaseInstrumentQueries bool

// Feature Management Settings
FeatureManagement FeatureMgmtSettings
}

// AddChangePasswordLink returns if login form is disabled or not since
Expand Down Expand Up @@ -1233,6 +1236,8 @@ func (cfg *Cfg) Load(args CommandLineArgs) error {
logSection := iniFile.Section("log")
cfg.UserFacingDefaultError = logSection.Key("user_facing_default_error").MustString("please inspect Grafana server log for details")

cfg.readFeatureManagementConfig()

return nil
}

Expand Down
32 changes: 32 additions & 0 deletions pkg/setting/setting_featuremgmt.go
@@ -0,0 +1,32 @@
package setting

import (
"github.com/grafana/grafana/pkg/util"
)

type FeatureMgmtSettings struct {
HiddenToggles map[string]struct{}
ReadOnlyToggles map[string]struct{}
}

func (cfg *Cfg) readFeatureManagementConfig() {
section := cfg.Raw.Section("feature_management")

hiddenToggles := make(map[string]struct{})
readOnlyToggles := make(map[string]struct{})

// parse the comma separated list in `hidden_toggles`.
hiddenTogglesStr := valueAsString(section, "hidden_toggles", "")
for _, feature := range util.SplitString(hiddenTogglesStr) {
hiddenToggles[feature] = struct{}{}
}

// parse the comma separated list in `read_only_toggles`.
readOnlyTogglesStr := valueAsString(section, "read_only_toggles", "")
for _, feature := range util.SplitString(readOnlyTogglesStr) {
readOnlyToggles[feature] = struct{}{}
}

cfg.FeatureManagement.HiddenToggles = hiddenToggles
cfg.FeatureManagement.ReadOnlyToggles = readOnlyToggles
}

0 comments on commit 39dcbac

Please sign in to comment.