diff --git a/core/src/main/java/google/registry/flows/domain/DomainRenewFlow.java b/core/src/main/java/google/registry/flows/domain/DomainRenewFlow.java index 901c2baf0..88b8a313c 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainRenewFlow.java +++ b/core/src/main/java/google/registry/flows/domain/DomainRenewFlow.java @@ -30,6 +30,7 @@ import static google.registry.flows.domain.DomainFlowUtils.validateFeeChallenge; import static google.registry.flows.domain.DomainFlowUtils.validateRegistrationPeriod; import static google.registry.flows.domain.DomainFlowUtils.verifyRegistrarIsActive; import static google.registry.flows.domain.DomainFlowUtils.verifyUnitIsYears; +import static google.registry.flows.domain.token.AllocationTokenFlowUtils.verifyTokenAllowedOnDomain; import static google.registry.model.reporting.HistoryEntry.Type.DOMAIN_RENEW; import static google.registry.persistence.transaction.TransactionManagerFactory.tm; import static google.registry.util.DateTimeUtils.leapSafeAddYears; @@ -119,6 +120,10 @@ import org.joda.time.Duration; * @error {@link DomainFlowUtils.UnsupportedFeeAttributeException} * @error {@link DomainRenewFlow.IncorrectCurrentExpirationDateException} * @error {@link + * google.registry.flows.domain.token.AllocationTokenFlowUtils.MissingRemovePackageTokenOnPackageDomainException} + * @error {@link + * google.registry.flows.domain.token.AllocationTokenFlowUtils.RemovePackageTokenOnNonPackageDomainException} + * @error {@link * google.registry.flows.domain.token.AllocationTokenFlowUtils.AllocationTokenNotValidForDomainException} * @error {@link * google.registry.flows.domain.token.AllocationTokenFlowUtils.InvalidAllocationTokenException} @@ -174,7 +179,7 @@ public final class DomainRenewFlow implements TransactionalFlow { registrarId, now, eppInput.getSingleExtension(AllocationTokenExtension.class)); - verifyRenewAllowed(authInfo, existingDomain, command); + verifyRenewAllowed(authInfo, existingDomain, command, allocationToken); int years = command.getPeriod().getValue(); DateTime newExpirationTime = leapSafeAddYears(existingDomain.getRegistrationExpirationTime(), years); // Uncapped @@ -302,10 +307,16 @@ public final class DomainRenewFlow implements TransactionalFlow { .build(); } - private void verifyRenewAllowed(Optional authInfo, Domain existingDomain, Renew command) + private void verifyRenewAllowed( + Optional authInfo, + Domain existingDomain, + Renew command, + Optional allocationToken) throws EppException { verifyOptionalAuthInfo(authInfo, existingDomain); verifyNoDisallowedStatuses(existingDomain, RENEW_DISALLOWED_STATUSES); + // We only allow __REMOVE_PACKAGE__ token on promo package domains for now + verifyTokenAllowedOnDomain(existingDomain, allocationToken); if (!isSuperuser) { verifyResourceOwnership(registrarId, existingDomain); checkAllowedAccessToTld(registrarId, existingDomain.getTld()); diff --git a/core/src/main/java/google/registry/flows/domain/token/AllocationTokenFlowUtils.java b/core/src/main/java/google/registry/flows/domain/token/AllocationTokenFlowUtils.java index a4a466165..02690f570 100644 --- a/core/src/main/java/google/registry/flows/domain/token/AllocationTokenFlowUtils.java +++ b/core/src/main/java/google/registry/flows/domain/token/AllocationTokenFlowUtils.java @@ -29,6 +29,7 @@ import google.registry.flows.EppException.StatusProhibitsOperationException; import google.registry.model.domain.Domain; import google.registry.model.domain.DomainCommand; import google.registry.model.domain.token.AllocationToken; +import google.registry.model.domain.token.AllocationToken.TokenBehavior; import google.registry.model.domain.token.AllocationToken.TokenStatus; import google.registry.model.domain.token.AllocationToken.TokenType; import google.registry.model.domain.token.AllocationTokenExtension; @@ -109,23 +110,27 @@ public class AllocationTokenFlowUtils { private void validateToken( InternetDomainName domainName, AllocationToken token, String registrarId, DateTime now) throws EppException { - if (!token.getAllowedRegistrarIds().isEmpty() - && !token.getAllowedRegistrarIds().contains(registrarId)) { - throw new AllocationTokenNotValidForRegistrarException(); - } - if (!token.getAllowedTlds().isEmpty() - && !token.getAllowedTlds().contains(domainName.parent().toString())) { - throw new AllocationTokenNotValidForTldException(); - } - if (token.getDomainName().isPresent() - && !token.getDomainName().get().equals(domainName.toString())) { - throw new AllocationTokenNotValidForDomainException(); - } - // Tokens without status transitions will just have a single-entry NOT_STARTED map, so only - // check the status transitions map if it's non-trivial. - if (token.getTokenStatusTransitions().size() > 1 - && !TokenStatus.VALID.equals(token.getTokenStatusTransitions().getValueAtTime(now))) { - throw new AllocationTokenNotInPromotionException(); + + // Only tokens with default behavior require validation + if (TokenBehavior.DEFAULT.equals(token.getTokenBehavior())) { + if (!token.getAllowedRegistrarIds().isEmpty() + && !token.getAllowedRegistrarIds().contains(registrarId)) { + throw new AllocationTokenNotValidForRegistrarException(); + } + if (!token.getAllowedTlds().isEmpty() + && !token.getAllowedTlds().contains(domainName.parent().toString())) { + throw new AllocationTokenNotValidForTldException(); + } + if (token.getDomainName().isPresent() + && !token.getDomainName().get().equals(domainName.toString())) { + throw new AllocationTokenNotValidForDomainException(); + } + // Tokens without status transitions will just have a single-entry NOT_STARTED map, so only + // check the status transitions map if it's non-trivial. + if (token.getTokenStatusTransitions().size() > 1 + && !TokenStatus.VALID.equals(token.getTokenStatusTransitions().getValueAtTime(now))) { + throw new AllocationTokenNotInPromotionException(); + } } } @@ -137,8 +142,15 @@ public class AllocationTokenFlowUtils { // See https://tools.ietf.org/html/draft-ietf-regext-allocation-token-04#section-2.1 throw new InvalidAllocationTokenException(); } - Optional maybeTokenEntity = + + Optional maybeTokenEntity = AllocationToken.maybeGetStaticTokenInstance(token); + if (maybeTokenEntity.isPresent()) { + return maybeTokenEntity.get(); + } + + maybeTokenEntity = tm().transact(() -> tm().loadByKeyIfPresent(VKey.create(AllocationToken.class, token))); + if (!maybeTokenEntity.isPresent()) { throw new InvalidAllocationTokenException(); } @@ -187,6 +199,21 @@ public class AllocationTokenFlowUtils { tokenCustomLogic.validateToken(existingDomain, tokenEntity, registry, registrarId, now)); } + public static void verifyTokenAllowedOnDomain( + Domain domain, Optional allocationToken) throws EppException { + + boolean domainHasPackageToken = domain.getCurrentPackageToken().isPresent(); + boolean hasRemovePackageToken = + allocationToken.isPresent() + && TokenBehavior.REMOVE_PACKAGE.equals(allocationToken.get().getTokenBehavior()); + + if (hasRemovePackageToken && !domainHasPackageToken) { + throw new RemovePackageTokenOnNonPackageDomainException(); + } else if (!hasRemovePackageToken && domainHasPackageToken) { + throw new MissingRemovePackageTokenOnPackageDomainException(); + } + } + // Note: exception messages should be <= 32 characters long for domain check results /** The allocation token is not currently valid. */ @@ -234,4 +261,20 @@ public class AllocationTokenFlowUtils { super("The allocation token is invalid"); } } + + /** The __REMOVEPACKAGE__ token is missing on a renew package domain command */ + public static class MissingRemovePackageTokenOnPackageDomainException + extends AssociationProhibitsOperationException { + MissingRemovePackageTokenOnPackageDomainException() { + super("Domains that are inside packages cannot be explicitly renewed"); + } + } + + /** The __REMOVEPACKAGE__ token is not allowed on non package domains */ + public static class RemovePackageTokenOnNonPackageDomainException + extends AssociationProhibitsOperationException { + RemovePackageTokenOnNonPackageDomainException() { + super("__REMOVEPACKAGE__ token is not allowed on non package domains"); + } + } } diff --git a/core/src/main/java/google/registry/model/domain/token/AllocationToken.java b/core/src/main/java/google/registry/model/domain/token/AllocationToken.java index 23d8b1b86..eb80e87d8 100644 --- a/core/src/main/java/google/registry/model/domain/token/AllocationToken.java +++ b/core/src/main/java/google/registry/model/domain/token/AllocationToken.java @@ -26,6 +26,7 @@ import static google.registry.util.PreconditionsUtils.checkArgumentNotNull; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedMap; @@ -79,6 +80,10 @@ import org.joda.time.DateTime; public class AllocationToken extends BackupGroupRoot implements Buildable { private static final long serialVersionUID = -3954475393220876903L; + private static final String REMOVE_PACKAGE = "__REMOVEPACKAGE__"; + + private static final ImmutableMap STATIC_TOKEN_BEHAVIORS = + ImmutableMap.of(REMOVE_PACKAGE, TokenBehavior.REMOVE_PACKAGE); // Promotions should only move forward, and ENDED / CANCELLED are terminal states. private static final ImmutableMultimap VALID_TOKEN_STATUS_TRANSITIONS = @@ -87,6 +92,18 @@ public class AllocationToken extends BackupGroupRoot implements Buildable { .putAll(VALID, ENDED, CANCELLED) .build(); + private static final ImmutableMap BEHAVIORAL_TOKENS = + ImmutableMap.of( + REMOVE_PACKAGE, + new AllocationToken.Builder() + .setTokenType(TokenType.UNLIMITED_USE) + .setToken(REMOVE_PACKAGE) + .build()); + + public static Optional maybeGetStaticTokenInstance(String name) { + return Optional.ofNullable(BEHAVIORAL_TOKENS.get(name)); + } + /** Any special behavior that should be used when registering domains using this token. */ public enum RegistrationBehavior { /** No special behavior */ @@ -110,7 +127,21 @@ public class AllocationToken extends BackupGroupRoot implements Buildable { public enum TokenType { PACKAGE, SINGLE_USE, - UNLIMITED_USE + UNLIMITED_USE, + } + + /** + * System behaves differently based on a token it gets inside a command. This enumerates different + * types of behaviors we support. + */ + public enum TokenBehavior { + /** No special behavior */ + DEFAULT, + /** + * REMOVE_PACKAGE triggers domain removal from promotional package, bypasses DEFAULT token + * validations. + */ + REMOVE_PACKAGE } /** The status of this token with regard to any potential promotion. */ @@ -255,6 +286,10 @@ public class AllocationToken extends BackupGroupRoot implements Buildable { return registrationBehavior; } + public TokenBehavior getTokenBehavior() { + return STATIC_TOKEN_BEHAVIORS.getOrDefault(token, TokenBehavior.DEFAULT); + } + @Override public VKey createVKey() { return VKey.create(AllocationToken.class, getToken(), Key.create(this)); diff --git a/core/src/test/java/google/registry/flows/domain/DomainRenewFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainRenewFlowTest.java index b25d0d173..0c52a0f7f 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainRenewFlowTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainRenewFlowTest.java @@ -20,6 +20,7 @@ import static google.registry.flows.domain.DomainTransferFlowTestCase.persistWit import static google.registry.model.billing.BillingEvent.RenewalPriceBehavior.DEFAULT; import static google.registry.model.billing.BillingEvent.RenewalPriceBehavior.NONPREMIUM; import static google.registry.model.billing.BillingEvent.RenewalPriceBehavior.SPECIFIED; +import static google.registry.model.domain.token.AllocationToken.TokenType.PACKAGE; import static google.registry.model.domain.token.AllocationToken.TokenType.SINGLE_USE; import static google.registry.model.domain.token.AllocationToken.TokenType.UNLIMITED_USE; import static google.registry.persistence.transaction.TransactionManagerFactory.tm; @@ -73,6 +74,8 @@ import google.registry.flows.domain.token.AllocationTokenFlowUtils.AllocationTok import google.registry.flows.domain.token.AllocationTokenFlowUtils.AllocationTokenNotValidForTldException; import google.registry.flows.domain.token.AllocationTokenFlowUtils.AlreadyRedeemedAllocationTokenException; import google.registry.flows.domain.token.AllocationTokenFlowUtils.InvalidAllocationTokenException; +import google.registry.flows.domain.token.AllocationTokenFlowUtils.MissingRemovePackageTokenOnPackageDomainException; +import google.registry.flows.domain.token.AllocationTokenFlowUtils.RemovePackageTokenOnNonPackageDomainException; import google.registry.flows.exceptions.ResourceStatusProhibitsOperationException; import google.registry.model.billing.BillingEvent; import google.registry.model.billing.BillingEvent.Flag; @@ -591,7 +594,8 @@ class DomainRenewFlowTest extends ResourceFlowTestCase @Test void testSuccess_allocationToken() throws Exception { setEppInput( - "domain_renew_allocationtoken.xml", ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2")); + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "abc123")); persistDomain(); AllocationToken allocationToken = persistResource( @@ -611,7 +615,8 @@ class DomainRenewFlowTest extends ResourceFlowTestCase @Test void testSuccess_allocationTokenMultiUse() throws Exception { setEppInput( - "domain_renew_allocationtoken.xml", ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2")); + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "abc123")); persistDomain(); persistResource( new AllocationToken.Builder().setToken("abc123").setTokenType(UNLIMITED_USE).build()); @@ -622,7 +627,7 @@ class DomainRenewFlowTest extends ResourceFlowTestCase clock.advanceOneMilli(); setEppInput( "domain_renew_allocationtoken.xml", - ImmutableMap.of("DOMAIN", "other-example.tld", "YEARS", "2")); + ImmutableMap.of("DOMAIN", "other-example.tld", "YEARS", "2", "TOKEN", "abc123")); persistDomain(); runFlowAssertResponse( loadFile( @@ -633,7 +638,8 @@ class DomainRenewFlowTest extends ResourceFlowTestCase @Test void testFailure_invalidAllocationToken() throws Exception { setEppInput( - "domain_renew_allocationtoken.xml", ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2")); + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "abc123")); persistDomain(); EppException thrown = assertThrows(InvalidAllocationTokenException.class, this::runFlow); assertAboutEppExceptions().that(thrown).marshalsToXml(); @@ -642,7 +648,8 @@ class DomainRenewFlowTest extends ResourceFlowTestCase @Test void testFailure_allocationTokenIsForADifferentDomain() throws Exception { setEppInput( - "domain_renew_allocationtoken.xml", ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2")); + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "abc123")); persistDomain(); persistResource( new AllocationToken.Builder() @@ -660,7 +667,8 @@ class DomainRenewFlowTest extends ResourceFlowTestCase @Test void testFailure_promotionNotActive() throws Exception { setEppInput( - "domain_renew_allocationtoken.xml", ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2")); + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "abc123")); persistDomain(); persistResource( new AllocationToken.Builder() @@ -682,7 +690,8 @@ class DomainRenewFlowTest extends ResourceFlowTestCase @Test void testFailure_promoTokenNotValidForRegistrar() throws Exception { setEppInput( - "domain_renew_allocationtoken.xml", ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2")); + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "abc123")); persistDomain(); persistResource( new AllocationToken.Builder() @@ -706,7 +715,8 @@ class DomainRenewFlowTest extends ResourceFlowTestCase @Test void testFailure_promoTokenNotValidForTld() throws Exception { setEppInput( - "domain_renew_allocationtoken.xml", ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2")); + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "abc123")); persistDomain(); persistResource( new AllocationToken.Builder() @@ -730,7 +740,8 @@ class DomainRenewFlowTest extends ResourceFlowTestCase @Test void testFailure_alreadyRedemeedAllocationToken() throws Exception { setEppInput( - "domain_renew_allocationtoken.xml", ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2")); + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "abc123")); persistDomain(); Domain domain = persistActiveDomain("foo.tld"); Key historyEntryKey = Key.create(Key.create(domain), HistoryEntry.class, 505L); @@ -1185,4 +1196,69 @@ class DomainRenewFlowTest extends ResourceFlowTestCase TransactionReportField.netRenewsFieldFromYears(5), 1)); } + + @Test + void testFailsPackageDomainInvalidAllocationToken() throws Exception { + AllocationToken token = + persistResource( + new AllocationToken.Builder() + .setToken("abc123") + .setTokenType(PACKAGE) + .setAllowedRegistrarIds(ImmutableSet.of("TheRegistrar")) + .setAllowedTlds(ImmutableSet.of("tld")) + .setRenewalPriceBehavior(SPECIFIED) + .build()); + persistDomain(); + persistResource( + reloadResourceByForeignKey() + .asBuilder() + .setCurrentPackageToken(token.createVKey()) + .build()); + + setEppInput( + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "abc123")); + + EppException thrown = + assertThrows(MissingRemovePackageTokenOnPackageDomainException.class, this::runFlow); + assertAboutEppExceptions().that(thrown).marshalsToXml(); + } + + @Test + void testFailsToRenewPackageDomainNoRemovePackageToken() throws Exception { + AllocationToken token = + persistResource( + new AllocationToken.Builder() + .setToken("abc123") + .setTokenType(PACKAGE) + .setAllowedRegistrarIds(ImmutableSet.of("TheRegistrar")) + .setAllowedTlds(ImmutableSet.of("tld")) + .setRenewalPriceBehavior(SPECIFIED) + .build()); + persistDomain(); + persistResource( + reloadResourceByForeignKey() + .asBuilder() + .setCurrentPackageToken(token.createVKey()) + .build()); + + setEppInput("domain_renew.xml", ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "5")); + + EppException thrown = + assertThrows(MissingRemovePackageTokenOnPackageDomainException.class, this::runFlow); + assertAboutEppExceptions().that(thrown).marshalsToXml(); + } + + @Test + void testFailsToRenewNonPackageDomainWithRemovePackageToken() throws Exception { + persistDomain(); + + setEppInput( + "domain_renew_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "2", "TOKEN", "__REMOVEPACKAGE__")); + + EppException thrown = + assertThrows(RemovePackageTokenOnNonPackageDomainException.class, this::runFlow); + assertAboutEppExceptions().that(thrown).marshalsToXml(); + } } diff --git a/core/src/test/resources/google/registry/flows/domain/domain_renew_allocationtoken.xml b/core/src/test/resources/google/registry/flows/domain/domain_renew_allocationtoken.xml index e79750b89..bebf1d3c4 100644 --- a/core/src/test/resources/google/registry/flows/domain/domain_renew_allocationtoken.xml +++ b/core/src/test/resources/google/registry/flows/domain/domain_renew_allocationtoken.xml @@ -12,7 +12,7 @@ - abc123 + %TOKEN% ABC-12345 diff --git a/docs/flows.md b/docs/flows.md index 5badced0c..e8addf278 100644 --- a/docs/flows.md +++ b/docs/flows.md @@ -497,6 +497,8 @@ comes in at the exact millisecond that the domain would have expired. * Resource status prohibits this operation. * The allocation token is not currently valid. * 2305 + * The __REMOVEPACKAGE__ token is missing on a renew package domain command + * The __REMOVEPACKAGE__ token is not allowed on non package domains * The allocation token is not valid for this domain. * The allocation token is not valid for this registrar. * The allocation token is not valid for this TLD.