AccessControl: Change teams permissions page when accesscontrol is enabled (#43971)

* AccessControl: Change teams permissions page when frontend is hit

* Implement frontend changes for group sync

* Changing the org/teams/edit permissions

Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com>

* Fixing routes

Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com>

* Use props straight away no need to go through the state

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/features/teams/TeamPages.tsx

Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Gabriel MABILLE 2022-02-03 17:49:39 +01:00 committed by GitHub
parent 85ea1a5d64
commit 8217d6d206
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 270 additions and 56 deletions

View File

@ -57,8 +57,9 @@ func (hs *HTTPServer) registerRoutes() {
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/teams", reqCanAccessTeams, hs.Index)
r.Get("/org/teams/*", reqCanAccessTeams, 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)
r.Get("/org/serviceaccounts", middleware.ReqOrgAdmin, hs.Index)
r.Get("/org/serviceaccounts/:serviceAccountId", middleware.ReqOrgAdmin, hs.Index)
r.Get("/org/apikeys/", reqOrgAdmin, hs.Index)

View File

@ -311,9 +311,10 @@ func setupSimpleHTTPServer(features *featuremgmt.FeatureManager) *HTTPServer {
cfg.IsFeatureToggleEnabled = features.IsEnabled
return &HTTPServer{
Cfg: cfg,
Features: features,
Bus: bus.GetBus(),
Cfg: cfg,
Features: features,
Bus: bus.GetBus(),
AccessControl: accesscontrolmock.New().WithDisabled(),
}
}

View File

@ -148,7 +148,7 @@ func enableServiceAccount(hs *HTTPServer, c *models.ReqContext) bool {
hs.Features.IsEnabled(featuremgmt.FlagServiceAccounts)
}
func enableTeams(hs *HTTPServer, c *models.ReqContext) bool {
func (hs *HTTPServer) ReqCanAdminTeams(c *models.ReqContext) bool {
return c.OrgRole == models.ROLE_ADMIN || (hs.Cfg.EditorsCanAdmin && c.OrgRole == models.ROLE_EDITOR)
}
@ -263,7 +263,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
})
}
if enableTeams(hs, c) {
if hasAccess(hs.ReqCanAdminTeams, teamsAccessEvaluator) {
configNodes = append(configNodes, &dtos.NavLink{
Text: "Teams",
Id: "teams",

View File

@ -298,3 +298,22 @@ var orgsCreateAccessEvaluator = accesscontrol.EvalAll(
accesscontrol.EvalPermission(ActionOrgsRead),
accesscontrol.EvalPermission(ActionOrgsCreate),
)
// teamsAccessEvaluator is used to protect the "Configuration > Teams" page access
var teamsAccessEvaluator = accesscontrol.EvalAll(
accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead),
accesscontrol.EvalAny(
accesscontrol.EvalPermission(accesscontrol.ActionTeamsCreate),
accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite),
accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite),
),
)
// teamsEditAccessEvaluator is used to protect the "Configuration > Teams > edit" page access
var teamsEditAccessEvaluator = accesscontrol.EvalAll(
accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead),
accesscontrol.EvalAny(
accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite),
accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite),
),
)

View File

@ -2,12 +2,14 @@ package api
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/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
@ -103,6 +105,20 @@ func (hs *HTTPServer) DeleteTeamByID(c *models.ReqContext) response.Response {
return response.Success("Team deleted")
}
func (hs *HTTPServer) getTeamsAccessControlMetadata(c *models.ReqContext, teamIDs map[string]bool) (map[string]accesscontrol.Metadata, error) {
if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") {
return nil, nil
}
userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser)
if err != nil || len(userPermissions) == 0 {
hs.log.Warn("could not fetch accesscontrol metadata for teams", "error", err)
return nil, err
}
return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "teams", teamIDs), nil
}
// GET /api/teams/search
func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response {
perPage := c.QueryInt("perpage")
@ -134,8 +150,17 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to search Teams", err)
}
teamIDs := map[string]bool{}
for _, team := range query.Result.Teams {
team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name)
teamIDs[strconv.FormatInt(team.Id, 10)] = true
}
metadata, err := hs.getTeamsAccessControlMetadata(c, teamIDs)
if err == nil && len(metadata) != 0 {
for _, team := range query.Result.Teams {
team.AccessControl = metadata[strconv.FormatInt(team.Id, 10)]
}
}
query.Result.Page = page
@ -144,6 +169,23 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response {
return response.JSON(200, query.Result)
}
func (hs *HTTPServer) getTeamAccessControlMetadata(c *models.ReqContext, teamID int64) (accesscontrol.Metadata, error) {
if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") {
return nil, nil
}
userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser)
if err != nil || len(userPermissions) == 0 {
hs.log.Warn("could not fetch accesscontrol metadata", "team", teamID, "error", err)
return nil, err
}
key := fmt.Sprintf("%d", teamID)
teamIDs := map[string]bool{key: true}
return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "teams", teamIDs)[key], nil
}
// GET /api/teams/:teamId
func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response {
teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64)
@ -165,6 +207,9 @@ func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to get Team", err)
}
metadata, _ := hs.getTeamAccessControlMetadata(c, query.Result.Id)
query.Result.AccessControl = metadata
query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name)
return response.JSON(200, &query.Result)
}

View File

@ -77,13 +77,14 @@ type SearchTeamsQuery struct {
}
type TeamDTO struct {
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
MemberCount int64 `json:"memberCount"`
Permission PermissionType `json:"permission"`
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
MemberCount int64 `json:"memberCount"`
Permission PermissionType `json:"permission"`
AccessControl map[string]bool `json:"accessControl"`
}
type SearchTeamQueryResult struct {

View File

@ -143,6 +143,13 @@ export class ContextSrv {
return this.hasPermission(action);
}
hasAccessInMetadata(action: string, object: WithAccessControlMetadata, fallBack: boolean) {
if (!config.featureToggles['accesscontrol']) {
return fallBack;
}
return this.hasPermissionInMetadata(action, object);
}
// evaluates access control permissions, granting access if the user has any of them; uses fallback if access control is disabled
evaluatePermission(fallback: () => string[], actions: string[]) {
if (!this.accessControlEnabled()) {

View File

@ -6,6 +6,7 @@ import { getMockTeamGroups } from './__mocks__/teamMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
isReadOnly: false,
groups: [] as TeamGroup[],
loadTeamGroups: jest.fn(),
addTeamGroup: jest.fn(),

View File

@ -23,13 +23,17 @@ const mapDispatchToProps = {
removeTeamGroup,
};
interface OwnProps {
isReadOnly: boolean;
}
interface State {
isAdding: boolean;
newGroupId: string;
}
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = ConnectedProps<typeof connector>;
export type Props = OwnProps & ConnectedProps<typeof connector>;
const headerTooltip = `Sync LDAP or OAuth groups with your Grafana teams.`;
@ -70,11 +74,12 @@ export class TeamGroupSync extends PureComponent<Props, State> {
}
renderGroup(group: TeamGroup) {
const { isReadOnly } = this.props;
return (
<tr key={group.groupId}>
<td>{group.groupId}</td>
<td style={{ width: '1%' }}>
<Button size="sm" variant="destructive" onClick={() => this.onRemoveGroup(group)}>
<Button size="sm" variant="destructive" onClick={() => this.onRemoveGroup(group)} disabled={isReadOnly}>
<Icon name="times" />
</Button>
</td>
@ -84,7 +89,7 @@ export class TeamGroupSync extends PureComponent<Props, State> {
render() {
const { isAdding, newGroupId } = this.state;
const groups = this.props.groups;
const { groups, isReadOnly } = this.props;
return (
<div>
@ -95,7 +100,7 @@ export class TeamGroupSync extends PureComponent<Props, State> {
</Tooltip>
<div className="page-action-bar__spacer" />
{groups.length > 0 && (
<Button className="pull-right" onClick={this.onToggleAdding}>
<Button className="pull-right" onClick={this.onToggleAdding} disabled={isReadOnly}>
<Icon name="plus" /> Add group
</Button>
)}
@ -113,11 +118,12 @@ export class TeamGroupSync extends PureComponent<Props, State> {
value={newGroupId}
onChange={this.onNewGroupIdChanged}
placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
disabled={isReadOnly}
/>
</div>
<div className="gf-form">
<Button type="submit" disabled={!this.isNewGroupValid()}>
<Button type="submit" disabled={isReadOnly || !this.isNewGroupValid()}>
Add group
</Button>
</div>
@ -135,6 +141,7 @@ export class TeamGroupSync extends PureComponent<Props, State> {
proTipLinkTitle="Learn more"
proTipLink="http://docs.grafana.org/auth/enhanced_ldap/"
proTipTarget="_blank"
buttonDisabled={isReadOnly}
/>
)}

View File

@ -69,7 +69,11 @@ export class TeamList extends PureComponent<Props, State> {
const { editorsCanAdmin, signedInUser } = this.props;
const permission = team.permission;
const teamUrl = `org/teams/edit/${team.id}`;
const canDelete = isPermissionTeamAdmin({ permission, editorsCanAdmin, signedInUser });
const canDelete = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsDelete,
team,
isPermissionTeamAdmin({ permission, editorsCanAdmin, signedInUser })
);
return (
<tr key={team.id}>

View File

@ -13,6 +13,7 @@ jest.mock('@grafana/runtime/src/config', () => ({
licenseInfo: {
enabledFeatures: { teamsync: true },
},
featureToggles: { accesscontrol: false },
},
}));

View File

@ -4,9 +4,10 @@ import { includes } from 'lodash';
import config from 'app/core/config';
import Page from 'app/core/components/Page/Page';
import TeamMembers from './TeamMembers';
import TeamPermissions from './TeamPermissions';
import TeamSettings from './TeamSettings';
import TeamGroupSync from './TeamGroupSync';
import { StoreState } from 'app/types';
import { AccessControlAction, StoreState } from 'app/types';
import { loadTeam, loadTeamMembers } from './state/actions';
import { getTeam, getTeamMembers, isSignedInUserTeamAdmin } from './state/selectors';
import { getTeamLoadingNav } from './state/navModel';
@ -37,10 +38,17 @@ enum PageTypes {
function mapStateToProps(state: StoreState, props: OwnProps) {
const teamId = parseInt(props.match.params.id, 10);
const pageName = props.match.params.page ?? 'members';
const team = getTeam(state.team, teamId);
let defaultPage = 'members';
if (contextSrv.accessControlEnabled()) {
// With FGAC the settings page will always be available
if (!team || !contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsPermissionsRead, team)) {
defaultPage = 'settings';
}
}
const pageName = props.match.params.page ?? defaultPage;
const teamLoadingNav = getTeamLoadingNav(pageName as string);
const navModel = getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav);
const team = getTeam(state.team, teamId);
const members = getTeamMembers(state.team);
return {
@ -81,7 +89,10 @@ export class TeamPages extends PureComponent<Props, State> {
const { loadTeam, teamId } = this.props;
this.setState({ isLoading: true });
const team = await loadTeam(teamId);
await this.props.loadTeamMembers();
// With accesscontrol, the TeamPermissions will fetch team members
if (!contextSrv.accessControlEnabled()) {
await this.props.loadTeamMembers();
}
this.setState({ isLoading: false });
return team;
}
@ -105,6 +116,10 @@ export class TeamPages extends PureComponent<Props, State> {
};
hideTabsFromNonTeamAdmin = (navModel: NavModel, isSignedInUserTeamAdmin: boolean) => {
if (contextSrv.accessControlEnabled()) {
return navModel;
}
if (!isSignedInUserTeamAdmin && navModel.main && navModel.main.children) {
navModel.main.children
.filter((navItem) => !this.textsAreEqual(navItem.text, PageTypes.Members))
@ -121,15 +136,34 @@ export class TeamPages extends PureComponent<Props, State> {
const { members, team } = this.props;
const currentPage = this.getCurrentPage();
const canReadTeam = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsRead,
team!,
isSignedInUserTeamAdmin
);
const canReadTeamPermissions = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsPermissionsRead,
team!,
isSignedInUserTeamAdmin
);
const canWriteTeamPermissions = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsPermissionsWrite,
team!,
isSignedInUserTeamAdmin
);
switch (currentPage) {
case PageTypes.Members:
return <TeamMembers syncEnabled={isSyncEnabled} members={members} />;
if (contextSrv.accessControlEnabled()) {
return <TeamPermissions team={team!} />;
} else {
return <TeamMembers syncEnabled={isSyncEnabled} members={members} />;
}
case PageTypes.Settings:
return isSignedInUserTeamAdmin && <TeamSettings team={team!} />;
return canReadTeam && <TeamSettings team={team!} />;
case PageTypes.GroupSync:
if (isSignedInUserTeamAdmin && isSyncEnabled) {
return <TeamGroupSync />;
if (canReadTeamPermissions && isSyncEnabled) {
return <TeamGroupSync isReadOnly={!canWriteTeamPermissions} />;
} else if (config.featureToggles.featureHighlights) {
return (
<UpgradeBox

View File

@ -0,0 +1,31 @@
import React from 'react';
import { Permissions } from 'app/core/components/AccessControl';
import { AccessControlAction, Team } from '../../types';
import { contextSrv } from 'app/core/services/context_srv';
type TeamPermissionsProps = {
team: Team;
};
// TeamPermissions component replaces TeamMembers component when the accesscontrol feature flag is set
const TeamPermissions = (props: TeamPermissionsProps) => {
const canListUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead);
const canSetPermissions = contextSrv.hasPermissionInMetadata(
AccessControlAction.ActionTeamsPermissionsWrite,
props.team
);
return (
<Permissions
title="Members"
addPermissionTitle="Add member"
buttonLabel="Add member"
resource="teams"
resourceId={props.team.id}
canListUsers={canListUsers}
canSetPermissions={canSetPermissions}
/>
);
};
export default TeamPermissions;

View File

@ -3,6 +3,12 @@ import { shallow } from 'enzyme';
import { Props, TeamSettings } from './TeamSettings';
import { getMockTeam } from './__mocks__/teamMocks';
jest.mock('app/core/core', () => ({
contextSrv: {
hasPermissionInMetadata: () => true,
},
}));
const setup = (propOverrides?: object) => {
const props: Props = {
team: getMockTeam(),

View File

@ -4,7 +4,8 @@ import { Input, Field, Form, Button, FieldSet, VerticalGroup } from '@grafana/ui
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
import { updateTeam } from './state/actions';
import { Team } from 'app/types';
import { AccessControlAction, Team } from 'app/types';
import { contextSrv } from 'app/core/core';
const mapDispatchToProps = {
updateTeam,
@ -18,6 +19,8 @@ interface OwnProps {
export type Props = ConnectedProps<typeof connector> & OwnProps;
export const TeamSettings: FC<Props> = ({ team, updateTeam }) => {
const canWriteTeamSettings = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsWrite, team);
return (
<VerticalGroup>
<FieldSet label="Team settings">
@ -26,6 +29,7 @@ export const TeamSettings: FC<Props> = ({ team, updateTeam }) => {
onSubmit={(formTeam: Team) => {
updateTeam(formTeam.name, formTeam.email);
}}
disabled={!canWriteTeamSettings}
>
{({ register }) => (
<>
@ -44,7 +48,7 @@ export const TeamSettings: FC<Props> = ({ team, updateTeam }) => {
)}
</Form>
</FieldSet>
<SharedPreferences resourceUri={`teams/${team.id}`} />
<SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} />
</VerticalGroup>
);
};

View File

@ -44,6 +44,7 @@ exports[`Render should render component 1`] = `
>
<Input
className="gf-form-input width-30"
disabled={false}
onChange={[Function]}
placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
type="text"
@ -64,6 +65,7 @@ exports[`Render should render component 1`] = `
</div>
</SlideDown>
<EmptyListCTA
buttonDisabled={false}
buttonIcon="users-alt"
buttonTitle="Add Group"
onClick={[Function]}
@ -100,6 +102,7 @@ exports[`Render should render groups table 1`] = `
/>
<Button
className="pull-right"
disabled={false}
onClick={[Function]}
>
<Icon
@ -129,6 +132,7 @@ exports[`Render should render groups table 1`] = `
>
<Input
className="gf-form-input width-30"
disabled={false}
onChange={[Function]}
placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
type="text"
@ -183,6 +187,7 @@ exports[`Render should render groups table 1`] = `
}
>
<Button
disabled={false}
onClick={[Function]}
size="sm"
variant="destructive"
@ -207,6 +212,7 @@ exports[`Render should render groups table 1`] = `
}
>
<Button
disabled={false}
onClick={[Function]}
size="sm"
variant="destructive"
@ -231,6 +237,7 @@ exports[`Render should render groups table 1`] = `
}
>
<Button
disabled={false}
onClick={[Function]}
size="sm"
variant="destructive"

View File

@ -17,7 +17,9 @@ exports[`Render should render group sync page 1`] = `
<PageContents
isLoading={true}
>
<Connect(TeamGroupSync) />
<Connect(TeamGroupSync)
isReadOnly={false}
/>
</PageContents>
</Page>
`;

View File

@ -16,12 +16,14 @@ exports[`Render should render component 1`] = `
"permission": 0,
}
}
disabled={false}
onSubmit={[Function]}
>
<Component />
</Form>
</FieldSet>
<SharedPreferences
disabled={false}
resourceUri="teams/1"
/>
</VerticalGroup>

View File

@ -4,17 +4,21 @@ import { TeamMember, ThunkResult } from 'app/types';
import { updateNavIndex } from 'app/core/actions';
import { buildNavModel } from './navModel';
import { teamGroupsLoaded, teamLoaded, teamMembersLoaded, teamsLoaded } from './reducers';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
export function loadTeams(): ThunkResult<void> {
return async (dispatch) => {
const response = await getBackendSrv().get('/api/teams/search', { perpage: 1000, page: 1 });
const response = await getBackendSrv().get(
'/api/teams/search',
accessControlQueryParam({ perpage: 1000, page: 1 })
);
dispatch(teamsLoaded(response.teams));
};
}
export function loadTeam(id: number): ThunkResult<void> {
return async (dispatch) => {
const response = await getBackendSrv().get(`/api/teams/${id}`);
const response = await getBackendSrv().get(`/api/teams/${id}`, accessControlQueryParam());
dispatch(teamLoaded(response));
dispatch(updateNavIndex(buildNavModel(response)));
};

View File

@ -1,8 +1,18 @@
import { Team, TeamPermissionLevel } from 'app/types';
import { AccessControlAction, Team, TeamPermissionLevel } from 'app/types';
import { featureEnabled } from '@grafana/runtime';
import { NavModelItem, NavModel } from '@grafana/data';
import config from 'app/core/config';
import { ProBadge } from 'app/core/components/Upgrade/ProBadge';
import { contextSrv } from 'app/core/services/context_srv';
const loadingTeam = {
avatarUrl: 'public/img/user_profile.png',
id: 1,
name: 'Loading',
email: 'loading',
memberCount: 0,
permission: TeamPermissionLevel.Member,
};
export function buildNavModel(team: Team): NavModelItem {
const navModel: NavModelItem = {
@ -13,13 +23,8 @@ export function buildNavModel(team: Team): NavModelItem {
text: team.name,
breadcrumbs: [{ title: 'Teams', url: 'org/teams' }],
children: [
{
active: false,
icon: 'users-alt',
id: `team-members-${team.id}`,
text: 'Members',
url: `org/teams/edit/${team.id}/members`,
},
// With FGAC this tab will always be available (but not always editable)
// With Legacy it will be hidden by hideTabsFromNonTeamAdmin should the user not be allowed to see it
{
active: false,
icon: 'sliders-v-alt',
@ -30,6 +35,22 @@ export function buildNavModel(team: Team): NavModelItem {
],
};
// While team is loading we leave the members tab
// With FGAC the Members tab is available when user has ActionTeamsPermissionsRead for this team
// With Legacy it will always be present
if (
team === loadingTeam ||
contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsPermissionsRead, team)
) {
navModel.children!.unshift({
active: false,
icon: 'users-alt',
id: `team-members-${team.id}`,
text: 'Members',
url: `org/teams/edit/${team.id}/members`,
});
}
const teamGroupSync = {
active: false,
icon: 'sync',
@ -38,7 +59,13 @@ export function buildNavModel(team: Team): NavModelItem {
url: `org/teams/edit/${team.id}/groupsync`,
};
if (featureEnabled('teamsync')) {
// With both Legacy and FGAC the tab is protected being featureEnabled
// While team is loading we leave the teamsync tab
// With FGAC the External Group Sync tab is available when user has ActionTeamsPermissionsRead for this team
if (
featureEnabled('teamsync') &&
(team === loadingTeam || contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsPermissionsRead, team))
) {
navModel.children!.push(teamGroupSync);
} else if (config.featureToggles.featureHighlights) {
navModel.children!.push({ ...teamGroupSync, tabSuffix: ProBadge });
@ -48,14 +75,7 @@ export function buildNavModel(team: Team): NavModelItem {
}
export function getTeamLoadingNav(pageName: string): NavModel {
const main = buildNavModel({
avatarUrl: 'public/img/user_profile.png',
id: 1,
name: 'Loading',
email: 'loading',
memberCount: 0,
permission: TeamPermissionLevel.Member,
});
const main = buildNavModel(loadingTeam);
let node: NavModelItem;

View File

@ -207,18 +207,29 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/org/teams',
roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),
roles: () =>
contextSrv.evaluatePermission(
() => (config.editorsCanAdmin ? ['Editor', 'Admin'] : ['Admin']),
[AccessControlAction.ActionTeamsRead]
),
component: SafeDynamicImport(() => import(/* webpackChunkName: "TeamList" */ 'app/features/teams/TeamList')),
},
{
path: '/org/teams/new',
roles: () => (config.editorsCanAdmin ? [] : ['Admin']),
roles: () =>
contextSrv.evaluatePermission(
() => (config.editorsCanAdmin ? ['Editor', 'Admin'] : ['Admin']),
[AccessControlAction.ActionTeamsCreate]
),
component: SafeDynamicImport(() => import(/* webpackChunkName: "CreateTeam" */ 'app/features/teams/CreateTeam')),
},
{
path: '/org/teams/edit/:id/:page?',
roles: () => (config.editorsCanAdmin ? [] : ['Admin']),
roles: () =>
contextSrv.evaluatePermission(
() => (config.editorsCanAdmin ? ['Editor', 'Admin'] : ['Admin']),
[AccessControlAction.ActionTeamsWrite, AccessControlAction.ActionTeamsPermissionsWrite]
),
component: SafeDynamicImport(() => import(/* webpackChunkName: "TeamPages" */ 'app/features/teams/TeamPages')),
},
{

View File

@ -50,6 +50,11 @@ export enum AccessControlAction {
ActionServerStatsRead = 'server.stats:read',
ActionTeamsCreate = 'teams:create',
ActionTeamsDelete = 'teams:delete',
ActionTeamsRead = 'teams:read',
ActionTeamsWrite = 'teams:write',
ActionTeamsPermissionsRead = 'teams.permissions:read',
ActionTeamsPermissionsWrite = 'teams.permissions:write',
ActionRolesList = 'roles:list',
ActionBuiltinRolesList = 'roles.builtin:list',

View File

@ -1,6 +1,7 @@
import { WithAccessControlMetadata } from '@grafana/data';
import { TeamPermissionLevel } from './acl';
export interface Team {
export interface Team extends WithAccessControlMetadata {
id: number;
name: string;
avatarUrl: string;