blob: 07894ceddae26011b96a503a0adb276c04a8c86c [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.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException.newInternalServerError;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Project;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
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.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.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* Class to resolve {@link CodeOwnerReference}s 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>Unless {@link CodeOwnerResolver#enforceVisibility} is {@code false} it is checked whether the
* {@link #user} or the calling user (if {@link #user} is unset) can see the accounts of the code
* owners and code owners whose accounts are not visible are filtered out.
*
* <p>In addition code owners that are referenced by a secondary email are filtered out if the
* {@link #user} or the calling user (if {@link #user} is unset) cannot see the secondary email:
*
* <ul>
* <li>every user can see their own secondary emails
* <li>users with the {@code Modify Account} global capability can see the secondary emails of all
* accounts
* </ul>
*
* <p>Resolved code owners are cached within this class so that each email needs to be resolved only
* once. To take advantage of this caching callers should reuse {@link CodeOwnerResolver} instances
* where possible.
*/
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;
private final CodeOwnerMetrics codeOwnerMetrics;
private final UnresolvedImportFormatter unresolvedImportFormatter;
private final TransientCodeOwnerCache transientCodeOwnerCache;
// 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,
CodeOwnerMetrics codeOwnerMetrics,
UnresolvedImportFormatter unresolvedImportFormatter,
TransientCodeOwnerCache transientCodeOwnerCache) {
this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
this.permissionBackend = permissionBackend;
this.currentUser = currentUser;
this.externalIds = externalIds;
this.accountCache = accountCache;
this.accountControlFactory = accountControlFactory;
this.pathCodeOwnersFactory = pathCodeOwnersFactory;
this.codeOwnerMetrics = codeOwnerMetrics;
this.unresolvedImportFormatter = unresolvedImportFormatter;
this.transientCodeOwnerCache = transientCodeOwnerCache;
}
/**
* 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;
transientCodeOwnerCache.clear();
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;
transientCodeOwnerCache.clear();
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>If the code owner config has already been resolved to {@link PathCodeOwners}, prefer calling
* {@link #resolvePathCodeOwners(PathCodeOwners)} instead, so that {@link PathCodeOwners} do not
* need to be created again.
*
* <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);
return resolvePathCodeOwners(
pathCodeOwnersFactory.createWithoutCache(codeOwnerConfig, absolutePath));
}
/**
* Resolves the the given path code owners from {@link CodeOwnerReference}s to a {@link
* CodeOwner}s.
*
* <p>Non-resolvable code owners are filtered out.
*
* @param pathCodeOwners the path code owners that should be resolved
* @return the resolved code owners
*/
public CodeOwnerResolverResult resolvePathCodeOwners(PathCodeOwners pathCodeOwners) {
requireNonNull(pathCodeOwners, "pathCodeOwners");
try (Timer0.Context ctx = codeOwnerMetrics.resolvePathCodeOwners.start()) {
logger.atFine().log(
"resolve path code owners (code owner config = %s, path = %s)",
pathCodeOwners.getCodeOwnerConfig().key(), pathCodeOwners.getPath());
OptionalResultWithMessages<PathCodeOwnersResult> pathCodeOwnersResult =
pathCodeOwners.resolveCodeOwnerConfig();
return resolve(
pathCodeOwnersResult.get().getPathCodeOwners(),
pathCodeOwnersResult.get().getAnnotations(),
pathCodeOwnersResult.get().unresolvedImports(),
pathCodeOwnersResult.messages());
}
}
/**
* 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.getProjectConfig(projectName).getGlobalCodeOwners());
}
/**
* 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
*/
public CodeOwnerResolverResult resolve(Set<CodeOwnerReference> codeOwnerReferences) {
return resolve(
codeOwnerReferences,
/* annotationsByCodeOwnerReference= */ ImmutableMultimap.of(),
/* unresolvedImports= */ ImmutableList.of(),
/* pathCodeOwnersMessages= */ ImmutableList.of());
}
/**
* Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
*
* <p>The accounts for the given {@link CodeOwnerReference}s are loaded from the account cache in
* parallel (via {@link AccountCache#get(Set)}.
*
* @param codeOwnerReferences the code owner references that should be resolved
* @param annotationsByCodeOwnerReference annotations by code owner reference
* @param unresolvedImports list of unresolved imports
* @param pathCodeOwnersMessages messages that were collected when resolving path code owners
* @return the resolved code owner references as a {@link CodeOwnerResolverResult}
*/
private CodeOwnerResolverResult resolve(
Set<CodeOwnerReference> codeOwnerReferences,
ImmutableMultimap<CodeOwnerReference, CodeOwnerAnnotation> annotationsByCodeOwnerReference,
List<UnresolvedImport> unresolvedImports,
ImmutableList<String> pathCodeOwnersMessages) {
requireNonNull(codeOwnerReferences, "codeOwnerReferences");
requireNonNull(unresolvedImports, "unresolvedImports");
requireNonNull(pathCodeOwnersMessages, "pathCodeOwnersMessages");
try (Timer0.Context ctx = codeOwnerMetrics.resolveCodeOwnerReferences.start()) {
ImmutableList.Builder<String> messageBuilder = ImmutableList.builder();
messageBuilder.addAll(pathCodeOwnersMessages);
unresolvedImports.forEach(
unresolvedImport ->
messageBuilder.add(unresolvedImportFormatter.format(unresolvedImport)));
AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
ImmutableMap<CodeOwner, ImmutableSet<CodeOwnerAnnotation>> codeOwnersWithAnnotations =
resolve(
messageBuilder,
ownedByAllUsers,
hasUnresolvedCodeOwners,
codeOwnerReferences,
annotationsByCodeOwnerReference);
ImmutableMultimap.Builder<CodeOwner, CodeOwnerAnnotation> annotationsByCodeOwner =
ImmutableMultimap.builder();
codeOwnersWithAnnotations.forEach(
(codeOwner, annotations) -> annotationsByCodeOwner.putAll(codeOwner, annotations));
CodeOwnerResolverResult codeOwnerResolverResult =
CodeOwnerResolverResult.create(
codeOwnersWithAnnotations.keySet(),
annotationsByCodeOwner.build(),
ownedByAllUsers.get(),
hasUnresolvedCodeOwners.get(),
!unresolvedImports.isEmpty(),
messageBuilder.build());
logger.atFine().log("resolve result = %s", codeOwnerResolverResult);
return codeOwnerResolverResult;
}
}
/**
* Resolves a {@link CodeOwnerReference} to a {@link CodeOwner}.
*
* <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) {
OptionalResultWithMessages<CodeOwner> resolveResult = resolveWithMessages(codeOwnerReference);
logger.atFine().log("resolve result = %s", resolveResult);
return resolveResult.result();
}
/**
* Resolves a {@link CodeOwnerReference} to a {@link CodeOwner}.
*
* <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.
*
* <p>Debug messages are returned with the result.
*
* @param codeOwnerReference the code owner reference that should be resolved
* @return the result of resolving the given code owner reference with debug messages
*/
public OptionalResultWithMessages<CodeOwner> resolveWithMessages(
CodeOwnerReference codeOwnerReference) {
requireNonNull(codeOwnerReference, "codeOwnerReference");
if (CodeOwnerResolver.ALL_USERS_WILDCARD.equals(codeOwnerReference.email())) {
return OptionalResultWithMessages.createEmpty(
String.format(
"cannot resolve code owner email %s: no account with this email exists",
CodeOwnerResolver.ALL_USERS_WILDCARD));
}
ImmutableList.Builder<String> messageBuilder = ImmutableList.builder();
AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
ImmutableMap<CodeOwner, ImmutableSet<CodeOwnerAnnotation>> codeOwnersWithAnnotations =
resolve(
messageBuilder,
ownedByAllUsers,
hasUnresolvedCodeOwners,
ImmutableSet.of(codeOwnerReference),
/* annotations= */ ImmutableMultimap.of());
ImmutableList<String> messages = messageBuilder.build();
if (codeOwnersWithAnnotations.isEmpty()) {
return OptionalResultWithMessages.createEmpty(messages);
}
return OptionalResultWithMessages.create(
Iterables.getOnlyElement(codeOwnersWithAnnotations.keySet()), messages);
}
/**
* Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
*
* <p>The accounts for the given {@link CodeOwnerReference}s are loaded from the account cache in
* parallel (via {@link AccountCache#get(Set)}.
*
* @param messages a builder to which debug messages are added
* @param ownedByAllUsers a flag that is set if any of the given {@link CodeOwnerReference}s
* assigns code ownership to all users
* @param hasUnresolvedCodeOwners a flag that is set any of the given {@link CodeOwnerReference}s
* cannot be resolved
* @param codeOwnerReferences the code owner references that should be resolved
* @param annotations annotations by code owner reference
* @return map that maps the resolved {@link CodeOwner}s to their annotations (note: we cannot
* return a {@code Multimap<CodeOwner, CodeOwnerAnnotation>} here since there may be code
* owners without annotations and Multimap doesn't store keys for which no values are stored)
*/
private ImmutableMap<CodeOwner, ImmutableSet<CodeOwnerAnnotation>> resolve(
ImmutableList.Builder<String> messages,
AtomicBoolean ownedByAllUsers,
AtomicBoolean hasUnresolvedCodeOwners,
Set<CodeOwnerReference> codeOwnerReferences,
ImmutableMultimap<CodeOwnerReference, CodeOwnerAnnotation> annotations) {
requireNonNull(codeOwnerReferences, "codeOwnerReferences");
ImmutableSet<String> emailsToResolve =
codeOwnerReferences.stream()
.map(CodeOwnerReference::email)
.filter(filterOutAllUsersWildCard(ownedByAllUsers))
.collect(toImmutableSet());
ImmutableMap<String, Optional<CodeOwner>> cachedCodeOwnersByEmail =
transientCodeOwnerCache.get(emailsToResolve);
ImmutableSet<String> emailsToLookup =
emailsToResolve.stream()
.filter(email -> !cachedCodeOwnersByEmail.containsKey(email))
.filter(filterOutEmailsWithNonAllowedDomains(messages))
.collect(toImmutableSet());
ImmutableMap<String, Collection<ExternalId>> externalIdsByEmail =
lookupExternalIds(messages, emailsToLookup);
Stream<Pair<String, AccountState>> accountsByEmail =
lookupAccounts(messages, externalIdsByEmail)
.map(removeInactiveAccounts(messages))
.filter(filterOutEmailsWithoutAccounts(messages))
.filter(filterOutAmbiguousEmails(messages))
.map(mapToOnlyAccount(messages));
if (enforceVisibility) {
accountsByEmail =
accountsByEmail
.filter(filterOutEmailsOfNonVisibleAccounts(messages))
.filter(filterOutNonVisibleSecondaryEmails(messages));
} else {
messages.add("code owner visibility is not checked");
}
ImmutableMap<String, CodeOwner> codeOwnersByEmail =
accountsByEmail.map(mapToCodeOwner()).collect(toImmutableMap(Pair::key, Pair::value));
if (codeOwnersByEmail.keySet().size() < emailsToResolve.size()) {
hasUnresolvedCodeOwners.set(true);
}
Map<CodeOwner, Set<CodeOwnerAnnotation>> codeOwnersWithAnnotations = new HashMap<>();
// Merge code owners that have been newly resolved with code owners which have been looked up
// from cache and return them with their annotations.
Stream<Pair<String, CodeOwner>> newlyResolvedCodeOwnersStream =
codeOwnersByEmail.entrySet().stream().map(e -> Pair.of(e.getKey(), e.getValue()));
Stream<Pair<String, CodeOwner>> cachedCodeOwnersStream =
cachedCodeOwnersByEmail.entrySet().stream()
.filter(e -> e.getValue().isPresent())
.map(e -> Pair.of(e.getKey(), e.getValue().get()));
Streams.concat(newlyResolvedCodeOwnersStream, cachedCodeOwnersStream)
.forEach(
p -> {
ImmutableSet.Builder<CodeOwnerAnnotation> annotationBuilder = ImmutableSet.builder();
annotationBuilder.addAll(annotations.get(CodeOwnerReference.create(p.key())));
// annotations for the all users wildcard (aka '*') apply to all code owners
annotationBuilder.addAll(
annotations.get(CodeOwnerReference.create(ALL_USERS_WILDCARD)));
if (!codeOwnersWithAnnotations.containsKey(p.value())) {
codeOwnersWithAnnotations.put(p.value(), new HashSet<>());
}
codeOwnersWithAnnotations.get(p.value()).addAll(annotationBuilder.build());
});
return codeOwnersWithAnnotations.entrySet().stream()
.collect(toImmutableMap(Map.Entry::getKey, e -> ImmutableSet.copyOf(e.getValue())));
}
/**
* Creates a predicate to filter out emails that are all users wild card (aka {@code *}).
*
* @param ownedByAllUsers flag that is set if any of the emails is the all users wild card (aka
* {@code *})
*/
private Predicate<String> filterOutAllUsersWildCard(AtomicBoolean ownedByAllUsers) {
return email -> {
if (ALL_USERS_WILDCARD.equals(email)) {
ownedByAllUsers.set(true);
return false;
}
return true;
};
}
/**
* Creates a predicate to filter out emails that have a non-allowed email domain.
*
* <p>Which emails domains are allowed is controlled via the plugin configuration (see {@link
* com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot#getAllowedEmailDomains()}
*
* @param messages builder to which debug messages are added
*/
private Predicate<String> filterOutEmailsWithNonAllowedDomains(
ImmutableList.Builder<String> messages) {
return email -> {
boolean isEmailDomainAllowed = isEmailDomainAllowed(messages, email);
if (!isEmailDomainAllowed) {
transientCodeOwnerCache.cacheNonResolvable(email);
}
return isEmailDomainAllowed;
};
}
/**
* Whether the domain of the given email is allowed for code owners.
*
* <p>Which emails domains are allowed is controlled via the plugin configuration (see {@link
* com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot#getAllowedEmailDomains()}
*
* <p>Debug messages are returned with the result.
*
* @param email the email for which the domain should be checked
* @return a {@link OptionalResultWithMessages} that contains {@code true} if the domain of the
* given email is allowed for code owners, otherwise {@link OptionalResultWithMessages} that
* contains {@code false}
*/
public OptionalResultWithMessages<Boolean> isEmailDomainAllowed(String email) {
ImmutableList.Builder<String> messages = ImmutableList.builder();
boolean isEmailDomainAllowed = isEmailDomainAllowed(messages, email);
return OptionalResultWithMessages.create(isEmailDomainAllowed, messages.build());
}
/**
* Whether the domain of the given email is allowed for code owners.
*
* <p>Which emails domains are allowed is controlled via the plugin configuration (see {@link
* com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot#getAllowedEmailDomains()}
*
* @param messages builder to which debug messages are added
* @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}
*/
private boolean isEmailDomainAllowed(ImmutableList.Builder<String> messages, String email) {
requireNonNull(messages, "messages");
requireNonNull(email, "email");
ImmutableSet<String> allowedEmailDomains =
codeOwnersPluginConfiguration.getGlobalConfig().getAllowedEmailDomains();
if (allowedEmailDomains.isEmpty()) {
messages.add("all domains are allowed");
return true;
}
if (email.equals(ALL_USERS_WILDCARD)) {
messages.add("all users wildcard is allowed");
return true;
}
int emailAtIndex = email.lastIndexOf('@');
if (emailAtIndex >= 0 && emailAtIndex < email.length() - 1) {
String emailDomain = email.substring(emailAtIndex + 1);
boolean isEmailDomainAllowed = allowedEmailDomains.contains(emailDomain);
messages.add(
String.format(
"domain %s of email %s is %s",
emailDomain, email, isEmailDomainAllowed ? "allowed" : "not allowed"));
return isEmailDomainAllowed;
}
messages.add(String.format("email %s has no domain", email));
return false;
}
/**
* Looks up the external IDs for the given emails.
*
* <p>Looks up all emails from the external ID cache at once, which is more efficient than looking
* up external IDs for emails one by one (see {@link ExternalIds#byEmails(String...)}).
*
* @param messages builder to which debug messages are added
* @param emails the emails for which the external IDs should be looked up
* @return external IDs per email
*/
private ImmutableMap<String, Collection<ExternalId>> lookupExternalIds(
ImmutableList.Builder<String> messages, ImmutableSet<String> emails) {
try {
ImmutableMap<String, Collection<ExternalId>> extIdsByEmail =
externalIds.byEmails(emails.toArray(new String[0])).asMap();
emails.stream()
.filter(email -> !extIdsByEmail.containsKey(email))
.forEach(
email -> {
transientCodeOwnerCache.cacheNonResolvable(email);
messages.add(
String.format(
"cannot resolve code owner email %s: no account with this email exists",
email));
});
return extIdsByEmail;
} catch (IOException e) {
throw newInternalServerError(
String.format("cannot resolve code owner emails: %s", emails), e);
}
}
/**
* Looks up the accounts for the given external IDs.
*
* <p>Looks up all accounts from the account cache at once, which is more efficient than looking
* up accounts one by one (see {@link AccountCache#get(Set)}).
*
* @param messages builder to which debug messages are added
* @param externalIdsByEmail external IDs for which the accounts should be looked up
* @return account states per email
*/
private Stream<Pair<String, Collection<AccountState>>> lookupAccounts(
ImmutableList.Builder<String> messages,
ImmutableMap<String, Collection<ExternalId>> externalIdsByEmail) {
ImmutableSet<Account.Id> accountIds =
externalIdsByEmail.values().stream()
.flatMap(Collection::stream)
.map(ExternalId::accountId)
.collect(toImmutableSet());
Map<Account.Id, AccountState> accounts = accountCache.get(accountIds);
return externalIdsByEmail.entrySet().stream()
.map(
e ->
Pair.of(
e.getKey(),
e.getValue().stream()
.map(
extId -> {
Account.Id accountId = extId.accountId();
AccountState accountState = accounts.get(accountId);
if (accountState == null) {
messages.add(
String.format(
"cannot resolve account %s for email %s: account does not"
+ " exists",
accountId, e.getKey()));
}
return accountState;
})
.filter(Objects::nonNull)
.collect(toImmutableSet())));
}
/**
* Creates a map function that removes inactive accounts from a {@code Pair<String,
* Collection<AccountState>>}.
*
* <p>The pair which is provided as input to the function maps an email to a collection of account
* states.
*
* @param messages builder to which debug messages are added
*/
private Function<Pair<String, Collection<AccountState>>, Pair<String, Collection<AccountState>>>
removeInactiveAccounts(ImmutableList.Builder<String> messages) {
return e -> Pair.of(e.key(), removeInactiveAccounts(messages, e.key(), e.value()));
}
/**
* Removes inactive accounts from the given collection of account states.
*
* @param messages builder to which debug messages are added
* @param email email to which the accounts belong
* @param accountStates the set of account states from which inactive accounts should be removed
* @return the account states that belong to active accounts
*/
private ImmutableSet<AccountState> removeInactiveAccounts(
ImmutableList.Builder<String> messages,
String email,
Collection<AccountState> accountStates) {
return accountStates.stream()
.filter(
accountState -> {
if (!accountState.account().isActive()) {
messages.add(
String.format(
"ignoring inactive account %s for email %s",
accountState.account().id(), email));
return false;
}
return true;
})
.collect(toImmutableSet());
}
/**
* Creates a predicate to filter out emails without accounts.
*
* <p>The pair which is provided as input to the predicate maps an email to a collection of
* account states. If the collection of account states is empty, the email is filtered out.
*
* @param messages builder to which debug messages are added
*/
private Predicate<Pair<String, Collection<AccountState>>> filterOutEmailsWithoutAccounts(
ImmutableList.Builder<String> messages) {
return e -> {
if (e.value().isEmpty()) {
String email = e.key();
transientCodeOwnerCache.cacheNonResolvable(email);
messages.add(
String.format(
"cannot resolve code owner email %s: no active account with this email found",
email));
return false;
}
return true;
};
}
/**
* Creates a predicate to filter out ambiguous emails (emails that belong to multiple accounts).
*
* <p>The pair which is provided as input to the predicate maps an email to a collection of
* account states. If the collection of account states contains more than 1 entry, the email is
* filtered out.
*
* @param messages builder to which debug messages are added
*/
private Predicate<Pair<String, Collection<AccountState>>> filterOutAmbiguousEmails(
ImmutableList.Builder<String> messages) {
return e -> {
if (e.value().size() > 1) {
String email = e.key();
transientCodeOwnerCache.cacheNonResolvable(email);
messages.add(
String.format("cannot resolve code owner email %s: email is ambiguous", email));
return false;
}
return true;
};
}
/**
* Creates a map function that maps a {@code Pair<String, Collection<AccountState>>} to a {@code
* Pair<String, AccountState>}.
*
* <p>The pair which is provided as input to the function maps an email to a collection of account
* states, which must contain exactly one entry. As output the function returns a pair that maps
* the email to the only account state.
*
* @param messages builder to which debug messages are added
*/
private Function<Pair<String, Collection<AccountState>>, Pair<String, AccountState>>
mapToOnlyAccount(ImmutableList.Builder<String> messages) {
return e -> {
String email = e.key();
AccountState accountState = Iterables.getOnlyElement(e.value());
messages.add(
String.format("resolved email %s to account %s", email, accountState.account().id()));
return Pair.of(email, accountState);
};
}
/**
* Creates a predicate to filter out emails that belong to non-visible accounts.
*
* @param messages builder to which debug messages are added
*/
private Predicate<Pair<String, AccountState>> filterOutEmailsOfNonVisibleAccounts(
ImmutableList.Builder<String> messages) {
return e -> {
String email = e.key();
AccountState accountState = e.value();
if (!canSee(accountState)) {
transientCodeOwnerCache.cacheNonResolvable(email);
messages.add(
String.format(
"cannot resolve code owner email %s: account %s is not visible to user %s",
email,
accountState.account().id(),
user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
return false;
}
return true;
};
}
/** 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);
}
/**
* Creates a predicate to filter out non-visible secondary emails.
*
* <p>A secondary email is only visible if
*
* <ul>
* <li>it is owned by the {@link #user} or the calling user (if {@link #user} is unset)
* <li>if the {@link #user} or the calling user (if {@link #user} is unset) has the {@code
* Modify Account} global capability
* </ul>
*
* @param messages builder to which debug messages are added
*/
private Predicate<Pair<String, AccountState>> filterOutNonVisibleSecondaryEmails(
ImmutableList.Builder<String> messages) {
return e -> {
String email = e.key();
AccountState accountState = e.value();
if (email.equals(accountState.account().preferredEmail())) {
// the email is a primary email of the account
messages.add(
String.format(
"account %s is visible to user %s",
accountState.account().id(),
user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
return true;
}
if (user != null) {
if (user.hasEmailAddress(email)) {
messages.add(
String.format(
"email %s is visible to user %s: email is a secondary email that is owned by this"
+ " user",
email, user.getLoggableName()));
return true;
}
} else 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
messages.add(
String.format(
"email %s is visible to the calling user %s: email is a secondary email that is"
+ " owned by this user",
email, currentUser.get().getLoggableName()));
return true;
}
// the email is a secondary email of another account, check if the user can see secondary
// emails
try {
if (user != null) {
if (!permissionBackend.user(user).test(GlobalPermission.MODIFY_ACCOUNT)) {
transientCodeOwnerCache.cacheNonResolvable(email);
messages.add(
String.format(
"cannot resolve code owner email %s: account %s is referenced by secondary email"
+ " but user %s cannot see secondary emails",
email, accountState.account().id(), user.getLoggableName()));
return false;
}
messages.add(
String.format(
"resolved code owner email %s: account %s is referenced by secondary email"
+ " and user %s can see secondary emails",
email, accountState.account().id(), user.getLoggableName()));
return true;
} else if (!permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
transientCodeOwnerCache.cacheNonResolvable(email);
messages.add(
String.format(
"cannot resolve code owner email %s: account %s is referenced by secondary email"
+ " but the calling user %s cannot see secondary emails",
email, accountState.account().id(), currentUser.get().getLoggableName()));
return false;
} else {
messages.add(
String.format(
"resolved code owner email %s: account %s is referenced by secondary email"
+ " and the calling user %s can see secondary emails",
email, accountState.account().id(), currentUser.get().getLoggableName()));
return true;
}
} catch (PermissionBackendException ex) {
throw newInternalServerError(
String.format(
"failed to test the %s global capability", GlobalPermission.MODIFY_ACCOUNT),
ex);
}
};
}
/**
* Creates a map function that maps a {@code Pair<String, AccountState>} to a code owner.
*
* <p>The pair which is provided as input to the function maps an email to an account states.
*/
private Function<Pair<String, AccountState>, Pair<String, CodeOwner>> mapToCodeOwner() {
return e -> {
String email = e.key();
CodeOwner codeOwner = CodeOwner.create(e.value().account().id());
transientCodeOwnerCache.cache(email, codeOwner);
return Pair.of(email, codeOwner);
};
}
/** Returns the counters for resolutions and cache reads of code owners. */
public TransientCodeOwnerCache.Counters getCodeOwnerCounters() {
return transientCodeOwnerCache.getCounters();
}
}