This is an automated email from the ASF dual-hosted git repository. btellier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 79092f21427011722b656fca5c58b5044235424f Author: Quan Tran <hqt...@linagora.com> AuthorDate: Fri May 19 17:18:51 2023 +0700 JAMES-3909 Task + Webadmin route for delete all users data of a domain --- .../docs/modules/ROOT/pages/operate/webadmin.adoc | 27 +++ .../james/modules/server/DataRoutesModules.java | 20 +++ .../james/webadmin/routes/DomainsRoutes.java | 33 +++- .../webadmin/service/DeleteUserDataService.java | 4 + .../service/DeleteUsersDataOfDomainTask.java | 173 ++++++++++++++++++ ...rsDataOfDomainTaskAdditionalInformationDTO.java | 81 +++++++++ .../service/DeleteUsersDataOfDomainTaskDTO.java | 68 ++++++++ .../james/webadmin/routes/DomainsRoutesTest.java | 174 +++++++++++++++++- ...leteUsersDataOfDomainTaskSerializationTest.java | 93 ++++++++++ .../service/DeleteUsersDataOfDomainTaskTest.java | 194 +++++++++++++++++++++ src/site/markdown/server/manage-webadmin.md | 28 +++ 11 files changed, 892 insertions(+), 3 deletions(-) diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc index 1a343c7d30..0a69f5618f 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc @@ -455,6 +455,33 @@ syntax * 400: source, domain and destination domain are the same * 404: `source.domain.tld` are not part of handled domains. +=== Delete all users data of a domain + +.... +curl -XPOST http://ip:port/domains/{domainToBeUsed}?action=deleteData +.... + +Would create a task that deletes data of all users of the domain. + +[More details about endpoints returning a task](#_endpoints_returning_a_task). + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the following type `DeleteUsersDataOfDomainTask` and the following `additionalInformation`: + +.... +{ + "type": "DeleteUsersDataOfDomainTask", + "domain": "domain.tld", + "successfulUsersCount": 2, + "failedUsersCount": 1, + "timestamp": "2023-05-22T08:52:47.076261Z" +} +.... + == Administrating users === Create a user diff --git a/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DataRoutesModules.java b/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DataRoutesModules.java index 5b3addf079..48fcf5efe2 100644 --- a/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DataRoutesModules.java +++ b/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DataRoutesModules.java @@ -25,6 +25,7 @@ import org.apache.james.server.task.json.dto.TaskDTO; import org.apache.james.server.task.json.dto.TaskDTOModule; import org.apache.james.task.Task; import org.apache.james.task.TaskExecutionDetails; +import org.apache.james.user.api.UsersRepository; import org.apache.james.webadmin.Routes; import org.apache.james.webadmin.dto.DTOModuleInjections; import org.apache.james.webadmin.dto.MappingSourceModule; @@ -44,6 +45,8 @@ import org.apache.james.webadmin.routes.UsernameChangeRoutes; import org.apache.james.webadmin.service.DeleteUserDataService; import org.apache.james.webadmin.service.DeleteUserDataTaskAdditionalInformationDTO; import org.apache.james.webadmin.service.DeleteUserDataTaskDTO; +import org.apache.james.webadmin.service.DeleteUsersDataOfDomainTaskAdditionalInformationDTO; +import org.apache.james.webadmin.service.DeleteUsersDataOfDomainTaskDTO; import org.apache.james.webadmin.service.UsernameChangeService; import org.apache.james.webadmin.service.UsernameChangeTaskAdditionalInformationDTO; import org.apache.james.webadmin.service.UsernameChangeTaskDTO; @@ -108,4 +111,21 @@ public class DataRoutesModules extends AbstractModule { public AdditionalInformationDTOModule<? extends TaskExecutionDetails.AdditionalInformation, ? extends AdditionalInformationDTO> webAdminDeleteUserDataTaskAdditionalInformationDTO() { return DeleteUserDataTaskAdditionalInformationDTO.module(); } + + // delete all users data of a domain DTO modules + @ProvidesIntoSet + public TaskDTOModule<? extends Task, ? extends TaskDTO> deleteUsersDataOfDomainTaskDTO(DeleteUserDataService service, UsersRepository usersRepository) { + return DeleteUsersDataOfDomainTaskDTO.module(service, usersRepository); + } + + @ProvidesIntoSet + public AdditionalInformationDTOModule<? extends TaskExecutionDetails.AdditionalInformation, ? extends AdditionalInformationDTO> deleteUsersDataOfDomainTaskAdditionalInformationDTO() { + return DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module(); + } + + @Named(DTOModuleInjections.WEBADMIN_DTO) + @ProvidesIntoSet + public AdditionalInformationDTOModule<? extends TaskExecutionDetails.AdditionalInformation, ? extends AdditionalInformationDTO> webAdminDeleteUsersDataOfDomainTaskAdditionalInformationDTO() { + return DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module(); + } } diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DomainsRoutes.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DomainsRoutes.java index 59ae633c61..34ef2f9ab9 100644 --- a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DomainsRoutes.java +++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DomainsRoutes.java @@ -34,9 +34,15 @@ import org.apache.james.domainlist.api.DomainList; import org.apache.james.domainlist.api.DomainListException; import org.apache.james.rrt.api.RecipientRewriteTableException; import org.apache.james.rrt.api.SameSourceAndDestinationException; +import org.apache.james.task.TaskManager; +import org.apache.james.user.api.UsersRepository; import org.apache.james.webadmin.Routes; import org.apache.james.webadmin.dto.DomainAliasResponse; +import org.apache.james.webadmin.service.DeleteUserDataService; +import org.apache.james.webadmin.service.DeleteUsersDataOfDomainTask; import org.apache.james.webadmin.service.DomainAliasService; +import org.apache.james.webadmin.tasks.TaskFromRequestRegistry; +import org.apache.james.webadmin.tasks.TaskRegistrationKey; import org.apache.james.webadmin.utils.ErrorResponder; import org.apache.james.webadmin.utils.ErrorResponder.ErrorType; import org.apache.james.webadmin.utils.JsonTransformer; @@ -45,11 +51,13 @@ import org.eclipse.jetty.http.HttpStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableSet; import spark.HaltException; import spark.Request; import spark.Response; +import spark.Route; import spark.Service; public class DomainsRoutes implements Routes { @@ -62,18 +70,26 @@ public class DomainsRoutes implements Routes { private static final String SPECIFIC_DOMAIN = DOMAINS + SEPARATOR + DOMAIN_NAME; private static final String ALIASES = "aliases"; private static final String DOMAIN_ALIASES = SPECIFIC_DOMAIN + SEPARATOR + ALIASES; + private static final String DELETE_ALL_USERS_DATA_OF_A_DOMAIN_PATH = "/domains/:domainName"; private static final String SPECIFIC_ALIAS = DOMAINS + SEPARATOR + DESTINATION_DOMAIN + SEPARATOR + ALIASES + SEPARATOR + SOURCE_DOMAIN; + private static final TaskRegistrationKey DELETE_USERS_DATA = TaskRegistrationKey.of("deleteData"); private final DomainList domainList; private final DomainAliasService domainAliasService; private final JsonTransformer jsonTransformer; + private final DeleteUserDataService deleteUserDataService; + private final UsersRepository usersRepository; + private final TaskManager taskManager; private Service service; @Inject - DomainsRoutes(DomainList domainList, DomainAliasService domainAliasService, JsonTransformer jsonTransformer) { + DomainsRoutes(DomainList domainList, DomainAliasService domainAliasService, JsonTransformer jsonTransformer, DeleteUserDataService deleteUserDataService, UsersRepository usersRepository, TaskManager taskManager) { this.domainList = domainList; this.domainAliasService = domainAliasService; this.jsonTransformer = jsonTransformer; + this.deleteUserDataService = deleteUserDataService; + this.usersRepository = usersRepository; + this.taskManager = taskManager; } @Override @@ -95,6 +111,21 @@ public class DomainsRoutes implements Routes { defineListAliases(service); defineAddAlias(service); defineRemoveAlias(service); + + // delete data of all users of a domain + service.post(DELETE_ALL_USERS_DATA_OF_A_DOMAIN_PATH, deleteAllUsersData(), jsonTransformer); + } + + public Route deleteAllUsersData() { + return TaskFromRequestRegistry.builder() + .parameterName("action") + .register(DELETE_USERS_DATA, request -> { + Domain domain = checkValidDomain(request.params(DOMAIN_NAME)); + Preconditions.checkArgument(domainList.containsDomain(domain), "'domainName' parameter should be an existing domain"); + + return new DeleteUsersDataOfDomainTask(deleteUserDataService, domain, usersRepository); + }) + .buildAsRoute(taskManager); } public void defineDeleteDomain() { diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUserDataService.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUserDataService.java index 2bd572b68c..e24987358d 100644 --- a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUserDataService.java +++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUserDataService.java @@ -143,4 +143,8 @@ public class DeleteUserDataService { public Performer performer(Optional<StepName> fromStep) { return new Performer(steps, new DeleteUserDataStatus(steps), fromStep); } + + public Performer performer() { + return new Performer(steps, new DeleteUserDataStatus(steps), Optional.empty()); + } } diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java new file mode 100644 index 0000000000..84487aade6 --- /dev/null +++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java @@ -0,0 +1,173 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; + +import org.apache.james.core.Domain; +import org.apache.james.core.Username; +import org.apache.james.task.Task; +import org.apache.james.task.TaskExecutionDetails; +import org.apache.james.task.TaskType; +import org.apache.james.user.api.UsersRepository; +import org.reactivestreams.Publisher; + +import com.google.common.annotations.VisibleForTesting; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class DeleteUsersDataOfDomainTask implements Task { + static final TaskType TYPE = TaskType.of("DeleteUsersDataOfDomainTask"); + private static final int LOW_CONCURRENCY = 2; + + public static class AdditionalInformation implements TaskExecutionDetails.AdditionalInformation { + private final Instant timestamp; + private final Domain domain; + private final long successfulUsersCount; + private final long failedUsersCount; + + public AdditionalInformation(Instant timestamp, Domain domain, long successfulUsersCount, long failedUsersCount) { + this.timestamp = timestamp; + this.domain = domain; + this.successfulUsersCount = successfulUsersCount; + this.failedUsersCount = failedUsersCount; + } + + public Domain getDomain() { + return domain; + } + + public long getSuccessfulUsersCount() { + return successfulUsersCount; + } + + public long getFailedUsersCount() { + return failedUsersCount; + } + + @Override + public Instant timestamp() { + return timestamp; + } + + @Override + public final boolean equals(Object o) { + if (o instanceof AdditionalInformation) { + AdditionalInformation that = (AdditionalInformation) o; + + return Objects.equals(this.successfulUsersCount, that.successfulUsersCount) + && Objects.equals(this.failedUsersCount, that.failedUsersCount) + && Objects.equals(this.timestamp, that.timestamp) + && Objects.equals(this.domain, that.domain); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(timestamp, domain, successfulUsersCount, failedUsersCount); + } + } + + static class Context { + private final AtomicLong successfulUsersCount; + private final AtomicLong failedUsersCount; + + public Context() { + this.successfulUsersCount = new AtomicLong(); + this.failedUsersCount = new AtomicLong(); + } + + private void increaseSuccessfulUsers() { + successfulUsersCount.incrementAndGet(); + } + + private void increaseFailedUsers() { + failedUsersCount.incrementAndGet(); + } + + public long getSuccessfulUsersCount() { + return successfulUsersCount.get(); + } + + public long getFailedUsersCount() { + return failedUsersCount.get(); + } + } + + private final Domain domain; + private final DeleteUserDataService deleteUserDataService; + private final UsersRepository usersRepository; + private final Context context; + + public DeleteUsersDataOfDomainTask(DeleteUserDataService deleteUserDataService, Domain domain, UsersRepository usersRepository) { + this.deleteUserDataService = deleteUserDataService; + this.domain = domain; + this.usersRepository = usersRepository; + this.context = new Context(); + } + + @Override + public Result run() { + return Flux.from(usersRepository.listUsersOfADomainReactive(domain)) + .flatMap(deleteUserData(), LOW_CONCURRENCY) + .reduce(Task::combine) + .switchIfEmpty(Mono.just(Result.COMPLETED)) + .block(); + } + + private Function<Username, Publisher<Result>> deleteUserData() { + return username -> deleteUserDataService.performer().deleteUserData(username) + .then(Mono.fromCallable(() -> { + context.increaseSuccessfulUsers(); + return Result.COMPLETED; + })) + .onErrorResume(error -> { + LOGGER.error("Error when deleting data of user {}", username.asString(), error); + context.increaseFailedUsers(); + return Mono.just(Result.PARTIAL); + }); + } + + @Override + public TaskType type() { + return TYPE; + } + + @Override + public Optional<TaskExecutionDetails.AdditionalInformation> details() { + return Optional.of(new AdditionalInformation(Clock.systemUTC().instant(), domain, context.getSuccessfulUsersCount(), context.getFailedUsersCount())); + } + + public Domain getDomain() { + return domain; + } + + @VisibleForTesting + Context getContext() { + return context; + } +} diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java new file mode 100644 index 0000000000..7bcd3c033d --- /dev/null +++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java @@ -0,0 +1,81 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.service; + +import java.time.Instant; + +import org.apache.james.core.Domain; +import org.apache.james.json.DTOModule; +import org.apache.james.server.task.json.dto.AdditionalInformationDTO; +import org.apache.james.server.task.json.dto.AdditionalInformationDTOModule; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class DeleteUsersDataOfDomainTaskAdditionalInformationDTO implements AdditionalInformationDTO { + public static AdditionalInformationDTOModule<DeleteUsersDataOfDomainTask.AdditionalInformation, DeleteUsersDataOfDomainTaskAdditionalInformationDTO> module() { + return DTOModule.forDomainObject(DeleteUsersDataOfDomainTask.AdditionalInformation.class) + .convertToDTO(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.class) + .toDomainObjectConverter(dto -> new DeleteUsersDataOfDomainTask.AdditionalInformation( + dto.timestamp, Domain.of(dto.domain), dto.successfulUsersCount, dto.failedUsersCount)) + .toDTOConverter((details, type) -> new DeleteUsersDataOfDomainTaskAdditionalInformationDTO( + type, details.getDomain().asString(), details.getSuccessfulUsersCount(), details.getFailedUsersCount(), details.timestamp())) + .typeName(DeleteUsersDataOfDomainTask.TYPE.asString()) + .withFactory(AdditionalInformationDTOModule::new); + } + + private final String type; + private final String domain; + private final long successfulUsersCount; + private final long failedUsersCount; + private final Instant timestamp; + + public DeleteUsersDataOfDomainTaskAdditionalInformationDTO(@JsonProperty("type") String type, + @JsonProperty("domain") String domain, + @JsonProperty("successfulUsersCount") long successfulUsersCount, + @JsonProperty("failedUsersCount") long failedUsersCount, + @JsonProperty("timestamp") Instant timestamp) { + this.type = type; + this.domain = domain; + this.successfulUsersCount = successfulUsersCount; + this.failedUsersCount = failedUsersCount; + this.timestamp = timestamp; + } + + public String getDomain() { + return domain; + } + + public long getSuccessfulUsersCount() { + return successfulUsersCount; + } + + public long getFailedUsersCount() { + return failedUsersCount; + } + + public Instant getTimestamp() { + return timestamp; + } + + @Override + public String getType() { + return type; + } +} diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskDTO.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskDTO.java new file mode 100644 index 0000000000..df9f11bd6d --- /dev/null +++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskDTO.java @@ -0,0 +1,68 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.service; + +import org.apache.james.core.Domain; +import org.apache.james.json.DTOModule; +import org.apache.james.server.task.json.dto.TaskDTO; +import org.apache.james.server.task.json.dto.TaskDTOModule; +import org.apache.james.user.api.UsersRepository; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class DeleteUsersDataOfDomainTaskDTO implements TaskDTO { + + public static TaskDTOModule<DeleteUsersDataOfDomainTask, DeleteUsersDataOfDomainTaskDTO> module(DeleteUserDataService service, UsersRepository usersRepository) { + return DTOModule + .forDomainObject(DeleteUsersDataOfDomainTask.class) + .convertToDTO(DeleteUsersDataOfDomainTaskDTO.class) + .toDomainObjectConverter(dto -> dto.fromDTO(service, usersRepository)) + .toDTOConverter(DeleteUsersDataOfDomainTaskDTO::toDTO) + .typeName(DeleteUsersDataOfDomainTask.TYPE.asString()) + .withFactory(TaskDTOModule::new); + } + + public static DeleteUsersDataOfDomainTaskDTO toDTO(DeleteUsersDataOfDomainTask domainObject, String typeName) { + return new DeleteUsersDataOfDomainTaskDTO(typeName, + domainObject.getDomain().asString()); + } + + private final String type; + private final String domain; + + public DeleteUsersDataOfDomainTaskDTO(@JsonProperty("type") String type, + @JsonProperty("domain") String domain) { + this.type = type; + this.domain = domain; + } + + public DeleteUsersDataOfDomainTask fromDTO(DeleteUserDataService service, UsersRepository usersRepository) { + return new DeleteUsersDataOfDomainTask(service, Domain.of(domain), usersRepository); + } + + @Override + public String getType() { + return type; + } + + public String getDomain() { + return domain; + } +} diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java index e4f0b9fe7b..baefeaf289 100644 --- a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java +++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java @@ -35,41 +35,97 @@ import static org.mockito.Mockito.when; import java.net.InetAddress; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.apache.commons.lang3.StringUtils; import org.apache.james.core.Domain; +import org.apache.james.core.Username; import org.apache.james.dnsservice.api.DNSService; import org.apache.james.domainlist.api.DomainList; import org.apache.james.domainlist.api.DomainListException; import org.apache.james.domainlist.lib.DomainListConfiguration; import org.apache.james.domainlist.memory.MemoryDomainList; +import org.apache.james.json.DTOConverter; import org.apache.james.rrt.memory.MemoryRecipientRewriteTable; +import org.apache.james.task.Hostname; +import org.apache.james.task.MemoryTaskManager; +import org.apache.james.user.api.DeleteUserDataTaskStep; +import org.apache.james.user.api.UsersRepositoryException; +import org.apache.james.user.memory.MemoryUsersRepository; import org.apache.james.webadmin.WebAdminServer; import org.apache.james.webadmin.WebAdminUtils; +import org.apache.james.webadmin.service.DeleteUserDataService; +import org.apache.james.webadmin.service.DeleteUsersDataOfDomainTaskAdditionalInformationDTO; import org.apache.james.webadmin.service.DomainAliasService; +import org.apache.james.webadmin.utils.ErrorResponder; import org.apache.james.webadmin.utils.JsonTransformer; import org.eclipse.jetty.http.HttpStatus; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import io.restassured.RestAssured; import io.restassured.http.ContentType; +import reactor.core.publisher.Mono; class DomainsRoutesTest { + private static class RecordProcessedUsersStep implements DeleteUserDataTaskStep { + private final Set<Username> processedUsers = ConcurrentHashMap.newKeySet(); + + public RecordProcessedUsersStep() { + } + + @Override + public StepName name() { + return new StepName("RecordProcessedUsersStep"); + } + + @Override + public int priority() { + return 0; + } + + @Override + public Publisher<Void> deleteUserData(Username username) { + processedUsers.add(username); + return Mono.empty(); + } + } + private static final String DOMAIN = "domain"; private static final String ALIAS_DOMAIN = "alias.domain"; private static final String ALIAS_DOMAIN_2 = "alias.domain.bis"; private static final String EXTERNAL_DOMAIN = "external.domain.tld"; private WebAdminServer webAdminServer; + private MemoryUsersRepository usersRepository; private void createServer(DomainList domainList) { + MemoryTaskManager taskManager = new MemoryTaskManager(new Hostname("foo")); + DomainAliasService domainAliasService = new DomainAliasService(new MemoryRecipientRewriteTable(), domainList); + usersRepository = MemoryUsersRepository.withVirtualHosting(domainList); + webAdminServer = WebAdminUtils.createWebAdminServer(new DomainsRoutes(domainList, domainAliasService, new JsonTransformer(), + new DeleteUserDataService(Set.of()), usersRepository, taskManager), new TasksRoutes(taskManager, new JsonTransformer(), DTOConverter.of(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module()))) + .start(); + + RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminServer) + .setBasePath(DomainsRoutes.DOMAINS) + .build(); + } + + private void createServer(DomainList domainList, Set<DeleteUserDataTaskStep> steps) { + MemoryTaskManager taskManager = new MemoryTaskManager(new Hostname("foo")); DomainAliasService domainAliasService = new DomainAliasService(new MemoryRecipientRewriteTable(), domainList); - webAdminServer = WebAdminUtils.createWebAdminServer(new DomainsRoutes(domainList, domainAliasService, new JsonTransformer())) + DeleteUserDataService deleteUserDataService = new DeleteUserDataService(steps); + usersRepository = MemoryUsersRepository.withVirtualHosting(domainList); + webAdminServer = WebAdminUtils.createWebAdminServer(new DomainsRoutes(domainList, domainAliasService, new JsonTransformer(), deleteUserDataService, usersRepository, taskManager), + new TasksRoutes(taskManager, new JsonTransformer(), DTOConverter.of(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module()))) .start(); RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminServer) @@ -82,6 +138,120 @@ class DomainsRoutesTest { webAdminServer.destroy(); } + @Nested + class DeleteAllUsersDataTests { + private RecordProcessedUsersStep recordProcessedUsersStep; + + @BeforeEach + void setUp() throws Exception { + DNSService dnsService = mock(DNSService.class); + when(dnsService.getHostName(any())).thenReturn("localhost"); + when(dnsService.getLocalHost()).thenReturn(InetAddress.getByName("localhost")); + MemoryDomainList domainList = new MemoryDomainList(dnsService); + domainList.configure(DomainListConfiguration.builder() + .autoDetect(false) + .autoDetectIp(false) + .build()); + domainList.addDomain(Domain.of("domain.tld")); + + recordProcessedUsersStep = new RecordProcessedUsersStep(); + Set<DeleteUserDataTaskStep> steps = ImmutableSet.of(new DeleteUserDataRoutesTest.StepImpl(new DeleteUserDataTaskStep.StepName("A"), 35, Mono.empty()), + recordProcessedUsersStep); + createServer(domainList, steps); + } + + @Test + void shouldDeleteAllUsersDataOfTheDomain() throws UsersRepositoryException { + // GIVEN localhost domain has 2 users + usersRepository.addUser(Username.of("user1@localhost"), "secret"); + usersRepository.addUser(Username.of("user2@localhost"), "secret"); + + // THEN delete all users data of localhost domain + String taskId = with() + .basePath("/domains") + .queryParam("action", "deleteData") + .post("/localhost") + .jsonPath() + .get("taskId"); + + given() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("type", is("DeleteUsersDataOfDomainTask")) + .body("status", is("completed")) + .body("additionalInformation.type", is("DeleteUsersDataOfDomainTask")) + .body("additionalInformation.domain", is("localhost")) + .body("additionalInformation.successfulUsersCount", is(2)) + .body("additionalInformation.failedUsersCount", is(0)); + + // then should delete data of the 2 users + assertThat(recordProcessedUsersStep.processedUsers) + .containsExactlyInAnyOrder(Username.of("user1@localhost"), Username.of("user2@localhost")); + } + + @Test + void shouldFailWhenInvalidAction() { + given() + .basePath("/domains") + .queryParam("action", "invalid") + .post("/localhost") + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("Invalid arguments supplied in the user request")) + .body("details", is("Invalid value supplied for query parameter 'action': invalid. Supported values are [deleteData]")); + } + + @Test + void shouldFailWhenNonExistingDomain() { + given() + .basePath("/domains") + .queryParam("action", "deleteData") + .post("/nonExistingDomain") + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("Invalid arguments supplied in the user request")) + .body("details", is("'domainName' parameter should be an existing domain")); + } + + @Test + void shouldNotDeleteUsersDataOfOtherDomains() throws UsersRepositoryException { + // GIVEN localhost domain has 2 users and domain.tld domain has 1 user + usersRepository.addUser(Username.of("user1@localhost"), "secret"); + usersRepository.addUser(Username.of("user2@localhost"), "secret"); + usersRepository.addUser(Username.of("us...@domain.tld"), "secret"); + + // WHEN delete users data of localhost domain + String taskId = with() + .basePath("/domains") + .queryParam("action", "deleteData") + .post("/localhost") + .jsonPath() + .get("taskId"); + + given() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("type", is("DeleteUsersDataOfDomainTask")) + .body("status", is("completed")) + .body("additionalInformation.type", is("DeleteUsersDataOfDomainTask")) + .body("additionalInformation.domain", is("localhost")) + .body("additionalInformation.successfulUsersCount", is(2)) + .body("additionalInformation.failedUsersCount", is(0)); + + // THEN users data of domain.tld should not be clear + assertThat(recordProcessedUsersStep.processedUsers) + .doesNotContain(Username.of("us...@domain.tld")); + } + } + @Nested class NormalBehaviour { @@ -433,7 +603,7 @@ class DomainsRoutesTest { with().put(DOMAIN); when() - .put(EXTERNAL_DOMAIN + "/aliases/" + DOMAIN).prettyPeek() + .put(EXTERNAL_DOMAIN + "/aliases/" + DOMAIN) .then() .contentType(ContentType.JSON) .statusCode(HttpStatus.NO_CONTENT_204); diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java new file mode 100644 index 0000000000..8de0149656 --- /dev/null +++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java @@ -0,0 +1,93 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.service; + +import static org.mockito.Mockito.mock; + +import java.time.Instant; + +import org.apache.james.JsonSerializationVerifier; +import org.apache.james.core.Domain; +import org.apache.james.core.Username; +import org.apache.james.user.api.DeleteUserDataTaskStep; +import org.apache.james.user.api.UsersRepository; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; + +import com.google.common.collect.ImmutableSet; + +import reactor.core.publisher.Mono; + +class DeleteUsersDataOfDomainTaskSerializationTest { + private static final Instant TIMESTAMP = Instant.parse("2018-11-13T12:00:55Z"); + private static final Domain DOMAIN = Domain.of("domain"); + private static final long SUCCESSFUL_USERS_COUNT = 99L; + private static final long FAILED_USERS_COUNT = 1L; + private static final DeleteUserDataTaskStep.StepName STEP_A = new DeleteUserDataTaskStep.StepName("A"); + private static final DeleteUserDataTaskStep.StepName STEP_B = new DeleteUserDataTaskStep.StepName("B"); + private static final DeleteUserDataTaskStep.StepName STEP_C = new DeleteUserDataTaskStep.StepName("C"); + private static final DeleteUserDataTaskStep.StepName STEP_D = new DeleteUserDataTaskStep.StepName("D"); + private static final DeleteUserDataTaskStep A = asStep(STEP_A); + private static final DeleteUserDataTaskStep B = asStep(STEP_B); + private static final DeleteUserDataTaskStep C = asStep(STEP_C); + private static final DeleteUserDataTaskStep D = asStep(STEP_D); + + private static DeleteUserDataTaskStep asStep(DeleteUserDataTaskStep.StepName name) { + return new DeleteUserDataTaskStep() { + @Override + public StepName name() { + return name; + } + + @Override + public int priority() { + return 0; + } + + @Override + public Publisher<Void> deleteUserData(Username username) { + return Mono.empty(); + } + }; + } + + private static final String SERIALIZED_TASK = "{\"type\":\"DeleteUsersDataOfDomainTask\",\"domain\":\"domain\"}"; + private static final String SERIALIZED_ADDITIONAL_INFORMATION = "{\"type\":\"DeleteUsersDataOfDomainTask\",\"domain\":\"domain\",\"successfulUsersCount\":99,\"failedUsersCount\":1,\"timestamp\":\"2018-11-13T12:00:55Z\"}"; + + private static final DeleteUserDataService SERVICE = new DeleteUserDataService(ImmutableSet.of(A, B, C, D)); + + @Test + void taskShouldBeSerializable() throws Exception { + UsersRepository usersRepository = mock(UsersRepository.class); + JsonSerializationVerifier.dtoModule(DeleteUsersDataOfDomainTaskDTO.module(SERVICE, usersRepository)) + .bean(new DeleteUsersDataOfDomainTask(SERVICE, DOMAIN, usersRepository)) + .json(SERIALIZED_TASK) + .verify(); + } + + @Test + void additionalInformationShouldBeSerializable() throws Exception { + JsonSerializationVerifier.dtoModule(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module()) + .bean(new DeleteUsersDataOfDomainTask.AdditionalInformation( + TIMESTAMP, DOMAIN, SUCCESSFUL_USERS_COUNT, FAILED_USERS_COUNT)) + .json(SERIALIZED_ADDITIONAL_INFORMATION) + .verify(); + } +} \ No newline at end of file diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java new file mode 100644 index 0000000000..f9bc9cecea --- /dev/null +++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java @@ -0,0 +1,194 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.InetAddress; +import java.util.Set; + +import org.apache.james.core.Domain; +import org.apache.james.core.Username; +import org.apache.james.dnsservice.api.DNSService; +import org.apache.james.domainlist.lib.DomainListConfiguration; +import org.apache.james.domainlist.memory.MemoryDomainList; +import org.apache.james.task.Task; +import org.apache.james.user.api.DeleteUserDataTaskStep; +import org.apache.james.user.api.UsersRepositoryException; +import org.apache.james.user.memory.MemoryUsersRepository; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; + +import reactor.core.publisher.Mono; + +class DeleteUsersDataOfDomainTaskTest { + public static class FailureStepUponUser implements DeleteUserDataTaskStep { + private final Set<Username> usersToBeFailed; + + public FailureStepUponUser(Set<Username> usersToBeFailed) { + this.usersToBeFailed = usersToBeFailed; + } + + @Override + public StepName name() { + return new StepName("FailureStepUponUser"); + } + + @Override + public int priority() { + return 0; + } + + @Override + public Publisher<Void> deleteUserData(Username username) { + if (usersToBeFailed.contains(username)) { + return Mono.error(new RuntimeException()); + } + return Mono.empty(); + } + } + + private static final Domain DOMAIN_1 = Domain.of("domain1.tld"); + private static final Domain DOMAIN_2 = Domain.of("domain2.tld"); + + private DeleteUserDataService service; + private MemoryUsersRepository usersRepository; + + @BeforeEach + void setup() throws Exception { + DNSService dnsService = mock(DNSService.class); + when(dnsService.getHostName(any())).thenReturn("localhost"); + when(dnsService.getLocalHost()).thenReturn(InetAddress.getByName("localhost")); + MemoryDomainList domainList = new MemoryDomainList(dnsService); + domainList.configure(DomainListConfiguration.builder() + .autoDetect(false) + .autoDetectIp(false) + .build()); + domainList.addDomain(DOMAIN_1); + domainList.addDomain(DOMAIN_2); + + usersRepository = MemoryUsersRepository.withVirtualHosting(domainList); + } + + @Test + void shouldCountSuccessfulUsers() throws UsersRepositoryException { + // GIVEN DOMAIN1 has 2 users + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + + // WHEN run task for DOMAIN1 + service = new DeleteUserDataService(Set.of()); + DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository); + Task.Result result = task.run(); + + // THEN should count successful DOMAIN1 users + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).isEqualTo(Task.Result.COMPLETED); + softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(2L); + softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(0L); + }); + } + + @Test + void shouldCountOnlySuccessfulUsersOfRequestedDomain() throws UsersRepositoryException { + // GIVEN DOMAIN1 has 2 users and DOMAIN2 has 1 user + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + usersRepository.addUser(Username.of("us...@domain2.tld"), "password"); + + // WHEN run task for DOMAIN1 + service = new DeleteUserDataService(Set.of()); + DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository); + Task.Result result = task.run(); + + // THEN should count only successful DOMAIN1 users + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).isEqualTo(Task.Result.COMPLETED); + softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(2L); + softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(0L); + }); + } + + @Test + void shouldCountFailedUsers() throws UsersRepositoryException { + // GIVEN DOMAIN1 has 2 users + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + + // WHEN run task for DOMAIN1 + Set<Username> usersTobeFailed = Set.of(Username.of("us...@domain1.tld"), Username.of("us...@domain1.tld")); + service = new DeleteUserDataService(Set.of(new FailureStepUponUser(usersTobeFailed))); + DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository); + Task.Result result = task.run(); + + // THEN should count failed DOMAIN1 users + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).isEqualTo(Task.Result.PARTIAL); + softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(0L); + softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(2L); + }); + } + + @Test + void shouldCountOnlyFailedUsersOfRequestedDomain() throws UsersRepositoryException { + // GIVEN DOMAIN1 has 2 users and DOMAIN2 has 1 user + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + usersRepository.addUser(Username.of("us...@domain2.tld"), "password"); + + // WHEN run task for DOMAIN1 + Set<Username> usersTobeFailed = Set.of(Username.of("us...@domain1.tld"), Username.of("us...@domain1.tld"), Username.of("us...@domain2.tld")); + service = new DeleteUserDataService(Set.of(new FailureStepUponUser(usersTobeFailed))); + DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository); + Task.Result result = task.run(); + + // THEN should count only failed DOMAIN1 users + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).isEqualTo(Task.Result.PARTIAL); + softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(0L); + softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(2L); + }); + } + + @Test + void mixedSuccessfulAndFailedUsersCase() throws UsersRepositoryException { + // GIVEN DOMAIN1 has 3 users + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + usersRepository.addUser(Username.of("us...@domain1.tld"), "password"); + + // WHEN run task for DOMAIN1 + Set<Username> usersTobeFailed = Set.of(Username.of("us...@domain1.tld")); + service = new DeleteUserDataService(Set.of(new FailureStepUponUser(usersTobeFailed))); + DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository); + Task.Result result = task.run(); + + // THEN should count both successful and failed users + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).isEqualTo(Task.Result.PARTIAL); + softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(2L); + softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(1L); + }); + } +} diff --git a/src/site/markdown/server/manage-webadmin.md b/src/site/markdown/server/manage-webadmin.md index 329cd488ad..5864d0a644 100644 --- a/src/site/markdown/server/manage-webadmin.md +++ b/src/site/markdown/server/manage-webadmin.md @@ -178,6 +178,7 @@ Response codes: - [Get the list of aliases for a domain](#Get_the_list_of_aliases_for_a_domain) - [Create an alias for a domain](#Create_an_alias_for_a_domain) - [Delete an alias for a domain](#Delete_an_alias_for_a_domain) + - [Delete all users data of a domain](#delete-all-users-data-of-a-domain) ### Create a domain @@ -302,6 +303,33 @@ Response codes: - 400: source, domain and destination domain are the same - 404: `source.domain.tld` are not part of handled domains. +### Delete all users data of a domain + +``` +curl -XPOST http://ip:port/domains/{domainToBeUsed}?action=deleteData +``` + +Would create a task that deletes data of all users of the domain. + +[More details about endpoints returning a task](#_endpoints_returning_a_task). + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the following type `DeleteUsersDataOfDomainTask` and the following `additionalInformation`: + +``` +{ + "type": "DeleteUsersDataOfDomainTask", + "domain": "domain.tld", + "successfulUsersCount": 2, + "failedUsersCount": 1, + "timestamp": "2023-05-22T08:52:47.076261Z" +} +``` + ## Administrating users - [Create a user](#Create_a_user) --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org