diff --git a/core/src/main/java/google/registry/bsa/persistence/BsaLabel.java b/core/src/main/java/google/registry/bsa/persistence/BsaLabel.java
index 976e4d8ed..deb099470 100644
--- a/core/src/main/java/google/registry/bsa/persistence/BsaLabel.java
+++ b/core/src/main/java/google/registry/bsa/persistence/BsaLabel.java
@@ -28,7 +28,7 @@ import org.joda.time.DateTime;
*
The label is valid (wrt IDN) in at least one TLD.
*/
@Entity
-public final class BsaLabel {
+final class BsaLabel {
@Id String label;
@@ -52,7 +52,7 @@ public final class BsaLabel {
}
/** Returns the label to be blocked. */
- public String getLabel() {
+ String getLabel() {
return label;
}
diff --git a/core/src/main/java/google/registry/bsa/persistence/BsaLabelUtils.java b/core/src/main/java/google/registry/bsa/persistence/BsaLabelUtils.java
new file mode 100644
index 000000000..dd03b0b34
--- /dev/null
+++ b/core/src/main/java/google/registry/bsa/persistence/BsaLabelUtils.java
@@ -0,0 +1,87 @@
+// Copyright 2023 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.bsa.persistence;
+
+import static google.registry.config.RegistryConfig.getEppResourceCachingDuration;
+import static google.registry.config.RegistryConfig.getEppResourceMaxCachedEntries;
+import static google.registry.model.CacheUtils.newCacheBuilder;
+import static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm;
+
+import com.github.benmanes.caffeine.cache.CacheLoader;
+import com.github.benmanes.caffeine.cache.LoadingCache;
+import com.google.common.annotations.VisibleForTesting;
+import google.registry.persistence.VKey;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Optional;
+
+/** Helpers for {@link BsaLabel}. */
+public final class BsaLabelUtils {
+
+ private BsaLabelUtils() {}
+
+ static final CacheLoader, Optional> CACHE_LOADER =
+ new CacheLoader, Optional>() {
+
+ @Override
+ public Optional load(VKey key) {
+ return replicaTm().reTransact(() -> replicaTm().loadByKeyIfPresent(key));
+ }
+
+ @Override
+ public Map, Optional> loadAll(
+ Iterable extends VKey> keys) {
+ // TODO(b/309173359): need this for DomainCheckFlow
+ throw new UnsupportedOperationException(
+ "LoadAll not supported by the BsaLabel cache loader.");
+ }
+ };
+
+ /**
+ * A limited size, limited expiry cache of BSA labels.
+ *
+ * BSA labels are used by the domain creation flow to verify that the requested domain name is
+ * not blocked by the BSA program. Label caching is mainly a defense against two scenarios, the
+ * initial rush and drop-catching, when clients run back-to-back domain creation requests around
+ * the time when a domain becomes available.
+ *
+ *
Because of caching and the use of the replica database, new BSA labels installed in the
+ * database will not take effect immediately. A blocked domain may be created due to race
+ * condition. A `refresh` job will detect such domains and report them to BSA as unblockable
+ * domains.
+ *
+ *
Since the cached BSA labels have the same usage pattern as the cached EppResources, the
+ * cache configuration for the latter are reused here.
+ */
+ private static LoadingCache, Optional> cacheBsaLabels =
+ createBsaLabelsCache(getEppResourceCachingDuration());
+
+ private static LoadingCache, Optional> createBsaLabelsCache(
+ Duration expiry) {
+ return newCacheBuilder(expiry)
+ .maximumSize(getEppResourceMaxCachedEntries())
+ .build(CACHE_LOADER);
+ }
+
+ @VisibleForTesting
+ void clearCache() {
+ cacheBsaLabels.invalidateAll();
+ }
+
+ /** Checks if the {@code domainLabel} (the leading `part` of a domain name) is blocked by BSA. */
+ public static boolean isLabelBlocked(String domainLabel) {
+ return cacheBsaLabels.get(BsaLabel.vKey(domainLabel)).isPresent();
+ }
+}
diff --git a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java
index 8002e9d3a..192499794 100644
--- a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java
+++ b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java
@@ -40,6 +40,7 @@ import static google.registry.flows.domain.DomainFlowUtils.verifyClaimsNoticeIfA
import static google.registry.flows.domain.DomainFlowUtils.verifyClaimsPeriodNotEnded;
import static google.registry.flows.domain.DomainFlowUtils.verifyLaunchPhaseMatchesRegistryPhase;
import static google.registry.flows.domain.DomainFlowUtils.verifyNoCodeMarks;
+import static google.registry.flows.domain.DomainFlowUtils.verifyNotBlockedByBsa;
import static google.registry.flows.domain.DomainFlowUtils.verifyNotReserved;
import static google.registry.flows.domain.DomainFlowUtils.verifyPremiumNameIsNotBlocked;
import static google.registry.flows.domain.DomainFlowUtils.verifyRegistrarIsActive;
@@ -168,6 +169,7 @@ import org.joda.time.Duration;
* @error {@link DomainFlowUtils.CurrencyUnitMismatchException}
* @error {@link DomainFlowUtils.CurrencyValueScaleException}
* @error {@link DomainFlowUtils.DashesInThirdAndFourthException}
+ * @error {@link DomainFlowUtils.DomainLabelBlockedByBsaException}
* @error {@link DomainFlowUtils.DomainLabelTooLongException}
* @error {@link DomainFlowUtils.DomainReservedException}
* @error {@link DomainFlowUtils.DuplicateContactForRoleException}
@@ -328,6 +330,7 @@ public final class DomainCreateFlow implements MutatingFlow {
.verifySignedMarks(launchCreate.get().getSignedMarks(), domainLabel, now)
.getId();
}
+ verifyNotBlockedByBsa(domainLabel, tld, now);
flowCustomLogic.afterValidation(
DomainCreateFlowCustomLogic.AfterValidationParameters.newBuilder()
.setDomainName(domainName)
diff --git a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java
index 859f5a0e4..3f64bb841 100644
--- a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java
+++ b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java
@@ -25,11 +25,13 @@ import static com.google.common.collect.Iterables.any;
import static com.google.common.collect.Sets.difference;
import static com.google.common.collect.Sets.intersection;
import static com.google.common.collect.Sets.union;
+import static google.registry.bsa.persistence.BsaLabelUtils.isLabelBlocked;
import static google.registry.model.domain.Domain.MAX_REGISTRATION_YEARS;
import static google.registry.model.tld.Tld.TldState.GENERAL_AVAILABILITY;
import static google.registry.model.tld.Tld.TldState.PREDELEGATION;
import static google.registry.model.tld.Tld.TldState.QUIET_PERIOD;
import static google.registry.model.tld.Tld.TldState.START_DATE_SUNRISE;
+import static google.registry.model.tld.Tld.isEnrolledWithBsa;
import static google.registry.model.tld.Tlds.findTldForName;
import static google.registry.model.tld.Tlds.getTlds;
import static google.registry.model.tld.label.ReservationType.ALLOWED_IN_SUNRISE;
@@ -259,6 +261,19 @@ public class DomainFlowUtils {
return idnTableName.get();
}
+ /**
+ * Verifies that the {@code domainLabel} is not blocked by any BSA block label for the given
+ * {@code tld} at the specified time.
+ *
+ * @throws DomainLabelBlockedByBsaException
+ */
+ public static void verifyNotBlockedByBsa(String domainLabel, Tld tld, DateTime now)
+ throws DomainLabelBlockedByBsaException {
+ if (isEnrolledWithBsa(tld, now) && isLabelBlocked(domainLabel)) {
+ throw new DomainLabelBlockedByBsaException();
+ }
+ }
+
/** Returns whether a given domain create request is for a valid anchor tenant. */
public static boolean isAnchorTenant(
InternetDomainName domainName,
@@ -1742,4 +1757,12 @@ public class DomainFlowUtils {
super("Registrar must be active in order to perform this operation");
}
}
+
+ /** Domain label is blocked by the Brand Safety Alliance. */
+ static class DomainLabelBlockedByBsaException extends ParameterValuePolicyErrorException {
+ public DomainLabelBlockedByBsaException() {
+ // TODO(b/309174065): finalize the exception message.
+ super("Domain label is blocked by the Brand Safety Alliance");
+ }
+ }
}
diff --git a/core/src/test/java/google/registry/bsa/persistence/BsaLabelTest.java b/core/src/test/java/google/registry/bsa/persistence/BsaLabelTest.java
index b49686a63..968caceaa 100644
--- a/core/src/test/java/google/registry/bsa/persistence/BsaLabelTest.java
+++ b/core/src/test/java/google/registry/bsa/persistence/BsaLabelTest.java
@@ -41,4 +41,15 @@ public class BsaLabelTest {
assertThat(persisted.getLabel()).isEqualTo("label");
assertThat(persisted.creationTime).isEqualTo(fakeClock.nowUtc());
}
+
+ @Test
+ void isLabelBlocked_no() {
+ assertThat(tm().transact(() -> BsaLabelUtils.isLabelBlocked("abc"))).isFalse();
+ }
+
+ @Test
+ void isLabelBlocked_yes() {
+ tm().transact(() -> tm().put(new BsaLabel("abc", fakeClock.nowUtc())));
+ assertThat(tm().transact(() -> BsaLabelUtils.isLabelBlocked("abc"))).isTrue();
+ }
}
diff --git a/core/src/test/java/google/registry/bsa/persistence/BsaLabelTestingUtils.java b/core/src/test/java/google/registry/bsa/persistence/BsaLabelTestingUtils.java
new file mode 100644
index 000000000..8ab720f47
--- /dev/null
+++ b/core/src/test/java/google/registry/bsa/persistence/BsaLabelTestingUtils.java
@@ -0,0 +1,29 @@
+// Copyright 2023 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.bsa.persistence;
+
+import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
+
+import org.joda.time.DateTime;
+
+/** Testing utils for users of {@link BsaLabel}. */
+public final class BsaLabelTestingUtils {
+
+ private BsaLabelTestingUtils() {}
+
+ public static void persistBsaLabel(String domainLabel, DateTime creationTime) {
+ tm().transact(() -> tm().put(new BsaLabel(domainLabel, creationTime)));
+ }
+}
diff --git a/core/src/test/java/google/registry/bsa/persistence/BsaLabelUtilsTest.java b/core/src/test/java/google/registry/bsa/persistence/BsaLabelUtilsTest.java
new file mode 100644
index 000000000..936b8bd99
--- /dev/null
+++ b/core/src/test/java/google/registry/bsa/persistence/BsaLabelUtilsTest.java
@@ -0,0 +1,102 @@
+// Copyright 2023 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.bsa.persistence;
+
+import static com.google.common.truth.Truth.assertThat;
+import static google.registry.bsa.persistence.BsaLabelTestingUtils.persistBsaLabel;
+import static google.registry.bsa.persistence.BsaLabelUtils.isLabelBlocked;
+import static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm;
+import static google.registry.persistence.transaction.TransactionManagerFactory.setJpaTm;
+import static google.registry.persistence.transaction.TransactionManagerFactory.setReplicaJpaTm;
+import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
+import static org.joda.time.DateTimeZone.UTC;
+import static org.joda.time.Duration.millis;
+import static org.joda.time.Duration.standardMinutes;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import google.registry.persistence.transaction.JpaTestExtensions;
+import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationWithCoverageExtension;
+import google.registry.persistence.transaction.JpaTransactionManager;
+import google.registry.testing.FakeClock;
+import org.joda.time.DateTime;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/** Unit tests for {@link BsaLabelUtils}. */
+public class BsaLabelUtilsTest {
+
+ protected FakeClock fakeClock = new FakeClock(DateTime.now(UTC));
+
+ @RegisterExtension
+ final JpaIntegrationWithCoverageExtension jpa =
+ new JpaTestExtensions.Builder().withClock(fakeClock).buildIntegrationWithCoverageExtension();
+
+ @Test
+ void isLabelBlocked_yes() {
+ persistBsaLabel("abc", fakeClock.nowUtc());
+ assertThat(isLabelBlocked("abc")).isTrue();
+ }
+
+ @Test
+ void isLabelBlocked_no() {
+ assertThat(isLabelBlocked("abc")).isFalse();
+ }
+
+ @Test
+ void isLabelBlocked_isCacheUsed_withReplica() throws Throwable {
+ JpaTransactionManager primaryTmSave = tm();
+ JpaTransactionManager replicaTmSave = replicaTm();
+
+ JpaTransactionManager primaryTm = mock(JpaTransactionManager.class);
+ JpaTransactionManager replicaTm = mock(JpaTransactionManager.class);
+ setJpaTm(() -> primaryTm);
+ setReplicaJpaTm(() -> replicaTm);
+ when(replicaTm.loadByKey(any())).thenReturn(new BsaLabel("abc", fakeClock.nowUtc()));
+ try {
+ assertThat(isLabelBlocked("abc")).isTrue();
+ assertThat(isLabelBlocked("abc")).isTrue();
+ verify(replicaTm, times(1)).loadByKey(any());
+ verify(primaryTm, never()).loadByKey(any());
+ } catch (Throwable e) {
+ setJpaTm(() -> primaryTmSave);
+ setReplicaJpaTm(() -> replicaTmSave);
+ }
+ }
+
+ @Test
+ void isLabelBlocked_isCacheUsed_withOneMinuteExpiry() throws Throwable {
+ JpaTransactionManager replicaTmSave = replicaTm();
+ JpaTransactionManager replicaTm = mock(JpaTransactionManager.class);
+ setReplicaJpaTm(() -> replicaTm);
+ when(replicaTm.loadByKey(any())).thenReturn(new BsaLabel("abc", fakeClock.nowUtc()));
+ try {
+ assertThat(isLabelBlocked("abc")).isTrue();
+ /**
+ * If test fails, check and fix cache expiry in the config file. Do not increase the duration
+ * on the line below without proper discussion.
+ */
+ fakeClock.advanceBy(standardMinutes(1).plus(millis(1)));
+ assertThat(isLabelBlocked("abc")).isTrue();
+ verify(replicaTm, times(2)).loadByKey(any());
+ } catch (Throwable e) {
+ setReplicaJpaTm(() -> replicaTmSave);
+ }
+ }
+}
diff --git a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java
index 035f04190..f9e720992 100644
--- a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java
+++ b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java
@@ -18,6 +18,7 @@ import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.io.BaseEncoding.base16;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat;
+import static google.registry.bsa.persistence.BsaLabelTestingUtils.persistBsaLabel;
import static google.registry.flows.FlowTestCase.UserPrivileges.SUPERUSER;
import static google.registry.model.billing.BillingBase.Flag.ANCHOR_TENANT;
import static google.registry.model.billing.BillingBase.Flag.RESERVED;
@@ -30,6 +31,7 @@ import static google.registry.model.domain.token.AllocationToken.TokenType.BULK_
import static google.registry.model.domain.token.AllocationToken.TokenType.DEFAULT_PROMO;
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.model.eppcommon.EppXmlTransformer.marshal;
import static google.registry.model.eppcommon.StatusValue.PENDING_DELETE;
import static google.registry.model.eppcommon.StatusValue.SERVER_HOLD;
import static google.registry.model.tld.Tld.TldState.GENERAL_AVAILABILITY;
@@ -96,6 +98,7 @@ import google.registry.flows.domain.DomainFlowUtils.ClaimsPeriodEndedException;
import google.registry.flows.domain.DomainFlowUtils.CurrencyUnitMismatchException;
import google.registry.flows.domain.DomainFlowUtils.CurrencyValueScaleException;
import google.registry.flows.domain.DomainFlowUtils.DashesInThirdAndFourthException;
+import google.registry.flows.domain.DomainFlowUtils.DomainLabelBlockedByBsaException;
import google.registry.flows.domain.DomainFlowUtils.DomainLabelTooLongException;
import google.registry.flows.domain.DomainFlowUtils.DomainNameExistsAsTldException;
import google.registry.flows.domain.DomainFlowUtils.DomainReservedException;
@@ -165,6 +168,9 @@ import google.registry.model.domain.secdns.DomainDsData;
import google.registry.model.domain.token.AllocationToken;
import google.registry.model.domain.token.AllocationToken.RegistrationBehavior;
import google.registry.model.domain.token.AllocationToken.TokenStatus;
+import google.registry.model.eppcommon.Trid;
+import google.registry.model.eppoutput.EppOutput;
+import google.registry.model.eppoutput.EppResponse;
import google.registry.model.poll.PendingActionNotificationResponse.DomainPendingActionNotificationResponse;
import google.registry.model.poll.PollMessage;
import google.registry.model.registrar.Registrar;
@@ -183,7 +189,9 @@ import google.registry.tmch.LordnTaskUtils.LordnPhase;
import google.registry.tmch.SmdrlCsvParser;
import google.registry.tmch.TmchData;
import google.registry.tmch.TmchTestData;
+import google.registry.xml.ValidationMode;
import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
@@ -2562,6 +2570,53 @@ class DomainCreateFlowTest extends ResourceFlowTestCase
+
+
+
+ Domain label is blocked by the Brand Safety Alliance
+
+
+ server-trid
+
+
+
diff --git a/docs/flows.md b/docs/flows.md
index 18d489d57..c6fd540ec 100644
--- a/docs/flows.md
+++ b/docs/flows.md
@@ -384,6 +384,7 @@ An EPP flow that creates a new domain resource.
* The requested fees cannot be provided in the requested currency.
* Non-IDN domain names cannot contain hyphens in the third or fourth
position.
+ * Domain label is blocked by the Brand Safety Alliance.
* Domain labels cannot be longer than 63 characters.
* More than one contact for a given role is not allowed.
* No part of a domain name can be empty.