diff --git a/pkg/services/accesscontrol/ossaccesscontrol/team.go b/pkg/services/accesscontrol/ossaccesscontrol/team.go index a2635987fb3..2cbeb4e0174 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/team.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/team.go @@ -2,6 +2,7 @@ package ossaccesscontrol import ( "context" + "errors" "fmt" "strconv" @@ -54,7 +55,7 @@ func ProvideTeamPermissions( return err } - _, err = teamService.GetTeamByID(ctx, &team.GetTeamByIDQuery{ + existingTeam, err := teamService.GetTeamByID(ctx, &team.GetTeamByIDQuery{ OrgID: orgID, ID: id, }) @@ -62,6 +63,10 @@ func ProvideTeamPermissions( return err } + if existingTeam.IsProvisioned { + return errors.New("team permissions cannot be updated for provisioned teams") + } + return nil }, Assignments: resourcepermissions.Assignments{ diff --git a/pkg/services/team/teamapi/team.go b/pkg/services/team/teamapi/team.go index 902bfd8eb30..97bb70e5d47 100644 --- a/pkg/services/team/teamapi/team.go +++ b/pkg/services/team/teamapi/team.go @@ -89,6 +89,19 @@ func (tapi *TeamAPI) updateTeam(c *contextmodel.ReqContext) response.Response { return response.Error(http.StatusBadRequest, "teamId is invalid", err) } + existingTeam, err := tapi.getTeamDTOByID(c, cmd.ID) + if err != nil { + if errors.Is(err, team.ErrTeamNotFound) { + return response.Error(http.StatusNotFound, "Team not found", err) + } + + return response.Error(http.StatusInternalServerError, "Failed to get Team", err) + } + + if existingTeam.IsProvisioned && existingTeam.Name != cmd.Name { + return response.Error(http.StatusBadRequest, "Team name cannot be changed for provisioned teams", err) + } + if err := tapi.teamService.UpdateTeam(c.Req.Context(), &cmd); err != nil { if errors.Is(err, team.ErrTeamNameTaken) { return response.Error(http.StatusBadRequest, "Team name taken", err) @@ -116,6 +129,11 @@ func (tapi *TeamAPI) deleteTeamByID(c *contextmodel.ReqContext) response.Respons return response.Error(http.StatusBadRequest, "teamId is invalid", err) } + resp := tapi.validateTeam(c, teamID, "Cannot delete provisioned teams") + if resp != nil { + return resp + } + if err := tapi.teamService.DeleteTeam(c.Req.Context(), &team.DeleteTeamCommand{OrgID: orgID, ID: teamID}); err != nil { if errors.Is(err, team.ErrTeamNotFound) { return response.Error(http.StatusNotFound, "Failed to delete Team. ID not found", nil) @@ -392,3 +410,30 @@ func (tapi *TeamAPI) getAccessControlMetadata(c *contextmodel.ReqContext, ids := map[string]bool{resourceID: true} return tapi.getMultiAccessControlMetadata(c, prefix, ids)[resourceID] } + +func (tapi *TeamAPI) getTeamDTOByID(c *contextmodel.ReqContext, teamID int64) (*team.TeamDTO, error) { + query := team.GetTeamByIDQuery{ + OrgID: c.SignedInUser.GetOrgID(), + ID: teamID, + SignedInUser: c.SignedInUser, + } + + return tapi.teamService.GetTeamByID(c.Req.Context(), &query) +} + +func (tapi *TeamAPI) validateTeam(c *contextmodel.ReqContext, teamID int64, provisionedMessage string) *response.NormalResponse { + teamDTO, err := tapi.getTeamDTOByID(c, teamID) + if err != nil { + if errors.Is(err, team.ErrTeamNotFound) { + return response.Error(http.StatusNotFound, "Team not found", err) + } + + return response.Error(http.StatusInternalServerError, "Failed to get Team", err) + } + + if teamDTO.IsProvisioned { + return response.Error(http.StatusBadRequest, provisionedMessage, err) + } + + return nil +} diff --git a/pkg/services/team/teamapi/team_members.go b/pkg/services/team/teamapi/team_members.go index c2b796aaf58..05486473323 100644 --- a/pkg/services/team/teamapi/team_members.go +++ b/pkg/services/team/teamapi/team_members.go @@ -83,6 +83,11 @@ func (tapi *TeamAPI) addTeamMember(c *contextmodel.ReqContext) response.Response return response.Error(http.StatusBadRequest, "teamId is invalid", err) } + resp := tapi.validateTeam(c, teamID, "Team memberships cannot be updated for provisioned teams") + if resp != nil { + return resp + } + isTeamMember, err := tapi.teamService.IsTeamMember(c.Req.Context(), c.SignedInUser.GetOrgID(), teamID, cmd.UserID) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to add team member.", err) @@ -129,6 +134,11 @@ func (tapi *TeamAPI) updateTeamMember(c *contextmodel.ReqContext) response.Respo } orgId := c.SignedInUser.GetOrgID() + resp := tapi.validateTeam(c, teamId, "Team memberships cannot be updated for provisioned teams") + if resp != nil { + return resp + } + isTeamMember, err := tapi.teamService.IsTeamMember(c.Req.Context(), orgId, teamId, userId) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to update team member.", err) @@ -168,6 +178,11 @@ func (tapi *TeamAPI) setTeamMemberships(c *contextmodel.ReqContext) response.Res } orgId := c.SignedInUser.GetOrgID() + resp := tapi.validateTeam(c, teamId, "Team memberships cannot be updated for provisioned teams") + if resp != nil { + return resp + } + teamMemberships, err := tapi.getTeamMembershipUpdates(c.Req.Context(), orgId, teamId, cmd, c.SignedInUser) if err != nil { if errors.Is(err, user.ErrUserNotFound) || errors.Is(err, team.ErrTeamNotFound) { @@ -275,6 +290,19 @@ func (tapi *TeamAPI) removeTeamMember(c *contextmodel.ReqContext) response.Respo return response.Error(http.StatusBadRequest, "userId is invalid", err) } + existingTeam, err := tapi.getTeamDTOByID(c, teamId) + if err != nil { + if errors.Is(err, team.ErrTeamNotFound) { + return response.Error(http.StatusNotFound, "Team not found", err) + } + + return response.Error(http.StatusInternalServerError, "Failed to get Team", err) + } + + if existingTeam.IsProvisioned { + return response.Error(http.StatusBadRequest, "Team memberships cannot be updated for provisioned teams", err) + } + teamIDString := strconv.FormatInt(teamId, 10) if _, err := tapi.teamPermissionsService.SetUserPermission(c.Req.Context(), orgId, accesscontrol.User{ID: userId}, teamIDString, ""); err != nil { if errors.Is(err, team.ErrTeamNotFound) { diff --git a/pkg/services/team/teamapi/team_members_test.go b/pkg/services/team/teamapi/team_members_test.go index 23c241434fb..f14c01d1c73 100644 --- a/pkg/services/team/teamapi/team_members_test.go +++ b/pkg/services/team/teamapi/team_members_test.go @@ -169,9 +169,41 @@ func TestUpdateTeamMembersAPIEndpoint(t *testing.T) { }) } +func TestUpdateTeamMembersFromProvisionedTeam(t *testing.T) { + server := SetupAPITestServer(t, &teamtest.FakeService{ + ExpectedIsMember: true, + ExpectedTeamDTO: &team.TeamDTO{ID: 1, UID: "a00001", IsProvisioned: true}, + }) + + t.Run("should not be able to update team member from a provisioned team", func(t *testing.T) { + req := webtest.RequestWithSignedInUser( + server.NewRequest(http.MethodPut, "/api/teams/1/members/1", strings.NewReader("{\"permission\": 1}")), + authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: "teams:id:1"}}), + ) + res, err := server.SendJSON(req) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, res.StatusCode) + require.NoError(t, res.Body.Close()) + }) + + t.Run("should not be able to update team member from a provisioned team by team UID", func(t *testing.T) { + req := webtest.RequestWithSignedInUser( + server.NewRequest(http.MethodPut, "/api/teams/a00001/members/1", strings.NewReader("{\"permission\": 1}")), + authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: "teams:id:1"}}), + ) + res, err := server.SendJSON(req) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, res.StatusCode) + require.NoError(t, res.Body.Close()) + }) +} + func TestDeleteTeamMembersAPIEndpoint(t *testing.T) { server := SetupAPITestServer(t, nil, func(hs *TeamAPI) { - hs.teamService = &teamtest.FakeService{ExpectedIsMember: true} + hs.teamService = &teamtest.FakeService{ + ExpectedIsMember: true, + ExpectedTeamDTO: &team.TeamDTO{ID: 1, UID: "a00001"}, + } hs.teamPermissionsService = &actest.FakePermissionsService{} }) @@ -197,6 +229,27 @@ func TestDeleteTeamMembersAPIEndpoint(t *testing.T) { }) } +func TestDeleteTeamMembersFromProvisionedTeam(t *testing.T) { + server := SetupAPITestServer(t, nil, func(hs *TeamAPI) { + hs.teamService = &teamtest.FakeService{ + ExpectedIsMember: true, + ExpectedTeamDTO: &team.TeamDTO{ID: 1, UID: "a00001", IsProvisioned: true}, + } + hs.teamPermissionsService = &actest.FakePermissionsService{} + }) + + t.Run("should not be able to delete team member from a provisioned team", func(t *testing.T) { + req := webtest.RequestWithSignedInUser( + server.NewRequest(http.MethodDelete, "/api/teams/1/members/1", nil), + authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: "teams:id:1"}}), + ) + res, err := server.SendJSON(req) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, res.StatusCode) + require.NoError(t, res.Body.Close()) + }) +} + func Test_getTeamMembershipUpdates(t *testing.T) { type testCase struct { description string diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index 135151d4b7f..df9c99f210a 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -16,6 +16,7 @@ import { LinkButton, Pagination, Stack, + Tag, TextLink, useStyles2, } from '@grafana/ui'; @@ -43,6 +44,7 @@ const skeletonData: TeamWithRoles[] = new Array(3).fill(null).map((_, index) => memberCount: 0, name: '', orgId: 0, + isProvisioned: false, })); export const TeamList = ({ @@ -166,6 +168,16 @@ export const TeamList = ({ }, ] : []), + { + id: 'isProvisioned', + header: '', + cell: ({ cell: { value } }: Cell<'isProvisioned'>) => { + if (!hasFetched) { + return ; + } + return !!value && + }, + }, { id: 'actions', header: '', @@ -181,7 +193,7 @@ export const TeamList = ({ } const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, original); - const canDelete = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsDelete, original); + const canDelete = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsDelete, original) && !original.isProvisioned; return ( {canReadTeam && ( diff --git a/public/app/features/teams/TeamPermissions.tsx b/public/app/features/teams/TeamPermissions.tsx index ac7873b5ffb..349b2531dab 100644 --- a/public/app/features/teams/TeamPermissions.tsx +++ b/public/app/features/teams/TeamPermissions.tsx @@ -9,11 +9,15 @@ type TeamPermissionsProps = { // TeamPermissions component replaces TeamMembers component when the accesscontrol feature flag is set const TeamPermissions = (props: TeamPermissionsProps) => { - const canSetPermissions = contextSrv.hasPermissionInMetadata( + let canSetPermissions = contextSrv.hasPermissionInMetadata( AccessControlAction.ActionTeamsPermissionsWrite, props.team ); + if (props.team.isProvisioned) { + canSetPermissions = false; + } + return ( { { memberCount: i, accessControl: { isEditor: false }, orgId: 0, + isProvisioned: false, ...overrides, }; }; diff --git a/public/app/features/teams/state/navModel.ts b/public/app/features/teams/state/navModel.ts index 5205e8aac98..4b225312b40 100644 --- a/public/app/features/teams/state/navModel.ts +++ b/public/app/features/teams/state/navModel.ts @@ -17,6 +17,7 @@ const loadingTeam = { accessControl: { isEditor: false }, orgId: 0, updated: '', + isProvisioned: false, }; export function buildNavModel(team: Team): NavModelItem { diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts index 26f4e6f3aa7..123954636bc 100644 --- a/public/app/types/teams.ts +++ b/public/app/types/teams.ts @@ -44,6 +44,10 @@ export interface Team extends WithAccessControlMetadata { * OrgId is the ID of an organisation the team belongs to. */ orgId: number; + /** + * isProvisioned is set if the team has been provisioned from IdP. + */ + isProvisioned: boolean; } export interface TeamWithRoles extends Team {