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
This commit is contained in:
Alexander Zobnin 2021-04-22 13:19:41 +03:00 committed by GitHub
parent 66020b419c
commit a7e721e987
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 356 additions and 204 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { Select } from '@grafana/ui';
interface Props {
value: OrgRole;
disabled?: boolean;
onChange: (role: OrgRole) => void;
}

View File

@ -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<Props, State> {
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 (
<Page navModel={navModel}>
@ -140,7 +143,7 @@ export class UserAdminPage extends PureComponent<Props, State> {
onUserEnable={this.onUserEnable}
onPasswordChange={this.onPasswordChange}
/>
{isLDAPUser && config.licenseInfo.hasLicense && ldapSyncInfo && (
{isLDAPUser && config.licenseInfo.hasLicense && ldapSyncInfo && canReadLDAPStatus && (
<UserLdapSyncInfo ldapSyncInfo={ldapSyncInfo} user={user} onUserSync={this.onUserSync} />
)}
<UserPermissions isGrafanaAdmin={user.isGrafanaAdmin} onGrafanaAdminChange={this.onGrafanaAdminChange} />
@ -156,7 +159,7 @@ export class UserAdminPage extends PureComponent<Props, State> {
/>
)}
{sessions && (
{sessions && canReadSessions && (
<UserSessions
sessions={sessions}
onSessionRevoke={this.onSessionRevoke}

View File

@ -1,7 +1,8 @@
import React, { PureComponent } from 'react';
import { dateTimeFormat } from '@grafana/data';
import { SyncInfo, UserDTO } from 'app/types';
import { AccessControlAction, SyncInfo, UserDTO } from 'app/types';
import { Button, LinkButton } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
interface Props {
ldapSyncInfo: SyncInfo;
@ -24,6 +25,8 @@ export class UserLdapSyncInfo extends PureComponent<Props, State> {
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<Props, State> {
</table>
</div>
<div className="gf-form-button-row">
<Button variant="secondary" onClick={this.onUserSync}>
Sync user
</Button>
<LinkButton variant="secondary" href={debugLDAPMappingURL}>
Debug LDAP Mapping
</LinkButton>
{canSyncLDAPUser && (
<Button variant="secondary" onClick={this.onUserSync}>
Sync user
</Button>
)}
{canReadLDAPUser && (
<LinkButton variant="secondary" href={debugLDAPMappingURL}>
Debug LDAP Mapping
</LinkButton>
)}
</div>
</div>
</>

View File

@ -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> = (props) => {
onChange={(event) => changeQuery(event.currentTarget.value)}
prefix={<Icon name="search" />}
/>
<LinkButton href="admin/users/create" variant="primary">
New user
</LinkButton>
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
<LinkButton href="admin/users/create" variant="primary">
New user
</LinkButton>
)}
</HorizontalGroup>
</div>

View File

@ -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<Props, State> {
const addToOrgContainerClass = css`
margin-top: 0.8rem;
`;
const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd);
return (
<>
@ -63,9 +65,11 @@ export class UserOrgs extends PureComponent<Props, State> {
</table>
</div>
<div className={addToOrgContainerClass}>
<Button variant="secondary" onClick={this.showOrgAddModal(true)}>
Add user to organization
</Button>
{canAddToOrg && (
<Button variant="secondary" onClick={this.showOrgAddModal(true)}>
Add user to organization
</Button>
)}
</div>
<AddToOrgModal isOpen={showAddOrgModal} onOrgAdd={onOrgAdd} onDismiss={this.showOrgAddModal(false)} />
</div>
@ -131,6 +135,8 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps, OrgRowState> {
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 (
<tr>
@ -144,26 +150,30 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps, OrgRowState> {
)}
<td colSpan={1}>
<div className="pull-right">
<ConfirmButton
confirmText="Save"
onClick={this.onChangeRoleClick}
onCancel={this.onCancelClick}
onConfirm={this.onOrgRoleSave}
>
Change role
</ConfirmButton>
{canChangeRole && (
<ConfirmButton
confirmText="Save"
onClick={this.onChangeRoleClick}
onCancel={this.onCancelClick}
onConfirm={this.onOrgRoleSave}
>
Change role
</ConfirmButton>
)}
</div>
</td>
<td colSpan={1}>
<div className="pull-right">
<ConfirmButton
confirmText="Confirm removal"
confirmVariant="destructive"
onCancel={this.onCancelClick}
onConfirm={this.onOrgRemove}
>
Remove from organization
</ConfirmButton>
{canRemoveFromOrg && (
<ConfirmButton
confirmText="Confirm removal"
confirmVariant="destructive"
onCancel={this.onCancelClick}
onConfirm={this.onOrgRemove}
>
Remove from organization
</ConfirmButton>
)}
</div>
</td>
</tr>

View File

@ -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<Props, State> {
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<Props, State> {
)}
<td>
<div className={changeButtonContainerClass}>
<ConfirmButton
className="pull-right"
onClick={this.onChangeClick}
onConfirm={this.onGrafanaAdminChange}
onCancel={this.onCancelClick}
confirmText="Change"
>
Change
</ConfirmButton>
{canChangePermissions && (
<ConfirmButton
className="pull-right"
onClick={this.onChangeClick}
onConfirm={this.onGrafanaAdminChange}
onCancel={this.onCancelClick}
confirmText="Change"
>
Change
</ConfirmButton>
)}
</div>
</td>
</tr>

View File

@ -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<Props, State> {
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 (
<>
<h3 className="page-heading">User information</h3>
@ -96,21 +103,21 @@ export class UserProfile extends PureComponent<Props, State> {
<UserProfileRow
label="Name"
value={user.name}
locked={user.isExternal}
locked={editLocked}
lockMessage={lockMessage}
onChange={this.onUserNameChange}
/>
<UserProfileRow
label="Email"
value={user.email}
locked={user.isExternal}
locked={editLocked}
lockMessage={lockMessage}
onChange={this.onUserEmailChange}
/>
<UserProfileRow
label="Username"
value={user.login}
locked={user.isExternal}
locked={editLocked}
lockMessage={lockMessage}
onChange={this.onUserLoginChange}
/>
@ -118,7 +125,7 @@ export class UserProfile extends PureComponent<Props, State> {
label="Password"
value="********"
inputType="password"
locked={user.isExternal}
locked={passwordChangeLocked}
lockMessage={lockMessage}
onChange={this.onPasswordChange}
/>
@ -126,34 +133,41 @@ export class UserProfile extends PureComponent<Props, State> {
</table>
</div>
<div className={styles.buttonRow}>
<Button variant="destructive" onClick={this.showDeleteUserModal(true)}>
Delete user
</Button>
<ConfirmModal
isOpen={showDeleteModal}
title="Delete user"
body="Are you sure you want to delete this user?"
confirmText="Delete user"
onConfirm={this.onUserDelete}
onDismiss={this.showDeleteUserModal(false)}
/>
{user.isDisabled ? (
{canDelete && (
<>
<Button variant="destructive" onClick={this.showDeleteUserModal(true)}>
Delete user
</Button>
<ConfirmModal
isOpen={showDeleteModal}
title="Delete user"
body="Are you sure you want to delete this user?"
confirmText="Delete user"
onConfirm={this.onUserDelete}
onDismiss={this.showDeleteUserModal(false)}
/>
</>
)}
{user.isDisabled && canEnable && (
<Button variant="secondary" onClick={this.onUserEnable}>
Enable user
</Button>
) : (
<Button variant="secondary" onClick={this.showDisableUserModal(true)}>
Disable user
</Button>
)}
<ConfirmModal
isOpen={showDisableModal}
title="Disable user"
body="Are you sure you want to disable this user?"
confirmText="Disable user"
onConfirm={this.onUserDisable}
onDismiss={this.showDisableUserModal(false)}
/>
{!user.isDisabled && canDisable && (
<>
<Button variant="secondary" onClick={this.showDisableUserModal(true)}>
Disable user
</Button>
<ConfirmModal
isOpen={showDisableModal}
title="Disable user"
body="Are you sure you want to disable this user?"
confirmText="Disable user"
onConfirm={this.onUserDisable}
onDismiss={this.showDisableUserModal(false)}
/>
</>
)}
</div>
</div>
</>

View File

@ -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<Props, State> {
margin-top: 0.8rem;
`;
const canLogout = contextSrv.hasPermission(AccessControlAction.UsersLogout);
return (
<>
<h3 className="page-heading">Sessions</h3>
@ -66,13 +69,15 @@ export class UserSessions extends PureComponent<Props, State> {
<td>{`${session.browser} on ${session.os} ${session.osVersion}`}</td>
<td>
<div className="pull-right">
<ConfirmButton
confirmText="Confirm logout"
confirmVariant="destructive"
onConfirm={this.onSessionRevoke(session.id)}
>
Force logout
</ConfirmButton>
{canLogout && (
<ConfirmButton
confirmText="Confirm logout"
confirmVariant="destructive"
onConfirm={this.onSessionRevoke(session.id)}
>
Force logout
</ConfirmButton>
)}
</div>
</td>
</tr>
@ -81,7 +86,7 @@ export class UserSessions extends PureComponent<Props, State> {
</table>
</div>
<div className={logoutFromAllDevicesClass}>
{sessions.length > 0 && (
{canLogout && sessions.length > 0 && (
<Button variant="secondary" onClick={this.showLogoutConfirmationModal(true)}>
Force logout from all devices
</Button>

View File

@ -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<Props, State> {
render() {
const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel, queryParams } = this.props;
const { isLoading } = this.state;
const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead);
return (
<Page navModel={navModel}>
@ -98,35 +108,39 @@ export class LdapPage extends PureComponent<Props, State> {
{config.licenseInfo.hasLicense && ldapSyncInfo && <LdapSyncInfo ldapSyncInfo={ldapSyncInfo} />}
<h3 className="page-heading">Test user mapping</h3>
<div className="gf-form-group">
<form onSubmit={this.search} className="gf-form-inline">
<FormField
label="Username"
labelWidth={8}
inputWidth={30}
type="text"
id="username"
name="username"
defaultValue={queryParams.username}
/>
<button type="submit" className="btn btn-primary">
Run
</button>
</form>
</div>
{userError && userError.title && (
<div className="gf-form-group">
<Alert
title={userError.title}
severity={AppNotificationSeverity.Error}
onRemove={this.onClearUserError}
>
{userError.body}
</Alert>
</div>
{canReadLDAPUser && (
<>
<h3 className="page-heading">Test user mapping</h3>
<div className="gf-form-group">
<form onSubmit={this.search} className="gf-form-inline">
<FormField
label="Username"
labelWidth={8}
inputWidth={30}
type="text"
id="username"
name="username"
defaultValue={queryParams.username}
/>
<button type="submit" className="btn btn-primary">
Run
</button>
</form>
</div>
{userError && userError.title && (
<div className="gf-form-group">
<Alert
title={userError.title}
severity={AppNotificationSeverity.Error}
onRemove={this.onClearUserError}
>
{userError.body}
</Alert>
</div>
)}
{ldapUser && <LdapUserInfo ldapUser={ldapUser} showAttributeMapping={true} />}
</>
)}
{ldapUser && <LdapUserInfo ldapUser={ldapUser} showAttributeMapping={true} />}
</>
</Page.Contents>
</Page>

View File

@ -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<void>
export function loadUserSessions(userId: number): ThunkResult<void> {
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<void> {
export function loadLdapSyncStatus(): ThunkResult<void> {
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<void> {
export function loadLdapState(): ThunkResult<void> {
return async (dispatch) => {
if (!contextSrv.hasPermission(AccessControlAction.LDAPStatusRead)) {
return;
}
try {
const connectionInfo = await getBackendSrv().get(`/api/admin/ldap/status`);
dispatch(ldapConnectionInfoLoadedAction(connectionInfo));

View File

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

View File

@ -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<Props> {
{ label: 'Users', value: 'users' },
{ label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' },
];
const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd);
return (
<div className="page-action-bar">
@ -47,7 +50,7 @@ export class UsersActionBar extends PureComponent<Props> {
</div>
)}
<div className="page-action-bar__spacer" />
{canInvite && <LinkButton href="org/users/invite">Invite</LinkButton>}
{canInvite && canAddToOrg && <LinkButton href="org/users/invite">Invite</LinkButton>}
{externalUserMngLinkUrl && (
<LinkButton href={externalUserMngLinkUrl} target="_blank" rel="noopener">
{externalUserMngLinkName}

View File

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

View File

@ -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> = (props) => {
const { users, onRoleChange, onRemoveUser } = props;
const canUpdateRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate);
const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove);
const [showRemoveModal, setShowRemoveModal] = useState<string | boolean>(false);
return (
@ -53,22 +56,28 @@ const UsersTable: FC<Props> = (props) => {
<td className="width-1">{user.lastSeenAtAge}</td>
<td className="width-8">
<OrgRolePicker value={user.role} onChange={(newRole) => onRoleChange(newRole, user)} />
</td>
<td>
<Button size="sm" variant="destructive" onClick={() => setShowRemoveModal(user.login)} icon="times" />
<ConfirmModal
body={`Are you sure you want to delete user ${user.login}?`}
confirmText="Delete"
title="Delete"
onDismiss={() => setShowRemoveModal(false)}
isOpen={user.login === showRemoveModal}
onConfirm={() => {
onRemoveUser(user);
}}
<OrgRolePicker
value={user.role}
disabled={!canUpdateRole}
onChange={(newRole) => onRoleChange(newRole, user)}
/>
</td>
{canRemoveFromOrg && (
<td>
<Button size="sm" variant="destructive" onClick={() => setShowRemoveModal(user.login)} icon="times" />
<ConfirmModal
body={`Are you sure you want to delete user ${user.login}?`}
confirmText="Delete"
title="Delete"
onDismiss={() => setShowRemoveModal(false)}
isOpen={user.login === showRemoveModal}
onConfirm={() => {
onRemoveUser(user);
}}
/>
</td>
)}
</tr>
);
})}

View File

@ -115,6 +115,7 @@ exports[`Render should render users table 1`] = `
className="width-8"
>
<OrgRolePicker
disabled={false}
onChange={[Function]}
value="Admin"
/>
@ -184,6 +185,7 @@ exports[`Render should render users table 1`] = `
className="width-8"
>
<OrgRolePicker
disabled={false}
onChange={[Function]}
value="Admin"
/>
@ -253,6 +255,7 @@ exports[`Render should render users table 1`] = `
className="width-8"
>
<OrgRolePicker
disabled={false}
onChange={[Function]}
value="Admin"
/>
@ -322,6 +325,7 @@ exports[`Render should render users table 1`] = `
className="width-8"
>
<OrgRolePicker
disabled={false}
onChange={[Function]}
value="Admin"
/>
@ -391,6 +395,7 @@ exports[`Render should render users table 1`] = `
className="width-8"
>
<OrgRolePicker
disabled={false}
onChange={[Function]}
value="Admin"
/>
@ -460,6 +465,7 @@ exports[`Render should render users table 1`] = `
className="width-8"
>
<OrgRolePicker
disabled={false}
onChange={[Function]}
value="Admin"
/>

View File

@ -1,7 +1,8 @@
import { ThunkResult } from '../../../types';
import { AccessControlAction, ThunkResult } from '../../../types';
import { getBackendSrv } from '@grafana/runtime';
import { OrgUser } from 'app/types';
import { inviteesLoaded, usersLoaded } from './reducers';
import { contextSrv } from 'app/core/core';
export function loadUsers(): ThunkResult<void> {
return async (dispatch) => {
@ -12,6 +13,10 @@ export function loadUsers(): ThunkResult<void> {
export function loadInvitees(): ThunkResult<void> {
return async (dispatch) => {
if (!contextSrv.hasPermission(AccessControlAction.OrgUsersAdd)) {
return;
}
const invitees = await getBackendSrv().get('/api/org/invites');
dispatch(inviteesLoaded(invitees));
};

View File

@ -8,11 +8,6 @@ export type UserPermission = {
[key: string]: { [key: string]: string };
};
export interface AccessControlPermission {
action: AccessControlAction;
scope?: AccessControlScope;
}
// Permission actions
export enum AccessControlAction {
UsersRead = 'users:read',
@ -20,19 +15,22 @@ export enum AccessControlAction {
UsersTeamRead = 'users.teams:read',
UsersAuthTokenList = 'users.authtoken:list',
UsersAuthTokenUpdate = 'users.authtoken:update',
UsersPasswordUpdate = 'users.password.update',
UsersPasswordUpdate = 'users.password:update',
UsersDelete = 'users:delete',
UsersCreate = 'users:create',
UsersEnable = 'users:enable',
UsersDisable = 'users:disable',
UsersPermissionsUpdate = 'users.permissions.update',
UsersPermissionsUpdate = 'users.permissions:update',
UsersLogout = 'users:logout',
UsersQuotasList = 'users.quotas:list',
UsersQuotasUpdate = 'users.quotas:update',
}
// Global Scopes
export enum AccessControlScope {
UsersAll = 'users:*',
UsersSelf = 'users:self',
OrgUsersRead = 'org.users:read',
OrgUsersAdd = 'org.users:add',
OrgUsersRemove = 'org.users:remove',
OrgUsersRoleUpdate = 'org.users.role:update',
LDAPUsersRead = 'ldap.user:read',
LDAPUsersSync = 'ldap.user:sync',
LDAPStatusRead = 'ldap.status:read',
}