From a7e721e9873394142e4da9c2239c431e9bdcd015 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 22 Apr 2021 13:19:41 +0300 Subject: [PATCH] Access control: Make Admin/Users UI working with the permissions (#33176) * API: authorize admin/users views * Render admin/users components based on user's permissions * Add LDAP permissions (required by admin/user page) * Extend default admin role by LDAP permissions * Show/hide LDAP debug views * Render LDAP debug page if user has access * Authorize LDAP debug view * fix permissions definitions * Add LDAP page permissions * remove ambiguous permissions check * Hide logout buttons in sessions table * Add org/users permissions * Use org permissions for managing user roles in orgs * Apply permissions to org/users * Apply suggestions from review * Fix tests * remove scopes from the frontend * Tweaks according to review * Handle /invites endpoints --- pkg/api/api.go | 85 ++++++++++--------- pkg/api/dtos/models.go | 2 +- pkg/api/index.go | 15 ++-- pkg/services/accesscontrol/accesscontrol.go | 20 ++--- pkg/services/accesscontrol/models.go | 15 ++++ pkg/services/accesscontrol/roles.go | 30 +++++++ public/app/core/services/context_srv.ts | 6 +- public/app/features/admin/OrgRolePicker.tsx | 1 + public/app/features/admin/UserAdminPage.tsx | 9 +- .../app/features/admin/UserLdapSyncInfo.tsx | 21 +++-- .../app/features/admin/UserListAdminPage.tsx | 11 ++- public/app/features/admin/UserOrgs.tsx | 50 ++++++----- public/app/features/admin/UserPermissions.tsx | 23 +++-- public/app/features/admin/UserProfile.tsx | 72 +++++++++------- public/app/features/admin/UserSessions.tsx | 23 +++-- public/app/features/admin/ldap/LdapPage.tsx | 72 +++++++++------- public/app/features/admin/state/actions.ts | 14 ++- .../features/users/UsersActionBar.test.tsx | 6 ++ public/app/features/users/UsersActionBar.tsx | 5 +- public/app/features/users/UsersTable.test.tsx | 6 ++ public/app/features/users/UsersTable.tsx | 39 +++++---- .../__snapshots__/UsersTable.test.tsx.snap | 6 ++ public/app/features/users/state/actions.ts | 7 +- public/app/types/accessControl.ts | 22 +++-- 24 files changed, 356 insertions(+), 204 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 8ea0304be83..7edd8ca2702 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -58,9 +58,9 @@ 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", reqOrgAdmin, hs.Index) + r.Get("/org/users", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRead, accesscontrol.ScopeOrgCurrentUsersAll), hs.Index) r.Get("/org/users/new", reqOrgAdmin, hs.Index) - r.Get("/org/users/invite", reqOrgAdmin, hs.Index) + r.Get("/org/users/invite", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersAdd, accesscontrol.ScopeOrgCurrentUsersAll), hs.Index) r.Get("/org/teams", reqCanAccessTeams, hs.Index) r.Get("/org/teams/*", reqCanAccessTeams, hs.Index) r.Get("/org/apikeys/", reqOrgAdmin, hs.Index) @@ -68,13 +68,13 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/configuration", reqGrafanaAdmin, hs.Index) r.Get("/admin", reqGrafanaAdmin, hs.Index) r.Get("/admin/settings", reqGrafanaAdmin, hs.Index) - r.Get("/admin/users", reqGrafanaAdmin, hs.Index) - r.Get("/admin/users/create", reqGrafanaAdmin, hs.Index) - r.Get("/admin/users/edit/:id", reqGrafanaAdmin, hs.Index) + r.Get("/admin/users", authorize(reqGrafanaAdmin, accesscontrol.ActionUsersRead, accesscontrol.ScopeUsersAll), 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/ldap", reqGrafanaAdmin, hs.Index) + r.Get("/admin/ldap", authorize(reqGrafanaAdmin, accesscontrol.ActionLDAPStatusRead), hs.Index) r.Get("/styleguide", reqSignedIn, hs.Index) @@ -201,22 +201,23 @@ func (hs *HTTPServer) registerRoutes() { // current org apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { - orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), routing.Wrap(UpdateOrgCurrent)) - orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), routing.Wrap(UpdateOrgAddressCurrent)) - orgRoute.Get("/users", routing.Wrap(hs.GetOrgUsersForCurrentOrg)) - orgRoute.Post("/users", quota("user"), bind(models.AddOrgUserCommand{}), routing.Wrap(AddOrgUserToCurrentOrg)) - orgRoute.Patch("/users/:userId", bind(models.UpdateOrgUserCommand{}), routing.Wrap(UpdateOrgUserForCurrentOrg)) - orgRoute.Delete("/users/:userId", routing.Wrap(RemoveOrgUserForCurrentOrg)) + const orgScope = `org:current/users:{{ index . ":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.ScopeOrgCurrentUsersAll), routing.Wrap(hs.GetOrgUsersForCurrentOrg)) + orgRoute.Post("/users", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersAdd, accesscontrol.ScopeOrgCurrentUsersAll), quota("user"), bind(models.AddOrgUserCommand{}), routing.Wrap(AddOrgUserToCurrentOrg)) + orgRoute.Patch("/users/:userId", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRoleUpdate, orgScope), bind(models.UpdateOrgUserCommand{}), routing.Wrap(UpdateOrgUserForCurrentOrg)) + orgRoute.Delete("/users/:userId", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRemove, orgScope), routing.Wrap(RemoveOrgUserForCurrentOrg)) // invites - orgRoute.Get("/invites", routing.Wrap(GetPendingOrgInvites)) - orgRoute.Post("/invites", quota("user"), bind(dtos.AddInviteForm{}), routing.Wrap(AddOrgInvite)) - orgRoute.Patch("/invites/:code/revoke", routing.Wrap(RevokeInvite)) + orgRoute.Get("/invites", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersAdd, accesscontrol.ScopeOrgCurrentUsersAll), routing.Wrap(GetPendingOrgInvites)) + orgRoute.Post("/invites", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersAdd, accesscontrol.ScopeOrgCurrentUsersAll), quota("user"), bind(dtos.AddInviteForm{}), routing.Wrap(AddOrgInvite)) + orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersAdd, accesscontrol.ScopeOrgCurrentUsersAll), routing.Wrap(RevokeInvite)) // prefs - orgRoute.Get("/preferences", routing.Wrap(GetOrgPreferences)) - orgRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), routing.Wrap(UpdateOrgPreferences)) - }, reqOrgAdmin) + orgRoute.Get("/preferences", reqOrgAdmin, routing.Wrap(GetOrgPreferences)) + orgRoute.Put("/preferences", reqOrgAdmin, bind(dtos.UpdatePrefsCmd{}), routing.Wrap(UpdateOrgPreferences)) + }) // current org without requirement of user to be org admin apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { @@ -231,17 +232,19 @@ func (hs *HTTPServer) registerRoutes() { // orgs (admin routes) apiRoute.Group("/orgs/:orgId", func(orgsRoute routing.RouteRegister) { - orgsRoute.Get("/", routing.Wrap(GetOrgByID)) - orgsRoute.Put("/", bind(dtos.UpdateOrgForm{}), routing.Wrap(UpdateOrg)) - orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), routing.Wrap(UpdateOrgAddress)) - orgsRoute.Delete("/", routing.Wrap(DeleteOrgByID)) - orgsRoute.Get("/users", routing.Wrap(hs.GetOrgUsers)) - orgsRoute.Post("/users", bind(models.AddOrgUserCommand{}), routing.Wrap(AddOrgUser)) - orgsRoute.Patch("/users/:userId", bind(models.UpdateOrgUserCommand{}), routing.Wrap(UpdateOrgUser)) - orgsRoute.Delete("/users/:userId", routing.Wrap(RemoveOrgUser)) - orgsRoute.Get("/quotas", routing.Wrap(GetOrgQuotas)) - orgsRoute.Put("/quotas/:target", bind(models.UpdateOrgQuotaCmd{}), routing.Wrap(UpdateOrgQuota)) - }, reqGrafanaAdmin) + const orgScope = `org:{{ index . ":orgId" }}/users:*` + const orgUsersScope = `org:{{ index . ":orgId" }}/users:{{ index . ":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, orgScope), routing.Wrap(hs.GetOrgUsers)) + orgsRoute.Post("/users", authorize(reqGrafanaAdmin, accesscontrol.ActionOrgUsersAdd, orgScope), bind(models.AddOrgUserCommand{}), routing.Wrap(AddOrgUser)) + orgsRoute.Patch("/users/:userId", authorize(reqGrafanaAdmin, accesscontrol.ActionOrgUsersRoleUpdate, orgUsersScope), bind(models.UpdateOrgUserCommand{}), routing.Wrap(UpdateOrgUser)) + orgsRoute.Delete("/users/:userId", authorize(reqGrafanaAdmin, accesscontrol.ActionOrgUsersRemove, orgUsersScope), routing.Wrap(RemoveOrgUser)) + orgsRoute.Get("/quotas", reqGrafanaAdmin, routing.Wrap(GetOrgQuotas)) + orgsRoute.Put("/quotas/:target", reqGrafanaAdmin, bind(models.UpdateOrgQuotaCmd{}), routing.Wrap(UpdateOrgQuota)) + }) // orgs (admin routes) apiRoute.Group("/orgs/name/:name", func(orgsRoute routing.RouteRegister) { @@ -425,19 +428,19 @@ func (hs *HTTPServer) registerRoutes() { // admin api r.Group("/api/admin", func(adminRoute routing.RouteRegister) { - adminRoute.Get("/settings", routing.Wrap(AdminGetSettings)) - adminRoute.Get("/stats", routing.Wrap(AdminGetStats)) - adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), routing.Wrap(PauseAllAlerts)) + adminRoute.Get("/settings", reqGrafanaAdmin, routing.Wrap(AdminGetSettings)) + adminRoute.Get("/stats", reqGrafanaAdmin, routing.Wrap(AdminGetStats)) + adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, bind(dtos.PauseAllAlertsCommand{}), routing.Wrap(PauseAllAlerts)) - 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)) - adminRoute.Post("/provisioning/notifications/reload", routing.Wrap(hs.AdminProvisioningReloadNotifications)) - adminRoute.Post("/ldap/reload", routing.Wrap(hs.ReloadLDAPCfg)) - adminRoute.Post("/ldap/sync/:id", routing.Wrap(hs.PostSyncUserWithLDAP)) - adminRoute.Get("/ldap/:username", routing.Wrap(hs.GetUserFromLDAP)) - adminRoute.Get("/ldap/status", routing.Wrap(hs.GetLDAPStatus)) - }, reqGrafanaAdmin) + adminRoute.Post("/provisioning/dashboards/reload", reqGrafanaAdmin, routing.Wrap(hs.AdminProvisioningReloadDashboards)) + adminRoute.Post("/provisioning/plugins/reload", reqGrafanaAdmin, routing.Wrap(hs.AdminProvisioningReloadPlugins)) + adminRoute.Post("/provisioning/datasources/reload", reqGrafanaAdmin, routing.Wrap(hs.AdminProvisioningReloadDatasources)) + adminRoute.Post("/provisioning/notifications/reload", reqGrafanaAdmin, routing.Wrap(hs.AdminProvisioningReloadNotifications)) + adminRoute.Post("/ldap/reload", reqGrafanaAdmin, 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)) + }) // Administering users r.Group("/api/admin/users", func(adminUserRoute routing.RouteRegister) { diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 91e27c8edce..eb78a602518 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -44,7 +44,7 @@ type CurrentUser struct { Permissions UserPermissionsMap `json:"permissions,omitempty"` } -type UserPermissionsMap map[string]map[string]string +type UserPermissionsMap map[string]bool type MetricRequest struct { From string `json:"from"` diff --git a/pkg/api/index.go b/pkg/api/index.go index d79914672af..a4956484293 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -126,6 +126,7 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) } func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dtos.NavLink, error) { + hasAccess := ac.HasAccess(hs.AccessControl, c) navTree := []*dtos.NavLink{} if hasEditPerm { @@ -251,6 +252,9 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto Id: "datasources", Url: hs.Cfg.AppSubURL + "/datasources", }) + } + + if hasAccess(ac.ReqOrgAdmin, ac.ActionOrgUsersRead, ac.ScopeOrgCurrentUsersAll) { configNodes = append(configNodes, &dtos.NavLink{ Text: "Users", Id: "users", @@ -361,11 +365,12 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Stats", Id: "server-stats", Url: hs.Cfg.AppSubURL + "/admin/stats", Icon: "graph-bar", }) - if hs.Cfg.LDAPEnabled { - adminNavLinks = append(adminNavLinks, &dtos.NavLink{ - Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book", - }) - } + } + + if hs.Cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.ActionLDAPStatusRead) { + adminNavLinks = append(adminNavLinks, &dtos.NavLink{ + Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book", + }) } return adminNavLinks diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 2ff175c221a..64e07b1b6c4 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -37,20 +37,14 @@ var ReqGrafanaAdmin = func(c *models.ReqContext) bool { return c.IsGrafanaAdmin } -func BuildPermissionsMap(permissions []*Permission) map[string]map[string]string { - permissionsMap := make(map[string]map[string]string) +var ReqOrgAdmin = func(c *models.ReqContext) bool { + return c.OrgRole == models.ROLE_ADMIN +} + +func BuildPermissionsMap(permissions []*Permission) map[string]bool { + permissionsMap := make(map[string]bool) for _, p := range permissions { - if item, ok := permissionsMap[p.Action]; ok { - if _, ok := item[p.Scope]; !ok && p.Scope != "" { - permissionsMap[p.Action][p.Scope] = p.Scope - } - } else { - newItem := make(map[string]string) - if p.Scope != "" { - newItem[p.Scope] = p.Scope - } - permissionsMap[p.Action] = newItem - } + permissionsMap[p.Action] = true } return permissionsMap diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index bec701dea87..486c47c8e19 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -42,6 +42,7 @@ func (p RoleDTO) Role() Role { const ( // Permission actions + // Users actions ActionUsersRead = "users:read" ActionUsersWrite = "users:write" ActionUsersTeamRead = "users.teams:read" @@ -63,9 +64,23 @@ const ( ActionUsersQuotasList = "users.quotas:list" ActionUsersQuotasUpdate = "users.quotas:update" + // Org actions + ActionOrgUsersRead = "org.users:read" + ActionOrgUsersAdd = "org.users:add" + ActionOrgUsersRemove = "org.users:remove" + ActionOrgUsersRoleUpdate = "org.users.role:update" + + // LDAP actions + ActionLDAPUsersRead = "ldap.user:read" + ActionLDAPUsersSync = "ldap.user:sync" + ActionLDAPStatusRead = "ldap.status:read" + // Global Scopes ScopeUsersAll = "users:*" ScopeUsersSelf = "users:self" + + ScopeOrgAllUsersAll = "org:*/users:*" + ScopeOrgCurrentUsersAll = "org:current/users:*" ) const RoleGrafanaAdmin = "Grafana Admin" diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go index 5ed89ea932f..3101ff7fca4 100644 --- a/pkg/services/accesscontrol/roles.go +++ b/pkg/services/accesscontrol/roles.go @@ -29,6 +29,16 @@ var PredefinedRoles = map[string]RoleDTO{ Action: ActionUsersQuotasList, Scope: ScopeUsersAll, }, + { + Action: ActionOrgUsersRead, + Scope: ScopeOrgAllUsersAll, + }, + { + Action: ActionLDAPUsersRead, + }, + { + Action: ActionLDAPStatusRead, + }, }, }, usersAdminEdit: { @@ -94,6 +104,26 @@ var PredefinedRoles = map[string]RoleDTO{ Action: ActionUsersQuotasUpdate, Scope: ScopeUsersAll, }, + { + // Inherited from grafana:roles:users:admin:read + Action: ActionOrgUsersRead, + Scope: ScopeOrgAllUsersAll, + }, + { + Action: ActionOrgUsersAdd, + Scope: ScopeOrgAllUsersAll, + }, + { + Action: ActionOrgUsersRemove, + Scope: ScopeOrgAllUsersAll, + }, + { + Action: ActionOrgUsersRoleUpdate, + Scope: ScopeOrgAllUsersAll, + }, + { + Action: ActionLDAPUsersSync, + }, }, }, } diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index d14c053fba6..e1062292de2 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -2,7 +2,7 @@ import config from '../../core/config'; import { extend } from 'lodash'; import coreModule from 'app/core/core_module'; import { rangeUtil } from '@grafana/data'; -import { AccessControlAction, AccessControlScope, UserPermission } from 'app/types'; +import { AccessControlAction, UserPermission } from 'app/types'; export class User { id: number; @@ -77,13 +77,13 @@ export class ContextSrv { } // Checks whether user has required permission - hasPermission(action: AccessControlAction, scope?: AccessControlScope): boolean { + hasPermission(action: AccessControlAction): boolean { // Fallback if access control disabled if (!config.featureToggles['accesscontrol']) { return true; } - return !!(this.user.permissions?.[action] && (scope ? this.user.permissions[action][scope] : true)); + return !!this.user.permissions?.[action]; } isGrafanaVisible() { diff --git a/public/app/features/admin/OrgRolePicker.tsx b/public/app/features/admin/OrgRolePicker.tsx index 5e24be647ff..7052f3b2372 100644 --- a/public/app/features/admin/OrgRolePicker.tsx +++ b/public/app/features/admin/OrgRolePicker.tsx @@ -4,6 +4,7 @@ import { Select } from '@grafana/ui'; interface Props { value: OrgRole; + disabled?: boolean; onChange: (role: OrgRole) => void; } diff --git a/public/app/features/admin/UserAdminPage.tsx b/public/app/features/admin/UserAdminPage.tsx index 9efa518c942..f4ee3324c1f 100644 --- a/public/app/features/admin/UserAdminPage.tsx +++ b/public/app/features/admin/UserAdminPage.tsx @@ -9,7 +9,7 @@ import { UserProfile } from './UserProfile'; import { UserPermissions } from './UserPermissions'; import { UserSessions } from './UserSessions'; import { UserLdapSyncInfo } from './UserLdapSyncInfo'; -import { StoreState, UserDTO, UserOrg, UserSession, SyncInfo, UserAdminError } from 'app/types'; +import { StoreState, UserDTO, UserOrg, UserSession, SyncInfo, UserAdminError, AccessControlAction } from 'app/types'; import { loadAdminUserPage, revokeSession, @@ -27,6 +27,7 @@ import { } from './state/actions'; import { UserOrgs } from './UserOrgs'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { contextSrv } from 'app/core/core'; interface Props extends GrafanaRouteComponentProps<{ id: string }> { navModel: NavModel; @@ -126,6 +127,8 @@ export class UserAdminPage extends PureComponent { const { navModel, user, orgs, sessions, ldapSyncInfo, isLoading } = this.props; // const { isLoading } = this.state; const isLDAPUser = user && user.isExternal && user.authLabels && user.authLabels.includes('LDAP'); + const canReadSessions = contextSrv.hasPermission(AccessControlAction.UsersAuthTokenList); + const canReadLDAPStatus = contextSrv.hasPermission(AccessControlAction.LDAPStatusRead); return ( @@ -140,7 +143,7 @@ export class UserAdminPage extends PureComponent { onUserEnable={this.onUserEnable} onPasswordChange={this.onPasswordChange} /> - {isLDAPUser && config.licenseInfo.hasLicense && ldapSyncInfo && ( + {isLDAPUser && config.licenseInfo.hasLicense && ldapSyncInfo && canReadLDAPStatus && ( )} @@ -156,7 +159,7 @@ export class UserAdminPage extends PureComponent { /> )} - {sessions && ( + {sessions && canReadSessions && ( { const nextSyncSuccessful = ldapSyncInfo && ldapSyncInfo.nextSync; const nextSyncTime = nextSyncSuccessful ? dateTimeFormat(ldapSyncInfo.nextSync, { format }) : ''; const debugLDAPMappingURL = `${debugLDAPMappingBaseURL}?user=${user && user.login}`; + const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead); + const canSyncLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersSync); return ( <> @@ -56,12 +59,16 @@ export class UserLdapSyncInfo extends PureComponent {
- - - Debug LDAP Mapping - + {canSyncLDAPUser && ( + + )} + {canReadLDAPUser && ( + + Debug LDAP Mapping + + )}
diff --git a/public/app/features/admin/UserListAdminPage.tsx b/public/app/features/admin/UserListAdminPage.tsx index 811f062288e..8024fbe1d84 100644 --- a/public/app/features/admin/UserListAdminPage.tsx +++ b/public/app/features/admin/UserListAdminPage.tsx @@ -4,11 +4,12 @@ import { hot } from 'react-hot-loader'; import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; import { NavModel } from '@grafana/data'; import { Pagination, Tooltip, HorizontalGroup, stylesFactory, LinkButton, Input, Icon } from '@grafana/ui'; -import { StoreState, UserDTO } from '../../types'; +import { AccessControlAction, StoreState, UserDTO } from '../../types'; import Page from 'app/core/components/Page/Page'; import { getNavModel } from '../../core/selectors/navModel'; import { fetchUsers, changeQuery, changePage } from './state/actions'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; +import { contextSrv } from 'app/core/core'; interface OwnProps {} @@ -54,9 +55,11 @@ const UserListAdminPageUnConnected: React.FC = (props) => { onChange={(event) => changeQuery(event.currentTarget.value)} prefix={} /> - - New user - + {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( + + New user + + )} diff --git a/public/app/features/admin/UserOrgs.tsx b/public/app/features/admin/UserOrgs.tsx index 80553fe16c7..85232d0ee54 100644 --- a/public/app/features/admin/UserOrgs.tsx +++ b/public/app/features/admin/UserOrgs.tsx @@ -12,9 +12,10 @@ import { withTheme, } from '@grafana/ui'; import { GrafanaTheme } from '@grafana/data'; -import { Organization, OrgRole, UserOrg } from 'app/types'; +import { AccessControlAction, Organization, OrgRole, UserOrg } from 'app/types'; import { OrgPicker, OrgSelectItem } from 'app/core/components/Select/OrgPicker'; import { OrgRolePicker } from './OrgRolePicker'; +import { contextSrv } from 'app/core/core'; interface Props { orgs: UserOrg[]; @@ -43,6 +44,7 @@ export class UserOrgs extends PureComponent { const addToOrgContainerClass = css` margin-top: 0.8rem; `; + const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd); return ( <> @@ -63,9 +65,11 @@ export class UserOrgs extends PureComponent {
- + {canAddToOrg && ( + + )}
@@ -131,6 +135,8 @@ class UnThemedOrgRow extends PureComponent { const { currentRole, isChangingRole } = this.state; const styles = getOrgRowStyles(theme); const labelClass = cx('width-16', styles.label); + const canChangeRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate); + const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove); return ( @@ -144,26 +150,30 @@ class UnThemedOrgRow extends PureComponent { )}
- - Change role - + {canChangeRole && ( + + Change role + + )}
- - Remove from organization - + {canRemoveFromOrg && ( + + Remove from organization + + )}
diff --git a/public/app/features/admin/UserPermissions.tsx b/public/app/features/admin/UserPermissions.tsx index 4813c989677..235c52870b0 100644 --- a/public/app/features/admin/UserPermissions.tsx +++ b/public/app/features/admin/UserPermissions.tsx @@ -1,6 +1,8 @@ import React, { PureComponent } from 'react'; import { ConfirmButton, RadioButtonGroup, Icon } from '@grafana/ui'; import { cx } from '@emotion/css'; +import { AccessControlAction } from 'app/types'; +import { contextSrv } from 'app/core/core'; interface Props { isGrafanaAdmin: boolean; @@ -49,6 +51,7 @@ export class UserPermissions extends PureComponent { const { isGrafanaAdmin } = this.props; const { isEditing, currentAdminOption } = this.state; const changeButtonContainerClass = cx('pull-right'); + const canChangePermissions = contextSrv.hasPermission(AccessControlAction.UsersPermissionsUpdate); return ( <> @@ -80,15 +83,17 @@ export class UserPermissions extends PureComponent { )}
- - Change - + {canChangePermissions && ( + + Change + + )}
diff --git a/public/app/features/admin/UserProfile.tsx b/public/app/features/admin/UserProfile.tsx index 58e10cd8065..50be1842748 100644 --- a/public/app/features/admin/UserProfile.tsx +++ b/public/app/features/admin/UserProfile.tsx @@ -1,9 +1,10 @@ import React, { FC, PureComponent } from 'react'; -import { UserDTO } from 'app/types'; +import { AccessControlAction, UserDTO } from 'app/types'; import { css, cx } from '@emotion/css'; import { config } from 'app/core/config'; import { GrafanaTheme } from '@grafana/data'; import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, stylesFactory } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; interface Props { user: UserDTO; @@ -86,6 +87,12 @@ export class UserProfile extends PureComponent { const lockMessage = authSource ? `Synced via ${authSource}` : ''; const styles = getStyles(config.theme); + const editLocked = user.isExternal || !contextSrv.hasPermission(AccessControlAction.UsersWrite); + const passwordChangeLocked = user.isExternal || !contextSrv.hasPermission(AccessControlAction.UsersPasswordUpdate); + const canDelete = contextSrv.hasPermission(AccessControlAction.UsersDelete); + const canDisable = contextSrv.hasPermission(AccessControlAction.UsersDisable); + const canEnable = contextSrv.hasPermission(AccessControlAction.UsersEnable); + return ( <>

User information

@@ -96,21 +103,21 @@ export class UserProfile extends PureComponent { @@ -118,7 +125,7 @@ export class UserProfile extends PureComponent { label="Password" value="********" inputType="password" - locked={user.isExternal} + locked={passwordChangeLocked} lockMessage={lockMessage} onChange={this.onPasswordChange} /> @@ -126,34 +133,41 @@ export class UserProfile extends PureComponent {
- - - {user.isDisabled ? ( + {canDelete && ( + <> + + + + )} + {user.isDisabled && canEnable && ( - ) : ( - )} - + {!user.isDisabled && canDisable && ( + <> + + + + )}
diff --git a/public/app/features/admin/UserSessions.tsx b/public/app/features/admin/UserSessions.tsx index cd417b22682..1ff8431eea4 100644 --- a/public/app/features/admin/UserSessions.tsx +++ b/public/app/features/admin/UserSessions.tsx @@ -1,7 +1,8 @@ import React, { PureComponent } from 'react'; import { css } from '@emotion/css'; import { ConfirmButton, ConfirmModal, Button } from '@grafana/ui'; -import { UserSession } from 'app/types'; +import { AccessControlAction, UserSession } from 'app/types'; +import { contextSrv } from 'app/core/core'; interface Props { sessions: UserSession[]; @@ -42,6 +43,8 @@ export class UserSessions extends PureComponent { margin-top: 0.8rem; `; + const canLogout = contextSrv.hasPermission(AccessControlAction.UsersLogout); + return ( <>

Sessions

@@ -66,13 +69,15 @@ export class UserSessions extends PureComponent { {`${session.browser} on ${session.os} ${session.osVersion}`}
- - Force logout - + {canLogout && ( + + Force logout + + )}
@@ -81,7 +86,7 @@ export class UserSessions extends PureComponent {
- {sessions.length > 0 && ( + {canLogout && sessions.length > 0 && ( diff --git a/public/app/features/admin/ldap/LdapPage.tsx b/public/app/features/admin/ldap/LdapPage.tsx index 74ba8031550..776a8b18fce 100644 --- a/public/app/features/admin/ldap/LdapPage.tsx +++ b/public/app/features/admin/ldap/LdapPage.tsx @@ -10,7 +10,15 @@ import Page from 'app/core/components/Page/Page'; import { LdapConnectionStatus } from './LdapConnectionStatus'; import { LdapSyncInfo } from './LdapSyncInfo'; import { LdapUserInfo } from './LdapUserInfo'; -import { AppNotificationSeverity, LdapError, LdapUser, StoreState, SyncInfo, LdapConnectionInfo } from 'app/types'; +import { + AppNotificationSeverity, + LdapError, + LdapUser, + StoreState, + SyncInfo, + LdapConnectionInfo, + AccessControlAction, +} from 'app/types'; import { loadLdapState, loadLdapSyncStatus, @@ -19,6 +27,7 @@ import { clearUserMappingInfo, } from '../state/actions'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { contextSrv } from 'app/core/core'; interface Props extends GrafanaRouteComponentProps<{}, { username: string }> { navModel: NavModel; @@ -81,6 +90,7 @@ export class LdapPage extends PureComponent { render() { const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel, queryParams } = this.props; const { isLoading } = this.state; + const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead); return ( @@ -98,35 +108,39 @@ export class LdapPage extends PureComponent { {config.licenseInfo.hasLicense && ldapSyncInfo && } -

Test user mapping

-
-
- - - -
- {userError && userError.title && ( -
- - {userError.body} - -
+ {canReadLDAPUser && ( + <> +

Test user mapping

+
+
+ + + +
+ {userError && userError.title && ( +
+ + {userError.body} + +
+ )} + {ldapUser && } + )} - {ldapUser && }
diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts index 2eeaeb781cd..a812192fc5f 100644 --- a/public/app/features/admin/state/actions.ts +++ b/public/app/features/admin/state/actions.ts @@ -1,7 +1,7 @@ import config from 'app/core/config'; import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; import { getBackendSrv, locationService } from '@grafana/runtime'; -import { ThunkResult, LdapUser, UserSession, UserDTO } from 'app/types'; +import { ThunkResult, LdapUser, UserSession, UserDTO, AccessControlAction } from 'app/types'; import { userAdminPageLoadedAction, @@ -21,6 +21,7 @@ import { pageChanged, } from './reducers'; import { debounce } from 'lodash'; +import { contextSrv } from 'app/core/core'; // UserAdminPage @@ -134,6 +135,10 @@ export function deleteOrgUser(userId: number, orgId: number): ThunkResult export function loadUserSessions(userId: number): ThunkResult { return async (dispatch) => { + if (!contextSrv.hasPermission(AccessControlAction.UsersAuthTokenList)) { + return; + } + const tokens = await getBackendSrv().get(`/api/admin/users/${userId}/auth-tokens`); tokens.reverse(); const sessions = tokens.map((session: UserSession) => { @@ -174,7 +179,8 @@ export function revokeAllSessions(userId: number): ThunkResult { export function loadLdapSyncStatus(): ThunkResult { return async (dispatch) => { // Available only in enterprise - if (config.licenseInfo.hasLicense) { + const canReadLDAPStatus = contextSrv.hasPermission(AccessControlAction.LDAPStatusRead); + if (config.licenseInfo.hasLicense && canReadLDAPStatus) { const syncStatus = await getBackendSrv().get(`/api/admin/ldap-sync-status`); dispatch(ldapSyncStatusLoadedAction(syncStatus)); } @@ -192,6 +198,10 @@ export function syncLdapUser(userId: number): ThunkResult { export function loadLdapState(): ThunkResult { return async (dispatch) => { + if (!contextSrv.hasPermission(AccessControlAction.LDAPStatusRead)) { + return; + } + try { const connectionInfo = await getBackendSrv().get(`/api/admin/ldap/status`); dispatch(ldapConnectionInfoLoadedAction(connectionInfo)); diff --git a/public/app/features/users/UsersActionBar.test.tsx b/public/app/features/users/UsersActionBar.test.tsx index 1364bb1774b..d22c12b3a57 100644 --- a/public/app/features/users/UsersActionBar.test.tsx +++ b/public/app/features/users/UsersActionBar.test.tsx @@ -4,6 +4,12 @@ import { Props, UsersActionBar } from './UsersActionBar'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { setUsersSearchQuery } from './state/reducers'; +jest.mock('app/core/core', () => ({ + contextSrv: { + hasPermission: () => true, + }, +})); + const setup = (propOverrides?: object) => { const props: Props = { searchQuery: '', diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx index 890e455af3d..e5f1fc52420 100644 --- a/public/app/features/users/UsersActionBar.tsx +++ b/public/app/features/users/UsersActionBar.tsx @@ -4,6 +4,8 @@ import { setUsersSearchQuery } from './state/reducers'; import { getInviteesCount, getUsersSearchQuery } from './state/selectors'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { RadioButtonGroup, LinkButton } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types'; export interface Props { searchQuery: string; @@ -32,6 +34,7 @@ export class UsersActionBar extends PureComponent { { label: 'Users', value: 'users' }, { label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' }, ]; + const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd); return (
@@ -47,7 +50,7 @@ export class UsersActionBar extends PureComponent {
)}
- {canInvite && Invite} + {canInvite && canAddToOrg && Invite} {externalUserMngLinkUrl && ( {externalUserMngLinkName} diff --git a/public/app/features/users/UsersTable.test.tsx b/public/app/features/users/UsersTable.test.tsx index 9a364eb9b59..e5127b26042 100644 --- a/public/app/features/users/UsersTable.test.tsx +++ b/public/app/features/users/UsersTable.test.tsx @@ -5,6 +5,12 @@ import { OrgUser } from 'app/types'; import { getMockUsers } from './__mocks__/userMocks'; import { ConfirmModal } from '@grafana/ui'; +jest.mock('app/core/core', () => ({ + contextSrv: { + hasPermission: () => true, + }, +})); + const setup = (propOverrides?: object) => { const props: Props = { users: [] as OrgUser[], diff --git a/public/app/features/users/UsersTable.tsx b/public/app/features/users/UsersTable.tsx index 4644d3c29ca..85e3cd18c1f 100644 --- a/public/app/features/users/UsersTable.tsx +++ b/public/app/features/users/UsersTable.tsx @@ -1,8 +1,9 @@ import React, { FC, useState } from 'react'; -import { OrgUser } from 'app/types'; +import { AccessControlAction, OrgUser } from 'app/types'; import { OrgRolePicker } from '../admin/OrgRolePicker'; import { Button, ConfirmModal } from '@grafana/ui'; import { OrgRole } from '@grafana/data'; +import { contextSrv } from 'app/core/core'; export interface Props { users: OrgUser[]; @@ -12,6 +13,8 @@ export interface Props { const UsersTable: FC = (props) => { const { users, onRoleChange, onRemoveUser } = props; + const canUpdateRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate); + const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove); const [showRemoveModal, setShowRemoveModal] = useState(false); return ( @@ -53,22 +56,28 @@ const UsersTable: FC = (props) => { {user.lastSeenAtAge} - onRoleChange(newRole, user)} /> - - - -