From 21950f7d82bea7552354ac553b6648bcb56463c8 Mon Sep 17 00:00:00 2001 From: gbrodman Date: Fri, 22 Nov 2024 15:47:47 -0500 Subject: [PATCH] Add a bulk-domain-action console endpoint (#2611) For now it only includes two options (domain deletion and domain suspension). In the future, as necessary, we can add other actions but this seems like a relatively simple starting point (actions like bulk updates are much more conceptually complex). --- .../registry/flows/ExtensionManager.java | 2 +- .../console/ConsoleBulkDomainAction.java | 228 +++++++++++++++++ .../ui/server/console/ConsoleModule.java | 6 + .../registry/flows/ExtensionManagerTest.java | 2 +- .../console/ConsoleBulkDomainActionTest.java | 242 ++++++++++++++++++ 5 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/google/registry/ui/server/console/ConsoleBulkDomainAction.java create mode 100644 core/src/test/java/google/registry/ui/server/console/ConsoleBulkDomainActionTest.java diff --git a/core/src/main/java/google/registry/flows/ExtensionManager.java b/core/src/main/java/google/registry/flows/ExtensionManager.java index fc18dad44..ef894549c 100644 --- a/core/src/main/java/google/registry/flows/ExtensionManager.java +++ b/core/src/main/java/google/registry/flows/ExtensionManager.java @@ -105,7 +105,7 @@ public final class ExtensionManager { } private static final ImmutableSet ALLOWED_METADATA_EPP_REQUEST_SOURCES = - ImmutableSet.of(EppRequestSource.TOOL, EppRequestSource.BACKEND); + ImmutableSet.of(EppRequestSource.BACKEND, EppRequestSource.CONSOLE, EppRequestSource.TOOL); private void checkForRestrictedExtensions( ImmutableSet> suppliedExtensions) diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleBulkDomainAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleBulkDomainAction.java new file mode 100644 index 000000000..9d71e38bd --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleBulkDomainAction.java @@ -0,0 +1,228 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.ui.server.console; + +import static com.google.common.collect.ImmutableMap.toImmutableMap; +import static jakarta.servlet.http.HttpServletResponse.SC_OK; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.collect.ImmutableMap; +import com.google.common.escape.Escaper; +import com.google.common.xml.XmlEscapers; +import com.google.gson.JsonElement; +import com.google.gson.annotations.Expose; +import google.registry.flows.EppController; +import google.registry.flows.EppRequestSource; +import google.registry.flows.PasswordOnlyTransportCredentials; +import google.registry.flows.StatelessRequestSessionMetadata; +import google.registry.model.console.ConsolePermission; +import google.registry.model.console.User; +import google.registry.model.eppcommon.ProtocolDefinition; +import google.registry.model.eppoutput.EppOutput; +import google.registry.model.eppoutput.Result; +import google.registry.request.Action; +import google.registry.request.OptionalJsonPayload; +import google.registry.request.Parameter; +import google.registry.request.auth.Auth; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.inject.Inject; + +/** + * Console endpoint to perform the same action to a list of domains. + * + *

All requests must include the {@link BulkAction} to perform as well as a {@link + * BulkDomainList} of domains on which to apply the action. The remaining contents of the request + * body depend on the type of action -- some requests may require more data than others. + */ +@Action( + service = Action.GaeService.DEFAULT, + gkeService = Action.GkeService.CONSOLE, + path = ConsoleBulkDomainAction.PATH, + auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public class ConsoleBulkDomainAction extends ConsoleApiAction { + + public static final String PATH = "/console-api/bulk-domain"; + + private static Escaper XML_ESCAPER = XmlEscapers.xmlContentEscaper(); + + public enum BulkAction { + DELETE, + SUSPEND + } + + /** All requests must include at least a list of domain names on which to perform the action. */ + public record BulkDomainList(@Expose List domainList) {} + + public record BulkDomainDeleteRequest(@Expose String reason) {} + + public record BulkDomainSuspendRequest(@Expose String reason) {} + + private static final String DOMAIN_DELETE_XML = + """ + + + + + + %DOMAIN_NAME% + + + + + %REASON% + true + + + RegistryConsole + +"""; + + private static final String DOMAIN_SUSPEND_XML = + """ + + + + + + %DOMAIN_NAME% + + + + + + + + + + + + + Console suspension: %REASON% + false + + + RegistryTool + +"""; + + private final EppController eppController; + private final String registrarId; + private final String bulkDomainAction; + private final Optional optionalJsonPayload; + + @Inject + public ConsoleBulkDomainAction( + ConsoleApiParams consoleApiParams, + EppController eppController, + @Parameter("registrarId") String registrarId, + @Parameter("bulkDomainAction") String bulkDomainAction, + @OptionalJsonPayload Optional optionalJsonPayload) { + super(consoleApiParams); + this.eppController = eppController; + this.registrarId = registrarId; + this.bulkDomainAction = bulkDomainAction; + this.optionalJsonPayload = optionalJsonPayload; + } + + @Override + protected void postHandler(User user) { + BulkAction bulkAction = BulkAction.valueOf(bulkDomainAction); + JsonElement jsonPayload = + optionalJsonPayload.orElseThrow( + () -> new IllegalArgumentException("Bulk action payload must be present")); + BulkDomainList domainList = consoleApiParams.gson().fromJson(jsonPayload, BulkDomainList.class); + checkPermission(user, registrarId, ConsolePermission.EXECUTE_EPP_COMMANDS); + ImmutableMap result = + switch (bulkAction) { + case DELETE -> handleBulkDelete(jsonPayload, domainList, user); + case SUSPEND -> handleBulkSuspend(jsonPayload, domainList, user); + }; + // Front end should parse situations where only some commands worked + consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(result)); + consoleApiParams.response().setStatus(SC_OK); + } + + private ImmutableMap handleBulkDelete( + JsonElement jsonPayload, BulkDomainList domainList, User user) { + String reason = + consoleApiParams.gson().fromJson(jsonPayload, BulkDomainDeleteRequest.class).reason; + return runCommandOverDomains( + domainList, + DOMAIN_DELETE_XML, + new ImmutableMap.Builder().put("REASON", reason), + user); + } + + private ImmutableMap handleBulkSuspend( + JsonElement jsonPayload, BulkDomainList domainList, User user) { + String reason = + consoleApiParams.gson().fromJson(jsonPayload, BulkDomainSuspendRequest.class).reason; + return runCommandOverDomains( + domainList, + DOMAIN_SUSPEND_XML, + new ImmutableMap.Builder().put("REASON", reason), + user); + } + + /** Runs the provided XML template and substitutions over a provided list of domains. */ + private ImmutableMap runCommandOverDomains( + BulkDomainList domainList, + String xmlTemplate, + ImmutableMap.Builder replacements, + User user) { + return domainList.domainList.stream() + .collect( + toImmutableMap( + d -> d, + d -> + executeEpp( + fillSubstitutions(xmlTemplate, replacements.put("DOMAIN_NAME", d)), user))); + } + + private ConsoleEppOutput executeEpp(String xml, User user) { + return ConsoleEppOutput.fromEppOutput( + eppController.handleEppCommand( + new StatelessRequestSessionMetadata( + registrarId, ProtocolDefinition.getVisibleServiceExtensionUris()), + new PasswordOnlyTransportCredentials(), + EppRequestSource.CONSOLE, + false, + user.getUserRoles().isAdmin(), + xml.getBytes(UTF_8))); + } + + /** Fills the provided XML template with the replacement values, including escaping the values. */ + private String fillSubstitutions( + String xmlTemplate, ImmutableMap.Builder replacements) { + String xml = xmlTemplate; + for (Map.Entry entry : replacements.buildKeepingLast().entrySet()) { + xml = xml.replaceAll("%" + entry.getKey() + "%", XML_ESCAPER.escape(entry.getValue())); + } + return xml; + } + + public record ConsoleEppOutput(@Expose String message, @Expose int responseCode) { + static ConsoleEppOutput fromEppOutput(EppOutput eppOutput) { + Result result = eppOutput.getResponse().getResult(); + return new ConsoleEppOutput(result.getMsg(), result.getCode().code); + } + } +} diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleModule.java b/core/src/main/java/google/registry/ui/server/console/ConsoleModule.java index 0a6257198..a4741664a 100644 --- a/core/src/main/java/google/registry/ui/server/console/ConsoleModule.java +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleModule.java @@ -241,6 +241,12 @@ public final class ConsoleModule { return extractOptionalParameter(req, "searchTerm"); } + @Provides + @Parameter("bulkDomainAction") + public static String provideBulkDomainAction(HttpServletRequest req) { + return extractRequiredParameter(req, "bulkDomainAction"); + } + @Provides @Parameter("eppPasswordChangeRequest") public static Optional provideEppPasswordChangeRequest( diff --git a/core/src/test/java/google/registry/flows/ExtensionManagerTest.java b/core/src/test/java/google/registry/flows/ExtensionManagerTest.java index 9398dabee..8d720a64b 100644 --- a/core/src/test/java/google/registry/flows/ExtensionManagerTest.java +++ b/core/src/test/java/google/registry/flows/ExtensionManagerTest.java @@ -121,7 +121,7 @@ class ExtensionManagerTest { void testMetadataExtension_forbiddenWhenNotToolSource() { ExtensionManager manager = new TestInstanceBuilder() - .setEppRequestSource(EppRequestSource.CONSOLE) + .setEppRequestSource(EppRequestSource.TLS) .setDeclaredUris() .setSuppliedExtensions(MetadataExtension.class) .build(); diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleBulkDomainActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleBulkDomainActionTest.java new file mode 100644 index 000000000..406936f49 --- /dev/null +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleBulkDomainActionTest.java @@ -0,0 +1,242 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.ui.server.console; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_OPTIONAL; +import static google.registry.model.common.FeatureFlag.FeatureStatus.INACTIVE; +import static google.registry.testing.DatabaseHelper.createTld; +import static google.registry.testing.DatabaseHelper.loadByEntity; +import static google.registry.testing.DatabaseHelper.persistActiveContact; +import static google.registry.testing.DatabaseHelper.persistDomainWithDependentResources; +import static google.registry.testing.DatabaseHelper.persistResource; +import static google.registry.util.DateTimeUtils.START_OF_TIME; +import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST; +import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static jakarta.servlet.http.HttpServletResponse.SC_OK; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSortedMap; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.reflect.TypeToken; +import google.registry.flows.DaggerEppTestComponent; +import google.registry.flows.EppController; +import google.registry.flows.EppTestComponent; +import google.registry.model.common.FeatureFlag; +import google.registry.model.console.GlobalRole; +import google.registry.model.console.RegistrarRole; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; +import google.registry.model.domain.Domain; +import google.registry.model.eppcommon.StatusValue; +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.request.auth.AuthResult; +import google.registry.testing.ConsoleApiParamsUtils; +import google.registry.testing.FakeClock; +import google.registry.testing.FakeResponse; +import google.registry.tools.GsonUtils; +import java.util.Map; +import java.util.Optional; +import org.joda.time.DateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Tests for {@link ConsoleBulkDomainAction}. */ +public class ConsoleBulkDomainActionTest { + + private static final Gson GSON = GsonUtils.provideGson(); + + private final FakeClock clock = new FakeClock(DateTime.parse("2024-05-13T00:00:00.000Z")); + + @RegisterExtension + final JpaTestExtensions.JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension(); + + private EppController eppController; + private FakeResponse fakeResponse; + private Domain domain; + + @BeforeEach + void beforeEach() { + persistResource( + new FeatureFlag() + .asBuilder() + .setFeatureName(MINIMUM_DATASET_CONTACTS_OPTIONAL) + .setStatusMap(ImmutableSortedMap.of(START_OF_TIME, INACTIVE)) + .build()); + eppController = + DaggerEppTestComponent.builder() + .fakesAndMocksModule(EppTestComponent.FakesAndMocksModule.create(clock)) + .build() + .startRequest() + .eppController(); + createTld("tld"); + domain = + persistDomainWithDependentResources( + "example", + "tld", + persistActiveContact("contact1234"), + clock.nowUtc(), + clock.nowUtc().minusMonths(1), + clock.nowUtc().plusMonths(11)); + } + + @Test + void testSuccess_delete() { + ConsoleBulkDomainAction action = + createAction( + "DELETE", + GSON.toJsonTree( + ImmutableMap.of("domainList", ImmutableList.of("example.tld"), "reason", "test"))); + action.run(); + assertThat(fakeResponse.getStatus()).isEqualTo(SC_OK); + assertThat(fakeResponse.getPayload()) + .isEqualTo( + "{\"example.tld\":{\"message\":\"Command completed" + + " successfully\",\"responseCode\":1000}}"); + assertThat(loadByEntity(domain).getDeletionTime()).isEqualTo(clock.nowUtc()); + } + + @Test + void testSuccess_suspend() throws Exception { + User adminUser = + persistResource( + new User.Builder() + .setEmailAddress("email@email.com") + .setUserRoles( + new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).setIsAdmin(true).build()) + .build()); + ConsoleBulkDomainAction action = + createAction( + "SUSPEND", + GSON.toJsonTree( + ImmutableMap.of("domainList", ImmutableList.of("example.tld"), "reason", "test")), + adminUser); + action.run(); + assertThat(fakeResponse.getStatus()).isEqualTo(SC_OK); + assertThat(fakeResponse.getPayload()) + .isEqualTo( + "{\"example.tld\":{\"message\":\"Command completed" + + " successfully\",\"responseCode\":1000}}"); + assertThat(loadByEntity(domain).getStatusValues()) + .containsAtLeast( + StatusValue.SERVER_RENEW_PROHIBITED, + StatusValue.SERVER_TRANSFER_PROHIBITED, + StatusValue.SERVER_UPDATE_PROHIBITED, + StatusValue.SERVER_DELETE_PROHIBITED, + StatusValue.SERVER_HOLD); + } + + @Test + void testHalfSuccess_halfNonexistent() throws Exception { + ConsoleBulkDomainAction action = + createAction( + "DELETE", + GSON.toJsonTree( + ImmutableMap.of( + "domainList", + ImmutableList.of("example.tld", "nonexistent.tld"), + "reason", + "test"))); + action.run(); + assertThat(fakeResponse.getStatus()).isEqualTo(SC_OK); + assertThat(fakeResponse.getPayload()) + .isEqualTo( + "{\"example.tld\":{\"message\":\"Command completed" + + " successfully\",\"responseCode\":1000},\"nonexistent.tld\":{\"message\":\"The" + + " domain with given ID (nonexistent.tld) doesn\\u0027t" + + " exist.\",\"responseCode\":2303}}"); + assertThat(loadByEntity(domain).getDeletionTime()).isEqualTo(clock.nowUtc()); + } + + @Test + void testFailure_badActionString() { + ConsoleBulkDomainAction action = createAction("bad", null); + action.run(); + assertThat(fakeResponse.getStatus()).isEqualTo(SC_BAD_REQUEST); + assertThat(fakeResponse.getPayload()) + .isEqualTo( + "No enum constant" + + " google.registry.ui.server.console.ConsoleBulkDomainAction.BulkAction.bad"); + } + + @Test + void testFailure_emptyBody() { + ConsoleBulkDomainAction action = createAction("DELETE", null); + action.run(); + assertThat(fakeResponse.getStatus()).isEqualTo(SC_BAD_REQUEST); + assertThat(fakeResponse.getPayload()).isEqualTo("Bulk action payload must be present"); + } + + @Test + void testFailure_noPermission() { + JsonElement payload = + GSON.toJsonTree(ImmutableMap.of("domainList", ImmutableList.of("domain.tld"))); + ConsoleBulkDomainAction action = + createAction( + "DELETE", + payload, + new User.Builder() + .setEmailAddress("foobar@theregistrar.com") + .setUserRoles( + new UserRoles.Builder() + .setRegistrarRoles( + ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER)) + .build()) + .build()); + action.run(); + assertThat(fakeResponse.getStatus()).isEqualTo(SC_FORBIDDEN); + } + + @Test + void testFailure_suspend_nonAdmin() { + ConsoleBulkDomainAction action = + createAction( + "SUSPEND", + GSON.toJsonTree( + ImmutableMap.of("domainList", ImmutableList.of("example.tld"), "reason", "test"))); + action.run(); + assertThat(fakeResponse.getStatus()).isEqualTo(SC_OK); + Map payload = + GSON.fromJson(fakeResponse.getPayload(), new TypeToken<>() {}); + assertThat(payload).containsKey("example.tld"); + assertThat(payload.get("example.tld").responseCode()).isEqualTo(2004); + assertThat(payload.get("example.tld").message()).contains("cannot be set by clients"); + assertThat(loadByEntity(domain)).isEqualTo(domain); + } + + private ConsoleBulkDomainAction createAction(String action, JsonElement payload) { + User user = + persistResource( + new User.Builder() + .setEmailAddress("email@email.com") + .setUserRoles(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build()) + .build()); + return createAction(action, payload, user); + } + + private ConsoleBulkDomainAction createAction(String action, JsonElement payload, User user) { + AuthResult authResult = AuthResult.createUser(user); + ConsoleApiParams params = ConsoleApiParamsUtils.createFake(authResult); + when(params.request().getMethod()).thenReturn("POST"); + fakeResponse = (FakeResponse) params.response(); + return new ConsoleBulkDomainAction( + params, eppController, "TheRegistrar", action, Optional.ofNullable(payload)); + } +}