blob: 74e2db62f368c67f165c0ecbe2ed7b83501bf3ca [file] [log] [blame]
// Copyright (C) 2020 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.plugins.codeowners.backend;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountControl;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.account.externalids.ExternalIds;
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.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/** Class to resolve {@link CodeOwnerReference}s to {@link CodeOwner}s. */
public class CodeOwnerResolver {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@VisibleForTesting public static final String ALL_USERS_WILDCARD = "*";
private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
private final PermissionBackend permissionBackend;
private final Provider<CurrentUser> currentUser;
private final ExternalIds externalIds;
private final AccountCache accountCache;
private final AccountControl.Factory accountControlFactory;
private final PathCodeOwners.Factory pathCodeOwnersFactory;
// Enforce visibility by default.
private boolean enforceVisibility = true;
// The the user that should be used to check the account visibility (whether this user can see the
// accounts of the code owners).
// If unset, the current user is used.
private IdentifiedUser user;
@Inject
CodeOwnerResolver(
CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
PermissionBackend permissionBackend,
Provider<CurrentUser> currentUser,
ExternalIds externalIds,
AccountCache accountCache,
AccountControl.Factory accountControlFactory,
PathCodeOwners.Factory pathCodeOwnersFactory) {
this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
this.permissionBackend = permissionBackend;
this.currentUser = currentUser;
this.externalIds = externalIds;
this.accountCache = accountCache;
this.accountControlFactory = accountControlFactory;
this.pathCodeOwnersFactory = pathCodeOwnersFactory;
}
/**
* Whether it should be enforced that the returned code owners are visible to the current user.
*
* @param enforceVisibility whether it should be enforced that the returned code owners are
* visible to the current user
* @return the {@link CodeOwnerResolver} instance for chaining calls
*/
public CodeOwnerResolver enforceVisibility(boolean enforceVisibility) {
logger.atFine().log("enforceVisibility = %s", enforceVisibility);
this.enforceVisibility = enforceVisibility;
return this;
}
/**
* Sets the user that should be used to check the account visibility (whether this user can see
* the accounts of the code owners).
*
* <p>This overrides the current user that would be used to check the account visibility if this
* method was not called.
*
* <p>Using this method it's possible to resolve code owner references for accounts that are
* visible to the given user, but not visible to the current user. Callers must use this method
* with care to not expose the existence of non-visible accounts to the current user.
*
* @param user the user that should be used to check the account visibility (whether this user can
* see the accounts of the code owners)
* @return the {@link CodeOwnerResolver} instance for chaining calls
*/
public CodeOwnerResolver forUser(IdentifiedUser user) {
logger.atFine().log("user = %s", user.getLoggableName());
this.user = user;
return this;
}
/** Whether the given code owner reference can be resolved. */
public boolean isResolvable(CodeOwnerReference codeOwnerReference) {
CodeOwnerResolverResult result = resolve(ImmutableSet.of(codeOwnerReference));
return !result.codeOwners().isEmpty() || result.ownedByAllUsers();
}
/**
* Resolves the code owners from the given code owner config for the given path from {@link
* CodeOwnerReference}s to a {@link CodeOwner}s.
*
* <p>Non-resolvable code owners are filtered out.
*
* @param codeOwnerConfig the code owner config for which the local owners for the given path
* should be resolved
* @param absolutePath path for which the code owners should be returned; the path must be
* absolute; can be the path of a file or folder; the path may or may not exist
* @return the resolved code owners
*/
public CodeOwnerResolverResult resolvePathCodeOwners(
CodeOwnerConfig codeOwnerConfig, Path absolutePath) {
requireNonNull(codeOwnerConfig, "codeOwnerConfig");
requireNonNull(absolutePath, "absolutePath");
checkState(absolutePath.isAbsolute(), "path %s must be absolute", absolutePath);
try (TraceTimer traceTimer =
TraceContext.newTimer(
"Resolve path code owners",
Metadata.builder()
.projectName(codeOwnerConfig.key().project().get())
.branchName(codeOwnerConfig.key().ref())
.filePath(codeOwnerConfig.key().fileName().orElse("<default>"))
.build())) {
logger.atFine().log("resolving path code owners for path %s", absolutePath);
PathCodeOwnersResult pathCodeOwnersResult =
pathCodeOwnersFactory.create(codeOwnerConfig, absolutePath).resolveCodeOwnerConfig();
return resolve(
pathCodeOwnersResult.getPathCodeOwners(), pathCodeOwnersResult.hasUnresolvedImports());
}
}
/**
* Resolves the global code owners for the given project.
*
* @param projectName the name of the project for which the global code owners should be resolved
* @return the resolved global code owners of the given project
*/
public CodeOwnerResolverResult resolveGlobalCodeOwners(Project.NameKey projectName) {
return resolve(codeOwnersPluginConfiguration.getGlobalCodeOwners(projectName));
}
/**
* Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
*
* @param codeOwnerReferences the code owner references that should be resolved
* @return the {@link CodeOwner} for the given code owner references
* @see #resolve(CodeOwnerReference)
*/
public CodeOwnerResolverResult resolve(Set<CodeOwnerReference> codeOwnerReferences) {
return resolve(codeOwnerReferences, /* hasUnresolvedImports= */ false);
}
/**
* Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
*
* @param codeOwnerReferences the code owner references that should be resolved
* @param hasUnresolvedImports whether there are unresolved imports
* @return the {@link CodeOwner} for the given code owner references
* @see #resolve(CodeOwnerReference)
*/
private CodeOwnerResolverResult resolve(
Set<CodeOwnerReference> codeOwnerReferences, boolean hasUnresolvedImports) {
requireNonNull(codeOwnerReferences, "codeOwnerReferences");
AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
ImmutableSet<CodeOwner> codeOwners =
codeOwnerReferences.stream()
.filter(
codeOwnerReference -> {
if (ALL_USERS_WILDCARD.equals(codeOwnerReference.email())) {
ownedByAllUsers.set(true);
return false;
}
return true;
})
.map(this::resolve)
.filter(
codeOwner -> {
if (!codeOwner.isPresent()) {
hasUnresolvedCodeOwners.set(true);
return false;
}
return true;
})
.map(Optional::get)
.collect(toImmutableSet());
return CodeOwnerResolverResult.create(
codeOwners, ownedByAllUsers.get(), hasUnresolvedCodeOwners.get(), hasUnresolvedImports);
}
/**
* Resolves a {@link CodeOwnerReference} to {@link CodeOwner}s.
*
* <p>Code owners are defined by {@link CodeOwnerReference}s (e.g. emails) that need to be
* resolved to accounts. The accounts are wrapped in {@link CodeOwner}s so that we can support
* different kind of code owners later (e.g. groups).
*
* <p>Code owners that cannot be resolved are filtered out:
*
* <ul>
* <li>Emails that have a non-allowed email domain (see config parameter {@code
* plugin.code-owners.allowedEmailDomain}).
* <li>Emails for which no account exists: If no account exists, we cannot return any account.
* It's fine to filter them out as it just means nobody can claim the ownership that was
* assigned for this email.
* <li>Emails for which multiple accounts exist: If an email is ambiguous it is treated the same
* way as if there was no account for the email. That's because we can't tell which account
* was meant to have the ownership. This behaviour is consistent with the behaviour in
* Gerrit core that also treats ambiguous identifiers as non-resolveable.
* </ul>
*
* <p>This methods checks whether the calling user can see the accounts of the code owners and
* returns code owners whose accounts are visible.
*
* <p>In addition code owners that are referenced by a secondary email are only returned if the
* calling user can see the secondary email:
*
* <ul>
* <li>every user can see the own secondary emails
* <li>users with the {@code Modify Account} global capability can see the secondary emails of
* all accounts
* </ul>
*
* <p>This method does not resolve {@link CodeOwnerReference}s that assign the code ownership to
* all user by using {@link #ALL_USERS_WILDCARD} as email.
*
* @param codeOwnerReference the code owner reference that should be resolved
* @return the {@link CodeOwner} for the code owner reference if it was resolved, otherwise {@link
* Optional#empty()}
*/
public Optional<CodeOwner> resolve(CodeOwnerReference codeOwnerReference) {
String email = requireNonNull(codeOwnerReference, "codeOwnerReference").email();
logger.atFine().log("resolving code owner reference %s", codeOwnerReference);
if (!isEmailDomainAllowed(email)) {
logger.atFine().log("domain of email %s is not allowed", email);
return Optional.empty();
}
Optional<AccountState> accountState =
lookupEmail(email).flatMap(accountId -> lookupAccount(accountId, email));
if (!accountState.isPresent()) {
logger.atFine().log("no account for email %s", email);
return Optional.empty();
}
if (!accountState.get().account().isActive()) {
logger.atFine().log("account for email %s is inactive", email);
return Optional.empty();
}
if (enforceVisibility && !isVisible(accountState.get(), email)) {
logger.atFine().log(
"account %d or email %s not visible", accountState.get().account().id().get(), email);
return Optional.empty();
}
CodeOwner codeOwner = CodeOwner.create(accountState.get().account().id());
logger.atFine().log("resolved to code owner %s", codeOwner);
return Optional.of(codeOwner);
}
/** Whether the given account can be seen. */
private boolean canSee(AccountState accountState) {
AccountControl accountControl =
user != null ? accountControlFactory.get(user) : accountControlFactory.get();
return accountControl.canSee(accountState);
}
/**
* Looks up an email and returns the ID of the account to which it belongs.
*
* <p>If the email is ambiguous (it belongs to multiple accounts) it is considered as
* non-resolvable and {@link Optional#empty()} is returned.
*
* @param email the email that should be looked up
* @return the ID of the account to which the email belongs if was found
*/
private Optional<Account.Id> lookupEmail(String email) {
ImmutableSet<ExternalId> extIds;
try {
extIds = externalIds.byEmail(email);
} catch (IOException e) {
throw new StorageException(String.format("cannot resolve code owner email %s", email), e);
}
if (extIds.isEmpty()) {
logger.atFine().log(
"cannot resolve code owner email %s: no account with this email exists", email);
return Optional.empty();
}
if (extIds.stream().map(ExternalId::accountId).distinct().count() > 1) {
logger.atFine().log("cannot resolve code owner email %s: email is ambiguous", email);
return Optional.empty();
}
return Optional.of(extIds.stream().findFirst().get().accountId());
}
/**
* Looks up an account by account ID and returns the corresponding {@link AccountState} if it is
* found.
*
* @param accountId the ID of the account that should be looked up
* @param email the email that was resolved to the account ID
* @return the {@link AccountState} of the account with the given account ID, if it exists
*/
private Optional<AccountState> lookupAccount(Account.Id accountId, String email) {
Optional<AccountState> accountState = accountCache.get(accountId);
if (!accountState.isPresent()) {
logger.atFine().log(
"cannot resolve code owner email %s: email belongs to account %s,"
+ " but no account with this ID exists",
email, accountId);
return Optional.empty();
}
return accountState;
}
/**
* Checks whether the given account and email are visible to the calling user.
*
* <p>If the email is a secondary email it is only visible if it is owned by the calling user or
* if the calling user has the {@code Modify Account} global capability.
*
* @param accountState the account for which it should be checked whether it's visible to the
* calling user
* @param email email that was used to reference the account
* @return {@code true} if the given account and email are visible to the calling user, otherwise
* {@code false}
*/
private boolean isVisible(AccountState accountState, String email) {
if (!canSee(accountState)) {
logger.atFine().log(
"cannot resolve code owner email %s: account %s is not visible to calling user",
email, accountState.account().id());
return false;
}
if (!email.equals(accountState.account().preferredEmail())) {
// the email is a secondary email of the account
if (currentUser.get().isIdentifiedUser()
&& currentUser.get().asIdentifiedUser().hasEmailAddress(email)) {
// it's a secondary email of the calling user, users can always see their own secondary
// emails
return true;
}
// the email is a secondary email of another account, check if the calling user can see
// secondary emails
try {
if (!permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
logger.atFine().log(
"cannot resolve code owner email %s: account %s is referenced by secondary email,"
+ " but the calling user cannot see secondary emails",
email, accountState.account().id());
return false;
}
} catch (PermissionBackendException e) {
throw new StorageException(
String.format(
"failed to test the %s global capability", GlobalPermission.MODIFY_ACCOUNT),
e);
}
}
return true;
}
/**
* Whether the domain of the given email is allowed for code owners.
*
* @param email the email for which the domain should be checked
* @return {@code true} if the domain of the given email is allowed for code owners, otherwise
* {@code false}
*/
public boolean isEmailDomainAllowed(String email) {
requireNonNull(email, "email");
ImmutableSet<String> allowedEmailDomains =
codeOwnersPluginConfiguration.getAllowedEmailDomains();
if (allowedEmailDomains.isEmpty()) {
// all domains are allowed
return true;
}
if (email.equals(ALL_USERS_WILDCARD)) {
return true;
}
int emailAtIndex = email.lastIndexOf('@');
if (emailAtIndex >= 0 && emailAtIndex < email.length() - 1) {
String emailDomain = email.substring(emailAtIndex + 1);
logger.atFine().log("email domain = %s", emailDomain);
return allowedEmailDomains.contains(emailDomain);
}
// email has no domain
logger.atFine().log("email %s has no domain", email);
return false;
}
}