diff options
53 files changed, 1925 insertions, 107 deletions
diff --git a/server/sonar-db-dao/src/it/java/org/sonar/db/user/UserDaoIT.java b/server/sonar-db-dao/src/it/java/org/sonar/db/user/UserDaoIT.java index ab8afc81fde..cbfc3cfefbe 100644 --- a/server/sonar-db-dao/src/it/java/org/sonar/db/user/UserDaoIT.java +++ b/server/sonar-db-dao/src/it/java/org/sonar/db/user/UserDaoIT.java @@ -267,6 +267,40 @@ public class UserDaoIT { } @Test + public void selectUsersByQuery_whenSearchingByGroupUuid_findsTheRightResults() { + db.users().insertUser(); + UserDto userToFind1 = db.users().insertUser(u -> u.setLogin("z")); + UserDto userToFind2 = db.users().insertUser(u -> u.setLogin("a")); + + GroupDto groupDto = db.users().insertGroup(); + db.users().insertMember(groupDto, userToFind2); + db.users().insertMember(groupDto, userToFind1); + + UserQuery query = UserQuery.builder().groupUuid(groupDto.getUuid()).build(); + List<UserDto> users = underTest.selectUsers(session, query); + + assertThat(users).usingRecursiveFieldByFieldElementComparator().containsExactly(userToFind2, userToFind1); + assertThat(underTest.countUsers(session, query)).isEqualTo(2); + } + + @Test + public void selectUsersByQuery_whenExcludingGroupUuid_findsTheRightResults() { + UserDto userToFind1 = db.users().insertUser(u -> u.setLogin("z")); + UserDto userToFind2 = db.users().insertUser(u -> u.setLogin("a")); + UserDto userToFind3 = db.users().insertUser(u -> u.setLogin("b")); + + GroupDto groupDto = db.users().insertGroup(); + db.users().insertMember(groupDto, userToFind2); + db.users().insertMember(groupDto, userToFind1); + + UserQuery query = UserQuery.builder().excludedGroupUuid(groupDto.getUuid()).build(); + List<UserDto> users = underTest.selectUsers(session, query); + + assertThat(users).usingRecursiveFieldByFieldElementComparator().containsExactly(userToFind3); + assertThat(underTest.countUsers(session, query)).isEqualTo(1); + } + + @Test public void selectUsersByQuery_whenSearchingByUuidsWithLongRange_shouldReturnTheExpectedUsers() { db.users().insertUser(); List<UserDto> users = generateAndInsertUsers(3200); diff --git a/server/sonar-db-dao/src/it/java/org/sonar/db/user/UserGroupDaoIT.java b/server/sonar-db-dao/src/it/java/org/sonar/db/user/UserGroupDaoIT.java index 79e5f6b45e8..6f504776284 100644 --- a/server/sonar-db-dao/src/it/java/org/sonar/db/user/UserGroupDaoIT.java +++ b/server/sonar-db-dao/src/it/java/org/sonar/db/user/UserGroupDaoIT.java @@ -19,15 +19,21 @@ */ package org.sonar.db.user; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.List; import java.util.Set; import org.junit.Rule; import org.junit.Test; +import org.junit.runner.RunWith; import org.sonar.api.utils.System2; import org.sonar.db.DbSession; import org.sonar.db.DbTester; import static org.assertj.core.api.Assertions.assertThat; +@RunWith(DataProviderRunner.class) public class UserGroupDaoIT { @Rule @@ -118,4 +124,70 @@ public class UserGroupDaoIT { assertThat(dbTester.getDbClient().groupMembershipDao().selectGroupUuidsByUserUuid(dbTester.getSession(), user2.getUuid())).containsOnly(group1.getUuid(), group2.getUuid()); } + @DataProvider + public static Object[][] userQueryAndExpectedValues() { + return new Object[][] { + {new UserGroupQuery(null, null, null), + List.of( + new UserGroupDto().setUuid("3").setGroupUuid("group_a").setUserUuid("1"), + new UserGroupDto().setUuid("4").setGroupUuid("group_a").setUserUuid("2"), + new UserGroupDto().setUuid("5").setGroupUuid("group_b").setUserUuid("1"), + new UserGroupDto().setUuid("6").setGroupUuid("group_b").setUserUuid("2") + )}, + {new UserGroupQuery("3", null, null), + List.of( + new UserGroupDto().setUuid("3").setGroupUuid("group_a").setUserUuid("1") + )}, + {new UserGroupQuery("3", "group_a", "1"), + List.of( + new UserGroupDto().setUuid("3").setGroupUuid("group_a").setUserUuid("1") + )}, + {new UserGroupQuery("3", "group_b", "1"), + List.of()}, + {new UserGroupQuery(null,"group_b", null), + List.of( + new UserGroupDto().setUuid("5").setGroupUuid("group_b").setUserUuid("1"), + new UserGroupDto().setUuid("6").setGroupUuid("group_b").setUserUuid("2") + )}, + {new UserGroupQuery(null,null, "2"), + List.of( + new UserGroupDto().setUuid("4").setGroupUuid("group_a").setUserUuid("2"), + new UserGroupDto().setUuid("6").setGroupUuid("group_b").setUserUuid("2") + )}, + {new UserGroupQuery(null,"group_a", "2"), + List.of( + new UserGroupDto().setUuid("4").setGroupUuid("group_a").setUserUuid("2") + )}, + {new UserGroupQuery(null,"group_c", null), + List.of()}, + {new UserGroupQuery(null,"group_c", "2"), + List.of()}, + {new UserGroupQuery(null,"group_a", "3"), + List.of()} + }; + } + + @Test + @UseDataProvider("userQueryAndExpectedValues") + public void selectByQuery_returnsExpectedResults(UserGroupQuery userQuery, List<UserGroupDto> expectedUserGroupDtos) { + insertUsersGroupsAndMembership(); + + List<UserGroupDto> userGroupDtos = underTest.selectByQuery(dbTester.getSession(), userQuery, 1, 100); + + assertThat(userGroupDtos).usingRecursiveFieldByFieldElementComparator().isEqualTo(expectedUserGroupDtos); + assertThat(underTest.countByQuery(dbTester.getSession(), userQuery)).isEqualTo(expectedUserGroupDtos.size()); + } + + + private void insertUsersGroupsAndMembership() { + UserDto user1 = dbTester.users().insertUser(); + UserDto user2 = dbTester.users().insertUser(); + GroupDto group1 = dbTester.users().insertGroup(g -> g.setUuid("group_a")); + GroupDto group2 = dbTester.users().insertGroup(g -> g.setUuid("group_b")); + dbTester.users().insertMember(group1, user1); + dbTester.users().insertMember(group1, user2); + dbTester.users().insertMember(group2, user1); + dbTester.users().insertMember(group2, user2); + } + } diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupDao.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupDao.java index 68ca217b4ea..e11a01fd91a 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupDao.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupDao.java @@ -19,20 +19,26 @@ */ package org.sonar.db.user; +import java.util.List; import java.util.Set; +import org.sonar.core.util.UuidFactory; import org.sonar.db.Dao; import org.sonar.db.DbSession; +import org.sonar.db.Pagination; import org.sonar.db.audit.AuditPersister; import org.sonar.db.audit.model.UserGroupNewValue; public class UserGroupDao implements Dao { private final AuditPersister auditPersister; + private final UuidFactory uuidFactory; - public UserGroupDao(AuditPersister auditPersister) { + public UserGroupDao(AuditPersister auditPersister, UuidFactory uuidFactory) { this.auditPersister = auditPersister; + this.uuidFactory = uuidFactory; } public UserGroupDto insert(DbSession session, UserGroupDto dto, String groupName, String login) { + dto.setUuid(uuidFactory.create()); mapper(session).insert(dto); auditPersister.addUserToGroup(session, new UserGroupNewValue(dto, groupName, login)); return dto; @@ -42,6 +48,14 @@ public class UserGroupDao implements Dao { return mapper(session).selectUserUuidsInGroup(groupUuid); } + public List<UserGroupDto> selectByQuery(DbSession session, UserGroupQuery query, int page, int pageSize) { + return mapper(session).selectByQuery(query, Pagination.forPage(page).andSize(pageSize)); + } + + public int countByQuery(DbSession session, UserGroupQuery query) { + return mapper(session).countByQuery(query); + } + public void delete(DbSession session, GroupDto group, UserDto user) { int deletedRows = mapper(session).delete(group.getUuid(), user.getUuid()); diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupDto.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupDto.java index 648db5c1ebb..cd69c3f222a 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupDto.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupDto.java @@ -19,11 +19,26 @@ */ package org.sonar.db.user; -public class UserGroupDto { +import java.util.Objects; +public class UserGroupDto { + private String uuid; private String userUuid; private String groupUuid; + public UserGroupDto() { + // + } + + public String getUuid() { + return uuid; + } + + public UserGroupDto setUuid(String uuid) { + this.uuid = uuid; + return this; + } + public String getUserUuid() { return userUuid; } @@ -41,4 +56,21 @@ public class UserGroupDto { this.groupUuid = groupUuid; return this; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserGroupDto that = (UserGroupDto) o; + return Objects.equals(userUuid, that.userUuid) && Objects.equals(groupUuid, that.groupUuid); + } + + @Override + public int hashCode() { + return Objects.hash(userUuid, groupUuid); + } } diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupMapper.java index ed89e480477..7ec5235cb4e 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupMapper.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupMapper.java @@ -19,8 +19,10 @@ */ package org.sonar.db.user; +import java.util.List; import java.util.Set; import org.apache.ibatis.annotations.Param; +import org.sonar.db.Pagination; public interface UserGroupMapper { @@ -34,4 +36,6 @@ public interface UserGroupMapper { int deleteByUserUuid(@Param("userUuid") String userUuid); + List<UserGroupDto> selectByQuery(@Param("query") UserGroupQuery query, @Param("pagination") Pagination pagination); + int countByQuery(@Param("query") UserGroupQuery query); } diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupQuery.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupQuery.java new file mode 100644 index 00000000000..425f4020df3 --- /dev/null +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserGroupQuery.java @@ -0,0 +1,25 @@ +/* + * 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.db.user; + +import javax.annotation.Nullable; + +public record UserGroupQuery(@Nullable String uuid, @Nullable String groupUuid, @Nullable String userUuid) { +} diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserQuery.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserQuery.java index 6b624e26cd7..6cc169bc81c 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserQuery.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserQuery.java @@ -37,6 +37,8 @@ public class UserQuery { private final Long sonarLintLastConnectionDateFrom; private final Long sonarLintLastConnectionDateTo; private final String externalLogin; + private final String groupUuid; + private final String excludedGroupUuid; private final Set<String> userUuids; private UserQuery(UserQuery userQuery, Collection<String> userUuids) { @@ -48,13 +50,15 @@ public class UserQuery { this.sonarLintLastConnectionDateTo = userQuery.getSonarLintLastConnectionDateTo(); this.sonarLintLastConnectionDateFrom = userQuery.getSonarLintLastConnectionDateFrom(); this.externalLogin = userQuery.externalLogin; + this.groupUuid = userQuery.groupUuid; + this.excludedGroupUuid = userQuery.excludedGroupUuid; this.userUuids = new HashSet<>(userUuids); } private UserQuery(@Nullable String searchText, @Nullable Boolean isActive, @Nullable String isManagedSqlClause, @Nullable OffsetDateTime lastConnectionDateFrom, @Nullable OffsetDateTime lastConnectionDateTo, @Nullable OffsetDateTime sonarLintLastConnectionDateFrom, @Nullable OffsetDateTime sonarLintLastConnectionDateTo, @Nullable String externalLogin, - @Nullable Set<String> userUuids) { + @Nullable String groupUuid, @Nullable String excludedGroupUuid, @Nullable Set<String> userUuids) { this.searchText = searchTextToSearchTextSql(searchText); this.isActive = isActive; this.isManagedSqlClause = isManagedSqlClause; @@ -63,6 +67,8 @@ public class UserQuery { this.sonarLintLastConnectionDateFrom = parseDateToLong(sonarLintLastConnectionDateFrom); this.sonarLintLastConnectionDateTo = formatDateToInput(sonarLintLastConnectionDateTo); this.externalLogin = externalLogin; + this.groupUuid = groupUuid; + this.excludedGroupUuid = excludedGroupUuid; this.userUuids = userUuids; } @@ -142,6 +148,16 @@ public class UserQuery { return userUuids; } + @CheckForNull + private String getGroupUuid() { + return groupUuid; + } + + @CheckForNull + private String getExcludedGroupUuid() { + return excludedGroupUuid; + } + public static UserQueryBuilder builder() { return new UserQueryBuilder(); } @@ -155,6 +171,8 @@ public class UserQuery { private OffsetDateTime sonarLintLastConnectionDateFrom = null; private OffsetDateTime sonarLintLastConnectionDateTo = null; private String externalLogin = null; + private String groupUuid = null; + private String excludedGroupUuid; private Set<String> userUuids = null; private UserQueryBuilder() { @@ -200,6 +218,16 @@ public class UserQuery { return this; } + public UserQueryBuilder groupUuid(@Nullable String groupUuid) { + this.groupUuid = groupUuid; + return this; + } + + public UserQueryBuilder excludedGroupUuid(@Nullable String excludedGroupUuid) { + this.excludedGroupUuid = excludedGroupUuid; + return this; + } + public UserQueryBuilder userUuids(@Nullable Set<String> userUuids) { this.userUuids = userUuids; return this; @@ -208,7 +236,7 @@ public class UserQuery { public UserQuery build() { return new UserQuery( searchText, isActive, isManagedSqlClause, lastConnectionDateFrom, lastConnectionDateTo, - sonarLintLastConnectionDateFrom, sonarLintLastConnectionDateTo, externalLogin, userUuids); + sonarLintLastConnectionDateFrom, sonarLintLastConnectionDateTo, externalLogin, groupUuid, excludedGroupUuid, userUuids); } } } diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserGroupMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserGroupMapper.xml index 8151f29ea83..85961adcd0d 100644 --- a/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserGroupMapper.xml +++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserGroupMapper.xml @@ -5,9 +5,11 @@ <insert id="insert" parameterType="UserGroup" useGeneratedKeys="false"> insert into groups_users ( + uuid, user_uuid, group_uuid ) values ( + #{uuid,jdbcType=VARCHAR}, #{userUuid,jdbcType=VARCHAR}, #{groupUuid,jdbcType=VARCHAR} ) @@ -19,6 +21,38 @@ where gu.group_uuid=#{groupUuid,jdbcType=VARCHAR} </select> + <select id="selectByQuery" resultType="org.sonar.db.user.UserGroupDto"> + select + gu.uuid as uuid, + gu.user_uuid as userUuid, + gu.group_uuid as groupUuid + from groups_users gu + <include refid="searchByQueryWhereClause"/> + order by gu.group_uuid, gu.user_uuid + <include refid="org.sonar.db.common.Common.pagination"/> + </select> + + <select id="countByQuery" parameterType="map" resultType="int"> + select count(1) + from groups_users gu + <include refid="searchByQueryWhereClause"/> + </select> + + <sql id="searchByQueryWhereClause"> + <where> + 1=1 + <if test="query.uuid != null"> + AND gu.uuid='${query.uuid}' + </if> + <if test="query.userUuid != null"> + AND gu.user_uuid='${query.userUuid}' + </if> + <if test="query.groupUuid != null"> + AND gu.group_uuid='${query.groupUuid}' + </if> + </where> + </sql> + <delete id="delete" parameterType="map"> delete from groups_users where user_uuid = #{userUuid,jdbcType=VARCHAR} and diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml index d3e234b2749..dae72afd8ce 100644 --- a/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml +++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml @@ -172,6 +172,12 @@ <if test="query.externalLogin != null"> AND (u.external_login = #{query.externalLogin, jdbcType=VARCHAR}) </if> + <if test="query.groupUuid != null"> + AND exists (select 1 from groups_users ug where ug.user_uuid = u.uuid AND ug.group_uuid=#{query.groupUuid, jdbcType=VARCHAR}) + </if> + <if test="query.excludedGroupUuid != null"> + AND NOT exists (select 1 from groups_users ug where ug.user_uuid = u.uuid AND ug.group_uuid=#{query.excludedGroupUuid, jdbcType=VARCHAR}) + </if> </where> </sql> diff --git a/server/sonar-db-dao/src/schema/schema-sq.ddl b/server/sonar-db-dao/src/schema/schema-sq.ddl index 9bd6370e171..272f0e0cdf2 100644 --- a/server/sonar-db-dao/src/schema/schema-sq.ddl +++ b/server/sonar-db-dao/src/schema/schema-sq.ddl @@ -387,8 +387,10 @@ CREATE UNIQUE NULLS NOT DISTINCT INDEX "UNIQ_GROUPS_NAME" ON "GROUPS"("NAME" NUL CREATE TABLE "GROUPS_USERS"( "GROUP_UUID" CHARACTER VARYING(40) NOT NULL, - "USER_UUID" CHARACTER VARYING(255) NOT NULL + "USER_UUID" CHARACTER VARYING(255) NOT NULL, + "UUID" CHARACTER VARYING(40) NOT NULL ); +ALTER TABLE "GROUPS_USERS" ADD CONSTRAINT "PK_GROUPS_USERS" PRIMARY KEY("UUID"); CREATE INDEX "INDEX_GROUPS_USERS_GROUP_UUID" ON "GROUPS_USERS"("GROUP_UUID" NULLS FIRST); CREATE INDEX "INDEX_GROUPS_USERS_USER_UUID" ON "GROUPS_USERS"("USER_UUID" NULLS FIRST); CREATE UNIQUE NULLS NOT DISTINCT INDEX "GROUPS_USERS_UNIQUE" ON "GROUPS_USERS"("USER_UUID" NULLS FIRST, "GROUP_UUID" NULLS FIRST); diff --git a/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/AddUuidColumnToGroupsUsersIT.java b/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/AddUuidColumnToGroupsUsersIT.java new file mode 100644 index 00000000000..d6a1bfd39c0 --- /dev/null +++ b/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/AddUuidColumnToGroupsUsersIT.java @@ -0,0 +1,51 @@ +/* + * 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.platform.db.migration.version.v104; + +import java.sql.SQLException; +import java.sql.Types; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.db.MigrationDbTester; + +import static org.assertj.core.api.Assertions.assertThatCode; + +public class AddUuidColumnToGroupsUsersIT { + + private static final String TABLE_NAME = "groups_users"; + private static final String COLUMN_NAME = "uuid"; + + @Rule + public final MigrationDbTester db = MigrationDbTester.createForMigrationStep(AddUuidColumnToGroupsUsers.class); + private final AddUuidColumnToGroupsUsers underTest = new AddUuidColumnToGroupsUsers(db.database()); + + @Test + public void execute_whenColumnDoesNotExist_shouldCreateColumn() throws SQLException { + db.assertColumnDoesNotExist(TABLE_NAME, COLUMN_NAME); + underTest.execute(); + db.assertColumnDefinition(TABLE_NAME, COLUMN_NAME, Types.VARCHAR, 40, true); + } + + @Test + public void execute_whenColumnsAlreadyExists_shouldNotFail() throws SQLException { + underTest.execute(); + assertThatCode(underTest::execute).doesNotThrowAnyException(); + } +} diff --git a/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/CreatePrimaryKeyOnGroupsUsersTableIT.java b/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/CreatePrimaryKeyOnGroupsUsersTableIT.java new file mode 100644 index 00000000000..85b9a4534b8 --- /dev/null +++ b/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/CreatePrimaryKeyOnGroupsUsersTableIT.java @@ -0,0 +1,52 @@ +/* + * 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.platform.db.migration.version.v104; + +import java.sql.SQLException; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.db.MigrationDbTester; + +import static org.sonar.server.platform.db.migration.version.v104.AddUuidColumnToGroupsUsers.GROUPS_USERS_TABLE_NAME; +import static org.sonar.server.platform.db.migration.version.v104.AddUuidColumnToGroupsUsers.GROUPS_USERS_UUID_COLUMN_NAME; +import static org.sonar.server.platform.db.migration.version.v104.CreatePrimaryKeyOnGroupsUsersTable.PK_NAME; + +public class CreatePrimaryKeyOnGroupsUsersTableIT { + @Rule + public final MigrationDbTester db = MigrationDbTester.createForMigrationStep(CreatePrimaryKeyOnGroupsUsersTable.class); + private final CreatePrimaryKeyOnGroupsUsersTable createIndex = new CreatePrimaryKeyOnGroupsUsersTable(db.database()); + + @Test + public void execute_whenPrimaryKeyDoesntExist_shouldCreatePrimaryKey() throws SQLException { + db.assertNoPrimaryKey(GROUPS_USERS_TABLE_NAME); + + createIndex.execute(); + db.assertPrimaryKey(GROUPS_USERS_TABLE_NAME, PK_NAME, GROUPS_USERS_UUID_COLUMN_NAME); + } + + @Test + public void execute_whenPrimaryKeyAlreadyExist_shouldKeepThePrimaryKeyAndNotFail() throws SQLException { + createIndex.execute(); + createIndex.execute(); + + db.assertPrimaryKey(GROUPS_USERS_TABLE_NAME, PK_NAME, GROUPS_USERS_UUID_COLUMN_NAME); + } + +} diff --git a/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/MakeUuidInGroupsUsersNotNullableIT.java b/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/MakeUuidInGroupsUsersNotNullableIT.java new file mode 100644 index 00000000000..88c3f6d1005 --- /dev/null +++ b/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/MakeUuidInGroupsUsersNotNullableIT.java @@ -0,0 +1,51 @@ +/* + * 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.platform.db.migration.version.v104; + +import java.sql.SQLException; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.db.MigrationDbTester; + +import static java.sql.Types.VARCHAR; +import static org.sonar.server.platform.db.migration.version.v104.AddUuidColumnToGroupsUsers.GROUPS_USERS_TABLE_NAME; +import static org.sonar.server.platform.db.migration.version.v104.AddUuidColumnToGroupsUsers.GROUPS_USERS_UUID_COLUMN_NAME; + +public class MakeUuidInGroupsUsersNotNullableIT { + + @Rule + public final MigrationDbTester db = MigrationDbTester.createForMigrationStep( MakeUuidInGroupsUsersNotNullable.class); + private final MakeUuidInGroupsUsersNotNullable underTest = new MakeUuidInGroupsUsersNotNullable(db.database()); + + @Test + public void execute_whenUuidColumnIsNullable_shouldMakeItNonNullable() throws SQLException { + db.assertColumnDefinition(GROUPS_USERS_TABLE_NAME, GROUPS_USERS_UUID_COLUMN_NAME, VARCHAR, null, true); + underTest.execute(); + db.assertColumnDefinition(GROUPS_USERS_TABLE_NAME, GROUPS_USERS_UUID_COLUMN_NAME, VARCHAR, null, false); + } + + @Test + public void execute_whenUuidColumnIsNullable_shouldKeepItNullableAndNotFail() throws SQLException { + db.assertColumnDefinition(GROUPS_USERS_TABLE_NAME, GROUPS_USERS_UUID_COLUMN_NAME, VARCHAR, null, true); + underTest.execute(); + underTest.execute(); + db.assertColumnDefinition(GROUPS_USERS_TABLE_NAME, GROUPS_USERS_UUID_COLUMN_NAME, VARCHAR, null, false); + } +} diff --git a/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/PopulateGroupsUsersUuidIT.java b/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/PopulateGroupsUsersUuidIT.java new file mode 100644 index 00000000000..ab8060739a3 --- /dev/null +++ b/server/sonar-db-migration/src/it/java/org/sonar/server/platform/db/migration/version/v104/PopulateGroupsUsersUuidIT.java @@ -0,0 +1,107 @@ +/* + * 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.platform.db.migration.version.v104; + +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import org.assertj.core.groups.Tuple; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.core.util.UuidFactoryFast; +import org.sonar.db.MigrationDbTester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +public class PopulateGroupsUsersUuidIT { + + private static final String GROUPS_USERS_TABLE_NAME = "groups_users"; + @Rule + public final MigrationDbTester db = MigrationDbTester.createForMigrationStep(PopulateGroupsUsersUuid.class); + + private final PopulateGroupsUsersUuid migration = new PopulateGroupsUsersUuid(db.database(), UuidFactoryFast.getInstance()); + + @Test + public void execute_whenTableIsEmpty_shouldPopulate() throws SQLException { + insertRowsWithoutUuid(); + + migration.execute(); + + verifyUuidPresentAndUnique(); + } + + + + @Test + public void execute_isReentrant() throws SQLException { + insertRowsWithoutUuid(); + migration.execute(); + List<Tuple> existingUuids = getExistingUuids(); + + migration.execute(); + verifyUuidsNotChanged(existingUuids); + + migration.execute(); + verifyUuidsNotChanged(existingUuids); + } + + private void insertRowsWithoutUuid() { + db.executeInsert(GROUPS_USERS_TABLE_NAME, + "uuid", null, + "group_uuid", "group1_uuid", + "user_uuid", "user1_uuid"); + + db.executeInsert(GROUPS_USERS_TABLE_NAME, + "uuid", null, + "group_uuid", "group2_uuid", + "user_uuid", "user2_uuid"); + + db.executeInsert(GROUPS_USERS_TABLE_NAME, + "uuid", null, + "group_uuid", "group3_uuid", + "user_uuid", "user3_uuid"); + } + + private void verifyUuidPresentAndUnique() { + List<Map<String, Object>> rows = db.select("select uuid, group_uuid, user_uuid from groups_users"); + rows + .forEach(stringObjectMap -> assertThat(stringObjectMap.get("UUID")).isNotNull()); + long uniqueCount = rows.stream().map(row -> row.get("UUID")).distinct().count(); + assertThat(uniqueCount).isEqualTo(rows.size()); + + } + + private List<Tuple> getExistingUuids() { + return db.select("select uuid, group_uuid, user_uuid from groups_users") + .stream() + .map(stringObjectMap -> tuple(stringObjectMap.get("UUID"), stringObjectMap.get("GROUP_UUID"), stringObjectMap.get("USER_UUID"))) + .toList(); + } + + private void verifyUuidsNotChanged(List<Tuple> existingUuids) { + assertThat(db.select("select uuid, group_uuid, user_uuid from groups_users")) + .extracting(stringObjectMap -> tuple(stringObjectMap.get("UUID"), stringObjectMap.get("GROUP_UUID"), stringObjectMap.get("USER_UUID"))) + .containsExactlyInAnyOrderElementsOf(existingUuids); + } + + + +} diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/MassUpdate.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/MassUpdate.java index fc08adfe0a4..117ca3271e6 100644 --- a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/MassUpdate.java +++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/MassUpdate.java @@ -67,7 +67,7 @@ public class MassUpdate { this.writeConnection = writeConnection; } - public SqlStatement select(String sql) throws SQLException { + public SqlStatement<Select> select(String sql) throws SQLException { this.select = SelectImpl.create(db, readConnection, sql); return this.select; } diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/AddUuidColumnToGroupsUsers.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/AddUuidColumnToGroupsUsers.java new file mode 100644 index 00000000000..8e3772b1164 --- /dev/null +++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/AddUuidColumnToGroupsUsers.java @@ -0,0 +1,55 @@ +/* + * 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.platform.db.migration.version.v104; + +import java.sql.Connection; +import java.sql.SQLException; +import org.sonar.db.Database; +import org.sonar.db.DatabaseUtils; +import org.sonar.server.platform.db.migration.def.ColumnDef; +import org.sonar.server.platform.db.migration.def.VarcharColumnDef; +import org.sonar.server.platform.db.migration.sql.AddColumnsBuilder; +import org.sonar.server.platform.db.migration.step.DdlChange; + +import static org.sonar.server.platform.db.migration.def.VarcharColumnDef.UUID_SIZE; + +public class AddUuidColumnToGroupsUsers extends DdlChange { + + public static final String GROUPS_USERS_TABLE_NAME = "groups_users"; + public static final String GROUPS_USERS_UUID_COLUMN_NAME = "uuid"; + + public AddUuidColumnToGroupsUsers(Database db) { + super(db); + } + + @Override + public void execute(Context context) throws SQLException { + try (Connection connection = getDatabase().getDataSource().getConnection()) { + if (!DatabaseUtils.tableColumnExists(connection, GROUPS_USERS_TABLE_NAME, GROUPS_USERS_UUID_COLUMN_NAME)) { + ColumnDef columnDef = VarcharColumnDef.newVarcharColumnDefBuilder() + .setColumnName(GROUPS_USERS_UUID_COLUMN_NAME) + .setLimit(UUID_SIZE) + .setIsNullable(true) + .build(); + context.execute(new AddColumnsBuilder(getDialect(), GROUPS_USERS_TABLE_NAME).addColumn(columnDef).build()); + } + } + } +} diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/CreatePrimaryKeyOnGroupsUsersTable.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/CreatePrimaryKeyOnGroupsUsersTable.java new file mode 100644 index 00000000000..549e6a3bed8 --- /dev/null +++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/CreatePrimaryKeyOnGroupsUsersTable.java @@ -0,0 +1,52 @@ +/* + * 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.platform.db.migration.version.v104; + +import com.google.common.annotations.VisibleForTesting; +import java.sql.SQLException; +import org.sonar.db.Database; +import org.sonar.server.platform.db.migration.sql.AddPrimaryKeyBuilder; +import org.sonar.server.platform.db.migration.sql.DbPrimaryKeyConstraintFinder; +import org.sonar.server.platform.db.migration.step.DdlChange; + +import static org.sonar.server.platform.db.migration.version.v104.AddUuidColumnToGroupsUsers.GROUPS_USERS_TABLE_NAME; +import static org.sonar.server.platform.db.migration.version.v104.AddUuidColumnToGroupsUsers.GROUPS_USERS_UUID_COLUMN_NAME; + +public class CreatePrimaryKeyOnGroupsUsersTable extends DdlChange { + + @VisibleForTesting + static final String PK_NAME = "pk_groups_users"; + + public CreatePrimaryKeyOnGroupsUsersTable(Database db) { + super(db); + } + + @Override + public void execute(Context context) throws SQLException { + createPrimaryKey(context); + } + + private void createPrimaryKey(Context context) throws SQLException { + boolean pkExists = new DbPrimaryKeyConstraintFinder(getDatabase()).findConstraintName(GROUPS_USERS_TABLE_NAME).isPresent(); + if (!pkExists) { + context.execute(new AddPrimaryKeyBuilder(GROUPS_USERS_TABLE_NAME, GROUPS_USERS_UUID_COLUMN_NAME).build()); + } + } +} diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/DbVersion104.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/DbVersion104.java index 6a7136534e3..73f7742f355 100644 --- a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/DbVersion104.java +++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/DbVersion104.java @@ -46,6 +46,10 @@ public class DbVersion104 implements DbVersion { .add(10_4_002, "Create table 'rules_tags'", CreateRuleTagsTable.class) .add(10_4_003, "Populate 'rule_tags' table", PopulateRuleTagsTable.class) .add(10_4_004, "Drop column 'tags' in the 'rules' table", DropTagsInRules.class) - .add(10_4_005, "Drop column 'system_tags' in the 'rules' table", DropSystemTagsInRules.class); + .add(10_4_005, "Drop column 'system_tags' in the 'rules' table", DropSystemTagsInRules.class) + .add(10_4_006, "Add 'uuid' column to 'groups_users'", AddUuidColumnToGroupsUsers.class) + .add(10_4_007, "Populate 'uuid' column in 'groups_users'", PopulateGroupsUsersUuid.class) + .add(10_4_008, "Make 'uuid' column in 'groups_users' table non-nullable", MakeUuidInGroupsUsersNotNullable.class) + .add(10_4_009, "Create primary key on 'groups_users.uuid'", CreatePrimaryKeyOnGroupsUsersTable.class); } } diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/MakeUuidInGroupsUsersNotNullable.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/MakeUuidInGroupsUsersNotNullable.java new file mode 100644 index 00000000000..f39d13f173e --- /dev/null +++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/MakeUuidInGroupsUsersNotNullable.java @@ -0,0 +1,48 @@ +/* + * 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.platform.db.migration.version.v104; + +import java.sql.SQLException; +import org.sonar.db.Database; +import org.sonar.server.platform.db.migration.def.VarcharColumnDef; +import org.sonar.server.platform.db.migration.sql.AlterColumnsBuilder; +import org.sonar.server.platform.db.migration.step.DdlChange; + +import static org.sonar.server.platform.db.migration.def.VarcharColumnDef.UUID_SIZE; +import static org.sonar.server.platform.db.migration.version.v104.AddUuidColumnToGroupsUsers.GROUPS_USERS_TABLE_NAME; +import static org.sonar.server.platform.db.migration.version.v104.AddUuidColumnToGroupsUsers.GROUPS_USERS_UUID_COLUMN_NAME; + +public class MakeUuidInGroupsUsersNotNullable extends DdlChange { + + private static final VarcharColumnDef UUID_COLUMN_DEF = VarcharColumnDef.newVarcharColumnDefBuilder(GROUPS_USERS_UUID_COLUMN_NAME) + .setIsNullable(false) + .setLimit(UUID_SIZE) + .build(); + + public MakeUuidInGroupsUsersNotNullable(Database db) { + super(db); + } + + @Override + public void execute(Context context) throws SQLException { + context.execute(new AlterColumnsBuilder(getDialect(), GROUPS_USERS_TABLE_NAME).updateColumn(UUID_COLUMN_DEF).build()); + + } +} diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/PopulateGroupsUsersUuid.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/PopulateGroupsUsersUuid.java new file mode 100644 index 00000000000..4bf8d11ad55 --- /dev/null +++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v104/PopulateGroupsUsersUuid.java @@ -0,0 +1,69 @@ +/* + * 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.platform.db.migration.version.v104; + +import java.sql.SQLException; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.Database; +import org.sonar.server.platform.db.migration.step.DataChange; +import org.sonar.server.platform.db.migration.step.MassUpdate; +import org.sonar.server.platform.db.migration.step.Select; +import org.sonar.server.platform.db.migration.step.SqlStatement; +import org.sonar.server.platform.db.migration.step.Upsert; + +public class PopulateGroupsUsersUuid extends DataChange { + + private static final String SELECT_QUERY = """ + SELECT group_uuid, user_uuid + FROM groups_users + WHERE uuid IS NULL + """; + + private static final String SET_UUID_STATEMENT = """ + UPDATE groups_users + SET uuid=? + WHERE group_uuid=? AND user_uuid=? + """; + + private final UuidFactory uuidFactory; + + public PopulateGroupsUsersUuid(Database db, UuidFactory uuidFactory) { + super(db); + this.uuidFactory = uuidFactory; + } + + @Override + protected void execute(Context context) throws SQLException { + MassUpdate massUpdate = context.prepareMassUpdate(); + SqlStatement<Select> select = massUpdate.select(SELECT_QUERY); + Upsert setUuid = massUpdate.update(SET_UUID_STATEMENT); + try (select; setUuid) { + massUpdate.execute((row, update, index) -> { + String groupUuid = row.getString(1); + String userUuid = row.getString(2); + String uuid = uuidFactory.create(); + update.setString(1, uuid); + update.setString(2, groupUuid); + update.setString(3, userUuid); + return true; + }); + } + } +} diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java index 96aaa999c3c..721a095931d 100644 --- a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java +++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java @@ -163,6 +163,36 @@ public class UserServiceIT { } @Test + public void findUsers_whenFilteringByGroup_returnsCorrectUsers() { + GroupDto groupDto = db.users().insertGroup(); + UserDto user3 = db.users().insertUser(u -> u.setLogin("user3")); + UserDto user2 = db.users().insertUser(u -> u.setLogin("user2")); + db.users().insertUser(u -> u.setLogin("user1")); + + db.users().insertMember(groupDto, user3); + db.users().insertMember(groupDto, user2); + + SearchResults<UserInformation> users = userService.findUsers(UsersSearchRequest.builder().setGroupUuid(groupDto.getUuid()).setPageSize(10).setPage(1).build()); + assertThat(users.searchResults()).extracting(UserInformation::userDto).extracting(UserDto::getLogin) + .containsExactly(user2.getLogin(), user3.getLogin()); + } + + @Test + public void findUsers_whenGroupIsExcluded_returnsCorrectUsers() { + GroupDto groupDto = db.users().insertGroup(); + UserDto user3 = db.users().insertUser(u -> u.setLogin("user3")); + UserDto user2 = db.users().insertUser(u -> u.setLogin("user2")); + UserDto user1 = db.users().insertUser(u -> u.setLogin("user1")); + + db.users().insertMember(groupDto, user3); + db.users().insertMember(groupDto, user2); + + SearchResults<UserInformation> users = userService.findUsers(UsersSearchRequest.builder().setExcludedGroupUuid(groupDto.getUuid()).setPageSize(10).setPage(1).build()); + assertThat(users.searchResults()).extracting(UserInformation::userDto).extracting(UserDto::getLogin) + .containsOnly(user1.getLogin()); + } + + @Test public void return_avatar() { UserDto user = db.users().insertUser(u -> u.setEmail("john@doe.com")); @@ -510,7 +540,7 @@ public class UserServiceIT { db.users().insertToken(user); db.commit(); - userService.deactivate(user.getUuid(),false); + userService.deactivate(user.getUuid(), false); assertThat(db.getDbClient().userTokenDao().selectByUser(dbSession, user)).isEmpty(); } diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/GroupMembershipSearchRequest.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/GroupMembershipSearchRequest.java new file mode 100644 index 00000000000..2fb41f7b6d3 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/GroupMembershipSearchRequest.java @@ -0,0 +1,29 @@ +/* + * 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 javax.annotation.Nullable; + +public record GroupMembershipSearchRequest( + @Nullable String groupUuid, + @Nullable String userUuid +) { + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/GroupMembershipService.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/GroupMembershipService.java new file mode 100644 index 00000000000..843bbc82efb --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/group/service/GroupMembershipService.java @@ -0,0 +1,128 @@ +/* + * 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.List; +import java.util.Optional; +import org.sonar.api.security.DefaultGroups; +import org.sonar.api.server.ServerSide; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.user.GroupDao; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.UserDao; +import org.sonar.db.user.UserDto; +import org.sonar.db.user.UserGroupDao; +import org.sonar.db.user.UserGroupDto; +import org.sonar.db.user.UserGroupQuery; +import org.sonar.server.common.SearchResults; +import org.sonar.server.exceptions.NotFoundException; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.lang.String.format; +import static org.sonar.server.exceptions.BadRequestException.checkRequest; +import static org.sonar.server.exceptions.NotFoundException.checkFound; + +@ServerSide +public class GroupMembershipService { + private final DbClient dbClient; + private final UserGroupDao userGroupDao; + private final UserDao userDao; + private final GroupDao groupDao; + + public GroupMembershipService(DbClient dbClient, UserGroupDao userGroupDao, UserDao userDao, GroupDao groupDao) { + this.dbClient = dbClient; + this.userGroupDao = userGroupDao; + this.userDao = userDao; + this.groupDao = groupDao; + } + + public SearchResults<UserGroupDto> searchMembers(GroupMembershipSearchRequest groupMembershipSearchRequest, int pageIndex, int pageSize) { + try (DbSession dbSession = dbClient.openSession(false)) { + UserGroupQuery query = new UserGroupQuery(null, groupMembershipSearchRequest.groupUuid(), groupMembershipSearchRequest.userUuid()); + int total = userGroupDao.countByQuery(dbSession, query); + if (pageSize == 0) { + return new SearchResults<>(List.of(), total); + } + List<UserGroupDto> userGroupDtos = userGroupDao.selectByQuery(dbSession, query, pageIndex, pageSize); + return new SearchResults<>(userGroupDtos, total); + } + } + + public UserGroupDto addMembership(String groupUuid, String userUuid) { + try (DbSession dbSession = dbClient.openSession(false)) { + UserDto userDto = findUserOrThrow(userUuid, dbSession); + GroupDto groupDto = findNonDefaultGroupOrThrow(groupUuid, dbSession); + UserGroupDto userGroupDto = new UserGroupDto().setGroupUuid(groupUuid).setUserUuid(userUuid); + checkArgument(isNotInGroup(dbSession, groupUuid, userUuid), "User '%s' is already a member of group '%s'", userDto.getLogin(), groupDto.getName()); + userGroupDao.insert(dbSession, userGroupDto, groupDto.getName(), userDto.getLogin()); + dbSession.commit(); + return userGroupDto; + } + } + + private boolean isNotInGroup(DbSession dbSession, String groupUuid, String userUuid) { + return userGroupDao.selectByQuery(dbSession, new UserGroupQuery(null, groupUuid, userUuid), 1, 1).isEmpty(); + } + + public void removeMembership(String groupMembershipUuid) { + try (DbSession dbSession = dbClient.openSession(false)) { + UserGroupDto userGroupDto = findMembershipOrThrow(groupMembershipUuid, dbSession); + removeMembership(userGroupDto.getGroupUuid(), userGroupDto.getUserUuid()); + } + } + + private UserGroupDto findMembershipOrThrow(String groupMembershipUuid, DbSession dbSession) { + return userGroupDao.selectByQuery(dbSession, new UserGroupQuery(groupMembershipUuid, null, null), 1, 1).stream() + .findFirst() + .orElseThrow(() -> new NotFoundException(format("Group membership '%s' not found", groupMembershipUuid))); + } + + public void removeMembership(String groupUuid, String userUuid) { + try (DbSession dbSession = dbClient.openSession(false)) { + UserDto userDto = findUserOrThrow(userUuid, dbSession); + GroupDto groupDto = findNonDefaultGroupOrThrow(groupUuid, dbSession); + ensureLastAdminIsNotRemoved(dbSession, groupUuid, userUuid); + userGroupDao.delete(dbSession, groupDto, userDto); + dbSession.commit(); + } + } + + private GroupDto findNonDefaultGroupOrThrow(String groupUuid, DbSession dbSession) { + GroupDto groupDto = groupDao.selectByUuid(dbSession, groupUuid); + checkFound(groupDto, "Group '%s' not found", groupUuid); + checkArgument(!groupDto.getName().equals(DefaultGroups.USERS), "Default group '%s' cannot be used to perform this action", groupDto.getName()); + return groupDto; + } + + private UserDto findUserOrThrow(String userUuid, DbSession dbSession) { + return Optional.ofNullable(userDao.selectByUuid(dbSession, userUuid)) + .filter(UserDto::isActive) + .orElseThrow(() -> new NotFoundException(format("User '%s' not found", userUuid))); + } + + private void ensureLastAdminIsNotRemoved(DbSession dbSession, String groupUuids, String userUuid) { + int remainingAdmins = dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroupMember(dbSession, + GlobalPermission.ADMINISTER.getKey(), groupUuids, userUuid); + checkRequest(remainingAdmins > 0, "The last administrator user cannot be removed"); + } + +} 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 index bf4dee25832..8479634cb3e 100644 --- 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 @@ -73,6 +73,11 @@ public class GroupService { GroupDto defaultGroup = defaultGroupFinder.findDefaultGroup(dbSession); GroupQuery query = toGroupQuery(groupSearchRequest); + int limit = dbClient.groupDao().countByQuery(dbSession, query); + if (groupSearchRequest.page() == 0) { + return new SearchResults<>(List.of(), limit); + } + List<GroupDto> groups = dbClient.groupDao().selectByQuery(dbSession, query, groupSearchRequest.page(), groupSearchRequest.pageSize()); List<String> groupUuids = extractGroupUuids(groups); Map<String, Boolean> groupUuidToIsManaged = managedInstanceService.getGroupUuidToManaged(dbSession, new HashSet<>(groupUuids)); @@ -81,7 +86,6 @@ public class GroupService { .map(groupDto -> toGroupInformation(groupDto, defaultGroup.getUuid(), groupUuidToIsManaged)) .toList(); - int limit = dbClient.groupDao().countByQuery(dbSession, query); return new SearchResults<>(results, limit); } diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java index 32e091e401c..47cab5bd065 100644 --- a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java @@ -94,7 +94,8 @@ public class UserService { request.getSonarLintLastConnectionDateFrom().ifPresent(builder::sonarLintLastConnectionDateFrom); request.getSonarLintLastConnectionDateTo().ifPresent(builder::sonarLintLastConnectionDateTo); request.getExternalLogin().ifPresent(builder::externalLogin); - + request.getGroupUuid().ifPresent(builder::groupUuid); + request.getExcludedGroupUuid().ifPresent(builder::excludedGroupUuid); if (managedInstanceService.isInstanceExternallyManaged()) { String managedInstanceSql = Optional.ofNullable(request.isManaged()) .map(managedInstanceService::getManagedUsersSqlFilter) diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java index 419e78dae61..3f1b3e8f47c 100644 --- a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java @@ -38,6 +38,8 @@ public class UsersSearchRequest { private final OffsetDateTime sonarLintLastConnectionDateFrom; private final OffsetDateTime sonarLintLastConnectionDateTo; private final String externalLogin; + private final String groupUuid; + private final String excludedGroupUuid; private UsersSearchRequest(Builder builder) { this.page = builder.page; @@ -46,6 +48,8 @@ public class UsersSearchRequest { this.deactivated = builder.deactivated; this.managed = builder.managed; this.externalLogin = builder.externalLogin; + this.groupUuid = builder.groupUuid; + this.excludedGroupUuid = builder.excludedGroupUuid; try { this.lastConnectionDateFrom = Optional.ofNullable(builder.lastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null); this.lastConnectionDateTo = Optional.ofNullable(builder.lastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null); @@ -98,10 +102,18 @@ public class UsersSearchRequest { return Optional.ofNullable(externalLogin); } + public Optional<String> getGroupUuid() { + return Optional.ofNullable(groupUuid); + } + public static Builder builder() { return new Builder(); } + public Optional<String> getExcludedGroupUuid() { + return Optional.ofNullable(excludedGroupUuid); + } + public static class Builder { private Integer page; private Integer pageSize; @@ -113,6 +125,8 @@ public class UsersSearchRequest { private String sonarLintLastConnectionDateFrom; private String sonarLintLastConnectionDateTo; private String externalLogin; + private String groupUuid; + private String excludedGroupUuid; private Builder() { // enforce factory method use @@ -168,6 +182,16 @@ public class UsersSearchRequest { return this; } + public Builder setGroupUuid(@Nullable String groupUuid) { + this.groupUuid = groupUuid; + return this; + } + + public Builder setExcludedGroupUuid(@Nullable String excludedGroupUuid) { + this.excludedGroupUuid = excludedGroupUuid; + return this; + } + public UsersSearchRequest build() { return new UsersSearchRequest(this); } diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/group/service/GroupMembershipServiceTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/group/service/GroupMembershipServiceTest.java new file mode 100644 index 00000000000..bde5921acb9 --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/group/service/GroupMembershipServiceTest.java @@ -0,0 +1,222 @@ +/* + * 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.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.user.GroupDao; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.UserDao; +import org.sonar.db.user.UserDto; +import org.sonar.db.user.UserGroupDao; +import org.sonar.db.user.UserGroupDto; +import org.sonar.db.user.UserGroupQuery; +import org.sonar.server.common.SearchResults; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.exceptions.NotFoundException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +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(MockitoJUnitRunner.class) +public class GroupMembershipServiceTest { + private static final String GROUP_A = "group_a"; + private static final String USER_1 = "user_1"; + private static final String UUID = "1"; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private DbClient dbClient; + @Mock + private UserGroupDao userGroupDao; + @Mock + private UserDao userDao; + @Mock + private GroupDao groupDao; + @Mock + private DbSession dbSession; + @InjectMocks + private GroupMembershipService groupMembershipService; + + @Before + public void setup() { + when(dbClient.openSession(false)).thenReturn(dbSession); + } + + @Test + public void addMembership_ifGroupAndUserNotFound_shouldThrow() { + assertThatExceptionOfType(NotFoundException.class) + .isThrownBy(() -> groupMembershipService.addMembership(GROUP_A, USER_1)) + .withMessage("User 'user_1' not found"); + } + + @Test + public void addMembership_ifGroupNotFound_shouldThrow() { + mockUserDto(); + + assertThatExceptionOfType(NotFoundException.class) + .isThrownBy(() -> groupMembershipService.addMembership(GROUP_A, USER_1)) + .withMessage("Group 'group_a' not found"); + } + + @Test + public void addMembership_ifGroupAndUserFound_shouldAddMemberToGroup() { + GroupDto groupDto = mockGroupDto(); + UserDto userDto = mockUserDto(); + + UserGroupDto userGroupDto = groupMembershipService.addMembership(GROUP_A, USER_1); + + assertThat(userGroupDto.getGroupUuid()).isEqualTo(groupDto.getUuid()); + assertThat(userGroupDto.getUserUuid()).isEqualTo(userDto.getUuid()); + + verify(userGroupDao).insert(dbSession, new UserGroupDto().setGroupUuid(GROUP_A).setUserUuid(USER_1), groupDto.getName(), userDto.getLogin()); + verify(dbSession).commit(); + } + + @Test + public void removeMembership_ifGroupAndUserNotFound_shouldThrow() { + assertThatExceptionOfType(NotFoundException.class) + .isThrownBy(() -> groupMembershipService.removeMembership(GROUP_A, USER_1)) + .withMessage("User 'user_1' not found"); + } + + @Test + public void removeMembership_ifGroupNotFound_shouldThrow() { + mockUserDto(); + + assertThatExceptionOfType(NotFoundException.class) + .isThrownBy(() -> groupMembershipService.removeMembership(GROUP_A, USER_1)) + .withMessage("Group 'group_a' not found"); + } + + @Test + public void removeMembership_ifLastAdmin_shouldThrow() { + mockUserDto(); + mockGroupDto(); + + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(() -> groupMembershipService.removeMembership(GROUP_A, USER_1)) + .withMessage("The last administrator user cannot be removed"); + } + + @Test + public void removeMembership_ifGroupAndUserFound_shouldRemoveMemberFromGroup() { + mockAdminInGroup(GROUP_A, USER_1); + GroupDto groupDto = mockGroupDto(); + UserDto userDto = mockUserDto(); + + groupMembershipService.removeMembership(GROUP_A, USER_1); + + verify(userGroupDao).delete(dbSession, groupDto, userDto); + verify(dbSession).commit(); + } + + @Test + public void removeMemberByMembershipUuid_ifMembershipNotFound_shouldThrow() { + assertThatExceptionOfType(NotFoundException.class) + .isThrownBy(() -> groupMembershipService.removeMembership(UUID)) + .withMessage("Group membership '1' not found"); + } + + @Test + public void removeMemberByMembershipUuid_ifFound_shouldRemoveMemberFromGroup() { + mockAdminInGroup(GROUP_A, USER_1); + + GroupDto groupDto = mockGroupDto(); + UserDto userDto = mockUserDto(); + UserGroupDto userGroupDto = new UserGroupDto().setUuid(UUID).setUserUuid(USER_1).setGroupUuid(GROUP_A); + when(userGroupDao.selectByQuery(any(), any(), anyInt(), anyInt())).thenReturn(List.of(userGroupDto)); + + groupMembershipService.removeMembership(UUID); + + verify(userGroupDao).selectByQuery(dbSession, new UserGroupQuery(UUID, null, null), 1, 1); + verify(userGroupDao).delete(dbSession, groupDto, userDto); + verify(dbSession).commit(); + } + + @Test + public void searchMembers_shouldReturnMembers() { + GroupDto groupDto = mockGroupDto(); + UserDto userDto = mockUserDto(); + + UserGroupDto result = mock(UserGroupDto.class); + when(userGroupDao.selectByQuery(any(), any(), anyInt(), anyInt())).thenReturn(List.of(result)); + when(userGroupDao.countByQuery(any(), any())).thenReturn(10); + + GroupMembershipSearchRequest searchRequest = new GroupMembershipSearchRequest(groupDto.getUuid(), userDto.getUuid()); + SearchResults<UserGroupDto> userGroupDtoSearchResults = groupMembershipService.searchMembers(searchRequest, 1, 10); + + assertThat(userGroupDtoSearchResults.searchResults()).containsOnly(result); + assertThat(userGroupDtoSearchResults.total()).isEqualTo(10); + + verify(userGroupDao).selectByQuery(dbSession, new UserGroupQuery(null, groupDto.getUuid(), userDto.getUuid()), 1, 10); + verify(userGroupDao).countByQuery(dbSession, new UserGroupQuery(null, groupDto.getUuid(), userDto.getUuid())); + } + + @Test + public void searchMembers_withPageSizeEquals0_shouldOnlyComputeTotal() { + when(userGroupDao.countByQuery(any(), any())).thenReturn(10); + + GroupMembershipSearchRequest searchRequest = new GroupMembershipSearchRequest(GROUP_A, USER_1); + SearchResults<UserGroupDto> userGroupDtoSearchResults = groupMembershipService.searchMembers(searchRequest, 1, 0); + + assertThat(userGroupDtoSearchResults.searchResults()).isEmpty(); + assertThat(userGroupDtoSearchResults.total()).isEqualTo(10); + + verify(userGroupDao, never()).selectByQuery(any(), any(), anyInt(), anyInt()); + verify(userGroupDao).countByQuery(dbSession, new UserGroupQuery(null, GROUP_A, USER_1)); + } + + private UserDto mockUserDto() { + UserDto userDto = mock(UserDto.class); + when(userDto.getUuid()).thenReturn(USER_1); + when(userDto.getLogin()).thenReturn("loginA"); + when(userDto.isActive()).thenReturn(true); + when(userDao.selectByUuid(dbSession, USER_1)).thenReturn(userDto); + return userDto; + } + + private GroupDto mockGroupDto() { + GroupDto groupDto = mock(GroupDto.class); + when(groupDto.getUuid()).thenReturn(GROUP_A); + when(groupDto.getName()).thenReturn("name_" + GROUP_A); + when(groupDao.selectByUuid(dbSession, GROUP_A)).thenReturn(groupDto); + return groupDto; + } + + private void mockAdminInGroup(String groupUuid, String userUuid) { + when(dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroupMember(dbSession, + GlobalPermission.ADMINISTER.getKey(), groupUuid, userUuid)).thenReturn(1); + } + +} 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 index 485ff5fd16e..12c1e87a291 100644 --- 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 @@ -390,6 +390,16 @@ public class GroupServiceTest { assertThat(queryCaptor.getValue().getSearchText()).isEqualTo("%QUERY%"); assertThat(queryCaptor.getValue().getIsManagedSqlClause()).isNull(); } + @Test + public void search_whenPageSizeEquals0_returnsOnlyTotal() { + when(dbClient.groupDao().countByQuery(eq(dbSession), any())).thenReturn(10); + + SearchResults<GroupInformation> searchResults = groupService.search(dbSession, new GroupSearchRequest("query", null, 0, 24)); + assertThat(searchResults.total()).isEqualTo(10); + assertThat(searchResults.searchResults()).isEmpty(); + + verify(dbClient.groupDao(), never()).selectByQuery(eq(dbSession), any(), anyInt(), anyInt()); + } @Test public void search_whenInstanceManagedAndManagedIsTrue_addsManagedClause() { 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 7c99e450da1..5e2449c8c6f 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 @@ -30,6 +30,7 @@ public class WebApiEndpoints { public static final String AUTHORIZATIONS_DOMAIN = "/authorizations"; public static final String GROUPS_ENDPOINT = AUTHORIZATIONS_DOMAIN + "/groups"; + public static final String GROUP_MEMBERSHIPS_ENDPOINT = AUTHORIZATIONS_DOMAIN + "/group-memberships"; 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 index 4eafac5232d..55df54b8967 100644 --- 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 @@ -45,7 +45,7 @@ public class DefaultGroupController implements GroupController { private final UserSession userSession; private final ManagedInstanceChecker managedInstanceChecker; - public DefaultGroupController(GroupService groupService, DbClient dbClient, ManagedInstanceChecker managedInstanceChecker, UserSession userSession) { + public DefaultGroupController(UserSession userSession, DbClient dbClient, GroupService groupService, ManagedInstanceChecker managedInstanceChecker) { this.groupService = groupService; this.dbClient = dbClient; this.userSession = userSession; 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 index 2b84b0defeb..d34538470dd 100644 --- 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 @@ -26,8 +26,8 @@ import javax.validation.Valid; import org.sonar.server.v2.api.group.request.GroupCreateRestRequest; import org.sonar.server.v2.api.group.request.GroupUpdateRestRequest; import org.sonar.server.v2.api.group.request.GroupsSearchRestRequest; -import org.sonar.server.v2.api.group.response.GroupsSearchRestResponse; import org.sonar.server.v2.api.group.response.GroupRestResponse; +import org.sonar.server.v2.api.group.response.GroupsSearchRestResponse; import org.sonar.server.v2.api.model.RestPage; import org.springdoc.api.annotations.ParameterObject; import org.springframework.http.HttpStatus; @@ -76,9 +76,8 @@ public interface GroupController { @PatchMapping(path = "/{id}", consumes = JSON_MERGE_PATCH_CONTENT_TYPE, produces = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.OK) - @Operation(summary = "Update a user", description = """ - Update a user. - Allows updating user's name, email and SCM accounts. + @Operation(summary = "Update a group", description = """ + Update a group name or description. """) GroupRestResponse updateGroup(@PathVariable("id") String id, @Valid @RequestBody GroupUpdateRestRequest updateRequest); } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/controller/DefaultGroupMembershipController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/controller/DefaultGroupMembershipController.java new file mode 100644 index 00000000000..cd919a506a5 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/controller/DefaultGroupMembershipController.java @@ -0,0 +1,92 @@ +/* + * 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.membership.controller; + +import java.util.List; +import org.sonar.db.user.UserGroupDto; +import org.sonar.server.common.SearchResults; +import org.sonar.server.common.group.service.GroupMembershipSearchRequest; +import org.sonar.server.common.group.service.GroupMembershipService; +import org.sonar.server.common.management.ManagedInstanceChecker; +import org.sonar.server.user.UserSession; +import org.sonar.server.v2.api.membership.request.GroupMembershipCreateRestRequest; +import org.sonar.server.v2.api.membership.request.GroupsMembershipSearchRestRequest; +import org.sonar.server.v2.api.membership.response.GroupsMembershipSearchRestResponse; +import org.sonar.server.v2.api.membership.response.GroupMembershipRestResponse; +import org.sonar.server.v2.api.model.RestPage; +import org.sonar.server.v2.api.response.PageRestResponse; + +public class DefaultGroupMembershipController implements GroupMembershipController { + + private final GroupMembershipService groupMembershipService; + private final UserSession userSession; + private final ManagedInstanceChecker managedInstanceChecker; + + public DefaultGroupMembershipController(UserSession userSession, GroupMembershipService groupMembershipService, ManagedInstanceChecker managedInstanceChecker) { + this.groupMembershipService = groupMembershipService; + this.userSession = userSession; + this.managedInstanceChecker = managedInstanceChecker; + } + + @Override + public GroupsMembershipSearchRestResponse search(GroupsMembershipSearchRestRequest groupsSearchRestRequest, RestPage restPage) { + userSession.checkLoggedIn().checkIsSystemAdministrator(); + SearchResults<UserGroupDto> groupMembershipSearchResults = searchMembership(groupsSearchRestRequest, restPage); + + List<GroupMembershipRestResponse> groupMembershipRestRespons = toRestGroupMembershipResponse(groupMembershipSearchResults); + return new GroupsMembershipSearchRestResponse(groupMembershipRestRespons, + new PageRestResponse(restPage.pageIndex(), restPage.pageSize(), groupMembershipSearchResults.total()) + ); + } + + private SearchResults<UserGroupDto> searchMembership(GroupsMembershipSearchRestRequest groupsSearchRestRequest, RestPage restPage) { + GroupMembershipSearchRequest groupMembershipSearchRequest = new GroupMembershipSearchRequest(groupsSearchRestRequest.groupId(), groupsSearchRestRequest.userId()); + return groupMembershipService.searchMembers(groupMembershipSearchRequest, restPage.pageIndex(), restPage.pageSize()); + } + + @Override + public void delete(String id) { + throwIfNotAllowedToModifyGroups(); + groupMembershipService.removeMembership(id); + } + + @Override + public GroupMembershipRestResponse create(GroupMembershipCreateRestRequest request) { + throwIfNotAllowedToModifyGroups(); + UserGroupDto userGroupDto = groupMembershipService.addMembership(request.groupId(), request.userId()); + return toRestGroupMembershipResponse(userGroupDto); + } + + private static List<GroupMembershipRestResponse> toRestGroupMembershipResponse(SearchResults<UserGroupDto> groupMembershipSearchResults) { + return groupMembershipSearchResults.searchResults().stream() + .map(DefaultGroupMembershipController::toRestGroupMembershipResponse) + .toList(); + } + + private void throwIfNotAllowedToModifyGroups() { + userSession.checkIsSystemAdministrator(); + managedInstanceChecker.throwIfInstanceIsManaged(); + } + + private static GroupMembershipRestResponse toRestGroupMembershipResponse(UserGroupDto group) { + return new GroupMembershipRestResponse(group.getUuid(), group.getGroupUuid(), group.getUserUuid()); + } + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/controller/GroupMembershipController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/controller/GroupMembershipController.java new file mode 100644 index 00000000000..a3bf27856ab --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/controller/GroupMembershipController.java @@ -0,0 +1,69 @@ +/* + * 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.membership.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import javax.validation.Valid; +import org.sonar.server.v2.api.membership.request.GroupMembershipCreateRestRequest; +import org.sonar.server.v2.api.membership.request.GroupsMembershipSearchRestRequest; +import org.sonar.server.v2.api.membership.response.GroupsMembershipSearchRestResponse; +import org.sonar.server.v2.api.membership.response.GroupMembershipRestResponse; +import org.sonar.server.v2.api.model.RestPage; +import org.springdoc.api.annotations.ParameterObject; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +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.GROUP_MEMBERSHIPS_ENDPOINT; + +@RequestMapping(GROUP_MEMBERSHIPS_ENDPOINT) +@RestController +public interface GroupMembershipController { + + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Search across group memberships", description = """ + Get the list of groups and members matching the query. + """) + GroupsMembershipSearchRestResponse search( + @Valid @ParameterObject GroupsMembershipSearchRestRequest groupsSearchRestRequest, + @Valid @ParameterObject RestPage restPage); + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Add a group membership", description = "Add a user to a group.") + GroupMembershipRestResponse create(@Valid @RequestBody GroupMembershipCreateRestRequest request); + + @DeleteMapping(path = "/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Remove a group membership", description = "Remove a user from a group") + void delete(@PathVariable("id") @Parameter(description = "The ID of the group membership to delete.", required = true, in = ParameterIn.PATH) String id); + + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/controller/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/controller/package-info.java new file mode 100644 index 00000000000..2b1f2f923f7 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/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.membership.controller; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/request/GroupMembershipCreateRestRequest.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/request/GroupMembershipCreateRestRequest.java new file mode 100644 index 00000000000..1e9f072bcb7 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/request/GroupMembershipCreateRestRequest.java @@ -0,0 +1,32 @@ +/* + * 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.membership.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record GroupMembershipCreateRestRequest( + + @Schema(description = "ID of the user to add to group.") + String userId, + + @Schema(description = "ID of the group where a member needs to be added.") + String groupId + +) {} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/request/GroupsMembershipSearchRestRequest.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/request/GroupsMembershipSearchRestRequest.java new file mode 100644 index 00000000000..efa0b20331d --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/request/GroupsMembershipSearchRestRequest.java @@ -0,0 +1,36 @@ +/* + * 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.membership.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import javax.annotation.Nullable; + +public record GroupsMembershipSearchRestRequest( + @Nullable + @Schema(description = "ID of the user for which to search groups. If not set, all groups are returned.") + String userId, + + @Nullable + @Schema(description = "ID of the group for which to search members. If not set, all groups are returned.") + String groupId + +) { + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/request/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/request/package-info.java new file mode 100644 index 00000000000..bfb39bf56de --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/request/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.membership.request; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/response/GroupMembershipRestResponse.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/response/GroupMembershipRestResponse.java new file mode 100644 index 00000000000..1a04e37224d --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/response/GroupMembershipRestResponse.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.membership.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record GroupMembershipRestResponse( + @Schema(accessMode = Schema.AccessMode.READ_ONLY) + String id, + @Schema(accessMode = Schema.AccessMode.READ_ONLY) + String groupId, + @Schema(accessMode = Schema.AccessMode.READ_ONLY) + String userId) { +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/response/GroupsMembershipSearchRestResponse.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/response/GroupsMembershipSearchRestResponse.java new file mode 100644 index 00000000000..71a12ea0d1e --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/response/GroupsMembershipSearchRestResponse.java @@ -0,0 +1,26 @@ +/* + * 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.membership.response; + +import java.util.List; +import org.sonar.server.v2.api.response.PageRestResponse; + +public record GroupsMembershipSearchRestResponse(List<GroupMembershipRestResponse> groupMemberships, PageRestResponse page) { +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/response/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/response/package-info.java new file mode 100644 index 00000000000..648538c4197 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/membership/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.membership.response; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java index 6c0fbb5e947..bf9d8c358b5 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java @@ -56,17 +56,19 @@ public class DefaultUserController implements UserController { } @Override - public UsersSearchRestResponse search(UsersSearchRestRequest usersSearchRestRequest, RestPage page) { - throwIfAdminOnlyParametersAreUsed(usersSearchRestRequest); + public UsersSearchRestResponse search(UsersSearchRestRequest usersSearchRestRequest, @Nullable String excludedGroupId, RestPage page) { + throwIfAdminOnlyParametersAreUsed(usersSearchRestRequest, excludedGroupId); - SearchResults<UserInformation> userSearchResults = userService.findUsers(toUserSearchRequest(usersSearchRestRequest, page)); + SearchResults<UserInformation> userSearchResults = userService.findUsers(toUserSearchRequest(usersSearchRestRequest, excludedGroupId, page)); PaginationInformation paging = forPageIndex(page.pageIndex()).withPageSize(page.pageSize()).andTotal(userSearchResults.total()); return usersSearchResponseGenerator.toUsersForResponse(userSearchResults.searchResults(), paging); } - private void throwIfAdminOnlyParametersAreUsed(UsersSearchRestRequest usersSearchRestRequest) { + private void throwIfAdminOnlyParametersAreUsed(UsersSearchRestRequest usersSearchRestRequest, @Nullable String excludedGroupId) { if (!userSession.isSystemAdministrator()) { + throwIfValuePresent("groupId", usersSearchRestRequest.groupId()); + throwIfValuePresent("groupId!", excludedGroupId); throwIfValuePresent("externalIdentity", usersSearchRestRequest.externalIdentity()); throwIfValuePresent("sonarLintLastConnectionDateFrom", usersSearchRestRequest.sonarLintLastConnectionDateFrom()); throwIfValuePresent("sonarLintLastConnectionDateTo", usersSearchRestRequest.sonarLintLastConnectionDateTo()); @@ -83,7 +85,7 @@ public class DefaultUserController implements UserController { throw new ForbiddenException("Parameter " + parameterName + " requires Administer System permission."); } - private static UsersSearchRequest toUserSearchRequest(UsersSearchRestRequest usersSearchRestRequest, RestPage page) { + private static UsersSearchRequest toUserSearchRequest(UsersSearchRestRequest usersSearchRestRequest, @Nullable String excludedGroupId, RestPage page) { return UsersSearchRequest.builder() .setDeactivated(Optional.ofNullable(usersSearchRestRequest.active()).map(active -> !active).orElse(false)) .setManaged(usersSearchRestRequest.managed()) @@ -93,6 +95,8 @@ public class DefaultUserController implements UserController { .setLastConnectionDateTo(usersSearchRestRequest.sonarQubeLastConnectionDateTo()) .setSonarLintLastConnectionDateFrom(usersSearchRestRequest.sonarLintLastConnectionDateFrom()) .setSonarLintLastConnectionDateTo(usersSearchRestRequest.sonarLintLastConnectionDateTo()) + .setGroupUuid(usersSearchRestRequest.groupId()) + .setExcludedGroupUuid(excludedGroupId) .setPage(page.pageIndex()) .setPageSize(page.pageSize()) .build(); diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java index 9a02a37a1b9..9d703ee6aca 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java @@ -22,6 +22,10 @@ package org.sonar.server.v2.api.user.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import javax.annotation.Nullable; import javax.validation.Valid; import org.sonar.server.v2.api.model.RestPage; import org.sonar.server.v2.api.user.response.UserRestResponse; @@ -67,6 +71,8 @@ public interface UserController { """) UsersSearchRestResponse search( @Valid @ParameterObject UsersSearchRestRequest usersSearchRestRequest, + @RequestParam(name = "groupId!") @Nullable @Schema(description = "Filter users not belonging to group. Only available for system administrators.", + extensions = @Extension(properties = {@ExtensionProperty(name = "internal", value = "true")}), hidden = true) String excludedGroupId, @Valid @ParameterObject RestPage restPage); @DeleteMapping(path = "/{id}") diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java index 7cf37e83c41..d607fe4f6b8 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java @@ -19,6 +19,8 @@ */ package org.sonar.server.v2.api.user.request; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; import io.swagger.v3.oas.annotations.media.Schema; import javax.annotation.Nullable; @@ -62,7 +64,12 @@ public record UsersSearchRestRequest( @Schema(description = "Filter users based on the SonarLint last connection date field. Only users that never connected or who interacted with this instance " + "using SonarLint at or before the date will be returned. The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)", example = "2020-01-01T00:00:00+0100") - String sonarLintLastConnectionDateTo + String sonarLintLastConnectionDateTo, + + @Nullable + @Schema(description = "Filter users belonging to group. Only available for system administrators. Using != operator will exclude users from this group.", + extensions = @Extension(properties = {@ExtensionProperty(name = "internal", value = "true")})) + String groupId ) { 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 95530e69891..42b057f357c 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 @@ -21,6 +21,7 @@ package org.sonar.server.v2.config; import javax.annotation.Nullable; import org.sonar.db.DbClient; +import org.sonar.server.common.group.service.GroupMembershipService; import org.sonar.server.common.group.service.GroupService; import org.sonar.server.common.health.CeStatusNodeCheck; import org.sonar.server.common.health.DbConnectionNodeCheck; @@ -36,6 +37,8 @@ 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.membership.controller.DefaultGroupMembershipController; +import org.sonar.server.v2.api.membership.controller.GroupMembershipController; 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; @@ -81,8 +84,16 @@ public class PlatformLevel4WebConfig { } @Bean - public GroupController groupController(GroupService groupService, DbClient dbClient, ManagedInstanceChecker managedInstanceChecker, UserSession userSession) { - return new DefaultGroupController(groupService, dbClient, managedInstanceChecker, userSession); + public GroupController groupController(UserSession userSession, DbClient dbClient, GroupService groupService, ManagedInstanceChecker managedInstanceChecker) { + return new DefaultGroupController(userSession, dbClient, groupService, managedInstanceChecker); } + + @Bean + public GroupMembershipController groupMembershipsController(UserSession userSession, + GroupMembershipService groupMembershipService, ManagedInstanceChecker managedInstanceChecker) { + return new DefaultGroupMembershipController(userSession, groupMembershipService, managedInstanceChecker); + } + + } 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 index 5ea432b0f79..c736d0236b5 100644 --- 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 @@ -80,7 +80,7 @@ public class DefaultGroupControllerTest { private final DbSession dbSession = mock(); private final ManagedInstanceChecker managedInstanceChecker = mock(); private final MockMvc mockMvc = ControllerTester - .getMockMvc(new DefaultGroupController(groupService, dbClient, managedInstanceChecker, userSession)); + .getMockMvc(new DefaultGroupController(userSession, dbClient, groupService, managedInstanceChecker)); @Before public void setUp() { @@ -88,6 +88,15 @@ public class DefaultGroupControllerTest { } @Test + public void fetchGroup_whenNotAnAdmin_shouldThrow() throws Exception { + userSession.logIn(); + mockMvc.perform(get(GROUPS_ENDPOINT + "/" + GROUP_UUID)) + .andExpectAll( + status().isForbidden(), + content().json("{\"message\":\"Insufficient privileges\"}")); + } + + @Test public void fetchGroup_whenGroupExists_returnsTheGroup() throws Exception { GroupDto groupDto = new GroupDto().setUuid(GROUP_UUID).setName("name").setDescription("description"); @@ -262,13 +271,13 @@ public class DefaultGroupControllerTest { when(groupService.updateGroup(dbSession, groupDto, newName, newDescription)).thenReturn(newGroupInformation); MvcResult mvcResult = mockMvc.perform( - patch(GROUPS_ENDPOINT + "/" + GROUP_UUID).contentType(JSON_MERGE_PATCH_CONTENT_TYPE).content( - """ - { - "name": "%s", - "description": %s - } - """.formatted(newName, newDescription == null ? "null" : "\"" + newDescription + "\""))) + patch(GROUPS_ENDPOINT + "/" + GROUP_UUID).contentType(JSON_MERGE_PATCH_CONTENT_TYPE).content( + """ + { + "name": "%s", + "description": %s + } + """.formatted(newName, newDescription == null ? "null" : "\"" + newDescription + "\""))) .andExpect(status().isOk()) .andReturn(); @@ -384,7 +393,7 @@ public class DefaultGroupControllerTest { public void search_whenCallerIsNotAdmin_shouldReturnForbidden() throws Exception { userSession.logIn().setNonSystemAdministrator(); mockMvc.perform( - get(GROUPS_ENDPOINT + "/" + GROUP_UUID)) + get(GROUPS_ENDPOINT)) .andExpectAll( status().isForbidden(), content().json("{\"message\":\"Insufficient privileges\"}")); diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/membership/controller/DefaultGroupMembershipControllerTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/membership/controller/DefaultGroupMembershipControllerTest.java new file mode 100644 index 00000000000..7dfa7b9ccc0 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/membership/controller/DefaultGroupMembershipControllerTest.java @@ -0,0 +1,246 @@ +/* + * 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.membership.controller; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.db.user.UserGroupDto; +import org.sonar.server.common.SearchResults; +import org.sonar.server.common.group.service.GroupMembershipSearchRequest; +import org.sonar.server.common.group.service.GroupMembershipService; +import org.sonar.server.common.management.ManagedInstanceChecker; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.v2.api.ControllerTester; +import org.sonar.server.v2.api.membership.response.GroupsMembershipSearchRestResponse; +import org.sonar.server.v2.api.membership.response.GroupMembershipRestResponse; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sonar.server.v2.WebApiEndpoints.GROUP_MEMBERSHIPS_ENDPOINT; +import static org.sonar.server.v2.api.model.RestPage.DEFAULT_PAGE_INDEX; +import static org.sonar.server.v2.api.model.RestPage.DEFAULT_PAGE_SIZE; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class DefaultGroupMembershipControllerTest { + private static final String GROUP_UUID = "1234"; + private static final String GROUP_MEMBERSHIP_UUID = "1234"; + private static final String USER_UUID = "abcd"; + private static final String CREATE_PAYLOAD = """ + { + "userId": "%s", + "groupId": "%s" + } + """.formatted(USER_UUID, GROUP_UUID); + + private static final Gson GSON = new GsonBuilder().create(); + + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + private final GroupMembershipService groupMembershipService = mock(); + private final ManagedInstanceChecker managedInstanceChecker = mock(); + + private final MockMvc mockMvc = ControllerTester + .getMockMvc(new DefaultGroupMembershipController(userSession, groupMembershipService, managedInstanceChecker)); + + @Test + public void create_whenCallersIsNotAdmin_shouldReturnForbidden() throws Exception { + userSession.logIn().setNonSystemAdministrator(); + mockMvc.perform( + post(GROUP_MEMBERSHIPS_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(CREATE_PAYLOAD)) + .andExpectAll( + status().isForbidden(), + content().json("{\"message\":\"Insufficient privileges\"}")); + } + + @Test + public void create_whenInstanceIsManaged_shouldReturnException() throws Exception { + userSession.logIn().setSystemAdministrator(); + doThrow(BadRequestException.create("the instance is managed")).when(managedInstanceChecker).throwIfInstanceIsManaged(); + mockMvc.perform( + post(GROUP_MEMBERSHIPS_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(CREATE_PAYLOAD)) + .andExpectAll( + status().isBadRequest(), + content().json("{\"message\":\"the instance is managed\"}")); + } + + @Test + public void create_whenUserIsAnAdmin_shouldReturnCreatedGroup() throws Exception { + userSession.logIn().setSystemAdministrator(); + + UserGroupDto userGroupDto = new UserGroupDto().setUuid(GROUP_MEMBERSHIP_UUID).setGroupUuid(GROUP_UUID).setUserUuid(USER_UUID); + + when(groupMembershipService.addMembership(GROUP_UUID, USER_UUID)).thenReturn(userGroupDto); + + mockMvc.perform( + post(GROUP_MEMBERSHIPS_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(CREATE_PAYLOAD)) + .andExpectAll( + status().isCreated(), + content().json(""" + { + "id": "%s", + "userId": "%s", + "groupId": "%s" + } + + """.formatted(GROUP_MEMBERSHIP_UUID, USER_UUID, GROUP_UUID))); + } + + @Test + public void delete_whenCallersIsNotAdmin_shouldReturnForbidden() throws Exception { + userSession.logIn().setNonSystemAdministrator(); + mockMvc.perform( + delete(GROUP_MEMBERSHIPS_ENDPOINT + "/" + GROUP_MEMBERSHIP_UUID) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(CREATE_PAYLOAD)) + .andExpectAll( + status().isForbidden(), + content().json("{\"message\":\"Insufficient privileges\"}")); + } + + @Test + public void delete_whenInstanceIsManaged_shouldReturnException() throws Exception { + userSession.logIn().setSystemAdministrator(); + doThrow(BadRequestException.create("the instance is managed")).when(managedInstanceChecker).throwIfInstanceIsManaged(); + mockMvc.perform( + delete(GROUP_MEMBERSHIPS_ENDPOINT + "/" + GROUP_MEMBERSHIP_UUID) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(CREATE_PAYLOAD)) + .andExpectAll( + status().isBadRequest(), + content().json("{\"message\":\"the instance is managed\"}")); + } + + @Test + public void delete_whenUserIsAnAdmin_shouldDelete() throws Exception { + userSession.logIn().setSystemAdministrator(); + UserGroupDto userGroupDto = new UserGroupDto().setUuid(GROUP_MEMBERSHIP_UUID).setGroupUuid(GROUP_UUID).setUserUuid(USER_UUID); + + mockMvc.perform( + delete(GROUP_MEMBERSHIPS_ENDPOINT + "/" + GROUP_MEMBERSHIP_UUID) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(CREATE_PAYLOAD)) + .andExpectAll( + status().isNoContent(), + content().string("") + ); + + verify(groupMembershipService).removeMembership(GROUP_MEMBERSHIP_UUID); + } + + @Test + public void search_whenCallerIsNotAdmin_shouldReturnForbidden() throws Exception { + userSession.logIn().setNonSystemAdministrator(); + mockMvc.perform( + get(GROUP_MEMBERSHIPS_ENDPOINT)) + .andExpectAll( + status().isForbidden(), + content().json("{\"message\":\"Insufficient privileges\"}")); + } + + @Test + public void search_whenNoParameters_shouldUseDefaultAndForwardToGroupMembershipService() throws Exception { + userSession.logIn().setSystemAdministrator(); + when(groupMembershipService.searchMembers(any(), anyInt(), anyInt())).thenReturn(new SearchResults<>(List.of(), 0)); + + mockMvc.perform(get(GROUP_MEMBERSHIPS_ENDPOINT)).andExpect(status().isOk()); + + verify(groupMembershipService).searchMembers(new GroupMembershipSearchRequest(null, null), Integer.parseInt(DEFAULT_PAGE_INDEX), Integer.parseInt(DEFAULT_PAGE_SIZE)); + } + + @Test + public void search_whenParametersUsed_shouldForwardWithParameters() throws Exception { + userSession.logIn().setSystemAdministrator(); + when(groupMembershipService.searchMembers(any(), anyInt(), anyInt())).thenReturn(new SearchResults<>(List.of(), 0)); + + mockMvc.perform(get(GROUP_MEMBERSHIPS_ENDPOINT) + .param("userId", USER_UUID) + .param("groupId", GROUP_UUID) + .param("pageSize", "100") + .param("pageIndex", "2")) + .andExpect(status().isOk()); + + verify(groupMembershipService).searchMembers(new GroupMembershipSearchRequest(GROUP_UUID, USER_UUID), 2, 100); + } + + @Test + public void search_whenGroupMembershipServiceReturnGroupMemberships_shouldReturnThem() throws Exception { + userSession.logIn().setSystemAdministrator(); + + UserGroupDto userGroupDto1 = generateUserGroupDto("1"); + UserGroupDto userGroupDto2 = generateUserGroupDto("2"); + UserGroupDto userGroupDto3 = generateUserGroupDto("3"); + List<UserGroupDto> groups = List.of(userGroupDto1, userGroupDto2, userGroupDto3); + SearchResults<UserGroupDto> searchResult = new SearchResults<>(groups, groups.size()); + when(groupMembershipService.searchMembers(any(), anyInt(), anyInt())).thenReturn(searchResult); + + MvcResult mvcResult = mockMvc.perform(get(GROUP_MEMBERSHIPS_ENDPOINT)) + .andExpect(status().isOk()) + .andReturn(); + + GroupsMembershipSearchRestResponse actualGroupsSearchRestResponse = GSON.fromJson(mvcResult.getResponse().getContentAsString(), GroupsMembershipSearchRestResponse.class); + + Map<String, GroupMembershipRestResponse> groupIdToGroupResponse = actualGroupsSearchRestResponse.groupMemberships().stream() + .collect(toMap(GroupMembershipRestResponse::id, identity())); + assertResponseContains(groupIdToGroupResponse, userGroupDto1); + assertResponseContains(groupIdToGroupResponse, userGroupDto2); + assertResponseContains(groupIdToGroupResponse, userGroupDto3); + + assertThat(actualGroupsSearchRestResponse.page().pageIndex()).hasToString(DEFAULT_PAGE_INDEX); + assertThat(actualGroupsSearchRestResponse.page().pageSize()).hasToString(DEFAULT_PAGE_SIZE); + assertThat(actualGroupsSearchRestResponse.page().total()).isEqualTo(groups.size()); + + } + + private void assertResponseContains(Map<String, GroupMembershipRestResponse> groupIdToGroupResponse, UserGroupDto expectedUserGroupDto) { + GroupMembershipRestResponse restGroupMembership = groupIdToGroupResponse.get(expectedUserGroupDto.getUuid()); + assertThat(restGroupMembership).isNotNull(); + assertThat(restGroupMembership.groupId()).isEqualTo(expectedUserGroupDto.getGroupUuid()); + assertThat(restGroupMembership.userId()).isEqualTo(expectedUserGroupDto.getUserUuid()); + } + + private UserGroupDto generateUserGroupDto(String id) { + return new UserGroupDto().setUuid(id).setGroupUuid("uuid_"+id).setUserUuid("user_"+id); + } +} diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java index f055935a9a7..ed920b4830b 100644 --- a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java @@ -110,6 +110,8 @@ public class DefaultUserControllerTest { .param("sonarQubeLastConnectionDateTo", "2020-01-01T00:00:00+0100") .param("sonarLintLastConnectionDateFrom", "2020-01-01T00:00:00+0100") .param("sonarLintLastConnectionDateTo", "2020-01-01T00:00:00+0100") + .param("groupId", "groupId1") + .param("groupId!", "groupId2") .param("pageSize", "100") .param("pageIndex", "2")) .andExpect(status().isOk()); @@ -125,6 +127,8 @@ public class DefaultUserControllerTest { assertThat(requestCaptor.getValue().getLastConnectionDateTo()).contains(parseOffsetDateTime("2020-01-01T00:00:00+0100")); assertThat(requestCaptor.getValue().getSonarLintLastConnectionDateFrom()).contains(parseOffsetDateTime("2020-01-01T00:00:00+0100")); assertThat(requestCaptor.getValue().getSonarLintLastConnectionDateTo()).contains(parseOffsetDateTime("2020-01-01T00:00:00+0100")); + assertThat(requestCaptor.getValue().getGroupUuid()).contains("groupId1"); + assertThat(requestCaptor.getValue().getExcludedGroupUuid()).contains("groupId2"); assertThat(requestCaptor.getValue().getPageSize()).isEqualTo(100); assertThat(requestCaptor.getValue().getPage()).isEqualTo(2); } @@ -160,6 +164,18 @@ public class DefaultUserControllerTest { .andExpectAll( status().isForbidden(), content().string("{\"message\":\"Parameter externalIdentity requires Administer System permission.\"}")); + + mockMvc.perform(get(USER_ENDPOINT) + .param("groupId", "groupId")) + .andExpectAll( + status().isForbidden(), + content().string("{\"message\":\"Parameter groupId requires Administer System permission.\"}")); + + mockMvc.perform(get(USER_ENDPOINT) + .param("groupId!", "groupId")) + .andExpectAll( + status().isForbidden(), + content().string("{\"message\":\"Parameter groupId! requires Administer System permission.\"}")); } @Test diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/AddUserActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/AddUserActionIT.java index 99f386075fa..897895254b5 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/AddUserActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/AddUserActionIT.java @@ -27,10 +27,11 @@ import org.sonar.api.server.ws.WebService.Action; import org.sonar.db.DbTester; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserDto; +import org.sonar.server.common.group.service.GroupMembershipService; +import org.sonar.server.common.management.ManagedInstanceChecker; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.exceptions.UnauthorizedException; -import org.sonar.server.common.management.ManagedInstanceChecker; import org.sonar.server.tester.UserSessionRule; import org.sonar.server.usergroups.DefaultGroupFinder; import org.sonar.server.ws.TestRequest; @@ -39,6 +40,7 @@ import org.sonar.server.ws.WsActionTester; import static java.net.HttpURLConnection.HTTP_NO_CONTENT; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; import static org.mockito.Mockito.doThrow; @@ -56,7 +58,10 @@ public class AddUserActionIT { private ManagedInstanceChecker managedInstanceChecker = mock(ManagedInstanceChecker.class); - private final WsActionTester ws = new WsActionTester(new AddUserAction(db.getDbClient(), userSession, newGroupWsSupport(), managedInstanceChecker)); + private GroupMembershipService groupMembershipService = new GroupMembershipService(db.getDbClient(), db.getDbClient().userGroupDao(), db.getDbClient().userDao(), db.getDbClient() + .groupDao()); + + private final WsActionTester ws = new WsActionTester(new AddUserAction(db.getDbClient(), userSession, newGroupWsSupport(), managedInstanceChecker, groupMembershipService)); @Test public void verify_definition() { @@ -70,7 +75,6 @@ public class AddUserActionIT { tuple("8.4", "Parameter 'id' is deprecated. Format changes from integer to string. Use 'name' instead.")); } - @Test public void add_user_to_group_referenced_by_its_name() { insertDefaultGroup(); @@ -104,20 +108,19 @@ public class AddUserActionIT { } @Test - public void do_not_fail_if_user_is_already_member_of_group() { + public void fail_if_user_is_already_member_of_group() { insertDefaultGroup(); - GroupDto users = db.users().insertGroup(); - UserDto user = db.users().insertUser(); - db.users().insertMember(users, user); + GroupDto group = db.users().insertGroup(g -> g.setName("group1")); + UserDto user = db.users().insertUser(u -> u.setLogin("user1")); + db.users().insertMember(group, user); loginAsAdmin(); - newRequest() - .setParam(PARAM_GROUP_NAME, users.getName()) - .setParam(PARAM_LOGIN, user.getLogin()) - .execute(); + TestRequest testRequest = newRequest() + .setParam(PARAM_GROUP_NAME, group.getName()) + .setParam(PARAM_LOGIN, user.getLogin()); - // do not insert duplicated row - assertThat(db.users().selectGroupUuidsOfUser(user)).hasSize(1).containsOnly(users.getUuid()); + assertThatIllegalArgumentException().isThrownBy(testRequest::execute) + .withMessage("User 'user1' is already a member of group 'group1'"); } @Test @@ -205,20 +208,6 @@ public class AddUserActionIT { } @Test - public void fail_when_no_default_group() { - GroupDto group = db.users().insertGroup(); - UserDto user = db.users().insertUser(); - loginAsAdmin(); - TestRequest request = newRequest() - .setParam(PARAM_LOGIN, user.getLogin()) - .setParam(PARAM_GROUP_NAME, group.getName()); - - assertThatThrownBy(request::execute) - .isInstanceOf(IllegalStateException.class) - .hasMessage("Default group cannot be found"); - } - - @Test public void fail_if_instance_is_externally_managed() { loginAsAdmin(); BadRequestException exception = BadRequestException.create("Not allowed"); diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/RemoveUserActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/RemoveUserActionIT.java index 69a3c6913db..0519c80e3ad 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/RemoveUserActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/usergroups/ws/RemoveUserActionIT.java @@ -27,10 +27,11 @@ import org.sonar.api.server.ws.WebService.Action; import org.sonar.db.DbTester; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserDto; +import org.sonar.server.common.group.service.GroupMembershipService; +import org.sonar.server.common.management.ManagedInstanceChecker; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.NotFoundException; -import org.sonar.server.common.management.ManagedInstanceChecker; import org.sonar.server.tester.UserSessionRule; import org.sonar.server.usergroups.DefaultGroupFinder; import org.sonar.server.ws.TestRequest; @@ -55,8 +56,12 @@ public class RemoveUserActionIT { public UserSessionRule userSession = UserSessionRule.standalone(); private ManagedInstanceChecker managedInstanceChecker = mock(ManagedInstanceChecker.class); + + private final GroupMembershipService groupMembershipService = new GroupMembershipService(db.getDbClient(), db.getDbClient().userGroupDao(), db.getDbClient().userDao(), + db.getDbClient().groupDao()); private final WsActionTester ws = new WsActionTester( - new RemoveUserAction(db.getDbClient(), userSession, new GroupWsSupport(db.getDbClient(), new DefaultGroupFinder(db.getDbClient())), managedInstanceChecker)); + new RemoveUserAction(db.getDbClient(), userSession, new GroupWsSupport(db.getDbClient(), new DefaultGroupFinder(db.getDbClient())), managedInstanceChecker, + groupMembershipService)); @Test public void verify_definition() { diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/AddUserAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/AddUserAction.java index abc7d8577fd..206c0a4540a 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/AddUserAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/AddUserAction.java @@ -28,7 +28,7 @@ import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserDto; -import org.sonar.db.user.UserGroupDto; +import org.sonar.server.common.group.service.GroupMembershipService; import org.sonar.server.common.management.ManagedInstanceChecker; import org.sonar.server.user.UserSession; @@ -47,19 +47,23 @@ public class AddUserAction implements UserGroupsWsAction { private final GroupWsSupport support; private final ManagedInstanceChecker managedInstanceChecker; - public AddUserAction(DbClient dbClient, UserSession userSession, GroupWsSupport support, ManagedInstanceChecker managedInstanceChecker) { + private final GroupMembershipService groupMembershipService; + + public AddUserAction(DbClient dbClient, UserSession userSession, GroupWsSupport support, ManagedInstanceChecker managedInstanceChecker, + GroupMembershipService groupMembershipService) { this.dbClient = dbClient; this.userSession = userSession; this.support = support; this.managedInstanceChecker = managedInstanceChecker; + this.groupMembershipService = groupMembershipService; } @Override public void define(NewController context) { NewAction action = context.createAction("add_user") .setDescription(format("Add a user to a group.<br />" + - "'%s' must be provided.<br />" + - "Requires the following permission: 'Administer System'.", PARAM_GROUP_NAME)) + "'%s' must be provided.<br />" + + "Requires the following permission: 'Administer System'.", PARAM_GROUP_NAME)) .setHandler(this) .setPost(true) .setSince("5.2") @@ -79,22 +83,14 @@ public class AddUserAction implements UserGroupsWsAction { GroupDto group = support.findGroupDto(dbSession, request); String login = request.mandatoryParam(PARAM_LOGIN); - UserDto user = dbClient.userDao().selectActiveUserByLogin(dbSession, login); + UserDto user = dbClient.userDao().selectByLogin(dbSession, login); checkFound(user, "Could not find a user with login '%s'", login); - support.checkGroupIsNotDefault(dbSession, group); - - if (!isMemberOf(dbSession, user, group)) { - UserGroupDto membershipDto = new UserGroupDto().setGroupUuid(group.getUuid()).setUserUuid(user.getUuid()); - dbClient.userGroupDao().insert(dbSession, membershipDto, group.getName(), login); - dbSession.commit(); - } + groupMembershipService.addMembership(group.getUuid(), user.getUuid()); + dbSession.commit(); response.noContent(); } } - private boolean isMemberOf(DbSession dbSession, UserDto user, GroupDto group) { - return dbClient.groupMembershipDao().selectGroupUuidsByUserUuid(dbSession, user.getUuid()).contains(group.getUuid()); - } } diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/RemoveUserAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/RemoveUserAction.java index c0ca2d56bc8..eb5227df95d 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/RemoveUserAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usergroups/ws/RemoveUserAction.java @@ -29,11 +29,11 @@ import org.sonar.db.DbSession; import org.sonar.db.permission.GlobalPermission; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserDto; +import org.sonar.server.common.group.service.GroupMembershipService; import org.sonar.server.common.management.ManagedInstanceChecker; import org.sonar.server.user.UserSession; import static java.lang.String.format; -import static org.sonar.server.exceptions.BadRequestException.checkRequest; import static org.sonar.server.exceptions.NotFoundException.checkFound; import static org.sonar.server.usergroups.ws.GroupWsSupport.PARAM_GROUP_NAME; import static org.sonar.server.usergroups.ws.GroupWsSupport.PARAM_LOGIN; @@ -45,22 +45,24 @@ public class RemoveUserAction implements UserGroupsWsAction { private final DbClient dbClient; private final UserSession userSession; private final GroupWsSupport support; - private final ManagedInstanceChecker managedInstanceChecker; + private final GroupMembershipService groupMembershipService; - public RemoveUserAction(DbClient dbClient, UserSession userSession, GroupWsSupport support, ManagedInstanceChecker managedInstanceChecker) { + public RemoveUserAction(DbClient dbClient, UserSession userSession, GroupWsSupport support, ManagedInstanceChecker managedInstanceChecker, + GroupMembershipService groupMembershipService) { this.dbClient = dbClient; this.userSession = userSession; this.support = support; this.managedInstanceChecker = managedInstanceChecker; + this.groupMembershipService = groupMembershipService; } @Override public void define(NewController context) { NewAction action = context.createAction("remove_user") .setDescription(format("Remove a user from a group.<br />" + - "'%s' must be provided.<br>" + - "Requires the following permission: 'Administer System'.", PARAM_GROUP_NAME)) + "'%s' must be provided.<br>" + + "Requires the following permission: 'Administer System'.", PARAM_GROUP_NAME)) .setHandler(this) .setPost(true) .setSince("5.2") @@ -75,34 +77,16 @@ public class RemoveUserAction implements UserGroupsWsAction { @Override public void handle(Request request, Response response) throws Exception { userSession.checkLoggedIn(); - + userSession.checkPermission(GlobalPermission.ADMINISTER); + managedInstanceChecker.throwIfInstanceIsManaged(); try (DbSession dbSession = dbClient.openSession(false)) { - userSession.checkPermission(GlobalPermission.ADMINISTER); - managedInstanceChecker.throwIfInstanceIsManaged(); - GroupDto group = support.findGroupDto(dbSession, request); - support.checkGroupIsNotDefault(dbSession, group); - - String login = request.mandatoryParam(PARAM_LOGIN); - UserDto user = getUser(dbSession, login); - - ensureLastAdminIsNotRemoved(dbSession, group, user); - - dbClient.userGroupDao().delete(dbSession, group, user); - dbSession.commit(); - + GroupDto groupDto = support.findGroupDto(dbSession, request); + UserDto userDto = getUser(dbSession, request.mandatoryParam(PARAM_LOGIN)); + groupMembershipService.removeMembership(groupDto.getUuid(), userDto.getUuid()); response.noContent(); } } - /** - * Ensure that there are still users with admin global permission if user is removed from the group. - */ - private void ensureLastAdminIsNotRemoved(DbSession dbSession, GroupDto group, UserDto user) { - int remainingAdmins = dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroupMember(dbSession, - GlobalPermission.ADMINISTER.getKey(), group.getUuid(), user.getUuid()); - checkRequest(remainingAdmins > 0, "The last administrator user cannot be removed"); - } - private UserDto getUser(DbSession dbSession, String userLogin) { return checkFound(dbClient.userDao().selectActiveUserByLogin(dbSession, userLogin), "User with login '%s' is not found'", userLogin); 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 9f92f4881cd..0b27ae1a17e 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,7 +20,6 @@ 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 { @@ -31,7 +30,6 @@ public class UserGroupsModule extends Module { UserGroupsWs.class, GroupWsSupport.class, ManagedInstanceChecker.class, - GroupService.class, // actions SearchAction.class, CreateAction.class, diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java index c55b30243a9..10c159af6f8 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java @@ -31,6 +31,7 @@ import org.sonar.alm.client.github.GithubApplicationClientImpl; import org.sonar.alm.client.github.GithubApplicationHttpClientImpl; import org.sonar.alm.client.github.GithubGlobalSettingsValidator; import org.sonar.alm.client.github.GithubPaginatedHttpClientImpl; +import org.sonar.alm.client.github.GithubPermissionConverter; import org.sonar.alm.client.github.RatioBasedRateLimitChecker; import org.sonar.alm.client.github.config.GithubProvisioningConfigValidator; import org.sonar.alm.client.github.security.GithubAppSecurityImpl; @@ -41,7 +42,6 @@ import org.sonar.api.server.rule.RulesDefinitionXmlLoader; import org.sonar.auth.bitbucket.BitbucketModule; import org.sonar.auth.github.GitHubModule; import org.sonar.auth.github.GitHubSettings; -import org.sonar.alm.client.github.GithubPermissionConverter; import org.sonar.auth.gitlab.GitLabModule; import org.sonar.auth.ldap.LdapModule; import org.sonar.auth.saml.SamlModule; @@ -78,6 +78,8 @@ import org.sonar.server.branch.ws.BranchWsModule; import org.sonar.server.ce.CeModule; import org.sonar.server.ce.projectdump.ProjectExportWsModule; import org.sonar.server.ce.ws.CeWsModule; +import org.sonar.server.common.group.service.GroupMembershipService; +import org.sonar.server.common.group.service.GroupService; import org.sonar.server.component.ComponentCleanerService; import org.sonar.server.component.ComponentFinder; import org.sonar.server.component.ComponentService; @@ -392,6 +394,8 @@ public class PlatformLevel4 extends PlatformLevel { new LdapModule(), new SamlModule(), new SamlValidationModule(), + GroupService.class, + GroupMembershipService.class, DefaultAdminCredentialsVerifierImpl.class, DefaultAdminCredentialsVerifierNotificationTemplate.class, DefaultAdminCredentialsVerifierNotificationHandler.class, |