diff --git a/pkg/api/api.go b/pkg/api/api.go index ea6c3859c08..32c0fbe88ce 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -58,8 +58,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) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index ed2e956e096..d5956922e28 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -309,9 +309,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(), } } diff --git a/pkg/api/index.go b/pkg/api/index.go index 25565347c10..da0c9c45bf1 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -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", diff --git a/pkg/api/roles.go b/pkg/api/roles.go index e8f3fc9fe53..0fc5d75723e 100644 --- a/pkg/api/roles.go +++ b/pkg/api/roles.go @@ -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), + ), +) diff --git a/pkg/api/team.go b/pkg/api/team.go index c695db38b4c..b59db581f49 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -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/services/sqlstore" "github.com/grafana/grafana/pkg/util" @@ -105,6 +107,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") @@ -136,8 +152,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 @@ -146,6 +171,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) @@ -167,6 +209,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) } diff --git a/pkg/models/team.go b/pkg/models/team.go index 328e1815b90..38f434458af 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -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 { diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index 507ebcaf60f..bf719821487 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -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()) { diff --git a/public/app/features/teams/TeamGroupSync.test.tsx b/public/app/features/teams/TeamGroupSync.test.tsx index f3deb62c77b..6ba6f050278 100644 --- a/public/app/features/teams/TeamGroupSync.test.tsx +++ b/public/app/features/teams/TeamGroupSync.test.tsx @@ -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(), diff --git a/public/app/features/teams/TeamGroupSync.tsx b/public/app/features/teams/TeamGroupSync.tsx index aeedfb12f16..a550b0cd9df 100644 --- a/public/app/features/teams/TeamGroupSync.tsx +++ b/public/app/features/teams/TeamGroupSync.tsx @@ -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; +export type Props = OwnProps & ConnectedProps; const headerTooltip = `Sync LDAP or OAuth groups with your Grafana teams.`; @@ -70,11 +74,12 @@ export class TeamGroupSync extends PureComponent { } renderGroup(group: TeamGroup) { + const { isReadOnly } = this.props; return ( {group.groupId} - @@ -84,7 +89,7 @@ export class TeamGroupSync extends PureComponent { render() { const { isAdding, newGroupId } = this.state; - const groups = this.props.groups; + const { groups, isReadOnly } = this.props; return (
@@ -95,7 +100,7 @@ export class TeamGroupSync extends PureComponent {
{groups.length > 0 && ( - )} @@ -113,11 +118,12 @@ export class TeamGroupSync extends PureComponent { value={newGroupId} onChange={this.onNewGroupIdChanged} placeholder="cn=ops,ou=groups,dc=grafana,dc=org" + disabled={isReadOnly} />
-
@@ -135,6 +141,7 @@ export class TeamGroupSync extends PureComponent { proTipLinkTitle="Learn more" proTipLink="http://docs.grafana.org/auth/enhanced_ldap/" proTipTarget="_blank" + buttonDisabled={isReadOnly} /> )} diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index 9c40f286826..83894dc5e46 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -69,7 +69,11 @@ export class TeamList extends PureComponent { 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 ( diff --git a/public/app/features/teams/TeamPages.test.tsx b/public/app/features/teams/TeamPages.test.tsx index 8f5fa7ec666..c4f852b2bf6 100644 --- a/public/app/features/teams/TeamPages.test.tsx +++ b/public/app/features/teams/TeamPages.test.tsx @@ -13,6 +13,7 @@ jest.mock('@grafana/runtime/src/config', () => ({ licenseInfo: { enabledFeatures: { teamsync: true }, }, + featureToggles: { accesscontrol: false }, }, })); diff --git a/public/app/features/teams/TeamPages.tsx b/public/app/features/teams/TeamPages.tsx index 9817ddefe5b..65430e5e898 100644 --- a/public/app/features/teams/TeamPages.tsx +++ b/public/app/features/teams/TeamPages.tsx @@ -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 { 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 { }; 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 { 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 ; - + if (contextSrv.accessControlEnabled()) { + return ; + } else { + return ; + } case PageTypes.Settings: - return isSignedInUserTeamAdmin && ; + return canReadTeam && ; case PageTypes.GroupSync: - if (isSignedInUserTeamAdmin && isSyncEnabled) { - return ; + if (canReadTeamPermissions && isSyncEnabled) { + return ; } else if (config.featureToggles.featureHighlights) { return ( { + const canListUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead); + const canSetPermissions = contextSrv.hasPermissionInMetadata( + AccessControlAction.ActionTeamsPermissionsWrite, + props.team + ); + + return ( + + ); +}; + +export default TeamPermissions; diff --git a/public/app/features/teams/TeamSettings.test.tsx b/public/app/features/teams/TeamSettings.test.tsx index b261116718e..4d4cfc0e83c 100644 --- a/public/app/features/teams/TeamSettings.test.tsx +++ b/public/app/features/teams/TeamSettings.test.tsx @@ -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(), diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx index 8ef7dd288f5..1d34aee41e9 100644 --- a/public/app/features/teams/TeamSettings.tsx +++ b/public/app/features/teams/TeamSettings.tsx @@ -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 & OwnProps; export const TeamSettings: FC = ({ team, updateTeam }) => { + const canWriteTeamSettings = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsWrite, team); + return (
@@ -26,6 +29,7 @@ export const TeamSettings: FC = ({ team, updateTeam }) => { onSubmit={(formTeam: Team) => { updateTeam(formTeam.name, formTeam.email); }} + disabled={!canWriteTeamSettings} > {({ register }) => ( <> @@ -44,7 +48,7 @@ export const TeamSettings: FC = ({ team, updateTeam }) => { )}
- +
); }; diff --git a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap index 35719d0908d..3abd26f7778 100644 --- a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap @@ -44,6 +44,7 @@ exports[`Render should render component 1`] = ` >