]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19969 Support apiv2 POST /users to create a user
authorAntoine Vigneau <antoine.vigneau@sonarsource.com>
Tue, 25 Jul 2023 19:07:09 +0000 (21:07 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 28 Jul 2023 20:03:15 +0000 (20:03 +0000)
15 files changed:
server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserCreateRequestTest.java [new file with mode: 0644]
server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserCreateRequest.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UserCreateRestRequest.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CreateActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DeactivateActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CreateAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UpdateAction.java

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 (file)
index 0000000..1a77e52
--- /dev/null
@@ -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");
+  }
+
+}
index 3a42305da8c0472818593b7f582aa9216d529c65..3c65ea743afed07c116cb2814dd2010c40a415d6 100644 (file)
@@ -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 (file)
index 0000000..2461813
--- /dev/null
@@ -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);
+    }
+  }
+
+}
index 69fabcc4c9e000feac3cac8101b6c27ce4eb2740..c8957fb3c97642a426e253fff18f447b748cb29b 100644 (file)
@@ -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);
+    }
+  }
+
 }
index ecd31afbc55194e878600a44b31b29d9818c0cb6..8336a2385b9d01249a5fe8496f846dab09a3113c 100644 (file)
@@ -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",
index 40c9ad6e1925c06250c25700f916b17d67ea920f..0cdaa1c13c91b507e938bd72d3cdabe2ec603bbe 100644 (file)
@@ -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();
+  }
+
 }
index 20fdac91ff8223e329761d05070dbde478018e64..2e9087fc3310c289e563f56fa910bf279a60df9c 100644 (file)
@@ -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 (file)
index 0000000..9b28d03
--- /dev/null
@@ -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;
+  }
+
+}
index baccbbbc764803d8eb992d535ca8fb525044f4e6..8bd0012b75c0e06be83d73e2f7f76e9d68608dbf 100644 (file)
@@ -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);
+  }
+
 }
index ccad66c8998bd73dc317f3ff6972db77836aa132..8f42313d81317c04f7194c7ef92210e41830553d 100644 (file)
@@ -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));
   }
+
 }
index 5071be725cdd224b37f7688759ba3df3c6d6b463..430dc0e30c703f9cb6380deebe01110443ce915e 100644 (file)
@@ -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() {
index 129f5561d382a1e780d45e691f002052d85a2e85..87eebebcc4e68e82053440a6485f41f64aedd1dd 100644 (file)
@@ -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
index 732d219d5a0aea9e2c2576b89119c997aae9ace7..091a47fd5ace2702c73569002ec8ab19cf9c20aa 100644 (file)
@@ -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);
 
index 839e26979189a7e43b8d252a5a4c16ae6efd2569..e6c65a5fa5965d115e4b1c14f27818060500325d 100644 (file)
  */
 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;
index 08cba7568405efcdfe0f4a0c96e49be35e3d2961..4603ecae29e954b1b60aed41dd29ffd0b719811c 100644 (file)
@@ -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();
   }