Merge "Add a stop-gap fix for the comment rendering."
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/GitUpdateFailureException.java b/java/com/google/gerrit/git/GitUpdateFailureException.java
index 7fcb828..339339c 100644
--- a/java/com/google/gerrit/git/GitUpdateFailureException.java
+++ b/java/com/google/gerrit/git/GitUpdateFailureException.java
@@ -46,6 +46,11 @@
.collect(toImmutableList());
}
+ protected GitUpdateFailureException(String message, Throwable cause) {
+ super(message, cause);
+ this.failures = ImmutableList.of();
+ }
+
/** Returns the names of the refs for which the update failed. */
public ImmutableList<String> getFailedRefs() {
return failures.stream().map(GitUpdateFailure::ref).collect(toImmutableList());
diff --git a/java/com/google/gerrit/git/LockFailureException.java b/java/com/google/gerrit/git/LockFailureException.java
index 371488d..2908db2 100644
--- a/java/com/google/gerrit/git/LockFailureException.java
+++ b/java/com/google/gerrit/git/LockFailureException.java
@@ -14,6 +14,7 @@
package com.google.gerrit.git;
+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.RefUpdate;
@@ -21,6 +22,9 @@
public class LockFailureException extends GitUpdateFailureException {
private static final long serialVersionUID = 1L;
+ private static final String REF_UPDATE_RETURN_CODE_WAS_LOCK_FAILURE =
+ "RefUpdate return code was: LOCK_FAILURE";
+
public LockFailureException(String message, RefUpdate refUpdate) {
super(message, refUpdate);
}
@@ -28,4 +32,15 @@
public LockFailureException(String message, BatchRefUpdate batchRefUpdate) {
super(message, batchRefUpdate);
}
+
+ protected LockFailureException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static void throwIfLockFailure(ConcurrentRefUpdateException e)
+ throws LockFailureException {
+ if (e.getMessage().contains(REF_UPDATE_RETURN_CODE_WAS_LOCK_FAILURE)) {
+ throw new LockFailureException(e.getMessage(), e);
+ }
+ }
}
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..f074522 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -18,6 +18,7 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.NotifyConfig.NotifyType;
@@ -161,6 +162,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()
@@ -182,6 +192,7 @@
*
* @param fullName the new full name, if {@code null} or empty string the full name is unset
*/
+ @CanIgnoreReturnValue
public abstract Builder setFullName(@Nullable String fullName);
/**
@@ -190,6 +201,7 @@
* @param displayName the new display name, if {@code null} or empty string the display name is
* unset
*/
+ @CanIgnoreReturnValue
public abstract Builder setDisplayName(@Nullable String displayName);
/**
@@ -198,6 +210,7 @@
* @param preferredEmail the new preferred email, if {@code null} or empty string the preferred
* email is unset
*/
+ @CanIgnoreReturnValue
public abstract Builder setPreferredEmail(@Nullable String preferredEmail);
/**
@@ -206,6 +219,7 @@
* @param active {@code true} if the account should be set to active, {@code false} if the
* account should be set to inactive
*/
+ @CanIgnoreReturnValue
public abstract Builder setActive(boolean active);
/**
@@ -213,6 +227,7 @@
*
* @param status the new status, if {@code null} or empty string the status is unset
*/
+ @CanIgnoreReturnValue
public abstract Builder setStatus(@Nullable String status);
/**
@@ -234,6 +249,7 @@
* @param extId external ID that should be added
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder addExternalId(ExternalId extId) {
return addExternalIds(ImmutableSet.of(extId));
}
@@ -250,6 +266,7 @@
* @param extIds external IDs that should be added
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder addExternalIds(Collection<ExternalId> extIds) {
createdExternalIdsBuilder().addAll(extIds);
return this;
@@ -273,6 +290,7 @@
* @param extId external ID that should be updated
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder updateExternalId(ExternalId extId) {
return updateExternalIds(ImmutableSet.of(extId));
}
@@ -289,6 +307,7 @@
* @param extIds external IDs that should be updated
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder updateExternalIds(Collection<ExternalId> extIds) {
updatedExternalIdsBuilder().addAll(extIds);
return this;
@@ -312,6 +331,7 @@
* @param extId external ID that should be deleted
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder deleteExternalId(ExternalId extId) {
return deleteExternalIds(ImmutableSet.of(extId));
}
@@ -327,6 +347,7 @@
* @param extIds external IDs that should be deleted
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder deleteExternalIds(Collection<ExternalId> extIds) {
deletedExternalIdsBuilder().addAll(extIds);
return this;
@@ -339,6 +360,7 @@
* @param extIdToAdd external ID that should be added
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder replaceExternalId(ExternalId extIdToDelete, ExternalId extIdToAdd) {
return replaceExternalIds(ImmutableSet.of(extIdToDelete), ImmutableSet.of(extIdToAdd));
}
@@ -350,6 +372,7 @@
* @param extIdsToAdd external IDs that should be added
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder replaceExternalIds(
Collection<ExternalId> extIdsToDelete, Collection<ExternalId> extIdsToAdd) {
return deleteExternalIds(extIdsToDelete).addExternalIds(extIdsToAdd);
@@ -371,6 +394,7 @@
* @param notifyTypes the notify types that should be set for the project watch
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder updateProjectWatch(
ProjectWatchKey projectWatchKey, Set<NotifyType> notifyTypes) {
return updateProjectWatches(ImmutableMap.of(projectWatchKey, notifyTypes));
@@ -385,6 +409,7 @@
* @param projectWatches project watches that should be updated
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
updatedProjectWatchesBuilder().putAll(projectWatches);
return this;
@@ -405,6 +430,7 @@
* @param projectWatch project watch that should be deleted
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder deleteProjectWatch(ProjectWatchKey projectWatch) {
return deleteProjectWatches(ImmutableSet.of(projectWatch));
}
@@ -417,6 +443,7 @@
* @param projectWatches project watches that should be deleted
* @return the builder
*/
+ @CanIgnoreReturnValue
public Builder deleteProjectWatches(Collection<ProjectWatchKey> projectWatches) {
deletedProjectWatchesBuilder().addAll(projectWatches);
return this;
@@ -430,6 +457,7 @@
* @param generalPreferences the general preferences that should be set
* @return the builder
*/
+ @CanIgnoreReturnValue
public abstract Builder setGeneralPreferences(GeneralPreferencesInfo generalPreferences);
/**
@@ -440,6 +468,7 @@
* @param diffPreferences the diff preferences that should be set
* @return the builder
*/
+ @CanIgnoreReturnValue
public abstract Builder setDiffPreferences(DiffPreferencesInfo diffPreferences);
/**
@@ -450,8 +479,25 @@
* @param editPreferences the edit preferences that should be set
* @return the builder
*/
+ @CanIgnoreReturnValue
public abstract Builder setEditPreferences(EditPreferencesInfo editPreferences);
+ @CanIgnoreReturnValue
+ public abstract Builder setShouldDeleteAccount(boolean shouldDelete);
+
+ /**
+ * Builds an AccountDelta that deletes all data.
+ *
+ * @param extIdsToDelete external IDs that should be deleted
+ * @return the builder
+ */
+ @CanIgnoreReturnValue
+ public Builder deleteAccount(Collection<ExternalId> extIdsToDelete) {
+ deleteExternalIds(extIdsToDelete);
+ setShouldDeleteAccount(true);
+ return this;
+ }
+
/** Builds the instance. */
public abstract AccountDelta build();
@@ -602,6 +648,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/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 9196db8..ef1781e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -27,7 +27,6 @@
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.git.ObjectIds;
import java.io.Serializable;
import java.util.Collection;
import java.util.Locale;
@@ -294,15 +293,6 @@
return key().isScheme(scheme);
}
- public byte[] toByteArray() {
- checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
- byte[] b = new byte[2 * ObjectIds.STR_LEN + 1];
- key().sha1().copyTo(b, 0);
- b[ObjectIds.STR_LEN] = ':';
- blobId().copyTo(b, ObjectIds.STR_LEN + 1);
- return b;
- }
-
/**
* For checking if two external IDs are equals the blobId is excluded and external IDs that have
* different blob IDs but identical other fields are considered equal. This way an external ID
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/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index e675003..f87fbf5 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -14,13 +14,16 @@
package com.google.gerrit.server.index.account;
+import static com.google.common.base.Preconditions.checkState;
import static java.util.stream.Collectors.toSet;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.gerrit.common.data.GlobalCapability;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.git.ObjectIds;
import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.RefState;
import com.google.gerrit.index.SchemaUtil;
@@ -237,12 +240,22 @@
a ->
a.externalIds().stream()
.filter(e -> e.blobId() != null)
- .map(ExternalId::toByteArray)
+ .map(AccountField::serializeExternalId)
.collect(toSet()));
public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec
EXTERNAL_ID_STATE_SPEC = EXTERNAL_ID_STATE_FIELD.storedOnly("external_id_state");
+ @VisibleForTesting
+ public static byte[] serializeExternalId(ExternalId extId) {
+ checkState(extId.blobId() != null, "Missing blobId in external ID %s", extId.key().get());
+ byte[] b = new byte[2 * ObjectIds.STR_LEN + 1];
+ extId.key().sha1().copyTo(b, 0);
+ b[ObjectIds.STR_LEN] = ':';
+ extId.blobId().copyTo(b, ObjectIds.STR_LEN + 1);
+ return b;
+ }
+
private static final Set<String> getNameParts(AccountState a, Iterable<String> emails) {
String fullName = a.account().fullName();
Set<String> parts = SchemaUtil.getNameParts(fullName, emails);
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/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 34c3ff7..b12ceef 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -29,6 +29,7 @@
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.git.LockFailureException;
import com.google.gerrit.server.WebLinks;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
@@ -48,6 +49,7 @@
import java.time.ZoneId;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
@@ -141,18 +143,23 @@
.newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
}
- Ref result = tag.call();
- tagCache.updateFastForward(
- resource.getNameKey(), ref, ObjectId.zeroId(), result.getObjectId());
- referenceUpdated.fire(
- resource.getNameKey(),
- ref,
- ObjectId.zeroId(),
- result.getObjectId(),
- resource.getUser().asIdentifiedUser().state());
- try (RevWalk w = new RevWalk(repo)) {
- return Response.created(
- ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
+ try {
+ Ref result = tag.call();
+ tagCache.updateFastForward(
+ resource.getNameKey(), ref, ObjectId.zeroId(), result.getObjectId());
+ referenceUpdated.fire(
+ resource.getNameKey(),
+ ref,
+ ObjectId.zeroId(),
+ result.getObjectId(),
+ resource.getUser().asIdentifiedUser().state());
+ try (RevWalk w = new RevWalk(repo)) {
+ return Response.created(
+ ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
+ }
+ } catch (ConcurrentRefUpdateException e) {
+ LockFailureException.throwIfLockFailure(e);
+ throw e;
}
}
} catch (GitAPIException e) {
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
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index aea4b95..c530c79 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -705,7 +705,7 @@
for (AccountExternalIdInfo info : externalIdInfos) {
Optional<ExternalId> extId = externalIds.get(externalIdKeyFactory.parse(info.identity));
assertThat(extId).isPresent();
- blobs.add(new ByteArrayWrapper(extId.get().toByteArray()));
+ blobs.add(new ByteArrayWrapper(AccountField.serializeExternalId(extId.get())));
}
// Some installations do not store EXTERNAL_ID_STATE_SPEC
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index c781d8b..2c31aef 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -16,6 +16,7 @@
"//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
+ "//java/com/google/gerrit/git",
"//java/com/google/gerrit/index",
"//java/com/google/gerrit/lifecycle",
"//java/com/google/gerrit/server",
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 158799d..1d2b39e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -60,7 +60,6 @@
import {Subject} from 'rxjs';
import {debounceTime} from 'rxjs/operators';
import {changeModelToken} from '../../../models/change/change-model';
-import {KnownExperimentId} from '../../../services/flags/flags';
import {isBase64FileContent} from '../../../api/rest-api';
import {createDiffUrl} from '../../../models/views/change';
import {userModelToken} from '../../../models/user/user-model';
@@ -207,8 +206,6 @@
private readonly reporting = getAppContext().reportingService;
- private readonly flagsService = getAppContext().flagsService;
-
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getCommentsModel = resolve(this, commentsModelToken);
@@ -777,9 +774,6 @@
}
private renderSuggestEditButton() {
- if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
- return nothing;
- }
if (
!this.editing ||
this.permanentEditingMode ||
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 59485e2..607d0f2 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -378,6 +378,17 @@
</gr-tooltip-content>
</div>
<div class="headerMiddle"></div>
+ <gr-button
+ aria-disabled="false"
+ class="action suggestEdit"
+ link=""
+ role="button"
+ tabindex="0"
+ title="This button copies the text to make a suggestion"
+ >
+ <gr-icon filled="" icon="edit" id="icon"> </gr-icon>
+ Suggest edit
+ </gr-button>
<span class="patchset-text">Patchset 1</span>
<span class="separator"></span>
<span class="date" tabindex="0">
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 405409d..9f3d79b 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -19,8 +19,6 @@
import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
import '../gr-account-chip/gr-account-chip';
import '../gr-user-suggestion-fix/gr-user-suggestion-fix';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {getAppContext} from '../../../services/app-context';
import {
getUserSuggestionFromString,
USER_SUGGESTION_INFO_STRING,
@@ -42,8 +40,6 @@
@state()
private repoCommentLinks: CommentLinks = {};
- private readonly flagsService = getAppContext().flagsService;
-
private readonly getConfigModel = resolve(this, configModelToken);
// Private const but used in tests.
@@ -158,10 +154,6 @@
}
private renderAsMarkdown() {
- // Need to find out here, since customRender is not arrow function
- const suggestEditsEnable = this.flagsService.isEnabled(
- KnownExperimentId.SUGGEST_EDIT
- );
// Bind `this` via closure.
const boundRewriteText = (text: string) => {
const nonAsteriskRewrites = Object.fromEntries(
@@ -216,7 +208,7 @@
renderer['codespan'] = (text: string) =>
`<code>${unescapeHTML(text)}</code>`;
renderer['code'] = (text: string, infostring: string) => {
- if (suggestEditsEnable && infostring === USER_SUGGESTION_INFO_STRING) {
+ if (infostring === USER_SUGGESTION_INFO_STRING) {
// default santizer in markedjs is very restrictive, we need to use
// existing html element to mark element. We cannot use css class for
// it. Therefore we pick mark - as not frequently used html element to
@@ -275,9 +267,7 @@
override updated() {
// Look for @mentions and replace them with an account-label chip.
this.convertEmailsToAccountChips();
- if (this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
- this.convertCodeToSuggestions();
- }
+ this.convertCodeToSuggestions();
}
private convertEmailsToAccountChips() {
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
index ea67661..95c525a 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -5,8 +5,6 @@
*/
import {css, html, LitElement, nothing} from 'lit';
import {customElement} from 'lit/decorators.js';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
import {fire} from '../../../utils/event-util';
declare global {
@@ -23,8 +21,6 @@
@customElement('gr-user-suggestion-fix')
export class GrUserSuggetionFix extends LitElement {
- private readonly flagsService = getAppContext().flagsService;
-
static override get styles() {
return [
css`
@@ -65,9 +61,6 @@
}
override render() {
- if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
- return nothing;
- }
if (!this.textContent) return nothing;
const code = this.textContent;
return html`<div class="header">
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 7488e79..e55c6d3 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -18,5 +18,4 @@
CHECKS_DEVELOPER = 'UiFeature__checks_developer',
PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
- SUGGEST_EDIT = 'UiFeature__suggest_edit',
}