From: Wojtek Wajerowicz <115081248+wojciech-wajerowicz-sonarsource@users.noreply.github.com> Date: Tue, 21 Nov 2023 10:59:19 +0000 (+0100) Subject: SONAR-21058 API v2 GET /authorizations/groups/{groupId} X-Git-Tag: 10.4.0.87286~438 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=6918599a8059b3c561ada4f26817b559c12e687e;p=sonarqube.git SONAR-21058 API v2 GET /authorizations/groups/{groupId} --- diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/GroupService.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/GroupService.java new file mode 100644 index 00000000000..4e28d96fcfd --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/GroupService.java @@ -0,0 +1,179 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.common.group.service; + +import java.util.Optional; +import javax.annotation.Nullable; +import org.sonar.api.security.DefaultGroups; +import org.sonar.api.server.ServerSide; +import org.sonar.api.user.UserGroupValidation; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.user.GroupDto; +import org.sonar.server.exceptions.BadRequestException; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.sonar.server.exceptions.BadRequestException.checkRequest; + +@ServerSide +public class GroupService { + + private final DbClient dbClient; + private final UuidFactory uuidFactory; + + public GroupService(DbClient dbClient, UuidFactory uuidFactory) { + this.dbClient = dbClient; + this.uuidFactory = uuidFactory; + } + + public Optional findGroup(DbSession dbSession, String groupName) { + return dbClient.groupDao().selectByName(dbSession, groupName); + } + + public Optional findGroupByUuid(DbSession dbSession, String groupUuid) { + return Optional.ofNullable(dbClient.groupDao().selectByUuid(dbSession, groupUuid)); + } + + public void delete(DbSession dbSession, GroupDto group) { + checkGroupIsNotDefault(dbSession, group); + checkNotTryingToDeleteLastAdminGroup(dbSession, group); + + removeGroupPermissions(dbSession, group); + removeGroupFromPermissionTemplates(dbSession, group); + removeGroupMembers(dbSession, group); + removeGroupFromQualityProfileEdit(dbSession, group); + removeGroupFromQualityGateEdit(dbSession, group); + removeGroupScimLink(dbSession, group); + removeExternalGroupMapping(dbSession, group); + removeGithubOrganizationGroup(dbSession, group); + + removeGroup(dbSession, group); + } + + public GroupDto updateGroup(DbSession dbSession, GroupDto group, @Nullable String newName) { + checkGroupIsNotDefault(dbSession, group); + return updateName(dbSession, group, newName); + } + + public GroupDto updateGroup(DbSession dbSession, GroupDto group, @Nullable String newName, @Nullable String newDescription) { + checkGroupIsNotDefault(dbSession, group); + GroupDto withUpdatedName = updateName(dbSession, group, newName); + return updateDescription(dbSession, withUpdatedName, newDescription); + } + + public GroupDto createGroup(DbSession dbSession, String name, @Nullable String description) { + validateGroupName(name); + checkNameDoesNotExist(dbSession, name); + + GroupDto group = new GroupDto() + .setUuid(uuidFactory.create()) + .setName(name) + .setDescription(description); + return dbClient.groupDao().insert(dbSession, group); + } + + private GroupDto updateName(DbSession dbSession, GroupDto group, @Nullable String newName) { + if (newName != null && !newName.equals(group.getName())) { + validateGroupName(newName); + checkNameDoesNotExist(dbSession, newName); + group.setName(newName); + return dbClient.groupDao().update(dbSession, group); + } + return group; + } + + private static void validateGroupName(String name) { + try { + UserGroupValidation.validateGroupName(name); + } catch (IllegalArgumentException e) { + BadRequestException.throwBadRequestException(e.getMessage()); + } + } + + private void checkNameDoesNotExist(DbSession dbSession, String name) { + // There is no database constraint on column groups.name + // because MySQL cannot create a unique index + // on a UTF-8 VARCHAR larger than 255 characters on InnoDB + checkRequest(!dbClient.groupDao().selectByName(dbSession, name).isPresent(), "Group '%s' already exists", name); + } + + private GroupDto updateDescription(DbSession dbSession, GroupDto group, @Nullable String newDescription) { + if (newDescription != null) { + group.setDescription(newDescription); + return dbClient.groupDao().update(dbSession, group); + } + return group; + } + + private void checkGroupIsNotDefault(DbSession dbSession, GroupDto groupDto) { + GroupDto defaultGroup = findDefaultGroup(dbSession); + checkArgument(!defaultGroup.getUuid().equals(groupDto.getUuid()), "Default group '%s' cannot be used to perform this action", groupDto.getName()); + } + + private GroupDto findDefaultGroup(DbSession dbSession) { + return dbClient.groupDao().selectByName(dbSession, DefaultGroups.USERS) + .orElseThrow(() -> new IllegalStateException("Default group cannot be found")); + } + + private void checkNotTryingToDeleteLastAdminGroup(DbSession dbSession, GroupDto group) { + int remaining = dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroup(dbSession, + GlobalPermission.ADMINISTER.getKey(), group.getUuid()); + + checkArgument(remaining > 0, "The last system admin group cannot be deleted"); + } + + private void removeGroupPermissions(DbSession dbSession, GroupDto group) { + dbClient.roleDao().deleteGroupRolesByGroupUuid(dbSession, group.getUuid()); + } + + private void removeGroupFromPermissionTemplates(DbSession dbSession, GroupDto group) { + dbClient.permissionTemplateDao().deleteByGroup(dbSession, group.getUuid(), group.getName()); + } + + private void removeGroupMembers(DbSession dbSession, GroupDto group) { + dbClient.userGroupDao().deleteByGroupUuid(dbSession, group.getUuid(), group.getName()); + } + + private void removeGroupFromQualityProfileEdit(DbSession dbSession, GroupDto group) { + dbClient.qProfileEditGroupsDao().deleteByGroup(dbSession, group); + } + + private void removeGroupFromQualityGateEdit(DbSession dbSession, GroupDto group) { + dbClient.qualityGateGroupPermissionsDao().deleteByGroup(dbSession, group); + } + + private void removeGroupScimLink(DbSession dbSession, GroupDto group) { + dbClient.scimGroupDao().deleteByGroupUuid(dbSession, group.getUuid()); + } + + private void removeExternalGroupMapping(DbSession dbSession, GroupDto group) { + dbClient.externalGroupDao().deleteByGroupUuid(dbSession, group.getUuid()); + } + private void removeGithubOrganizationGroup(DbSession dbSession, GroupDto group) { + dbClient.githubOrganizationGroupDao().deleteByGroupUuid(dbSession, group.getUuid()); + } + + private void removeGroup(DbSession dbSession, GroupDto group) { + dbClient.groupDao().deleteByUuid(dbSession, group.getUuid(), group.getName()); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/package-info.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/package-info.java new file mode 100644 index 00000000000..3e126243ab7 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.common.group.service; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/group/service/GroupServiceTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/group/service/GroupServiceTest.java new file mode 100644 index 00000000000..f64f4e1c200 --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/group/service/GroupServiceTest.java @@ -0,0 +1,357 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.common.group.service; + +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.Optional; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.sonar.api.security.DefaultGroups; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.permission.AuthorizationDao; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.permission.template.PermissionTemplateDao; +import org.sonar.db.provisioning.GithubOrganizationGroupDao; +import org.sonar.db.qualitygate.QualityGateGroupPermissionsDao; +import org.sonar.db.qualityprofile.QProfileEditGroupsDao; +import org.sonar.db.scim.ScimGroupDao; +import org.sonar.db.user.ExternalGroupDao; +import org.sonar.db.user.GroupDao; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.RoleDao; +import org.sonar.db.user.UserGroupDao; +import org.sonar.server.exceptions.BadRequestException; + +import static java.lang.String.format; +import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(DataProviderRunner.class) +public class GroupServiceTest { + + private static final String GROUP_NAME = "GROUP_NAME"; + private static final String GROUP_UUID = "GROUP_UUID"; + private static final String DEFAULT_GROUP_NAME = "sonar-users"; + private static final String DEFAULT_GROUP_UUID = "DEFAULT_GROUP_UUID"; + @Mock + private DbSession dbSession; + @Mock + private DbClient dbClient; + @Mock + private UuidFactory uuidFactory; + @InjectMocks + private GroupService groupService; + + @Rule + public MockitoRule rule = MockitoJUnit.rule(); + + @Before + public void setUp() { + mockNeededDaos(); + } + + private void mockNeededDaos() { + when(dbClient.authorizationDao()).thenReturn(mock(AuthorizationDao.class)); + when(dbClient.roleDao()).thenReturn(mock(RoleDao.class)); + when(dbClient.permissionTemplateDao()).thenReturn(mock(PermissionTemplateDao.class)); + when(dbClient.userGroupDao()).thenReturn(mock(UserGroupDao.class)); + when(dbClient.qProfileEditGroupsDao()).thenReturn(mock(QProfileEditGroupsDao.class)); + when(dbClient.qualityGateGroupPermissionsDao()).thenReturn(mock(QualityGateGroupPermissionsDao.class)); + when(dbClient.scimGroupDao()).thenReturn(mock(ScimGroupDao.class)); + when(dbClient.externalGroupDao()).thenReturn(mock(ExternalGroupDao.class)); + when(dbClient.groupDao()).thenReturn(mock(GroupDao.class)); + when(dbClient.githubOrganizationGroupDao()).thenReturn(mock(GithubOrganizationGroupDao.class)); + } + + @Test + public void findGroup_whenGroupExists_returnsIt() { + GroupDto groupDto = mockGroupDto(); + + when(dbClient.groupDao().selectByName(dbSession, GROUP_NAME)) + .thenReturn(Optional.of(groupDto)); + + assertThat(groupService.findGroup(dbSession, GROUP_NAME)).contains(groupDto); + } + + @Test + public void findGroup_whenGroupDoesntExist_returnsEmtpyOptional() { + when(dbClient.groupDao().selectByName(dbSession, GROUP_NAME)) + .thenReturn(Optional.empty()); + + assertThat(groupService.findGroup(dbSession, GROUP_NAME)).isEmpty(); + } + + @Test + public void findGroupByUuid_whenGroupExists_returnsIt() { + GroupDto groupDto = mockGroupDto(); + + when(dbClient.groupDao().selectByUuid(dbSession, GROUP_UUID)) + .thenReturn(groupDto); + + assertThat(groupService.findGroupByUuid(dbSession, GROUP_UUID)).contains(groupDto); + } + + @Test + public void findGroupByUuid_whenGroupDoesntExist_returnsEmptyOptional() { + when(dbClient.groupDao().selectByUuid(dbSession, GROUP_UUID)) + .thenReturn(null); + + assertThat(groupService.findGroupByUuid(dbSession, GROUP_UUID)).isEmpty(); + } + + @Test + public void delete_whenNotDefaultAndNotLastAdminGroup_deleteGroup() { + GroupDto groupDto = mockGroupDto(); + + when(dbClient.groupDao().selectByName(dbSession, DefaultGroups.USERS)) + .thenReturn(Optional.of(new GroupDto().setUuid("another_group_uuid"))); + when(dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroup(dbSession, GlobalPermission.ADMINISTER.getKey(), groupDto.getUuid())) + .thenReturn(2); + + groupService.delete(dbSession, groupDto); + + verifyGroupDelete(dbSession, groupDto); + } + + @Test + public void delete_whenDefaultGroup_throwAndDontDeleteGroup() { + GroupDto groupDto = mockGroupDto(); + + when(dbClient.groupDao().selectByName(dbSession, DefaultGroups.USERS)) + .thenReturn(Optional.of(groupDto)); + + assertThatThrownBy(() -> groupService.delete(dbSession, groupDto)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Default group '%s' cannot be used to perform this action", GROUP_NAME)); + + verifyNoGroupDelete(dbSession, groupDto); + } + + @Test + public void delete_whenLastAdminGroup_throwAndDontDeleteGroup() { + GroupDto groupDto = mockGroupDto(); + + when(dbClient.groupDao().selectByName(dbSession, DefaultGroups.USERS)) + .thenReturn(Optional.of(new GroupDto().setUuid("another_group_uuid"))); // We must pass the default group check + when(dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroup(dbSession, GlobalPermission.ADMINISTER.getKey(), groupDto.getUuid())) + .thenReturn(0); + + assertThatThrownBy(() -> groupService.delete(dbSession, groupDto)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("The last system admin group cannot be deleted"); + + verifyNoGroupDelete(dbSession, groupDto); + } + + @Test + public void updateGroup_updatesGroupNameAndDescription() { + GroupDto group = mockGroupDto(); + GroupDto groupWithUpdatedName = mockGroupDto(); + mockDefaultGroup(); + when(dbClient.groupDao().update(dbSession, group)).thenReturn(groupWithUpdatedName); + + groupService.updateGroup(dbSession, group, "new-name", "New Description"); + verify(group).setName("new-name"); + verify(groupWithUpdatedName).setDescription("New Description"); + verify(dbClient.groupDao()).update(dbSession, group); + verify(dbClient.groupDao()).update(dbSession, groupWithUpdatedName); + } + + @Test + public void updateGroup_updatesGroupName() { + GroupDto group = mockGroupDto(); + mockDefaultGroup(); + + groupService.updateGroup(dbSession, group, "new-name"); + verify(group).setName("new-name"); + verify(dbClient.groupDao()).update(dbSession, group); + } + + @Test + public void updateGroup_whenGroupIsDefault_throws() { + GroupDto defaultGroup = mockDefaultGroup(); + when(dbClient.groupDao().selectByName(dbSession, DEFAULT_GROUP_NAME)).thenReturn(Optional.of(defaultGroup)); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> groupService.updateGroup(dbSession, defaultGroup, "new-name", "New Description")) + .withMessage("Default group 'sonar-users' cannot be used to perform this action"); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> groupService.updateGroup(dbSession, defaultGroup, "new-name")) + .withMessage("Default group 'sonar-users' cannot be used to perform this action"); + } + + @Test + public void updateGroup_whenGroupNameDoesntChange_succeedsWithDescription() { + GroupDto group = mockGroupDto(); + mockDefaultGroup(); + + groupService.updateGroup(dbSession, group, group.getName(), "New Description"); + verify(group).setDescription("New Description"); + verify(dbClient.groupDao()).update(dbSession, group); + } + + @Test + public void updateGroup_whenGroupNameDoesntChange_succeeds() { + GroupDto group = mockGroupDto(); + mockDefaultGroup(); + + assertThatNoException() + .isThrownBy(() -> groupService.updateGroup(dbSession, group, group.getName())); + + verify(dbClient.groupDao(), never()).update(dbSession, group); + } + + @Test + public void updateGroup_whenGroupExist_throws() { + GroupDto group = mockGroupDto(); + GroupDto group2 = mockGroupDto(); + mockDefaultGroup(); + String group2Name = GROUP_NAME + "2"; + + when(dbClient.groupDao().selectByName(dbSession, group2Name)).thenReturn(Optional.of(group2)); + + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(() -> groupService.updateGroup(dbSession, group, group2Name, "New Description")) + .withMessage("Group '" + group2Name + "' already exists"); + + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(() -> groupService.updateGroup(dbSession, group, group2Name)) + .withMessage("Group '" + group2Name + "' already exists"); + } + + @Test + @UseDataProvider("invalidGroupNames") + public void updateGroup_whenGroupNameIsInvalid_throws(String groupName, String errorMessage) { + GroupDto group = mockGroupDto(); + mockDefaultGroup(); + + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(() -> groupService.updateGroup(dbSession, group, groupName, "New Description")) + .withMessage(errorMessage); + + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(() -> groupService.updateGroup(dbSession, group, groupName)) + .withMessage(errorMessage); + } + + @Test + public void createGroup_whenNameAndDescriptionIsProvided_createsGroup() { + + when(uuidFactory.create()).thenReturn("1234"); + groupService.createGroup(dbSession, "Name", "Description"); + + ArgumentCaptor groupCaptor = ArgumentCaptor.forClass(GroupDto.class); + verify(dbClient.groupDao()).insert(eq(dbSession), groupCaptor.capture()); + GroupDto createdGroup = groupCaptor.getValue(); + assertThat(createdGroup.getName()).isEqualTo("Name"); + assertThat(createdGroup.getDescription()).isEqualTo("Description"); + assertThat(createdGroup.getUuid()).isEqualTo("1234"); + } + + @Test + public void createGroup_whenGroupExist_throws() { + GroupDto group = mockGroupDto(); + + when(dbClient.groupDao().selectByName(dbSession, GROUP_NAME)).thenReturn(Optional.of(group)); + + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(() -> groupService.createGroup(dbSession, GROUP_NAME, "New Description")) + .withMessage("Group '" + GROUP_NAME + "' already exists"); + + } + + @Test + @UseDataProvider("invalidGroupNames") + public void createGroup_whenGroupNameIsInvalid_throws(String groupName, String errorMessage) { + mockDefaultGroup(); + + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(() -> groupService.createGroup(dbSession, groupName, "Description")) + .withMessage(errorMessage); + + } + + @DataProvider + public static Object[][] invalidGroupNames() { + return new Object[][] { + {"", "Group name cannot be empty"}, + {randomAlphanumeric(256), "Group name cannot be longer than 255 characters"}, + {"Anyone", "Anyone group cannot be used"}, + }; + } + + private static GroupDto mockGroupDto() { + GroupDto groupDto = mock(GroupDto.class); + when(groupDto.getName()).thenReturn(GROUP_NAME); + when(groupDto.getUuid()).thenReturn(GROUP_UUID); + return groupDto; + } + + private GroupDto mockDefaultGroup() { + GroupDto defaultGroup = mock(GroupDto.class); + when(defaultGroup.getName()).thenReturn(DEFAULT_GROUP_NAME); + when(defaultGroup.getUuid()).thenReturn(DEFAULT_GROUP_UUID); + when(dbClient.groupDao().selectByName(dbSession, DEFAULT_GROUP_NAME)).thenReturn(Optional.of(defaultGroup)); + return defaultGroup; + } + + private void verifyNoGroupDelete(DbSession dbSession, GroupDto groupDto) { + verify(dbClient.roleDao(), never()).deleteGroupRolesByGroupUuid(dbSession, groupDto.getUuid()); + verify(dbClient.permissionTemplateDao(), never()).deleteByGroup(dbSession, groupDto.getUuid(), groupDto.getName()); + verify(dbClient.userGroupDao(), never()).deleteByGroupUuid(dbSession, groupDto.getUuid(), groupDto.getName()); + verify(dbClient.qProfileEditGroupsDao(), never()).deleteByGroup(dbSession, groupDto); + verify(dbClient.qualityGateGroupPermissionsDao(), never()).deleteByGroup(dbSession, groupDto); + verify(dbClient.scimGroupDao(), never()).deleteByGroupUuid(dbSession, groupDto.getUuid()); + verify(dbClient.groupDao(), never()).deleteByUuid(dbSession, groupDto.getUuid(), groupDto.getName()); + verify(dbClient.githubOrganizationGroupDao(), never()).deleteByGroupUuid(dbSession, groupDto.getUuid()); + } + + private void verifyGroupDelete(DbSession dbSession, GroupDto groupDto) { + verify(dbClient.roleDao()).deleteGroupRolesByGroupUuid(dbSession, groupDto.getUuid()); + verify(dbClient.permissionTemplateDao()).deleteByGroup(dbSession, groupDto.getUuid(), groupDto.getName()); + verify(dbClient.userGroupDao()).deleteByGroupUuid(dbSession, groupDto.getUuid(), groupDto.getName()); + verify(dbClient.qProfileEditGroupsDao()).deleteByGroup(dbSession, groupDto); + verify(dbClient.qualityGateGroupPermissionsDao()).deleteByGroup(dbSession, groupDto); + verify(dbClient.scimGroupDao()).deleteByGroupUuid(dbSession, groupDto.getUuid()); + verify(dbClient.externalGroupDao()).deleteByGroupUuid(dbSession, groupDto.getUuid()); + verify(dbClient.groupDao()).deleteByUuid(dbSession, groupDto.getUuid(), groupDto.getName()); + verify(dbClient.githubOrganizationGroupDao()).deleteByGroupUuid(dbSession, groupDto.getUuid()); + } +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java index 9e5c9aa684d..7c99e450da1 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java @@ -27,6 +27,9 @@ public class WebApiEndpoints { public static final String USERS_MANAGEMENT_DOMAIN = "/users-management"; public static final String USER_ENDPOINT = USERS_MANAGEMENT_DOMAIN + "/users"; public static final String JSON_MERGE_PATCH_CONTENT_TYPE = "application/merge-patch+json"; + public static final String AUTHORIZATIONS_DOMAIN = "/authorizations"; + + public static final String GROUPS_ENDPOINT = AUTHORIZATIONS_DOMAIN + "/groups"; private WebApiEndpoints() { } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/DefaultGroupController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/DefaultGroupController.java new file mode 100644 index 00000000000..49a4e4f2f89 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/DefaultGroupController.java @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.group.controller; + +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.user.GroupDto; +import org.sonar.server.common.group.service.GroupService; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.user.UserSession; +import org.sonar.server.v2.api.group.response.RestGroupResponse; + +public class DefaultGroupController implements GroupController { + + private static final String GROUP_NOT_FOUND_MESSAGE = "Group '%s' not found"; + private final GroupService groupService; + private final DbClient dbClient; + private final UserSession userSession; + + public DefaultGroupController(GroupService groupService, DbClient dbClient, UserSession userSession) { + this.groupService = groupService; + this.dbClient = dbClient; + this.userSession = userSession; + } + + @Override + public RestGroupResponse fetchGroup(String id) { + userSession.checkLoggedIn().checkIsSystemAdministrator(); + try (DbSession session = dbClient.openSession(false)) { + return groupService.findGroupByUuid(session, id) + .map(DefaultGroupController::groupDtoToResponse) + .orElseThrow(() -> new NotFoundException(String.format(GROUP_NOT_FOUND_MESSAGE, id))); + } + } + + private static RestGroupResponse groupDtoToResponse(GroupDto group) { + return new RestGroupResponse(group.getUuid(), group.getName(), group.getDescription()); + } +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/GroupController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/GroupController.java new file mode 100644 index 00000000000..f889fc8b686 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/GroupController.java @@ -0,0 +1,43 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.group.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import org.sonar.server.v2.api.group.response.RestGroupResponse; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import static org.sonar.server.v2.WebApiEndpoints.GROUPS_ENDPOINT; + +@RequestMapping(GROUPS_ENDPOINT) +@RestController +public interface GroupController { + + @GetMapping(path = "/{id}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Fetch a single group", description = "Fetch a single group.") + RestGroupResponse fetchGroup(@PathVariable("id") @Parameter(description = "The id of the group to fetch.", required = true, in = ParameterIn.PATH) String id); +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/package-info.java new file mode 100644 index 00000000000..44abc95d13d --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.v2.api.group.controller; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/response/RestGroupResponse.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/response/RestGroupResponse.java new file mode 100644 index 00000000000..6a4bdd30c83 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/response/RestGroupResponse.java @@ -0,0 +1,31 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.group.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import javax.annotation.Nullable; + +public record RestGroupResponse( + @Schema(accessMode = Schema.AccessMode.READ_ONLY) + String id, + String name, + @Nullable + String description) { +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/response/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/response/package-info.java new file mode 100644 index 00000000000..e70cba3847b --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/response/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.v2.api.group.response; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java index 9231cc10c6b..fa61ea447cc 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java @@ -20,6 +20,8 @@ package org.sonar.server.v2.config; import javax.annotation.Nullable; +import org.sonar.db.DbClient; +import org.sonar.server.common.group.service.GroupService; import org.sonar.server.common.health.CeStatusNodeCheck; import org.sonar.server.common.health.DbConnectionNodeCheck; import org.sonar.server.common.health.EsStatusNodeCheck; @@ -31,6 +33,8 @@ import org.sonar.server.health.HealthChecker; import org.sonar.server.platform.NodeInformation; import org.sonar.server.user.SystemPasscode; import org.sonar.server.user.UserSession; +import org.sonar.server.v2.api.group.controller.DefaultGroupController; +import org.sonar.server.v2.api.group.controller.GroupController; import org.sonar.server.v2.api.system.controller.DefaultLivenessController; import org.sonar.server.v2.api.system.controller.HealthController; import org.sonar.server.v2.api.system.controller.LivenessController; @@ -75,4 +79,9 @@ public class PlatformLevel4WebConfig { return new DefaultUserController(userSession, userService, usersSearchResponseGenerator); } + @Bean + public GroupController groupController(GroupService groupService, DbClient dbClient, UserSession userSession) { + return new DefaultGroupController(groupService, dbClient, userSession); + } + } diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/group/controller/DefaultGroupControllerTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/group/controller/DefaultGroupControllerTest.java new file mode 100644 index 00000000000..387b6daa8e4 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/group/controller/DefaultGroupControllerTest.java @@ -0,0 +1,101 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.group.controller; + +import java.util.Optional; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.user.GroupDto; +import org.sonar.server.common.group.service.GroupService; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.v2.api.ControllerTester; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.server.v2.WebApiEndpoints.GROUPS_ENDPOINT; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class DefaultGroupControllerTest { + + private static final String GROUP_UUID = "1234"; + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + + private final GroupService groupService = mock(GroupService.class); + private final DbClient dbClient = mock(DbClient.class); + + private final DbSession dbSession = mock(DbSession.class); + + private final MockMvc mockMvc = ControllerTester.getMockMvc(new DefaultGroupController(groupService, dbClient, userSession)); + + @Before + public void setUp() { + when(dbClient.openSession(false)).thenReturn(dbSession); + } + + @Test + public void fetchGroup_whenGroupExists_returnsTheGroup() throws Exception { + + GroupDto groupDto = new GroupDto().setUuid(GROUP_UUID).setName("name").setDescription("description"); + + when(groupService.findGroupByUuid(dbSession, GROUP_UUID)).thenReturn(Optional.of(groupDto)); + + userSession.logIn().setSystemAdministrator(); + mockMvc.perform(get(GROUPS_ENDPOINT + "/" + GROUP_UUID)) + .andExpectAll( + status().isOk(), + content().json(""" + { + "id": "1234", + "name": "name", + "description": "description" + } + """)); + } + + @Test + public void fetchGroup_whenCallerIsNotAdmin_shouldReturnForbidden() throws Exception { + userSession.logIn().setNonSystemAdministrator(); + mockMvc.perform( + get(GROUPS_ENDPOINT + "/" + GROUP_UUID)) + .andExpectAll( + status().isForbidden(), + content().json("{\"message\":\"Insufficient privileges\"}")); + } + + @Test + public void fetchGroup_whenGroupDoesntExist_shouldReturnNotFound() throws Exception { + userSession.logIn().setSystemAdministrator(); + when(groupService.findGroupByUuid(dbSession, GROUP_UUID)).thenReturn(Optional.empty()); + mockMvc.perform( + get(GROUPS_ENDPOINT + "/" + GROUP_UUID) + .content("{}")) + .andExpectAll( + status().isNotFound(), + content().json("{\"message\":\"Group '1234' not found\"}")); + } + +} diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/CreateActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/CreateActionIT.java index 3541a23cfb0..225e2478031 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/CreateActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/CreateActionIT.java @@ -28,6 +28,7 @@ import org.sonar.api.utils.System2; import org.sonar.core.util.SequenceUuidFactory; import org.sonar.db.DbTester; import org.sonar.db.user.GroupDto; +import org.sonar.server.common.group.service.GroupService; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.ServerException; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/DeleteActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/DeleteActionIT.java index cba6d341edb..f8d2e2eee36 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/DeleteActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/DeleteActionIT.java @@ -37,6 +37,7 @@ import org.sonar.db.qualitygate.QualityGateDto; import org.sonar.db.qualityprofile.QProfileDto; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserDto; +import org.sonar.server.common.group.service.GroupService; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.management.ManagedInstanceService; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/ExternalGroupServiceIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/ExternalGroupServiceIT.java index a355dbfd164..b9666421dbf 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/ExternalGroupServiceIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/ExternalGroupServiceIT.java @@ -29,6 +29,7 @@ import org.sonar.db.DbSession; import org.sonar.db.DbTester; import org.sonar.db.user.ExternalGroupDto; import org.sonar.db.user.GroupDto; +import org.sonar.server.common.group.service.GroupService; import static org.assertj.core.api.Assertions.assertThat; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/UpdateActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/UpdateActionIT.java index 787af00f823..f82cbaee955 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/UpdateActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/UpdateActionIT.java @@ -28,6 +28,7 @@ import org.sonar.core.util.UuidFactoryImpl; import org.sonar.db.DbTester; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserDto; +import org.sonar.server.common.group.service.GroupService; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.NotFoundException; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/CreateAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/CreateAction.java index a728d4704a9..c6d367e4976 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/CreateAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/CreateAction.java @@ -27,6 +27,7 @@ import org.sonar.api.server.ws.WebService.NewController; import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.user.GroupDto; +import org.sonar.server.common.group.service.GroupService; import org.sonar.server.common.management.ManagedInstanceChecker; import org.sonar.server.user.UserSession; import org.sonarqube.ws.UserGroups; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/DeleteAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/DeleteAction.java index bc16c54cca6..f0f67ac5fe9 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/DeleteAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/DeleteAction.java @@ -29,6 +29,7 @@ import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.permission.GlobalPermission; import org.sonar.db.user.GroupDto; +import org.sonar.server.common.group.service.GroupService; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.management.ManagedInstanceService; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/ExternalGroupService.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/ExternalGroupService.java index dc5befd6bb2..989615b13dc 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/ExternalGroupService.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/ExternalGroupService.java @@ -26,6 +26,7 @@ import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.user.ExternalGroupDto; import org.sonar.db.user.GroupDto; +import org.sonar.server.common.group.service.GroupService; public class ExternalGroupService { diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/GroupService.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/GroupService.java deleted file mode 100644 index 1dc200fc933..00000000000 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/GroupService.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.server.usergroups.ws; - -import java.util.Optional; -import javax.annotation.Nullable; -import org.sonar.api.security.DefaultGroups; -import org.sonar.api.server.ServerSide; -import org.sonar.api.user.UserGroupValidation; -import org.sonar.core.util.UuidFactory; -import org.sonar.db.DbClient; -import org.sonar.db.DbSession; -import org.sonar.db.permission.GlobalPermission; -import org.sonar.db.user.GroupDto; -import org.sonar.server.exceptions.BadRequestException; - -import static com.google.common.base.Preconditions.checkArgument; -import static org.sonar.server.exceptions.BadRequestException.checkRequest; - -@ServerSide -public class GroupService { - - private final DbClient dbClient; - private final UuidFactory uuidFactory; - - public GroupService(DbClient dbClient, UuidFactory uuidFactory) { - this.dbClient = dbClient; - this.uuidFactory = uuidFactory; - } - - public Optional findGroup(DbSession dbSession, String groupName) { - return dbClient.groupDao().selectByName(dbSession, groupName); - } - - public void delete(DbSession dbSession, GroupDto group) { - checkGroupIsNotDefault(dbSession, group); - checkNotTryingToDeleteLastAdminGroup(dbSession, group); - - removeGroupPermissions(dbSession, group); - removeGroupFromPermissionTemplates(dbSession, group); - removeGroupMembers(dbSession, group); - removeGroupFromQualityProfileEdit(dbSession, group); - removeGroupFromQualityGateEdit(dbSession, group); - removeGroupScimLink(dbSession, group); - removeExternalGroupMapping(dbSession, group); - removeGithubOrganizationGroup(dbSession, group); - - removeGroup(dbSession, group); - } - - public GroupDto updateGroup(DbSession dbSession, GroupDto group, @Nullable String newName) { - checkGroupIsNotDefault(dbSession, group); - return updateName(dbSession, group, newName); - } - - public GroupDto updateGroup(DbSession dbSession, GroupDto group, @Nullable String newName, @Nullable String newDescription) { - checkGroupIsNotDefault(dbSession, group); - GroupDto withUpdatedName = updateName(dbSession, group, newName); - return updateDescription(dbSession, withUpdatedName, newDescription); - } - - public GroupDto createGroup(DbSession dbSession, String name, @Nullable String description) { - validateGroupName(name); - checkNameDoesNotExist(dbSession, name); - - GroupDto group = new GroupDto() - .setUuid(uuidFactory.create()) - .setName(name) - .setDescription(description); - return dbClient.groupDao().insert(dbSession, group); - } - - private GroupDto updateName(DbSession dbSession, GroupDto group, @Nullable String newName) { - if (newName != null && !newName.equals(group.getName())) { - validateGroupName(newName); - checkNameDoesNotExist(dbSession, newName); - group.setName(newName); - return dbClient.groupDao().update(dbSession, group); - } - return group; - } - - private static void validateGroupName(String name) { - try { - UserGroupValidation.validateGroupName(name); - } catch (IllegalArgumentException e) { - BadRequestException.throwBadRequestException(e.getMessage()); - } - } - - private void checkNameDoesNotExist(DbSession dbSession, String name) { - // There is no database constraint on column groups.name - // because MySQL cannot create a unique index - // on a UTF-8 VARCHAR larger than 255 characters on InnoDB - checkRequest(!dbClient.groupDao().selectByName(dbSession, name).isPresent(), "Group '%s' already exists", name); - } - - private GroupDto updateDescription(DbSession dbSession, GroupDto group, @Nullable String newDescription) { - if (newDescription != null) { - group.setDescription(newDescription); - return dbClient.groupDao().update(dbSession, group); - } - return group; - } - - private void checkGroupIsNotDefault(DbSession dbSession, GroupDto groupDto) { - GroupDto defaultGroup = findDefaultGroup(dbSession); - checkArgument(!defaultGroup.getUuid().equals(groupDto.getUuid()), "Default group '%s' cannot be used to perform this action", groupDto.getName()); - } - - private GroupDto findDefaultGroup(DbSession dbSession) { - return dbClient.groupDao().selectByName(dbSession, DefaultGroups.USERS) - .orElseThrow(() -> new IllegalStateException("Default group cannot be found")); - } - - private void checkNotTryingToDeleteLastAdminGroup(DbSession dbSession, GroupDto group) { - int remaining = dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroup(dbSession, - GlobalPermission.ADMINISTER.getKey(), group.getUuid()); - - checkArgument(remaining > 0, "The last system admin group cannot be deleted"); - } - - private void removeGroupPermissions(DbSession dbSession, GroupDto group) { - dbClient.roleDao().deleteGroupRolesByGroupUuid(dbSession, group.getUuid()); - } - - private void removeGroupFromPermissionTemplates(DbSession dbSession, GroupDto group) { - dbClient.permissionTemplateDao().deleteByGroup(dbSession, group.getUuid(), group.getName()); - } - - private void removeGroupMembers(DbSession dbSession, GroupDto group) { - dbClient.userGroupDao().deleteByGroupUuid(dbSession, group.getUuid(), group.getName()); - } - - private void removeGroupFromQualityProfileEdit(DbSession dbSession, GroupDto group) { - dbClient.qProfileEditGroupsDao().deleteByGroup(dbSession, group); - } - - private void removeGroupFromQualityGateEdit(DbSession dbSession, GroupDto group) { - dbClient.qualityGateGroupPermissionsDao().deleteByGroup(dbSession, group); - } - - private void removeGroupScimLink(DbSession dbSession, GroupDto group) { - dbClient.scimGroupDao().deleteByGroupUuid(dbSession, group.getUuid()); - } - - private void removeExternalGroupMapping(DbSession dbSession, GroupDto group) { - dbClient.externalGroupDao().deleteByGroupUuid(dbSession, group.getUuid()); - } - private void removeGithubOrganizationGroup(DbSession dbSession, GroupDto group) { - dbClient.githubOrganizationGroupDao().deleteByGroupUuid(dbSession, group.getUuid()); - } - - private void removeGroup(DbSession dbSession, GroupDto group) { - dbClient.groupDao().deleteByUuid(dbSession, group.getUuid(), group.getName()); - } - -} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/UpdateAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/UpdateAction.java index 8113fa13afd..7310c11302b 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/UpdateAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/UpdateAction.java @@ -28,6 +28,7 @@ import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserMembershipQuery; +import org.sonar.server.common.group.service.GroupService; import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.common.management.ManagedInstanceChecker; import org.sonar.server.user.UserSession; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/UserGroupsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/UserGroupsModule.java index 22ea5abe0bf..9f92f4881cd 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/UserGroupsModule.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/UserGroupsModule.java @@ -20,6 +20,7 @@ package org.sonar.server.usergroups.ws; import org.sonar.core.platform.Module; +import org.sonar.server.common.group.service.GroupService; import org.sonar.server.common.management.ManagedInstanceChecker; public class UserGroupsModule extends Module { diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usergroups/ws/GroupServiceTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usergroups/ws/GroupServiceTest.java deleted file mode 100644 index 998a5095015..00000000000 --- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usergroups/ws/GroupServiceTest.java +++ /dev/null @@ -1,339 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.server.usergroups.ws; - -import com.tngtech.java.junit.dataprovider.DataProvider; -import com.tngtech.java.junit.dataprovider.DataProviderRunner; -import com.tngtech.java.junit.dataprovider.UseDataProvider; -import java.util.Optional; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; -import org.sonar.api.security.DefaultGroups; -import org.sonar.core.util.UuidFactory; -import org.sonar.db.DbClient; -import org.sonar.db.DbSession; -import org.sonar.db.permission.AuthorizationDao; -import org.sonar.db.permission.GlobalPermission; -import org.sonar.db.permission.template.PermissionTemplateDao; -import org.sonar.db.provisioning.GithubOrganizationGroupDao; -import org.sonar.db.qualitygate.QualityGateGroupPermissionsDao; -import org.sonar.db.qualityprofile.QProfileEditGroupsDao; -import org.sonar.db.scim.ScimGroupDao; -import org.sonar.db.user.ExternalGroupDao; -import org.sonar.db.user.GroupDao; -import org.sonar.db.user.GroupDto; -import org.sonar.db.user.RoleDao; -import org.sonar.db.user.UserGroupDao; -import org.sonar.server.exceptions.BadRequestException; - -import static java.lang.String.format; -import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@RunWith(DataProviderRunner.class) -public class GroupServiceTest { - - private static final String GROUP_NAME = "GROUP_NAME"; - private static final String GROUP_UUID = "GROUP_UUID"; - private static final String DEFAULT_GROUP_NAME = "sonar-users"; - private static final String DEFAULT_GROUP_UUID = "DEFAULT_GROUP_UUID"; - @Mock - private DbSession dbSession; - @Mock - private DbClient dbClient; - @Mock - private UuidFactory uuidFactory; - @InjectMocks - private GroupService groupService; - - @Rule - public MockitoRule rule = MockitoJUnit.rule(); - - @Before - public void setUp() { - mockNeededDaos(); - } - - private void mockNeededDaos() { - when(dbClient.authorizationDao()).thenReturn(mock(AuthorizationDao.class)); - when(dbClient.roleDao()).thenReturn(mock(RoleDao.class)); - when(dbClient.permissionTemplateDao()).thenReturn(mock(PermissionTemplateDao.class)); - when(dbClient.userGroupDao()).thenReturn(mock(UserGroupDao.class)); - when(dbClient.qProfileEditGroupsDao()).thenReturn(mock(QProfileEditGroupsDao.class)); - when(dbClient.qualityGateGroupPermissionsDao()).thenReturn(mock(QualityGateGroupPermissionsDao.class)); - when(dbClient.scimGroupDao()).thenReturn(mock(ScimGroupDao.class)); - when(dbClient.externalGroupDao()).thenReturn(mock(ExternalGroupDao.class)); - when(dbClient.groupDao()).thenReturn(mock(GroupDao.class)); - when(dbClient.githubOrganizationGroupDao()).thenReturn(mock(GithubOrganizationGroupDao.class)); - } - - @Test - public void findGroupDtoOrThrow_whenGroupExists_returnsIt() { - GroupDto groupDto = mockGroupDto(); - - when(dbClient.groupDao().selectByName(dbSession, GROUP_NAME)) - .thenReturn(Optional.of(groupDto)); - - assertThat(groupService.findGroup(dbSession, GROUP_NAME)).contains(groupDto); - } - - @Test - public void findGroupDtoOrThrow_whenGroupDoesntExist_throw() { - when(dbClient.groupDao().selectByName(dbSession, GROUP_NAME)) - .thenReturn(Optional.empty()); - - assertThat(groupService.findGroup(dbSession, GROUP_NAME)).isEmpty(); - } - - @Test - public void delete_whenNotDefaultAndNotLastAdminGroup_deleteGroup() { - GroupDto groupDto = mockGroupDto(); - - when(dbClient.groupDao().selectByName(dbSession, DefaultGroups.USERS)) - .thenReturn(Optional.of(new GroupDto().setUuid("another_group_uuid"))); - when(dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroup(dbSession, GlobalPermission.ADMINISTER.getKey(), groupDto.getUuid())) - .thenReturn(2); - - groupService.delete(dbSession, groupDto); - - verifyGroupDelete(dbSession, groupDto); - } - - @Test - public void delete_whenDefaultGroup_throwAndDontDeleteGroup() { - GroupDto groupDto = mockGroupDto(); - - when(dbClient.groupDao().selectByName(dbSession, DefaultGroups.USERS)) - .thenReturn(Optional.of(groupDto)); - - assertThatThrownBy(() -> groupService.delete(dbSession, groupDto)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage(format("Default group '%s' cannot be used to perform this action", GROUP_NAME)); - - verifyNoGroupDelete(dbSession, groupDto); - } - - @Test - public void delete_whenLastAdminGroup_throwAndDontDeleteGroup() { - GroupDto groupDto = mockGroupDto(); - - when(dbClient.groupDao().selectByName(dbSession, DefaultGroups.USERS)) - .thenReturn(Optional.of(new GroupDto().setUuid("another_group_uuid"))); // We must pass the default group check - when(dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroup(dbSession, GlobalPermission.ADMINISTER.getKey(), groupDto.getUuid())) - .thenReturn(0); - - assertThatThrownBy(() -> groupService.delete(dbSession, groupDto)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("The last system admin group cannot be deleted"); - - verifyNoGroupDelete(dbSession, groupDto); - } - - @Test - public void updateGroup_updatesGroupNameAndDescription() { - GroupDto group = mockGroupDto(); - GroupDto groupWithUpdatedName = mockGroupDto(); - mockDefaultGroup(); - when(dbClient.groupDao().update(dbSession, group)).thenReturn(groupWithUpdatedName); - - groupService.updateGroup(dbSession, group, "new-name", "New Description"); - verify(group).setName("new-name"); - verify(groupWithUpdatedName).setDescription("New Description"); - verify(dbClient.groupDao()).update(dbSession, group); - verify(dbClient.groupDao()).update(dbSession, groupWithUpdatedName); - } - - @Test - public void updateGroup_updatesGroupName() { - GroupDto group = mockGroupDto(); - mockDefaultGroup(); - - groupService.updateGroup(dbSession, group, "new-name"); - verify(group).setName("new-name"); - verify(dbClient.groupDao()).update(dbSession, group); - } - - @Test - public void updateGroup_whenGroupIsDefault_throws() { - GroupDto defaultGroup = mockDefaultGroup(); - when(dbClient.groupDao().selectByName(dbSession, DEFAULT_GROUP_NAME)).thenReturn(Optional.of(defaultGroup)); - - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> groupService.updateGroup(dbSession, defaultGroup, "new-name", "New Description")) - .withMessage("Default group 'sonar-users' cannot be used to perform this action"); - - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> groupService.updateGroup(dbSession, defaultGroup, "new-name")) - .withMessage("Default group 'sonar-users' cannot be used to perform this action"); - } - - @Test - public void updateGroup_whenGroupNameDoesntChange_succeedsWithDescription() { - GroupDto group = mockGroupDto(); - mockDefaultGroup(); - - groupService.updateGroup(dbSession, group, group.getName(), "New Description"); - verify(group).setDescription("New Description"); - verify(dbClient.groupDao()).update(dbSession, group); - } - - @Test - public void updateGroup_whenGroupNameDoesntChange_succeeds() { - GroupDto group = mockGroupDto(); - mockDefaultGroup(); - - assertThatNoException() - .isThrownBy(() -> groupService.updateGroup(dbSession, group, group.getName())); - - verify(dbClient.groupDao(), never()).update(dbSession, group); - } - - @Test - public void updateGroup_whenGroupExist_throws() { - GroupDto group = mockGroupDto(); - GroupDto group2 = mockGroupDto(); - mockDefaultGroup(); - String group2Name = GROUP_NAME + "2"; - - when(dbClient.groupDao().selectByName(dbSession, group2Name)).thenReturn(Optional.of(group2)); - - assertThatExceptionOfType(BadRequestException.class) - .isThrownBy(() -> groupService.updateGroup(dbSession, group, group2Name, "New Description")) - .withMessage("Group '" + group2Name + "' already exists"); - - assertThatExceptionOfType(BadRequestException.class) - .isThrownBy(() -> groupService.updateGroup(dbSession, group, group2Name)) - .withMessage("Group '" + group2Name + "' already exists"); - } - - @Test - @UseDataProvider("invalidGroupNames") - public void updateGroup_whenGroupNameIsInvalid_throws(String groupName, String errorMessage) { - GroupDto group = mockGroupDto(); - mockDefaultGroup(); - - assertThatExceptionOfType(BadRequestException.class) - .isThrownBy(() -> groupService.updateGroup(dbSession, group, groupName, "New Description")) - .withMessage(errorMessage); - - assertThatExceptionOfType(BadRequestException.class) - .isThrownBy(() -> groupService.updateGroup(dbSession, group, groupName)) - .withMessage(errorMessage); - } - - @Test - public void createGroup_whenNameAndDescriptionIsProvided_createsGroup() { - - when(uuidFactory.create()).thenReturn("1234"); - groupService.createGroup(dbSession, "Name", "Description"); - - ArgumentCaptor groupCaptor = ArgumentCaptor.forClass(GroupDto.class); - verify(dbClient.groupDao()).insert(eq(dbSession), groupCaptor.capture()); - GroupDto createdGroup = groupCaptor.getValue(); - assertThat(createdGroup.getName()).isEqualTo("Name"); - assertThat(createdGroup.getDescription()).isEqualTo("Description"); - assertThat(createdGroup.getUuid()).isEqualTo("1234"); - } - - @Test - public void createGroup_whenGroupExist_throws() { - GroupDto group = mockGroupDto(); - - when(dbClient.groupDao().selectByName(dbSession, GROUP_NAME)).thenReturn(Optional.of(group)); - - assertThatExceptionOfType(BadRequestException.class) - .isThrownBy(() -> groupService.createGroup(dbSession, GROUP_NAME, "New Description")) - .withMessage("Group '" + GROUP_NAME + "' already exists"); - - } - - @Test - @UseDataProvider("invalidGroupNames") - public void createGroup_whenGroupNameIsInvalid_throws(String groupName, String errorMessage) { - mockDefaultGroup(); - - assertThatExceptionOfType(BadRequestException.class) - .isThrownBy(() -> groupService.createGroup(dbSession, groupName, "Description")) - .withMessage(errorMessage); - - } - - @DataProvider - public static Object[][] invalidGroupNames() { - return new Object[][] { - {"", "Group name cannot be empty"}, - {randomAlphanumeric(256), "Group name cannot be longer than 255 characters"}, - {"Anyone", "Anyone group cannot be used"}, - }; - } - - private static GroupDto mockGroupDto() { - GroupDto groupDto = mock(GroupDto.class); - when(groupDto.getName()).thenReturn(GROUP_NAME); - when(groupDto.getUuid()).thenReturn(GROUP_UUID); - return groupDto; - } - - private GroupDto mockDefaultGroup() { - GroupDto defaultGroup = mock(GroupDto.class); - when(defaultGroup.getName()).thenReturn(DEFAULT_GROUP_NAME); - when(defaultGroup.getUuid()).thenReturn(DEFAULT_GROUP_UUID); - when(dbClient.groupDao().selectByName(dbSession, DEFAULT_GROUP_NAME)).thenReturn(Optional.of(defaultGroup)); - return defaultGroup; - } - - private void verifyNoGroupDelete(DbSession dbSession, GroupDto groupDto) { - verify(dbClient.roleDao(), never()).deleteGroupRolesByGroupUuid(dbSession, groupDto.getUuid()); - verify(dbClient.permissionTemplateDao(), never()).deleteByGroup(dbSession, groupDto.getUuid(), groupDto.getName()); - verify(dbClient.userGroupDao(), never()).deleteByGroupUuid(dbSession, groupDto.getUuid(), groupDto.getName()); - verify(dbClient.qProfileEditGroupsDao(), never()).deleteByGroup(dbSession, groupDto); - verify(dbClient.qualityGateGroupPermissionsDao(), never()).deleteByGroup(dbSession, groupDto); - verify(dbClient.scimGroupDao(), never()).deleteByGroupUuid(dbSession, groupDto.getUuid()); - verify(dbClient.groupDao(), never()).deleteByUuid(dbSession, groupDto.getUuid(), groupDto.getName()); - verify(dbClient.githubOrganizationGroupDao(), never()).deleteByGroupUuid(dbSession, groupDto.getUuid()); - } - - private void verifyGroupDelete(DbSession dbSession, GroupDto groupDto) { - verify(dbClient.roleDao()).deleteGroupRolesByGroupUuid(dbSession, groupDto.getUuid()); - verify(dbClient.permissionTemplateDao()).deleteByGroup(dbSession, groupDto.getUuid(), groupDto.getName()); - verify(dbClient.userGroupDao()).deleteByGroupUuid(dbSession, groupDto.getUuid(), groupDto.getName()); - verify(dbClient.qProfileEditGroupsDao()).deleteByGroup(dbSession, groupDto); - verify(dbClient.qualityGateGroupPermissionsDao()).deleteByGroup(dbSession, groupDto); - verify(dbClient.scimGroupDao()).deleteByGroupUuid(dbSession, groupDto.getUuid()); - verify(dbClient.externalGroupDao()).deleteByGroupUuid(dbSession, groupDto.getUuid()); - verify(dbClient.groupDao()).deleteByUuid(dbSession, groupDto.getUuid(), groupDto.getName()); - verify(dbClient.githubOrganizationGroupDao()).deleteByGroupUuid(dbSession, groupDto.getUuid()); - } -}