mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Access control: Allow organisation admins to add existing users to org (#51668)
* check users with user add permission to access the invite endpoint * undo unneeded changes * tests and cleanup * linting * linting * betterer * betterer again * fix prettier issue Co-authored-by: jguer <joao.guerreiro@grafana.com>
This commit is contained in:
parent
506e63f4e1
commit
0c33b9f211
19782
.betterer.results
19782
.betterer.results
File diff suppressed because it is too large
Load Diff
@ -423,6 +423,13 @@ var orgsCreateAccessEvaluator = ac.EvalAll(
|
|||||||
ac.EvalPermission(ActionOrgsCreate),
|
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
|
// 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
|
// grants access to a user when they can either create teams or can read and update a team
|
||||||
var teamsAccessEvaluator = ac.EvalAny(
|
var teamsAccessEvaluator = ac.EvalAny(
|
||||||
|
@ -59,7 +59,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
r.Get("/datasources/edit/*", authorize(reqOrgAdmin, datasources.EditPageAccess), hs.Index)
|
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", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), hs.Index)
|
||||||
r.Get("/org/users/new", reqOrgAdmin, 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", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsRead)), hs.Index)
|
||||||
r.Get("/org/teams/edit/*", authorize(reqCanAccessTeams, teamsEditAccessEvaluator), 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)
|
r.Get("/org/teams/new", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsCreate)), hs.Index)
|
||||||
@ -244,7 +244,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
|
|
||||||
// invites
|
// invites
|
||||||
orgRoute.Get("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(hs.GetPendingOrgInvites))
|
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))
|
orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(hs.RevokeInvite))
|
||||||
|
|
||||||
// prefs
|
// prefs
|
||||||
|
@ -5,12 +5,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
"github.com/grafana/grafana/pkg/events"
|
"github.com/grafana/grafana/pkg/events"
|
||||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
@ -50,9 +52,27 @@ func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response {
|
|||||||
return response.Error(500, "Failed to query db for existing user check", err)
|
return response.Error(500, "Failed to query db for existing user check", err)
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
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 {
|
if setting.DisableLoginForm {
|
||||||
return response.Error(400, "Cannot invite when login is disabled.", nil)
|
return response.Error(400, "Cannot invite when login is disabled.", nil)
|
||||||
}
|
}
|
||||||
@ -63,7 +83,6 @@ func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response {
|
|||||||
cmd.Name = inviteDto.Name
|
cmd.Name = inviteDto.Name
|
||||||
cmd.Status = models.TmpUserInvitePending
|
cmd.Status = models.TmpUserInvitePending
|
||||||
cmd.InvitedByUserId = c.UserId
|
cmd.InvitedByUserId = c.UserId
|
||||||
var err error
|
|
||||||
cmd.Code, err = util.GetRandomString(30)
|
cmd.Code, err = util.GetRandomString(30)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Error(500, "Could not generate random string", err)
|
return response.Error(500, "Could not generate random string", err)
|
||||||
|
86
pkg/api/org_invite_test.go
Normal file
86
pkg/api/org_invite_test.go
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -166,7 +166,7 @@ export class ContextSrv {
|
|||||||
return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
|
return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAccess(action: string, fallBack: boolean) {
|
hasAccess(action: string, fallBack: boolean): boolean {
|
||||||
if (!this.accessControlEnabled()) {
|
if (!this.accessControlEnabled()) {
|
||||||
return fallBack;
|
return fallBack;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
|||||||
|
|
||||||
import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui';
|
import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
import { AccessControlAction } from 'app/types';
|
import { AccessControlAction, StoreState } from 'app/types';
|
||||||
|
|
||||||
import { selectTotal } from '../invites/state/selectors';
|
import { selectTotal } from '../invites/state/selectors';
|
||||||
|
|
||||||
@ -37,7 +37,9 @@ export class UsersActionBar extends PureComponent<Props> {
|
|||||||
{ label: 'Users', value: 'users' },
|
{ label: 'Users', value: 'users' },
|
||||||
{ label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' },
|
{ 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 (
|
return (
|
||||||
<div className="page-action-bar" data-testid="users-action-bar">
|
<div className="page-action-bar" data-testid="users-action-bar">
|
||||||
@ -64,7 +66,7 @@ export class UsersActionBar extends PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStateToProps(state: any) {
|
function mapStateToProps(state: StoreState) {
|
||||||
return {
|
return {
|
||||||
searchQuery: getUsersSearchQuery(state.users),
|
searchQuery: getUsersSearchQuery(state.users),
|
||||||
pendingInvitesCount: selectTotal(state.invites),
|
pendingInvitesCount: selectTotal(state.invites),
|
||||||
|
Loading…
Reference in New Issue
Block a user