// Copyright 2017 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.registrar; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableSet.toImmutableSet; import static com.google.common.collect.Sets.difference; import static google.registry.export.sheet.SyncRegistrarsSheetAction.enqueueRegistrarSheetSync; import static google.registry.model.ofy.ObjectifyService.ofy; import static google.registry.security.JsonResponseHelper.Status.ERROR; import static google.registry.security.JsonResponseHelper.Status.SUCCESS; import static google.registry.util.PreconditionsUtils.checkArgumentPresent; import com.google.auto.value.AutoValue; import com.google.common.base.Ascii; import com.google.common.base.Strings; import com.google.common.collect.HashMultimap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Multimap; import com.google.common.collect.Sets; import com.google.common.collect.Streams; import com.google.common.flogger.FluentLogger; import google.registry.config.RegistryEnvironment; import google.registry.model.registrar.Registrar; import google.registry.model.registrar.RegistrarContact; import google.registry.model.registrar.RegistrarContact.Type; import google.registry.request.Action; import google.registry.request.HttpException.BadRequestException; import google.registry.request.HttpException.ForbiddenException; import google.registry.request.JsonActionRunner; import google.registry.request.auth.Auth; import google.registry.request.auth.AuthResult; import google.registry.request.auth.AuthenticatedRegistrarAccessor; import google.registry.request.auth.AuthenticatedRegistrarAccessor.RegistrarAccessDeniedException; import google.registry.request.auth.AuthenticatedRegistrarAccessor.Role; import google.registry.security.JsonResponseHelper; import google.registry.ui.forms.FormException; import google.registry.ui.forms.FormFieldException; import google.registry.ui.server.RegistrarFormFields; import google.registry.ui.server.SendEmailUtils; import google.registry.util.AppEngineServiceUtils; import google.registry.util.CollectionUtils; import google.registry.util.DiffUtils; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; import javax.inject.Inject; import org.joda.time.DateTime; /** * Admin servlet that allows creating or updating a registrar. Deletes are not allowed so as to * preserve history. */ @Action( service = Action.Service.DEFAULT, path = RegistrarSettingsAction.PATH, method = Action.Method.POST, auth = Auth.AUTH_PUBLIC_LOGGED_IN) public class RegistrarSettingsAction implements Runnable, JsonActionRunner.JsonAction { public static final String PATH = "/registrar-settings"; private static final FluentLogger logger = FluentLogger.forEnclosingClass(); static final String OP_PARAM = "op"; static final String ARGS_PARAM = "args"; static final String ID_PARAM = "id"; @Inject JsonActionRunner jsonActionRunner; @Inject AppEngineServiceUtils appEngineServiceUtils; @Inject RegistrarConsoleMetrics registrarConsoleMetrics; @Inject SendEmailUtils sendEmailUtils; @Inject AuthenticatedRegistrarAccessor registrarAccessor; @Inject AuthResult authResult; @Inject RegistryEnvironment registryEnvironment; @Inject RegistrarSettingsAction() {} private static final Predicate HAS_PHONE = contact -> contact.getPhoneNumber() != null; @Override public void run() { jsonActionRunner.run(this); } @Override public Map handleJsonRequest(Map input) { if (input == null) { throw new BadRequestException("Malformed JSON"); } String clientId = (String) input.get(ID_PARAM); if (Strings.isNullOrEmpty(clientId)) { throw new BadRequestException(String.format("Missing key for resource ID: %s", ID_PARAM)); } // Process the operation. Though originally derived from a CRUD // handler, registrar-settings really only supports read and update. String op = Optional.ofNullable((String) input.get(OP_PARAM)).orElse("read"); @SuppressWarnings("unchecked") Map args = (Map) Optional.ofNullable(input.get(ARGS_PARAM)).orElse(ImmutableMap.of()); logger.atInfo().log("Received request '%s' on registrar '%s' with args %s", op, clientId, args); String status = "SUCCESS"; try { switch (op) { case "update": return update(args, clientId).toJsonResponse(); case "read": return read(clientId).toJsonResponse(); default: throw new IllegalArgumentException("Unknown or unsupported operation: " + op); } } catch (Throwable e) { logger.atWarning().withCause(e).log( "Failed to perform operation '%s' on registrar '%s' for args %s", op, clientId, args); status = "ERROR: " + e.getClass().getSimpleName(); if (e instanceof FormFieldException) { FormFieldException formFieldException = (FormFieldException) e; return JsonResponseHelper.createFormFieldError( formFieldException.getMessage(), formFieldException.getFieldName()); } return JsonResponseHelper.create( ERROR, Optional.ofNullable(e.getMessage()).orElse("Unspecified error")); } finally { registrarConsoleMetrics.registerSettingsRequest( clientId, op, registrarAccessor.getRolesForRegistrar(clientId), status); } } @AutoValue abstract static class RegistrarResult { abstract String message(); abstract Registrar registrar(); Map toJsonResponse() { return JsonResponseHelper.create(SUCCESS, message(), registrar().toJsonMap()); } static RegistrarResult create(String message, Registrar registrar) { return new AutoValue_RegistrarSettingsAction_RegistrarResult(message, registrar); } } private RegistrarResult read(String clientId) { try { return RegistrarResult.create("Success", registrarAccessor.getRegistrar(clientId)); } catch (RegistrarAccessDeniedException e) { throw new ForbiddenException(e.getMessage(), e); } } private RegistrarResult update(final Map args, String clientId) { return ofy() .transact( () -> { // We load the registrar here rather than outside of the transaction - to make // sure we have the latest version. This one is loaded inside the transaction, so it's // guaranteed to not change before we update it. Registrar registrar; try { registrar = registrarAccessor.getRegistrar(clientId); } catch (RegistrarAccessDeniedException e) { throw new ForbiddenException(e.getMessage(), e); } // Verify that the registrar hasn't been changed. // To do that - we find the latest update time (or null if the registrar has been // deleted) and compare to the update time from the args. The update time in the args // comes from the read that gave the UI the data - if it's out of date, then the UI // had out of date data. DateTime latest = registrar.getLastUpdateTime(); DateTime latestFromArgs = RegistrarFormFields.LAST_UPDATE_TIME.extractUntyped(args).get(); if (!latestFromArgs.equals(latest)) { logger.atWarning().log( "registrar changed since reading the data! " + " Last updated at %s, but args data last updated at %s", latest, latestFromArgs); throw new IllegalStateException( "registrar has been changed by someone else. Please reload and retry."); } // Keep the current contacts so we can later check that no required contact was // removed, email the changes to the contacts ImmutableSet contacts = registrar.getContacts(); Registrar updatedRegistrar = registrar; // Do OWNER only updates to the registrar from the request. updatedRegistrar = checkAndUpdateOwnerControlledFields(updatedRegistrar, args); // Do ADMIN only updates to the registrar from the request. updatedRegistrar = checkAndUpdateAdminControlledFields(updatedRegistrar, args); // read the contacts from the request. ImmutableSet updatedContacts = readContacts(registrar, args); // Save the updated contacts if (!updatedContacts.equals(contacts)) { if (!registrarAccessor.hasRoleOnRegistrar(Role.OWNER, registrar.getClientId())) { throw new ForbiddenException("Only OWNERs can update the contacts"); } checkContactRequirements(contacts, updatedContacts); RegistrarContact.updateContacts(updatedRegistrar, updatedContacts); updatedRegistrar = updatedRegistrar.asBuilder().setContactsRequireSyncing(true).build(); } // Save the updated registrar if (!updatedRegistrar.equals(registrar)) { ofy().save().entity(updatedRegistrar); } // Email and return update. sendExternalUpdatesIfNecessary( registrar, contacts, updatedRegistrar, updatedContacts); return RegistrarResult.create("Saved " + clientId, updatedRegistrar); }); } private Map expandRegistrarWithContacts(Iterable contacts, Registrar registrar) { ImmutableSet> expandedContacts = Streams.stream(contacts) .map(RegistrarContact::toDiffableFieldMap) .collect(toImmutableSet()); // Use LinkedHashMap here to preserve ordering; null values mean we can't use ImmutableMap. LinkedHashMap result = new LinkedHashMap<>(registrar.toDiffableFieldMap()); result.put("contacts", expandedContacts); return result; } /** * Updates registrar with the OWNER-controlled args from the http request. * *

If any changes were made and the user isn't an OWNER - throws a {@link ForbiddenException}. */ private Registrar checkAndUpdateOwnerControlledFields( Registrar initialRegistrar, Map args) { Registrar.Builder builder = initialRegistrar.asBuilder(); // WHOIS // // Because of how whoisServer handles "default value", it's possible that setting the existing // value will still change the Registrar. So we first check whether the value has changed. // // The problem is - if the Registrar has a "null" whoisServer value, the console gets the // "default value" instead of the actual (null) value. // This was done so we display the "default" value, but it also means that it always looks like // the user updated the whoisServer value from "null" to the default value. // // TODO(b/119913848):once a null whoisServer value is sent to the console as "null", there's no // need to check for equality before setting the value in the builder. String updatedWhoisServer = RegistrarFormFields.WHOIS_SERVER_FIELD.extractUntyped(args).orElse(null); if (!Objects.equals(initialRegistrar.getWhoisServer(), updatedWhoisServer)) { builder.setWhoisServer(updatedWhoisServer); } builder.setUrl(RegistrarFormFields.URL_FIELD.extractUntyped(args).orElse(null)); // If the email is already null / empty - we can keep it so. But if it's set - it's required to // remain set. (Strings.isNullOrEmpty(initialRegistrar.getEmailAddress()) ? RegistrarFormFields.EMAIL_ADDRESS_FIELD_OPTIONAL : RegistrarFormFields.EMAIL_ADDRESS_FIELD_REQUIRED) .extractUntyped(args) .ifPresent(builder::setEmailAddress); builder.setPhoneNumber( RegistrarFormFields.PHONE_NUMBER_FIELD.extractUntyped(args).orElse(null)); builder.setFaxNumber( RegistrarFormFields.FAX_NUMBER_FIELD.extractUntyped(args).orElse(null)); builder.setLocalizedAddress( RegistrarFormFields.L10N_ADDRESS_FIELD.extractUntyped(args).orElse(null)); // Security builder.setIpAddressWhitelist( RegistrarFormFields.IP_ADDRESS_WHITELIST_FIELD .extractUntyped(args) .orElse(ImmutableList.of())); RegistrarFormFields.CLIENT_CERTIFICATE_FIELD .extractUntyped(args) .ifPresent( certificate -> builder.setClientCertificate(certificate, ofy().getTransactionTime())); RegistrarFormFields.FAILOVER_CLIENT_CERTIFICATE_FIELD .extractUntyped(args) .ifPresent( certificate -> builder.setFailoverClientCertificate(certificate, ofy().getTransactionTime())); return checkNotChangedUnlessAllowed(builder, initialRegistrar, Role.OWNER); } /** * Updates a registrar with the ADMIN-controlled args from the http request. * *

If any changes were made and the user isn't an ADMIN - throws a {@link ForbiddenException}. */ private Registrar checkAndUpdateAdminControlledFields( Registrar initialRegistrar, Map args) { Registrar.Builder builder = initialRegistrar.asBuilder(); Set updatedAllowedTlds = RegistrarFormFields.ALLOWED_TLDS_FIELD.extractUntyped(args).orElse(ImmutableSet.of()); // Temporarily block anyone from removing an allowed TLD. // This is so we can start having Support users use the console in production before we finish // implementing configurable access control. // TODO(b/119549884): remove this code once configurable access control is implemented. if (!Sets.difference(initialRegistrar.getAllowedTlds(), updatedAllowedTlds).isEmpty()) { throw new ForbiddenException("Can't remove allowed TLDs using the console."); } if (!Sets.difference(updatedAllowedTlds, initialRegistrar.getAllowedTlds()).isEmpty()) { // If a REAL registrar isn't in compliance with regards to having an abuse contact set, // prevent addition of allowed TLDs until that's fixed. if (Registrar.Type.REAL.equals(initialRegistrar.getType()) && RegistryEnvironment.PRODUCTION.equals(registryEnvironment)) { checkArgumentPresent( initialRegistrar.getWhoisAbuseContact(), "Cannot add allowed TLDs if there is no WHOIS abuse contact set."); } } builder.setAllowedTlds(updatedAllowedTlds); return checkNotChangedUnlessAllowed(builder, initialRegistrar, Role.ADMIN); } /** * Makes sure builder.build is different than originalRegistrar only if we have the correct role. * *

On success, returns {@code builder.build()}. */ private Registrar checkNotChangedUnlessAllowed( Registrar.Builder builder, Registrar originalRegistrar, Role allowedRole) { Registrar updatedRegistrar = builder.build(); if (updatedRegistrar.equals(originalRegistrar)) { return updatedRegistrar; } checkArgument( updatedRegistrar.getClientId().equals(originalRegistrar.getClientId()), "Can't change clientId (%s -> %s)", originalRegistrar.getClientId(), updatedRegistrar.getClientId()); if (registrarAccessor.hasRoleOnRegistrar(allowedRole, originalRegistrar.getClientId())) { return updatedRegistrar; } Map diffs = DiffUtils.deepDiff( originalRegistrar.toDiffableFieldMap(), updatedRegistrar.toDiffableFieldMap(), true); throw new ForbiddenException( String.format("Unauthorized: only %s can change fields %s", allowedRole, diffs.keySet())); } /** Reads the contacts from the supplied args. */ public static ImmutableSet readContacts( Registrar registrar, Map args) { ImmutableSet.Builder contacts = new ImmutableSet.Builder<>(); Optional> builders = RegistrarFormFields.CONTACTS_FIELD.extractUntyped(args); if (builders.isPresent()) { builders.get().forEach(c -> contacts.add(c.setParent(registrar).build())); } return contacts.build(); } /** * Enforces business logic checks on registrar contacts. * * @throws FormException if the checks fail. */ void checkContactRequirements( Set existingContacts, Set updatedContacts) { // Check that no two contacts use the same email address. Set emails = new HashSet<>(); for (RegistrarContact contact : updatedContacts) { if (!emails.add(contact.getEmailAddress())) { throw new ContactRequirementException(String.format( "One email address (%s) cannot be used for multiple contacts", contact.getEmailAddress())); } } // Check that required contacts don't go away, once they are set. Multimap oldContactsByType = HashMultimap.create(); for (RegistrarContact contact : existingContacts) { for (Type t : contact.getTypes()) { oldContactsByType.put(t, contact); } } Multimap newContactsByType = HashMultimap.create(); for (RegistrarContact contact : updatedContacts) { for (Type t : contact.getTypes()) { newContactsByType.put(t, contact); } } for (Type t : difference(oldContactsByType.keySet(), newContactsByType.keySet())) { if (t.isRequired()) { throw new ContactRequirementException(t); } } ensurePhoneNumberNotRemovedForContactTypes(oldContactsByType, newContactsByType, Type.TECH); Optional domainWhoisAbuseContact = getDomainWhoisVisibleAbuseContact(updatedContacts); // If the new set has a domain WHOIS abuse contact, it must have a phone number. if (domainWhoisAbuseContact.isPresent() && domainWhoisAbuseContact.get().getPhoneNumber() == null) { throw new ContactRequirementException( "The abuse contact visible in domain WHOIS query must have a phone number"); } // If there was a domain WHOIS abuse contact in the old set, the new set must have one. if (getDomainWhoisVisibleAbuseContact(existingContacts).isPresent() && !domainWhoisAbuseContact.isPresent()) { throw new ContactRequirementException( "An abuse contact visible in domain WHOIS query must be designated"); } } /** * Ensure that for each given registrar type, a phone number is present after update, if there was * one before. */ private static void ensurePhoneNumberNotRemovedForContactTypes( Multimap oldContactsByType, Multimap newContactsByType, Type... types) { for (Type type : types) { if (oldContactsByType.get(type).stream().anyMatch(HAS_PHONE) && newContactsByType.get(type).stream().noneMatch(HAS_PHONE)) { throw new ContactRequirementException( String.format( "Please provide a phone number for at least one %s contact", type.getDisplayName())); } } } /** * Retrieves the registrar contact whose phone number and email address is visible in domain WHOIS * query as abuse contact (if any). * *

Frontend processing ensures that only one contact can be set as abuse contact in domain * WHOIS record. Therefore it is possible to return inside the loop once one such contact is * found. */ private static Optional getDomainWhoisVisibleAbuseContact( Set contacts) { return contacts.stream().filter(RegistrarContact::getVisibleInDomainWhoisAsAbuse).findFirst(); } /** * Determines if any changes were made to the registrar besides the lastUpdateTime, and if so, * sends an email with a diff of the changes to the configured notification email address and all * contact addresses and enqueues a task to re-sync the registrar sheet. */ private void sendExternalUpdatesIfNecessary( Registrar existingRegistrar, ImmutableSet existingContacts, Registrar updatedRegistrar, ImmutableSet updatedContacts) { if (!sendEmailUtils.hasRecipients() && existingContacts.isEmpty()) { return; } Map diffs = DiffUtils.deepDiff( expandRegistrarWithContacts(existingContacts, existingRegistrar), expandRegistrarWithContacts(updatedContacts, updatedRegistrar), true); @SuppressWarnings("unchecked") Set changedKeys = (Set) diffs.keySet(); if (CollectionUtils.difference(changedKeys, "lastUpdateTime").isEmpty()) { return; } enqueueRegistrarSheetSync(appEngineServiceUtils.getCurrentVersionHostname("backend")); String environment = Ascii.toLowerCase(String.valueOf(registryEnvironment)); sendEmailUtils.sendEmail( String.format( "Registrar %s (%s) updated in registry %s environment", existingRegistrar.getRegistrarName(), existingRegistrar.getClientId(), environment), String.format( "The following changes were made in registry %s environment to " + "the registrar %s by %s:\n\n%s", environment, existingRegistrar.getClientId(), authResult.userIdForLogging(), DiffUtils.prettyPrintDiffedMap(diffs, null)), existingContacts.stream() .filter(c -> c.getTypes().contains(Type.ADMIN)) .map(RegistrarContact::getEmailAddress) .collect(toImmutableList())); } /** Thrown when a set of contacts doesn't meet certain constraints. */ private static class ContactRequirementException extends FormException { ContactRequirementException(String msg) { super(msg); } ContactRequirementException(Type type) { super(String.format("Must have at least one %s contact", type.getDisplayName())); } } }