diff --git a/java/google/registry/model/domain/secdns/DelegationSignerData.java b/java/google/registry/model/domain/secdns/DelegationSignerData.java index 4ae599bdc..a66666db7 100644 --- a/java/google/registry/model/domain/secdns/DelegationSignerData.java +++ b/java/google/registry/model/domain/secdns/DelegationSignerData.java @@ -32,6 +32,8 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; @XmlType(name = "dsData") public class DelegationSignerData extends ImmutableObject { + private DelegationSignerData() {} + /** The identifier for this particular key in the domain. */ int keyTag; @@ -74,6 +76,10 @@ public class DelegationSignerData extends ImmutableObject { return digest; } + public String getDigestAsString() { + return digest == null ? "" : DatatypeConverter.printHexBinary(digest); + } + public static DelegationSignerData create( int keyTag, int algorithm, int digestType, byte[] digest) { DelegationSignerData instance = new DelegationSignerData(); @@ -84,6 +90,11 @@ public class DelegationSignerData extends ImmutableObject { return instance; } + public static DelegationSignerData create( + int keyTag, int algorithm, int digestType, String digestAsHex) { + return create(keyTag, algorithm, digestType, DatatypeConverter.parseHexBinary(digestAsHex)); + } + /** * Returns the presentation format of this DS record. * diff --git a/java/google/registry/rdap/BUILD b/java/google/registry/rdap/BUILD index 7b33dd272..f143256cc 100644 --- a/java/google/registry/rdap/BUILD +++ b/java/google/registry/rdap/BUILD @@ -14,6 +14,7 @@ java_library( "//java/google/registry/request", "//java/google/registry/request/auth", "//java/google/registry/util", + "//third_party/jaxb", "//third_party/objectify:objectify-v4_1", "@com_google_auto_value", "@com_google_code_findbugs_jsr305", diff --git a/java/google/registry/rdap/RdapActionBase.java b/java/google/registry/rdap/RdapActionBase.java index 653750e21..1ea5f53cb 100644 --- a/java/google/registry/rdap/RdapActionBase.java +++ b/java/google/registry/rdap/RdapActionBase.java @@ -162,8 +162,10 @@ public abstract class RdapActionBase implements Runnable { setPayload(replyObject); metricInformationBuilder.setStatusCode(SC_OK); } catch (HttpException e) { + logger.atInfo().withCause(e).log("Error in RDAP"); setError(e.getResponseCode(), e.getResponseCodeString(), e.getMessage()); } catch (URISyntaxException | IllegalArgumentException e) { + logger.atInfo().withCause(e).log("Bad request in RDAP"); setError(SC_BAD_REQUEST, "Bad Request", "Not a valid " + getHumanReadableObjectTypeName()); } catch (RuntimeException e) { setError(SC_INTERNAL_SERVER_ERROR, "Internal Server Error", "An error was encountered"); @@ -240,18 +242,17 @@ public abstract class RdapActionBase implements Runnable { */ boolean isAuthorized(EppResource eppResource) { return getRequestTime().isBefore(eppResource.getDeletionTime()) - || (shouldIncludeDeleted() - && rdapAuthorization - .isAuthorizedForClientId(eppResource.getPersistedCurrentSponsorClientId())); + || (shouldIncludeDeleted() + && rdapAuthorization.isAuthorizedForClientId( + eppResource.getPersistedCurrentSponsorClientId())); } /** * Returns true if the EPP resource should be visible. * - *

This is true iff: - * 1. The resource is not deleted, or the request wants to see deleted items, and is authorized to - * do so, and: - * 2. The request did not specify a registrar to filter on, or the registrar matches. + *

This is true iff: 1. The resource is not deleted, or the request wants to see deleted items, + * and is authorized to do so, and: 2. The request did not specify a registrar to filter on, or + * the registrar matches. */ boolean shouldBeVisible(EppResource eppResource) { return isAuthorized(eppResource) @@ -262,10 +263,9 @@ public abstract class RdapActionBase implements Runnable { /** * Returns true if the EPP resource should be visible. * - *

This is true iff: - * 1. The passed in resource exists and is not deleted (deleted ones will have been projected - * forward in time to empty), - * 2. The request did not specify a registrar to filter on, or the registrar matches. + *

This is true iff: 1. The passed in resource exists and is not deleted (deleted ones will + * have been projected forward in time to empty), 2. The request did not specify a registrar to + * filter on, or the registrar matches. */ boolean shouldBeVisible(Optional eppResource) { return eppResource.isPresent() && shouldBeVisible(eppResource.get()); @@ -463,19 +463,19 @@ public abstract class RdapActionBase implements Runnable { * Runs the given query, and checks for permissioning if necessary. * * @param query an already-defined query to be run; a filter on currentSponsorClientId will be - * added if appropriate + * added if appropriate * @param checkForVisibility true if the results should be checked to make sure they are visible; - * normally this should be equal to the shouldIncludeDeleted setting, but in cases where - * the query could not check deletion status (due to Datastore limitations such as the - * limit of one field queried for inequality, for instance), it may need to be set to true - * even when not including deleted records + * normally this should be equal to the shouldIncludeDeleted setting, but in cases where the + * query could not check deletion status (due to Datastore limitations such as the limit of + * one field queried for inequality, for instance), it may need to be set to true even when + * not including deleted records * @param querySizeLimit the maximum number of items the query is expected to return, usually - * because the limit has been set - * @return an {@link RdapResultSet} object containing the list of - * resources and an incompleteness warning flag, which is set to MIGHT_BE_INCOMPLETE iff - * any resources were excluded due to lack of visibility, and the resulting list of - * resources is less than the maximum allowable, and the number of items returned by the - * query is greater than or equal to the maximum number we might have expected + * because the limit has been set + * @return an {@link RdapResultSet} object containing the list of resources and an incompleteness + * warning flag, which is set to MIGHT_BE_INCOMPLETE iff any resources were excluded due to + * lack of visibility, and the resulting list of resources is less than the maximum allowable, + * and the number of items returned by the query is greater than or equal to the maximum + * number we might have expected */ RdapResultSet getMatchingResources( Query query, boolean checkForVisibility, int querySizeLimit) { diff --git a/java/google/registry/rdap/RdapDataStructures.java b/java/google/registry/rdap/RdapDataStructures.java index e0eada897..d65934158 100644 --- a/java/google/registry/rdap/RdapDataStructures.java +++ b/java/google/registry/rdap/RdapDataStructures.java @@ -43,9 +43,7 @@ final class RdapDataStructures { public JsonArray toJson() { JsonArray jsonArray = new JsonArray(); // Conformance to RFC7483 - // TODO(b/127490882) check if we need to Add back the rdap_level_0 string, as I think that - // just means we conform to the RFC, which we do - // jsonArray.add("rdap_level_0"); + jsonArray.add("rdap_level_0"); // Conformance to the RDAP Response Profile V2.1 // (see section 1.3) diff --git a/java/google/registry/rdap/RdapDomainAction.java b/java/google/registry/rdap/RdapDomainAction.java index 41f3f690d..a991bbd5a 100644 --- a/java/google/registry/rdap/RdapDomainAction.java +++ b/java/google/registry/rdap/RdapDomainAction.java @@ -65,8 +65,6 @@ public class RdapDomainAction extends RdapActionBase { if (!shouldBeVisible(domainBase)) { throw new NotFoundException(pathSearchString + " not found"); } - return rdapJsonFormatter.makeRdapJsonForDomain( - domainBase.get(), - OutputDataType.FULL); + return rdapJsonFormatter.createRdapDomain(domainBase.get(), OutputDataType.FULL); } } diff --git a/java/google/registry/rdap/RdapDomainSearchAction.java b/java/google/registry/rdap/RdapDomainSearchAction.java index 733a50b3b..8a6f46651 100644 --- a/java/google/registry/rdap/RdapDomainSearchAction.java +++ b/java/google/registry/rdap/RdapDomainSearchAction.java @@ -122,8 +122,9 @@ public class RdapDomainSearchAction extends RdapSearchActionBase { if (!LDH_PATTERN.matcher(nsLdhNameParam.get()).matches()) { throw new BadRequestException("Invalid value of nsLdhName parameter"); } - results = searchByNameserverLdhName( - recordWildcardType(RdapSearchPattern.create(nsLdhNameParam.get(), true))); + results = + searchByNameserverLdhName( + recordWildcardType(RdapSearchPattern.create(nsLdhNameParam.get(), true))); } else { metricInformationBuilder.setSearchType(SearchType.BY_NAMESERVER_ADDRESS); metricInformationBuilder.setWildcardType(WildcardType.NO_WILDCARD); @@ -182,9 +183,7 @@ public class RdapDomainSearchAction extends RdapSearchActionBase { return searchByDomainNameByTld(partialStringQuery.getSuffix()); } - /** - * Searches for domains by domain name without a wildcard or interest in deleted entries. - */ + /** Searches for domains by domain name without a wildcard or interest in deleted entries. */ private DomainSearchResponse searchByDomainNameWithoutWildcard( final RdapSearchPattern partialStringQuery) { Optional domainBase = @@ -388,15 +387,14 @@ public class RdapDomainSearchAction extends RdapSearchActionBase { *

In theory, we could have any number of hosts using the same IP address. To make sure we get * all the associated domains, we have to retrieve all of them, and use them to look up domains. * This could open us up to a kind of DoS attack if huge number of hosts are defined on a single - * IP. To avoid this, fetch only the first {@link #maxNameserversInFirstStage} nameservers. In - * all normal circumstances, this should be orders of magnitude more than there actually are. But - * it could result in us missing some domains. + * IP. To avoid this, fetch only the first {@link #maxNameserversInFirstStage} nameservers. In all + * normal circumstances, this should be orders of magnitude more than there actually are. But it + * could result in us missing some domains. * *

The includeDeleted parameter does NOT cause deleted nameservers to be searched, only deleted * domains which used to be connected to an undeleted nameserver. */ - private DomainSearchResponse searchByNameserverIp( - final InetAddress inetAddress) { + private DomainSearchResponse searchByNameserverIp(final InetAddress inetAddress) { Query query = queryItems( HostResource.class, @@ -419,8 +417,7 @@ public class RdapDomainSearchAction extends RdapSearchActionBase { *

This method is called by {@link #searchByNameserverLdhName} and {@link * #searchByNameserverIp} after they assemble the relevant host keys. */ - private DomainSearchResponse searchByNameserverRefs( - final Iterable> hostKeys) { + private DomainSearchResponse searchByNameserverRefs(final Iterable> hostKeys) { // We must break the query up into chunks, because the in operator is limited to 30 subqueries. // Since it is possible for the same domain to show up more than once in our result list (if // we do a wildcard nameserver search that returns multiple nameservers used by the same @@ -459,8 +456,8 @@ public class RdapDomainSearchAction extends RdapSearchActionBase { return makeSearchResults( domains, (numHostKeysSearched >= maxNameserversInFirstStage) - ? IncompletenessWarningType.MIGHT_BE_INCOMPLETE - : IncompletenessWarningType.COMPLETE, + ? IncompletenessWarningType.MIGHT_BE_INCOMPLETE + : IncompletenessWarningType.COMPLETE, (numHostKeysSearched > 0) ? Optional.of((long) domains.size()) : Optional.empty()); } @@ -471,8 +468,7 @@ public class RdapDomainSearchAction extends RdapSearchActionBase { } /** Output JSON from data in an {@link RdapResultSet} object. */ - private DomainSearchResponse makeSearchResults( - RdapResultSet resultSet) { + private DomainSearchResponse makeSearchResults(RdapResultSet resultSet) { return makeSearchResults( resultSet.resources(), resultSet.incompletenessWarningType(), @@ -501,7 +497,7 @@ public class RdapDomainSearchAction extends RdapSearchActionBase { newCursor = Optional.of(domain.getFullyQualifiedDomainName()); builder .domainSearchResultsBuilder() - .add(rdapJsonFormatter.makeRdapJsonForDomain(domain, outputDataType)); + .add(rdapJsonFormatter.createRdapDomain(domain, outputDataType)); } if (rdapResultSetMaxSize < domains.size()) { builder.setNextPageUri(createNavigationUri(newCursor.get())); diff --git a/java/google/registry/rdap/RdapEntityAction.java b/java/google/registry/rdap/RdapEntityAction.java index 03db2fa23..9bdfefb2c 100644 --- a/java/google/registry/rdap/RdapEntityAction.java +++ b/java/google/registry/rdap/RdapEntityAction.java @@ -19,6 +19,7 @@ import static google.registry.rdap.RdapUtils.getRegistrarByIanaIdentifier; import static google.registry.request.Action.Method.GET; import static google.registry.request.Action.Method.HEAD; +import com.google.common.collect.ImmutableSet; import com.google.common.primitives.Longs; import com.google.re2j.Pattern; import com.googlecode.objectify.Key; @@ -72,10 +73,8 @@ public class RdapEntityAction extends RdapActionBase { // As per Andy Newton on the regext mailing list, contacts by themselves have no role, since // they are global, and might have different roles for different domains. if ((contactResource != null) && shouldBeVisible(contactResource)) { - return rdapJsonFormatter.makeRdapJsonForContact( - contactResource, - Optional.empty(), - OutputDataType.FULL); + return rdapJsonFormatter.createRdapContactEntity( + contactResource, ImmutableSet.of(), OutputDataType.FULL); } } Long ianaIdentifier = Longs.tryParse(pathSearchString); @@ -83,8 +82,7 @@ public class RdapEntityAction extends RdapActionBase { wasValidKey = true; Optional registrar = getRegistrarByIanaIdentifier(ianaIdentifier); if (registrar.isPresent() && shouldBeVisible(registrar.get())) { - return rdapJsonFormatter.makeRdapJsonForRegistrar( - registrar.get(), OutputDataType.FULL); + return rdapJsonFormatter.createRdapRegistrarEntity(registrar.get(), OutputDataType.FULL); } } // At this point, we have failed to find either a contact or a registrar. diff --git a/java/google/registry/rdap/RdapEntitySearchAction.java b/java/google/registry/rdap/RdapEntitySearchAction.java index ab3be11a2..c1522b0be 100644 --- a/java/google/registry/rdap/RdapEntitySearchAction.java +++ b/java/google/registry/rdap/RdapEntitySearchAction.java @@ -21,6 +21,7 @@ import static google.registry.request.Action.Method.GET; import static google.registry.request.Action.Method.HEAD; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Streams; import com.google.common.primitives.Booleans; @@ -380,10 +381,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { shouldIncludeDeleted(), querySizeLimit); } - return makeSearchResults( - contactResultSet, - registrars, - QueryType.HANDLE); + return makeSearchResults(contactResultSet, registrars, QueryType.HANDLE); } } @@ -406,9 +404,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { * properties of the {@link RdapResultSet} structure and passes them as separate arguments. */ private EntitySearchResponse makeSearchResults( - RdapResultSet resultSet, - List registrars, - QueryType queryType) { + RdapResultSet resultSet, List registrars, QueryType queryType) { return makeSearchResults( resultSet.resources(), resultSet.incompletenessWarningType(), @@ -424,10 +420,10 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { * * @param contacts the list of contacts which can be returned * @param incompletenessWarningType MIGHT_BE_INCOMPLETE if the list of contacts might be - * incomplete; this only matters if the total count of contacts and registrars combined is - * less than a full result set's worth + * incomplete; this only matters if the total count of contacts and registrars combined is + * less than a full result set's worth * @param numContactsRetrieved the number of contacts retrieved in the process of generating the - * results + * results * @param registrars the list of registrars which can be returned * @param queryType whether the query was by full name or by handle * @return an {@link RdapSearchResults} object @@ -460,10 +456,11 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { for (ContactResource contact : Iterables.limit(contacts, rdapResultSetMaxSize)) { // As per Andy Newton on the regext mailing list, contacts by themselves have no role, since // they are global, and might have different roles for different domains. - builder.entitySearchResultsBuilder().add(rdapJsonFormatter.makeRdapJsonForContact( - contact, - Optional.empty(), - outputDataType)); + builder + .entitySearchResultsBuilder() + .add( + rdapJsonFormatter.createRdapContactEntity( + contact, ImmutableSet.of(), outputDataType)); newCursor = Optional.of( CONTACT_CURSOR_PREFIX @@ -476,9 +473,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { Iterables.limit(registrars, rdapResultSetMaxSize - contacts.size())) { builder .entitySearchResultsBuilder() - .add( - rdapJsonFormatter.makeRdapJsonForRegistrar( - registrar, outputDataType)); + .add(rdapJsonFormatter.createRdapRegistrarEntity(registrar, outputDataType)); newCursor = Optional.of(REGISTRAR_CURSOR_PREFIX + registrar.getRegistrarName()); } } diff --git a/java/google/registry/rdap/RdapIcannStandardInformation.java b/java/google/registry/rdap/RdapIcannStandardInformation.java index 6d59586e8..368c63df9 100644 --- a/java/google/registry/rdap/RdapIcannStandardInformation.java +++ b/java/google/registry/rdap/RdapIcannStandardInformation.java @@ -51,11 +51,12 @@ public class RdapIcannStandardInformation { .build()) .build(); - /** Required by ICANN RDAP Profile section 1.5.20. */ + /** Required by ICANN RDAP Response Profile section 2.11. */ private static final Notice INACCURACY_COMPLAINT_FORM_NOTICE = Notice.builder() + .setTitle("RDDS Inaccuracy Complaint Form") .setDescription( - "URL of the ICANN Whois Inaccuracy Complaint Form: https://www.icann.org/wicf") + "URL of the ICANN RDDS Inaccuracy Complaint Form: https://www.icann.org/wicf") .addLink( Link.builder() .setValue("https://www.icann.org/wicf") @@ -71,6 +72,7 @@ public class RdapIcannStandardInformation { CONFORMANCE_NOTICE, // RDAP Response Profile 2.6.3 DOMAIN_STATUS_CODES_NOTICE, + // RDAP Response Profile 2.11 INACCURACY_COMPLAINT_FORM_NOTICE); /** Boilerplate remarks required by nameserver and entity responses. */ @@ -125,21 +127,14 @@ public class RdapIcannStandardInformation { static final ImmutableList POSSIBLY_INCOMPLETE_NOTICES = ImmutableList.of(POSSIBLY_INCOMPLETE_RESULT_SET_NOTICE); - /** Included when the requester is not logged in as the owner of the domain being returned. */ - static final Remark DOMAIN_CONTACTS_HIDDEN_DATA_REMARK = - Remark.builder() - .setTitle("Contacts Hidden") - .setDescription("Domain contacts are visible only to the owning registrar.") - .setType(Remark.Type.OBJECT_TRUNCATED_UNEXPLAINABLE) - .build(); - /** - * Included when requester is not logged in as the owner of the contact being returned. Format - * required by ICANN RDAP Response Profile 15feb19 section 2.7.4.3. + * Included when requester is not logged in as the owner of the contact being returned. + * + *

Format required by ICANN RDAP Response Profile 15feb19 section 2.7.4.3. */ static final Remark CONTACT_PERSONAL_DATA_HIDDEN_DATA_REMARK = Remark.builder() - .setTitle("Redacted for Privacy") + .setTitle("REDACTED FOR PRIVACY") .setDescription( "Some of the data in this object has been removed.", "Contact personal data is visible only to the owning registrar.") @@ -154,4 +149,30 @@ public class RdapIcannStandardInformation { .setType("text/html") .build()) .build(); + + /** + * String that replaces GDPR redacted values. + * + *

GTLD Registration Data Temp Spec 17may18, Appendix A, 2.2: Fields required to be "redacted" + * MUST privide in the value section text similar to "REDACTED FOR PRIVACY" + */ + static final String CONTACT_REDACTED_VALUE = "REDACTED FOR PRIVACY"; + + /** + * Included in ALL contact responses, even if the user is authorized. + * + *

Format required by ICANN RDAP Response Profile 15feb19 section 2.7.5.3. + * + *

NOTE that unlike other redacted fields, there's no allowance to give the email to authorized + * users or allow for registrar consent. + */ + static final Remark CONTACT_EMAIL_REDACTED_FOR_DOMAIN = + Remark.builder() + .setTitle("EMAIL REDACTED FOR PRIVACY") + .setDescription( + "Please query the RDDS service of the Registrar of Record identifies in this output" + + " for information on how to contact the Registrant of the queried domain" + + " name.") + .setType(Remark.Type.OBJECT_REDACTED_AUTHORIZATION) + .build(); } diff --git a/java/google/registry/rdap/RdapJsonFormatter.java b/java/google/registry/rdap/RdapJsonFormatter.java index 527eadcc0..8a8ea473c 100644 --- a/java/google/registry/rdap/RdapJsonFormatter.java +++ b/java/google/registry/rdap/RdapJsonFormatter.java @@ -17,21 +17,27 @@ package google.registry.rdap; import static com.google.common.base.Predicates.not; import static com.google.common.base.Strings.nullToEmpty; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap; import static google.registry.model.EppResourceUtils.isLinked; import static google.registry.model.ofy.ObjectifyService.ofy; +import static google.registry.rdap.RdapIcannStandardInformation.CONTACT_REDACTED_VALUE; import static google.registry.util.CollectionUtils.union; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSetMultimap; import com.google.common.collect.Maps; import com.google.common.collect.Ordering; import com.google.common.collect.Streams; +import com.google.common.flogger.FluentLogger; import com.google.common.net.InetAddresses; import com.google.gson.JsonArray; import com.googlecode.objectify.Key; import google.registry.config.RegistryConfig.Config; import google.registry.model.EppResource; +import google.registry.model.contact.ContactAddress; import google.registry.model.contact.ContactPhoneNumber; import google.registry.model.contact.ContactResource; import google.registry.model.contact.PostalInfo; @@ -51,9 +57,12 @@ import google.registry.rdap.RdapDataStructures.Link; import google.registry.rdap.RdapDataStructures.Notice; import google.registry.rdap.RdapDataStructures.PublicId; import google.registry.rdap.RdapDataStructures.RdapStatus; +import google.registry.rdap.RdapObjectClasses.RdapContactEntity; import google.registry.rdap.RdapObjectClasses.RdapDomain; import google.registry.rdap.RdapObjectClasses.RdapEntity; import google.registry.rdap.RdapObjectClasses.RdapNameserver; +import google.registry.rdap.RdapObjectClasses.RdapRegistrarEntity; +import google.registry.rdap.RdapObjectClasses.SecureDns; import google.registry.rdap.RdapObjectClasses.Vcard; import google.registry.rdap.RdapObjectClasses.VcardArray; import google.registry.request.FullServletPath; @@ -68,11 +77,11 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Stream; import javax.annotation.Nullable; import javax.inject.Inject; import org.joda.time.DateTime; -import org.joda.time.DateTimeComparator; /** * Helper class to create RDAP JSON objects for various registry entities and objects. @@ -87,6 +96,8 @@ import org.joda.time.DateTimeComparator; */ public class RdapJsonFormatter { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private DateTime requestTime = null; @Inject @Config("rdapTos") ImmutableList rdapTos; @@ -108,8 +119,35 @@ public class RdapJsonFormatter { * returned object indicating that it includes only summary data. */ public enum OutputDataType { + /** + * The full information about an RDAP object. + * + *

Reserved to cases when this object is the only result of a query - either queried + * directly, or the sole result of a search query. + */ FULL, - SUMMARY + /** + * The minimal information about an RDAP object that is allowed as a reply. + * + *

Reserved to cases when this object is one of many results of a search query. + * + *

We want to minimize the size of the reply, and also minimize the Datastore queries needed + * to generate these replies since we might have a lot of these objects to return. + * + *

Each object with a SUMMARY type will have a remark with a direct link to itself, which + * will return the FULL result. + */ + SUMMARY, + /** + * The object isn't the subject of the query, but is rather a sub-object of the actual reply. + * + *

These objects have less required fields in the RDAP spec, and hence can be even smaller + * than the SUMMARY objects. + * + *

Like SUMMARY objects, these objects will also have a remark with a direct link to itself, + * which will return the FULL result. + */ + INTERNAL } /** Map of EPP status values to the RDAP equivalents. */ @@ -144,12 +182,11 @@ public class RdapJsonFormatter { /** * Map of EPP event values to the RDAP equivalents. * - *

Only has entries for the events we care about, according to the RDAP Response Profile - * 15feb19. + *

Only has entries for optional events, either stated as optional in the RDAP Response Profile + * 15feb19, or not mentioned at all but thought to be useful anyway. * - * There are additional events that don't have HistoryEntry equivalent and are created - * differently. They will be in different locations in the code. These values are: EXPIRATION, - * LAST_CHANGED, LAST_UPDATE_OF_RDAP_DATABASE. + *

Any required event should be added elsewhere, preferably without using HistoryEntries (so + * that we don't need to load HistoryEntries for "summary" responses). */ private static final ImmutableMap HISTORY_ENTRY_TYPE_TO_RDAP_EVENT_ACTION_MAP = @@ -160,8 +197,6 @@ public class RdapJsonFormatter { /** Not in the Response Profile. */ .put(HistoryEntry.Type.DOMAIN_AUTORENEW, EventAction.REREGISTRATION) - /** Section 2.3.1.1, obligatory. */ - .put(HistoryEntry.Type.DOMAIN_CREATE, EventAction.REGISTRATION) /** Not in the Response Profile. */ .put(HistoryEntry.Type.DOMAIN_DELETE, EventAction.DELETION) /** Not in the Response Profile. */ @@ -170,7 +205,6 @@ public class RdapJsonFormatter { .put(HistoryEntry.Type.DOMAIN_RESTORE, EventAction.REINSTANTIATION) /** Section 2.3.2.3, optional. */ .put(HistoryEntry.Type.DOMAIN_TRANSFER_APPROVE, EventAction.TRANSFER) - .put(HistoryEntry.Type.HOST_CREATE, EventAction.REGISTRATION) .put(HistoryEntry.Type.HOST_DELETE, EventAction.DELETION) .build(); @@ -214,85 +248,134 @@ public class RdapJsonFormatter { /** * Creates a JSON object for a {@link DomainBase}. * + *

NOTE that domain searches aren't in the spec yet - they're in the RFC7482 that describes the + * query format, but they aren't in the RDAP Technical Implementation Guide 15feb19, meaning we + * don't have to implement them yet and the RDAP Response Profile doesn't apply to them. + * + *

We're implementing domain searches anyway, BUT we won't have the response for searches + * conform to the RDAP Response Profile. + * * @param domainBase the domain resource object from which the JSON object should be created - * @param outputDataType whether to generate full or summary data + * @param outputDataType whether to generate FULL or SUMMARY data. Domains are never INTERNAL. */ - RdapDomain makeRdapJsonForDomain( - DomainBase domainBase, - OutputDataType outputDataType) { + RdapDomain createRdapDomain(DomainBase domainBase, OutputDataType outputDataType) { RdapDomain.Builder builder = RdapDomain.builder(); + builder.linksBuilder().add(makeSelfLink("domain", domainBase.getFullyQualifiedDomainName())); + if (outputDataType != OutputDataType.FULL) { + builder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); + } + // RDAP Response Profile 15feb19 section 2.1 discusses the domain name. + builder.setLdhName(domainBase.getFullyQualifiedDomainName()); // RDAP Response Profile 15feb19 section 2.2: // The domain handle MUST be the ROID builder.setHandle(domainBase.getRepoId()); - builder.setLdhName(domainBase.getFullyQualifiedDomainName()); - builder.statusBuilder().addAll( + // If this is a summary (search result) - we'll return now. Since there's no requirement for + // domain searches at all, having the name, handle, and self link is enough. + if (outputDataType == OutputDataType.SUMMARY) { + return builder.build(); + } + // RDAP Response Profile 15feb19 section 2.3.1: + // The domain object in the RDAP response MUST contain the following events: + // [registration, expiration, last update of RDAP database] + builder + .eventsBuilder() + .add( + Event.builder() + .setEventAction(EventAction.REGISTRATION) + .setEventActor( + Optional.ofNullable(domainBase.getCreationClientId()).orElse("(none)")) + .setEventDate(domainBase.getCreationTime()) + .build(), + Event.builder() + .setEventAction(EventAction.EXPIRATION) + .setEventDate(domainBase.getRegistrationExpirationTime()) + .build(), + Event.builder() + .setEventAction(EventAction.LAST_UPDATE_OF_RDAP_DATABASE) + .setEventDate(getRequestTime()) + .build()); + // RDAP Response Profile 15feb19 section 2.3.2 discusses optional events. We add some of those + // here. We also add a few others we find interesting. + builder.eventsBuilder().addAll(makeOptionalEvents(domainBase)); + // RDAP Response Profile 15feb19 section 2.4.1: + // The domain object in the RDAP response MUST contain an entity with the Registrar role. + // + // See {@link createRdapRegistrarEntity} for details of section 2.4 conformance + builder + .entitiesBuilder() + .add( + createRdapRegistrarEntity( + domainBase.getCurrentSponsorClientId(), OutputDataType.INTERNAL)); + // RDAP Response Profile 2.6.1: must have at least one status member + // makeStatusValueList should in theory always contain one of either "active" or "inactive". + ImmutableSet status = makeStatusValueList( domainBase.getStatusValues(), false, // isRedacted - domainBase.getDeletionTime().isBefore(getRequestTime()))); - builder.linksBuilder().add( - makeSelfLink("domain", domainBase.getFullyQualifiedDomainName())); - boolean displayContacts = - rdapAuthorization.isAuthorizedForClientId(domainBase.getCurrentSponsorClientId()); - // If we are outputting all data (not just summary data), also add information about hosts, - // contacts and events (history entries). If we are outputting summary data, instead add a - // remark indicating that fact. - if (outputDataType == OutputDataType.SUMMARY) { - builder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); - } else { - ImmutableList events = makeEvents(domainBase); - builder.eventsBuilder().addAll(events); - // Kick off the database loads of the nameservers that we will need, so it can load - // asynchronously while we load and process the contacts. - Map, HostResource> loadedHosts = - ofy().load().keys(domainBase.getNameservers()); - // Load the registrant and other contacts and add them to the data. - if (!displayContacts) { - builder - .remarksBuilder() - .add(RdapIcannStandardInformation.DOMAIN_CONTACTS_HIDDEN_DATA_REMARK); - } else { - Map, ContactResource> loadedContacts = - ofy().load().keys(domainBase.getReferencedContacts()); + domainBase.getDeletionTime().isBefore(getRequestTime())); + builder.statusBuilder().addAll(status); + if (status.isEmpty()) { + logger.atWarning().log( + "Domain %s (ROID %s) doesn't have any status", + domainBase.getFullyQualifiedDomainName(), domainBase.getRepoId()); + } + // RDAP Response Profile 2.6.3, must have a notice about statuses. That is in {@link + // RdapIcannStandardInformation#domainBoilerplateNotices} + + // Kick off the database loads of the nameservers that we will need, so it can load + // asynchronously while we load and process the contacts. + Map, HostResource> loadedHosts = + ofy().load().keys(domainBase.getNameservers()); + // Load the registrant and other contacts and add them to the data. + Map, ContactResource> loadedContacts = + ofy().load().keys(domainBase.getReferencedContacts()); + // RDAP Response Profile 2.7.3, A domain MUST have the REGISTRANT, ADMIN, TECH roles and MAY + // have others. We also add the BILLING. + // + // RDAP Response Profile 2.7.1, 2.7.3 - we MUST have the contacts. 2.7.4 discusses redaction of + // fields we don't want to show (as opposed to not having contacts at all) because of GDPR etc. + // + // the GDPR redaction is handled in createRdapContactEntity + ImmutableSetMultimap, Type> contactsToRoles = Streams.concat( - domainBase.getContacts().stream(), - Stream.of( - DesignatedContact.create(Type.REGISTRANT, domainBase.getRegistrant()))) + domainBase.getContacts().stream(), + Stream.of(DesignatedContact.create(Type.REGISTRANT, domainBase.getRegistrant()))) .sorted(DESIGNATED_CONTACT_ORDERING) - .map( - designatedContact -> - makeRdapJsonForContact( - loadedContacts.get(designatedContact.getContactKey()), - Optional.of(designatedContact.getType()), - outputDataType)) - .forEach(builder.entitiesBuilder()::add); + .collect( + toImmutableSetMultimap( + DesignatedContact::getContactKey, DesignatedContact::getType)); + + for (Key contactKey : contactsToRoles.keySet()) { + Set roles = + contactsToRoles.get(contactKey).stream() + .map(RdapJsonFormatter::convertContactTypeToRdapRole) + .collect(toImmutableSet()); + if (roles.isEmpty()) { + continue; } builder .entitiesBuilder() - .add(createInternalRegistrarEntity(domainBase.getCurrentSponsorClientId())); - // Add the nameservers to the data; the load was kicked off above for efficiency. - for (HostResource hostResource - : HOST_RESOURCE_ORDERING.immutableSortedCopy(loadedHosts.values())) { - builder.nameserversBuilder().add(makeRdapJsonForHost(hostResource, outputDataType)); - } + .add( + createRdapContactEntity( + loadedContacts.get(contactKey), roles, OutputDataType.INTERNAL)); + } + // Add the nameservers to the data; the load was kicked off above for efficiency. + // RDAP Response Profile 2.9: we MUST have the nameservers + for (HostResource hostResource : + HOST_RESOURCE_ORDERING.immutableSortedCopy(loadedHosts.values())) { + builder.nameserversBuilder().add(createRdapNameserver(hostResource, OutputDataType.INTERNAL)); } - return builder.build(); - } - /** - * Creates a JSON object for the desired registrar to an existing list of JSON objects. - * - * @param clientId the registrar client ID - */ - RdapEntity createInternalRegistrarEntity(String clientId) { - Optional registrar = Registrar.loadByClientIdCached(clientId); - if (!registrar.isPresent()) { - throw new InternalServerErrorException( - String.format("Coudn't find registrar '%s'", clientId)); - } - // TODO(b/130150723): we need to display the ABUSE contact for registrar object inside of Domain - // responses. Currently, we use summary for any "internal" registrar. - return makeRdapJsonForRegistrar(registrar.get(), OutputDataType.SUMMARY); + // RDAP Response Profile 2.10 - MUST contain a secureDns member including at least a + // delegationSigned element. Other elements (e.g. dsData) MUST be included if the domain name is + // signed and the elements are stored in the Registry + // + // TODO(b/133310221): get the zoneSigned value from the config files. + SecureDns.Builder secureDnsBuilder = SecureDns.builder().setZoneSigned(true); + domainBase.getDsData().forEach(secureDnsBuilder::addDsData); + builder.setSecureDns(secureDnsBuilder.build()); + + return builder.build(); } /** @@ -301,58 +384,80 @@ public class RdapJsonFormatter { * @param hostResource the host resource object from which the JSON object should be created * @param outputDataType whether to generate full or summary data */ - RdapNameserver makeRdapJsonForHost( - HostResource hostResource, - OutputDataType outputDataType) { - RdapNameserver.Builder builder = RdapNameserver.builder() - .setHandle(hostResource.getRepoId()) - .setLdhName(hostResource.getFullyQualifiedHostName()); - - ImmutableSet.Builder statuses = new ImmutableSet.Builder<>(); - statuses.addAll(hostResource.getStatusValues()); - if (isLinked(Key.create(hostResource), getRequestTime())) { - statuses.add(StatusValue.LINKED); - } - if (hostResource.isSubordinate() - && ofy() - .load() - .key(hostResource.getSuperordinateDomain()) - .now() - .cloneProjectedAtTime(getRequestTime()) - .getStatusValues() - .contains(StatusValue.PENDING_TRANSFER)) { - statuses.add(StatusValue.PENDING_TRANSFER); - } - builder - .statusBuilder() - .addAll( - makeStatusValueList( - statuses.build(), - false, // isRedacted - hostResource.getDeletionTime().isBefore(getRequestTime()))); + RdapNameserver createRdapNameserver(HostResource hostResource, OutputDataType outputDataType) { + RdapNameserver.Builder builder = RdapNameserver.builder(); builder .linksBuilder() .add(makeSelfLink("nameserver", hostResource.getFullyQualifiedHostName())); - - // If we are outputting all data (not just summary data), also add events taken from the history - // entries. If we are outputting summary data, instead add a remark indicating that fact. - if (outputDataType == OutputDataType.SUMMARY) { + if (outputDataType != OutputDataType.FULL) { builder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); - } else { - builder.eventsBuilder().addAll(makeEvents(hostResource)); } - // We MUST have the ip addresses: RDAP Response Profile 4.2. - for (InetAddress inetAddress : hostResource.getInetAddresses()) { - if (inetAddress instanceof Inet4Address) { - builder.ipv4Builder().add(InetAddresses.toAddrString(inetAddress)); - } else if (inetAddress instanceof Inet6Address) { - builder.ipv6Builder().add(InetAddresses.toAddrString(inetAddress)); + // We need the ldhName: RDAP Response Profile 2.9.1, 4.1 + builder.setLdhName(hostResource.getFullyQualifiedHostName()); + // Handle is optional, but if given it MUST be the ROID. + // We will set it always as it's important as a "self link" + builder.setHandle(hostResource.getRepoId()); + + // Status is optional for internal Nameservers - RDAP Response Profile 2.9.2 + // It isn't mentioned at all anywhere else. So we can just not put it at all? + // + // To be safe, we'll put it on the "FULL" version anyway + if (outputDataType == OutputDataType.FULL) { + ImmutableSet.Builder statuses = new ImmutableSet.Builder<>(); + statuses.addAll(hostResource.getStatusValues()); + if (isLinked(Key.create(hostResource), getRequestTime())) { + statuses.add(StatusValue.LINKED); + } + if (hostResource.isSubordinate() + && ofy() + .load() + .key(hostResource.getSuperordinateDomain()) + .now() + .cloneProjectedAtTime(getRequestTime()) + .getStatusValues() + .contains(StatusValue.PENDING_TRANSFER)) { + statuses.add(StatusValue.PENDING_TRANSFER); + } + builder + .statusBuilder() + .addAll( + makeStatusValueList( + statuses.build(), + false, // isRedacted + hostResource.getDeletionTime().isBefore(getRequestTime()))); + } + + // For query responses - we MUST have all the ip addresses: RDAP Response Profile 4.2. + // + // However, it is optional for internal responses: RDAP Response Profile 2.9.2 + if (outputDataType != OutputDataType.INTERNAL) { + for (InetAddress inetAddress : hostResource.getInetAddresses()) { + if (inetAddress instanceof Inet4Address) { + builder.ipv4Builder().add(InetAddresses.toAddrString(inetAddress)); + } else if (inetAddress instanceof Inet6Address) { + builder.ipv6Builder().add(InetAddresses.toAddrString(inetAddress)); + } } } - builder - .entitiesBuilder() - .add(createInternalRegistrarEntity(hostResource.getPersistedCurrentSponsorClientId())); + + // RDAP Response Profile 4.3 - Registrar member is optional, so we only set it for FULL + if (outputDataType == OutputDataType.FULL) { + builder + .entitiesBuilder() + .add( + createRdapRegistrarEntity( + hostResource.getPersistedCurrentSponsorClientId(), OutputDataType.INTERNAL)); + } + if (outputDataType != OutputDataType.INTERNAL) { + // Rdap Response Profile 4.4, must have "last update of RDAP database" response. But this is + // only for direct query responses and not for internal objects. + builder.setLastUpdateOfRdapDatabaseEvent( + Event.builder() + .setEventAction(EventAction.LAST_UPDATE_OF_RDAP_DATABASE) + .setEventDate(getRequestTime()) + .build()); + } return builder.build(); } @@ -360,165 +465,340 @@ public class RdapJsonFormatter { * Creates a JSON object for a {@link ContactResource} and associated contact type. * * @param contactResource the contact resource object from which the JSON object should be created - * @param contactType the contact type to map to an RDAP role; if absent, no role is listed + * @param roles the roles of this contact * @param outputDataType whether to generate full or summary data */ - RdapEntity makeRdapJsonForContact( + RdapContactEntity createRdapContactEntity( ContactResource contactResource, - Optional contactType, + Iterable roles, OutputDataType outputDataType) { + RdapContactEntity.Builder contactBuilder = RdapContactEntity.builder(); + + // RDAP Response Profile 2.7.1, 2.7.3 - we MUST have the contacts. 2.7.4 discusses censoring of + // fields we don't want to show (as opposed to not having contacts at all) because of GDPR etc. + // + // 2.8 allows for unredacted output for authorized people. boolean isAuthorized = rdapAuthorization.isAuthorizedForClientId(contactResource.getCurrentSponsorClientId()); - RdapEntity.Builder entityBuilder = - RdapEntity.builder() - .setHandle(contactResource.getRepoId()); - entityBuilder - .statusBuilder() - .addAll( - makeStatusValueList( - isLinked(Key.create(contactResource), getRequestTime()) - ? union(contactResource.getStatusValues(), StatusValue.LINKED) - : contactResource.getStatusValues(), - !isAuthorized, - contactResource.getDeletionTime().isBefore(getRequestTime()))); - - contactType.ifPresent( - type -> entityBuilder.rolesBuilder().add(convertContactTypeToRdapRole(type))); - entityBuilder.linksBuilder().add(makeSelfLink("entity", contactResource.getRepoId())); - // If we are logged in as the owner of this contact, create the vCard. + // ROID needs to be redacted if we aren't authorized, so we can't have a self-link for + // unauthorized users if (isAuthorized) { - VcardArray.Builder vcardBuilder = VcardArray.builder(); - PostalInfo postalInfo = contactResource.getInternationalizedPostalInfo(); - if (postalInfo == null) { - postalInfo = contactResource.getLocalizedPostalInfo(); - } - if (postalInfo != null) { - if (postalInfo.getName() != null) { - vcardBuilder.add(Vcard.create("fn", "text", postalInfo.getName())); - } - if (postalInfo.getOrg() != null) { - vcardBuilder.add(Vcard.create("org", "text", postalInfo.getOrg())); - } - addVCardAddressEntry(vcardBuilder, postalInfo.getAddress()); - } - ContactPhoneNumber voicePhoneNumber = contactResource.getVoiceNumber(); - if (voicePhoneNumber != null) { - vcardBuilder.add(makePhoneEntry(PHONE_TYPE_VOICE, makePhoneString(voicePhoneNumber))); - } - ContactPhoneNumber faxPhoneNumber = contactResource.getFaxNumber(); - if (faxPhoneNumber != null) { - vcardBuilder.add(makePhoneEntry(PHONE_TYPE_FAX, makePhoneString(faxPhoneNumber))); - } - String emailAddress = contactResource.getEmailAddress(); - if (emailAddress != null) { - vcardBuilder.add(Vcard.create("email", "text", emailAddress)); - } - entityBuilder.setVcardArray(vcardBuilder.build()); - } else { - entityBuilder + contactBuilder.linksBuilder().add(makeSelfLink("entity", contactResource.getRepoId())); + } + + // Only show the "summary data remark" if the user is authorized to see this data - because + // unauthorized users don't have a self link meaning they can't navigate to the full data. + if (outputDataType != OutputDataType.FULL && isAuthorized) { + contactBuilder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); + } + + // GTLD Registration Data Temp Spec 17may18, Appendix A, 2.3, 2.4 and RDAP Response Profile + // 2.7.4.1, 2.7.4.2 - the following fields must be redacted: + // for REGISTRANT: + // handle (ROID), FN (name), TEL (telephone/fax and extension), street, city, postal code + // for ADMIN, TECH: + // handle (ROID), FN (name), TEL (telephone/fax and extension), Organization, street, city, + // state/province, postal code, country + // + // Note that in theory we have to show the Organization and state/province and country for the + // REGISTRANT. For now we won't do that until we make sure it's really OK for GDPR + // + if (!isAuthorized) { + // RDAP Response Profile 2.7.4.3: if we redact values from the contact, we MUST include a + // remark + contactBuilder .remarksBuilder() .add(RdapIcannStandardInformation.CONTACT_PERSONAL_DATA_HIDDEN_DATA_REMARK); + // to make sure we don't accidentally display data we shouldn't - we replace the + // contactResource with a safe resource. Then we can add any information we need (e.g. the + // Organization / state / country of the registrant), although we currently don't do that. + contactResource = + new ContactResource.Builder() + .setRepoId(CONTACT_REDACTED_VALUE) + .setVoiceNumber( + new ContactPhoneNumber.Builder().setPhoneNumber(CONTACT_REDACTED_VALUE).build()) + .setFaxNumber( + new ContactPhoneNumber.Builder().setPhoneNumber(CONTACT_REDACTED_VALUE).build()) + .setInternationalizedPostalInfo( + new PostalInfo.Builder() + .setName(CONTACT_REDACTED_VALUE) + .setOrg(CONTACT_REDACTED_VALUE) + .setType(PostalInfo.Type.INTERNATIONALIZED) + .setAddress( + new ContactAddress.Builder() + .setStreet(ImmutableList.of(CONTACT_REDACTED_VALUE)) + .setCity(CONTACT_REDACTED_VALUE) + .setState(CONTACT_REDACTED_VALUE) + .setZip(CONTACT_REDACTED_VALUE) + .setCountryCode("XX") + .build()) + .build()) + .build(); } + + // RDAP Response Profile 2.7.3 - we MUST provide a handle set with the ROID, subject to the + // redaction above. + contactBuilder.setHandle(contactResource.getRepoId()); + + // RDAP Response Profile doesn't mention status for contacts, so we only show it if we're both + // FULL and Authorized. + if (outputDataType == OutputDataType.FULL && isAuthorized) { + contactBuilder + .statusBuilder() + .addAll( + makeStatusValueList( + isLinked(Key.create(contactResource), getRequestTime()) + ? union(contactResource.getStatusValues(), StatusValue.LINKED) + : contactResource.getStatusValues(), + false, + contactResource.getDeletionTime().isBefore(getRequestTime()))); + } + + contactBuilder.rolesBuilder().addAll(roles); + + VcardArray.Builder vcardBuilder = VcardArray.builder(); + // Adding the VCard members subject to the redaction above. + // + // RDAP Response Profile 2.7.3 - we MUST have FN, ADR, TEL, EMAIL. + // + // Note that 2.7.5 also says the EMAIL must be omitted, so we'll omit it + PostalInfo postalInfo = contactResource.getInternationalizedPostalInfo(); + if (postalInfo == null) { + postalInfo = contactResource.getLocalizedPostalInfo(); + } + if (postalInfo != null) { + if (postalInfo.getName() != null) { + vcardBuilder.add(Vcard.create("fn", "text", postalInfo.getName())); + } + if (postalInfo.getOrg() != null) { + vcardBuilder.add(Vcard.create("org", "text", postalInfo.getOrg())); + } + addVCardAddressEntry(vcardBuilder, postalInfo.getAddress()); + } + ContactPhoneNumber voicePhoneNumber = contactResource.getVoiceNumber(); + if (voicePhoneNumber != null) { + vcardBuilder.add(makePhoneEntry(PHONE_TYPE_VOICE, makePhoneString(voicePhoneNumber))); + } + ContactPhoneNumber faxPhoneNumber = contactResource.getFaxNumber(); + if (faxPhoneNumber != null) { + vcardBuilder.add(makePhoneEntry(PHONE_TYPE_FAX, makePhoneString(faxPhoneNumber))); + } + // RDAP Response Profile 2.7.5.1, 2.7.5.3: + // email MUST be omitted, and we MUST have a Remark saying so + contactBuilder + .remarksBuilder() + .add(RdapIcannStandardInformation.CONTACT_EMAIL_REDACTED_FOR_DOMAIN); + contactBuilder.setVcardArray(vcardBuilder.build()); + + if (outputDataType != OutputDataType.INTERNAL) { + // Rdap Response Profile 2.7.6 must have "last update of RDAP database" response. But this is + // only for direct query responses and not for internal objects. I'm not sure why it's in that + // section at all... + contactBuilder.setLastUpdateOfRdapDatabaseEvent( + Event.builder() + .setEventAction(EventAction.LAST_UPDATE_OF_RDAP_DATABASE) + .setEventDate(getRequestTime()) + .build()); + } + // If we are outputting all data (not just summary data), also add events taken from the history - // entries. If we are outputting summary data, instead add a remark indicating that fact. - if (outputDataType == OutputDataType.SUMMARY) { - entityBuilder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); - } else { - entityBuilder.eventsBuilder().addAll(makeEvents(contactResource)); + // entries. This isn't strictly required. + // + // We also only add it for authorized users because millisecond times can fingerprint a user + // just as much as the handle can. + if (outputDataType == OutputDataType.FULL && isAuthorized) { + contactBuilder.eventsBuilder().addAll(makeOptionalEvents(contactResource)); } - return entityBuilder.build(); + return contactBuilder.build(); } /** * Creates a JSON object for a {@link Registrar}. * - * @param registrar the registrar object from which the JSON object should be created - * @param outputDataType whether to generate full or summary data + *

This object can be INTERNAL to the Domain and Nameserver responses, with requirements + * discussed in the RDAP Response Profile 15feb19 sections 2.4 (internal to Domain) and 4.3 + * (internal to Namesever) + * + * @param registrar the registrar object from which the RDAP response + * @param outputDataType whether to generate FULL, SUMMARY, or INTERNAL data. */ - RdapEntity makeRdapJsonForRegistrar( - Registrar registrar, - OutputDataType outputDataType) { - RdapEntity.Builder builder = RdapEntity.builder(); - Long ianaIdentifier = registrar.getIanaIdentifier(); - // the handle MUST be the ianaIdentifier, RDAP Response Profile 2.4.2. - builder.setHandle((ianaIdentifier == null) ? "(none)" : ianaIdentifier.toString()); - builder.statusBuilder().addAll(registrar.isLive() ? STATUS_LIST_ACTIVE : STATUS_LIST_INACTIVE); - builder.rolesBuilder().add(RdapEntity.Role.REGISTRAR); - if (ianaIdentifier != null) { - builder.linksBuilder().add(makeSelfLink("entity", ianaIdentifier.toString())); - // We MUST have a publicId with the ianaIdentifier, RDAP Response Profile 2.4.3, 4.3 - builder - .publicIdsBuilder() - .add(PublicId.create(PublicId.Type.IANA_REGISTRAR_ID, ianaIdentifier.toString())); + RdapRegistrarEntity createRdapRegistrarEntity( + Registrar registrar, OutputDataType outputDataType) { + RdapRegistrarEntity.Builder builder = RdapRegistrarEntity.builder(); + if (outputDataType != OutputDataType.FULL) { + builder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); } // Create the vCard. VcardArray.Builder vcardBuilder = VcardArray.builder(); + // Rdap Response Profile 2.4.1, 3.1 - The role must me "registrar" and a valid FN VCard must be + // present (3.1 requires additional VCards, which will be added next) + builder.rolesBuilder().add(RdapEntity.Role.REGISTRAR); String registrarName = registrar.getRegistrarName(); - if (registrarName != null) { - // A valid fn member MUST be present: RDAP Response Profile 2.4.1. - vcardBuilder.add(Vcard.create("fn", "text", registrarName)); + vcardBuilder.add(Vcard.create("fn", "text", registrarName == null ? "(none)" : registrarName)); + + // Rdap Response Profile 3.1 says the response MUST have valid elements FN (already added), ADR, + // TEL, EMAIL. + // Other than FN (that we already added), these aren't required in INTERNAL responses. + if (outputDataType != OutputDataType.INTERNAL) { + // Rdap Response Profile 3.1.1 and 3.1.2 discuss the ADR field. See {@link + // addVcardAddressEntry} + RegistrarAddress address = registrar.getInternationalizedAddress(); + if (address == null) { + address = registrar.getLocalizedAddress(); + } + addVCardAddressEntry(vcardBuilder, address); + // TEL fields can be phone or fax + String voicePhoneNumber = registrar.getPhoneNumber(); + if (voicePhoneNumber != null) { + vcardBuilder.add(makePhoneEntry(PHONE_TYPE_VOICE, "tel:" + voicePhoneNumber)); + } + String faxPhoneNumber = registrar.getFaxNumber(); + if (faxPhoneNumber != null) { + vcardBuilder.add(makePhoneEntry(PHONE_TYPE_FAX, "tel:" + faxPhoneNumber)); + } + // EMAIL field + String emailAddress = registrar.getEmailAddress(); + if (emailAddress != null) { + vcardBuilder.add(Vcard.create("email", "text", emailAddress)); + } } - RegistrarAddress address = registrar.getInternationalizedAddress(); - if (address == null) { - address = registrar.getLocalizedAddress(); + + // RDAP Response Profile 2.4.2 and 4.3: + // The handle MUST be the IANA ID + // 4.3 also says that if no IANA ID exists (which should never be the case for a valid + // registrar), the value must be "not applicable". 2.4 doesn't discuss this possibility. + Long ianaIdentifier = registrar.getIanaIdentifier(); + builder.setHandle((ianaIdentifier == null) ? "not applicable" : ianaIdentifier.toString()); + // RDAP Response Profile 2.4.3 and 4.3: + // MUST contain a publicId member with the IANA ID + // 4.3 also says that if no IANA ID exists, the response MUST NOT contain the publicId member. + // 2.4 doesn't discuss this possibility. + if (ianaIdentifier != null) { + builder + .publicIdsBuilder() + .add(PublicId.create(PublicId.Type.IANA_REGISTRAR_ID, ianaIdentifier.toString())); + // We also add a self link if an IANA ID exists + builder.linksBuilder().add(makeSelfLink("entity", ianaIdentifier.toString())); } - addVCardAddressEntry(vcardBuilder, address); - String voicePhoneNumber = registrar.getPhoneNumber(); - if (voicePhoneNumber != null) { - vcardBuilder.add(makePhoneEntry(PHONE_TYPE_VOICE, "tel:" + voicePhoneNumber)); - } - String faxPhoneNumber = registrar.getFaxNumber(); - if (faxPhoneNumber != null) { - vcardBuilder.add(makePhoneEntry(PHONE_TYPE_FAX, "tel:" + faxPhoneNumber)); - } - String emailAddress = registrar.getEmailAddress(); - if (emailAddress != null) { - vcardBuilder.add(Vcard.create("email", "text", emailAddress)); + + // There's no mention of the registrar STATUS in the RDAP Response Profile, so we'll only add it + // for FULL response + // We could probably not add it at all, but it could be useful for us internally + if (outputDataType == OutputDataType.FULL) { + builder + .statusBuilder() + .addAll(registrar.isLive() ? STATUS_LIST_ACTIVE : STATUS_LIST_INACTIVE); } + builder.setVcardArray(vcardBuilder.build()); - // If we are outputting all data (not just summary data), also add registrar contacts. If we are - // outputting summary data, instead add a remark indicating that fact. - if (outputDataType == OutputDataType.SUMMARY) { - builder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); - } else { - builder.eventsBuilder().addAll(makeEvents(registrar)); - // include the registrar contacts as subentities - ImmutableList registrarContacts = + + // Registrar contacts are a bit complicated. + // + // Rdap Response Profile 3.2, we SHOULD have at least ADMIN and TECH contacts. It says + // nothing about ABUSE at all. + // + // Rdap Response Profile 4.3 doesn't mention contacts at all, meaning probably we don't have to + // have any contacts there. But the Registrar itself is Optional in that case, so we will just + // skip it completely. + // + // Rdap Response Profile 2.4.5 says the Registrar inside a Domain response MUST include the + // ABUSE contact, but doesn't require any other contact. + // + // In our current Datastore schema, to get the ABUSE contact we must go over all contacts. + // However, there's something to be said about returning smaller JSON + // + // TODO(b/117242274): Need to decide between 2 options: + // - Write the minimum, meaning only ABUSE for INTERNAL registrars, nothing for SUMMARY (also + // saves resources for the RegistrarContact Datastore query!) and everything for FULL. + // - Write everything for everything. + // + // For now we'll do the first. + + if (outputDataType != OutputDataType.SUMMARY) { + ImmutableList registrarContacts = registrar.getContacts().stream() .map(registrarContact -> makeRdapJsonForRegistrarContact(registrarContact)) .filter(optional -> optional.isPresent()) .map(optional -> optional.get()) + .filter( + contact -> + outputDataType == OutputDataType.FULL + || contact.roles().contains(RdapEntity.Role.ABUSE)) .collect(toImmutableList()); - // TODO(b/117242274): add a warning (severe?) log if registrar has no ABUSE contact, as having - // one is required by the RDAP response profile + if (registrarContacts.stream() + .noneMatch(contact -> contact.roles().contains(RdapEntity.Role.ABUSE))) { + logger.atWarning().log( + "Registrar '%s' (IANA ID %s) is missing ABUSE contact", + registrar.getClientId(), registrar.getIanaIdentifier()); + } builder.entitiesBuilder().addAll(registrarContacts); } + + // Rdap Response Profile 3.3, must have "last update of RDAP database" response. But this is + // only for direct query responses and not for internal objects. + if (outputDataType != OutputDataType.INTERNAL) { + builder.setLastUpdateOfRdapDatabaseEvent( + Event.builder() + .setEventAction(EventAction.LAST_UPDATE_OF_RDAP_DATABASE) + .setEventDate(getRequestTime()) + .build()); + } return builder.build(); } + /** + * Creates a JSON object for the desired registrar to an existing list of JSON objects. + * + * @param clientId the registrar client ID + * @param outputDataType whether to generate FULL, SUMMARY, or INTERNAL data. + */ + RdapRegistrarEntity createRdapRegistrarEntity(String clientId, OutputDataType outputDataType) { + Optional registrar = Registrar.loadByClientIdCached(clientId); + if (!registrar.isPresent()) { + throw new InternalServerErrorException( + String.format("Couldn't find registrar '%s'", clientId)); + } + return createRdapRegistrarEntity(registrar.get(), outputDataType); + } + /** * Creates a JSON object for a {@link RegistrarContact}. * *

Returns empty if this contact shouldn't be visible (doesn't have a role). * + *

NOTE that registrar locations in the response require different roles and different VCard + * members according to the spec. Currently, this function returns all the rolls and all the + * members for every location, but we might consider refactoring it to allow the minimal required + * roles and members. + * + *

Specifically: + *

  • Registrar inside a Domain only requires the ABUSE role, and only the TEL and EMAIL members + * (RDAP Response Profile 2.4.5) + *
  • Registrar responses to direct query don't require any contact, but *should* have the TECH + * and ADMIN roles, but require the FN, TEL and EMAIL members + *
  • Registrar inside a Nameserver isn't required at all, and if given doesn't require any + * contacts + * * @param registrarContact the registrar contact for which the JSON object should be created */ - static Optional makeRdapJsonForRegistrarContact( + static Optional makeRdapJsonForRegistrarContact( RegistrarContact registrarContact) { ImmutableList roles = makeRdapRoleList(registrarContact); if (roles.isEmpty()) { return Optional.empty(); } - RdapEntity.Builder builder = RdapEntity.builder(); + RdapContactEntity.Builder builder = RdapContactEntity.builder(); builder.statusBuilder().addAll(STATUS_LIST_ACTIVE); builder.rolesBuilder().addAll(roles); // Create the vCard. VcardArray.Builder vcardBuilder = VcardArray.builder(); + // MUST include FN member: RDAP Response Profile 3.2 String name = registrarContact.getName(); if (name != null) { vcardBuilder.add(Vcard.create("fn", "text", name)); } + // MUST include TEL and EMAIL members: RDAP Response Profile 2.4.5, 3.2 String voicePhoneNumber = registrarContact.getPhoneNumber(); if (voicePhoneNumber != null) { vcardBuilder.add(makePhoneEntry(PHONE_TYPE_VOICE, "tel:" + voicePhoneNumber)); @@ -546,9 +826,8 @@ public class RdapJsonFormatter { return RdapEntity.Role.BILLING; case ADMIN: return RdapEntity.Role.ADMIN; - default: - throw new AssertionError(); } + throw new AssertionError(); } /** @@ -561,6 +840,8 @@ public class RdapJsonFormatter { *

    2.4.5. Abuse Contact (email, phone) - an RDAP server MUST include an *entity* with the * *abuse* role within the registrar *entity* which MUST include *tel* and *email*, and MAY * include other members + * + *

    3.2. For direct Registrar queries, we SHOULD have at least "admin" and "tech". */ private static ImmutableList makeRdapRoleList( RegistrarContact registrarContact) { @@ -578,9 +859,16 @@ public class RdapJsonFormatter { } /** - * Creates an event list for a domain, host or contact resource. + * Creates the list of optional events to list in domain, nameserver, or contact replies. + * + *

    Only has entries for optional events that won't be shown in "SUMMARY" versions of these + * objects. These are either stated as optional in the RDAP Response Profile 15feb19, or not + * mentioned at all but thought to be useful anyway. + * + *

    Any required event should be added elsewhere, preferably without using HistoryEntries (so + * that we don't need to load HistoryEntries for "summary" responses). */ - private ImmutableList makeEvents(EppResource resource) { + private ImmutableList makeOptionalEvents(EppResource resource) { HashMap lastEntryOfType = Maps.newHashMap(); // Events (such as transfer, but also create) can appear multiple times. We only want the last // time they appeared. @@ -604,21 +892,9 @@ public class RdapJsonFormatter { lastEntryOfType.put(rdapEventAction, historyEntry); } ImmutableList.Builder eventsBuilder = new ImmutableList.Builder<>(); - // There are 2 possibly conflicting values for the creation time - either the - // resource.getCreationTime, or the REGISTRATION event created from a HistoryEntry - // - // We favor the HistoryEntry if it exists, since we show that value as REGISTRATION time in the - // reply, so the reply will be self-consistent. - // - // This is mostly an issue in the tests as in "reality" these two values should be the same. - // - DateTime creationTime = - Optional.ofNullable(lastEntryOfType.get(EventAction.REGISTRATION)) - .map(historyEntry -> historyEntry.getModificationTime()) - .orElse(resource.getCreationTime()); - // TODO(b/129849684) remove this and use the events List defined above once we have Event - // objects - ImmutableList.Builder changeTimesBuilder = new ImmutableList.Builder<>(); + DateTime creationTime = resource.getCreationTime(); + DateTime lastChangeTime = + resource.getLastEppUpdateTime() == null ? creationTime : resource.getLastEppUpdateTime(); // The order of the elements is stable - it's the order in which the enum elements are defined // in EventAction for (EventAction rdapEventAction : EventAction.values()) { @@ -640,54 +916,18 @@ public class RdapJsonFormatter { .setEventActor(historyEntry.getClientId()) .setEventDate(modificationTime) .build()); - changeTimesBuilder.add(modificationTime); - } - if (resource instanceof DomainBase) { - DateTime expirationTime = ((DomainBase) resource).getRegistrationExpirationTime(); - if (expirationTime != null) { - eventsBuilder.add( - Event.builder() - .setEventAction(EventAction.EXPIRATION) - .setEventDate(expirationTime) - .build()); - changeTimesBuilder.add(expirationTime); + // The last change time might not be the lastEppUpdateTime, since some changes happen without + // any EPP update (for example, by the passage of time). + if (modificationTime.isAfter(lastChangeTime) && modificationTime.isBefore(getRequestTime())) { + lastChangeTime = modificationTime; } } - if (resource.getLastEppUpdateTime() != null) { - changeTimesBuilder.add(resource.getLastEppUpdateTime()); - } - // The last change time might not be the lastEppUpdateTime, since some changes happen without - // any EPP update (for example, by the passage of time). - DateTime lastChangeTime = - changeTimesBuilder.build().stream() - .filter(changeTime -> changeTime.isBefore(getRequestTime())) - .max(DateTimeComparator.getInstance()) - .orElse(null); - if (lastChangeTime != null && lastChangeTime.isAfter(creationTime)) { + // RDAP Response Profile 15feb19 section 2.3.2.2: + // The event of eventAction type last changed MUST be omitted if the domain name has not been + // updated since it was created + if (lastChangeTime.isAfter(creationTime)) { eventsBuilder.add(makeEvent(EventAction.LAST_CHANGED, null, lastChangeTime)); } - eventsBuilder.add(makeEvent(EventAction.LAST_UPDATE_OF_RDAP_DATABASE, null, getRequestTime())); - // TODO(b/129849684): sort events by their time once we return a list of Events instead of JSON - // objects. - return eventsBuilder.build(); - } - - /** - * Creates an event list for a {@link Registrar}. - */ - private ImmutableList makeEvents(Registrar registrar) { - ImmutableList.Builder eventsBuilder = new ImmutableList.Builder<>(); - Long ianaIdentifier = registrar.getIanaIdentifier(); - eventsBuilder.add(makeEvent( - EventAction.REGISTRATION, - (ianaIdentifier == null) ? "(none)" : ianaIdentifier.toString(), - registrar.getCreationTime())); - if ((registrar.getLastUpdateTime() != null) - && registrar.getLastUpdateTime().isAfter(registrar.getCreationTime())) { - eventsBuilder.add(makeEvent( - EventAction.LAST_CHANGED, null, registrar.getLastUpdateTime())); - } - eventsBuilder.add(makeEvent(EventAction.LAST_UPDATE_OF_RDAP_DATABASE, null, getRequestTime())); return eventsBuilder.build(); } @@ -710,8 +950,11 @@ public class RdapJsonFormatter { /** * Creates a vCard address entry: array of strings specifying the components of the address. * - * @see - * RFC 7095: jCard: The JSON Format for vCard + *

    Rdap Response Profile 3.1.1: MUST contain the following fields: Street, City, Country Rdap + * Response Profile 3.1.2: optional fields: State/Province, Postal Code, Fax Number + * + * @see RFC 7095: jCard: The JSON Format for + * vCard */ private static void addVCardAddressEntry(VcardArray.Builder vcardArrayBuilder, Address address) { if (address == null) { @@ -761,7 +1004,8 @@ public class RdapJsonFormatter { addressArray.add(nullToEmpty(address.getState())); addressArray.add(nullToEmpty(address.getZip())); addressArray.add( - new Locale("en", address.getCountryCode()).getDisplayCountry(new Locale("en"))); + new Locale("en", nullToEmpty(address.getCountryCode())) + .getDisplayCountry(new Locale("en"))); vcardArrayBuilder.add(Vcard.create( "adr", "text", @@ -791,7 +1035,7 @@ public class RdapJsonFormatter { * indicate deleted objects, and as directed by the profile, the "removed" status to indicate * redacted objects. */ - private static ImmutableList makeStatusValueList( + private static ImmutableSet makeStatusValueList( ImmutableSet statusValues, boolean isRedacted, boolean isDeleted) { Stream stream = statusValues @@ -808,7 +1052,7 @@ public class RdapJsonFormatter { } return stream .sorted(Ordering.natural().onResultOf(RdapStatus::getDisplayName)) - .collect(toImmutableList()); + .collect(toImmutableSet()); } /** diff --git a/java/google/registry/rdap/RdapNameserverAction.java b/java/google/registry/rdap/RdapNameserverAction.java index 7d1468357..0bda72f8c 100644 --- a/java/google/registry/rdap/RdapNameserverAction.java +++ b/java/google/registry/rdap/RdapNameserverAction.java @@ -67,6 +67,6 @@ public class RdapNameserverAction extends RdapActionBase { if (!shouldBeVisible(hostResource)) { throw new NotFoundException(pathSearchString + " not found"); } - return rdapJsonFormatter.makeRdapJsonForHost(hostResource.get(), OutputDataType.FULL); + return rdapJsonFormatter.createRdapNameserver(hostResource.get(), OutputDataType.FULL); } } diff --git a/java/google/registry/rdap/RdapNameserverSearchAction.java b/java/google/registry/rdap/RdapNameserverSearchAction.java index 3405d1a28..9dde55df2 100644 --- a/java/google/registry/rdap/RdapNameserverSearchAction.java +++ b/java/google/registry/rdap/RdapNameserverSearchAction.java @@ -167,10 +167,9 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase { NameserverSearchResponse.Builder builder = NameserverSearchResponse.builder() .setIncompletenessWarningType(IncompletenessWarningType.COMPLETE); - builder.nameserverSearchResultsBuilder().add( - rdapJsonFormatter.makeRdapJsonForHost( - hostResource.get(), - OutputDataType.FULL)); + builder + .nameserverSearchResultsBuilder() + .add(rdapJsonFormatter.createRdapNameserver(hostResource.get(), OutputDataType.FULL)); return builder.build(); } @@ -230,8 +229,7 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase { getDeletedItemHandling(), querySizeLimit); return makeSearchResults( - getMatchingResources(query, shouldIncludeDeleted(), querySizeLimit), - CursorType.NAME); + getMatchingResources(query, shouldIncludeDeleted(), querySizeLimit), CursorType.NAME); } /** Searches for nameservers by IP address, returning a JSON array of nameserver info maps. */ @@ -248,8 +246,7 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase { getDeletedItemHandling(), querySizeLimit); return makeSearchResults( - getMatchingResources(query, shouldIncludeDeleted(), querySizeLimit), - CursorType.ADDRESS); + getMatchingResources(query, shouldIncludeDeleted(), querySizeLimit), CursorType.ADDRESS); } /** Output JSON for a lists of hosts contained in an {@link RdapResultSet}. */ @@ -282,7 +279,7 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase { : host.getRepoId()); builder .nameserverSearchResultsBuilder() - .add(rdapJsonFormatter.makeRdapJsonForHost(host, outputDataType)); + .add(rdapJsonFormatter.createRdapNameserver(host, outputDataType)); } if (rdapResultSetMaxSize < hosts.size()) { builder.setNextPageUri(createNavigationUri(newCursor.get())); diff --git a/java/google/registry/rdap/RdapObjectClasses.java b/java/google/registry/rdap/RdapObjectClasses.java index b989431e7..fdc7191ef 100644 --- a/java/google/registry/rdap/RdapObjectClasses.java +++ b/java/google/registry/rdap/RdapObjectClasses.java @@ -25,6 +25,7 @@ import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import google.registry.model.domain.secdns.DelegationSignerData; import google.registry.rdap.AbstractJsonableObject.RestrictJsonNames; import google.registry.rdap.RdapDataStructures.Event; import google.registry.rdap.RdapDataStructures.EventWithoutActor; @@ -149,7 +150,6 @@ final class RdapObjectClasses { * * All Actions need to return an object of this type. */ - @RestrictJsonNames("*") abstract static class ReplyPayloadBase extends AbstractJsonableObject { final BoilerplateType boilerplateType; @@ -207,6 +207,18 @@ final class RdapObjectClasses { @JsonableElement abstract ImmutableList links(); @JsonableElement abstract ImmutableList events(); + /** + * Required event for all response objects, but not for internal objects. + * + *

    Meaning it's required in, e.g., an RdapNameserver object that is a response to a + * Nameserver query, but not to an RdapNameserver that's part of an RdapDomain response to a + * Domain query. + * + *

    RDAP Response Profile 2.3.1.3, 3.3, 4.4 + */ + @JsonableElement("events[]") + abstract Optional lastUpdateOfRdapDatabaseEvent(); + /** * WHOIS server displayed in RDAP query responses. * @@ -232,16 +244,20 @@ final class RdapObjectClasses { abstract ImmutableList.Builder linksBuilder(); abstract B setPort43(Port43WhoisServer port43); abstract ImmutableList.Builder eventsBuilder(); + + abstract B setLastUpdateOfRdapDatabaseEvent(Event event); } } /** * The Entity Object Class defined in 5.1 of RFC7483. * + *

    Entities are used both for Contacts and for Registrars. We will create different subobjects + * for each one for type safety. + * *

    We're missing the "autnums" and "networks" fields */ @RestrictJsonNames({"entities[]", "entitySearchResults[]"}) - @AutoValue abstract static class RdapEntity extends RdapObjectBase { /** Role values specified in RFC 7483 § 10.2.4. */ @@ -280,17 +296,50 @@ final class RdapObjectClasses { @JsonableElement abstract ImmutableSet roles(); @JsonableElement abstract ImmutableList asEventActor(); + private abstract static class Builder> extends RdapObjectBase.Builder { + abstract B setVcardArray(VcardArray vcardArray); + + abstract ImmutableSet.Builder rolesBuilder(); + + abstract ImmutableList.Builder asEventActorBuilder(); + } + } + + /** + * Registrar version of the Entity Object Class defined in 5.1 of RFC7483. + * + *

    Entities are used both for Contacts and for Registrars. We will create different subobjects + * for each one for type safety. + */ + @AutoValue + abstract static class RdapRegistrarEntity extends RdapEntity { + static Builder builder() { - return new AutoValue_RdapObjectClasses_RdapEntity.Builder(); + return new AutoValue_RdapObjectClasses_RdapRegistrarEntity.Builder(); } @AutoValue.Builder - abstract static class Builder extends RdapObjectBase.Builder { - abstract Builder setVcardArray(VcardArray vcardArray); - abstract ImmutableSet.Builder rolesBuilder(); - abstract ImmutableList.Builder asEventActorBuilder(); + abstract static class Builder extends RdapEntity.Builder { + abstract RdapRegistrarEntity build(); + } + } - abstract RdapEntity build(); + /** + * Contact version of the Entity Object Class defined in 5.1 of RFC7483. + * + *

    Entities are used both for Contacts and for Registrars. We will create different subobjects + * for each one for type safety. + */ + @AutoValue + abstract static class RdapContactEntity extends RdapEntity { + + static Builder builder() { + return new AutoValue_RdapObjectClasses_RdapContactEntity.Builder(); + } + + @AutoValue.Builder + abstract static class Builder extends RdapEntity.Builder { + abstract RdapContactEntity build(); } } @@ -301,6 +350,10 @@ final class RdapObjectClasses { * *

    See RDAP Response Profile 15feb19 sections 2.1 and 4.1. * + *

    Note the ldhName field is only required for non-IDN names or IDN names when the query was an + * A-label. It is optional for IDN names when the query was a U-label. Because we don't want to + * remember the query when building the results, we always show it. + * *

    Not part of the spec, but seems convenient. */ private abstract static class RdapNamedObjectBase extends RdapObjectBase { @@ -374,7 +427,80 @@ final class RdapObjectClasses { abstract RdapNameserver build(); } + } + /** Object defined in RFC7483 section 5.3, only used for RdapDomain. */ + @RestrictJsonNames("secureDNS") + @AutoValue + abstract static class SecureDns extends AbstractJsonableObject { + @RestrictJsonNames("dsData[]") + @AutoValue + abstract static class DsData extends AbstractJsonableObject { + @JsonableElement + abstract int keyTag(); + + @JsonableElement + abstract int algorithm(); + + @JsonableElement + abstract String digest(); + + @JsonableElement + abstract int digestType(); + + static DsData create(DelegationSignerData dsData) { + return new AutoValue_RdapObjectClasses_SecureDns_DsData( + dsData.getKeyTag(), + dsData.getAlgorithm(), + dsData.getDigestAsString(), + dsData.getDigestType()); + } + } + + /** true if the zone has been signed, false otherwise. */ + @JsonableElement + abstract boolean zoneSigned(); + + /** true if there are DS records in the parent, false otherwise. */ + @JsonableElement + boolean delegationSigned() { + return !dsData().isEmpty(); + } + + /** + * an integer representing the signature lifetime in seconds to be used when creating the RRSIG + * DS record in the parent zone [RFC5910]. + * + *

    Note that although it isn't given as optional in RFC7483, in RFC5910 it's mentioned as + * optional. Also, our code doesn't support it at all - so it's set to always be empty. + */ + @JsonableElement + Optional maxSigLife() { + return Optional.empty(); + } + + @JsonableElement + abstract ImmutableList dsData(); + + static Builder builder() { + return new AutoValue_RdapObjectClasses_SecureDns.Builder(); + } + + abstract Builder toBuilder(); + + @AutoValue.Builder + abstract static class Builder { + abstract Builder setZoneSigned(boolean zoneSigned); + + abstract ImmutableList.Builder dsDataBuilder(); + + Builder addDsData(DelegationSignerData dsData) { + dsDataBuilder().add(DsData.create(dsData)); + return this; + } + + abstract SecureDns build(); + } } /** @@ -388,6 +514,9 @@ final class RdapObjectClasses { @JsonableElement abstract ImmutableList nameservers(); + @JsonableElement("secureDNS") + abstract Optional secureDns(); + RdapDomain() { super(BoilerplateType.DOMAIN, ObjectClassName.DOMAIN); } @@ -400,6 +529,8 @@ final class RdapObjectClasses { abstract static class Builder extends RdapNamedObjectBase.Builder { abstract ImmutableList.Builder nameserversBuilder(); + abstract Builder setSecureDns(SecureDns secureDns); + abstract RdapDomain build(); } } diff --git a/java/google/registry/request/RequestModule.java b/java/google/registry/request/RequestModule.java index 0725eab57..5d0eb9ac7 100644 --- a/java/google/registry/request/RequestModule.java +++ b/java/google/registry/request/RequestModule.java @@ -49,8 +49,7 @@ public final class RequestModule { private final AuthResult authResult; @VisibleForTesting - public RequestModule( - HttpServletRequest req, HttpServletResponse rsp) { + public RequestModule(HttpServletRequest req, HttpServletResponse rsp) { this(req, rsp, AuthResult.NOT_AUTHENTICATED); } diff --git a/javatests/google/registry/model/domain/DomainBaseTest.java b/javatests/google/registry/model/domain/DomainBaseTest.java index 8be180275..6090c227c 100644 --- a/javatests/google/registry/model/domain/DomainBaseTest.java +++ b/javatests/google/registry/model/domain/DomainBaseTest.java @@ -207,7 +207,7 @@ public class DomainBaseTest extends EntityTestCase { .isNotNull(); // This behavior should also hold true for ImmutableObjects nested in collections. assertThat(newDomainBase("example.com").asBuilder() - .setDsData(ImmutableSet.of(DelegationSignerData.create(1, 1, 1, null))) + .setDsData(ImmutableSet.of(DelegationSignerData.create(1, 1, 1, (byte[]) null))) .build().getDsData().asList().get(0).getDigest()) .isNull(); assertThat(newDomainBase("example.com").asBuilder() diff --git a/javatests/google/registry/rdap/RdapActionBaseTest.java b/javatests/google/registry/rdap/RdapActionBaseTest.java index e69b9d894..86d8e29e9 100644 --- a/javatests/google/registry/rdap/RdapActionBaseTest.java +++ b/javatests/google/registry/rdap/RdapActionBaseTest.java @@ -16,6 +16,8 @@ package google.registry.rdap; import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN; import static com.google.common.truth.Truth.assertThat; +import static google.registry.rdap.RdapTestHelper.loadJsonFile; +import static google.registry.rdap.RdapTestHelper.parseJsonObject; import static google.registry.request.Action.Method.GET; import static google.registry.request.Action.Method.HEAD; import static google.registry.testing.DatastoreHelper.createTld; diff --git a/javatests/google/registry/rdap/RdapActionBaseTestCase.java b/javatests/google/registry/rdap/RdapActionBaseTestCase.java index 2d801da32..2b415114c 100644 --- a/javatests/google/registry/rdap/RdapActionBaseTestCase.java +++ b/javatests/google/registry/rdap/RdapActionBaseTestCase.java @@ -20,12 +20,9 @@ import static google.registry.rdap.RdapAuthorization.Role.PUBLIC; import static google.registry.rdap.RdapAuthorization.Role.REGISTRAR; import static google.registry.request.Action.Method.GET; import static google.registry.request.Action.Method.HEAD; -import static google.registry.testing.TestDataHelper.loadFile; import static org.mockito.Mockito.mock; import com.google.appengine.api.users.User; -import com.google.common.collect.ImmutableMap; -import com.google.gson.Gson; import com.google.gson.JsonObject; import google.registry.model.ofy.Ofy; import google.registry.request.Action; @@ -37,8 +34,9 @@ import google.registry.testing.AppEngineRule; import google.registry.testing.FakeClock; import google.registry.testing.FakeResponse; import google.registry.testing.InjectRule; +import google.registry.util.Idn; import google.registry.util.TypeUtils; -import java.util.Map; +import java.util.HashMap; import java.util.Optional; import org.joda.time.DateTime; import org.junit.Before; @@ -78,8 +76,6 @@ public class RdapActionBaseTestCase { protected final String actionPath; protected final Class rdapActionClass; - private static final Gson GSON = new Gson(); - protected RdapActionBaseTestCase(Class rdapActionClass) { this.rdapActionClass = rdapActionClass; this.actionPath = Actions.getPathForAction(rdapActionClass); @@ -117,15 +113,11 @@ public class RdapActionBaseTestCase { metricRole = ADMINISTRATOR; } - protected static JsonObject parseJsonObject(String jsonString) { - return GSON.fromJson(jsonString, JsonObject.class); - } - protected JsonObject generateActualJson(String domainName) { action.requestPath = actionPath + domainName; action.requestMethod = GET; action.run(); - return parseJsonObject(response.getPayload()); + return RdapTestHelper.parseJsonObject(response.getPayload()); } protected String generateHeadPayload(String domainName) { @@ -135,38 +127,6 @@ public class RdapActionBaseTestCase { return response.getPayload(); } - /** - * Loads a resource testdata JSON file, and applies substitutions. - * - *

    {@code loadJsonFile("filename.json", "NANE", "something", "ID", "other")} is the same as - * {@code loadJsonFile("filename.json", ImmutableMap.of("NANE", "something", "ID", "other"))}. - * - * @param filename the name of the file from the testdata directory - * @param keysAndValues alternating substitution key and value. The substitutions are applied to - * the file before parsing it to JSON. - */ - protected static JsonObject loadJsonFile(String filename, String... keysAndValues) { - checkArgument(keysAndValues.length % 2 == 0); - ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); - for (int i = 0; i < keysAndValues.length; i += 2) { - if (keysAndValues[i + 1] != null) { - builder.put(keysAndValues[i], keysAndValues[i + 1]); - } - } - return loadJsonFile(filename, builder.build()); - } - - /** - * Loads a resource testdata JSON file, and applies substitutions. - * - * @param filename the name of the file from the testdata directory - * @param substitutions map of substitutions to apply to the file. The substitutions are applied - * to the file before parsing it to JSON. - */ - protected static JsonObject loadJsonFile(String filename, Map substitutions) { - return parseJsonObject(loadFile(RdapActionBaseTestCase.class, filename, substitutions)); - } - protected JsonObject generateExpectedJsonError( String description, int code) { @@ -191,10 +151,75 @@ public class RdapActionBaseTestCase { title = "ERR"; break; } - return loadJsonFile( + return RdapTestHelper.loadJsonFile( "rdap_error.json", "DESCRIPTION", description, "TITLE", title, "CODE", String.valueOf(code)); } + + protected static JsonFileBuilder jsonFileBuilder() { + return new JsonFileBuilder(); + } + + protected static final class JsonFileBuilder { + private final HashMap substitutions = new HashMap<>(); + + public JsonObject load(String filename) { + return RdapTestHelper.loadJsonFile(filename, substitutions); + } + + public JsonFileBuilder put(String key, String value) { + checkArgument( + substitutions.put(key, value) == null, "substitutions already had key of %s", key); + return this; + } + + public JsonFileBuilder put(String key, int index, String value) { + return put(String.format("%s%d", key, index), value); + } + + public JsonFileBuilder putNext(String key, String value, String... moreKeyValues) { + checkArgument(moreKeyValues.length % 2 == 0); + int index = putNextAndReturnIndex(key, value); + for (int i = 0; i < moreKeyValues.length; i += 2) { + put(moreKeyValues[i], index, moreKeyValues[i + 1]); + } + return this; + } + + public JsonFileBuilder addDomain(String name, String handle) { + return putNext( + "DOMAIN_PUNYCODE_NAME_", Idn.toASCII(name), + "DOMAIN_UNICODE_NAME_", name, + "DOMAIN_HANDLE_", handle); + } + + public JsonFileBuilder addNameserver(String name, String handle) { + return putNext( + "NAMESERVER_NAME_", Idn.toASCII(name), + "NAMESERVER_UNICODE_NAME_", name, + "NAMESERVER_HANDLE_", handle); + } + + public JsonFileBuilder addRegistrar(String fullName) { + return putNext("REGISTRAR_FULL_NAME_", fullName); + } + + public JsonFileBuilder addContact(String handle) { + return putNext("CONTACT_HANDLE_", handle); + } + + public JsonFileBuilder setNextQuery(String nextQuery) { + return put("NEXT_QUERY", nextQuery); + } + + private int putNextAndReturnIndex(String key, String value) { + for (int i = 1; ; i++) { + if (substitutions.putIfAbsent(String.format("%s%d", key, i), value) == null) { + return i; + } + } + } + } } diff --git a/javatests/google/registry/rdap/RdapDataStructuresTest.java b/javatests/google/registry/rdap/RdapDataStructuresTest.java index 4bc8ffff5..89870123b 100644 --- a/javatests/google/registry/rdap/RdapDataStructuresTest.java +++ b/javatests/google/registry/rdap/RdapDataStructuresTest.java @@ -45,7 +45,7 @@ public final class RdapDataStructuresTest { @Test public void testRdapConformance() { assertThat(RdapConformance.INSTANCE.toJson()) - .isEqualTo(createJson("['icann_rdap_response_profile_0']")); + .isEqualTo(createJson("['rdap_level_0','icann_rdap_response_profile_0']")); } @Test diff --git a/javatests/google/registry/rdap/RdapDomainActionTest.java b/javatests/google/registry/rdap/RdapDomainActionTest.java index 3fb688de9..f03a4fd6f 100644 --- a/javatests/google/registry/rdap/RdapDomainActionTest.java +++ b/javatests/google/registry/rdap/RdapDomainActionTest.java @@ -15,6 +15,7 @@ package google.registry.rdap; import static com.google.common.truth.Truth.assertThat; +import static google.registry.rdap.RdapTestHelper.assertThat; import static google.registry.testing.DatastoreHelper.createTld; import static google.registry.testing.DatastoreHelper.persistResource; import static google.registry.testing.DatastoreHelper.persistSimpleResources; @@ -26,8 +27,6 @@ import static google.registry.testing.FullFieldsTestEntityHelper.makeRegistrar; import static google.registry.testing.FullFieldsTestEntityHelper.makeRegistrarContacts; import static org.mockito.Mockito.verify; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; import com.google.gson.JsonObject; import google.registry.model.contact.ContactResource; import google.registry.model.domain.DomainBase; @@ -41,9 +40,7 @@ import google.registry.rdap.RdapMetrics.SearchType; import google.registry.rdap.RdapMetrics.WildcardType; import google.registry.rdap.RdapSearchResults.IncompletenessWarningType; import google.registry.request.Action; -import java.util.List; import java.util.Optional; -import javax.annotation.Nullable; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; @@ -90,37 +87,54 @@ public class RdapDomainActionTest extends RdapActionBaseTestCase contactRoids, - @Nullable List nameserverRoids, - @Nullable List nameserverNames, - @Nullable String registrarName) { - ImmutableMap.Builder substitutionsBuilder = new ImmutableMap.Builder<>(); - substitutionsBuilder.put("NAME", name); - substitutionsBuilder.put("PUNYCODENAME", (punycodeName == null) ? name : punycodeName); - substitutionsBuilder.put("HANDLE", handle); - substitutionsBuilder.put("TYPE", "domain name"); - substitutionsBuilder.put("NAMESERVER1ADDRESS", "1.2.3.4"); - substitutionsBuilder.put("NAMESERVER2ADDRESS", "bad:f00d:cafe::15:beef"); - if (registrarName != null) { - substitutionsBuilder.put("REGISTRARNAME", registrarName); - } - if (contactRoids != null) { - for (int i = 0; i < contactRoids.size(); i++) { - substitutionsBuilder.put("CONTACT" + (i + 1) + "ROID", contactRoids.get(i)); - } - } - if (nameserverRoids != null) { - for (int i = 0; i < nameserverRoids.size(); i++) { - substitutionsBuilder.put("NAMESERVER" + (i + 1) + "ROID", nameserverRoids.get(i)); - } - } else { - substitutionsBuilder.put("NAMESERVER1ROID", "8-ROID"); - substitutionsBuilder.put("NAMESERVER2ROID", "A-ROID"); - } - if (nameserverNames != null) { - for (int i = 0; i < nameserverRoids.size(); i++) { - substitutionsBuilder.put("NAMESERVER" + (i + 1) + "NAME", nameserverNames.get(i)); - substitutionsBuilder.put("NAMESERVER" + (i + 1) + "PUNYCODENAME", nameserverNames.get(i)); - } - } else { - substitutionsBuilder.put("NAMESERVER1NAME", "ns1.cat.lol"); - substitutionsBuilder.put("NAMESERVER1PUNYCODENAME", "ns1.cat.lol"); - substitutionsBuilder.put("NAMESERVER2NAME", "ns2.cat.lol"); - substitutionsBuilder.put("NAMESERVER2PUNYCODENAME", "ns2.cat.lol"); - } - return loadJsonFile(expectedOutputFile, substitutionsBuilder.build()); - } - - private JsonObject generateExpectedJsonWithTopLevelEntries( - String name, - String punycodeName, - String handle, - @Nullable List contactRoids, - @Nullable List nameserverRoids, - @Nullable String registrarName, - String expectedOutputFile) { - return generateExpectedJsonWithTopLevelEntries( - name, - punycodeName, - handle, - contactRoids, - nameserverRoids, - null, - registrarName, - expectedOutputFile); - } - private JsonObject generateExpectedJsonWithTopLevelEntries( - String name, - String punycodeName, - String handle, - @Nullable List contactRoids, - @Nullable List nameserverRoids, - @Nullable List nameserverNames, - @Nullable String registrarName, - String expectedOutputFile) { - JsonObject obj = - generateExpectedJson( - expectedOutputFile, - name, - punycodeName, - handle, - contactRoids, - nameserverRoids, - nameserverNames, - registrarName); + private JsonObject addBoilerplate(JsonObject obj) { RdapTestHelper.addDomainBoilerplateNotices(obj, "https://example.tld/rdap/"); return obj; } @@ -316,14 +232,16 @@ public class RdapDomainActionTest extends RdapActionBaseTestCase", - expectedOutputFile)); + addBoilerplate( + jsonFileBuilder() + .addDomain("cat.lol", "C-LOL") + .addContact("4-ROID") + .addContact("6-ROID") + .addContact("2-ROID") + .addNameserver("ns1.cat.lol", "8-ROID") + .addNameserver("ns2.cat.lol", "A-ROID") + .addRegistrar("Yes Virginia