diff options
author | Duarte Meneses <duarte.meneses@sonarsource.com> | 2022-08-26 15:20:27 -0500 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-09-02 20:02:50 +0000 |
commit | f22f405e8303f879c76280ffaff189b8a03de68b (patch) | |
tree | 8ffb8817f1ef8f5f62d3b6fa463126edce498fd3 | |
parent | 0b64031069181e831ad630d7ba00cd8d87c12abc (diff) | |
download | sonarqube-f22f405e8303f879c76280ffaff189b8a03de68b.tar.gz sonarqube-f22f405e8303f879c76280ffaff189b8a03de68b.zip |
SONAR-17241 Add 'users/anonymize' WS to anonymize deactivated users
3 files changed, 335 insertions, 0 deletions
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/AnonymizeAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/AnonymizeAction.java new file mode 100644 index 00000000000..5f6b25a65b3 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/AnonymizeAction.java @@ -0,0 +1,109 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 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.user.ws; + +import java.util.function.Supplier; +import javax.inject.Inject; +import org.apache.commons.lang.RandomStringUtils; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.api.server.ws.WebService.NewAction; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.user.UserDto; +import org.sonar.server.user.ExternalIdentity; +import org.sonar.server.user.UserSession; +import org.sonar.server.user.index.UserIndexer; + +import static org.sonar.server.exceptions.NotFoundException.checkFound; + +public class AnonymizeAction implements UsersWsAction { + private static final int LOGIN_RANDOM_LENGTH = 6; + private static final String PARAM_LOGIN = "login"; + + private final DbClient dbClient; + private final UserIndexer userIndexer; + private final UserSession userSession; + private final Supplier<String> randomNameGenerator; + + @Inject + public AnonymizeAction(DbClient dbClient, UserIndexer userIndexer, UserSession userSession) { + this(dbClient, userIndexer, userSession, () -> "sq-removed-" + RandomStringUtils.randomAlphanumeric(LOGIN_RANDOM_LENGTH)); + } + + public AnonymizeAction(DbClient dbClient, UserIndexer userIndexer, UserSession userSession, Supplier<String> randomNameGenerator) { + this.dbClient = dbClient; + this.userIndexer = userIndexer; + this.userSession = userSession; + this.randomNameGenerator = randomNameGenerator; + } + + @Override + public void define(WebService.NewController controller) { + NewAction action = controller.createAction("anonymize") + .setDescription("Anonymize a deactivated user. Requires Administer System permission") + .setSince("9.7") + .setPost(true) + .setHandler(this); + + action.createParam(PARAM_LOGIN) + .setDescription("User login") + .setRequired(true) + .setExampleValue("myuser"); + } + + @Override + public void handle(Request request, Response response) throws Exception { + userSession.checkLoggedIn().checkIsSystemAdministrator(); + String login = request.mandatoryParam(PARAM_LOGIN); + + try (DbSession dbSession = dbClient.openSession(false)) { + UserDto user = dbClient.userDao().selectByLogin(dbSession, login); + checkFound(user, "User '%s' doesn't exist", login); + if (user.isActive()) { + throw new IllegalArgumentException(String.format("User '%s' is not deactivated", login)); + } + + String newLogin = generateAnonymousLogin(dbSession); + user + .setLogin(newLogin) + .setName(newLogin) + .setExternalIdentityProvider(ExternalIdentity.SQ_AUTHORITY) + .setLocal(true) + .setExternalId(newLogin) + .setExternalLogin(newLogin); + dbClient.userDao().update(dbSession, user); + userIndexer.commitAndIndex(dbSession, user); + } + + response.noContent(); + } + + private String generateAnonymousLogin(DbSession session) { + for (int i = 0; i < 10; i++) { + String candidate = randomNameGenerator.get(); + if (dbClient.userDao().selectByLogin(session, candidate) == null) { + return candidate; + } + } + throw new IllegalStateException("Could not find a unique login"); + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java index 9247212af2e..6cff442be80 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java @@ -27,6 +27,7 @@ public class UsersWsModule extends Module { protected void configureModule() { add( UsersWs.class, + AnonymizeAction.class, CreateAction.class, UpdateAction.class, UpdateLoginAction.class, diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/AnonymizeActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/AnonymizeActionTest.java new file mode 100644 index 00000000000..09a7870694a --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/AnonymizeActionTest.java @@ -0,0 +1,225 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 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.user.ws; + +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; +import org.elasticsearch.search.SearchHits; +import org.elasticsearch.search.builder.SearchSourceBuilder; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.impl.utils.AlwaysIncreasingSystem2; +import org.sonar.api.user.UserQuery; +import org.sonar.api.utils.System2; +import org.sonar.db.DbClient; +import org.sonar.db.DbTester; +import org.sonar.db.user.UserDto; +import org.sonar.server.es.EsClient; +import org.sonar.server.es.EsTester; +import org.sonar.server.es.EsUtils; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.user.ExternalIdentity; +import org.sonar.server.user.index.UserDoc; +import org.sonar.server.user.index.UserIndexDefinition; +import org.sonar.server.user.index.UserIndexer; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.TestResponse; +import org.sonar.server.ws.WsActionTester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.elasticsearch.index.query.QueryBuilders.boolQuery; +import static org.elasticsearch.index.query.QueryBuilders.termQuery; +import static org.sonar.db.permission.GlobalPermission.ADMINISTER; +import static org.sonar.server.user.index.UserIndexDefinition.FIELD_ACTIVE; +import static org.sonar.server.user.index.UserIndexDefinition.FIELD_UUID; + +public class AnonymizeActionTest { + private final System2 system2 = new AlwaysIncreasingSystem2(); + + @Rule + public DbTester db = DbTester.create(system2); + @Rule + public EsTester es = EsTester.create(); + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + + private final DbClient dbClient = db.getDbClient(); + private final UserIndexer userIndexer = new UserIndexer(dbClient, es.client()); + private final WsActionTester ws = new WsActionTester(new AnonymizeAction(dbClient, userIndexer, userSession)); + + @Test + public void anonymize_user() { + UserDto user = db.users().insertUser(u -> u + .setLogin("ada.lovelace") + .setName("Ada Lovelace") + .setActive(false) + .setEmail(null) + .setScmAccounts((String) null) + .setExternalIdentityProvider("provider") + .setExternalLogin("external.login") + .setExternalId("external.id")); + logInAsSystemAdministrator(); + + anonymize(user.getLogin()); + + verifyThatUserIsAnonymized(user.getUuid()); + verifyThatUserIsAnonymizedOnEs(user.getUuid()); + } + + @Test + public void try_avoid_login_collisions() { + List<String> logins = List.of("login1", "login2", "login3"); + Iterator<String> randomGeneratorIt = logins.iterator(); + WsActionTester ws = new WsActionTester(new AnonymizeAction(dbClient, userIndexer, userSession, randomGeneratorIt::next)); + + UserDto user1 = db.users().insertUser(u -> u.setLogin("login1")); + UserDto user2 = db.users().insertUser(u -> u.setLogin("login2")); + UserDto userToAnonymize = db.users().insertUser(u -> u.setLogin("toAnonymize").setActive(false)); + + logInAsSystemAdministrator(); + + anonymize(ws, userToAnonymize.getLogin()); + assertThat(dbClient.userDao().selectUsers(db.getSession(), UserQuery.builder().includeDeactivated().build())) + .extracting(UserDto::getLogin) + .containsOnly("login1", "login2", "login3"); + } + + @Test + public void cannot_anonymize_active_user() { + createAdminUser(); + UserDto user = db.users().insertUser(); + userSession.logIn(user.getLogin()).setSystemAdministrator(); + + String login = user.getLogin(); + assertThatThrownBy(() -> anonymize(login)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User '" + user.getLogin() + "' is not deactivated"); + } + + @Test + public void requires_to_be_logged_in() { + createAdminUser(); + + assertThatThrownBy(() -> anonymize("someone")) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("Authentication is required"); + } + + @Test + public void requires_administrator_permission_on_sonarqube() { + createAdminUser(); + userSession.logIn(); + + assertThatThrownBy(() -> anonymize("someone")) + .isInstanceOf(ForbiddenException.class) + .hasMessage("Insufficient privileges"); + } + + @Test + public void fail_if_user_does_not_exist() { + createAdminUser(); + logInAsSystemAdministrator(); + + assertThatThrownBy(() -> anonymize("someone")) + .isInstanceOf(NotFoundException.class) + .hasMessage("User 'someone' doesn't exist"); + } + + @Test + public void fail_if_login_is_blank() { + createAdminUser(); + logInAsSystemAdministrator(); + + assertThatThrownBy(() -> anonymize("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("The 'login' parameter is missing"); + } + + @Test + public void fail_if_login_is_missing() { + createAdminUser(); + logInAsSystemAdministrator(); + + assertThatThrownBy(() -> anonymize(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("The 'login' parameter is missing"); + } + + @Test + public void test_definition() { + assertThat(ws.getDef().isPost()).isTrue(); + assertThat(ws.getDef().isInternal()).isFalse(); + assertThat(ws.getDef().params()).hasSize(1); + } + + private void logInAsSystemAdministrator() { + userSession.logIn().setSystemAdministrator(); + } + + private TestResponse anonymize(@Nullable String login) { + return anonymize(ws, login); + } + + private TestResponse anonymize(WsActionTester ws, @Nullable String login) { + TestRequest request = ws.newRequest() + .setMethod("POST"); + Optional.ofNullable(login).ifPresent(t -> request.setParam("login", login)); + return request.execute(); + } + + private void verifyThatUserIsAnonymizedOnEs(String uuid) { + SearchHits hits = es.client().search(EsClient.prepareSearch(UserIndexDefinition.TYPE_USER) + .source(new SearchSourceBuilder() + .query(boolQuery() + .must(termQuery(FIELD_UUID, uuid)) + .must(termQuery(FIELD_ACTIVE, "false"))))) + .getHits(); + List<UserDoc> userDocs = EsUtils.convertToDocs(hits, UserDoc::new); + assertThat(userDocs).hasSize(1); + assertThat(userDocs.get(0).login()).startsWith("sq-removed-"); + assertThat(userDocs.get(0).name()).startsWith("sq-removed-"); + } + + private void verifyThatUserIsAnonymized(String uuid) { + List<UserDto> users = dbClient.userDao().selectUsers(db.getSession(), UserQuery.builder().includeDeactivated().build()); + assertThat(users).hasSize(1); + + UserDto anonymized = dbClient.userDao().selectByUuid(db.getSession(), uuid); + assertThat(anonymized.getLogin()).startsWith("sq-removed-"); + assertThat(anonymized.getName()).isEqualTo(anonymized.getLogin()); + assertThat(anonymized.getExternalLogin()).isEqualTo(anonymized.getLogin()); + assertThat(anonymized.getExternalId()).isEqualTo(anonymized.getLogin()); + assertThat(anonymized.getExternalIdentityProvider()).isEqualTo(ExternalIdentity.SQ_AUTHORITY); + assertThat(anonymized.isActive()).isFalse(); + } + + private UserDto createAdminUser() { + UserDto admin = db.users().insertUser(); + db.users().insertPermissionOnUser(admin, ADMINISTER); + db.commit(); + return admin; + } +} |