From 9f82eac8331a35e51fa07d3c44cd08f9f3fcf682 Mon Sep 17 00:00:00 2001 From: Vardan Torosyan Date: Wed, 14 Apr 2021 16:31:27 +0200 Subject: [PATCH] Access control: Add access control based permissions to admins/users (#32409) Co-authored-by: Emil Tullstedt --- go.mod | 1 + pkg/api/api.go | 53 ++++---- .../accesscontrol/evaluator/evaluator.go | 1 - .../accesscontrol/middleware/middleware.go | 8 +- pkg/services/accesscontrol/models.go | 31 +++++ .../ossaccesscontrol/builtin_roles.go | 44 ------- .../ossaccesscontrol/ossaccesscontrol.go | 16 +-- .../ossaccesscontrol/ossaccesscontrol_test.go | 9 +- pkg/services/accesscontrol/roles.go | 113 ++++++++++++++++++ pkg/services/accesscontrol/roles_test.go | 35 ++++++ 10 files changed, 230 insertions(+), 81 deletions(-) delete mode 100644 pkg/services/accesscontrol/ossaccesscontrol/builtin_roles.go create mode 100644 pkg/services/accesscontrol/roles.go create mode 100644 pkg/services/accesscontrol/roles_test.go diff --git a/go.mod b/go.mod index 04e755d3443..35e072d6275 100644 --- a/go.mod +++ b/go.mod @@ -101,6 +101,7 @@ require ( gopkg.in/square/go-jose.v2 v2.5.1 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b + gotest.tools v2.2.0+incompatible xorm.io/core v0.7.3 xorm.io/xorm v0.8.2 ) diff --git a/pkg/api/api.go b/pkg/api/api.go index b3dbc73b702..28d015a54f5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -5,6 +5,7 @@ import ( "time" "github.com/go-macaron/binding" + "github.com/grafana/grafana/pkg/api/avatar" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/frontendlogging" @@ -13,6 +14,9 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + + acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware" ) var plog = log.New("api") @@ -30,6 +34,7 @@ func (hs *HTTPServer) registerRoutes() { redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL() redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL(hs.Cfg) redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg) + authorize := acmiddleware.Middleware(hs.AccessControl) quota := middleware.Quota(hs.QuotaService) bind := binding.Bind @@ -154,16 +159,17 @@ func (hs *HTTPServer) registerRoutes() { // users (admin permission required) apiRoute.Group("/users", func(usersRoute routing.RouteRegister) { - usersRoute.Get("/", routing.Wrap(SearchUsers)) - usersRoute.Get("/search", routing.Wrap(SearchUsersWithPaging)) - usersRoute.Get("/:id", routing.Wrap(GetUserByID)) - usersRoute.Get("/:id/teams", routing.Wrap(GetUserTeams)) - usersRoute.Get("/:id/orgs", routing.Wrap(GetUserOrgList)) + const userIDScope = `users:{{ index . ":id" }}` + usersRoute.Get("/", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, accesscontrol.ScopeUsersAll), routing.Wrap(SearchUsers)) + usersRoute.Get("/search", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, accesscontrol.ScopeUsersAll), routing.Wrap(SearchUsersWithPaging)) + usersRoute.Get("/:id", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, userIDScope), routing.Wrap(GetUserByID)) + usersRoute.Get("/:id/teams", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersTeamRead, userIDScope), routing.Wrap(GetUserTeams)) + usersRoute.Get("/:id/orgs", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, userIDScope), routing.Wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com - usersRoute.Get("/lookup", routing.Wrap(GetUserByLoginOrEmail)) - usersRoute.Put("/:id", bind(models.UpdateUserCommand{}), routing.Wrap(UpdateUser)) - usersRoute.Post("/:id/using/:orgId", routing.Wrap(UpdateUserActiveOrg)) - }, reqGrafanaAdmin) + usersRoute.Get("/lookup", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, accesscontrol.ScopeUsersAll), routing.Wrap(GetUserByLoginOrEmail)) + usersRoute.Put("/:id", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersWrite, userIDScope), bind(models.UpdateUserCommand{}), routing.Wrap(UpdateUser)) + usersRoute.Post("/:id/using/:orgId", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersWrite, userIDScope), routing.Wrap(UpdateUserActiveOrg)) + }) // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { @@ -417,21 +423,9 @@ func (hs *HTTPServer) registerRoutes() { // admin api r.Group("/api/admin", func(adminRoute routing.RouteRegister) { adminRoute.Get("/settings", routing.Wrap(AdminGetSettings)) - adminRoute.Post("/users", bind(dtos.AdminCreateUserForm{}), routing.Wrap(hs.AdminCreateUser)) - adminRoute.Put("/users/:id/password", bind(dtos.AdminUpdateUserPasswordForm{}), routing.Wrap(AdminUpdateUserPassword)) - adminRoute.Put("/users/:id/permissions", bind(dtos.AdminUpdateUserPermissionsForm{}), routing.Wrap(hs.AdminUpdateUserPermissions)) - adminRoute.Delete("/users/:id", routing.Wrap(AdminDeleteUser)) - adminRoute.Post("/users/:id/disable", routing.Wrap(hs.AdminDisableUser)) - adminRoute.Post("/users/:id/enable", routing.Wrap(AdminEnableUser)) - adminRoute.Get("/users/:id/quotas", routing.Wrap(GetUserQuotas)) - adminRoute.Put("/users/:id/quotas/:target", bind(models.UpdateUserQuotaCmd{}), routing.Wrap(UpdateUserQuota)) adminRoute.Get("/stats", routing.Wrap(AdminGetStats)) adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), routing.Wrap(PauseAllAlerts)) - adminRoute.Post("/users/:id/logout", routing.Wrap(hs.AdminLogoutUser)) - adminRoute.Get("/users/:id/auth-tokens", routing.Wrap(hs.AdminGetUserAuthTokens)) - adminRoute.Post("/users/:id/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), routing.Wrap(hs.AdminRevokeUserAuthToken)) - adminRoute.Post("/provisioning/dashboards/reload", routing.Wrap(hs.AdminProvisioningReloadDashboards)) adminRoute.Post("/provisioning/plugins/reload", routing.Wrap(hs.AdminProvisioningReloadPlugins)) adminRoute.Post("/provisioning/datasources/reload", routing.Wrap(hs.AdminProvisioningReloadDatasources)) @@ -442,6 +436,23 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Get("/ldap/status", routing.Wrap(hs.GetLDAPStatus)) }, reqGrafanaAdmin) + // Administering users + r.Group("/api/admin/users", func(adminUserRoute routing.RouteRegister) { + const userIDScope = `users:{{ index . ":id" }}` + adminUserRoute.Post("/", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersCreate), bind(dtos.AdminCreateUserForm{}), routing.Wrap(hs.AdminCreateUser)) + adminUserRoute.Put("/:id/password", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersPasswordUpdate, userIDScope), bind(dtos.AdminUpdateUserPasswordForm{}), routing.Wrap(AdminUpdateUserPassword)) + adminUserRoute.Put("/:id/permissions", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersPermissionsUpdate, userIDScope), bind(dtos.AdminUpdateUserPermissionsForm{}), routing.Wrap(hs.AdminUpdateUserPermissions)) + adminUserRoute.Delete("/:id", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersDelete, userIDScope), routing.Wrap(AdminDeleteUser)) + adminUserRoute.Post("/:id/disable", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersDisable, userIDScope), routing.Wrap(hs.AdminDisableUser)) + adminUserRoute.Post("/:id/enable", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersEnable, userIDScope), routing.Wrap(AdminEnableUser)) + adminUserRoute.Get("/:id/quotas", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersQuotasList, userIDScope), routing.Wrap(GetUserQuotas)) + adminUserRoute.Put("/:id/quotas/:target", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersQuotasUpdate, userIDScope), bind(models.UpdateUserQuotaCmd{}), routing.Wrap(UpdateUserQuota)) + + adminUserRoute.Post("/:id/logout", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersLogout, userIDScope), routing.Wrap(hs.AdminLogoutUser)) + adminUserRoute.Get("/:id/auth-tokens", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersAuthTokenList, userIDScope), routing.Wrap(hs.AdminGetUserAuthTokens)) + adminUserRoute.Post("/:id/revoke-auth-token", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersAuthTokenUpdate, userIDScope), bind(models.RevokeAuthTokenCmd{}), routing.Wrap(hs.AdminRevokeUserAuthToken)) + }) + // rendering r.Get("/render/*", reqSignedIn, hs.RenderToPng) diff --git a/pkg/services/accesscontrol/evaluator/evaluator.go b/pkg/services/accesscontrol/evaluator/evaluator.go index a9ae7bc8e11..79f1ac2e50d 100644 --- a/pkg/services/accesscontrol/evaluator/evaluator.go +++ b/pkg/services/accesscontrol/evaluator/evaluator.go @@ -20,7 +20,6 @@ func Evaluate(ctx context.Context, ac accesscontrol.AccessControl, user *models. if !ok { return false, nil } - for _, s := range scope { var match bool for dbScope := range dbScopes { diff --git a/pkg/services/accesscontrol/middleware/middleware.go b/pkg/services/accesscontrol/middleware/middleware.go index 2975da36c22..0ed6f240049 100644 --- a/pkg/services/accesscontrol/middleware/middleware.go +++ b/pkg/services/accesscontrol/middleware/middleware.go @@ -18,6 +18,8 @@ func Middleware(ac accesscontrol.AccessControl) func(macaron.Handler, string, .. } return func(c *models.ReqContext) { + // We need this otherwise templated scopes get initialized only once, during the first call + runtimeScope := make([]string, len(scopes)) for i, scope := range scopes { var buf bytes.Buffer @@ -31,17 +33,17 @@ func Middleware(ac accesscontrol.AccessControl) func(macaron.Handler, string, .. c.JsonApiErr(http.StatusInternalServerError, "Internal server error", err) return } - scopes[i] = buf.String() + runtimeScope[i] = buf.String() } - hasAccess, err := ac.Evaluate(c.Req.Context(), c.SignedInUser, permission, scopes...) + hasAccess, err := ac.Evaluate(c.Req.Context(), c.SignedInUser, permission, runtimeScope...) if err != nil { c.Logger.Error("Error from access control system", "error", err) c.JsonApiErr(http.StatusForbidden, "Forbidden", nil) return } if !hasAccess { - c.Logger.Info("Access denied", "userID", c.UserId, "permission", permission, "scopes", scopes) + c.Logger.Info("Access denied", "userID", c.UserId, "permission", permission, "scopes", runtimeScope) c.JsonApiErr(http.StatusForbidden, "Forbidden", nil) return } diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 561246f7ce9..bec701dea87 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -38,3 +38,34 @@ func (p RoleDTO) Role() Role { Description: p.Description, } } + +const ( + // Permission actions + + ActionUsersRead = "users:read" + ActionUsersWrite = "users:write" + ActionUsersTeamRead = "users.teams:read" + // We can ignore gosec G101 since this does not contain any credentials + // nolint:gosec + ActionUsersAuthTokenList = "users.authtoken:list" + // We can ignore gosec G101 since this does not contain any credentials + // nolint:gosec + ActionUsersAuthTokenUpdate = "users.authtoken:update" + // We can ignore gosec G101 since this does not contain any credentials + // nolint:gosec + ActionUsersPasswordUpdate = "users.password:update" + ActionUsersDelete = "users:delete" + ActionUsersCreate = "users:create" + ActionUsersEnable = "users:enable" + ActionUsersDisable = "users:disable" + ActionUsersPermissionsUpdate = "users.permissions:update" + ActionUsersLogout = "users:logout" + ActionUsersQuotasList = "users.quotas:list" + ActionUsersQuotasUpdate = "users.quotas:update" + + // Global Scopes + ScopeUsersAll = "users:*" + ScopeUsersSelf = "users:self" +) + +const RoleGrafanaAdmin = "Grafana Admin" diff --git a/pkg/services/accesscontrol/ossaccesscontrol/builtin_roles.go b/pkg/services/accesscontrol/ossaccesscontrol/builtin_roles.go deleted file mode 100644 index 5064a8f2442..00000000000 --- a/pkg/services/accesscontrol/ossaccesscontrol/builtin_roles.go +++ /dev/null @@ -1,44 +0,0 @@ -package ossaccesscontrol - -import ( - "github.com/grafana/grafana/pkg/services/accesscontrol" -) - -const roleGrafanaAdmin = "Grafana Admin" - -var builtInRolesMap = map[string]accesscontrol.RoleDTO{ - "grafana:builtin:users:read:self": { - Name: "grafana:builtin:users:read:self", - Version: 1, - Permissions: []accesscontrol.Permission{ - { - Action: "users:read", - Scope: "users:self", - }, - { - Action: "users.tokens:list", - Scope: "users:self", - }, - { - Action: "users.teams:read", - Scope: "users:self", - }, - }, - }, -} - -var builtInRoleGrants = map[string][]string{ - "Viewer": { - "grafana:builtin:users:read:self", - }, -} - -func getBuiltInRole(role string) *accesscontrol.RoleDTO { - var builtInRole accesscontrol.RoleDTO - if r, ok := builtInRolesMap[role]; ok { - // Do not modify builtInRoles - builtInRole = r - return &builtInRole - } - return nil -} diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index 8d58eb9b808..d161251b1da 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go @@ -39,16 +39,16 @@ func (ac *OSSAccessControlService) Evaluate(ctx context.Context, user *models.Si // GetUserPermissions returns user permissions based on built-in roles func (ac *OSSAccessControlService) GetUserPermissions(ctx context.Context, user *models.SignedInUser) ([]*accesscontrol.Permission, error) { - roles := ac.GetUserBuiltInRoles(user) + builtinRoles := ac.GetUserBuiltInRoles(user) permissions := make([]*accesscontrol.Permission, 0) - for _, legacyRole := range roles { - if builtInRoleNames, ok := builtInRoleGrants[legacyRole]; ok { - for _, builtInRoleName := range builtInRoleNames { - builtInRole := getBuiltInRole(builtInRoleName) - if builtInRole == nil { + for _, builtin := range builtinRoles { + if roleNames, ok := accesscontrol.PredefinedRoleGrants[builtin]; ok { + for _, name := range roleNames { + r, exists := accesscontrol.PredefinedRoles[name] + if !exists { continue } - for _, p := range builtInRole.Permissions { + for _, p := range r.Permissions { permission := p permissions = append(permissions, &permission) } @@ -65,7 +65,7 @@ func (ac *OSSAccessControlService) GetUserBuiltInRoles(user *models.SignedInUser roles = append(roles, string(role)) } if user.IsGrafanaAdmin { - roles = append(roles, roleGrafanaAdmin) + roles = append(roles, accesscontrol.RoleGrafanaAdmin) } return roles diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go index 9268ddeac5e..09c81a182aa 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/setting" ) @@ -53,12 +54,12 @@ func TestEvaluatingPermissions(t *testing.T) { desc: "should successfully evaluate access to the endpoint", user: userTestCase{ name: "testuser", - orgRole: models.ROLE_EDITOR, + orgRole: "Grafana Admin", isGrafanaAdmin: false, }, endpoints: []endpointTestCase{ - {permission: "users.teams:read", scope: []string{"users:self"}}, - {permission: "users:read", scope: []string{"users:self"}}, + {permission: accesscontrol.ActionUsersDisable, scope: []string{accesscontrol.ScopeUsersAll}}, + {permission: accesscontrol.ActionUsersEnable, scope: []string{accesscontrol.ScopeUsersAll}}, }, evalResult: true, }, @@ -70,7 +71,7 @@ func TestEvaluatingPermissions(t *testing.T) { isGrafanaAdmin: false, }, endpoints: []endpointTestCase{ - {permission: "users:create", scope: []string{"users"}}, + {permission: accesscontrol.ActionUsersCreate, scope: []string{accesscontrol.ScopeUsersAll}}, }, evalResult: false, }, diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go new file mode 100644 index 00000000000..5ed89ea932f --- /dev/null +++ b/pkg/services/accesscontrol/roles.go @@ -0,0 +1,113 @@ +package accesscontrol + +// PredefinedRoles provides a map of permission sets/roles which can be +// assigned to a set of users. When adding a new resource protected by +// Grafana access control the default permissions should be added to a +// new predefined role in this set so that users can access the new +// resource. PredefinedRoleGrants lists which organization roles are +// assigned which predefined roles in this list. +var PredefinedRoles = map[string]RoleDTO{ + // TODO: Add support for inheritance between the predefined roles to + // make the admin ⊃ editor ⊃ viewer property hold. + usersAdminRead: { + Name: usersAdminRead, + Version: 1, + Permissions: []Permission{ + { + Action: ActionUsersRead, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersTeamRead, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersAuthTokenList, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersQuotasList, + Scope: ScopeUsersAll, + }, + }, + }, + usersAdminEdit: { + Name: usersAdminEdit, + Version: 1, + Permissions: []Permission{ + { + // Inherited from grafana:roles:users:admin:read + Action: ActionUsersRead, + Scope: ScopeUsersAll, + }, + { + // Inherited from grafana:roles:users:admin:read + Action: ActionUsersTeamRead, + Scope: ScopeUsersAll, + }, + { + // Inherited from grafana:roles:users:admin:read + Action: ActionUsersAuthTokenList, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersPasswordUpdate, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersCreate, + }, + { + Action: ActionUsersWrite, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersDelete, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersEnable, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersDisable, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersPermissionsUpdate, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersLogout, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersAuthTokenUpdate, + Scope: ScopeUsersAll, + }, + { + // Inherited from grafana:roles:users:admin:read + Action: ActionUsersQuotasList, + Scope: ScopeUsersAll, + }, + { + Action: ActionUsersQuotasUpdate, + Scope: ScopeUsersAll, + }, + }, + }, +} + +const ( + usersAdminEdit = "grafana:roles:users:admin:edit" + usersAdminRead = "grafana:roles:users:admin:read" +) + +// PredefinedRoleGrants specifies which organization roles are assigned +// to which set of PredefinedRoles by default. Alphabetically sorted. +var PredefinedRoleGrants = map[string][]string{ + RoleGrafanaAdmin: { + usersAdminEdit, + usersAdminRead, + }, +} diff --git a/pkg/services/accesscontrol/roles_test.go b/pkg/services/accesscontrol/roles_test.go new file mode 100644 index 00000000000..91518d333f1 --- /dev/null +++ b/pkg/services/accesscontrol/roles_test.go @@ -0,0 +1,35 @@ +package accesscontrol + +import ( + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPredefinedRoles(t *testing.T) { + for name, r := range PredefinedRoles { + assert.Truef(t, + strings.HasPrefix(name, "grafana:roles:"), + "expected all predefined roles to be prefixed by 'grafana:roles:', found role '%s'", name, + ) + assert.Equal(t, name, r.Name) + assert.NotZero(t, r.Version) + // assert.NotEmpty(t, r.Description) + } +} + +func TestPredefinedRoleGrants(t *testing.T) { + for _, v := range PredefinedRoleGrants { + assert.True(t, + sort.SliceIsSorted(v, func(i, j int) bool { + return v[i] < v[j] + }), + "require role grant lists to be sorted", + ) + for _, r := range v { + assert.Contains(t, PredefinedRoles, r) + } + } +}