mirror of
https://github.com/grafana/grafana.git
synced 2026-09-05 04:40:13 -05:00
Access control: permissions for team creation (#43506)
* FGAC for team creation * tests * fix snapshot for UI tests * linting * update snapshots * Remove unecessary class and update tests Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com> * Make the condition slightly easier Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com> Co-authored-by: gamab <gabi.mabs@gmail.com>
This commit is contained in:
+10
-10
@@ -178,16 +178,16 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
|
|
||||||
// team (admin permission required)
|
// team (admin permission required)
|
||||||
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
|
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
|
||||||
teamsRoute.Post("/", routing.Wrap(hs.CreateTeam))
|
teamsRoute.Post("/", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsCreate)), routing.Wrap(hs.CreateTeam))
|
||||||
teamsRoute.Put("/:teamId", routing.Wrap(hs.UpdateTeam))
|
teamsRoute.Put("/:teamId", reqCanAccessTeams, routing.Wrap(hs.UpdateTeam))
|
||||||
teamsRoute.Delete("/:teamId", routing.Wrap(hs.DeleteTeamByID))
|
teamsRoute.Delete("/:teamId", reqCanAccessTeams, routing.Wrap(hs.DeleteTeamByID))
|
||||||
teamsRoute.Get("/:teamId/members", routing.Wrap(hs.GetTeamMembers))
|
teamsRoute.Get("/:teamId/members", reqCanAccessTeams, routing.Wrap(hs.GetTeamMembers))
|
||||||
teamsRoute.Post("/:teamId/members", routing.Wrap(hs.AddTeamMember))
|
teamsRoute.Post("/:teamId/members", reqCanAccessTeams, routing.Wrap(hs.AddTeamMember))
|
||||||
teamsRoute.Put("/:teamId/members/:userId", routing.Wrap(hs.UpdateTeamMember))
|
teamsRoute.Put("/:teamId/members/:userId", reqCanAccessTeams, routing.Wrap(hs.UpdateTeamMember))
|
||||||
teamsRoute.Delete("/:teamId/members/:userId", routing.Wrap(hs.RemoveTeamMember))
|
teamsRoute.Delete("/:teamId/members/:userId", reqCanAccessTeams, routing.Wrap(hs.RemoveTeamMember))
|
||||||
teamsRoute.Get("/:teamId/preferences", routing.Wrap(hs.GetTeamPreferences))
|
teamsRoute.Get("/:teamId/preferences", reqCanAccessTeams, routing.Wrap(hs.GetTeamPreferences))
|
||||||
teamsRoute.Put("/:teamId/preferences", routing.Wrap(hs.UpdateTeamPreferences))
|
teamsRoute.Put("/:teamId/preferences", reqCanAccessTeams, routing.Wrap(hs.UpdateTeamPreferences))
|
||||||
}, reqCanAccessTeams)
|
})
|
||||||
|
|
||||||
// team without requirement of user to be org admin
|
// team without requirement of user to be org admin
|
||||||
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
|
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
|
||||||
|
|||||||
+14
-5
@@ -283,17 +283,17 @@ func setInitCtxSignedInViewer(initCtx *models.ReqContext) {
|
|||||||
initCtx.SignedInUser = &models.SignedInUser{UserId: testUserID, OrgId: 1, OrgRole: models.ROLE_VIEWER, Login: testUserLogin}
|
initCtx.SignedInUser = &models.SignedInUser{UserId: testUserID, OrgId: 1, OrgRole: models.ROLE_VIEWER, Login: testUserLogin}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setInitCtxSignedInEditor(initCtx *models.ReqContext) {
|
||||||
|
initCtx.IsSignedIn = true
|
||||||
|
initCtx.SignedInUser = &models.SignedInUser{UserId: testUserID, OrgId: 1, OrgRole: models.ROLE_EDITOR, Login: testUserLogin}
|
||||||
|
}
|
||||||
|
|
||||||
func setInitCtxSignedInOrgAdmin(initCtx *models.ReqContext) {
|
func setInitCtxSignedInOrgAdmin(initCtx *models.ReqContext) {
|
||||||
initCtx.IsSignedIn = true
|
initCtx.IsSignedIn = true
|
||||||
initCtx.SignedInUser = &models.SignedInUser{UserId: testUserID, OrgId: 1, OrgRole: models.ROLE_ADMIN, Login: testUserLogin}
|
initCtx.SignedInUser = &models.SignedInUser{UserId: testUserID, OrgId: 1, OrgRole: models.ROLE_ADMIN, Login: testUserLogin}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHTTPServer(t *testing.T, useFakeAccessControl bool, enableAccessControl bool) accessControlScenarioContext {
|
func setupHTTPServer(t *testing.T, useFakeAccessControl bool, enableAccessControl bool) accessControlScenarioContext {
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var acmock *accesscontrolmock.Mock
|
|
||||||
var ac *ossaccesscontrol.OSSAccessControlService
|
|
||||||
|
|
||||||
// Use a new conf
|
// Use a new conf
|
||||||
cfg := setting.NewCfg()
|
cfg := setting.NewCfg()
|
||||||
cfg.FeatureToggles = make(map[string]bool)
|
cfg.FeatureToggles = make(map[string]bool)
|
||||||
@@ -301,6 +301,15 @@ func setupHTTPServer(t *testing.T, useFakeAccessControl bool, enableAccessContro
|
|||||||
cfg.FeatureToggles["accesscontrol"] = enableAccessControl
|
cfg.FeatureToggles["accesscontrol"] = enableAccessControl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return setupHTTPServerWithCfg(t, useFakeAccessControl, enableAccessControl, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessControl bool, cfg *setting.Cfg) accessControlScenarioContext {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var acmock *accesscontrolmock.Mock
|
||||||
|
var ac *ossaccesscontrol.OSSAccessControlService
|
||||||
|
|
||||||
// Use a test DB
|
// Use a test DB
|
||||||
db := sqlstore.InitTestDB(t)
|
db := sqlstore.InitTestDB(t)
|
||||||
db.Cfg = cfg
|
db.Cfg = cfg
|
||||||
|
|||||||
+3
-2
@@ -19,7 +19,8 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response {
|
|||||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||||
}
|
}
|
||||||
if c.OrgRole == models.ROLE_VIEWER {
|
accessControlEnabled := hs.Cfg.FeatureToggles["accesscontrol"]
|
||||||
|
if !accessControlEnabled && c.OrgRole == models.ROLE_VIEWER {
|
||||||
return response.Error(403, "Not allowed to create team.", nil)
|
return response.Error(403, "Not allowed to create team.", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response {
|
|||||||
return response.Error(500, "Failed to create Team", err)
|
return response.Error(500, "Failed to create Team", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.OrgRole == models.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin {
|
if accessControlEnabled || (c.OrgRole == models.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin) {
|
||||||
// if the request is authenticated using API tokens
|
// if the request is authenticated using API tokens
|
||||||
// the SignedInUser is an empty struct therefore
|
// the SignedInUser is an empty struct therefore
|
||||||
// an additional check whether it is an actual user is required
|
// an additional check whether it is an actual user is required
|
||||||
|
|||||||
+61
-4
@@ -2,18 +2,19 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/web"
|
"github.com/grafana/grafana/pkg/web"
|
||||||
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -156,3 +157,59 @@ func TestTeamAPIEndpoint(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
createTeamURL = "/api/teams/"
|
||||||
|
createTeamCmd = `{"name": "MyTestTeam%d"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTeamAPIEndpoint_CreateTeam_LegacyAccessControl(t *testing.T) {
|
||||||
|
sc := setupHTTPServer(t, true, false)
|
||||||
|
setInitCtxSignedInOrgAdmin(sc.initCtx)
|
||||||
|
|
||||||
|
input := strings.NewReader(fmt.Sprintf(createTeamCmd, 1))
|
||||||
|
t.Run("Organisation admin can create a team", func(t *testing.T) {
|
||||||
|
response := callAPI(sc.server, http.MethodPost, createTeamURL, input, t)
|
||||||
|
assert.Equal(t, http.StatusOK, response.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
setInitCtxSignedInEditor(sc.initCtx)
|
||||||
|
sc.initCtx.IsGrafanaAdmin = true
|
||||||
|
input = strings.NewReader(fmt.Sprintf(createTeamCmd, 2))
|
||||||
|
t.Run("Org editor and server admin cannot create a team", func(t *testing.T) {
|
||||||
|
response := callAPI(sc.server, http.MethodPost, createTeamURL, strings.NewReader(createTeamCmd), t)
|
||||||
|
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeamAPIEndpoint_CreateTeam_LegacyAccessControl_EditorsCanAdmin(t *testing.T) {
|
||||||
|
cfg := setting.NewCfg()
|
||||||
|
cfg.EditorsCanAdmin = true
|
||||||
|
sc := setupHTTPServerWithCfg(t, true, false, cfg)
|
||||||
|
|
||||||
|
setInitCtxSignedInEditor(sc.initCtx)
|
||||||
|
input := strings.NewReader(fmt.Sprintf(createTeamCmd, 1))
|
||||||
|
t.Run("Editors can create a team if editorsCanAdmin is set to true", func(t *testing.T) {
|
||||||
|
response := callAPI(sc.server, http.MethodPost, createTeamURL, input, t)
|
||||||
|
assert.Equal(t, http.StatusOK, response.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeamAPIEndpoint_CreateTeam_FGAC(t *testing.T) {
|
||||||
|
sc := setupHTTPServer(t, true, true)
|
||||||
|
|
||||||
|
setInitCtxSignedInViewer(sc.initCtx)
|
||||||
|
input := strings.NewReader(fmt.Sprintf(createTeamCmd, 1))
|
||||||
|
t.Run("Access control allows creating teams with the correct permissions", func(t *testing.T) {
|
||||||
|
setAccessControlPermissions(sc.acmock, []*accesscontrol.Permission{{Action: accesscontrol.ActionTeamsCreate}}, 1)
|
||||||
|
response := callAPI(sc.server, http.MethodPost, createTeamURL, input, t)
|
||||||
|
assert.Equal(t, http.StatusOK, response.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
input = strings.NewReader(fmt.Sprintf(createTeamCmd, 2))
|
||||||
|
t.Run("Access control prevents creating teams with the incorrect permissions", func(t *testing.T) {
|
||||||
|
setAccessControlPermissions(sc.acmock, []*accesscontrol.Permission{{Action: "teams:invalid"}}, accesscontrol.GlobalOrgID)
|
||||||
|
response := callAPI(sc.server, http.MethodPost, createTeamURL, input, t)
|
||||||
|
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -310,6 +310,9 @@ const (
|
|||||||
ActionLicensingUpdate = "licensing:update"
|
ActionLicensingUpdate = "licensing:update"
|
||||||
ActionLicensingDelete = "licensing:delete"
|
ActionLicensingDelete = "licensing:delete"
|
||||||
ActionLicensingReportsRead = "licensing.reports:read"
|
ActionLicensingReportsRead = "licensing.reports:read"
|
||||||
|
|
||||||
|
// Team actions
|
||||||
|
ActionTeamsCreate = "teams:create"
|
||||||
)
|
)
|
||||||
|
|
||||||
const RoleGrafanaAdmin = "Grafana Admin"
|
const RoleGrafanaAdmin = "Grafana Admin"
|
||||||
|
|||||||
@@ -197,6 +197,19 @@ var (
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
teamsWriterRole = RoleDTO{
|
||||||
|
Name: teamsWriter,
|
||||||
|
DisplayName: "Teams writer",
|
||||||
|
Description: "Create teams.",
|
||||||
|
Group: "Teams",
|
||||||
|
Version: 1,
|
||||||
|
Permissions: []Permission{
|
||||||
|
{
|
||||||
|
Action: ActionTeamsCreate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Role names definitions
|
// Role names definitions
|
||||||
@@ -210,6 +223,7 @@ const (
|
|||||||
statsReader = "fixed:stats:reader"
|
statsReader = "fixed:stats:reader"
|
||||||
usersReader = "fixed:users:reader"
|
usersReader = "fixed:users:reader"
|
||||||
usersWriter = "fixed:users:writer"
|
usersWriter = "fixed:users:writer"
|
||||||
|
teamsWriter = "fixed:teams:writer"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -229,6 +243,7 @@ var (
|
|||||||
statsReader: statsReaderRole,
|
statsReader: statsReaderRole,
|
||||||
usersReader: usersReaderRole,
|
usersReader: usersReaderRole,
|
||||||
usersWriter: usersWriterRole,
|
usersWriter: usersWriterRole,
|
||||||
|
teamsWriter: teamsWriterRole,
|
||||||
}
|
}
|
||||||
|
|
||||||
// FixedRoleGrants specifies which built-in roles are assigned
|
// FixedRoleGrants specifies which built-in roles are assigned
|
||||||
@@ -247,6 +262,7 @@ var (
|
|||||||
string(models.ROLE_ADMIN): {
|
string(models.ROLE_ADMIN): {
|
||||||
orgUsersReader,
|
orgUsersReader,
|
||||||
orgUsersWriter,
|
orgUsersWriter,
|
||||||
|
teamsWriter,
|
||||||
},
|
},
|
||||||
string(models.ROLE_EDITOR): {
|
string(models.ROLE_EDITOR): {
|
||||||
datasourcesExplorer,
|
datasourcesExplorer,
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ import { shallow } from 'enzyme';
|
|||||||
import { Props, TeamList } from './TeamList';
|
import { Props, TeamList } from './TeamList';
|
||||||
import { OrgRole, Team } from '../../types';
|
import { OrgRole, Team } from '../../types';
|
||||||
import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
|
import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
|
||||||
import { User } from 'app/core/services/context_srv';
|
import { contextSrv, User } from 'app/core/services/context_srv';
|
||||||
import { NavModel } from '@grafana/data';
|
import { NavModel } from '@grafana/data';
|
||||||
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
|
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
|
||||||
import { setSearchQuery } from './state/reducers';
|
import { setSearchQuery } from './state/reducers';
|
||||||
|
|
||||||
|
jest.mock('app/core/config', () => {
|
||||||
|
return {
|
||||||
|
featureToggles: { accesscontrol: false },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const setup = (propOverrides?: object) => {
|
const setup = (propOverrides?: object) => {
|
||||||
const props: Props = {
|
const props: Props = {
|
||||||
navModel: {
|
navModel: {
|
||||||
@@ -34,6 +40,8 @@ const setup = (propOverrides?: object) => {
|
|||||||
|
|
||||||
Object.assign(props, propOverrides);
|
Object.assign(props, propOverrides);
|
||||||
|
|
||||||
|
contextSrv.user = props.signedInUser;
|
||||||
|
|
||||||
const wrapper = shallow(<TeamList {...props} />);
|
const wrapper = shallow(<TeamList {...props} />);
|
||||||
const instance = wrapper.instance() as TeamList;
|
const instance = wrapper.instance() as TeamList;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Page from 'app/core/components/Page/Page';
|
|||||||
import { DeleteButton, LinkButton, FilterInput } from '@grafana/ui';
|
import { DeleteButton, LinkButton, FilterInput } from '@grafana/ui';
|
||||||
import { NavModel } from '@grafana/data';
|
import { NavModel } from '@grafana/data';
|
||||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||||
import { OrgRole, StoreState, Team } from 'app/types';
|
import { AccessControlAction, StoreState, Team } from 'app/types';
|
||||||
import { deleteTeam, loadTeams } from './state/actions';
|
import { deleteTeam, loadTeams } from './state/actions';
|
||||||
import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors';
|
import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors';
|
||||||
import { getNavModel } from 'app/core/selectors/navModel';
|
import { getNavModel } from 'app/core/selectors/navModel';
|
||||||
@@ -94,10 +94,10 @@ export class TeamList extends PureComponent<Props, any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderTeamList() {
|
renderTeamList() {
|
||||||
const { teams, searchQuery, editorsCanAdmin, signedInUser } = this.props;
|
const { teams, searchQuery, editorsCanAdmin } = this.props;
|
||||||
const isCanAdminAndViewer = editorsCanAdmin && signedInUser.orgRole === OrgRole.Viewer;
|
const teamAdmin = contextSrv.hasRole('Admin') || (editorsCanAdmin && contextSrv.hasRole('Editor'));
|
||||||
const disabledClass = isCanAdminAndViewer ? ' disabled' : '';
|
const canCreate = contextSrv.hasAccess(AccessControlAction.ActionTeamsCreate, teamAdmin);
|
||||||
const newTeamHref = isCanAdminAndViewer ? '#' : 'org/teams/new';
|
const newTeamHref = canCreate ? 'org/teams/new' : '#';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -106,7 +106,7 @@ export class TeamList extends PureComponent<Props, any> {
|
|||||||
<FilterInput placeholder="Search teams" value={searchQuery} onChange={this.onSearchQueryChange} />
|
<FilterInput placeholder="Search teams" value={searchQuery} onChange={this.onSearchQueryChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LinkButton className={disabledClass} href={newTeamHref}>
|
<LinkButton href={newTeamHref} disabled={!canCreate}>
|
||||||
New Team
|
New Team
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ exports[`Render should render teams table 1`] = `
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className=""
|
disabled={true}
|
||||||
href="org/teams/new"
|
href="#"
|
||||||
>
|
>
|
||||||
New Team
|
New Team
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
@@ -388,7 +388,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className=" disabled"
|
disabled={true}
|
||||||
href="#"
|
href="#"
|
||||||
>
|
>
|
||||||
New Team
|
New Team
|
||||||
@@ -512,7 +512,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className=""
|
disabled={false}
|
||||||
href="org/teams/new"
|
href="org/teams/new"
|
||||||
>
|
>
|
||||||
New Team
|
New Team
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export enum AccessControlAction {
|
|||||||
DataSourcesPermissionsRead = 'datasources.permissions:read',
|
DataSourcesPermissionsRead = 'datasources.permissions:read',
|
||||||
|
|
||||||
ActionServerStatsRead = 'server.stats:read',
|
ActionServerStatsRead = 'server.stats:read',
|
||||||
|
|
||||||
|
ActionTeamsCreate = 'teams:create',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Role {
|
export interface Role {
|
||||||
|
|||||||
Reference in New Issue
Block a user