Access Control: Add fine-grained access control to GET stats and settings handlers (#35622)

* add accesscontrol action for stats read

* use accesscontrol middleware for stats route

* add fixed role with permissions to read sever stats

* add accesscontrol action for settings read

* use accesscontrol middleware for settings route

* add fixed role with permissions to read settings

* add accesscontrol tests for AdminGetSettings and AdminGetStats

* add ability to scope settings

* add tests for AdminGetSettings
This commit is contained in:
Karl Persson
2021-06-14 17:36:48 +02:00
committed by GitHub
parent 74a6e6d973
commit 395b942134
10 changed files with 292 additions and 22 deletions

View File

@ -22,11 +22,13 @@ Fixed roles | Permissions | Descriptions
`fixed:users:org:edit` | All permissions from `fixed:users:org:read` and <br>`org.users:add`<br>`org.users:remove`<br>`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`<br>`ldap.status:read` | Allows to read LDAP information and status.
`fixed:ldap:admin:edit` | All permissions from `fixed:ldap:admin:read` and <br>`ldap.user:sync`<br>`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<br>`settings:write` | Update settings
## Default built-in role assignments
Built-in roles | Associated roles | Descriptions
--- | --- | ---
Grafana Admin | `fixed:permissions:admin:edit`<br>`fixed:permissions:admin:read`<br>`fixed:reporting:admin:edit`<br>`fixed:reporting:admin:read`<br>`fixed:users:admin:edit`<br>`fixed:users:admin:read`<br>`fixed:users:org:edit`<br>`fixed:users:org:read`<br>`fixed:ldap:admin:edit`<br>`fixed:ldap:admin:read`<br>`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`<br>`fixed:permissions:admin:read`<br>`fixed:reporting:admin:edit`<br>`fixed:reporting:admin:read`<br>`fixed:users:admin:edit`<br>`fixed:users:admin:read`<br>`fixed:users:org:edit`<br>`fixed:users:org:read`<br>`fixed:ldap:admin:edit`<br>`fixed:ldap:admin:read`<br>`fixed:server:admin:read`<br>`fixed:settings:admin:read`<br>`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`<br>`fixed:users:org:read`<br>`fixed:reporting:admin:edit`<br>`fixed:reporting:admin:read` | Allows access to resource which [Admin]({{< relref "../../permissions/organization_roles.md" >}}) has permissions by default.

View File

@ -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:**`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Read settings
`settings:write` | `settings:**`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Update settings
`server.stats:read` | n/a | Read server stats
## Scope definitions

View File

@ -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)
}

159
pkg/api/admin_test.go Normal file
View File

@ -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)
})
}
}

View File

@ -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))

View File

@ -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
}

View File

@ -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",

View File

@ -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)

View File

@ -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:*"
)

View File

@ -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,