blob: c0c934b140d6f2f9ef525a530d7017633b72415a [file] [log] [blame]
// Copyright (C) 2017 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.group.db;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.DuplicateKeyException;
import com.google.gerrit.exceptions.NoSuchGroupException;
import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.GroupCache;
import com.google.gerrit.server.account.GroupIncludeCache;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.group.GroupAuditService;
import com.google.gerrit.server.index.group.GroupIndexer;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Provider;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import java.io.IOException;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
/**
* A database accessor for write calls related to groups.
*
* <p>All calls which write group related details to the database are gathered here. Other classes
* should always use this class instead of accessing the database directly. There are a few
* exceptions though: schema classes, wrapper classes, and classes executed during init. The latter
* ones should use {@link com.google.gerrit.pgm.init.GroupsOnInit} instead.
*
* <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
*/
public class GroupsUpdate {
public interface Factory {
/**
* Creates a {@link GroupsUpdate} which uses the identity of the specified user to mark database
* modifications executed by it. For NoteDb, this identity is used as author and committer for
* all related commits.
*
* <p><strong>Note</strong>: Please use this method with care and consider using the {@link
* com.google.gerrit.server.UserInitiated} annotation on the provider of a {@link GroupsUpdate}
* instead.
*
* @param currentUser the user to which modifications should be attributed
*/
GroupsUpdate create(IdentifiedUser currentUser);
/**
* Creates a {@link GroupsUpdate} which uses the server identity to mark database modifications
* executed by it. For NoteDb, this identity is used as author and committer for all related
* commits.
*
* <p><strong>Note</strong>: Please use this method with care and consider using the {@link
* com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@link
* GroupsUpdate} instead.
*/
GroupsUpdate createWithServerIdent();
}
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final GitRepositoryManager repoManager;
private final AllUsersName allUsersName;
private final GroupCache groupCache;
private final GroupIncludeCache groupIncludeCache;
private final Provider<GroupIndexer> indexer;
private final GroupAuditService groupAuditService;
private final RenameGroupOp.Factory renameGroupOpFactory;
private final Optional<IdentifiedUser> currentUser;
private final AuditLogFormatter auditLogFormatter;
private final PersonIdent authorIdent;
private final MetaDataUpdateFactory metaDataUpdateFactory;
private final GitReferenceUpdated gitRefUpdated;
private final RetryHelper retryHelper;
@AssistedInject
@SuppressWarnings("BindingAnnotationWithoutInject")
GroupsUpdate(
GitRepositoryManager repoManager,
AllUsersName allUsersName,
GroupBackend groupBackend,
GroupCache groupCache,
GroupIncludeCache groupIncludeCache,
Provider<GroupIndexer> indexer,
GroupAuditService auditService,
AccountCache accountCache,
RenameGroupOp.Factory renameGroupOpFactory,
@GerritServerId String serverId,
@GerritPersonIdent PersonIdent serverIdent,
MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
GitReferenceUpdated gitRefUpdated,
RetryHelper retryHelper) {
this(
repoManager,
allUsersName,
groupBackend,
groupCache,
groupIncludeCache,
indexer,
auditService,
accountCache,
renameGroupOpFactory,
serverId,
serverIdent,
metaDataUpdateInternalFactory,
gitRefUpdated,
retryHelper,
Optional.empty());
}
@AssistedInject
@SuppressWarnings("BindingAnnotationWithoutInject")
GroupsUpdate(
GitRepositoryManager repoManager,
AllUsersName allUsersName,
GroupBackend groupBackend,
GroupCache groupCache,
GroupIncludeCache groupIncludeCache,
Provider<GroupIndexer> indexer,
GroupAuditService auditService,
AccountCache accountCache,
RenameGroupOp.Factory renameGroupOpFactory,
@GerritServerId String serverId,
@GerritPersonIdent PersonIdent serverIdent,
MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
GitReferenceUpdated gitRefUpdated,
RetryHelper retryHelper,
@Assisted IdentifiedUser currentUser) {
this(
repoManager,
allUsersName,
groupBackend,
groupCache,
groupIncludeCache,
indexer,
auditService,
accountCache,
renameGroupOpFactory,
serverId,
serverIdent,
metaDataUpdateInternalFactory,
gitRefUpdated,
retryHelper,
Optional.of(currentUser));
}
@SuppressWarnings("BindingAnnotationWithoutInject")
private GroupsUpdate(
GitRepositoryManager repoManager,
AllUsersName allUsersName,
GroupBackend groupBackend,
GroupCache groupCache,
GroupIncludeCache groupIncludeCache,
Provider<GroupIndexer> indexer,
GroupAuditService auditService,
AccountCache accountCache,
RenameGroupOp.Factory renameGroupOpFactory,
@GerritServerId String serverId,
@GerritPersonIdent PersonIdent serverIdent,
MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
GitReferenceUpdated gitRefUpdated,
RetryHelper retryHelper,
Optional<IdentifiedUser> currentUser) {
this.repoManager = repoManager;
this.allUsersName = allUsersName;
this.groupCache = groupCache;
this.groupIncludeCache = groupIncludeCache;
this.indexer = indexer;
this.groupAuditService = auditService;
this.renameGroupOpFactory = renameGroupOpFactory;
this.gitRefUpdated = gitRefUpdated;
this.retryHelper = retryHelper;
this.currentUser = currentUser;
auditLogFormatter = AuditLogFormatter.createBackedBy(accountCache, groupBackend, serverId);
metaDataUpdateFactory =
getMetaDataUpdateFactory(
metaDataUpdateInternalFactory, currentUser, serverIdent, auditLogFormatter);
authorIdent = getAuthorIdent(serverIdent, currentUser);
}
private static MetaDataUpdateFactory getMetaDataUpdateFactory(
MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
Optional<IdentifiedUser> currentUser,
PersonIdent serverIdent,
AuditLogFormatter auditLogFormatter) {
return (projectName, repository, batchRefUpdate) -> {
MetaDataUpdate metaDataUpdate =
metaDataUpdateInternalFactory.create(projectName, repository, batchRefUpdate);
metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
PersonIdent authorIdent;
if (currentUser.isPresent()) {
metaDataUpdate.setAuthor(currentUser.get());
authorIdent =
auditLogFormatter.getParsableAuthorIdent(currentUser.get().getAccount(), serverIdent);
} else {
authorIdent = serverIdent;
}
metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
return metaDataUpdate;
};
}
private static PersonIdent getAuthorIdent(
PersonIdent serverIdent, Optional<IdentifiedUser> currentUser) {
return currentUser.map(user -> createPersonIdent(serverIdent, user)).orElse(serverIdent);
}
private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
return user.newCommitterIdent(ident);
}
/**
* Creates the specified group for the specified members (accounts).
*
* @param groupCreation an {@link InternalGroupCreation} which specifies all mandatory properties
* of the group
* @param groupDelta a {@link GroupDelta} which specifies optional properties of the group. If
* this {@link GroupDelta} updates a property which was already specified by the {@link
* InternalGroupCreation}, the value of this {@link GroupDelta} wins.
* @throws DuplicateKeyException if a group with the chosen name already exists
* @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
* @return the created {@link InternalGroup}
*/
public InternalGroup createGroup(InternalGroupCreation groupCreation, GroupDelta groupDelta)
throws DuplicateKeyException, IOException, ConfigInvalidException {
try (TraceTimer ignored =
TraceContext.newTimer(
"Creating group",
Metadata.builder()
.groupName(groupDelta.getName().orElseGet(groupCreation::getNameKey).get())
.build())) {
InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupDelta);
evictCachesOnGroupCreation(createdGroup);
dispatchAuditEventsOnGroupCreation(createdGroup);
return createdGroup;
}
}
/**
* Updates the specified group.
*
* @param groupUuid the UUID of the group to update
* @param groupDelta a {@link GroupDelta} which indicates the desired updates on the group
* @throws DuplicateKeyException if the new name of the group is used by another group
* @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
* @throws NoSuchGroupException if the specified group doesn't exist
*/
public void updateGroup(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
throws DuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
try (TraceTimer ignored =
TraceContext.newTimer(
"Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
Optional<Instant> updatedOn = groupDelta.getUpdatedOn();
if (!updatedOn.isPresent()) {
updatedOn = Optional.of(TimeUtil.now());
groupDelta = groupDelta.toBuilder().setUpdatedOn(updatedOn.get()).build();
}
UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupDelta);
updateNameInProjectConfigsIfNecessary(result);
evictCachesOnGroupUpdate(result);
dispatchAuditEventsOnGroupUpdate(result, updatedOn.get());
}
}
private InternalGroup createGroupInNoteDbWithRetry(
InternalGroupCreation groupCreation, GroupDelta groupDelta)
throws IOException, ConfigInvalidException, DuplicateKeyException {
try {
return retryHelper
.groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupDelta))
.call();
} catch (Exception e) {
Throwables.throwIfUnchecked(e);
Throwables.throwIfInstanceOf(e, IOException.class);
Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
throw new IOException(e);
}
}
@VisibleForTesting
public InternalGroup createGroupInNoteDb(
InternalGroupCreation groupCreation, GroupDelta groupDelta)
throws IOException, ConfigInvalidException, DuplicateKeyException {
try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
AccountGroup.NameKey groupName = groupDelta.getName().orElseGet(groupCreation::getNameKey);
GroupNameNotes groupNameNotes =
GroupNameNotes.forNewGroup(
allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
GroupConfig groupConfig =
GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
commit(allUsersRepo, groupConfig, groupNameNotes);
return groupConfig
.getLoadedGroup()
.orElseThrow(
() -> new IllegalStateException("Created group wasn't automatically loaded"));
}
}
private UpdateResult updateGroupInNoteDbWithRetry(
AccountGroup.UUID groupUuid, GroupDelta groupDelta)
throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
try {
return retryHelper
.groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupDelta))
.call();
} catch (Exception e) {
Throwables.throwIfUnchecked(e);
Throwables.throwIfInstanceOf(e, IOException.class);
Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
Throwables.throwIfInstanceOf(e, NoSuchGroupException.class);
throw new IOException(e);
}
}
@VisibleForTesting
public UpdateResult updateGroupInNoteDb(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
if (!groupConfig.getLoadedGroup().isPresent()) {
throw new NoSuchGroupException(groupUuid);
}
InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
GroupNameNotes groupNameNotes = null;
if (groupDelta.getName().isPresent()) {
AccountGroup.NameKey oldName = originalGroup.getNameKey();
AccountGroup.NameKey newName = groupDelta.getName().get();
groupNameNotes =
GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
}
commit(allUsersRepo, groupConfig, groupNameNotes);
InternalGroup updatedGroup =
groupConfig
.getLoadedGroup()
.orElseThrow(
() -> new IllegalStateException("Updated group wasn't automatically loaded"));
return getUpdateResult(originalGroup, updatedGroup);
}
}
private static UpdateResult getUpdateResult(
InternalGroup originalGroup, InternalGroup updatedGroup) {
Set<Account.Id> addedMembers =
Sets.difference(updatedGroup.getMembers(), originalGroup.getMembers());
Set<Account.Id> deletedMembers =
Sets.difference(originalGroup.getMembers(), updatedGroup.getMembers());
Set<AccountGroup.UUID> addedSubgroups =
Sets.difference(updatedGroup.getSubgroups(), originalGroup.getSubgroups());
Set<AccountGroup.UUID> deletedSubgroups =
Sets.difference(originalGroup.getSubgroups(), updatedGroup.getSubgroups());
UpdateResult.Builder resultBuilder =
UpdateResult.builder()
.setGroupUuid(updatedGroup.getGroupUUID())
.setGroupId(updatedGroup.getId())
.setGroupName(updatedGroup.getNameKey())
.setAddedMembers(addedMembers)
.setDeletedMembers(deletedMembers)
.setAddedSubgroups(addedSubgroups)
.setDeletedSubgroups(deletedSubgroups);
if (!Objects.equals(originalGroup.getNameKey(), updatedGroup.getNameKey())) {
resultBuilder.setPreviousGroupName(originalGroup.getNameKey());
}
return resultBuilder.build();
}
private void commit(
Repository allUsersRepo, GroupConfig groupConfig, @Nullable GroupNameNotes groupNameNotes)
throws IOException {
BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
try (MetaDataUpdate metaDataUpdate =
metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
groupConfig.commit(metaDataUpdate);
}
if (groupNameNotes != null) {
// MetaDataUpdates unfortunately can't be reused. -> Create a new one.
try (MetaDataUpdate metaDataUpdate =
metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
groupNameNotes.commit(metaDataUpdate);
}
}
RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
gitRefUpdated.fire(
allUsersName, batchRefUpdate, currentUser.map(user -> user.state()).orElse(null));
}
private void evictCachesOnGroupCreation(InternalGroup createdGroup) {
logger.atFine().log("evict caches on creation of group %s", createdGroup.getGroupUUID());
// By UUID is used for the index and hence should be evicted before refreshing the index.
groupCache.evict(createdGroup.getGroupUUID());
indexer.get().index(createdGroup.getGroupUUID());
// These caches use the result from the index and hence must be evicted after refreshing the
// index.
groupCache.evict(createdGroup.getId());
groupCache.evict(createdGroup.getNameKey());
createdGroup.getMembers().forEach(groupIncludeCache::evictGroupsWithMember);
createdGroup.getSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
}
private void evictCachesOnGroupUpdate(UpdateResult result) {
logger.atFine().log("evict caches on update of group %s", result.getGroupUuid());
// By UUID is used for the index and hence should be evicted before refreshing the index.
groupCache.evict(result.getGroupUuid());
indexer.get().index(result.getGroupUuid());
// These caches use the result from the index and hence must be evicted after refreshing the
// index.
groupCache.evict(result.getGroupId());
groupCache.evict(result.getGroupName());
result.getPreviousGroupName().ifPresent(groupCache::evict);
result.getAddedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
result.getDeletedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
result.getAddedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
result.getDeletedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
}
private void updateNameInProjectConfigsIfNecessary(UpdateResult result) {
if (result.getPreviousGroupName().isPresent()) {
AccountGroup.NameKey previousName = result.getPreviousGroupName().get();
@SuppressWarnings("unused")
Future<?> possiblyIgnoredError =
renameGroupOpFactory
.create(
authorIdent,
result.getGroupUuid(),
previousName.get(),
result.getGroupName().get())
.start(0, TimeUnit.MILLISECONDS);
}
}
private void dispatchAuditEventsOnGroupCreation(InternalGroup createdGroup) {
if (!currentUser.isPresent()) {
return;
}
if (!createdGroup.getMembers().isEmpty()) {
groupAuditService.dispatchAddMembers(
currentUser.get().getAccountId(),
createdGroup.getGroupUUID(),
createdGroup.getMembers(),
createdGroup.getCreatedOn());
}
if (!createdGroup.getSubgroups().isEmpty()) {
groupAuditService.dispatchAddSubgroups(
currentUser.get().getAccountId(),
createdGroup.getGroupUUID(),
createdGroup.getSubgroups(),
createdGroup.getCreatedOn());
}
}
private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Instant updatedOn) {
if (!currentUser.isPresent()) {
return;
}
if (!result.getAddedMembers().isEmpty()) {
groupAuditService.dispatchAddMembers(
currentUser.get().getAccountId(),
result.getGroupUuid(),
result.getAddedMembers(),
updatedOn);
}
if (!result.getDeletedMembers().isEmpty()) {
groupAuditService.dispatchDeleteMembers(
currentUser.get().getAccountId(),
result.getGroupUuid(),
result.getDeletedMembers(),
updatedOn);
}
if (!result.getAddedSubgroups().isEmpty()) {
groupAuditService.dispatchAddSubgroups(
currentUser.get().getAccountId(),
result.getGroupUuid(),
result.getAddedSubgroups(),
updatedOn);
}
if (!result.getDeletedSubgroups().isEmpty()) {
groupAuditService.dispatchDeleteSubgroups(
currentUser.get().getAccountId(),
result.getGroupUuid(),
result.getDeletedSubgroups(),
updatedOn);
}
}
@FunctionalInterface
private interface MetaDataUpdateFactory {
MetaDataUpdate create(
Project.NameKey projectName, Repository repository, BatchRefUpdate batchRefUpdate)
throws IOException;
}
@AutoValue
abstract static class UpdateResult {
abstract AccountGroup.UUID getGroupUuid();
abstract AccountGroup.Id getGroupId();
abstract AccountGroup.NameKey getGroupName();
abstract Optional<AccountGroup.NameKey> getPreviousGroupName();
abstract ImmutableSet<Account.Id> getAddedMembers();
abstract ImmutableSet<Account.Id> getDeletedMembers();
abstract ImmutableSet<AccountGroup.UUID> getAddedSubgroups();
abstract ImmutableSet<AccountGroup.UUID> getDeletedSubgroups();
static Builder builder() {
return new AutoValue_GroupsUpdate_UpdateResult.Builder();
}
@AutoValue.Builder
abstract static class Builder {
abstract Builder setGroupUuid(AccountGroup.UUID groupUuid);
abstract Builder setGroupId(AccountGroup.Id groupId);
abstract Builder setGroupName(AccountGroup.NameKey name);
abstract Builder setPreviousGroupName(AccountGroup.NameKey previousName);
abstract Builder setAddedMembers(Set<Account.Id> addedMembers);
abstract Builder setDeletedMembers(Set<Account.Id> deletedMembers);
abstract Builder setAddedSubgroups(Set<AccountGroup.UUID> addedSubgroups);
abstract Builder setDeletedSubgroups(Set<AccountGroup.UUID> deletedSubgroups);
abstract UpdateResult build();
}
}
}