diff --git a/core/src/main/java/google/registry/batch/WipeOutContactHistoryPiiAction.java b/core/src/main/java/google/registry/batch/WipeOutContactHistoryPiiAction.java
new file mode 100644
index 000000000..337df863a
--- /dev/null
+++ b/core/src/main/java/google/registry/batch/WipeOutContactHistoryPiiAction.java
@@ -0,0 +1,134 @@
+// Copyright 2021 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.batch;
+
+import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm;
+import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
+import static org.apache.http.HttpStatus.SC_OK;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.net.MediaType;
+import google.registry.config.RegistryConfig.Config;
+import google.registry.model.contact.ContactHistory;
+import google.registry.request.Action;
+import google.registry.request.Action.Service;
+import google.registry.request.Response;
+import google.registry.request.auth.Auth;
+import google.registry.util.Clock;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+import javax.inject.Inject;
+import org.joda.time.DateTime;
+
+/**
+ * An action that wipes out Personal Identifiable Information (PII) fields of {@link ContactHistory}
+ * entities.
+ *
+ *
ContactHistory entities should be retained in the database for only certain amount of time.
+ * This periodic wipe out action only applies to SQL.
+ */
+@Action(
+ service = Service.BACKEND,
+ path = WipeOutContactHistoryPiiAction.PATH,
+ auth = Auth.AUTH_INTERNAL_OR_ADMIN)
+public class WipeOutContactHistoryPiiAction implements Runnable {
+
+ public static final String PATH = "/_dr/task/wipeOutContactHistoryPii";
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ private final Clock clock;
+ private final Response response;
+ private final int minMonthsBeforeWipeOut;
+ private final int wipeOutQueryBatchSize;
+
+ @Inject
+ public WipeOutContactHistoryPiiAction(
+ Clock clock,
+ @Config("minMonthsBeforeWipeOut") int minMonthsBeforeWipeOut,
+ @Config("wipeOutQueryBatchSize") int wipeOutQueryBatchSize,
+ Response response) {
+ this.clock = clock;
+ this.response = response;
+ this.minMonthsBeforeWipeOut = minMonthsBeforeWipeOut;
+ this.wipeOutQueryBatchSize = wipeOutQueryBatchSize;
+ }
+
+ @Override
+ public void run() {
+ response.setContentType(MediaType.PLAIN_TEXT_UTF_8);
+ try {
+ int totalNumOfWipedEntities = 0;
+ DateTime wipeOutTime = clock.nowUtc().minusMonths(minMonthsBeforeWipeOut);
+ logger.atInfo().log(
+ "About to wipe out all PII of contact history entities prior to %s.", wipeOutTime);
+
+ int numOfWipedEntities = 0;
+ do {
+ numOfWipedEntities =
+ jpaTm()
+ .transact(
+ () ->
+ wipeOutContactHistoryData(
+ getNextContactHistoryEntitiesWithPiiBatch(wipeOutTime)));
+ totalNumOfWipedEntities += numOfWipedEntities;
+ } while (numOfWipedEntities > 0);
+ logger.atInfo().log(
+ "Wiped out PII of %d ContactHistory entities in total.", totalNumOfWipedEntities);
+ response.setStatus(SC_OK);
+
+ } catch (Exception e) {
+ logger.atSevere().withCause(e).log(
+ "Exception thrown during the process of wiping out contact history PII.");
+ response.setStatus(SC_INTERNAL_SERVER_ERROR);
+ response.setPayload(
+ String.format(
+ "Exception thrown during the process of wiping out contact history PII with cause"
+ + ": %s",
+ e));
+ }
+ }
+
+ /**
+ * Returns a stream of up to {@link #wipeOutQueryBatchSize} {@link ContactHistory} entities
+ * containing PII that are prior to @param wipeOutTime.
+ */
+ @VisibleForTesting
+ Stream getNextContactHistoryEntitiesWithPiiBatch(DateTime wipeOutTime) {
+ // email is one of the required fields in EPP, meaning it's initially not null.
+ // Therefore, checking if it's null is one way to avoid processing contact history entities
+ // that have been processed previously. Refer to RFC 5733 for more information.
+ return jpaTm()
+ .query(
+ "FROM ContactHistory WHERE modificationTime < :wipeOutTime " + "AND email IS NOT NULL",
+ ContactHistory.class)
+ .setParameter("wipeOutTime", wipeOutTime)
+ .setMaxResults(wipeOutQueryBatchSize)
+ .getResultStream();
+ }
+
+ /** Wipes out the PII of each of the {@link ContactHistory} entities in the stream. */
+ @VisibleForTesting
+ int wipeOutContactHistoryData(Stream contactHistoryEntities) {
+ AtomicInteger numOfEntities = new AtomicInteger(0);
+ contactHistoryEntities.forEach(
+ contactHistoryEntity -> {
+ jpaTm().update(contactHistoryEntity.asBuilder().wipeOutPii().build());
+ numOfEntities.incrementAndGet();
+ });
+ logger.atInfo().log(
+ "Wiped out all PII fields of %d ContactHistory entities.", numOfEntities.get());
+ return numOfEntities.get();
+ }
+}
diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java
index 5edb93995..1eea552bc 100644
--- a/core/src/main/java/google/registry/config/RegistryConfig.java
+++ b/core/src/main/java/google/registry/config/RegistryConfig.java
@@ -1306,6 +1306,18 @@ public final class RegistryConfig {
public static ImmutableSet provideAllowedEcdsaCurves(RegistryConfigSettings config) {
return ImmutableSet.copyOf(config.sslCertificateValidation.allowedEcdsaCurves);
}
+
+ @Provides
+ @Config("minMonthsBeforeWipeOut")
+ public static int provideMinMonthsBeforeWipeOut(RegistryConfigSettings config) {
+ return config.contactHistory.minMonthsBeforeWipeOut;
+ }
+
+ @Provides
+ @Config("wipeOutQueryBatchSize")
+ public static int provideWipeOutQueryBatchSize(RegistryConfigSettings config) {
+ return config.contactHistory.wipeOutQueryBatchSize;
+ }
}
/** Returns the App Engine project ID, which is based off the environment name. */
diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java
index 1ae768176..0084625c3 100644
--- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java
+++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java
@@ -41,6 +41,7 @@ public class RegistryConfigSettings {
public Keyring keyring;
public RegistryTool registryTool;
public SslCertificateValidation sslCertificateValidation;
+ public ContactHistory contactHistory;
/** Configuration options that apply to the entire App Engine project. */
public static class AppEngine {
@@ -234,4 +235,10 @@ public class RegistryConfigSettings {
public String expirationWarningEmailBodyText;
public String expirationWarningEmailSubjectText;
}
+
+ /** Configuration for contact history. */
+ public static class ContactHistory {
+ public int minMonthsBeforeWipeOut;
+ public int wipeOutQueryBatchSize;
+ }
}
diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml
index f182c76f8..e76d90dfc 100644
--- a/core/src/main/java/google/registry/config/files/default-config.yaml
+++ b/core/src/main/java/google/registry/config/files/default-config.yaml
@@ -442,6 +442,13 @@ registryTool:
# OAuth client secret used by the tool.
clientSecret: YOUR_CLIENT_SECRET
+# Configuration options for handling contact history.
+contactHistory:
+ # The number of months that a ContactHistory entity should be stored in the database.
+ minMonthsBeforeWipeOut: 18
+ # The batch size for querying ContactHistory table in the database.
+ wipeOutQueryBatchSize: 500
+
# Configuration options for checking SSL certificates.
sslCertificateValidation:
# A map specifying the maximum amount of days the certificate can be valid.
diff --git a/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml b/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml
index 627075b8c..9f4667327 100644
--- a/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml
+++ b/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml
@@ -391,6 +391,13 @@
/_dr/task/relockDomain
+
+
+ backend-servlet
+ /_dr/task/wipeOutContactHistoryPii
+
+
backend-servlet
diff --git a/core/src/main/java/google/registry/env/sandbox/default/WEB-INF/cron.xml b/core/src/main/java/google/registry/env/sandbox/default/WEB-INF/cron.xml
index 472bb074c..2e9a1e41c 100644
--- a/core/src/main/java/google/registry/env/sandbox/default/WEB-INF/cron.xml
+++ b/core/src/main/java/google/registry/env/sandbox/default/WEB-INF/cron.xml
@@ -246,4 +246,14 @@
every 3 minutes
backend
+
+
+
+
+ This job runs weekly to wipe out PII fields of ContactHistory entities
+ that have been in the database for a certain period of time.
+
+ every monday synchronized
+ backend
+
diff --git a/core/src/main/java/google/registry/model/contact/ContactHistory.java b/core/src/main/java/google/registry/model/contact/ContactHistory.java
index b7e606d81..bc0e4794a 100644
--- a/core/src/main/java/google/registry/model/contact/ContactHistory.java
+++ b/core/src/main/java/google/registry/model/contact/ContactHistory.java
@@ -227,5 +227,11 @@ public class ContactHistory extends HistoryEntry implements SqlEntity {
getInstance().parent = Key.create(ContactResource.class, contactRepoId);
return this;
}
+
+ public Builder wipeOutPii() {
+ getInstance().contactBase =
+ getInstance().getContactBase().get().asBuilder().wipeOut().build();
+ return this;
+ }
}
}
diff --git a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java
index 3f76832bd..4f5c73342 100644
--- a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java
+++ b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java
@@ -33,6 +33,7 @@ import google.registry.batch.ResaveAllEppResourcesAction;
import google.registry.batch.ResaveEntityAction;
import google.registry.batch.SendExpiringCertificateNotificationEmailAction;
import google.registry.batch.WipeOutCloudSqlAction;
+import google.registry.batch.WipeOutContactHistoryPiiAction;
import google.registry.batch.WipeoutDatastoreAction;
import google.registry.cron.CommitLogFanoutAction;
import google.registry.cron.CronModule;
@@ -219,6 +220,8 @@ interface BackendRequestComponent {
WipeoutDatastoreAction wipeoutDatastoreAction();
+ WipeOutContactHistoryPiiAction wipeOutContactHistoryPiiAction();
+
@Subcomponent.Builder
abstract class Builder implements RequestComponentBuilder {
diff --git a/core/src/test/java/google/registry/batch/WipeOutContactHistoryPiiActionTest.java b/core/src/test/java/google/registry/batch/WipeOutContactHistoryPiiActionTest.java
new file mode 100644
index 000000000..d941b2e91
--- /dev/null
+++ b/core/src/test/java/google/registry/batch/WipeOutContactHistoryPiiActionTest.java
@@ -0,0 +1,372 @@
+// Copyright 2021 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.batch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm;
+import static google.registry.testing.DatabaseHelper.persistResource;
+import static org.apache.http.HttpStatus.SC_OK;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Truth8;
+import google.registry.model.contact.ContactAddress;
+import google.registry.model.contact.ContactAuthInfo;
+import google.registry.model.contact.ContactBase;
+import google.registry.model.contact.ContactHistory;
+import google.registry.model.contact.ContactPhoneNumber;
+import google.registry.model.contact.ContactResource;
+import google.registry.model.contact.Disclose;
+import google.registry.model.contact.PostalInfo;
+import google.registry.model.eppcommon.AuthInfo.PasswordAuth;
+import google.registry.model.eppcommon.PresenceMarker;
+import google.registry.model.eppcommon.StatusValue;
+import google.registry.testing.AppEngineExtension;
+import google.registry.testing.DatabaseHelper;
+import google.registry.testing.DualDatabaseTest;
+import google.registry.testing.FakeClock;
+import google.registry.testing.FakeResponse;
+import google.registry.testing.InjectExtension;
+import google.registry.testing.TestSqlOnly;
+import org.joda.time.DateTime;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/** Unit tests for {@link WipeOutContactHistoryPiiAction}. */
+@DualDatabaseTest
+class WipeOutContactHistoryPiiActionTest {
+
+ private static final int MIN_MONTHS_BEFORE_WIPE_OUT = 18;
+ private static final int BATCH_SIZE = 500;
+ private static final ContactResource defaultContactResource =
+ new ContactResource.Builder()
+ .setContactId("sh8013")
+ .setRepoId("2FF-ROID")
+ .setStatusValues(ImmutableSet.of(StatusValue.CLIENT_DELETE_PROHIBITED))
+ .setLocalizedPostalInfo(
+ new PostalInfo.Builder()
+ .setType(PostalInfo.Type.LOCALIZED)
+ .setAddress(
+ new ContactAddress.Builder()
+ .setStreet(ImmutableList.of("123 Grand Ave"))
+ .build())
+ .build())
+ .setInternationalizedPostalInfo(
+ new PostalInfo.Builder()
+ .setType(PostalInfo.Type.INTERNATIONALIZED)
+ .setName("John Doe")
+ .setOrg("Example Inc.")
+ .setAddress(
+ new ContactAddress.Builder()
+ .setStreet(ImmutableList.of("123 Example Dr.", "Suite 100"))
+ .setCity("Dulles")
+ .setState("VA")
+ .setZip("20166-6503")
+ .setCountryCode("US")
+ .build())
+ .build())
+ .setVoiceNumber(
+ new ContactPhoneNumber.Builder()
+ .setPhoneNumber("+1.7035555555")
+ .setExtension("1234")
+ .build())
+ .setFaxNumber(new ContactPhoneNumber.Builder().setPhoneNumber("+1.7035555556").build())
+ .setEmailAddress("jdoe@example.com")
+ .setPersistedCurrentSponsorRegistrarId("TheRegistrar")
+ .setCreationRegistrarId("NewRegistrar")
+ .setLastEppUpdateRegistrarId("NewRegistrar")
+ .setCreationTimeForTest(DateTime.parse("1999-04-03T22:00:00.0Z"))
+ .setLastEppUpdateTime(DateTime.parse("1999-12-03T09:00:00.0Z"))
+ .setLastTransferTime(DateTime.parse("2000-04-08T09:00:00.0Z"))
+ .setAuthInfo(ContactAuthInfo.create(PasswordAuth.create("2fooBAR")))
+ .setDisclose(
+ new Disclose.Builder()
+ .setFlag(true)
+ .setVoice(new PresenceMarker())
+ .setEmail(new PresenceMarker())
+ .build())
+ .build();
+
+ @RegisterExtension
+ public final AppEngineExtension appEngine =
+ AppEngineExtension.builder().withDatastoreAndCloudSql().withTaskQueue().build();
+
+ @RegisterExtension public final InjectExtension inject = new InjectExtension();
+ private final FakeClock clock = new FakeClock(DateTime.parse("2021-08-26T20:21:22Z"));
+
+ private FakeResponse response;
+ private WipeOutContactHistoryPiiAction action;
+
+ @BeforeEach
+ void beforeEach() {
+ response = new FakeResponse();
+ action =
+ new WipeOutContactHistoryPiiAction(clock, MIN_MONTHS_BEFORE_WIPE_OUT, BATCH_SIZE, response);
+ }
+
+ @TestSqlOnly
+ void getAllHistoryEntitiesOlderThan_returnsAllPersistedEntities() {
+ ImmutableList expectedToBeWipedOut =
+ persistLotsOfContactHistoryEntities(
+ 20, MIN_MONTHS_BEFORE_WIPE_OUT + 1, 0, defaultContactResource);
+ jpaTm()
+ .transact(
+ () ->
+ assertThat(
+ action.getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT)))
+ .containsExactlyElementsIn(expectedToBeWipedOut));
+ }
+
+ @TestSqlOnly
+ void getAllHistoryEntitiesOlderThan_returnsOnlyPartOfThePersistedEntities() {
+ ImmutableList expectedToBeWipedOut =
+ persistLotsOfContactHistoryEntities(
+ 40, MIN_MONTHS_BEFORE_WIPE_OUT + 2, 0, defaultContactResource);
+
+ // persisted entities that should not be part of the actual result
+ persistLotsOfContactHistoryEntities(
+ 15, 17, MIN_MONTHS_BEFORE_WIPE_OUT - 1, defaultContactResource);
+
+ jpaTm()
+ .transact(
+ () ->
+ Truth8.assertThat(
+ action.getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT)))
+ .containsExactlyElementsIn(expectedToBeWipedOut));
+ }
+
+ @TestSqlOnly
+ void run_withNoEntitiesToWipeOut_success() {
+ assertThat(
+ jpaTm()
+ .transact(
+ () ->
+ action
+ .getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT))
+ .count()))
+ .isEqualTo(0);
+ action.run();
+
+ assertThat(
+ jpaTm()
+ .transact(
+ () ->
+ action
+ .getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT))
+ .count()))
+ .isEqualTo(0);
+
+ assertThat(response.getStatus()).isEqualTo(SC_OK);
+ }
+
+ @TestSqlOnly
+ void run_withOneBatchOfEntities_success() {
+ int numOfMonthsFromNow = MIN_MONTHS_BEFORE_WIPE_OUT + 2;
+ ImmutableList expectedToBeWipedOut =
+ persistLotsOfContactHistoryEntities(20, numOfMonthsFromNow, 0, defaultContactResource);
+
+ // The query should return a stream of all persisted entities.
+ assertThat(
+ jpaTm()
+ .transact(
+ () ->
+ action
+ .getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT))
+ .count()))
+ .isEqualTo(expectedToBeWipedOut.size());
+
+ assertAllEntitiesContainPii(DatabaseHelper.loadByEntitiesIfPresent(expectedToBeWipedOut));
+
+ action.run();
+
+ // The query should return an empty stream after the wipe out action.
+ assertThat(
+ jpaTm()
+ .transact(
+ () ->
+ action
+ .getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT))
+ .count()))
+ .isEqualTo(0);
+
+ assertAllPiiFieldsAreWipedOut(DatabaseHelper.loadByEntitiesIfPresent(expectedToBeWipedOut));
+ }
+
+ @TestSqlOnly
+ void run_withMultipleBatches_numOfEntitiesAsNonMultipleOfBatchSize_success() {
+ int numOfMonthsFromNow = MIN_MONTHS_BEFORE_WIPE_OUT + 2;
+ ImmutableList expectedToBeWipedOut =
+ persistLotsOfContactHistoryEntities(1234, numOfMonthsFromNow, 0, defaultContactResource);
+
+ // The query should return a subset of all persisted data.
+ assertThat(
+ jpaTm()
+ .transact(
+ () ->
+ action
+ .getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT))
+ .count()))
+ .isEqualTo(BATCH_SIZE);
+
+ assertAllEntitiesContainPii(DatabaseHelper.loadByEntitiesIfPresent(expectedToBeWipedOut));
+ action.run();
+
+ // The query should return an empty stream after the wipe out action.
+ assertThat(
+ jpaTm()
+ .transact(
+ () ->
+ action
+ .getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT))
+ .count()))
+ .isEqualTo(0);
+
+ assertAllPiiFieldsAreWipedOut(DatabaseHelper.loadByEntitiesIfPresent(expectedToBeWipedOut));
+ }
+
+ @TestSqlOnly
+ void run_withMultipleBatches_numOfEntitiesAsMultiplesOfBatchSize_success() {
+ int numOfMonthsFromNow = MIN_MONTHS_BEFORE_WIPE_OUT + 2;
+ ImmutableList expectedToBeWipedOut =
+ persistLotsOfContactHistoryEntities(2000, numOfMonthsFromNow, 0, defaultContactResource);
+
+ // The query should return a subset of all persisted data.
+ assertThat(
+ jpaTm()
+ .transact(
+ () ->
+ action
+ .getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT))
+ .count()))
+ .isEqualTo(BATCH_SIZE);
+
+ assertAllEntitiesContainPii(DatabaseHelper.loadByEntitiesIfPresent(expectedToBeWipedOut));
+ action.run();
+
+ // The query should return an empty stream after the wipe out action.
+ assertThat(
+ jpaTm()
+ .transact(
+ () ->
+ action
+ .getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT))
+ .count()))
+ .isEqualTo(0);
+
+ assertAllPiiFieldsAreWipedOut(DatabaseHelper.loadByEntitiesIfPresent(expectedToBeWipedOut));
+ }
+
+ @TestSqlOnly
+ void wipeOutContactHistoryData_wipesOutNoEntity() {
+ jpaTm()
+ .transact(
+ () -> {
+ assertThat(
+ action.wipeOutContactHistoryData(
+ action.getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT))))
+ .isEqualTo(0);
+ });
+ }
+
+ @TestSqlOnly
+ void wipeOutContactHistoryData_wipesOutMultipleEntities() {
+ int numOfMonthsFromNow = MIN_MONTHS_BEFORE_WIPE_OUT + 3;
+ ImmutableList expectedToBeWipedOut =
+ persistLotsOfContactHistoryEntities(20, numOfMonthsFromNow, 0, defaultContactResource);
+
+ assertAllEntitiesContainPii(DatabaseHelper.loadByEntitiesIfPresent(expectedToBeWipedOut));
+
+ jpaTm()
+ .transact(
+ () -> {
+ action.wipeOutContactHistoryData(
+ action.getNextContactHistoryEntitiesWithPiiBatch(
+ clock.nowUtc().minusMonths(MIN_MONTHS_BEFORE_WIPE_OUT)));
+ });
+
+ assertAllPiiFieldsAreWipedOut(DatabaseHelper.loadByEntitiesIfPresent(expectedToBeWipedOut));
+ }
+
+ /** persists a number of ContactHistory entities for load and query testing. */
+ ImmutableList persistLotsOfContactHistoryEntities(
+ int numOfEntities, int minusMonths, int minusDays, ContactResource contact) {
+ ImmutableList.Builder expectedEntitesBuilder = new ImmutableList.Builder<>();
+ for (int i = 0; i < numOfEntities; i++) {
+ expectedEntitesBuilder.add(
+ persistResource(
+ new ContactHistory()
+ .asBuilder()
+ .setRegistrarId("NewRegistrar")
+ .setModificationTime(clock.nowUtc().minusMonths(minusMonths).minusDays(minusDays))
+ .setType(ContactHistory.Type.CONTACT_DELETE)
+ .setContact(persistResource(contact))
+ .build()));
+ }
+ return expectedEntitesBuilder.build();
+ }
+
+ boolean areAllPiiFieldsWiped(ContactBase contactBase) {
+ return contactBase.getEmailAddress() == null
+ && contactBase.getFaxNumber() == null
+ && contactBase.getInternationalizedPostalInfo() == null
+ && contactBase.getLocalizedPostalInfo() == null
+ && contactBase.getVoiceNumber() == null;
+ }
+
+ boolean containsPii(ContactBase contactBase) {
+ return contactBase.getEmailAddress() != null
+ || contactBase.getFaxNumber() != null
+ || contactBase.getInternationalizedPostalInfo() != null
+ || contactBase.getLocalizedPostalInfo() != null
+ || contactBase.getVoiceNumber() != null;
+ }
+
+ void assertAllPiiFieldsAreWipedOut(ImmutableList entities) {
+ ImmutableList.Builder notWipedEntities = new ImmutableList.Builder<>();
+ for (ContactHistory entity : entities) {
+ if (!areAllPiiFieldsWiped(entity.getContactBase().get())) {
+ notWipedEntities.add(entity);
+ }
+ }
+ assertWithMessage("Not all PII fields of the contact history entities were wiped.")
+ .that(notWipedEntities.build())
+ .isEmpty();
+ }
+
+ void assertAllEntitiesContainPii(ImmutableList entities) {
+ ImmutableList.Builder entitiesWithNoPii = new ImmutableList.Builder<>();
+ for (ContactHistory entity : entities) {
+ if (!containsPii(entity.getContactBase().get())) {
+ entitiesWithNoPii.add(entity);
+ }
+ }
+ assertWithMessage("Not all contact history entities contain PII.")
+ .that(entitiesWithNoPii.build())
+ .isEmpty();
+ }
+}
diff --git a/core/src/test/java/google/registry/model/history/ContactHistoryTest.java b/core/src/test/java/google/registry/model/history/ContactHistoryTest.java
index 8e1326569..15f46c0b6 100644
--- a/core/src/test/java/google/registry/model/history/ContactHistoryTest.java
+++ b/core/src/test/java/google/registry/model/history/ContactHistoryTest.java
@@ -24,11 +24,15 @@ import static google.registry.testing.DatabaseHelper.newContactResource;
import static google.registry.testing.DatabaseHelper.newContactResourceWithRoid;
import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.common.collect.ImmutableList;
import com.googlecode.objectify.Key;
import google.registry.model.EntityTestCase;
+import google.registry.model.contact.ContactAddress;
import google.registry.model.contact.ContactBase;
import google.registry.model.contact.ContactHistory;
+import google.registry.model.contact.ContactPhoneNumber;
import google.registry.model.contact.ContactResource;
+import google.registry.model.contact.PostalInfo;
import google.registry.model.eppcommon.Trid;
import google.registry.model.reporting.HistoryEntry;
import google.registry.persistence.VKey;
@@ -130,6 +134,60 @@ public class ContactHistoryTest extends EntityTestCase {
.hasFieldsEqualTo(jpaTm().loadByEntity(contactHistory).getContactBase().get()));
}
+ @TestSqlOnly
+ void testWipeOutPii_assertsAllPiiFieldsAreNull() {
+ ContactHistory originalEntity =
+ createContactHistory(
+ new ContactResource.Builder()
+ .setRepoId("1-FOOBAR")
+ .setLocalizedPostalInfo(
+ new PostalInfo.Builder()
+ .setType(PostalInfo.Type.LOCALIZED)
+ .setAddress(
+ new ContactAddress.Builder()
+ .setStreet(ImmutableList.of("111 8th Ave", "4th Floor"))
+ .setCity("New York")
+ .setState("NY")
+ .setZip("10011")
+ .setCountryCode("US")
+ .build())
+ .build())
+ .setInternationalizedPostalInfo(
+ new PostalInfo.Builder()
+ .setType(PostalInfo.Type.INTERNATIONALIZED)
+ .setAddress(
+ new ContactAddress.Builder()
+ .setStreet(ImmutableList.of("111 8th Ave", "4th Floor"))
+ .setCity("New York")
+ .setState("NY")
+ .setZip("10011")
+ .setCountryCode("US")
+ .build())
+ .build())
+ .setVoiceNumber(new ContactPhoneNumber.Builder().setPhoneNumber("867-5309").build())
+ .setFaxNumber(
+ new ContactPhoneNumber.Builder()
+ .setPhoneNumber("867-5309")
+ .setExtension("1000")
+ .build())
+ .setEmailAddress("test@example.com")
+ .build());
+
+ assertThat(originalEntity.getContactBase().get().getEmailAddress()).isNotNull();
+ assertThat(originalEntity.getContactBase().get().getLocalizedPostalInfo()).isNotNull();
+ assertThat(originalEntity.getContactBase().get().getInternationalizedPostalInfo()).isNotNull();
+ assertThat(originalEntity.getContactBase().get().getVoiceNumber()).isNotNull();
+ assertThat(originalEntity.getContactBase().get().getFaxNumber()).isNotNull();
+
+ ContactHistory wipedEntity = originalEntity.asBuilder().wipeOutPii().build();
+
+ assertThat(wipedEntity.getContactBase().get().getEmailAddress()).isNull();
+ assertThat(wipedEntity.getContactBase().get().getLocalizedPostalInfo()).isNull();
+ assertThat(wipedEntity.getContactBase().get().getInternationalizedPostalInfo()).isNull();
+ assertThat(wipedEntity.getContactBase().get().getVoiceNumber()).isNull();
+ assertThat(wipedEntity.getContactBase().get().getFaxNumber()).isNull();
+ }
+
private ContactHistory createContactHistory(ContactBase contact) {
return new ContactHistory.Builder()
.setType(HistoryEntry.Type.HOST_CREATE)
diff --git a/core/src/test/resources/google/registry/module/backend/backend_routing.txt b/core/src/test/resources/google/registry/module/backend/backend_routing.txt
index 8a351599e..8028b34a0 100644
--- a/core/src/test/resources/google/registry/module/backend/backend_routing.txt
+++ b/core/src/test/resources/google/registry/module/backend/backend_routing.txt
@@ -47,4 +47,5 @@ PATH CLASS
/_dr/task/updateSnapshotView UpdateSnapshotViewAction POST n INTERNAL,API APP ADMIN
/_dr/task/uploadDatastoreBackup UploadDatastoreBackupAction POST n INTERNAL,API APP ADMIN
/_dr/task/wipeOutCloudSql WipeOutCloudSqlAction GET n INTERNAL,API APP ADMIN
+/_dr/task/wipeOutContactHistoryPii WipeOutContactHistoryPiiAction GET n INTERNAL,API APP ADMIN
/_dr/task/wipeOutDatastore WipeoutDatastoreAction GET n INTERNAL,API APP ADMIN