diff --git a/pkg/api/admin.go b/pkg/api/admin.go index a21d59fd2bd..88a5c165267 100644 --- a/pkg/api/admin.go +++ b/pkg/api/admin.go @@ -2,13 +2,12 @@ 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" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/setting" ) @@ -35,11 +34,11 @@ func (hs *HTTPServer) getAuthorizedSettings(ctx context.Context, user *models.Si return bag, nil } - eval := func(scopes ...string) (bool, error) { - return hs.AccessControl.Evaluate(ctx, user, accesscontrol.ActionSettingsRead, scopes...) + eval := func(scope string) (bool, error) { + return hs.AccessControl.Evaluate(ctx, user, ac.EvalPermission(ac.ActionSettingsRead, scope)) } - ok, err := eval(accesscontrol.ScopeSettingsAll) + ok, err := eval(ac.ScopeSettingsAll) if err != nil { return nil, err } @@ -50,7 +49,7 @@ func (hs *HTTPServer) getAuthorizedSettings(ctx context.Context, user *models.Si authorizedBag := make(setting.SettingsBag) for section, keys := range bag { - ok, err := eval(getSettingsScope(section, "*")) + ok, err := eval(ac.Scope("settings", section, "*")) if err != nil { return nil, err } @@ -60,7 +59,7 @@ func (hs *HTTPServer) getAuthorizedSettings(ctx context.Context, user *models.Si } for key := range keys { - ok, err := eval(getSettingsScope(section, key)) + ok, err := eval(ac.Scope("settings", section, key)) if err != nil { return nil, err } @@ -74,7 +73,3 @@ func (hs *HTTPServer) getAuthorizedSettings(ctx context.Context, user *models.Si } return authorizedBag, nil } - -func getSettingsScope(section, key string) string { - return fmt.Sprintf("settings:%s:%s", section, key) -} diff --git a/pkg/api/api.go b/pkg/api/api.go index 9e8c55b66ad..0ece27b47a4 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -13,7 +13,7 @@ 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" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware" ) @@ -55,23 +55,23 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/datasources/", reqOrgAdmin, hs.Index) r.Get("/datasources/new", reqOrgAdmin, hs.Index) r.Get("/datasources/edit/*", reqOrgAdmin, hs.Index) - r.Get("/org/users", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRead, accesscontrol.ScopeUsersAll), hs.Index) + r.Get("/org/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)), hs.Index) r.Get("/org/users/new", reqOrgAdmin, hs.Index) - r.Get("/org/users/invite", authorize(reqOrgAdmin, accesscontrol.ActionUsersCreate), hs.Index) + r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) r.Get("/org/teams", reqCanAccessTeams, hs.Index) r.Get("/org/teams/*", reqCanAccessTeams, hs.Index) r.Get("/org/apikeys/", reqOrgAdmin, hs.Index) r.Get("/dashboard/import/", reqSignedIn, hs.Index) r.Get("/configuration", reqGrafanaAdmin, hs.Index) r.Get("/admin", 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/settings", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), hs.Index) + r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index) + r.Get("/admin/users/create", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) + r.Get("/admin/users/edit/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead)), hs.Index) r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index) r.Get("/admin/orgs/edit/:id", 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("/admin/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index) + r.Get("/admin/ldap", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index) r.Get("/styleguide", reqSignedIn, hs.Index) @@ -100,7 +100,7 @@ func (hs *HTTPServer) registerRoutes() { f(c) } middleware.EnsureEditorOrViewerCanEdit(c) - }, accesscontrol.ActionDatasourcesExplore), hs.Index) + }, ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index) r.Get("/playlists/", reqSignedIn, hs.Index) r.Get("/playlists/*", reqSignedIn, hs.Index) @@ -163,16 +163,16 @@ func (hs *HTTPServer) registerRoutes() { // users (admin permission required) apiRoute.Group("/users", func(usersRoute routing.RouteRegister) { - const userIDScope = `global:users:{{ index . ":id" }}` - usersRoute.Get("/", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, accesscontrol.ScopeGlobalUsersAll), routing.Wrap(SearchUsers)) - usersRoute.Get("/search", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, accesscontrol.ScopeGlobalUsersAll), 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)) + userIDScope := ac.Scope("global", "users", ac.Parameter(":id")) + usersRoute.Get("/", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), routing.Wrap(SearchUsers)) + usersRoute.Get("/search", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), routing.Wrap(SearchUsersWithPaging)) + usersRoute.Get("/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, userIDScope)), routing.Wrap(GetUserByID)) + usersRoute.Get("/:id/teams", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersTeamRead, userIDScope)), routing.Wrap(GetUserTeams)) + usersRoute.Get("/:id/orgs", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, userIDScope)), routing.Wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com - usersRoute.Get("/lookup", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, accesscontrol.ScopeGlobalUsersAll), 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)) + usersRoute.Get("/lookup", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), routing.Wrap(GetUserByLoginOrEmail)) + usersRoute.Put("/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersWrite, userIDScope)), bind(models.UpdateUserCommand{}), routing.Wrap(UpdateUser)) + usersRoute.Post("/:id/using/:orgId", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersWrite, userIDScope)), routing.Wrap(UpdateUserActiveOrg)) }) // team (admin permission required) @@ -202,19 +202,19 @@ func (hs *HTTPServer) registerRoutes() { // current org apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { - const usersScope = `users:{{ index . ":userId" }}` + userIDScope := ac.Scope("users", ac.Parameter(":userId")) orgRoute.Put("/", reqOrgAdmin, bind(dtos.UpdateOrgForm{}), routing.Wrap(UpdateOrgCurrent)) orgRoute.Put("/address", reqOrgAdmin, bind(dtos.UpdateOrgAddressForm{}), routing.Wrap(UpdateOrgAddressCurrent)) - orgRoute.Get("/users", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRead, accesscontrol.ScopeUsersAll), routing.Wrap(hs.GetOrgUsersForCurrentOrg)) - orgRoute.Get("/users/search", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRead, accesscontrol.ScopeUsersAll), routing.Wrap(hs.SearchOrgUsersWithPaging)) - orgRoute.Post("/users", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersAdd, accesscontrol.ScopeUsersAll), quota("user"), bind(models.AddOrgUserCommand{}), routing.Wrap(AddOrgUserToCurrentOrg)) - orgRoute.Patch("/users/:userId", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRoleUpdate, usersScope), bind(models.UpdateOrgUserCommand{}), routing.Wrap(UpdateOrgUserForCurrentOrg)) - orgRoute.Delete("/users/:userId", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRemove, usersScope), routing.Wrap(RemoveOrgUserForCurrentOrg)) + orgRoute.Get("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)), routing.Wrap(hs.GetOrgUsersForCurrentOrg)) + orgRoute.Get("/users/search", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)), routing.Wrap(hs.SearchOrgUsersWithPaging)) + orgRoute.Post("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), quota("user"), bind(models.AddOrgUserCommand{}), routing.Wrap(AddOrgUserToCurrentOrg)) + orgRoute.Patch("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRoleUpdate, userIDScope)), bind(models.UpdateOrgUserCommand{}), routing.Wrap(UpdateOrgUserForCurrentOrg)) + orgRoute.Delete("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(RemoveOrgUserForCurrentOrg)) // invites - orgRoute.Get("/invites", authorize(reqOrgAdmin, accesscontrol.ActionUsersCreate), routing.Wrap(GetPendingOrgInvites)) - orgRoute.Post("/invites", authorize(reqOrgAdmin, accesscontrol.ActionUsersCreate), quota("user"), bind(dtos.AddInviteForm{}), routing.Wrap(AddOrgInvite)) - orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, accesscontrol.ActionUsersCreate), routing.Wrap(RevokeInvite)) + orgRoute.Get("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(GetPendingOrgInvites)) + orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), quota("user"), bind(dtos.AddInviteForm{}), routing.Wrap(AddOrgInvite)) + orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(RevokeInvite)) // prefs orgRoute.Get("/preferences", reqOrgAdmin, routing.Wrap(GetOrgPreferences)) @@ -234,15 +234,15 @@ func (hs *HTTPServer) registerRoutes() { // orgs (admin routes) apiRoute.Group("/orgs/:orgId", func(orgsRoute routing.RouteRegister) { - const usersScope = `users:{{ index . ":userId" }}` + userIDScope := ac.Scope("users", ac.Parameter(":userId")) orgsRoute.Get("/", reqGrafanaAdmin, routing.Wrap(GetOrgByID)) orgsRoute.Put("/", reqGrafanaAdmin, bind(dtos.UpdateOrgForm{}), routing.Wrap(UpdateOrg)) orgsRoute.Put("/address", reqGrafanaAdmin, bind(dtos.UpdateOrgAddressForm{}), routing.Wrap(UpdateOrgAddress)) orgsRoute.Delete("/", reqGrafanaAdmin, routing.Wrap(DeleteOrgByID)) - orgsRoute.Get("/users", authorize(reqGrafanaAdmin, accesscontrol.ActionOrgUsersRead, accesscontrol.ScopeUsersAll), routing.Wrap(hs.GetOrgUsers)) - orgsRoute.Post("/users", authorize(reqGrafanaAdmin, accesscontrol.ActionOrgUsersAdd, accesscontrol.ScopeUsersAll), bind(models.AddOrgUserCommand{}), routing.Wrap(AddOrgUser)) - orgsRoute.Patch("/users/:userId", authorize(reqGrafanaAdmin, accesscontrol.ActionOrgUsersRoleUpdate, usersScope), bind(models.UpdateOrgUserCommand{}), routing.Wrap(UpdateOrgUser)) - orgsRoute.Delete("/users/:userId", authorize(reqGrafanaAdmin, accesscontrol.ActionOrgUsersRemove, usersScope), routing.Wrap(RemoveOrgUser)) + orgsRoute.Get("/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)), routing.Wrap(hs.GetOrgUsers)) + orgsRoute.Post("/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), bind(models.AddOrgUserCommand{}), routing.Wrap(AddOrgUser)) + orgsRoute.Patch("/users/:userId", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionOrgUsersRoleUpdate, userIDScope)), bind(models.UpdateOrgUserCommand{}), routing.Wrap(UpdateOrgUser)) + orgsRoute.Delete("/users/:userId", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(RemoveOrgUser)) orgsRoute.Get("/quotas", reqGrafanaAdmin, routing.Wrap(GetOrgQuotas)) orgsRoute.Put("/quotas/:target", reqGrafanaAdmin, bind(models.UpdateOrgQuotaCmd{}), routing.Wrap(UpdateOrgQuota)) }) @@ -439,36 +439,37 @@ func (hs *HTTPServer) registerRoutes() { // admin api r.Group("/api/admin", func(adminRoute routing.RouteRegister) { - adminRoute.Get("/settings", authorize(reqGrafanaAdmin, accesscontrol.ActionSettingsRead), routing.Wrap(hs.AdminGetSettings)) - adminRoute.Get("/stats", authorize(reqGrafanaAdmin, accesscontrol.ActionServerStatsRead), routing.Wrap(AdminGetStats)) + adminRoute.Get("/settings", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), routing.Wrap(hs.AdminGetSettings)) + adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(AdminGetStats)) adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, bind(dtos.PauseAllAlertsCommand{}), routing.Wrap(PauseAllAlerts)) - adminRoute.Post("/provisioning/dashboards/reload", authorize(reqGrafanaAdmin, ActionProvisioningReload, ScopeProvisionersDashboards), routing.Wrap(hs.AdminProvisioningReloadDashboards)) - adminRoute.Post("/provisioning/plugins/reload", authorize(reqGrafanaAdmin, ActionProvisioningReload, ScopeProvisionersPlugins), routing.Wrap(hs.AdminProvisioningReloadPlugins)) - adminRoute.Post("/provisioning/datasources/reload", authorize(reqGrafanaAdmin, ActionProvisioningReload, ScopeProvisionersDatasources), routing.Wrap(hs.AdminProvisioningReloadDatasources)) - adminRoute.Post("/provisioning/notifications/reload", authorize(reqGrafanaAdmin, ActionProvisioningReload, ScopeProvisionersNotifications), routing.Wrap(hs.AdminProvisioningReloadNotifications)) + adminRoute.Post("/provisioning/dashboards/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDashboards)), routing.Wrap(hs.AdminProvisioningReloadDashboards)) + adminRoute.Post("/provisioning/plugins/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersPlugins)), routing.Wrap(hs.AdminProvisioningReloadPlugins)) + adminRoute.Post("/provisioning/datasources/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDatasources)), routing.Wrap(hs.AdminProvisioningReloadDatasources)) + adminRoute.Post("/provisioning/notifications/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersNotifications)), routing.Wrap(hs.AdminProvisioningReloadNotifications)) - adminRoute.Post("/ldap/reload", authorize(reqGrafanaAdmin, accesscontrol.ActionLDAPConfigReload), routing.Wrap(hs.ReloadLDAPCfg)) - adminRoute.Post("/ldap/sync/:id", authorize(reqGrafanaAdmin, accesscontrol.ActionLDAPUsersSync), routing.Wrap(hs.PostSyncUserWithLDAP)) - adminRoute.Get("/ldap/:username", authorize(reqGrafanaAdmin, accesscontrol.ActionLDAPUsersRead), routing.Wrap(hs.GetUserFromLDAP)) - adminRoute.Get("/ldap/status", authorize(reqGrafanaAdmin, accesscontrol.ActionLDAPStatusRead), routing.Wrap(hs.GetLDAPStatus)) + adminRoute.Post("/ldap/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPConfigReload)), routing.Wrap(hs.ReloadLDAPCfg)) + adminRoute.Post("/ldap/sync/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPUsersSync)), routing.Wrap(hs.PostSyncUserWithLDAP)) + adminRoute.Get("/ldap/:username", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPUsersRead)), routing.Wrap(hs.GetUserFromLDAP)) + adminRoute.Get("/ldap/status", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), routing.Wrap(hs.GetLDAPStatus)) }) // Administering users r.Group("/api/admin/users", func(adminUserRoute routing.RouteRegister) { - const userIDScope = `global: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)) + userIDScope := ac.Scope("global", "users", ac.Parameter(":id")) - 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)) + adminUserRoute.Post("/", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersCreate)), bind(dtos.AdminCreateUserForm{}), routing.Wrap(hs.AdminCreateUser)) + adminUserRoute.Put("/:id/password", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersPasswordUpdate, userIDScope)), bind(dtos.AdminUpdateUserPasswordForm{}), routing.Wrap(AdminUpdateUserPassword)) + adminUserRoute.Put("/:id/permissions", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersPermissionsUpdate, userIDScope)), bind(dtos.AdminUpdateUserPermissionsForm{}), routing.Wrap(hs.AdminUpdateUserPermissions)) + adminUserRoute.Delete("/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersDelete, userIDScope)), routing.Wrap(AdminDeleteUser)) + adminUserRoute.Post("/:id/disable", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersDisable, userIDScope)), routing.Wrap(hs.AdminDisableUser)) + adminUserRoute.Post("/:id/enable", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersEnable, userIDScope)), routing.Wrap(AdminEnableUser)) + adminUserRoute.Get("/:id/quotas", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasList, userIDScope)), routing.Wrap(GetUserQuotas)) + adminUserRoute.Put("/:id/quotas/:target", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasUpdate, userIDScope)), bind(models.UpdateUserQuotaCmd{}), routing.Wrap(UpdateUserQuota)) + + adminUserRoute.Post("/:id/logout", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersLogout, userIDScope)), routing.Wrap(hs.AdminLogoutUser)) + adminUserRoute.Get("/:id/auth-tokens", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersAuthTokenList, userIDScope)), routing.Wrap(hs.AdminGetUserAuthTokens)) + adminUserRoute.Post("/:id/revoke-auth-token", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersAuthTokenUpdate, userIDScope)), bind(models.RevokeAuthTokenCmd{}), routing.Wrap(hs.AdminRevokeUserAuthToken)) }) // rendering diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index e5a95a0525e..d836cdfa1c8 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -8,9 +8,6 @@ import ( "path/filepath" "testing" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/evaluator" - "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" @@ -18,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/contexthandler" @@ -238,8 +236,12 @@ type fakeAccessControl struct { permissions []*accesscontrol.Permission } -func (f *fakeAccessControl) Evaluate(ctx context.Context, user *models.SignedInUser, permission string, scope ...string) (bool, error) { - return evaluator.Evaluate(ctx, f, user, permission, scope...) +func (f *fakeAccessControl) Evaluate(ctx context.Context, user *models.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) { + permissions, err := f.GetUserPermissions(ctx, user) + if err != nil { + return false, err + } + return evaluator.Evaluate(accesscontrol.GroupScopesByAction(permissions)) } func (f *fakeAccessControl) GetUserPermissions(ctx context.Context, user *models.SignedInUser) ([]*accesscontrol.Permission, error) { diff --git a/pkg/api/index.go b/pkg/api/index.go index 282a222d886..86b4185655d 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -190,7 +190,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto return c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR || setting.ViewersCanEdit } - if setting.ExploreEnabled && hasAccess(canExplore, ac.ActionDatasourcesExplore) { + if setting.ExploreEnabled && hasAccess(canExplore, ac.EvalPermission(ac.ActionDatasourcesExplore)) { navTree = append(navTree, &dtos.NavLink{ Text: "Explore", Id: "explore", @@ -263,7 +263,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto }) } - if hasAccess(ac.ReqOrgAdmin, ac.ActionOrgUsersRead, ac.ScopeUsersAll) { + if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)) { configNodes = append(configNodes, &dtos.NavLink{ Text: "Users", Id: "users", @@ -358,7 +358,7 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { hasAccess := ac.HasAccess(hs.AccessControl, c) adminNavLinks := []*dtos.NavLink{} - if hasAccess(ac.ReqGrafanaAdmin, ac.ActionUsersRead, ac.ScopeGlobalUsersAll) { + if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Users", Id: "global-users", Url: hs.Cfg.AppSubURL + "/admin/users", Icon: "user", }) @@ -370,25 +370,25 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { }) } - if hasAccess(ac.ReqGrafanaAdmin, ac.ActionSettingsRead) { + if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(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) { + if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)) { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Stats", Id: "server-stats", Url: hs.Cfg.AppSubURL + "/admin/stats", Icon: "graph-bar", }) } - if hs.Cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.ActionLDAPStatusRead) { + if hs.Cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book", }) } - if hs.Cfg.PluginAdminEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.ActionPluginsManage) { + if hs.Cfg.PluginAdminEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionPluginsManage)) { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Plugins", Id: "admin-plugins", Url: hs.Cfg.AppSubURL + "/admin/plugins", Icon: "plug", }) diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index cddb344d50c..7514a102617 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -8,13 +8,13 @@ import ( ) type AccessControl interface { - // Evaluate evaluates access to the given resource. - Evaluate(ctx context.Context, user *models.SignedInUser, permission string, scope ...string) (bool, error) + // Evaluate evaluates access to the given resources. + Evaluate(ctx context.Context, user *models.SignedInUser, evaluator Evaluator) (bool, error) // GetUserPermissions returns user permissions. GetUserPermissions(ctx context.Context, user *models.SignedInUser) ([]*Permission, error) - // Middleware checks if service disabled or not to switch to fallback authorization. + //IsDisabled returns if access control is enabled or not IsDisabled() bool // DeclareFixedRoles allow the caller to declare, to the service, fixed roles and their @@ -22,13 +22,13 @@ type AccessControl interface { DeclareFixedRoles(...RoleRegistration) error } -func HasAccess(ac AccessControl, c *models.ReqContext) func(fallback func(*models.ReqContext) bool, permission string, scopes ...string) bool { - return func(fallback func(*models.ReqContext) bool, permission string, scopes ...string) bool { +func HasAccess(ac AccessControl, c *models.ReqContext) func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { + return func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { if ac.IsDisabled() { return fallback(c) } - hasAccess, err := ac.Evaluate(c.Req.Context(), c.SignedInUser, permission, scopes...) + hasAccess, err := ac.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) if err != nil { c.Logger.Error("Error from access control system", "error", err) return false @@ -55,6 +55,19 @@ func BuildPermissionsMap(permissions []*Permission) map[string]bool { return permissionsMap } +// GroupScopesByAction will group scopes on action +func GroupScopesByAction(permissions []*Permission) map[string]map[string]struct{} { + m := make(map[string]map[string]struct{}) + for _, p := range permissions { + if _, ok := m[p.Action]; ok { + m[p.Action][p.Scope] = struct{}{} + } else { + m[p.Action] = map[string]struct{}{p.Scope: {}} + } + } + return m +} + func ValidateScope(scope string) bool { prefix, last := scope[:len(scope)-1], scope[len(scope)-1] // verify that last char is either ':' or '/' if last character of scope is '*' diff --git a/pkg/services/accesscontrol/evaluator.go b/pkg/services/accesscontrol/evaluator.go new file mode 100644 index 00000000000..54cbe773e02 --- /dev/null +++ b/pkg/services/accesscontrol/evaluator.go @@ -0,0 +1,194 @@ +package accesscontrol + +import ( + "bytes" + "fmt" + "html/template" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" +) + +var logger = log.New("accesscontrol.evaluator") + +type Evaluator interface { + // Evaluate permissions that are grouped by action + Evaluate(permissions map[string]map[string]struct{}) (bool, error) + // Inject params into the evaluator's templated scopes. e.g. "settings:" + eval.Parameters(":id") and returns a new Evaluator + Inject(params map[string]string) (Evaluator, error) + // String returns a string representation of permission required by the evaluator + String() string +} + +var _ Evaluator = new(permissionEvaluator) + +// EvalPermission returns an evaluator that will require all scopes in combination with action to match +func EvalPermission(action string, scopes ...string) Evaluator { + return permissionEvaluator{Action: action, Scopes: scopes} +} + +type permissionEvaluator struct { + Action string + Scopes []string +} + +func (p permissionEvaluator) Evaluate(permissions map[string]map[string]struct{}) (bool, error) { + userScopes, ok := permissions[p.Action] + if !ok { + return false, nil + } + + if len(p.Scopes) == 0 { + return true, nil + } + + for _, target := range p.Scopes { + var err error + var matches bool + + for scope := range userScopes { + matches, err = match(scope, target) + if err != nil { + return false, err + } + if matches { + break + } + } + if !matches { + return false, nil + } + } + + return true, nil +} + +func match(scope, target string) (bool, error) { + if scope == "" { + return false, nil + } + + if !ValidateScope(scope) { + logger.Error( + "invalid scope", + "scope", scope, + "reason", "scopes should not contain meta-characters like * or ?, except in the last position", + ) + return false, nil + } + + prefix, last := scope[:len(scope)-1], scope[len(scope)-1] + //Prefix match + if last == '*' { + if strings.HasPrefix(target, prefix) { + logger.Debug("matched scope", "user scope", scope, "target scope", target) + return true, nil + } + } + + return scope == target, nil +} + +func (p permissionEvaluator) Inject(params map[string]string) (Evaluator, error) { + scopes := make([]string, 0, len(p.Scopes)) + for _, scope := range p.Scopes { + tmpl, err := template.New("scope").Parse(scope) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err = tmpl.Execute(&buf, params); err != nil { + return nil, err + } + scopes = append(scopes, buf.String()) + } + return EvalPermission(p.Action, scopes...), nil +} + +func (p permissionEvaluator) String() string { + return fmt.Sprintf("action:%s scopes:%s", p.Action, strings.Join(p.Scopes, ", ")) +} + +var _ Evaluator = new(allEvaluator) + +// EvalAll returns evaluator that requires all passed evaluators to evaluate to true +func EvalAll(allOf ...Evaluator) Evaluator { + return allEvaluator{allOf: allOf} +} + +type allEvaluator struct { + allOf []Evaluator +} + +func (a allEvaluator) Evaluate(permissions map[string]map[string]struct{}) (bool, error) { + for _, e := range a.allOf { + if ok, err := e.Evaluate(permissions); !ok || err != nil { + return false, err + } + } + return true, nil +} + +func (a allEvaluator) Inject(params map[string]string) (Evaluator, error) { + var injected []Evaluator + for _, e := range a.allOf { + i, err := e.Inject(params) + if err != nil { + return nil, err + } + injected = append(injected, i) + } + return EvalAll(injected...), nil +} + +func (a allEvaluator) String() string { + permissions := make([]string, 0, len(a.allOf)) + for _, e := range a.allOf { + permissions = append(permissions, e.String()) + } + return fmt.Sprintf("all(%s)", strings.Join(permissions, " ")) +} + +var _ Evaluator = new(anyEvaluator) + +// EvalAny returns evaluator that requires at least one of passed evaluators to evaluate to true +func EvalAny(anyOf ...Evaluator) Evaluator { + return anyEvaluator{anyOf: anyOf} +} + +type anyEvaluator struct { + anyOf []Evaluator +} + +func (a anyEvaluator) Evaluate(permissions map[string]map[string]struct{}) (bool, error) { + for _, e := range a.anyOf { + ok, err := e.Evaluate(permissions) + if err != nil { + return false, err + } + if ok { + return true, nil + } + } + return false, nil +} + +func (a anyEvaluator) Inject(params map[string]string) (Evaluator, error) { + var injected []Evaluator + for _, e := range a.anyOf { + i, err := e.Inject(params) + if err != nil { + return nil, err + } + injected = append(injected, i) + } + return EvalAny(injected...), nil +} + +func (a anyEvaluator) String() string { + permissions := make([]string, 0, len(a.anyOf)) + for _, e := range a.anyOf { + permissions = append(permissions, e.String()) + } + return fmt.Sprintf("any(%s)", strings.Join(permissions, " ")) +} diff --git a/pkg/services/accesscontrol/evaluator/evaluator.go b/pkg/services/accesscontrol/evaluator/evaluator.go deleted file mode 100644 index 9bb3c72e8a0..00000000000 --- a/pkg/services/accesscontrol/evaluator/evaluator.go +++ /dev/null @@ -1,101 +0,0 @@ -package evaluator - -import ( - "context" - "fmt" - "strings" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/prometheus/client_golang/prometheus" -) - -var logger = log.New("accesscontrol.evaluator") - -// Evaluate evaluates access to the given resource, using provided AccessControl instance. -// Scopes are evaluated with an `OR` relationship. -func Evaluate(ctx context.Context, ac accesscontrol.AccessControl, user *models.SignedInUser, action string, scope ...string) (bool, error) { - timer := prometheus.NewTimer(metrics.MAccessEvaluationsSummary) - defer timer.ObserveDuration() - metrics.MAccessEvaluationCount.Inc() - userPermissions, err := ac.GetUserPermissions(ctx, user) - if err != nil { - return false, err - } - - ok, dbScopes := extractScopes(userPermissions, action) - if !ok { - return false, nil - } - - res, err := evaluateScope(dbScopes, scope...) - return res, err -} - -func evaluateScope(dbScopes map[string]struct{}, targetScopes ...string) (bool, error) { - if len(targetScopes) == 0 { - return true, nil - } - - for _, s := range targetScopes { - for dbScope := range dbScopes { - if dbScope == "" { - continue - } - - if !accesscontrol.ValidateScope(dbScope) { - logger.Error( - "invalid scope", - "reason", fmt.Sprintf("%v should not contain meta-characters like * or ?, except in the last position", dbScope), - "scope", dbScope, - ) - continue - } - - prefix, last := dbScope[:len(dbScope)-1], dbScope[len(dbScope)-1] - //Prefix match - if last == '*' { - if strings.HasPrefix(s, prefix) { - logger.Debug( - "matched scope", - "reason", fmt.Sprintf("matched request scope %v against resource scope %v", dbScope, s), - "request scope", dbScope, - "resource scope", s, - ) - return true, nil - } - } - - if s == dbScope { - return true, nil - } - } - } - - logger.Debug( - "access control failed", - "request scope", dbScopes, - "resource scope", targetScopes, - "reason", fmt.Sprintf("Could not match resource scopes %v with request scopes %v", dbScopes, targetScopes), - ) - return false, nil -} - -func extractScopes(permissions []*accesscontrol.Permission, targetAction string) (bool, map[string]struct{}) { - scopes := map[string]struct{}{} - ok := false - - for _, p := range permissions { - if p == nil { - continue - } - if p.Action == targetAction { - ok = true - scopes[p.Scope] = struct{}{} - } - } - - return ok, scopes -} diff --git a/pkg/services/accesscontrol/evaluator/evaluator_test.go b/pkg/services/accesscontrol/evaluator/evaluator_test.go deleted file mode 100644 index d0b85750cac..00000000000 --- a/pkg/services/accesscontrol/evaluator/evaluator_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package evaluator - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/services/accesscontrol" -) - -func TestExtractPermission(t *testing.T) { - const targetPermission = "permissions:create" - userPermissions := []*accesscontrol.Permission{ - { - Action: "permissions:create", - Scope: "teams:*", - }, - { - Action: "permissions:create", - Scope: "permissions:*", - }, - { - Action: "permissions:remove", - Scope: "permissions:*", - }, - } - expectedScopes := map[string]struct{}{ - "permissions:*": {}, - "teams:*": {}, - } - ok, scopes := extractScopes(userPermissions, targetPermission) - assert.True(t, ok) - assert.Equal(t, expectedScopes, scopes) -} - -func TestEvaluatePermissions(t *testing.T) { - tests := []struct { - Name string - HasScopes map[string]struct{} - NeedAnyScope []string - Valid bool - }{ - { - Name: "Base", - HasScopes: map[string]struct{}{}, - NeedAnyScope: []string{}, - Valid: true, - }, - { - Name: "No expected scope always returns true", - HasScopes: map[string]struct{}{ - "teams:*": {}, - "permissions:*": {}, - "users:*": {}, - "permissions:delegate": {}, - }, - NeedAnyScope: []string{}, - Valid: true, - }, - { - Name: "Single scope from list", - HasScopes: map[string]struct{}{ - "teams:1": {}, - "permissions:delegate": {}, - }, - NeedAnyScope: []string{"teams:1", "permissions:delegate"}, - Valid: true, - }, - { - Name: "Single scope from glob list", - HasScopes: map[string]struct{}{ - "teams:*": {}, - "permissions:*": {}, - "users:*": {}, - "permissions:delegate": {}, - }, - NeedAnyScope: []string{"teams:1", "permissions:delegate"}, - Valid: true, - }, - { - Name: "Either of two scopes from glob list", - HasScopes: map[string]struct{}{ - "teams:*": {}, - "permissions:*": {}, - "users:*": {}, - "permissions:delegate": {}, - }, - NeedAnyScope: []string{"global:admin", "permissions:delegate"}, - Valid: true, - }, - { - Name: "No match found", - HasScopes: map[string]struct{}{ - "teams:*": {}, - "users:*": {}, - "permissions:delegate": {}, - }, - NeedAnyScope: []string{"teams1", "permissions:nodelegate"}, - Valid: false, - }, - } - - for _, tc := range tests { - tc := tc - t.Run(tc.Name, func(t *testing.T) { - ok, err := evaluateScope(tc.HasScopes, tc.NeedAnyScope...) - require.NoError(t, err) - assert.Equal(t, tc.Valid, ok) - }) - } -} diff --git a/pkg/services/accesscontrol/evaluator_test.go b/pkg/services/accesscontrol/evaluator_test.go new file mode 100644 index 00000000000..dd1f6bf9442 --- /dev/null +++ b/pkg/services/accesscontrol/evaluator_test.go @@ -0,0 +1,399 @@ +package accesscontrol + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type evaluateTestCase struct { + desc string + expected bool + evaluator Evaluator + permissions map[string]map[string]struct{} +} + +type injectTestCase struct { + desc string + expected bool + evaluator Evaluator + params map[string]string + permissions map[string]map[string]struct{} +} + +func TestPermission_Evaluate(t *testing.T) { + tests := []evaluateTestCase{ + { + desc: "should evaluate to true", + expected: true, + evaluator: EvalPermission("reports:read", "reports:1"), + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + }, + }, + }, + { + desc: "should evaluate to true when allEvaluator required scopes matches", + expected: true, + evaluator: EvalPermission("reports:read", "reports:1", "reports:2"), + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + "reports:2": struct{}{}, + }, + }, + }, + { + desc: "should evaluate to true for empty scope", + expected: true, + evaluator: EvalPermission("reports:read"), + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + }, + }, + }, + { + desc: "should evaluate to false when only one of required scopes exists", + expected: false, + evaluator: EvalPermission("reports:read", "reports:1", "reports:2"), + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + ok, err := test.evaluator.Evaluate(test.permissions) + assert.NoError(t, err) + assert.Equal(t, test.expected, ok) + }) + } +} + +func TestPermission_Inject(t *testing.T) { + tests := []injectTestCase{ + { + desc: "should inject correct param", + expected: true, + evaluator: EvalPermission("reports:read", Scope("reports", Parameter(":reportId"))), + params: map[string]string{ + ":id": "10", + ":reportId": "1", + }, + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + }, + }, + }, + { + desc: "should fail for nil params", + expected: false, + evaluator: EvalPermission("reports:read", Scope("reports", Parameter(":reportId"))), + params: nil, + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + }, + }, + }, + { + desc: "should inject several parameters to one permission", + expected: true, + evaluator: EvalPermission("reports:read", Scope("reports", Parameter(":reportId"), Parameter(":reportId2"))), + params: map[string]string{ + ":reportId": "report", + ":reportId2": "report2", + }, + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:report:report2": struct{}{}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + injected, err := test.evaluator.Inject(test.params) + assert.NoError(t, err) + ok, err := injected.Evaluate(test.permissions) + assert.NoError(t, err) + assert.Equal(t, test.expected, ok) + }) + } +} + +func TestAll_Evaluate(t *testing.T) { + tests := []evaluateTestCase{ + { + desc: "should return true for one that matches", + evaluator: EvalAll( + EvalPermission("settings:write", Scope("settings", "*")), + ), + permissions: map[string]map[string]struct{}{ + "settings:write": {"settings:*": struct{}{}}, + }, + expected: true, + }, + { + desc: "should return true for several that matches", + evaluator: EvalAll( + EvalPermission("settings:write", Scope("settings", "*")), + EvalPermission("settings:read", Scope("settings", "auth.saml", "*")), + ), + permissions: map[string]map[string]struct{}{ + "settings:write": {"settings:*": struct{}{}}, + "settings:read": {"settings:*": struct{}{}}, + }, + expected: true, + }, + { + desc: "should return false if one does not match", + evaluator: EvalAll( + EvalPermission("settings:write", Scope("settings", "*")), + EvalPermission("settings:read", Scope("settings", "auth.saml", "*")), + EvalPermission("report:read", Scope("reports", "*")), + ), + permissions: map[string]map[string]struct{}{ + "settings:write": {"settings:*": struct{}{}}, + "settings:read": {"settings:*": struct{}{}}, + "report:read": {"report:1": struct{}{}}, + }, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + ok, err := test.evaluator.Evaluate(test.permissions) + assert.NoError(t, err) + assert.Equal(t, test.expected, ok) + }) + } +} + +func TestAll_Inject(t *testing.T) { + tests := []injectTestCase{ + { + desc: "should inject correct param", + expected: true, + evaluator: EvalAll( + EvalPermission("reports:read", Scope("reports", Parameter(":reportId"))), + EvalPermission("settings:read", Scope("settings", Parameter(":settingsId"))), + ), + params: map[string]string{ + ":id": "10", + ":settingsId": "3", + ":reportId": "1", + }, + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + }, + "settings:read": { + "settings:3": struct{}{}, + }, + }, + }, + { + desc: "should fail for nil params", + expected: false, + evaluator: EvalAll( + EvalPermission("settings:read", Scope("reports", Parameter(":settingsId"))), + EvalPermission("reports:read", Scope("reports", Parameter(":reportId"))), + ), + params: nil, + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + }, + "settings:read": { + "settings:3": struct{}{}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + injected, err := test.evaluator.Inject(test.params) + assert.NoError(t, err) + ok, err := injected.Evaluate(test.permissions) + assert.NoError(t, err) + assert.Equal(t, test.expected, ok) + }) + } +} + +func TestAny_Evaluate(t *testing.T) { + tests := []evaluateTestCase{ + { + desc: "should return true for one that matches", + evaluator: EvalAny( + EvalPermission("settings:write", Scope("settings", "*")), + ), + permissions: map[string]map[string]struct{}{ + "settings:write": {"settings:*": struct{}{}}, + }, + expected: true, + }, + { + desc: "should return true when at least one matches", + evaluator: EvalAny( + EvalPermission("settings:write", Scope("settings", "auth.saml", "*")), + EvalPermission("report:read", Scope("reports", "1")), + EvalPermission("report:write", Scope("reports", "10")), + ), + permissions: map[string]map[string]struct{}{ + "settings:write": {"settings:*": struct{}{}}, + }, + expected: true, + }, + { + desc: "should return false when there is no match", + evaluator: EvalAny( + EvalPermission("settings:write", Scope("settings", "auth.saml", "*")), + EvalPermission("report:read", Scope("reports", "1")), + EvalPermission("report:write", Scope("reports", "10")), + ), + permissions: map[string]map[string]struct{}{ + "permissions:write": {"permissions:delegate": struct{}{}}, + }, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + ok, err := test.evaluator.Evaluate(test.permissions) + assert.NoError(t, err) + assert.Equal(t, test.expected, ok) + }) + } +} + +func TestAny_Inject(t *testing.T) { + tests := []injectTestCase{ + { + desc: "should inject correct param", + expected: true, + evaluator: EvalAny( + EvalPermission("reports:read", Scope("reports", Parameter(":reportId"))), + EvalPermission("settings:read", Scope("settings", Parameter(":settingsId"))), + ), + params: map[string]string{ + ":id": "10", + ":settingsId": "3", + ":reportId": "1", + }, + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + }, + "settings:read": { + "settings:3": struct{}{}, + }, + }, + }, + { + desc: "should fail for nil params", + expected: false, + evaluator: EvalAny( + EvalPermission("settings:read", Scope("reports", Parameter(":settingsId"))), + EvalPermission("reports:read", Scope("reports", Parameter(":reportId"))), + ), + params: nil, + permissions: map[string]map[string]struct{}{ + "reports:read": { + "reports:1": struct{}{}, + }, + "settings:read": { + "settings:3": struct{}{}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + injected, err := test.evaluator.Inject(test.params) + assert.NoError(t, err) + ok, err := injected.Evaluate(test.permissions) + assert.NoError(t, err) + assert.Equal(t, test.expected, ok) + }) + } +} + +type combinedTestCase struct { + desc string + evaluator Evaluator + expected bool + permissions map[string]map[string]struct{} +} + +func TestEval(t *testing.T) { + tests := []combinedTestCase{ + { + desc: "should return true when first is true", + evaluator: EvalAny( + EvalPermission("settings:write", Scope("settings", "*")), + EvalAll( + EvalPermission("settings:write", "settings:auth.saml:enabled"), + EvalPermission("settings:write", "settings:auth.saml:max_issue_delay"), + ), + ), + expected: true, + permissions: map[string]map[string]struct{}{ + "settings:write": {"settings:*": struct{}{}}, + }, + }, + { + desc: "should return true when first is false and all is true", + evaluator: EvalAny( + EvalPermission("settings:write", Scope("settings", "*")), + EvalAll( + EvalPermission("settings:write", "settings:auth.saml:enabled"), + EvalPermission("settings:write", "settings:auth.saml:max_issue_delay"), + ), + ), + expected: true, + permissions: map[string]map[string]struct{}{ + "settings:write": { + "settings:auth.saml:enabled": struct{}{}, + "settings:auth.saml:max_issue_delay": struct{}{}, + }, + }, + }, + { + desc: "should return false when both are false", + evaluator: EvalAny( + EvalPermission("settings:write", Scope("settings", "*")), + EvalAll( + EvalPermission("settings:write", "settings:auth.saml:enabled"), + EvalPermission("settings:write", "settings:auth.saml:max_issue_delay"), + ), + ), + expected: false, + permissions: map[string]map[string]struct{}{ + "settings:write": { + "settings:auth.saml:enabled": struct{}{}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + ok, err := test.evaluator.Evaluate(test.permissions) + assert.NoError(t, err) + assert.Equal(t, test.expected, ok) + }) + } +} diff --git a/pkg/services/accesscontrol/middleware/middleware.go b/pkg/services/accesscontrol/middleware/middleware.go index cc7b77984d0..c9a65bcfc76 100644 --- a/pkg/services/accesscontrol/middleware/middleware.go +++ b/pkg/services/accesscontrol/middleware/middleware.go @@ -1,68 +1,50 @@ package middleware import ( - "bytes" "fmt" "net/http" - "text/template" "time" - "github.com/grafana/grafana/pkg/util" - - macaron "gopkg.in/macaron.v1" + "gopkg.in/macaron.v1" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/util" ) -func Middleware(ac accesscontrol.AccessControl) func(macaron.Handler, string, ...string) macaron.Handler { - return func(fallback macaron.Handler, permission string, scopes ...string) macaron.Handler { +func Middleware(ac accesscontrol.AccessControl) func(macaron.Handler, accesscontrol.Evaluator) macaron.Handler { + return func(fallback macaron.Handler, evaluator accesscontrol.Evaluator) macaron.Handler { if ac.IsDisabled() { return fallback } 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 - - tmpl, err := template.New("scope").Parse(scope) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Internal server error", err) - return - } - err = tmpl.Execute(&buf, c.AllParams()) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Internal server error", err) - return - } - runtimeScope[i] = buf.String() - } - - hasAccess, err := ac.Evaluate(c.Req.Context(), c.SignedInUser, permission, runtimeScope...) + injected, err := evaluator.Inject(c.AllParams()) if err != nil { - Deny(c, permission, runtimeScope, err) + c.JsonApiErr(http.StatusInternalServerError, "Internal server error", err) return } - if !hasAccess { - Deny(c, permission, runtimeScope, nil) + + hasAccess, err := ac.Evaluate(c.Req.Context(), c.SignedInUser, injected) + if !hasAccess || err != nil { + Deny(c, injected, err) return } } } } -func Deny(c *models.ReqContext, permission string, scopes []string, err error) { +func Deny(c *models.ReqContext, evaluator accesscontrol.Evaluator, err error) { id := newID() if err != nil { c.Logger.Error("Error from access control system", "error", err, "accessErrorID", id) } else { - c.Logger.Info("Access denied", + c.Logger.Info( + "Access denied", "userID", c.UserId, - "permission", permission, - "scopes", scopes, - "accessErrorID", id) + "accessErrorID", id, + "permissions", evaluator.String(), + ) } // If the user triggers an error in the access control system, we diff --git a/pkg/services/accesscontrol/middleware/middleware_test.go b/pkg/services/accesscontrol/middleware/middleware_test.go new file mode 100644 index 00000000000..ad38dc2e574 --- /dev/null +++ b/pkg/services/accesscontrol/middleware/middleware_test.go @@ -0,0 +1,121 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "gopkg.in/macaron.v1" + + "github.com/stretchr/testify/assert" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +type middlewareTestCase struct { + desc string + expectFallback bool + expectEndpoint bool + evaluator accesscontrol.Evaluator + ac accesscontrol.AccessControl +} + +func TestMiddleware(t *testing.T) { + tests := []middlewareTestCase{ + { + desc: "should use fallback if access control is disabled", + ac: fakeAccessControl{isDisabled: true}, + expectFallback: true, + expectEndpoint: true, + }, + { + desc: "should pass middleware for correct permissions", + ac: fakeAccessControl{ + isDisabled: false, + permissions: []*accesscontrol.Permission{{Action: "users:read", Scope: "users:*"}}, + }, + evaluator: accesscontrol.EvalPermission("users:read", "users:*"), + expectFallback: false, + expectEndpoint: true, + }, + { + desc: "should not reach endpoint when missing permissions", + ac: fakeAccessControl{ + isDisabled: false, + permissions: []*accesscontrol.Permission{{Action: "users:read", Scope: "users:1"}}, + }, + evaluator: accesscontrol.EvalPermission("users:read", "users:*"), + expectFallback: false, + expectEndpoint: false, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + fallbackCalled := false + fallback := func(c *models.ReqContext) { + fallbackCalled = true + } + + server := macaron.New() + server.UseMiddleware(macaron.Renderer("../../public/views", "[[", "]]")) + + server.Use(contextProvider()) + server.Use(Middleware(test.ac)(fallback, test.evaluator)) + + endpointCalled := false + server.Get("/", func(c *models.ReqContext) { + endpointCalled = true + }) + + request, err := http.NewRequest(http.MethodGet, "/", nil) + assert.NoError(t, err) + recorder := httptest.NewRecorder() + + server.ServeHTTP(recorder, request) + + assert.Equal(t, test.expectFallback, fallbackCalled) + assert.Equal(t, test.expectEndpoint, endpointCalled) + }) + } +} + +func contextProvider() macaron.Handler { + return func(c *macaron.Context) { + reqCtx := &models.ReqContext{ + Context: c, + Logger: log.New(""), + SignedInUser: &models.SignedInUser{}, + IsSignedIn: true, + SkipCache: true, + } + c.Map(reqCtx) + } +} + +var _ accesscontrol.AccessControl = new(fakeAccessControl) + +type fakeAccessControl struct { + isDisabled bool + permissions []*accesscontrol.Permission +} + +func (f fakeAccessControl) Evaluate(ctx context.Context, user *models.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) { + permissions, _ := f.GetUserPermissions(ctx, user) + return evaluator.Evaluate(accesscontrol.GroupScopesByAction(permissions)) +} + +func (f fakeAccessControl) GetUserPermissions(ctx context.Context, user *models.SignedInUser) ([]*accesscontrol.Permission, error) { + return f.permissions, nil +} + +func (f fakeAccessControl) IsDisabled() bool { + return f.isDisabled +} + +func (f fakeAccessControl) DeclareFixedRoles(registration ...accesscontrol.RoleRegistration) error { + return nil +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 6ee746b730b..a4db7af2977 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -34,11 +34,6 @@ type Permission struct { Scope string `json:"scope"` } -type EvaluationResult struct { - HasAccess bool - Meta interface{} -} - func (p RoleDTO) Role() Role { return Role{ Name: p.Name, @@ -53,13 +48,13 @@ const ( ActionUsersRead = "users:read" ActionUsersWrite = "users:write" ActionUsersTeamRead = "users.teams:read" - // We can ignore gosec G101 since this does not contain any credentials + // 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 + // 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 + // We can ignore gosec G101 since this does not contain any credentials. // nolint:gosec ActionUsersPasswordUpdate = "users.password:update" ActionUsersDelete = "users:delete" diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index a3919f5b77d..91a0a584682 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/evaluator" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" ) @@ -55,9 +54,17 @@ func (ac *OSSAccessControlService) getUsageMetrics() interface{} { return 1 } -// Evaluate evaluates access to the given resource -func (ac *OSSAccessControlService) Evaluate(ctx context.Context, user *models.SignedInUser, permission string, scope ...string) (bool, error) { - return evaluator.Evaluate(ctx, ac, user, permission, scope...) +// Evaluate evaluates access to the given resources +func (ac *OSSAccessControlService) Evaluate(ctx context.Context, user *models.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) { + timer := prometheus.NewTimer(metrics.MAccessEvaluationsSummary) + defer timer.ObserveDuration() + metrics.MAccessEvaluationCount.Inc() + + permissions, err := ac.GetUserPermissions(ctx, user) + if err != nil { + return false, err + } + return evaluator.Evaluate(accesscontrol.GroupScopesByAction(permissions)) } // GetUserPermissions returns user permissions based on built-in roles diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go index 91787e8d5dd..84bc997214a 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go @@ -94,8 +94,7 @@ type userTestCase struct { } type endpointTestCase struct { - permission string - scope []string + evaluator accesscontrol.Evaluator } func TestEvaluatingPermissions(t *testing.T) { @@ -108,8 +107,8 @@ func TestEvaluatingPermissions(t *testing.T) { isGrafanaAdmin: false, }, endpoints: []endpointTestCase{ - {permission: accesscontrol.ActionUsersDisable, scope: []string{accesscontrol.ScopeGlobalUsersAll}}, - {permission: accesscontrol.ActionUsersEnable, scope: []string{accesscontrol.ScopeGlobalUsersAll}}, + {evaluator: accesscontrol.EvalPermission(accesscontrol.ActionUsersDisable, accesscontrol.ScopeGlobalUsersAll)}, + {evaluator: accesscontrol.EvalPermission(accesscontrol.ActionUsersEnable, accesscontrol.ScopeGlobalUsersAll)}, }, evalResult: true, }, @@ -121,7 +120,7 @@ func TestEvaluatingPermissions(t *testing.T) { isGrafanaAdmin: false, }, endpoints: []endpointTestCase{ - {permission: accesscontrol.ActionUsersCreate, scope: []string{accesscontrol.ScopeGlobalUsersAll}}, + {evaluator: accesscontrol.EvalPermission(accesscontrol.ActionUsersCreate, accesscontrol.ScopeGlobalUsersAll)}, }, evalResult: false, }, @@ -140,7 +139,7 @@ func TestEvaluatingPermissions(t *testing.T) { } for _, endpoint := range tc.endpoints { - result, err := ac.Evaluate(context.Background(), user, endpoint.permission, endpoint.scope...) + result, err := ac.Evaluate(context.Background(), user, endpoint.evaluator) require.NoError(t, err) assert.Equal(t, tc.evalResult, result) } diff --git a/pkg/services/accesscontrol/scope.go b/pkg/services/accesscontrol/scope.go new file mode 100644 index 00000000000..b25dd9150eb --- /dev/null +++ b/pkg/services/accesscontrol/scope.go @@ -0,0 +1,25 @@ +package accesscontrol + +import ( + "fmt" + "strings" +) + +// Scope builds scope from parts +// e.g. Scope("users", "*") return "users:*" +func Scope(parts ...string) string { + b := strings.Builder{} + for i, c := range parts { + if i != 0 { + b.WriteRune(':') + } + b.WriteString(c) + } + return b.String() +} + +// Parameter returns injectable scope part +// e.g. Scope("users", Parameter(":id")) or "users:" + Parameter(":id") +func Parameter(key string) string { + return fmt.Sprintf(`{{ index . "%s" }}`, key) +}