diff --git a/docs/sources/enterprise/access-control/fine-grained-access-control-references.md b/docs/sources/enterprise/access-control/fine-grained-access-control-references.md index 65e1d7f7cc2..f9104b23241 100644 --- a/docs/sources/enterprise/access-control/fine-grained-access-control-references.md +++ b/docs/sources/enterprise/access-control/fine-grained-access-control-references.md @@ -22,11 +22,13 @@ Fixed roles | Permissions | Descriptions `fixed:users:org:edit` | All permissions from `fixed:users:org:read` and
`org.users:add`
`org.users:remove`
`org.users.role:update` | Allows every read action for user organizations and in addition allows to administer user organizations. `fixed:ldap:admin:read` | `ldap.user:read`
`ldap.status:read` | Allows to read LDAP information and status. `fixed:ldap:admin:edit` | All permissions from `fixed:ldap:admin:read` and
`ldap.user:sync`
`ldap.config:reload` | Allows every read action for LDAP and in addition allows to administer LDAP. -`fixed:settings:admin:edit` | `settings:write` | Update all settings +`fixed:server:admin:read` | `server.stats:read` | Read server stats +`fixed:settings:admin:read` | `settings:read` | Read settings +`fixed:settings:admin:edit` | All permissions from `fixed:settings:admin:read` and
`settings:write` | Update settings ## Default built-in role assignments Built-in roles | Associated roles | Descriptions --- | --- | --- -Grafana Admin | `fixed:permissions:admin:edit`
`fixed:permissions:admin:read`
`fixed:reporting:admin:edit`
`fixed:reporting:admin:read`
`fixed:users:admin:edit`
`fixed:users:admin:read`
`fixed:users:org:edit`
`fixed:users:org:read`
`fixed:ldap:admin:edit`
`fixed:ldap:admin:read`
`fixed:settings:admin:edit` | Allows access to resources which [Grafana Server Admin]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) has permissions by default. +Grafana Admin | `fixed:permissions:admin:edit`
`fixed:permissions:admin:read`
`fixed:reporting:admin:edit`
`fixed:reporting:admin:read`
`fixed:users:admin:edit`
`fixed:users:admin:read`
`fixed:users:org:edit`
`fixed:users:org:read`
`fixed:ldap:admin:edit`
`fixed:ldap:admin:read`
`fixed:server:admin:read`
`fixed:settings:admin:read`
`fixed:settings:admin:edit` | Allows access to resources which [Grafana Server Admin]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) has permissions by default. Admin | `fixed:users:org:edit`
`fixed:users:org:read`
`fixed:reporting:admin:edit`
`fixed:reporting:admin:read` | Allows access to resource which [Admin]({{< relref "../../permissions/organization_roles.md" >}}) has permissions by default. diff --git a/docs/sources/enterprise/access-control/permissions.md b/docs/sources/enterprise/access-control/permissions.md index 25f87ddcd45..fff815e3bb7 100644 --- a/docs/sources/enterprise/access-control/permissions.md +++ b/docs/sources/enterprise/access-control/permissions.md @@ -63,7 +63,9 @@ Actions | Applicable scopes | Descriptions `ldap.status:read` | n/a | Verify the LDAP servers’ availability. `ldap.config:reload` | n/a | Reload the LDAP configuration. `status:accesscontrol` | `service:accesscontrol` | Get access-control enabled status. +`settings:read` | `settings:**`
`settings:auth.saml:*`
`settings:auth.saml:enabled` (property level) | Read settings `settings:write` | `settings:**`
`settings:auth.saml:*`
`settings:auth.saml:enabled` (property level) | Update settings +`server.stats:read` | n/a | Read server stats ## Scope definitions diff --git a/pkg/api/admin.go b/pkg/api/admin.go index 643db782be2..a21d59fd2bd 100644 --- a/pkg/api/admin.go +++ b/pkg/api/admin.go @@ -1,13 +1,23 @@ package api import ( + "context" + "fmt" + "net/http" + "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/setting" ) -func (hs *HTTPServer) AdminGetSettings(_ *models.ReqContext) response.Response { - return response.JSON(200, hs.SettingsProvider.Current()) +func (hs *HTTPServer) AdminGetSettings(c *models.ReqContext) response.Response { + settings, err := hs.getAuthorizedSettings(c.Req.Context(), c.SignedInUser, hs.SettingsProvider.Current()) + if err != nil { + return response.Error(http.StatusForbidden, "Failed to authorize settings", err) + } + return response.JSON(http.StatusOK, settings) } func AdminGetStats(c *models.ReqContext) response.Response { @@ -19,3 +29,52 @@ func AdminGetStats(c *models.ReqContext) response.Response { return response.JSON(200, statsQuery.Result) } + +func (hs *HTTPServer) getAuthorizedSettings(ctx context.Context, user *models.SignedInUser, bag setting.SettingsBag) (setting.SettingsBag, error) { + if hs.AccessControl.IsDisabled() { + return bag, nil + } + + eval := func(scopes ...string) (bool, error) { + return hs.AccessControl.Evaluate(ctx, user, accesscontrol.ActionSettingsRead, scopes...) + } + + ok, err := eval(accesscontrol.ScopeSettingsAll) + if err != nil { + return nil, err + } + if ok { + return bag, nil + } + + authorizedBag := make(setting.SettingsBag) + + for section, keys := range bag { + ok, err := eval(getSettingsScope(section, "*")) + if err != nil { + return nil, err + } + if ok { + authorizedBag[section] = keys + continue + } + + for key := range keys { + ok, err := eval(getSettingsScope(section, key)) + if err != nil { + return nil, err + } + if ok { + if _, exists := authorizedBag[section]; !exists { + authorizedBag[section] = make(map[string]string) + } + authorizedBag[section][key] = bag[section][key] + } + } + } + return authorizedBag, nil +} + +func getSettingsScope(section, key string) string { + return fmt.Sprintf("settings:%s:%s", section, key) +} diff --git a/pkg/api/admin_test.go b/pkg/api/admin_test.go new file mode 100644 index 00000000000..f24aa263306 --- /dev/null +++ b/pkg/api/admin_test.go @@ -0,0 +1,159 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" +) + +type getSettingsTestCase struct { + desc string + expectedCode int + expectedBody string + permissions []*accesscontrol.Permission +} + +func TestAPI_AdminGetSettings(t *testing.T) { + tests := []getSettingsTestCase{ + { + desc: "should return all settings", + expectedCode: http.StatusOK, + expectedBody: `{"auth.proxy":{"enable_login_token":"false","enabled":"false"},"auth.saml":{"allow_idp_initiated":"false","enabled":"true"}}`, + permissions: []*accesscontrol.Permission{ + { + Action: accesscontrol.ActionSettingsRead, + Scope: accesscontrol.ScopeSettingsAll, + }, + }, + }, + { + desc: "should only return auth.saml settings", + expectedCode: http.StatusOK, + expectedBody: `{"auth.saml":{"allow_idp_initiated":"false","enabled":"true"}}`, + permissions: []*accesscontrol.Permission{ + { + Action: accesscontrol.ActionSettingsRead, + Scope: "settings:auth.saml:*", + }, + }, + }, + { + desc: "should only partial properties from auth.saml and auth.proxy settings", + expectedCode: http.StatusOK, + expectedBody: `{"auth.proxy":{"enable_login_token":"false"},"auth.saml":{"enabled":"true"}}`, + permissions: []*accesscontrol.Permission{ + { + Action: accesscontrol.ActionSettingsRead, + Scope: "settings:auth.saml:enabled", + }, + { + Action: accesscontrol.ActionSettingsRead, + Scope: "settings:auth.proxy:enable_login_token", + }, + }, + }, + } + + cfg := setting.NewCfg() + //seed sections and keys + cfg.Raw.DeleteSection("DEFAULT") + saml, err := cfg.Raw.NewSection("auth.saml") + assert.NoError(t, err) + _, err = saml.NewKey("enabled", "true") + assert.NoError(t, err) + _, err = saml.NewKey("allow_idp_initiated", "false") + assert.NoError(t, err) + + proxy, err := cfg.Raw.NewSection("auth.proxy") + assert.NoError(t, err) + _, err = proxy.NewKey("enabled", "false") + assert.NoError(t, err) + _, err = proxy.NewKey("enable_login_token", "false") + assert.NoError(t, err) + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + sc, hs := setupAccessControlScenarioContext(t, cfg, "/api/admin/settings", test.permissions) + hs.SettingsProvider = &setting.OSSImpl{Cfg: cfg} + + sc.resp = httptest.NewRecorder() + var err error + sc.req, err = http.NewRequest(http.MethodGet, "/api/admin/settings", nil) + assert.NoError(t, err) + + sc.exec() + + assert.Equal(t, test.expectedCode, sc.resp.Code) + assert.Equal(t, test.expectedBody, sc.resp.Body.String()) + }) + } +} + +func TestAdmin_AccessControl(t *testing.T) { + tests := []accessControlTestCase{ + { + expectedCode: http.StatusOK, + desc: "AdminGetStats should return 200 for user with correct permissions", + url: "/api/admin/stats", + method: http.MethodGet, + permissions: []*accesscontrol.Permission{ + { + Action: accesscontrol.ActionServerStatsRead, + }, + }, + }, + { + expectedCode: http.StatusForbidden, + desc: "AdminGetStats should return 403 for user without required permissions", + url: "/api/admin/stats", + method: http.MethodGet, + permissions: []*accesscontrol.Permission{ + { + Action: "wrong", + }, + }, + }, + { + expectedCode: http.StatusOK, + desc: "AdminGetSettings should return 200 for user with correct permissions", + url: "/api/admin/settings", + method: http.MethodGet, + permissions: []*accesscontrol.Permission{ + { + Action: accesscontrol.ActionSettingsRead, + }, + }, + }, + { + expectedCode: http.StatusForbidden, + desc: "AdminGetSettings should return 403 for user without required permissions", + url: "/api/admin/settings", + method: http.MethodGet, + permissions: []*accesscontrol.Permission{ + { + Action: "wrong", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + cfg := setting.NewCfg() + sc, hs := setupAccessControlScenarioContext(t, cfg, test.url, test.permissions) + sc.resp = httptest.NewRecorder() + hs.SettingsProvider = &setting.OSSImpl{Cfg: cfg} + + var err error + sc.req, err = http.NewRequest(test.method, test.url, nil) + assert.NoError(t, err) + + sc.exec() + assert.Equal(t, test.expectedCode, sc.resp.Code) + }) + } +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 8e0724bd1ff..d0d1e6ec9d7 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -63,13 +63,13 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboard/import/", reqSignedIn, hs.Index) r.Get("/configuration", reqGrafanaAdmin, hs.Index) r.Get("/admin", reqGrafanaAdmin, hs.Index) - r.Get("/admin/settings", reqGrafanaAdmin, hs.Index) + r.Get("/admin/settings", authorize(reqGrafanaAdmin, accesscontrol.ActionSettingsRead), hs.Index) r.Get("/admin/users", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, accesscontrol.ScopeGlobalUsersAll), hs.Index) r.Get("/admin/users/create", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersCreate), hs.Index) r.Get("/admin/users/edit/:id", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead), hs.Index) r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index) r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, hs.Index) - r.Get("/admin/stats", reqGrafanaAdmin, hs.Index) + r.Get("/admin/stats", authorize(reqGrafanaAdmin, accesscontrol.ActionServerStatsRead), hs.Index) r.Get("/admin/ldap", authorize(reqGrafanaAdmin, accesscontrol.ActionLDAPStatusRead), hs.Index) r.Get("/styleguide", reqSignedIn, hs.Index) @@ -433,8 +433,8 @@ func (hs *HTTPServer) registerRoutes() { // admin api r.Group("/api/admin", func(adminRoute routing.RouteRegister) { - adminRoute.Get("/settings", reqGrafanaAdmin, routing.Wrap(hs.AdminGetSettings)) - adminRoute.Get("/stats", reqGrafanaAdmin, routing.Wrap(AdminGetStats)) + adminRoute.Get("/settings", authorize(reqGrafanaAdmin, accesscontrol.ActionSettingsRead), routing.Wrap(hs.AdminGetSettings)) + adminRoute.Get("/stats", authorize(reqGrafanaAdmin, accesscontrol.ActionServerStatsRead), routing.Wrap(AdminGetStats)) adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, bind(dtos.PauseAllAlertsCommand{}), routing.Wrap(PauseAllAlerts)) adminRoute.Post("/provisioning/dashboards/reload", reqGrafanaAdmin, routing.Wrap(hs.AdminProvisioningReloadDashboards)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 9da77f27b10..921f31532cb 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -253,7 +253,7 @@ func (f *fakeAccessControl) IsDisabled() bool { return f.isDisabled } -func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url string, permissions []*accesscontrol.Permission) *scenarioContext { +func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url string, permissions []*accesscontrol.Permission) (*scenarioContext, *HTTPServer) { cfg.FeatureToggles = make(map[string]bool) cfg.FeatureToggles["accesscontrol"] = true @@ -268,5 +268,13 @@ func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url strin hs.registerRoutes() hs.RouteRegister.Register(sc.m.Router) - return sc + return sc, hs +} + +type accessControlTestCase struct { + expectedCode int + desc string + url string + method string + permissions []*accesscontrol.Permission } diff --git a/pkg/api/index.go b/pkg/api/index.go index 1c536164204..e9081600b15 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -357,12 +357,21 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Orgs", Id: "global-orgs", Url: hs.Cfg.AppSubURL + "/admin/orgs", Icon: "building", }) + } + + if hasAccess(ac.ReqGrafanaAdmin, ac.ActionSettingsRead) { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Settings", Id: "server-settings", Url: hs.Cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt", }) + } + + if hasAccess(ac.ReqGrafanaAdmin, ac.ActionServerStatsRead) { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Stats", Id: "server-stats", Url: hs.Cfg.AppSubURL + "/admin/stats", Icon: "graph-bar", }) + } + + if c.IsGrafanaAdmin { if hs.Cfg.PluginAdminEnabled { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Plugin catalog", Id: "plugin-catalog", Url: hs.Cfg.AppSubURL + "/a/grafana-plugin-admin-app", Icon: "plug", diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go index 5868d9118cd..b8d31cc1b26 100644 --- a/pkg/api/ldap_debug_test.go +++ b/pkg/api/ldap_debug_test.go @@ -581,16 +581,8 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) { // Access control tests for ldap endpoints // *** -type ldapAccessControlTestCase struct { - expectedCode int - desc string - url string - method string - permissions []*accesscontrol.Permission -} - func TestLDAP_AccessControl(t *testing.T) { - tests := []ldapAccessControlTestCase{ + tests := []accessControlTestCase{ { url: "/api/admin/ldap/reload", method: http.MethodPost, @@ -667,8 +659,6 @@ func TestLDAP_AccessControl(t *testing.T) { for _, test := range tests { t.Run(test.desc, func(t *testing.T) { - t.Helper() - enabled := setting.LDAPEnabled configFile := setting.LDAPConfigFile @@ -685,7 +675,7 @@ func TestLDAP_AccessControl(t *testing.T) { cfg := setting.NewCfg() cfg.LDAPEnabled = true - sc := setupAccessControlScenarioContext(t, cfg, test.url, test.permissions) + sc, _ := setupAccessControlScenarioContext(t, cfg, test.url, test.permissions) sc.resp = httptest.NewRecorder() sc.req, err = http.NewRequest(test.method, test.url, nil) assert.NoError(t, err) diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 2d1e91edc78..09fe491b43e 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -80,12 +80,22 @@ const ( ActionLDAPStatusRead = "ldap.status:read" ActionLDAPConfigReload = "ldap.config:reload" + // Server actions + ActionServerStatsRead = "server.stats:read" + + // Settings actions + ActionSettingsRead = "settings:read" + // Global Scopes ScopeGlobalUsersAll = "global:users:*" + // Users scopes ScopeUsersSelf = "users:self" ScopeUsersAll = "users:*" + // Settings scope + ScopeSettingsAll = "settings:**" + // Services Scopes ScopeServicesAll = "service:*" ) diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go index 270c3915d3d..6dc8c484816 100644 --- a/pkg/services/accesscontrol/roles.go +++ b/pkg/services/accesscontrol/roles.go @@ -28,6 +28,27 @@ var ldapAdminEditRole = RoleDTO{ }), } +var serverAdminReadRole = RoleDTO{ + Version: 1, + Name: serverAdminRead, + Permissions: []Permission{ + { + Action: ActionServerStatsRead, + }, + }, +} + +var settingsAdminReadRole = RoleDTO{ + Version: 1, + Name: settingsAdminRead, + Permissions: []Permission{ + { + Action: ActionSettingsRead, + Scope: ScopeSettingsAll, + }, + }, +} + var usersOrgReadRole = RoleDTO{ Name: usersOrgRead, Version: 1, @@ -145,6 +166,10 @@ var provisioningAdminRole = RoleDTO{ // resource. FixedRoleGrants lists which built-in roles are // assigned which fixed roles in this list. var FixedRoles = map[string]RoleDTO{ + serverAdminRead: serverAdminReadRole, + + settingsAdminRead: settingsAdminReadRole, + usersAdminRead: usersAdminReadRole, usersAdminEdit: usersAdminEditRole, @@ -158,6 +183,10 @@ var FixedRoles = map[string]RoleDTO{ } const ( + serverAdminRead = "fixed:server:admin:read" + + settingsAdminRead = "fixed:settings:admin:read" + usersAdminEdit = "fixed:users:admin:edit" usersAdminRead = "fixed:users:admin:read" @@ -177,6 +206,8 @@ var FixedRoleGrants = map[string][]string{ ldapAdminEdit, ldapAdminRead, provisioningAdmin, + serverAdminRead, + settingsAdminRead, usersAdminEdit, usersAdminRead, usersOrgEdit,