Skip to content

Commit 21950f7

Browse files
authored
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).
1 parent e66aee0 commit 21950f7

File tree

5 files changed

+478
-2
lines changed

5 files changed

+478
-2
lines changed

core/src/main/java/google/registry/flows/ExtensionManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ private void checkForUndeclaredExtensions(
105105
}
106106

107107
private static final ImmutableSet<EppRequestSource> ALLOWED_METADATA_EPP_REQUEST_SOURCES =
108-
ImmutableSet.of(EppRequestSource.TOOL, EppRequestSource.BACKEND);
108+
ImmutableSet.of(EppRequestSource.BACKEND, EppRequestSource.CONSOLE, EppRequestSource.TOOL);
109109

110110
private void checkForRestrictedExtensions(
111111
ImmutableSet<Class<? extends CommandExtension>> suppliedExtensions)
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package google.registry.ui.server.console;
16+
17+
import static com.google.common.collect.ImmutableMap.toImmutableMap;
18+
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
19+
import static java.nio.charset.StandardCharsets.UTF_8;
20+
21+
import com.google.common.collect.ImmutableMap;
22+
import com.google.common.escape.Escaper;
23+
import com.google.common.xml.XmlEscapers;
24+
import com.google.gson.JsonElement;
25+
import com.google.gson.annotations.Expose;
26+
import google.registry.flows.EppController;
27+
import google.registry.flows.EppRequestSource;
28+
import google.registry.flows.PasswordOnlyTransportCredentials;
29+
import google.registry.flows.StatelessRequestSessionMetadata;
30+
import google.registry.model.console.ConsolePermission;
31+
import google.registry.model.console.User;
32+
import google.registry.model.eppcommon.ProtocolDefinition;
33+
import google.registry.model.eppoutput.EppOutput;
34+
import google.registry.model.eppoutput.Result;
35+
import google.registry.request.Action;
36+
import google.registry.request.OptionalJsonPayload;
37+
import google.registry.request.Parameter;
38+
import google.registry.request.auth.Auth;
39+
import java.util.List;
40+
import java.util.Map;
41+
import java.util.Optional;
42+
import javax.inject.Inject;
43+
44+
/**
45+
* Console endpoint to perform the same action to a list of domains.
46+
*
47+
* <p>All requests must include the {@link BulkAction} to perform as well as a {@link
48+
* BulkDomainList} of domains on which to apply the action. The remaining contents of the request
49+
* body depend on the type of action -- some requests may require more data than others.
50+
*/
51+
@Action(
52+
service = Action.GaeService.DEFAULT,
53+
gkeService = Action.GkeService.CONSOLE,
54+
path = ConsoleBulkDomainAction.PATH,
55+
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
56+
public class ConsoleBulkDomainAction extends ConsoleApiAction {
57+
58+
public static final String PATH = "/console-api/bulk-domain";
59+
60+
private static Escaper XML_ESCAPER = XmlEscapers.xmlContentEscaper();
61+
62+
public enum BulkAction {
63+
DELETE,
64+
SUSPEND
65+
}
66+
67+
/** All requests must include at least a list of domain names on which to perform the action. */
68+
public record BulkDomainList(@Expose List<String> domainList) {}
69+
70+
public record BulkDomainDeleteRequest(@Expose String reason) {}
71+
72+
public record BulkDomainSuspendRequest(@Expose String reason) {}
73+
74+
private static final String DOMAIN_DELETE_XML =
75+
"""
76+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
77+
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
78+
<command>
79+
<delete>
80+
<domain:delete
81+
xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
82+
<domain:name>%DOMAIN_NAME%</domain:name>
83+
</domain:delete>
84+
</delete>
85+
<extension>
86+
<metadata:metadata xmlns:metadata="urn:google:params:xml:ns:metadata-1.0">
87+
<metadata:reason>%REASON%</metadata:reason>
88+
<metadata:requestedByRegistrar>true</metadata:requestedByRegistrar>
89+
</metadata:metadata>
90+
</extension>
91+
<clTRID>RegistryConsole</clTRID>
92+
</command>
93+
</epp>""";
94+
95+
private static final String DOMAIN_SUSPEND_XML =
96+
"""
97+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
98+
<epp
99+
xmlns="urn:ietf:params:xml:ns:epp-1.0">
100+
<command>
101+
<update>
102+
<domain:update
103+
xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
104+
<domain:name>%DOMAIN_NAME%</domain:name>
105+
<domain:add>
106+
<domain:status s="serverDeleteProhibited" lang="en"></domain:status>
107+
<domain:status s="serverHold" lang="en"></domain:status>
108+
<domain:status s="serverRenewProhibited" lang="en"></domain:status>
109+
<domain:status s="serverTransferProhibited" lang="en"></domain:status>
110+
<domain:status s="serverUpdateProhibited" lang="en"></domain:status>
111+
</domain:add>
112+
<domain:rem></domain:rem>
113+
</domain:update>
114+
</update>
115+
<extension>
116+
<metadata:metadata
117+
xmlns:metadata="urn:google:params:xml:ns:metadata-1.0">
118+
<metadata:reason>Console suspension: %REASON%</metadata:reason>
119+
<metadata:requestedByRegistrar>false</metadata:requestedByRegistrar>
120+
</metadata:metadata>
121+
</extension>
122+
<clTRID>RegistryTool</clTRID>
123+
</command>
124+
</epp>""";
125+
126+
private final EppController eppController;
127+
private final String registrarId;
128+
private final String bulkDomainAction;
129+
private final Optional<JsonElement> optionalJsonPayload;
130+
131+
@Inject
132+
public ConsoleBulkDomainAction(
133+
ConsoleApiParams consoleApiParams,
134+
EppController eppController,
135+
@Parameter("registrarId") String registrarId,
136+
@Parameter("bulkDomainAction") String bulkDomainAction,
137+
@OptionalJsonPayload Optional<JsonElement> optionalJsonPayload) {
138+
super(consoleApiParams);
139+
this.eppController = eppController;
140+
this.registrarId = registrarId;
141+
this.bulkDomainAction = bulkDomainAction;
142+
this.optionalJsonPayload = optionalJsonPayload;
143+
}
144+
145+
@Override
146+
protected void postHandler(User user) {
147+
BulkAction bulkAction = BulkAction.valueOf(bulkDomainAction);
148+
JsonElement jsonPayload =
149+
optionalJsonPayload.orElseThrow(
150+
() -> new IllegalArgumentException("Bulk action payload must be present"));
151+
BulkDomainList domainList = consoleApiParams.gson().fromJson(jsonPayload, BulkDomainList.class);
152+
checkPermission(user, registrarId, ConsolePermission.EXECUTE_EPP_COMMANDS);
153+
ImmutableMap<String, ConsoleEppOutput> result =
154+
switch (bulkAction) {
155+
case DELETE -> handleBulkDelete(jsonPayload, domainList, user);
156+
case SUSPEND -> handleBulkSuspend(jsonPayload, domainList, user);
157+
};
158+
// Front end should parse situations where only some commands worked
159+
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(result));
160+
consoleApiParams.response().setStatus(SC_OK);
161+
}
162+
163+
private ImmutableMap<String, ConsoleEppOutput> handleBulkDelete(
164+
JsonElement jsonPayload, BulkDomainList domainList, User user) {
165+
String reason =
166+
consoleApiParams.gson().fromJson(jsonPayload, BulkDomainDeleteRequest.class).reason;
167+
return runCommandOverDomains(
168+
domainList,
169+
DOMAIN_DELETE_XML,
170+
new ImmutableMap.Builder<String, String>().put("REASON", reason),
171+
user);
172+
}
173+
174+
private ImmutableMap<String, ConsoleEppOutput> handleBulkSuspend(
175+
JsonElement jsonPayload, BulkDomainList domainList, User user) {
176+
String reason =
177+
consoleApiParams.gson().fromJson(jsonPayload, BulkDomainSuspendRequest.class).reason;
178+
return runCommandOverDomains(
179+
domainList,
180+
DOMAIN_SUSPEND_XML,
181+
new ImmutableMap.Builder<String, String>().put("REASON", reason),
182+
user);
183+
}
184+
185+
/** Runs the provided XML template and substitutions over a provided list of domains. */
186+
private ImmutableMap<String, ConsoleEppOutput> runCommandOverDomains(
187+
BulkDomainList domainList,
188+
String xmlTemplate,
189+
ImmutableMap.Builder<String, String> replacements,
190+
User user) {
191+
return domainList.domainList.stream()
192+
.collect(
193+
toImmutableMap(
194+
d -> d,
195+
d ->
196+
executeEpp(
197+
fillSubstitutions(xmlTemplate, replacements.put("DOMAIN_NAME", d)), user)));
198+
}
199+
200+
private ConsoleEppOutput executeEpp(String xml, User user) {
201+
return ConsoleEppOutput.fromEppOutput(
202+
eppController.handleEppCommand(
203+
new StatelessRequestSessionMetadata(
204+
registrarId, ProtocolDefinition.getVisibleServiceExtensionUris()),
205+
new PasswordOnlyTransportCredentials(),
206+
EppRequestSource.CONSOLE,
207+
false,
208+
user.getUserRoles().isAdmin(),
209+
xml.getBytes(UTF_8)));
210+
}
211+
212+
/** Fills the provided XML template with the replacement values, including escaping the values. */
213+
private String fillSubstitutions(
214+
String xmlTemplate, ImmutableMap.Builder<String, String> replacements) {
215+
String xml = xmlTemplate;
216+
for (Map.Entry<String, String> entry : replacements.buildKeepingLast().entrySet()) {
217+
xml = xml.replaceAll("%" + entry.getKey() + "%", XML_ESCAPER.escape(entry.getValue()));
218+
}
219+
return xml;
220+
}
221+
222+
public record ConsoleEppOutput(@Expose String message, @Expose int responseCode) {
223+
static ConsoleEppOutput fromEppOutput(EppOutput eppOutput) {
224+
Result result = eppOutput.getResponse().getResult();
225+
return new ConsoleEppOutput(result.getMsg(), result.getCode().code);
226+
}
227+
}
228+
}

core/src/main/java/google/registry/ui/server/console/ConsoleModule.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ public static Optional<String> provideSearchTerm(HttpServletRequest req) {
241241
return extractOptionalParameter(req, "searchTerm");
242242
}
243243

244+
@Provides
245+
@Parameter("bulkDomainAction")
246+
public static String provideBulkDomainAction(HttpServletRequest req) {
247+
return extractRequiredParameter(req, "bulkDomainAction");
248+
}
249+
244250
@Provides
245251
@Parameter("eppPasswordChangeRequest")
246252
public static Optional<EppPasswordData> provideEppPasswordChangeRequest(

core/src/test/java/google/registry/flows/ExtensionManagerTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ void testMetadataExtension_allowedForToolSource() throws Exception {
121121
void testMetadataExtension_forbiddenWhenNotToolSource() {
122122
ExtensionManager manager =
123123
new TestInstanceBuilder()
124-
.setEppRequestSource(EppRequestSource.CONSOLE)
124+
.setEppRequestSource(EppRequestSource.TLS)
125125
.setDeclaredUris()
126126
.setSuppliedExtensions(MetadataExtension.class)
127127
.build();

0 commit comments

Comments
 (0)