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:
Ieva
2022-01-10 17:05:53 +00:00
committed by GitHub
co-authored by gamab
parent 5bab02c7ef
commit 41b709d08d
10 changed files with 128 additions and 32 deletions
+10 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
}
+3
View File
@@ -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"
+16
View File
@@ -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,
+9 -1
View File
@@ -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;
+6 -6
View File
@@ -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
+2
View File
@@ -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 {