diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index 82c82670bfc..6bf4fbb5b4d 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -443,6 +443,13 @@ var orgsCreateAccessEvaluator = ac.EvalAll( ac.EvalPermission(ActionOrgsCreate), ) +// usersInviteEvaluator is used to protect the "Configuration > Users > Invite" page access +// accessible to org admins and server admins by default +var usersInviteEvaluator = ac.EvalAny( + ac.EvalPermission(ac.ActionUsersCreate), + ac.EvalPermission(ac.ActionOrgUsersAdd), +) + // teamsAccessEvaluator is used to protect the "Configuration > Teams" page access // grants access to a user when they can either create teams or can read and update a team var teamsAccessEvaluator = ac.EvalAny( diff --git a/pkg/api/api.go b/pkg/api/api.go index 1c6ab86c1d2..3a726c157f9 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -59,7 +59,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/datasources/edit/*", authorize(reqOrgAdmin, datasources.EditPageAccess), hs.Index) r.Get("/org/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), hs.Index) r.Get("/org/users/new", reqOrgAdmin, hs.Index) - r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) + r.Get("/org/users/invite", authorize(reqOrgAdmin, usersInviteEvaluator), hs.Index) r.Get("/org/teams", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsRead)), hs.Index) r.Get("/org/teams/edit/*", authorize(reqCanAccessTeams, teamsEditAccessEvaluator), hs.Index) r.Get("/org/teams/new", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsCreate)), hs.Index) @@ -239,7 +239,7 @@ func (hs *HTTPServer) registerRoutes() { // invites orgRoute.Get("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(hs.GetPendingOrgInvites)) - orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), quota("user"), routing.Wrap(hs.AddOrgInvite)) + orgRoute.Post("/invites", authorize(reqOrgAdmin, usersInviteEvaluator), quota("user"), routing.Wrap(hs.AddOrgInvite)) orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(hs.RevokeInvite)) // prefs diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index 970ac732d95..39230014222 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -5,12 +5,14 @@ import ( "errors" "fmt" "net/http" + "strconv" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -46,9 +48,27 @@ func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response { return response.Error(500, "Failed to query db for existing user check", err) } } else { + // Evaluate permissions for adding an existing user to the organization + userIDScope := ac.Scope("users", "id", strconv.Itoa(int(userQuery.Result.Id))) + hasAccess, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, ac.EvalPermission(ac.ActionOrgUsersAdd, userIDScope)) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to evaluate permissions", err) + } + if !hasAccess { + return response.Error(http.StatusForbidden, "Permission denied: not permitted to add an existing user to this organisation", err) + } return hs.inviteExistingUserToOrg(c, userQuery.Result, &inviteDto) } + // Evaluate permissions for inviting a new user to Grafana + hasAccess, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, ac.EvalPermission(ac.ActionUsersCreate)) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to evaluate permissions", err) + } + if !hasAccess { + return response.Error(http.StatusForbidden, "Permission denied: not permitted to create a new user", err) + } + if setting.DisableLoginForm { return response.Error(400, "Cannot invite when login is disabled.", nil) } @@ -59,7 +79,6 @@ func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response { cmd.Name = inviteDto.Name cmd.Status = models.TmpUserInvitePending cmd.InvitedByUserId = c.UserId - var err error cmd.Code, err = util.GetRandomString(30) if err != nil { return response.Error(500, "Could not generate random string", err) diff --git a/pkg/api/org_invite_test.go b/pkg/api/org_invite_test.go new file mode 100644 index 00000000000..cdd338a5a49 --- /dev/null +++ b/pkg/api/org_invite_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func TestOrgInvitesAPIEndpointAccess(t *testing.T) { + type accessControlTestCase2 struct { + expectedCode int + desc string + url string + method string + permissions []*accesscontrol.Permission + input string + } + tests := []accessControlTestCase2{ + { + expectedCode: http.StatusOK, + desc: "org viewer with the correct permissions can invite and existing user to his org", + url: "/api/org/invites", + method: http.MethodPost, + permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: accesscontrol.ScopeUsersAll}}, + input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_VIEWER) + `"}`, + }, + { + expectedCode: http.StatusForbidden, + desc: "org viewer with missing permissions cannot invite and existing user to his org", + url: "/api/org/invites", + method: http.MethodPost, + permissions: []*accesscontrol.Permission{}, + input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_VIEWER) + `"}`, + }, + { + expectedCode: http.StatusForbidden, + desc: "org viewer with the wrong scope cannot invite and existing user to his org", + url: "/api/org/invites", + method: http.MethodPost, + permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: "users:id:100"}}, + input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_VIEWER) + `"}`, + }, + { + expectedCode: http.StatusForbidden, + desc: "org viewer with user add permission cannot invite a new user to his org", + url: "/api/org/invites", + method: http.MethodPost, + permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: accesscontrol.ScopeUsersAll}}, + input: `{"loginOrEmail": "new user", "role": "` + string(models.ROLE_VIEWER) + `"}`, + }, + { + expectedCode: http.StatusOK, + desc: "org viewer with the correct permissions can invite a new user to his org", + url: "/api/org/invites", + method: http.MethodPost, + permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionUsersCreate}}, + input: `{"loginOrEmail": "new user", "role": "` + string(models.ROLE_VIEWER) + `"}`, + }, + { + expectedCode: http.StatusForbidden, + desc: "org viewer with missing permissions cannot invite a new user to his org", + url: "/api/org/invites", + method: http.MethodPost, + permissions: []*accesscontrol.Permission{}, + input: `{"loginOrEmail": "new user", "role": "` + string(models.ROLE_VIEWER) + `"}`, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + sc := setupHTTPServer(t, true, true) + setInitCtxSignedInViewer(sc.initCtx) + setupOrgUsersDBForAccessControlTests(t, sc.db) + setAccessControlPermissions(sc.acmock, test.permissions, sc.initCtx.OrgId) + + input := strings.NewReader(test.input) + response := callAPI(sc.server, test.method, test.url, input, t) + assert.Equal(t, test.expectedCode, response.Code) + }) + } +} diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index 842d1de3f20..e9abe2979bf 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -166,7 +166,7 @@ export class ContextSrv { return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled; } - hasAccess(action: string, fallBack: boolean) { + hasAccess(action: string, fallBack: boolean): boolean { if (!this.accessControlEnabled()) { return fallBack; } diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx index 55fe3b046f3..0d69e835bf3 100644 --- a/public/app/features/users/UsersActionBar.tsx +++ b/public/app/features/users/UsersActionBar.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; -import { AccessControlAction } from 'app/types'; +import { AccessControlAction, StoreState } from 'app/types'; import { selectTotal } from '../invites/state/selectors'; @@ -37,7 +37,9 @@ export class UsersActionBar extends PureComponent { { label: 'Users', value: 'users' }, { label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' }, ]; - const canAddToOrg = contextSrv.hasAccess(AccessControlAction.UsersCreate, canInvite); + const canAddToOrg: boolean = + contextSrv.hasAccess(AccessControlAction.UsersCreate, canInvite) || + contextSrv.hasAccess(AccessControlAction.OrgUsersAdd, canInvite); return (
@@ -64,7 +66,7 @@ export class UsersActionBar extends PureComponent { } } -function mapStateToProps(state: any) { +function mapStateToProps(state: StoreState) { return { searchQuery: getUsersSearchQuery(state.users), pendingInvitesCount: selectTotal(state.invites),