diff options
author | Antoine Vigneau <antoine.vigneau@sonarsource.com> | 2023-07-25 21:07:09 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-07-28 20:03:15 +0000 |
commit | c43733d1db5a9aa18b9b8985ad2a19972421987d (patch) | |
tree | 512938ea09fc2bc2d7453d6c9abbf969ee8fc112 /server | |
parent | 3994545c5d4313228716935ec752c0eb1253a077 (diff) | |
download | sonarqube-c43733d1db5a9aa18b9b8985ad2a19972421987d.tar.gz sonarqube-c43733d1db5a9aa18b9b8985ad2a19972421987d.zip |
SONAR-19969 Support apiv2 POST /users to create a user
Diffstat (limited to 'server')
15 files changed, 641 insertions, 75 deletions
diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserCreateRequestTest.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserCreateRequestTest.java new file mode 100644 index 00000000000..1a77e523797 --- /dev/null +++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserCreateRequestTest.java @@ -0,0 +1,49 @@ +/* + * 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.user.service; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class UserCreateRequestTest { + @Test + public void build_whenNoPasswordAndUserLocal_shouldThrow() { + UserCreateRequest.Builder requestBuilder = UserCreateRequest.builder() + .setPassword(null) + .setLocal(true); + + assertThatThrownBy(requestBuilder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Password is mandatory and must not be empty"); + } + + @Test + public void build_whenPasswordSetButUserNonLocal_shouldThrow() { + UserCreateRequest.Builder requestBuilder = UserCreateRequest.builder() + .setPassword("password") + .setLocal(false); + + assertThatThrownBy(requestBuilder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Password should only be set on local user"); + } + +} 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 3a42305da8c..3c65ea743af 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 @@ -22,18 +22,23 @@ package org.sonar.server.common.user.service; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Collection; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.stream.IntStream; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.sonar.api.config.internal.MapSettings; import org.sonar.api.utils.DateUtils; import org.sonar.core.util.UuidFactory; import org.sonar.db.DbTester; +import org.sonar.db.audit.NoOpAuditPersister; import org.sonar.db.scim.ScimUserDao; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserDto; +import org.sonar.server.authentication.CredentialsLocalAuthentication; import org.sonar.server.common.SearchResults; import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.common.management.ManagedInstanceChecker; @@ -41,7 +46,11 @@ import org.sonar.server.common.user.UserDeactivator; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.management.ManagedInstanceService; +import org.sonar.server.user.NewUserNotifier; +import org.sonar.server.user.UserUpdater; +import org.sonar.server.usergroups.DefaultGroupFinder; +import static java.lang.String.format; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; import static java.util.function.Function.identity; @@ -57,20 +66,29 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.sonar.db.user.UserTesting.newUserDto; public class UserServiceIT { private static final UsersSearchRequest SEARCH_REQUEST = getBuilderWithDefaultsPageSize().build(); + private GroupDto defaultGroup; + @Rule public DbTester db = DbTester.create(); - private final ManagedInstanceService managedInstanceService = mock(ManagedInstanceService.class); - private final ManagedInstanceChecker managedInstanceChecker = mock(ManagedInstanceChecker.class); - private final UserDeactivator userDeactivator = mock(UserDeactivator.class); + private final MapSettings settings = new MapSettings().setProperty("sonar.internal.pbkdf2.iterations", "1"); + private final CredentialsLocalAuthentication localAuthentication = new CredentialsLocalAuthentication(db.getDbClient(), settings.asConfig()); + private final UserUpdater userUpdater = new UserUpdater(mock(NewUserNotifier.class), db.getDbClient(), new DefaultGroupFinder(db.getDbClient()), + settings.asConfig(), new NoOpAuditPersister(), localAuthentication); - private final UserService userService = new UserService(db.getDbClient(), new AvatarResolverImpl(), managedInstanceService, managedInstanceChecker, userDeactivator); + private final UserService userService = new UserService(db.getDbClient(), new AvatarResolverImpl(), managedInstanceService, managedInstanceChecker, userDeactivator, userUpdater); + + @Before + public void setUp() { + defaultGroup = db.users().insertDefaultGroup(); + } @Test public void search_for_all_active_users() { @@ -482,4 +500,157 @@ public class UserServiceIT { private static UsersSearchRequest.Builder getBuilderWithDefaultsPageSize() { return UsersSearchRequest.builder().setPage(1).setPageSize(50); } + + @Test + public void createUser_shouldCreateLocalUser() { + UserCreateRequest userCreateRequest = UserCreateRequest.builder() + .setLogin("john") + .setName("John") + .setEmail("john@email.com") + .setScmAccounts(singletonList("jn")) + .setPassword("1234") + .setLocal(true) + .build(); + + UserSearchResult user = userService.createUser(userCreateRequest); + + assertThat(user.userDto()) + .extracting(UserDto::getLogin, UserDto::getName, UserDto::getEmail, UserDto::getSortedScmAccounts, UserDto::isLocal) + .containsOnly("john", "John", "john@email.com", singletonList("jn"), true); + + Optional<UserDto> dbUser = db.users().selectUserByLogin("john"); + assertThat(dbUser).isPresent(); + + assertThat(db.users().selectGroupUuidsOfUser(dbUser.get())).containsOnly(defaultGroup.getUuid()); + } + + @Test + public void createUser_shouldCreateNonLocalUser() { + UserCreateRequest userCreateRequest = UserCreateRequest.builder() + .setLogin("john") + .setName("John") + .setLocal(false) + .build(); + + userService.createUser(userCreateRequest); + + assertThat(db.users().selectUserByLogin("john").get()) + .extracting(UserDto::isLocal, UserDto::getExternalIdentityProvider, UserDto::getExternalLogin) + .containsOnly(false, "sonarqube", "john"); + } + + @Test + public void createUser_shouldHandleCommasInScmAccounts() { + UserCreateRequest userCreateRequest = UserCreateRequest.builder() + .setLogin("john") + .setName("John") + .setEmail("john@email.com") + .setScmAccounts(singletonList("j,n")) + .setPassword("1234") + .setLocal(true) + .build(); + + UserSearchResult user = userService.createUser(userCreateRequest); + + assertThat(user.userDto().getSortedScmAccounts()).containsOnly("j,n"); + } + + @Test + public void createUser_whenWhitespaceInScmAccounts_shouldFail() { + UserCreateRequest userCreateRequest = UserCreateRequest.builder() + .setLogin("john") + .setName("John") + .setEmail("john@email.com") + .setScmAccounts(List.of("admin", " admin ")) + .setPassword("1234") + .setLocal(true) + .build(); + + assertThatThrownBy(() -> userService.createUser(userCreateRequest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SCM account cannot start or end with whitespace: ' admin '"); + } + + @Test + public void createUser_whenDuplicatesInScmAccounts_shouldFail() { + UserCreateRequest userCreateRequest = UserCreateRequest.builder() + .setLogin("john") + .setName("John") + .setEmail("john@email.com") + .setScmAccounts(List.of("admin", "admin")) + .setPassword("1234") + .setLocal(true) + .build(); + + assertThatThrownBy(() -> userService.createUser(userCreateRequest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Duplicate SCM account: 'admin'"); + } + + @Test + public void createUser_whenEmptyEmail_shouldCreateUser() { + UserCreateRequest userCreateRequest = UserCreateRequest.builder() + .setLogin("john") + .setName("John") + .setPassword("1234") + .setEmail("") + .setLocal(true) + .build(); + + userService.createUser(userCreateRequest); + + assertThat(db.users().selectUserByLogin("john").get()) + .extracting(UserDto::getExternalLogin) + .isEqualTo("john"); + } + + @Test + public void createUser_whenDeactivatedUserExists_shouldReactivate() { + db.users().insertUser(newUserDto("john", "John", "john@email.com").setActive(false)); + + UserCreateRequest userCreateRequest = UserCreateRequest.builder() + .setLogin("john") + .setName("John") + .setEmail("john@email.com") + .setScmAccounts(singletonList("jn")) + .setPassword("1234") + .setLocal(true) + .build(); + + userService.createUser(userCreateRequest); + + assertThat(db.users().selectUserByLogin("john").get().isActive()).isTrue(); + } + + @Test + public void createUser_whenActiveUserExists_shouldThrow() { + UserDto user = db.users().insertUser(); + + UserCreateRequest userCreateRequest = UserCreateRequest.builder() + .setLogin(user.getLogin()) + .setName("John") + .setPassword("1234") + .setLocal(true) + .build(); + + assertThatThrownBy(() -> userService.createUser(userCreateRequest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(format("An active user with login '%s' already exists", user.getLogin())); + } + + @Test + public void createUser_whenInstanceManaged_shouldThrow() { + BadRequestException badRequestException = BadRequestException.create("message"); + doThrow(badRequestException).when(managedInstanceChecker).throwIfInstanceIsManaged(); + + UserCreateRequest userCreateRequest = UserCreateRequest.builder() + .setLogin("john") + .setName("John") + .setLocal(false) + .build(); + + assertThatThrownBy(() -> userService.createUser(userCreateRequest)) + .isEqualTo(badRequestException); + } + } diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserCreateRequest.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserCreateRequest.java new file mode 100644 index 00000000000..24618137681 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserCreateRequest.java @@ -0,0 +1,122 @@ +/* + * 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.user.service; + +import java.util.List; +import java.util.Optional; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Strings.isNullOrEmpty; + +public class UserCreateRequest { + private final String email; + private final Boolean local; + private final String login; + private final String name; + private final String password; + private final List<String> scmAccounts; + + private UserCreateRequest(Builder builder) { + this.email = builder.email; + this.local = builder.local; + this.login = builder.login; + this.name = builder.name; + this.password = builder.password; + this.scmAccounts = builder.scmAccounts; + } + + public Optional<String> getEmail() { + return Optional.ofNullable(email); + } + + public Boolean isLocal() { + return local; + } + + public String getLogin() { + return login; + } + + public String getName() { + return name; + } + + public Optional<String> getPassword() { + return Optional.ofNullable(password); + } + + public Optional<List<String>> getScmAccounts() { + return Optional.ofNullable(scmAccounts); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String email; + private Boolean local; + private String login; + private String name; + private String password; + private List<String> scmAccounts; + + private Builder() { + // enforce factory method use + } + + public Builder setEmail(String email) { + this.email = email; + return this; + } + + public Builder setLocal(Boolean local) { + this.local = local; + return this; + } + + public Builder setLogin(String login) { + this.login = login; + return this; + } + + public Builder setName(String name) { + this.name = name; + return this; + } + + public Builder setPassword(String password) { + this.password = password; + return this; + } + + public Builder setScmAccounts(List<String> scmAccounts) { + this.scmAccounts = scmAccounts; + return this; + } + + public UserCreateRequest build() { + checkArgument(!local || !isNullOrEmpty(password), "Password is mandatory and must not be empty"); + checkArgument(local || isNullOrEmpty(password), "Password should only be set on local user"); + return new UserCreateRequest(this); + } + } + +} 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 69fabcc4c9e..c8957fb3c97 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 @@ -20,7 +20,9 @@ package org.sonar.server.common.user.service; import com.google.common.collect.Multimap; +import java.util.ArrayList; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -36,9 +38,15 @@ import org.sonar.server.common.management.ManagedInstanceChecker; import org.sonar.server.common.user.UserDeactivator; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.management.ManagedInstanceService; +import org.sonar.server.user.ExternalIdentity; +import org.sonar.server.user.NewUser; +import org.sonar.server.user.UserUpdater; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Strings.emptyToNull; import static java.util.Comparator.comparing; import static org.sonar.server.exceptions.NotFoundException.checkFound; +import static org.sonar.server.user.ExternalIdentity.SQ_AUTHORITY; public class UserService { @@ -47,18 +55,21 @@ public class UserService { private final ManagedInstanceService managedInstanceService; private final ManagedInstanceChecker managedInstanceChecker; private final UserDeactivator userDeactivator; + private final UserUpdater userUpdater; public UserService( DbClient dbClient, AvatarResolver avatarResolver, ManagedInstanceService managedInstanceService, ManagedInstanceChecker managedInstanceChecker, - UserDeactivator userDeactivator) { + UserDeactivator userDeactivator, + UserUpdater userUpdater) { this.dbClient = dbClient; this.avatarResolver = avatarResolver; this.managedInstanceService = managedInstanceService; this.managedInstanceChecker = managedInstanceChecker; this.userDeactivator = userDeactivator; + this.userUpdater = userUpdater; } public SearchResults<UserSearchResult> findUsers(UsersSearchRequest request) { @@ -116,7 +127,7 @@ public class UserService { } private Optional<String> findAvatar(UserDto userDto) { - return Optional.ofNullable(userDto.getEmail()).map(email -> avatarResolver.create(userDto)); + return Optional.ofNullable(emptyToNull(userDto.getEmail())).map(email -> avatarResolver.create(userDto)); } private static Set<String> getUserUuids(List<UserDto> users) { @@ -156,4 +167,51 @@ public class UserService { groups, tokenCount); } + + public UserSearchResult createUser(UserCreateRequest userCreateRequest) { + managedInstanceChecker.throwIfInstanceIsManaged(); + List<String> scmAccounts = userCreateRequest.getScmAccounts().orElse(new ArrayList<>()); + validateScmAccounts(scmAccounts); + try (DbSession dbSession = dbClient.openSession(false)) { + String login = userCreateRequest.getLogin(); + NewUser.Builder newUserBuilder = NewUser.builder() + .setLogin(login) + .setName(userCreateRequest.getName()) + .setEmail(userCreateRequest.getEmail().orElse(null)) + .setScmAccounts(scmAccounts) + .setPassword(userCreateRequest.getPassword().orElse(null)); + if (Boolean.FALSE.equals(userCreateRequest.isLocal())) { + newUserBuilder.setExternalIdentity(new ExternalIdentity(SQ_AUTHORITY, login, login)); + } + return registerUser(dbSession, login, newUserBuilder); + } + } + + private UserSearchResult registerUser(DbSession dbSession, String login, NewUser.Builder newUserBuilder) { + UserDto user = dbClient.userDao().selectByLogin(dbSession, login); + if (user == null) { + user = userUpdater.createAndCommit(dbSession, newUserBuilder.build(), u -> {}); + } else { + checkArgument(!user.isActive(), "An active user with login '%s' already exists", login); + user = userUpdater.reactivateAndCommit(dbSession, user, newUserBuilder.build(), u -> {}); + } + return fetchUser(user.getLogin()); + } + + public static void validateScmAccounts(List<String> scmAccounts) { + scmAccounts.forEach(UserService::validateScmAccountFormat); + validateNoDuplicates(scmAccounts); + } + + private static void validateScmAccountFormat(String scmAccount) { + checkArgument(scmAccount.equals(scmAccount.strip()), "SCM account cannot start or end with whitespace: '%s'", scmAccount); + } + + private static void validateNoDuplicates(List<String> scmAccounts) { + Set<String> duplicateCheck = new HashSet<>(); + for (String account : scmAccounts) { + checkArgument(duplicateCheck.add(account), "Duplicate SCM account: '%s'", account); + } + } + } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java index ecd31afbc55..8336a2385b9 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java @@ -34,6 +34,7 @@ public record RestPage( description = "Number of results per page", schema = @Schema(defaultValue = DEFAULT_PAGE_SIZE, implementation = Integer.class)) Integer pageSize, + @Positive @Parameter( description = "1-based page number", 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 40c9ad6e192..0cdaa1c13c9 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 @@ -23,6 +23,7 @@ import java.util.Optional; import javax.annotation.Nullable; import org.sonar.api.utils.Paging; import org.sonar.server.common.SearchResults; +import org.sonar.server.common.user.service.UserCreateRequest; import org.sonar.server.common.user.service.UserSearchResult; import org.sonar.server.common.user.service.UserService; import org.sonar.server.common.user.service.UsersSearchRequest; @@ -31,6 +32,7 @@ import org.sonar.server.user.UserSession; import org.sonar.server.v2.api.model.RestPage; import org.sonar.server.v2.api.user.converter.UsersSearchRestResponseGenerator; import org.sonar.server.v2.api.user.model.RestUser; +import org.sonar.server.v2.api.user.request.UserCreateRestRequest; import org.sonar.server.v2.api.user.request.UsersSearchRestRequest; import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; @@ -103,4 +105,23 @@ public class DefaultUserController implements UserController { public RestUser fetchUser(String login) { return usersSearchResponseGenerator.toRestUser(userService.fetchUser(login)); } + + @Override + public RestUser create(UserCreateRestRequest userCreateRestRequest) { + userSession.checkLoggedIn().checkIsSystemAdministrator(); + UserCreateRequest userCreateRequest = toUserCreateRequest(userCreateRestRequest); + return usersSearchResponseGenerator.toRestUser(userService.createUser(userCreateRequest)); + } + + private static UserCreateRequest toUserCreateRequest(UserCreateRestRequest userCreateRestRequest) { + return UserCreateRequest.builder() + .setEmail(userCreateRestRequest.email()) + .setLocal(userCreateRestRequest.local()) + .setLogin(userCreateRestRequest.login()) + .setName(userCreateRestRequest.name()) + .setPassword(userCreateRestRequest.password()) + .setScmAccounts(userCreateRestRequest.scmAccounts()) + .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 20fdac91ff8..2e9087fc331 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 @@ -25,6 +25,7 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import javax.validation.Valid; import org.sonar.server.v2.api.model.RestPage; import org.sonar.server.v2.api.user.model.RestUser; +import org.sonar.server.v2.api.user.request.UserCreateRestRequest; import org.sonar.server.v2.api.user.request.UsersSearchRestRequest; import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; import org.springdoc.api.annotations.ParameterObject; @@ -33,6 +34,8 @@ 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.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -82,4 +85,14 @@ public interface UserController { Field 'sonarqubeLastConnectionDate' is only updated every hour, so it may not be accurate, for instance when a user authenticates many times in less than one hour. """) RestUser fetchUser(@PathVariable("login") @Parameter(description = "The login of the user to fetch.", required = true, in = ParameterIn.PATH) String login); + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "User creation", description = """ + Create a user. + If a deactivated user account exists with the given login, it will be reactivated. + Requires Administer System permission + """) + RestUser create(@Valid @RequestBody(required = true) UserCreateRestRequest userCreateRestRequest); + } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UserCreateRestRequest.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UserCreateRestRequest.java new file mode 100644 index 00000000000..9b28d032c22 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UserCreateRestRequest.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.user.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import javax.annotation.Nullable; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +public record UserCreateRestRequest( + @Nullable + @Email + @Size(max = 100) + @Schema(description = "User email") + String email, + + @Nullable + @Schema(description = "Specify if the user should be authenticated from SonarQube server or from an external authentication system. " + + "Password should not be set when local is set to false.", + defaultValue = "true") + Boolean local, + + @NotNull + @Size(min = 2, max = 100) + @Schema(description = "User login") + String login, + + @NotNull + @Size(max = 200) + @Schema(description = "User name") + String name, + + @Nullable + @Schema(description = "User password. Only mandatory when creating local user, otherwise it should not be set") + String password, + + @Nullable + @Schema(description = "List of SCM accounts.") + List<String> scmAccounts) { + + public UserCreateRestRequest(@Nullable String email, @Nullable Boolean local, String login, String name, @Nullable String password, @Nullable List<String> scmAccounts) { + this.email = email; + this.local = local == null ? Boolean.TRUE : local; + this.login = login; + this.name = name; + this.password = password; + this.scmAccounts = scmAccounts; + } + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java index baccbbbc764..8bd0012b75c 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java @@ -23,6 +23,7 @@ import java.util.Optional; import java.util.stream.Collectors; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.exceptions.ServerException; import org.sonar.server.exceptions.UnauthorizedException; import org.sonar.server.v2.api.model.RestError; @@ -37,12 +38,18 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class RestResponseEntityExceptionHandler { - @ExceptionHandler(IllegalStateException.class) + @ExceptionHandler({IllegalStateException.class}) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) protected ResponseEntity<RestError> handleIllegalStateException(IllegalStateException illegalStateException) { return new ResponseEntity<>(new RestError(illegalStateException.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR); } + @ExceptionHandler({IllegalArgumentException.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + protected ResponseEntity<RestError> handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { + return new ResponseEntity<>(new RestError(illegalArgumentException.getMessage()), HttpStatus.BAD_REQUEST); + } + @ExceptionHandler(BindException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) protected ResponseEntity<RestError> handleBindException(BindException bindException) { @@ -64,4 +71,11 @@ public class RestResponseEntityExceptionHandler { return new ResponseEntity<>(new RestError(serverException.getMessage()), Optional.ofNullable(HttpStatus.resolve(serverException.httpCode())).orElse(HttpStatus.INTERNAL_SERVER_ERROR)); } + + @ExceptionHandler({NotFoundException.class}) + @ResponseStatus(HttpStatus.NOT_FOUND) + protected ResponseEntity<RestError> handleNotFoundException(NotFoundException notFoundException) { + return new ResponseEntity<>(new RestError(notFoundException.getMessage()), HttpStatus.NOT_FOUND); + } + } 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 ccad66c8998..8f42313d813 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 @@ -39,7 +39,9 @@ import org.sonar.server.v2.api.ControllerTester; import org.sonar.server.v2.api.response.PageRestResponse; import org.sonar.server.v2.api.user.converter.UsersSearchRestResponseGenerator; import org.sonar.server.v2.api.user.model.RestUser; +import org.sonar.server.v2.api.user.request.UserCreateRestRequest; import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -56,6 +58,7 @@ 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; @@ -243,7 +246,7 @@ public class DefaultUserControllerTest { public void deactivate_whenAnonymizeParameterIsNotBoolean_shouldReturnBadRequest() throws Exception { userSession.logIn().setSystemAdministrator(); - mockMvc.perform(delete(USER_ENDPOINT + "/userToDelete?anonymize=maybe")) + mockMvc.perform(delete(USER_ENDPOINT + "/userToDelete").param("anonymize", "maybe")) .andExpect( status().isBadRequest()); } @@ -300,6 +303,78 @@ public class DefaultUserControllerTest { .andReturn(); RestUser responseUser = gson.fromJson(mvcResult.getResponse().getContentAsString(), RestUser.class); assertThat(responseUser).isEqualTo(restUser); + } + + @Test + public void create_whenNotAnAdmin_shouldReturnForbidden() throws Exception { + userSession.logIn().setNonSystemAdministrator(); + + mockMvc.perform( + post(USER_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(gson.toJson(new UserCreateRestRequest(null, null, "login", "name", null, null)))) + .andExpectAll( + status().isForbidden(), + content().json("{\"message\":\"Insufficient privileges\"}")); + } + + @Test + public void create_whenNoLogin_shouldReturnBadRequest() throws Exception { + userSession.logIn().setSystemAdministrator(); + + mockMvc.perform( + post(USER_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(gson.toJson(new UserCreateRestRequest(null, null, null, "name", null, null)))) + .andExpectAll( + status().isBadRequest(), + content().json("{\"message\":\"Value {} for field login was rejected. Error: must not be null\"}")); + } + + @Test + public void create_whenNoName_shouldReturnBadRequest() throws Exception { + userSession.logIn().setSystemAdministrator(); + + mockMvc.perform( + post(USER_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(gson.toJson(new UserCreateRestRequest(null, null, "login", null, null, null)))) + .andExpectAll( + status().isBadRequest(), + content().json("{\"message\":\"Value {} for field name was rejected. Error: must not be null\"}")); + } + + @Test + public void create_whenUserServiceThrow_shouldReturnServerError() throws Exception { + userSession.logIn().setSystemAdministrator(); + when(userService.createUser(any())).thenThrow(new IllegalArgumentException("IllegalArgumentException")); + + mockMvc.perform( + post(USER_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(gson.toJson(new UserCreateRestRequest("e@mail.com", true, "login", "name", "password", List.of("scm"))))) + .andExpectAll( + status().isBadRequest(), + content().json("{\"message\":\"IllegalArgumentException\"}")); + } + @Test + public void create_whenUserServiceReturnUser_shouldReturnIt() throws Exception { + userSession.logIn().setSystemAdministrator(); + UserSearchResult userSearchResult = generateUserSearchResult("1", true, true, false, 1, 2); + UserDto userDto = userSearchResult.userDto(); + when(userService.createUser(any())).thenReturn(userSearchResult); + when(responseGenerator.toRestUser(userSearchResult)).thenReturn(toRestUser(userSearchResult)); + + MvcResult mvcResult = mockMvc.perform( + post(USER_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(gson.toJson(new UserCreateRestRequest( + userDto.getEmail(), userDto.isLocal(), userDto.getLogin(), userDto.getName(), "password", userDto.getSortedScmAccounts())))) + .andExpect(status().isOk()) + .andReturn(); + RestUser responseUser = gson.fromJson(mvcResult.getResponse().getContentAsString(), RestUser.class); + assertThat(responseUser).isEqualTo(toRestUser(userSearchResult)); } + } diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CreateActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CreateActionIT.java index 5071be725cd..430dc0e30c7 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CreateActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CreateActionIT.java @@ -33,10 +33,14 @@ import org.sonar.db.audit.NoOpAuditPersister; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserDto; import org.sonar.server.authentication.CredentialsLocalAuthentication; +import org.sonar.server.common.avatar.AvatarResolverImpl; +import org.sonar.server.common.management.ManagedInstanceChecker; +import org.sonar.server.common.user.UserDeactivator; +import org.sonar.server.common.user.service.UserService; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.UnauthorizedException; -import org.sonar.server.common.management.ManagedInstanceChecker; +import org.sonar.server.management.ManagedInstanceService; import org.sonar.server.tester.UserSessionRule; import org.sonar.server.user.NewUserNotifier; import org.sonar.server.user.UserUpdater; @@ -72,8 +76,10 @@ public class CreateActionIT { private final CredentialsLocalAuthentication localAuthentication = new CredentialsLocalAuthentication(db.getDbClient(), settings.asConfig()); private final ManagedInstanceChecker managedInstanceChecker = mock(ManagedInstanceChecker.class); - private final WsActionTester tester = new WsActionTester(new CreateAction(db.getDbClient(), new UserUpdater(mock(NewUserNotifier.class), - db.getDbClient(), new DefaultGroupFinder(db.getDbClient()), settings.asConfig(), new NoOpAuditPersister(), localAuthentication), userSessionRule, managedInstanceChecker)); + private final ManagedInstanceService managedInstanceService = mock(ManagedInstanceService.class); + private final UserService userService = new UserService(db.getDbClient(), new AvatarResolverImpl(), managedInstanceService, managedInstanceChecker, mock(UserDeactivator.class), + new UserUpdater(mock(NewUserNotifier.class), db.getDbClient(), new DefaultGroupFinder(db.getDbClient()), settings.asConfig(), new NoOpAuditPersister(), localAuthentication)); + private final WsActionTester tester = new WsActionTester(new CreateAction(userSessionRule, managedInstanceChecker, userService)); @Before public void setUp() { diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DeactivateActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DeactivateActionIT.java index 129f5561d38..87eebebcc4e 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DeactivateActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DeactivateActionIT.java @@ -56,6 +56,7 @@ import org.sonar.server.exceptions.UnauthorizedException; import org.sonar.server.management.ManagedInstanceService; import org.sonar.server.tester.UserSessionRule; import org.sonar.server.user.ExternalIdentity; +import org.sonar.server.user.UserUpdater; import org.sonar.server.ws.TestRequest; import org.sonar.server.ws.TestResponse; import org.sonar.server.ws.WsActionTester; @@ -85,7 +86,8 @@ public class DeactivateActionIT { private final UserDeactivator userDeactivator = new UserDeactivator(dbClient, userAnonymizer); private final ManagedInstanceChecker managedInstanceChecker = mock(ManagedInstanceChecker.class); - private final UserService userService = new UserService(dbClient, mock(AvatarResolver.class), mock(ManagedInstanceService.class), managedInstanceChecker, userDeactivator); + private final UserService userService = new UserService(dbClient, mock(AvatarResolver.class), mock(ManagedInstanceService.class), managedInstanceChecker, userDeactivator, + mock(UserUpdater.class)); private final WsActionTester ws = new WsActionTester(new DeactivateAction(dbClient, userSession, new UserJsonWriter(userSession), userService)); @Test diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java index 732d219d5a0..091a47fd5ac 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java @@ -44,6 +44,7 @@ import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ServerException; import org.sonar.server.management.ManagedInstanceService; import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.user.UserUpdater; import org.sonar.server.ws.TestRequest; import org.sonar.server.ws.WsActionTester; import org.sonarqube.ws.Common.Paging; @@ -81,7 +82,8 @@ public class SearchActionIT { new AvatarResolverImpl(), managedInstanceService, mock(ManagedInstanceChecker.class), - mock(UserDeactivator.class)); + mock(UserDeactivator.class), + mock(UserUpdater.class)); private final SearchWsReponseGenerator searchWsReponseGenerator = new SearchWsReponseGenerator(userSession); diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CreateAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CreateAction.java index 839e2697918..e6c65a5fa59 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CreateAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CreateAction.java @@ -19,23 +19,19 @@ */ package org.sonar.server.user.ws; -import java.util.HashSet; import java.util.List; -import java.util.Set; import javax.annotation.CheckForNull; import javax.annotation.Nullable; import org.sonar.api.server.ws.Change; import org.sonar.api.server.ws.Request; import org.sonar.api.server.ws.Response; import org.sonar.api.server.ws.WebService; -import org.sonar.db.DbClient; -import org.sonar.db.DbSession; import org.sonar.db.user.UserDto; import org.sonar.server.common.management.ManagedInstanceChecker; -import org.sonar.server.user.ExternalIdentity; -import org.sonar.server.user.NewUser; +import org.sonar.server.common.user.service.UserCreateRequest; +import org.sonar.server.common.user.service.UserSearchResult; +import org.sonar.server.common.user.service.UserService; import org.sonar.server.user.UserSession; -import org.sonar.server.user.UserUpdater; import org.sonarqube.ws.Users.CreateWsResponse; import static com.google.common.base.Preconditions.checkArgument; @@ -43,7 +39,6 @@ import static com.google.common.base.Strings.emptyToNull; import static com.google.common.base.Strings.isNullOrEmpty; import static java.util.Collections.emptyList; import static java.util.Optional.ofNullable; -import static org.sonar.server.user.ExternalIdentity.SQ_AUTHORITY; import static org.sonar.server.user.UserUpdater.EMAIL_MAX_LENGTH; import static org.sonar.server.user.UserUpdater.LOGIN_MAX_LENGTH; import static org.sonar.server.user.UserUpdater.LOGIN_MIN_LENGTH; @@ -60,16 +55,14 @@ import static org.sonarqube.ws.client.user.UsersWsParameters.PARAM_SCM_ACCOUNT; public class CreateAction implements UsersWsAction { - private final DbClient dbClient; - private final UserUpdater userUpdater; private final UserSession userSession; private final ManagedInstanceChecker managedInstanceChecker; + private final UserService userService; - public CreateAction(DbClient dbClient, UserUpdater userUpdater, UserSession userSession, ManagedInstanceChecker managedInstanceService) { - this.dbClient = dbClient; - this.userUpdater = userUpdater; + public CreateAction(UserSession userSession, ManagedInstanceChecker managedInstanceService, UserService userService) { this.userSession = userSession; this.managedInstanceChecker = managedInstanceService; + this.userService = userService; } @Override @@ -124,32 +117,17 @@ public class CreateAction implements UsersWsAction { public void handle(Request request, Response response) throws Exception { userSession.checkLoggedIn().checkIsSystemAdministrator(); managedInstanceChecker.throwIfInstanceIsManaged(); - CreateRequest createRequest = toWsRequest(request); - checkArgument(isValidIfPresent(createRequest.getEmail()), "Email '%s' is not valid", createRequest.getEmail()); - writeProtobuf(doHandle(createRequest), request, response); + + UserCreateRequest userCreateRequest = toUserCreateRequest(request); + String email = userCreateRequest.getEmail().orElse(null); + checkArgument(isValidIfPresent(email), "Email '%s' is not valid", email); + + writeProtobuf(doHandle(userCreateRequest), request, response); } - private CreateWsResponse doHandle(CreateRequest request) { - try (DbSession dbSession = dbClient.openSession(false)) { - String login = request.getLogin(); - NewUser.Builder newUser = NewUser.builder() - .setLogin(login) - .setName(request.getName()) - .setEmail(request.getEmail()) - .setScmAccounts(request.getScmAccounts()) - .setPassword(request.getPassword()); - if (!request.isLocal()) { - newUser.setExternalIdentity(new ExternalIdentity(SQ_AUTHORITY, login, login)); - } - UserDto existingUser = dbClient.userDao().selectByLogin(dbSession, login); - if (existingUser == null) { - return buildResponse(userUpdater.createAndCommit(dbSession, newUser.build(), u -> { - })); - } - checkArgument(!existingUser.isActive(), "An active user with login '%s' already exists", login); - return buildResponse(userUpdater.reactivateAndCommit(dbSession, existingUser, newUser.build(), u -> { - })); - } + private CreateWsResponse doHandle(UserCreateRequest userCreateRequest) { + UserSearchResult userSearchResult = userService.createUser(userCreateRequest); + return buildResponse(userSearchResult.userDto()); } private static CreateWsResponse buildResponse(UserDto userDto) { @@ -163,42 +141,24 @@ public class CreateAction implements UsersWsAction { return CreateWsResponse.newBuilder().setUser(userBuilder).build(); } - private static CreateRequest toWsRequest(Request request) { - return CreateRequest.builder() + private static UserCreateRequest toUserCreateRequest(Request request) { + return UserCreateRequest.builder() + .setEmail(request.param(PARAM_EMAIL)) + .setLocal(request.mandatoryParamAsBoolean(PARAM_LOCAL)) .setLogin(request.mandatoryParam(PARAM_LOGIN)) .setName(request.mandatoryParam(PARAM_NAME)) .setPassword(request.param(PARAM_PASSWORD)) - .setEmail(request.param(PARAM_EMAIL)) .setScmAccounts(parseScmAccounts(request)) - .setLocal(request.mandatoryParamAsBoolean(PARAM_LOCAL)) .build(); } public static List<String> parseScmAccounts(Request request) { if (request.hasParam(PARAM_SCM_ACCOUNT)) { - List<String> scmAccounts = request.multiParam(PARAM_SCM_ACCOUNT); - validateScmAccounts(scmAccounts); - return scmAccounts; + return request.multiParam(PARAM_SCM_ACCOUNT); } return emptyList(); } - private static void validateScmAccounts(List<String> scmAccounts) { - scmAccounts.forEach(CreateAction::validateScmAccountFormat); - validateNoDuplicates(scmAccounts); - } - - private static void validateScmAccountFormat(String scmAccount) { - checkArgument(scmAccount.equals(scmAccount.strip()), "SCM account cannot start or end with whitespace: '%s'", scmAccount); - } - - private static void validateNoDuplicates(List<String> scmAccounts) { - Set<String> duplicateCheck = new HashSet<>(); - for (String account : scmAccounts) { - checkArgument(duplicateCheck.add(account), "Duplicate SCM account: '%s'", account); - } - } - static class CreateRequest { private final String login; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UpdateAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UpdateAction.java index 08cba756840..4603ecae29e 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UpdateAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UpdateAction.java @@ -33,8 +33,9 @@ import org.sonar.api.utils.text.JsonWriter; import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.user.UserDto; -import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.common.management.ManagedInstanceChecker; +import org.sonar.server.common.user.service.UserService; +import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.user.UpdateUser; import org.sonar.server.user.UserSession; import org.sonar.server.user.UserUpdater; @@ -160,11 +161,13 @@ public class UpdateAction implements UsersWsAction { } private static UpdateRequest toWsRequest(Request request) { + List<String> scmAccounts = parseScmAccounts(request); + UserService.validateScmAccounts(scmAccounts); return UpdateRequest.builder() .setLogin(request.mandatoryParam(PARAM_LOGIN)) .setName(request.param(PARAM_NAME)) .setEmail(request.param(PARAM_EMAIL)) - .setScmAccounts(parseScmAccounts(request)) + .setScmAccounts(scmAccounts) .build(); } |