From 687d0b26d6355627ce5704dbc3118b66ebad85c6 Mon Sep 17 00:00:00 2001 From: Duarte Meneses Date: Tue, 30 Aug 2022 13:38:09 -0500 Subject: [PATCH] SONAR-17242 Add 'anonymize' parameter to 'users/deactivate' --- .../sonar/server/user/ws/AnonymizeAction.java | 34 ++-------- .../server/user/ws/DeactivateAction.java | 22 ++++++- .../sonar/server/user/ws/UserAnonymizer.java | 66 +++++++++++++++++++ .../sonar/server/user/ws/UsersWsModule.java | 1 + .../server/user/ws/AnonymizeActionTest.java | 25 ++----- .../server/user/ws/DeactivateActionTest.java | 60 ++++++++++++++--- .../server/user/ws/UserAnonymizerTest.java | 65 ++++++++++++++++++ 7 files changed, 211 insertions(+), 62 deletions(-) create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UserAnonymizer.java create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/UserAnonymizerTest.java 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 index 5f6b25a65b3..3d1189456e9 100644 --- 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 @@ -19,9 +19,6 @@ */ 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; @@ -29,31 +26,24 @@ 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 randomNameGenerator; + private final UserAnonymizer userAnonymizer; - @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 randomNameGenerator) { + public AnonymizeAction(DbClient dbClient, UserIndexer userIndexer, UserSession userSession, UserAnonymizer userAnonymizer) { + this.userAnonymizer = userAnonymizer; this.dbClient = dbClient; this.userIndexer = userIndexer; this.userSession = userSession; - this.randomNameGenerator = randomNameGenerator; } @Override @@ -82,14 +72,7 @@ public class AnonymizeAction implements UsersWsAction { 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); + userAnonymizer.anonymize(dbSession, user); dbClient.userDao().update(dbSession, user); userIndexer.commitAndIndex(dbSession, user); } @@ -97,13 +80,4 @@ public class AnonymizeAction implements UsersWsAction { 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/DeactivateAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DeactivateAction.java index 480264d4921..b8428a30331 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DeactivateAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DeactivateAction.java @@ -42,17 +42,20 @@ import static org.sonar.server.exceptions.NotFoundException.checkFound; public class DeactivateAction implements UsersWsAction { private static final String PARAM_LOGIN = "login"; + private static final String PARAM_ANONYMIZE = "anonymize"; private final DbClient dbClient; private final UserIndexer userIndexer; private final UserSession userSession; private final UserJsonWriter userWriter; + private final UserAnonymizer userAnonymizer; - public DeactivateAction(DbClient dbClient, UserIndexer userIndexer, UserSession userSession, UserJsonWriter userWriter) { + public DeactivateAction(DbClient dbClient, UserIndexer userIndexer, UserSession userSession, UserJsonWriter userWriter, UserAnonymizer userAnonymizer) { this.dbClient = dbClient; this.userIndexer = userIndexer; this.userSession = userSession; this.userWriter = userWriter; + this.userAnonymizer = userAnonymizer; } @Override @@ -68,6 +71,13 @@ public class DeactivateAction implements UsersWsAction { .setDescription("User login") .setRequired(true) .setExampleValue("myuser"); + + action.createParam(PARAM_ANONYMIZE) + .setDescription("Anonymize user in addition to deactivating it") + .setBooleanPossibleValues() + .setRequired(false) + .setSince("9.7") + .setDefaultValue(false); } @Override @@ -96,11 +106,17 @@ public class DeactivateAction implements UsersWsAction { dbClient.sessionTokensDao().deleteByUser(dbSession, user); dbClient.userDismissedMessagesDao().deleteByUser(dbSession, user); dbClient.qualityGateUserPermissionDao().deleteByUser(dbSession, user); + + if (request.mandatoryParamAsBoolean(PARAM_ANONYMIZE)) { + userAnonymizer.anonymize(dbSession, user); + dbClient.userDao().update(dbSession, user); + } + dbClient.userDao().deactivateUser(dbSession, user); + userIndexer.commitAndIndex(dbSession, user); + writeResponse(response, user.getLogin()); } - - writeResponse(response, login); } private void writeResponse(Response response, String login) { diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UserAnonymizer.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UserAnonymizer.java new file mode 100644 index 00000000000..873df0b92c6 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UserAnonymizer.java @@ -0,0 +1,66 @@ +/* + * 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.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.user.UserDto; +import org.sonar.server.user.ExternalIdentity; + +public class UserAnonymizer { + private static final int LOGIN_RANDOM_LENGTH = 6; + + private final DbClient dbClient; + private final Supplier randomNameGenerator; + + @Inject + public UserAnonymizer(DbClient dbClient) { + this(dbClient, () -> "sq-removed-" + RandomStringUtils.randomAlphanumeric(LOGIN_RANDOM_LENGTH)); + } + + public UserAnonymizer(DbClient dbClient, Supplier randomNameGenerator) { + this.dbClient = dbClient; + this.randomNameGenerator = randomNameGenerator; + } + + public void anonymize(DbSession session, UserDto user) { + String newLogin = generateAnonymousLogin(session); + user + .setLogin(newLogin) + .setName(newLogin) + .setExternalIdentityProvider(ExternalIdentity.SQ_AUTHORITY) + .setLocal(true) + .setExternalId(newLogin) + .setExternalLogin(newLogin); + } + + 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 6cff442be80..0e6c86eff99 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 @@ -41,6 +41,7 @@ public class UsersWsModule extends Module { UserJsonWriter.class, SetHomepageAction.class, HomepageTypesImpl.class, + UserAnonymizer.class, UpdateIdentityProviderAction.class, DismissNoticeAction.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 index 09a7870694a..993fc1d1c5b 100644 --- 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 @@ -19,7 +19,6 @@ */ package org.sonar.server.user.ws; -import java.util.Iterator; import java.util.List; import java.util.Optional; import javax.annotation.Nullable; @@ -68,7 +67,8 @@ public class AnonymizeActionTest { 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)); + private final UserAnonymizer userAnonymizer = new UserAnonymizer(db.getDbClient()); + private final WsActionTester ws = new WsActionTester(new AnonymizeAction(dbClient, userIndexer, userSession, userAnonymizer)); @Test public void anonymize_user() { @@ -83,28 +83,11 @@ public class AnonymizeActionTest { .setExternalId("external.id")); logInAsSystemAdministrator(); - anonymize(user.getLogin()); + TestResponse response = anonymize(user.getLogin()); verifyThatUserIsAnonymized(user.getUuid()); verifyThatUserIsAnonymizedOnEs(user.getUuid()); - } - - @Test - public void try_avoid_login_collisions() { - List logins = List.of("login1", "login2", "login3"); - Iterator 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"); + assertThat(response.getInput()).isEmpty(); } @Test diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java index a6ee09f9eac..ab3c6f72531 100644 --- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java @@ -50,6 +50,7 @@ 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.UserIndexDefinition; import org.sonar.server.user.index.UserIndexer; import org.sonar.server.ws.TestRequest; @@ -61,6 +62,10 @@ 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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.sonar.api.web.UserRole.CODEVIEWER; import static org.sonar.api.web.UserRole.USER; import static org.sonar.db.permission.GlobalPermission.ADMINISTER; @@ -85,7 +90,8 @@ public class DeactivateActionTest { private final DbClient dbClient = db.getDbClient(); private final UserIndexer userIndexer = new UserIndexer(dbClient, es.client()); private final DbSession dbSession = db.getSession(); - private final WsActionTester ws = new WsActionTester(new DeactivateAction(dbClient, userIndexer, userSession, new UserJsonWriter(userSession))); + private final UserAnonymizer userAnonymizer = new UserAnonymizer(db.getDbClient(), () -> "anonymized"); + private final WsActionTester ws = new WsActionTester(new DeactivateAction(dbClient, userIndexer, userSession, new UserJsonWriter(userSession), userAnonymizer)); @Test public void deactivate_user_and_delete_their_related_data() { @@ -101,10 +107,32 @@ public class DeactivateActionTest { verifyThatUserIsDeactivated(user.getLogin()); assertThat(es.client().search(EsClient.prepareSearch(UserIndexDefinition.TYPE_USER) - .source(new SearchSourceBuilder() - .query(boolQuery() - .must(termQuery(FIELD_UUID, user.getUuid())) - .must(termQuery(FIELD_ACTIVE, "false"))))) + .source(new SearchSourceBuilder() + .query(boolQuery() + .must(termQuery(FIELD_UUID, user.getUuid())) + .must(termQuery(FIELD_ACTIVE, "false"))))) + .getHits().getHits()).hasSize(1); + } + + @Test + public void anonymize_user_if_param_provided() { + createAdminUser(); + UserDto user = db.users().insertUser(u -> u + .setLogin("ada.lovelace") + .setEmail("ada.lovelace@noteg.com") + .setName("Ada Lovelace") + .setScmAccounts(singletonList("al"))); + logInAsSystemAdministrator(); + + deactivate(user.getLogin(), true); + + verifyThatUserIsDeactivated("anonymized"); + verifyThatUserIsAnomymized("anonymized"); + assertThat(es.client().search(EsClient.prepareSearch(UserIndexDefinition.TYPE_USER) + .source(new SearchSourceBuilder() + .query(boolQuery() + .must(termQuery(FIELD_UUID, user.getUuid())) + .must(termQuery(FIELD_ACTIVE, "false"))))) .getHits().getHits()).hasSize(1); } @@ -394,7 +422,7 @@ public class DeactivateActionTest { public void test_definition() { assertThat(ws.getDef().isPost()).isTrue(); assertThat(ws.getDef().isInternal()).isFalse(); - assertThat(ws.getDef().params()).hasSize(1); + assertThat(ws.getDef().params()).hasSize(2); } @Test @@ -418,13 +446,20 @@ public class DeactivateActionTest { } private TestResponse deactivate(@Nullable String login) { - return deactivate(ws, login); + return deactivate(login, false); + } + + private TestResponse deactivate(@Nullable String login, boolean anonymize) { + return deactivate(ws, login, anonymize); } - private TestResponse deactivate(WsActionTester ws, @Nullable String login) { + private TestResponse deactivate(WsActionTester ws, @Nullable String login, boolean anonymize) { TestRequest request = ws.newRequest() .setMethod("POST"); Optional.ofNullable(login).ifPresent(t -> request.setParam("login", login)); + if (anonymize) { + request.setParam("anonymize", "true"); + } return request.execute(); } @@ -440,6 +475,15 @@ public class DeactivateActionTest { assertThat(user.get().getScmAccountsAsList()).isEmpty(); } + private void verifyThatUserIsAnomymized(String login) { + Optional user = db.users().selectUserByLogin(login); + assertThat(user).isPresent(); + assertThat(user.get().getName()).isEqualTo(login); + assertThat(user.get().getExternalLogin()).isEqualTo(login); + assertThat(user.get().getExternalId()).isEqualTo(login); + assertThat(user.get().getExternalIdentityProvider()).isEqualTo(ExternalIdentity.SQ_AUTHORITY); + } + private UserDto createAdminUser() { UserDto admin = db.users().insertUser(); db.users().insertPermissionOnUser(admin, ADMINISTER); diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/UserAnonymizerTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/UserAnonymizerTest.java new file mode 100644 index 00000000000..c2b66a38d57 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/UserAnonymizerTest.java @@ -0,0 +1,65 @@ +/* + * 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 org.junit.Rule; +import org.junit.Test; +import org.sonar.api.impl.utils.AlwaysIncreasingSystem2; +import org.sonar.api.utils.System2; +import org.sonar.db.DbTester; +import org.sonar.db.user.UserDto; +import org.sonar.server.user.ExternalIdentity; + +import static org.assertj.core.api.Assertions.assertThat; + +public class UserAnonymizerTest { + private final System2 system2 = new AlwaysIncreasingSystem2(); + @Rule + public DbTester db = DbTester.create(system2); + private final UserAnonymizer userAnonymizer = new UserAnonymizer(db.getDbClient()); + + @Test + public void anonymize_user() { + UserDto user = db.users().insertUser(u -> u.setLogin("login1")); + userAnonymizer.anonymize(db.getSession(), user); + assertThat(user.getLogin()).startsWith("sq-removed-"); + assertThat(user.getExternalIdentityProvider()).isEqualTo(ExternalIdentity.SQ_AUTHORITY); + assertThat(user.getExternalId()).isEqualTo(user.getLogin()); + assertThat(user.getExternalLogin()).isEqualTo(user.getLogin()); + assertThat(user.getName()).isEqualTo(user.getLogin()); + } + + @Test + public void try_avoid_login_collisions() { + List logins = List.of("login1", "login2", "login3"); + Iterator randomGeneratorIt = logins.iterator(); + UserAnonymizer userAnonymizer = new UserAnonymizer(db.getDbClient(), 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)); + + userAnonymizer.anonymize(db.getSession(), userToAnonymize); + assertThat(userToAnonymize.getLogin()).isEqualTo("login3"); + } + +} -- 2.39.5