diff --git a/core/src/main/java/google/registry/batch/BatchModule.java b/core/src/main/java/google/registry/batch/BatchModule.java
index f452066fb..cdac49629 100644
--- a/core/src/main/java/google/registry/batch/BatchModule.java
+++ b/core/src/main/java/google/registry/batch/BatchModule.java
@@ -21,6 +21,7 @@ import static google.registry.batch.AsyncTaskEnqueuer.PARAM_RESOURCE_KEY;
import static google.registry.batch.AsyncTaskEnqueuer.QUEUE_ASYNC_ACTIONS;
import static google.registry.batch.AsyncTaskEnqueuer.QUEUE_ASYNC_DELETE;
import static google.registry.batch.AsyncTaskEnqueuer.QUEUE_ASYNC_HOST_RENAME;
+import static google.registry.batch.CannedScriptExecutionAction.SCRIPT_PARAM;
import static google.registry.request.RequestParameters.extractBooleanParameter;
import static google.registry.request.RequestParameters.extractIntParameter;
import static google.registry.request.RequestParameters.extractLongParameter;
@@ -145,4 +146,11 @@ public class BatchModule {
static Queue provideAsyncHostRenamePullQueue() {
return getQueue(QUEUE_ASYNC_HOST_RENAME);
}
+
+ // TODO(b/234424397): remove method after credential changes are rolled out.
+ @Provides
+ @Parameter(SCRIPT_PARAM)
+ static String provideScriptName(HttpServletRequest req) {
+ return extractRequiredParameter(req, SCRIPT_PARAM);
+ }
}
diff --git a/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java b/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java
new file mode 100644
index 000000000..f62098bed
--- /dev/null
+++ b/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java
@@ -0,0 +1,74 @@
+// Copyright 2022 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.batch;
+
+import static google.registry.request.Action.Method.POST;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import google.registry.batch.cannedscript.GroupsApiChecker;
+import google.registry.request.Action;
+import google.registry.request.Parameter;
+import google.registry.request.auth.Auth;
+import javax.inject.Inject;
+
+/**
+ * Action that executes a canned script specified by the caller.
+ *
+ *
This class is introduced to help the safe rollout of credential changes. The delegated
+ * credentials in particular, benefit from this: they require manual configuration of the peer
+ * system in each environment, and may wait hours or even days after deployment until triggered by
+ * user activities.
+ *
+ *
This action can be invoked using the Nomulus CLI command: {@code nomulus -e ${env} curl
+ * --service BACKEND -X POST -u '/_dr/task/executeCannedScript?script=${script_name}'}
+ */
+// TODO(b/234424397): remove class after credential changes are rolled out.
+@Action(
+ service = Action.Service.BACKEND,
+ path = "/_dr/task/executeCannedScript",
+ method = POST,
+ automaticallyPrintOk = true,
+ auth = Auth.AUTH_INTERNAL_OR_ADMIN)
+public class CannedScriptExecutionAction implements Runnable {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ static final String SCRIPT_PARAM = "script";
+
+ static final ImmutableMap SCRIPTS =
+ ImmutableMap.of("runGroupsApiChecks", GroupsApiChecker::runGroupsApiChecks);
+
+ private final String scriptName;
+
+ @Inject
+ CannedScriptExecutionAction(@Parameter(SCRIPT_PARAM) String scriptName) {
+ logger.atInfo().log("Received request to run script %s", scriptName);
+ this.scriptName = scriptName;
+ }
+
+ @Override
+ public void run() {
+ if (!SCRIPTS.containsKey(scriptName)) {
+ throw new IllegalArgumentException("Script not found:" + scriptName);
+ }
+ try {
+ SCRIPTS.get(scriptName).run();
+ logger.atInfo().log("Finished running %s.", scriptName);
+ } catch (Throwable t) {
+ logger.atWarning().withCause(t).log("Error executing %s", scriptName);
+ throw new RuntimeException("Execution failed.");
+ }
+ }
+}
diff --git a/core/src/main/java/google/registry/batch/cannedscript/GroupsApiChecker.java b/core/src/main/java/google/registry/batch/cannedscript/GroupsApiChecker.java
new file mode 100644
index 000000000..26937b96d
--- /dev/null
+++ b/core/src/main/java/google/registry/batch/cannedscript/GroupsApiChecker.java
@@ -0,0 +1,122 @@
+// Copyright 2022 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.batch.cannedscript;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static google.registry.util.RegistrarUtils.normalizeRegistrarId;
+
+import com.google.api.services.admin.directory.Directory;
+import com.google.api.services.groupssettings.Groupssettings;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.base.Throwables;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import dagger.Component;
+import dagger.Module;
+import dagger.Provides;
+import google.registry.config.CredentialModule;
+import google.registry.config.CredentialModule.AdcDelegatedCredential;
+import google.registry.config.RegistryConfig.Config;
+import google.registry.config.RegistryConfig.ConfigModule;
+import google.registry.groups.DirectoryGroupsConnection;
+import google.registry.model.registrar.Registrar;
+import google.registry.model.registrar.RegistrarPoc;
+import google.registry.util.GoogleCredentialsBundle;
+import google.registry.util.UtilsModule;
+import java.util.List;
+import java.util.Set;
+import javax.inject.Singleton;
+
+/**
+ * Verifies that the credential with the {@link AdcDelegatedCredential} annotation can be used to
+ * access the Google Workspace Groups API.
+ */
+// TODO(b/234424397): remove class after credential changes are rolled out.
+public class GroupsApiChecker {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private static final Supplier COMPONENT_SUPPLIER =
+ Suppliers.memoize(DaggerGroupsApiChecker_GroupsConnectionComponent::create);
+
+ public static void runGroupsApiChecks() {
+ GroupsConnectionComponent component = COMPONENT_SUPPLIER.get();
+ DirectoryGroupsConnection groupsConnection = component.groupsConnection();
+
+ List registrars =
+ Streams.stream(Registrar.loadAllCached())
+ .filter(registrar -> registrar.isLive() && registrar.getType() == Registrar.Type.REAL)
+ .collect(toImmutableList());
+ for (Registrar registrar : registrars) {
+ for (final RegistrarPoc.Type type : RegistrarPoc.Type.values()) {
+ String groupKey =
+ String.format(
+ "%s-%s-contacts@%s",
+ normalizeRegistrarId(registrar.getRegistrarId()),
+ type.getDisplayName(),
+ component.gSuiteDomainName());
+ try {
+ Set currentMembers = groupsConnection.getMembersOfGroup(groupKey);
+ logger.atInfo().log("Found %s members for %s.", currentMembers.size(), groupKey);
+ } catch (Exception e) {
+ Throwables.throwIfUnchecked(e);
+ throw new RuntimeException(e);
+ }
+ }
+ }
+ }
+
+ @Singleton
+ @Component(
+ modules = {
+ ConfigModule.class,
+ CredentialModule.class,
+ GroupsApiModule.class,
+ UtilsModule.class
+ })
+ interface GroupsConnectionComponent {
+ DirectoryGroupsConnection groupsConnection();
+
+ @Config("gSuiteDomainName")
+ String gSuiteDomainName();
+ }
+
+ @Module
+ static class GroupsApiModule {
+ @Provides
+ static Directory provideDirectory(
+ @AdcDelegatedCredential GoogleCredentialsBundle credentialsBundle,
+ @Config("projectId") String projectId) {
+ return new Directory.Builder(
+ credentialsBundle.getHttpTransport(),
+ credentialsBundle.getJsonFactory(),
+ credentialsBundle.getHttpRequestInitializer())
+ .setApplicationName(projectId)
+ .build();
+ }
+
+ @Provides
+ static Groupssettings provideGroupsSettings(
+ @AdcDelegatedCredential GoogleCredentialsBundle credentialsBundle,
+ @Config("projectId") String projectId) {
+ return new Groupssettings.Builder(
+ credentialsBundle.getHttpTransport(),
+ credentialsBundle.getJsonFactory(),
+ credentialsBundle.getHttpRequestInitializer())
+ .setApplicationName(projectId)
+ .build();
+ }
+ }
+}
diff --git a/core/src/main/java/google/registry/config/CredentialModule.java b/core/src/main/java/google/registry/config/CredentialModule.java
index 9e1f5b54a..39a36bee7 100644
--- a/core/src/main/java/google/registry/config/CredentialModule.java
+++ b/core/src/main/java/google/registry/config/CredentialModule.java
@@ -14,14 +14,17 @@
package google.registry.config;
+import static com.google.common.base.Preconditions.checkArgument;
import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.auth.ServiceAccountSigner;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.common.collect.ImmutableList;
import dagger.Module;
import dagger.Provides;
import google.registry.config.RegistryConfig.Config;
import google.registry.keyring.api.KeyModule.Key;
+import google.registry.util.Clock;
import google.registry.util.GoogleCredentialsBundle;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -29,6 +32,7 @@ import java.io.UncheckedIOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
+import java.time.Duration;
import javax.inject.Qualifier;
import javax.inject.Singleton;
@@ -158,6 +162,48 @@ public abstract class CredentialModule {
.createScoped(requiredScopes));
}
+ /**
+ * Provides a {@link GoogleCredentialsBundle} with delegated access to Google Workspace APIs for
+ * the application default credential user.
+ *
+ * The Workspace domain must grant delegated admin access to the default service account user
+ * (project-id@appspot.gserviceaccount.com on AppEngine) with all scopes in {@code defaultScopes}
+ * and {@code delegationScopes}.
+ */
+ @AdcDelegatedCredential
+ @Provides
+ @Singleton
+ public static GoogleCredentialsBundle provideSelfSignedDelegatedCredential(
+ @Config("defaultCredentialOauthScopes") ImmutableList defaultScopes,
+ @Config("delegatedCredentialOauthScopes") ImmutableList delegationScopes,
+ @ApplicationDefaultCredential GoogleCredentialsBundle credentialsBundle,
+ @Config("gSuiteAdminAccountEmailAddress") String gSuiteAdminAccountEmailAddress,
+ @Config("tokenRefreshDelay") Duration tokenRefreshDelay,
+ Clock clock) {
+ GoogleCredentials signer = credentialsBundle.getGoogleCredentials();
+
+ checkArgument(
+ signer instanceof ServiceAccountSigner,
+ "Expecting a ServiceAccountSigner, found %s.",
+ signer.getClass().getSimpleName());
+
+ try {
+ // Refreshing as sanity check on the ADC.
+ signer.refresh();
+ } catch (IOException e) {
+ throw new RuntimeException("Cannot refresh the ApplicationDefaultCredential", e);
+ }
+
+ DelegatedCredentials credential =
+ DelegatedCredentials.createSelfSignedDelegatedCredential(
+ (ServiceAccountSigner) signer,
+ ImmutableList.builder().addAll(defaultScopes).addAll(delegationScopes).build(),
+ gSuiteAdminAccountEmailAddress,
+ clock,
+ tokenRefreshDelay);
+ return GoogleCredentialsBundle.create(credential);
+ }
+
/** Dagger qualifier for the scope-less Application Default Credential. */
@Qualifier
@Documented
@@ -195,6 +241,15 @@ public abstract class CredentialModule {
@Retention(RetentionPolicy.RUNTIME)
public @interface DelegatedCredential {}
+ /**
+ * Dagger qualifier for a credential with delegated admin access for a dasher domain (for Google
+ * Workspace) backed by the application default credential (ADC).
+ */
+ @Qualifier
+ @Documented
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface AdcDelegatedCredential {}
+
/** Dagger qualifier for the local credential used in the nomulus tool. */
@Qualifier
@Documented
diff --git a/core/src/main/java/google/registry/config/DelegatedCredentials.java b/core/src/main/java/google/registry/config/DelegatedCredentials.java
new file mode 100644
index 000000000..ce7902df6
--- /dev/null
+++ b/core/src/main/java/google/registry/config/DelegatedCredentials.java
@@ -0,0 +1,268 @@
+// Copyright 2022 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.config;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpBackOffIOExceptionHandler;
+import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.http.UrlEncodedContent;
+import com.google.api.client.http.javanet.NetHttpTransport;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.gson.GsonFactory;
+import com.google.api.client.json.webtoken.JsonWebSignature;
+import com.google.api.client.json.webtoken.JsonWebToken;
+import com.google.api.client.util.ExponentialBackOff;
+import com.google.api.client.util.GenericData;
+import com.google.api.client.util.StringUtils;
+import com.google.auth.ServiceAccountSigner;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.oauth2.AccessToken;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import google.registry.util.Clock;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Date;
+import java.util.Map;
+import java.util.ServiceLoader;
+import org.apache.commons.codec.binary.Base64;
+
+/**
+ * OAuth2 credentials for accessing Google Workspace APIs with domain-wide delegation. It fetches
+ * access tokens using JSON Web Tokens (JWT) signed by a user-provided {@link ServiceAccountSigner}.
+ *
+ * This class accepts the application-default-credential as {@code ServiceAccountSigner},
+ * avoiding the need for exported private keys. In this case, the default credential user itself
+ * (project-id@appspot.gserviceaccount.com on AppEngine) must have domain-wide delegation to the
+ * Workspace APIs. The default credential user also must have the Token Creator role to itself.
+ *
+ *
If the user provides a credential {@code S} that carries its own private key, such as {@link
+ * com.google.auth.oauth2.ServiceAccountCredentials}, this class can use {@code S} to impersonate
+ * another service account {@code D} and gain delegated access as {@code D}, as long as S has the
+ * Token Creator role to {@code D}. This usage is documented here for future reference.
+ *
+ *
As of October 2022, the functionalities described above are not implemented in the GCP Java
+ * Auth library, although they are available in the Python library. We have filed a feature request.
+ * This class is a stop-gap implementation.
+ *
+ *
The main body of this class is adapted from {@link
+ * com.google.auth.oauth2.ServiceAccountCredentials} with cosmetic changes. The important changes
+ * include the removal of all uses of the private key and the signing of the JWT (in {@link
+ * #signAssertion}). We choose not to extend {@code ServiceAccountCredentials} because it would add
+ * dependency to the non-public details of that class.
+ */
+public class DelegatedCredentials extends GoogleCredentials {
+
+ private static final long serialVersionUID = 617127523756785546L;
+
+ private static final String DEFAULT_TOKEN_URI = "https://accounts.google.com/o/oauth2/token";
+ private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer";
+
+ private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
+ private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
+
+ private static final String VALUE_NOT_FOUND_MESSAGE = "%sExpected value %s not found.";
+ private static final String VALUE_WRONG_TYPE_MESSAGE = "%sExpected %s value %s of wrong type.";
+ private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. ";
+
+ private static final Duration MAX_TOKEN_REFRESH_DELAY = Duration.ofHours(1);
+
+ private final ServiceAccountSigner signer;
+ private final String delegatedServiceAccountUser;
+ private final ImmutableList scopes;
+ private final String delegatingUserEmail;
+
+ private final Clock clock;
+ private final Duration tokenRefreshDelay;
+
+ private final HttpTransportFactory transportFactory;
+
+ /**
+ * Creates a {@link DelegatedCredentials} instance that is self-signed by the signer, which must
+ * have delegated access to the Workspace APIs.
+ *
+ * @param signer Signs for the generated JWT tokens. This may be the application default
+ * credential
+ * @param scopes The scopes to use when generating JWT tokens
+ * @param delegatingUserEmail The Workspace user whose permissions are delegated to the signer
+ * @param clock Used for setting token expiration times.
+ * @param tokenRefreshDelay The lifetime of each token. Should not exceed one hour according to
+ * GCP recommendations.
+ * @return
+ */
+ static DelegatedCredentials createSelfSignedDelegatedCredential(
+ ServiceAccountSigner signer,
+ Collection scopes,
+ String delegatingUserEmail,
+ Clock clock,
+ Duration tokenRefreshDelay) {
+ return new DelegatedCredentials(
+ signer, signer.getAccount(), scopes, delegatingUserEmail, clock, tokenRefreshDelay);
+ }
+
+ private DelegatedCredentials(
+ ServiceAccountSigner signer,
+ String delegatedServiceAccountUser,
+ Collection scopes,
+ String delegatingUserEmail,
+ Clock clock,
+ Duration tokenRefreshDelay) {
+ checkArgument(
+ tokenRefreshDelay.getSeconds() <= MAX_TOKEN_REFRESH_DELAY.getSeconds(),
+ "Max refresh delay must not exceed %s.",
+ MAX_TOKEN_REFRESH_DELAY);
+
+ this.signer = signer;
+ this.delegatedServiceAccountUser = delegatedServiceAccountUser;
+ this.scopes = ImmutableList.copyOf(scopes);
+ this.delegatingUserEmail = delegatingUserEmail;
+
+ this.clock = clock;
+ this.tokenRefreshDelay = tokenRefreshDelay;
+
+ this.transportFactory =
+ getFromServiceLoader(
+ HttpTransportFactory.class, DelegatedCredentials::provideHttpTransport);
+ }
+
+ /**
+ * Refreshes the OAuth2 access token by getting a new access token using a JSON Web Token (JWT).
+ */
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+ JsonFactory jsonFactory = JSON_FACTORY;
+ long currentTime = clock.nowUtc().getMillis();
+ String assertion = createAssertion(jsonFactory, currentTime);
+
+ GenericData tokenRequest = new GenericData();
+ tokenRequest.set("grant_type", GRANT_TYPE);
+ tokenRequest.set("assertion", assertion);
+ UrlEncodedContent content = new UrlEncodedContent(tokenRequest);
+
+ HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
+ HttpRequest request =
+ requestFactory.buildPostRequest(new GenericUrl(DEFAULT_TOKEN_URI), content);
+ request.setParser(new JsonObjectParser(jsonFactory));
+
+ request.setIOExceptionHandler(new HttpBackOffIOExceptionHandler(new ExponentialBackOff()));
+ request.setUnsuccessfulResponseHandler(
+ new HttpBackOffUnsuccessfulResponseHandler(new ExponentialBackOff())
+ .setBackOffRequired(
+ response -> {
+ int code = response.getStatusCode();
+ return (
+ // Server error --- includes timeout errors, which use 500 instead of 408
+ code / 100 == 5
+ // Forbidden error --- for historical reasons, used for rate_limit_exceeded
+ // errors instead of 429, but there currently seems no robust automatic way
+ // to
+ // distinguish these cases: see
+ // https://github.com/google/google-api-java-client/issues/662
+ || code == 403);
+ }));
+
+ HttpResponse response;
+ try {
+ response = request.execute();
+ } catch (IOException e) {
+ throw new IOException(
+ String.format("Error getting access token for service account: %s", e.getMessage()), e);
+ }
+
+ GenericData responseData = response.parseAs(GenericData.class);
+ String accessToken = validateString(responseData, "access_token", PARSE_ERROR_PREFIX);
+ int expiresInSeconds = validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX);
+ long expiresAtMilliseconds = clock.nowUtc().getMillis() + expiresInSeconds * 1000L;
+ return new AccessToken(accessToken, new Date(expiresAtMilliseconds));
+ }
+
+ String createAssertion(JsonFactory jsonFactory, long currentTime) throws IOException {
+ JsonWebSignature.Header header = new JsonWebSignature.Header();
+ header.setAlgorithm("RS256");
+ header.setType("JWT");
+
+ JsonWebToken.Payload payload = new JsonWebToken.Payload();
+ payload.setIssuer(this.delegatedServiceAccountUser);
+ payload.setIssuedAtTimeSeconds(currentTime / 1000);
+ payload.setExpirationTimeSeconds(currentTime / 1000 + tokenRefreshDelay.getSeconds());
+ payload.setSubject(delegatingUserEmail);
+ payload.put("scope", Joiner.on(' ').join(scopes));
+ payload.setAudience(DEFAULT_TOKEN_URI);
+
+ return signAssertion(jsonFactory, header, payload);
+ }
+
+ String signAssertion(
+ JsonFactory jsonFactory, JsonWebSignature.Header header, JsonWebToken.Payload payload)
+ throws IOException {
+ String content =
+ Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(header))
+ + "."
+ + Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(payload));
+ byte[] contentBytes = StringUtils.getBytesUtf8(content);
+ byte[] signature = signer.sign(contentBytes); // Changed from ServiceAccountCredentials.
+ return content + "." + Base64.encodeBase64URLSafeString(signature);
+ }
+
+ static HttpTransport provideHttpTransport() {
+ return HTTP_TRANSPORT;
+ }
+
+ protected static T getFromServiceLoader(Class extends T> clazz, T defaultInstance) {
+ return Iterables.getFirst(ServiceLoader.load(clazz), defaultInstance);
+ }
+
+ /** Return the specified string from JSON or throw a helpful error message. */
+ static String validateString(Map map, String key, String errorPrefix)
+ throws IOException {
+ Object value = map.get(key);
+ if (value == null) {
+ throw new IOException(String.format(VALUE_NOT_FOUND_MESSAGE, errorPrefix, key));
+ }
+ if (!(value instanceof String)) {
+ throw new IOException(String.format(VALUE_WRONG_TYPE_MESSAGE, errorPrefix, "string", key));
+ }
+ return (String) value;
+ }
+
+ /** Return the specified integer from JSON or throw a helpful error message. */
+ static int validateInt32(Map map, String key, String errorPrefix)
+ throws IOException {
+ Object value = map.get(key);
+ if (value == null) {
+ throw new IOException(String.format(VALUE_NOT_FOUND_MESSAGE, errorPrefix, key));
+ }
+ if (value instanceof BigDecimal) {
+ BigDecimal bigDecimalValue = (BigDecimal) value;
+ return bigDecimalValue.intValueExact();
+ }
+ if (!(value instanceof Integer)) {
+ throw new IOException(String.format(VALUE_WRONG_TYPE_MESSAGE, errorPrefix, "integer", key));
+ }
+ return (Integer) value;
+ }
+}
diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java
index 53459d42e..cd162f0b7 100644
--- a/core/src/main/java/google/registry/config/RegistryConfig.java
+++ b/core/src/main/java/google/registry/config/RegistryConfig.java
@@ -1223,6 +1223,12 @@ public final class RegistryConfig {
return ImmutableList.copyOf(config.credentialOAuth.localCredentialOauthScopes);
}
+ @Provides
+ @Config("tokenRefreshDelay")
+ public static java.time.Duration provideTokenRefreshDelay(RegistryConfigSettings config) {
+ return java.time.Duration.ofSeconds(config.credentialOAuth.tokenRefreshDelaySeconds);
+ }
+
/** OAuth client ID used by the nomulus tool. */
@Provides
@Config("toolsClientId")
diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java
index e9ab135f7..94e77240b 100644
--- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java
+++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java
@@ -67,6 +67,7 @@ public class RegistryConfigSettings {
public List defaultCredentialOauthScopes;
public List delegatedCredentialOauthScopes;
public List localCredentialOauthScopes;
+ public int tokenRefreshDelaySeconds;
}
/** Configuration options for the G Suite account used by Nomulus. */
diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml
index e43252e60..7e81620f3 100644
--- a/core/src/main/java/google/registry/config/files/default-config.yaml
+++ b/core/src/main/java/google/registry/config/files/default-config.yaml
@@ -340,6 +340,9 @@ credentialOAuth:
- https://www.googleapis.com/auth/userinfo.email
# View and manage your applications deployed on Google App Engine
- https://www.googleapis.com/auth/appengine.admin
+ # The lifetime of an access token generated by our custom credentials classes
+ # Must be shorter than one hour.
+ tokenRefreshDelaySeconds: 1800
icannReporting:
# URL we PUT monthly ICANN transactions reports to.
diff --git a/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml b/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml
index 50a6443ca..8ffdfa4a4 100644
--- a/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml
+++ b/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml
@@ -293,6 +293,12 @@ have been in the database for a certain period of time. -->
/_dr/task/wipeOutCloudSql
+
+
+ backend-servlet
+ /_dr/task/executeCannedScript
+
+
diff --git a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java
index 6e544ea01..dca1f8170 100644
--- a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java
+++ b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java
@@ -17,6 +17,7 @@ package google.registry.module.backend;
import dagger.Module;
import dagger.Subcomponent;
import google.registry.batch.BatchModule;
+import google.registry.batch.CannedScriptExecutionAction;
import google.registry.batch.DeleteExpiredDomainsAction;
import google.registry.batch.DeleteLoadTestDataAction;
import google.registry.batch.DeleteProberDataAction;
@@ -102,6 +103,8 @@ interface BackendRequestComponent {
BrdaCopyAction brdaCopyAction();
+ CannedScriptExecutionAction cannedScriptExecutionAction();
+
CopyDetailReportsAction copyDetailReportAction();
DeleteExpiredDomainsAction deleteExpiredDomainsAction();
diff --git a/core/src/test/resources/google/registry/module/backend/backend_routing.txt b/core/src/test/resources/google/registry/module/backend/backend_routing.txt
index 3486de387..e2b2e43c9 100644
--- a/core/src/test/resources/google/registry/module/backend/backend_routing.txt
+++ b/core/src/test/resources/google/registry/module/backend/backend_routing.txt
@@ -7,6 +7,7 @@ PATH CLASS
/_dr/task/deleteExpiredDomains DeleteExpiredDomainsAction GET n INTERNAL,API APP ADMIN
/_dr/task/deleteLoadTestData DeleteLoadTestDataAction POST n INTERNAL,API APP ADMIN
/_dr/task/deleteProberData DeleteProberDataAction POST n INTERNAL,API APP ADMIN
+/_dr/task/executeCannedScript CannedScriptExecutionAction POST y INTERNAL,API APP ADMIN
/_dr/task/expandRecurringBillingEvents ExpandRecurringBillingEventsAction GET n INTERNAL,API APP ADMIN
/_dr/task/exportDomainLists ExportDomainListsAction POST n INTERNAL,API APP ADMIN
/_dr/task/exportPremiumTerms ExportPremiumTermsAction POST n INTERNAL,API APP ADMIN