diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/manage-users-teams/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/manage-users-teams/_index.md index bd8473be5a1..23a389d582e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/manage-users-teams/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/manage-users-teams/_index.md @@ -223,7 +223,7 @@ Team provisioning requires `group_sync_enabled = true` in the SCIM configuration {{< /admonition >}} {{< admonition type="warning" >}} -Teams provisioned through SCIM cannot be deleted manually from Grafana - they can only be deleted by removing their corresponding groups from the identity provider. +Teams provisioned through SCIM cannot be deleted manually from Grafana - they can only be deleted by removing their corresponding groups from the identity provider. Optionally, you can disable SCIM group sync to allow manual deletion of teams. {{< /admonition >}} For detailed configuration steps specific to the identity provider, see: diff --git a/pkg/services/team/teamapi/team.go b/pkg/services/team/teamapi/team.go index 9df49d0d66a..69436edd8cc 100644 --- a/pkg/services/team/teamapi/team.go +++ b/pkg/services/team/teamapi/team.go @@ -431,7 +431,8 @@ func (tapi *TeamAPI) validateTeam(c *contextmodel.ReqContext, teamID int64, prov return response.Error(http.StatusInternalServerError, "Failed to get Team", err) } - if teamDTO.IsProvisioned { + isGroupSyncEnabled := tapi.cfg.Raw.Section("auth.scim").Key("group_sync_enabled").MustBool(false) + if isGroupSyncEnabled && teamDTO.IsProvisioned { return response.Error(http.StatusBadRequest, provisionedMessage, err) } diff --git a/pkg/services/team/teamapi/team_members.go b/pkg/services/team/teamapi/team_members.go index 1303f97e028..e9f4d0ac91d 100644 --- a/pkg/services/team/teamapi/team_members.go +++ b/pkg/services/team/teamapi/team_members.go @@ -299,7 +299,8 @@ func (tapi *TeamAPI) removeTeamMember(c *contextmodel.ReqContext) response.Respo return response.Error(http.StatusInternalServerError, "Failed to get Team", err) } - if existingTeam.IsProvisioned { + isGroupSyncEnabled := tapi.cfg.Raw.Section("auth.scim").Key("group_sync_enabled").MustBool(false) + if isGroupSyncEnabled && existingTeam.IsProvisioned { return response.Error(http.StatusBadRequest, "Team memberships cannot be updated for provisioned teams", err) } diff --git a/pkg/services/team/teamapi/team_members_test.go b/pkg/services/team/teamapi/team_members_test.go index f14c01d1c73..65f8f2693ee 100644 --- a/pkg/services/team/teamapi/team_members_test.go +++ b/pkg/services/team/teamapi/team_members_test.go @@ -169,13 +169,15 @@ func TestUpdateTeamMembersAPIEndpoint(t *testing.T) { }) } -func TestUpdateTeamMembersFromProvisionedTeam(t *testing.T) { +func TestUpdateTeamMembersFromProvisionedTeamWhenGroupSyncIsEnabled(t *testing.T) { server := SetupAPITestServer(t, &teamtest.FakeService{ ExpectedIsMember: true, ExpectedTeamDTO: &team.TeamDTO{ID: 1, UID: "a00001", IsProvisioned: true}, + }, func(tapi *TeamAPI) { + tapi.cfg.Raw.Section("auth.scim").Key("group_sync_enabled").SetValue("true") }) - t.Run("should not be able to update team member from a provisioned team", func(t *testing.T) { + t.Run("should not be able to update team member from a provisioned team if team sync is enabled", 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"}}), @@ -186,7 +188,7 @@ func TestUpdateTeamMembersFromProvisionedTeam(t *testing.T) { 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) { + t.Run("should not be able to update team member from a provisioned team by team UID if team sync is enabled", 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"}}), @@ -198,6 +200,27 @@ func TestUpdateTeamMembersFromProvisionedTeam(t *testing.T) { }) } +func TestUpdateTeamMembersFromProvisionedTeamWhenGroupSyncIsDisabled(t *testing.T) { + t.Run("should be able to delete team member from a provisioned team when SCIM group sync is disabled", func(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{} + }) + + 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.StatusOK, res.StatusCode) + require.NoError(t, res.Body.Close()) + }) +} + func TestDeleteTeamMembersAPIEndpoint(t *testing.T) { server := SetupAPITestServer(t, nil, func(hs *TeamAPI) { hs.teamService = &teamtest.FakeService{ @@ -236,6 +259,8 @@ func TestDeleteTeamMembersFromProvisionedTeam(t *testing.T) { ExpectedTeamDTO: &team.TeamDTO{ID: 1, UID: "a00001", IsProvisioned: true}, } hs.teamPermissionsService = &actest.FakePermissionsService{} + }, func(hs *TeamAPI) { + hs.cfg.Raw.Section("auth.scim").Key("group_sync_enabled").SetValue("true") }) t.Run("should not be able to delete team member from a provisioned team", func(t *testing.T) { diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index b1a5952eca8..f08bcbe2658 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -5,6 +5,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; +import { config, getBackendSrv } from '@grafana/runtime'; import { Avatar, CellProps, @@ -65,6 +66,7 @@ export const TeamList = ({ changeSort, }: Props) => { const [roleOptions, setRoleOptions] = useState([]); + const [scimGroupSyncEnabled, setScimGroupSyncEnabled] = useState(false); const styles = useStyles2(getStyles); useEffect(() => { @@ -77,6 +79,25 @@ export const TeamList = ({ } }, []); + useEffect(() => { + const checkSCIMSettings = async () => { + if (!config.featureToggles.enableSCIM) { + setScimGroupSyncEnabled(false); + return; + } + try { + const scimSettings = await getBackendSrv().get( + `/apis/scim.grafana.app/v0alpha1/namespaces/${config.namespace}/config` + ); + setScimGroupSyncEnabled(scimSettings?.items[0]?.spec?.enableGroupSync || false); + } catch { + setScimGroupSyncEnabled(false); + } + }; + + checkSCIMSettings(); + }, []); + const canCreate = contextSrv.hasPermission(AccessControlAction.ActionTeamsCreate); const displayRolePicker = shouldDisplayRolePicker(); @@ -198,7 +219,7 @@ export const TeamList = ({ const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, original); const canDelete = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsDelete, original) && - !original.isProvisioned; + (!scimGroupSyncEnabled || !original.isProvisioned); return ( {canReadTeam && ( @@ -226,7 +247,7 @@ export const TeamList = ({ }, }, ], - [displayRolePicker, hasFetched, rolesLoading, roleOptions, deleteTeam, styles] + [displayRolePicker, hasFetched, rolesLoading, roleOptions, deleteTeam, styles, scimGroupSyncEnabled] ); return (