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