Merge "Add API method for deleting self account"
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 2d5ab1d..275006f9 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -1082,6 +1082,7 @@
 [[bouncycastle]]
 bouncycastle
 
+* bouncycastle:bcpg
 * bouncycastle:bcpg-neverlink
 * bouncycastle:bcpkix-neverlink
 * bouncycastle:bcprov-neverlink
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 7646777..64bdce0 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -186,6 +186,27 @@
   }
 ----
 
+[[delete-account]]
+=== Delete Account
+--
+'DELETE /accounts/link:#account-id[\{account-id\}]'
+--
+
+Deletes the given account.
+
+Currently only supporting self deletion (regardless of the way
+link:#account-id[\{account-id\}] is provided).
+
+.Request
+----
+  DELETE /accounts/self HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[get-detail]]
 === Get Account Details
 --
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index e93c152..28ef0ed 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -293,6 +293,7 @@
   protected TestAccount admin;
   protected TestAccount user;
   protected TestRepository<InMemoryRepository> testRepo;
+  protected String testMethodName;
   protected String resourcePrefix;
   protected Description description;
   protected GerritServer.Description testMethodDescription;
@@ -479,9 +480,10 @@
 
     initSsh();
 
+    testMethodName = description.getMethodName();
     resourcePrefix =
         UNSAFE_PROJECT_NAME
-            .matcher(description.getClassName() + "_" + description.getMethodName() + "_")
+            .matcher(description.getClassName() + "_" + testMethodName + "_")
             .replaceAll("");
 
     Context ctx = newRequestContext(admin);
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index ff5bc00..310d141 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -166,6 +166,10 @@
         accounts.get(username), () -> String.format("No TestAccount created for %s ", username));
   }
 
+  public void evict(Account.Id id) {
+    evict(ImmutableSet.of(id));
+  }
+
   public void evict(Collection<Account.Id> ids) {
     accounts.values().removeIf(a -> ids.contains(a.id()));
   }
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 9c9c282..0d019aa 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -124,6 +124,8 @@
    */
   String setHttpPassword(String httpPassword) throws RestApiException;
 
+  void delete() throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -327,5 +329,10 @@
     public String setHttpPassword(String httpPassword) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/git/RefUpdateUtil.java b/java/com/google/gerrit/git/RefUpdateUtil.java
index bd88962..6885d84 100644
--- a/java/com/google/gerrit/git/RefUpdateUtil.java
+++ b/java/com/google/gerrit/git/RefUpdateUtil.java
@@ -147,18 +147,18 @@
    *     occurs.
    * @throws IOException if an error occurred.
    */
-  public static void deleteChecked(Repository repo, String refName) throws IOException {
+  public static RefUpdate deleteChecked(Repository repo, String refName) throws IOException {
     RefUpdate ru = repo.updateRef(refName);
     ru.setForceUpdate(true);
     ru.setCheckConflicting(false);
     switch (ru.delete()) {
       case FORCED:
         // Ref was deleted.
-        return;
+        return ru;
 
       case NEW:
         // Ref didn't exist (yes, really).
-        return;
+        return ru;
 
       case LOCK_FAILURE:
         throw new LockFailureException("Failed to delete " + refName + ": " + ru.getResult(), ru);
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index fcf4f0f..3958821 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -4,6 +4,9 @@
     name = "gpg",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//lib/bouncycastle:bcpg",
+    ],
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
diff --git a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
index 2c1cae6..1140bcd 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
@@ -107,4 +107,9 @@
     }
     return res;
   }
+
+  public List<RefUpdate.Result> deleteAllPgpKeysForUser(
+      Account.Id id, PersonIdent committer, PersonIdent author) throws PGPException, IOException {
+    return deletePgpKeys(listGpgKeysForUser(id), committer, author);
+  }
 }
diff --git a/java/com/google/gerrit/gpg/SignedPushModule.java b/java/com/google/gerrit/gpg/SignedPushModule.java
index f4fb9f9..98487ca 100644
--- a/java/com/google/gerrit/gpg/SignedPushModule.java
+++ b/java/com/google/gerrit/gpg/SignedPushModule.java
@@ -33,6 +33,7 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
+import com.google.inject.multibindings.OptionalBinder;
 import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
@@ -54,7 +55,12 @@
     if (!BouncyCastleUtil.havePGP()) {
       throw new ProvisionException("Bouncy Castle PGP not installed");
     }
-    bind(PublicKeyStore.class).toProvider(StoreProvider.class);
+    // This binding is optional as some modules might bind
+    // {@code UnimplementedPublicKeyStoreProvider} as default binding.
+    OptionalBinder.newOptionalBinder(binder(), PublicKeyStore.class)
+        .setBinding()
+        .toProvider(StoreProvider.class);
+
     DynamicSet.bind(binder(), ReceivePackInitializer.class).to(Initializer.class);
   }
 
diff --git a/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java b/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java
new file mode 100644
index 0000000..12e8edb
--- /dev/null
+++ b/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// 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 com.google.gerrit.gpg;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class UnimplementedPublicKeyStoreProvider implements Provider<PublicKeyStore> {
+  @Override
+  public PublicKeyStore get() {
+    throw new NotImplementedException("UnimplementedPublicKeyStoreProvider was bound.");
+  }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index cf04029..8b41356 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -294,6 +294,10 @@
   }
 
   public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
+    return byAccountId(accountId, true);
+  }
+
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, boolean skipInvalidChanges) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
       for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
@@ -310,7 +314,7 @@
 
         // Skip invalid change ids.
         Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
-        if (changeId == null) {
+        if (skipInvalidChanges && changeId == null) {
           continue;
         }
         builder.add(changeId);
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index 8f285b5..30f879e 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -161,6 +161,15 @@
    */
   public abstract Optional<EditPreferencesInfo> getEditPreferences();
 
+  /**
+   * Returns whether the delta for this account is deleting the account.
+   *
+   * <p>If set to true, deletion takes precedence on any other change in this delta.
+   *
+   * @return whether the account should be deleted.
+   */
+  public abstract Optional<Boolean> getShouldDeleteAccount();
+
   public boolean hasExternalIdUpdates() {
     return !this.getCreatedExternalIds().isEmpty()
         || !this.getDeletedExternalIds().isEmpty()
@@ -452,6 +461,20 @@
      */
     public abstract Builder setEditPreferences(EditPreferencesInfo editPreferences);
 
+    public abstract Builder setShouldDeleteAccount(boolean shouldDelete);
+
+    /**
+     * Builds an AccountDelta that deletes all data.
+     *
+     * @param extIdsToDelete external IDs that should be deleted
+     * @return the builder
+     */
+    public Builder deleteAccount(Collection<ExternalId> extIdsToDelete) {
+      deleteExternalIds(extIdsToDelete);
+      setShouldDeleteAccount(true);
+      return this;
+    }
+
     /** Builds the instance. */
     public abstract AccountDelta build();
 
@@ -602,6 +625,12 @@
         delegate.setEditPreferences(editPreferences);
         return this;
       }
+
+      @Override
+      public Builder setShouldDeleteAccount(boolean shouldDelete) {
+        delegate.setShouldDeleteAccount(shouldDelete);
+        return this;
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index b706bca..7dd4828 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -25,6 +25,7 @@
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -34,6 +35,7 @@
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes.ExternalIdNotesLoader;
 import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -53,6 +55,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -62,8 +65,10 @@
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
  * Creates and updates accounts.
@@ -380,6 +385,22 @@
         .get(0);
   }
 
+  /**
+   * Deletes all the account state data.
+   *
+   * @param message commit message for the account update, must not be {@code null or empty}
+   * @param accountId ID of the account
+   * @throws IOException if updating the user branch fails due to an IO error
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public void delete(String message, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    ImmutableSet<ExternalId> accountExternalIds = externalIds.byAccount(accountId);
+    Consumer<AccountDelta.Builder> delta =
+        deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
+    update(message, accountId, delta);
+  }
+
   private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
     return repo -> {
       AccountConfig accountConfig = read(repo, updateArguments.accountId);
@@ -395,7 +416,7 @@
       updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
 
       AccountDelta delta = deltaBuilder.build();
-      accountConfig.setAccountDelta(delta);
+
       ExternalIdNotes.checkSameAccount(
           Iterables.concat(
               delta.getCreatedExternalIds(),
@@ -415,9 +436,13 @@
         externalIdNotes.upsert(delta.getUpdatedExternalIds());
       }
 
+      if (delta.getShouldDeleteAccount().orElse(false)) {
+        return new DeletedAccount(updateArguments.message, accountConfig.getRefName());
+      }
+
+      accountConfig.setAccountDelta(delta);
       CachedPreferences cachedDefaultPreferences =
           CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
-
       return new UpdatedAccount(
           updateArguments.message, accountConfig, cachedDefaultPreferences, false);
     };
@@ -468,7 +493,8 @@
                   allUsersRepo,
                   updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
               for (UpdatedAccount ua : updatedAccounts) {
-                accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
+                accountState.add(
+                    ua == null || ua.deleted ? Optional.empty() : ua.getAccountState());
               }
             }
             return null;
@@ -514,6 +540,7 @@
     beforeCommit.run();
 
     BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    Set<Account.Id> accountsToSkipForReindex = new HashSet<>();
     //  External ids may be not updated if:
     //  * externalIdNotes is not loaded  (there were no externalId updates in the delta)
     //  * new revCommit is identical to the previous externalId tip
@@ -531,7 +558,12 @@
       externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
     }
     for (UpdatedAccount updatedAccount : updatedAccounts) {
-
+      if (updatedAccount.deleted) {
+        RefUpdate ru = RefUpdateUtil.deleteChecked(allUsersRepo, updatedAccount.refName);
+        gitRefUpdated.fire(allUsersName, ru, ReceiveCommand.Type.DELETE, null);
+        accountsToSkipForReindex.add(Account.Id.fromRef(updatedAccount.refName));
+        continue;
+      }
       // These updates are all for different refs (because batches never update the same account
       // more than once), so there can be multiple commits in the same batch, all with the same base
       // revision in their AccountConfig.
@@ -540,7 +572,7 @@
       // when no account properties are set and hence no
       // 'account.config' file will be created.
       // 2) When updating "refs/meta/external-ids", so that refs/users/* meta ref is updated too.
-      // This allows to schedule reindexing of account transactionally  on refs/users/* meta
+      // This allows to schedule reindexing of account transactionally on refs/users/* meta
       // updates.
       boolean allowEmptyCommit = externalIdsUpdated || updatedAccount.created;
       commitAccountConfig(
@@ -553,8 +585,8 @@
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
 
-    Set<Account.Id> accountsToSkipForReindex = getUpdatedAccountIds(batchRefUpdate);
     if (externalIdsUpdated) {
+      accountsToSkipForReindex.addAll(getUpdatedAccountIds(batchRefUpdate));
       extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
           externalIdNotes, accountsToSkipForReindex);
     }
@@ -615,18 +647,38 @@
     final String message;
     final AccountConfig accountConfig;
     final CachedPreferences defaultPreferences;
+    final String refName;
     final boolean created;
+    final boolean deleted;
 
     UpdatedAccount(
         String message,
         AccountConfig accountConfig,
         CachedPreferences defaultPreferences,
         boolean created) {
+      this(
+          message,
+          requireNonNull(accountConfig),
+          defaultPreferences,
+          accountConfig.getRefName(),
+          created,
+          false);
+    }
+
+    protected UpdatedAccount(
+        String message,
+        AccountConfig accountConfig,
+        CachedPreferences defaultPreferences,
+        String refName,
+        boolean created,
+        boolean deleted) {
       checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
       this.message = requireNonNull(message);
-      this.accountConfig = requireNonNull(accountConfig);
+      this.accountConfig = accountConfig;
       this.defaultPreferences = defaultPreferences;
+      this.refName = refName;
       this.created = created;
+      this.deleted = deleted;
     }
 
     Optional<AccountState> getAccountState() throws IOException {
@@ -634,4 +686,10 @@
           externalIds, accountConfig, externalIdNotes, defaultPreferences);
     }
   }
+
+  private class DeletedAccount extends UpdatedAccount {
+    DeletedAccount(String message, String refName) {
+      super(message, null, null, refName, false, true);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 828f868..400521b 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.restapi.account.AddSshKey;
 import com.google.gerrit.server.restapi.account.CreateEmail;
+import com.google.gerrit.server.restapi.account.DeleteAccount;
 import com.google.gerrit.server.restapi.account.DeleteActive;
 import com.google.gerrit.server.restapi.account.DeleteDraftComments;
 import com.google.gerrit.server.restapi.account.DeleteEmail;
@@ -135,6 +136,7 @@
   private final EmailApiImpl.Factory emailApi;
   private final PutName putName;
   private final PutHttpPassword putHttpPassword;
+  private final DeleteAccount deleteAccount;
 
   @Inject
   AccountApiImpl(
@@ -176,6 +178,7 @@
       EmailApiImpl.Factory emailApi,
       PutName putName,
       PutHttpPassword putPassword,
+      DeleteAccount deleteAccount,
       @Assisted AccountResource account) {
     this.account = account;
     this.accountLoaderFactory = ailf;
@@ -216,6 +219,7 @@
     this.emailApi = emailApi;
     this.putName = putName;
     this.putHttpPassword = putPassword;
+    this.deleteAccount = deleteAccount;
   }
 
   @Override
@@ -604,4 +608,13 @@
       throw asRestApiException("Cannot generate HTTP password", e);
     }
   }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteAccount.apply(account, null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete account " + account.getUser().getNameEmail(), e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
index 1b9008d..dc34095 100644
--- a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 import java.util.Collection;
 import java.util.Optional;
 
@@ -91,6 +92,16 @@
   void clearReviewed(Change.Id changeId);
 
   /**
+   * Clears the reviewed flags for the given user in all the relevant changes/patch-set/files.
+   *
+   * @param accountId account ID of the user
+   */
+  default void clearReviewedBy(Account.Id accountId) {
+    throw new NotImplementedException(
+        "clearReviewedBy() is not implemented for this AccountPatchReviewStore.");
+  }
+
+  /**
    * Find the latest patch set, that is smaller or equals to the given patch set, where at least,
    * one file has been reviewed by the given user.
    *
diff --git a/java/com/google/gerrit/server/edit/ChangeEdit.java b/java/com/google/gerrit/server/edit/ChangeEdit.java
index c652289..d7a3a11 100644
--- a/java/com/google/gerrit/server/edit/ChangeEdit.java
+++ b/java/com/google/gerrit/server/edit/ChangeEdit.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -53,6 +54,10 @@
     return editCommit;
   }
 
+  public ObjectId getEditCommitId() {
+    return editCommit.getId();
+  }
+
   public PatchSet getBasePatchSet() {
     return basePatchSet;
   }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 3594afb..c09dac3 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -250,7 +250,7 @@
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       String refName = edit.getRefName();
       RefUpdate ru = repo.updateRef(refName, true);
-      ru.setExpectedOldObjectId(edit.getEditCommit());
+      ru.setExpectedOldObjectId(edit.getEditCommitId());
       ru.setForceUpdate(true);
       RefUpdate.Result result = ru.delete();
       switch (result) {
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 62c070c..76ee627 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -73,6 +74,10 @@
     return ChangeStatusPredicate.forStatus(status);
   }
 
+  private static Predicate<ChangeData> editBy(Account.Id accountId) {
+    return ChangePredicates.editBy(accountId);
+  }
+
   private static Predicate<ChangeData> commit(String id) {
     return ChangePredicates.commitPrefix(id);
   }
@@ -210,6 +215,10 @@
     return query(and(ChangePredicates.exactTopic(topic), open()));
   }
 
+  public List<ChangeData> byOpenEditByUser(Account.Id accountId) {
+    return query(editBy(accountId));
+  }
+
   public List<ChangeData> byCommit(ObjectId id) {
     return byCommit(id.name());
   }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index dd0ec78d..a153399 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -15,6 +15,7 @@
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
diff --git a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
index 2a8f55f..af93b8a 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
@@ -23,18 +23,24 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.UnimplementedPublicKeyStoreProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.inject.Provides;
+import com.google.inject.multibindings.OptionalBinder;
 
 public class AccountRestApiModule extends RestApiModule {
   @Override
   protected void configure() {
     bind(AccountsCollection.class);
     bind(Capabilities.class);
+    OptionalBinder.newOptionalBinder(binder(), PublicKeyStore.class)
+        .setDefault()
+        .toProvider(UnimplementedPublicKeyStoreProvider.class);
 
     DynamicMap.mapOf(binder(), ACCOUNT_KIND);
     DynamicMap.mapOf(binder(), CAPABILITY_KIND);
@@ -45,6 +51,7 @@
 
     create(ACCOUNT_KIND).to(CreateAccount.class);
     put(ACCOUNT_KIND).to(PutAccount.class);
+    delete(ACCOUNT_KIND).to(DeleteAccount.class);
     get(ACCOUNT_KIND).to(GetAccount.class);
     get(ACCOUNT_KIND, "detail").to(GetDetail.class);
     post(ACCOUNT_KIND, "index").to(Index.class);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteAccount.java b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
new file mode 100644
index 0000000..e5b7fd2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
@@ -0,0 +1,192 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// 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 com.google.gerrit.server.restapi.account;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Table;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * REST endpoint for deleting an account.
+ *
+ * <p>This REST endpoint handles {@code DELETE /accounts/<account-identifier>} requests. Currently,
+ * only self deletions are allowed.
+ */
+@Singleton
+public class DeleteAccount implements RestModifyView<AccountResource, Input> {
+  private final Provider<CurrentUser> self;
+  private final Provider<PersonIdent> serverIdent;
+  private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+  private final SshKeyCache sshKeyCache;
+  private final StarredChangesUtil starredChangesUtil;
+  private final DeleteDraftCommentsUtil deleteDraftCommentsUtil;
+  private final GitRepositoryManager gitManager;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeEditUtil changeEditUtil;
+  private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+  private final Provider<PublicKeyStoreUtil> publicKeyStoreUtilProvider;
+
+  @Inject
+  public DeleteAccount(
+      Provider<CurrentUser> self,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      SshKeyCache sshKeyCache,
+      StarredChangesUtil starredChangesUtil,
+      DeleteDraftCommentsUtil deleteDraftCommentsUtil,
+      GitRepositoryManager gitManager,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeEditUtil changeEditUtil,
+      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+      Provider<PublicKeyStoreUtil> publicKeyStoreUtilProvider) {
+    this.self = self;
+    this.serverIdent = serverIdent;
+    this.accountsUpdateProvider = accountsUpdateProvider;
+    this.authorizedKeys = authorizedKeys;
+    this.sshKeyCache = sshKeyCache;
+    this.starredChangesUtil = starredChangesUtil;
+    this.deleteDraftCommentsUtil = deleteDraftCommentsUtil;
+    this.gitManager = gitManager;
+    this.queryProvider = queryProvider;
+    this.changeEditUtil = changeEditUtil;
+    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.publicKeyStoreUtilProvider = publicKeyStoreUtilProvider;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource rsrc, Input unusedInput)
+      throws AuthException, AccountException {
+    IdentifiedUser user = rsrc.getUser();
+    if (!self.get().hasSameAccountId(user)) {
+      throw new AuthException("Delete account is only permitted for self");
+    }
+
+    Account.Id userId = user.getAccountId();
+    try {
+      deletePgpKeys(user);
+      deleteSshKeys(user);
+      deleteStarredChanges(userId);
+      deleteChangeEdits(userId);
+      deleteDraftCommentsUtil.deleteDraftComments(user, null);
+      accountPatchReviewStore.run(a -> a.clearReviewedBy(userId));
+      accountsUpdateProvider
+          .get()
+          .delete("Deleting user through `DELETE /accounts/{ID}`", user.getAccountId());
+    } catch (Exception e) {
+      throw new AccountException("Could not delete account", e);
+    }
+    return Response.none();
+  }
+
+  private void deletePgpKeys(IdentifiedUser user) {
+    if (publicKeyStoreUtilProvider == null || publicKeyStoreUtilProvider.get() == null) {
+      return;
+    }
+    try {
+      PublicKeyStoreUtil storeUtil = publicKeyStoreUtilProvider.get();
+      List<RefUpdate.Result> deletedKeyResults =
+          storeUtil.deleteAllPgpKeysForUser(
+              user.getAccountId(), serverIdent.get(), serverIdent.get());
+      for (RefUpdate.Result saveResult : deletedKeyResults) {
+        if (saveResult != RefUpdate.Result.NO_CHANGE
+            && saveResult != RefUpdate.Result.FAST_FORWARD) {
+          throw new StorageException(String.format("Failed to delete PGP key: %s", saveResult));
+        }
+      }
+    } catch (Exception e) {
+      throw new StorageException("Failed to delete PGP keys.", e);
+    }
+  }
+
+  private void deleteSshKeys(IdentifiedUser user) throws ConfigInvalidException, IOException {
+    List<AccountSshKey> keys = authorizedKeys.getKeys(user.getAccountId());
+    for (AccountSshKey key : keys) {
+      authorizedKeys.deleteKey(user.getAccountId(), key.seq());
+    }
+    user.getUserName().ifPresent(sshKeyCache::evict);
+  }
+
+  private void deleteStarredChanges(Account.Id accountId)
+      throws StarredChangesUtil.IllegalLabelException {
+    ImmutableSet<Change.Id> staredChanges = starredChangesUtil.byAccountId(accountId, false);
+    for (Change.Id change : staredChanges) {
+      starredChangesUtil.star(
+          self.get().getAccountId(), change, StarredChangesUtil.Operation.REMOVE);
+    }
+  }
+
+  private void deleteChangeEdits(Account.Id accountId) throws IOException {
+    // Note: in case of a stale index, the results of this query might be incomplete.
+    List<ChangeData> changesWithEdits = queryProvider.get().byOpenEditByUser(accountId);
+
+    for (ChangeData cd : changesWithEdits) {
+      for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : cd.editRefs().cellSet()) {
+        if (!accountId.equals(edit.getRowKey())) {
+          continue;
+        }
+        try (Repository repo = gitManager.openRepository(cd.project());
+            RevWalk rw = new RevWalk(repo)) {
+          RevCommit commit = rw.parseCommit(edit.getValue().getObjectId());
+          changeEditUtil.delete(
+              new ChangeEdit(
+                  cd.change(),
+                  edit.getValue().getName(),
+                  commit,
+                  cd.patchSet(edit.getColumnKey())));
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
index 9e02592..336d5a4 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -89,7 +89,7 @@
   }
 
   public ImmutableList<DeletedDraftCommentInfo> deleteDraftComments(
-      IdentifiedUser user, String query) throws RestApiException, UpdateException {
+      IdentifiedUser user, @Nullable String query) throws RestApiException, UpdateException {
     CommentJson.HumanCommentFormatter humanCommentFormatter =
         commentJsonProvider.get().newHumanCommentFormatter();
     Account.Id accountId = user.getAccountId();
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 189d448..35b9893 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -343,6 +343,22 @@
   }
 
   @Override
+  public void clearReviewedBy(Account.Id accountId) {
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Clear all reviewed flags by user",
+                Metadata.builder().accountId(accountId.get()).build());
+        Connection con = ds.getConnection();
+        PreparedStatement stmt =
+            con.prepareStatement("DELETE FROM account_patch_reviews WHERE account_id = ?")) {
+      stmt.setInt(1, accountId.get());
+      stmt.executeUpdate();
+    } catch (SQLException e) {
+      throw convertError("delete", e);
+    }
+  }
+
+  @Override
   public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
     try (TraceTimer ignored =
             TraceContext.newTimer(
diff --git a/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
index 1533aeb..0c612f0 100644
--- a/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
@@ -117,6 +117,19 @@
   }
 
   @Override
+  public void clearReviewedBy(Account.Id accountId) {
+    synchronized (store) {
+      List<Entity> toRemove = new ArrayList<>();
+      for (Entity entity : store) {
+        if (entity.accountId().equals(accountId)) {
+          toRemove.add(entity);
+        }
+      }
+      store.removeAll(toRemove);
+    }
+  }
+
+  @Override
   public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
     synchronized (store) {
       int matchedPsNumber = -1;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index dd04200..2c65019 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -83,6 +83,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
@@ -145,6 +146,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -175,6 +177,8 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -255,6 +259,8 @@
 
   @Inject protected GroupOperations groupOperations;
 
+  @Inject private AccountPatchReviewStore accountPatchReviewStore;
+
   private BasicCookieStore httpCookieStore;
   private CloseableHttpClient httpclient;
 
@@ -3224,6 +3230,214 @@
     }
   }
 
+  @Test
+  public void deleteAccount_deletesAccountIdentifiers() throws Exception {
+    TestAccount deleted = accountCreator.createValid(testMethodName);
+    String secondaryEmail = "secondary@email.com";
+    gApi.accounts().id(deleted.id().get()).addEmail(newEmailInput(secondaryEmail));
+
+    requestScopeOperations.setApiUser(deleted.id());
+    gApi.accounts().self().delete();
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(deleted.id().get()).get());
+    assertThrows(NoSuchElementException.class, () -> accountCache.get(deleted.id()).get());
+
+    // Verifies the account is not queryable
+    assertThat(gApi.accounts().query(deleted.id().toString()).get()).isEmpty();
+    assertThat(gApi.accounts().query(deleted.username()).get()).isEmpty();
+    assertThat(gApi.accounts().query(deleted.fullName()).get()).isEmpty();
+    assertThat(gApi.accounts().query(deleted.displayName()).get()).isEmpty();
+    assertThat(gApi.accounts().query(deleted.email()).get()).isEmpty();
+    assertThat(gApi.accounts().query(secondaryEmail).get()).isEmpty();
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesAccountExternalIds() throws Exception {
+    TestAccount deleted =
+        accountCreator.create("deleted", "deleted@internal.com", "Full Name", "Display");
+    requestScopeOperations.setApiUser(deleted.id());
+    addExternalIdEmail(deleted, "deleted@external.com");
+    assertExternalEmails(
+        deleted.id(), ImmutableSet.of("deleted@internal.com", "deleted@external.com"));
+
+    gApi.accounts().self().delete();
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(externalIds.allByEmail().keySet())
+        .containsNoneOf("deleted@internal.com", "deleted@external.com");
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  @UseSsh
+  public void deleteAccount_deletesSshKeys() throws Exception {
+    TestAccount deleted = accountCreator.createValid(testMethodName);
+    requestScopeOperations.setApiUser(deleted.id());
+    String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), deleted.email());
+    gApi.accounts().self().addSshKey(newKey);
+    assertThat(gApi.accounts().self().listSshKeys()).hasSize(1);
+    assertThat(authorizedKeys.getKeys(deleted.id())).hasSize(1);
+
+    gApi.accounts().self().delete();
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(authorizedKeys.getKeys(deleted.id())).isEmpty();
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesGpgKeys() throws Exception {
+    TestAccount deleted = accountCreator.createValid(testMethodName);
+
+    requestScopeOperations.setApiUser(deleted.id());
+    addExternalIdEmail(
+        deleted,
+        PushCertificateIdent.parse(validKeyWithoutExpiration().getFirstUserId()).getEmailAddress());
+    TestKey key = validKeyWithoutExpiration();
+    addGpgKey(deleted, key.getPublicKeyArmored());
+    assertKeys(key);
+    assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
+
+    gApi.accounts().self().delete();
+
+    requestScopeOperations.setApiUser(admin.id());
+    try (PublicKeyStore store = publicKeyStoreProvider.get()) {
+      Iterable<PGPPublicKeyRing> keys = store.get(key.getKeyId());
+      assertThat(keys).isEmpty();
+    }
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesStarredChanges() throws Exception {
+    TestAccount deleted = accountCreator.createValid(testMethodName);
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    requestScopeOperations.setApiUser(deleted.id());
+
+    gApi.accounts().self().starChange(triplet);
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(
+              repo.getRefDatabase()
+                  .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
+          .hasSize(1);
+
+      gApi.accounts().self().delete();
+
+      assertThat(
+              repo.getRefDatabase()
+                  .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
+          .isEmpty();
+    }
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesChangeEdits() throws Exception {
+    TestAccount deleted = accountCreator.createValid(testMethodName);
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(deleted.id());
+
+    gApi.changes().id(r.getChangeId()).edit().create();
+    gApi.changes()
+        .id(r.getChangeId())
+        .edit()
+        .modifyFile(PushOneCommit.FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
+    try (Repository repo = repoManager.openRepository(r.getChange().change().getProject())) {
+      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsEditPrefix(deleted.id())))
+          .hasSize(1);
+
+      gApi.accounts().self().delete();
+
+      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsEditPrefix(deleted.id())))
+          .isEmpty();
+    }
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesDraftComments() throws Exception {
+    TestAccount deleted = accountCreator.createValid(testMethodName);
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(deleted.id());
+
+    createDraft(r, PushOneCommit.FILE_NAME, "draft");
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(
+              repo.getRefDatabase()
+                  .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
+          .hasSize(1);
+
+      gApi.accounts().self().delete();
+
+      assertThat(
+              repo.getRefDatabase()
+                  .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
+          .isEmpty();
+    }
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesReviewedFlags() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount deleted = accountCreator.createValid(testMethodName);
+    ReviewerInput in = new ReviewerInput();
+    in.reviewer = deleted.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    requestScopeOperations.setApiUser(deleted.id());
+
+    accountPatchReviewStore.markReviewed(r.getPatchSetId(), deleted.id(), PushOneCommit.FILE_NAME);
+    assertThat(accountPatchReviewStore.findReviewed(r.getPatchSetId(), deleted.id())).isPresent();
+
+    gApi.accounts().self().delete();
+
+    assertThat(accountPatchReviewStore.findReviewed(r.getPatchSetId(), deleted.id())).isEmpty();
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_appliesForSelfById() throws Exception {
+    TestAccount deleted = accountCreator.createValid(testMethodName);
+    requestScopeOperations.setApiUser(deleted.id());
+    gApi.accounts().id(deleted.id().get()).delete();
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_throwsForOtherUsers() throws Exception {
+    TestAccount deleted = accountCreator.createValid(testMethodName);
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.accounts().id(deleted.id().get()).delete());
+    assertThat(thrown).hasMessageThat().isEqualTo("Delete account is only permitted for self");
+  }
+
   private TestGroupBackend createTestGroupBackendWithAllUsersGroup(String nameOfAllUsersGroup)
       throws IOException {
     TestGroupBackend testGroupBackend = new TestGroupBackend();
@@ -3281,6 +3495,16 @@
         .isEqualTo(extIds);
   }
 
+  private void assertExternalEmails(Account.Id accountId, ImmutableSet<String> extIds)
+      throws Exception {
+    assertThat(
+            gApi.accounts().id(accountId.get()).getExternalIds().stream()
+                .map(e -> e.emailAddress)
+                .filter(Objects::nonNull)
+                .collect(toImmutableSet()))
+        .isEqualTo(extIds);
+  }
+
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
     return NullAwareCorrespondence.transforming(groupInfo -> groupInfo.name, "has name");
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
index 13353bd..eed4d63 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
@@ -97,7 +97,9 @@
           RestCall.get("/accounts/%s/capabilities"),
           RestCall.get("/accounts/%s/capabilities/viewPlugins"),
           RestCall.get("/accounts/%s/gpgkeys"),
-          RestCall.post("/accounts/%s/gpgkeys"));
+          RestCall.post("/accounts/%s/gpgkeys"),
+          // Account deletion must be the last tested endpoint
+          RestCall.delete("/accounts/%s"));
 
   /**
    * Email REST endpoints to be tested, each URL contains a placeholders for the account and email