Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature toggles management: Define get feature toggles api #72106

Merged
merged 2 commits into from Jul 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need these as instance level config? (Vs just options in each feature)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These configs were proposed during our DNA discussion, as they could be useful for operators (HG or others) to decide which features their grafana administrators would be able to tune in.


# 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)
}
97 changes: 97 additions & 0 deletions pkg/api/featuremgmt_test.go
@@ -0,0 +1,97 @@
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 func() { require.NoError(t, 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)
}
})
}
}
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
}