All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.account.AccountResolver Maven / Gradle / Ivy

There is a newer version: 3.11.0-rc3
Show newest version
// Copyright (C) 2019 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.account;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Suppliers;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.common.UsedAt.Project;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.index.Schema;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.config.AnonymousCowardName;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.query.account.InternalAccountQuery;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;

/**
 * Helper for resolving accounts given arbitrary user-provided input.
 *
 * 

The {@code resolve*} methods each define a list of accepted formats for account resolution. * The algorithm for resolving accounts from a list of formats is as follows: * *

    *
  1. For each recognized format in the order listed in the method Javadoc, check whether the * input matches that format. *
  2. If so, resolve accounts according to that format. *
  3. Filter out invisible and inactive accounts. *
  4. If the result list is non-empty, return. *
  5. If the format is listed above as being short-circuiting, return. *
  6. Otherwise, return to step 1 with the next format. *
* *

The result never includes accounts that are not visible to the calling user. It also never * includes inactive accounts, with a small number of specific exceptions noted in method Javadoc. */ @Singleton public class AccountResolver { public static class UnresolvableAccountException extends UnprocessableEntityException { private static final long serialVersionUID = 1L; private final Result result; @VisibleForTesting UnresolvableAccountException(Result result) { super(exceptionMessage(result)); this.result = result; } public boolean isSelf() { return result.isSelf(); } } public static String exceptionMessage(Result result) { checkArgument(result.asList().size() != 1); if (result.asList().isEmpty()) { if (result.isSelf()) { return "Resolving account '" + result.input() + "' requires login"; } if (result.filteredInactive().isEmpty()) { return "Account '" + result.input() + "' not found"; } return result.filteredInactive().stream() .map(a -> formatForException(result, a)) .collect( joining( "\n", "Account '" + result.input() + "' only matches inactive accounts. To use an inactive account, retry with" + " one of the following exact account IDs:\n", "")); } return result.asList().stream() .map(a -> formatForException(result, a)) .limit(3) .collect( joining( "\n", "Account '" + result.input() + "' is ambiguous (at most 3 shown):\n", "")); } private static String formatForException(Result result, AccountState state) { return state.account().id() + ": " + state.account().getNameEmail(result.accountResolver().anonymousCowardName); } public static boolean isSelf(String input) { return "self".equals(input) || "me".equals(input); } public class Result { private final String input; private final ImmutableList list; private final ImmutableList filteredInactive; private final CurrentUser searchedAsUser; @VisibleForTesting Result( String input, List list, List filteredInactive, CurrentUser searchedAsUser) { this.input = requireNonNull(input); this.list = canonicalize(list); this.filteredInactive = canonicalize(filteredInactive); this.searchedAsUser = requireNonNull(searchedAsUser); } private ImmutableList canonicalize(List list) { TreeSet set = new TreeSet<>(comparing(a -> a.account().id().get())); set.addAll(requireNonNull(list)); return ImmutableList.copyOf(set); } public String input() { return input; } public boolean isSelf() { return AccountResolver.isSelf(input); } public ImmutableList asList() { return list; } public ImmutableSet asNonEmptyIdSet() throws UnresolvableAccountException { if (list.isEmpty()) { throw new UnresolvableAccountException(this); } return asIdSet(); } public ImmutableSet asIdSet() { return list.stream().map(a -> a.account().id()).collect(toImmutableSet()); } public AccountState asUnique() throws UnresolvableAccountException { ensureUnique(); return list.get(0); } private void ensureUnique() throws UnresolvableAccountException { if (list.size() != 1) { throw new UnresolvableAccountException(this); } } private void ensureSelfIsUniqueIdentifiedUser() throws UnresolvableAccountException { ensureUnique(); if (!searchedAsUser.isIdentifiedUser()) { throw new UnresolvableAccountException(this); } } public IdentifiedUser asUniqueUser() throws UnresolvableAccountException { if (isSelf()) { ensureSelfIsUniqueIdentifiedUser(); // In the special case of "self", use the exact IdentifiedUser from the request context, to // preserve the peer address and any other per-request state. return searchedAsUser.asIdentifiedUser(); } ensureUnique(); return userFactory.create(asUnique()); } public IdentifiedUser asUniqueUserOnBehalfOf(CurrentUser caller) throws UnresolvableAccountException { ensureUnique(); if (isSelf()) { return searchedAsUser.asIdentifiedUser(); } return userFactory.runAs( /* remotePeer= */ null, list.get(0).account().id(), requireNonNull(caller).getRealUser()); } @VisibleForTesting ImmutableList filteredInactive() { return filteredInactive; } private AccountResolver accountResolver() { return AccountResolver.this; } } @VisibleForTesting interface Searcher { default boolean callerShouldFilterOutInactiveCandidates() { return true; } /** * Searches can be done on behalf of either the current user or another provided user. The * results of some searchers, such as BySelf, are affected by the context user. */ default boolean requiresContextUser() { return false; } Optional tryParse(String input) throws IOException; /** * This method should be implemented for every searcher which doesn't require a context user. * * @param input to search for * @return stream of the matching accounts * @throws IOException by some subclasses * @throws ConfigInvalidException by some subclasses */ default Stream search(I input) throws IOException, ConfigInvalidException { throw new IllegalStateException("search(I) default implementation should never be called."); } /** * This method should be implemented for every searcher which requires a context user. * * @param input to search for * @param asUser the context user for the search * @return stream of the matching accounts * @throws IOException by some subclasses * @throws ConfigInvalidException by some subclasses */ default Stream search(I input, CurrentUser asUser) throws IOException, ConfigInvalidException { if (!requiresContextUser()) { return search(input); } throw new IllegalStateException( "The searcher requires a context user, but doesn't implement search(input, asUser)."); } boolean shortCircuitIfNoResults(); default Optional> trySearch(String input, CurrentUser asUser) throws IOException, ConfigInvalidException { Optional parsed = tryParse(input); if (parsed.isEmpty()) { return Optional.empty(); } return requiresContextUser() ? Optional.of(search(parsed.get(), asUser)) : Optional.of(search(parsed.get())); } } @VisibleForTesting abstract static class StringSearcher implements Searcher { @Override public final Optional tryParse(String input) { return matches(input) ? Optional.of(input) : Optional.empty(); } protected abstract boolean matches(String input); } private abstract class AccountIdSearcher implements Searcher { @Override public final Stream search(Account.Id input) { return accountCache.get(input).stream(); } } private static class BySelf extends StringSearcher { @Override public boolean callerShouldFilterOutInactiveCandidates() { return false; } @Override public boolean requiresContextUser() { return true; } @Override protected boolean matches(String input) { return "self".equals(input) || "me".equals(input); } @Override public Stream search(String input, CurrentUser asUser) { if (!asUser.isIdentifiedUser()) { return Stream.empty(); } return Stream.of(asUser.asIdentifiedUser().state()); } @Override public boolean shortCircuitIfNoResults() { return true; } } private class ByExactAccountId extends AccountIdSearcher { @Override public boolean callerShouldFilterOutInactiveCandidates() { return false; } @Override public Optional tryParse(String input) { return Account.Id.tryParse(input); } @Override public boolean shortCircuitIfNoResults() { return true; } } private class ByParenthesizedAccountId extends AccountIdSearcher { private final Pattern pattern = Pattern.compile("^.* \\(([1-9][0-9]*)\\)$"); @Override public Optional tryParse(String input) { Matcher m = pattern.matcher(input); return m.matches() ? Account.Id.tryParse(m.group(1)) : Optional.empty(); } @Override public boolean shortCircuitIfNoResults() { return true; } } private class ByUsername extends StringSearcher { @Override public boolean matches(String input) { return ExternalId.isValidUsername(input); } @Override public Stream search(String input) { return accountCache.getByUsername(input).stream(); } @Override public boolean shortCircuitIfNoResults() { return false; } } private class ByNameAndEmail extends StringSearcher { @Override protected boolean matches(String input) { int lt = input.indexOf('<'); int gt = input.indexOf('>'); return lt >= 0 && gt > lt && input.contains("@"); } @Override public Stream search(String nameOrEmail) throws IOException { // TODO(dborowitz): This would probably work as a Searcher

int lt = nameOrEmail.indexOf('<'); int gt = nameOrEmail.indexOf('>'); ImmutableSet ids = emails.getAccountFor(nameOrEmail.substring(lt + 1, gt)); ImmutableList allMatches = toAccountStates(ids).collect(toImmutableList()); if (allMatches.isEmpty() || allMatches.size() == 1) { return allMatches.stream(); } // More than one match. If there are any that match the full name as well, return only that // subset. Otherwise, all are equally non-matching, so return the full set. if (lt == 0) { // No name was specified in the input string. return allMatches.stream(); } String name = nameOrEmail.substring(0, lt - 1); ImmutableList nameMatches = allMatches.stream() .filter(a -> name.equals(a.account().fullName())) .collect(toImmutableList()); return !nameMatches.isEmpty() ? nameMatches.stream() : allMatches.stream(); } @Override public boolean shortCircuitIfNoResults() { return true; } } private class ByEmail extends StringSearcher { @Override public boolean requiresContextUser() { return true; } @Override protected boolean matches(String input) { return input.contains("@"); } @Override public Stream search(String input, CurrentUser asUser) throws IOException { boolean canViewSecondaryEmails = false; try { if (permissionBackend.user(asUser).test(GlobalPermission.VIEW_SECONDARY_EMAILS)) { canViewSecondaryEmails = true; } } catch (PermissionBackendException e) { // remains false } if (canViewSecondaryEmails) { return toAccountStates(emails.getAccountFor(input)); } // User cannot see secondary emails, hence search by preferred email only. List accountStates = accountQueryProvider.get().byPreferredEmail(input); if (accountStates.size() == 1) { return Stream.of(Iterables.getOnlyElement(accountStates)); } if (accountStates.size() > 1) { // An email can only belong to a single account. If multiple accounts are found it means // there is an inconsistency, i.e. some of the found accounts have a preferred email set // that they do not own via an external ID. Hence in this case we return only the one // account that actually owns the email via an external ID. for (AccountState accountState : accountStates) { if (accountState.externalIds().stream() .map(ExternalId::email) .filter(Objects::nonNull) .anyMatch(email -> email.equals(input))) { return Stream.of(accountState); } } // None of the matched accounts owns the email, return all matches to be consistent with // the behavior of Emails.getAccountFor(String) that is used above if the user can see // secondary emails. return accountStates.stream(); } // No match by preferred email. Since users can always see their own secondary emails, check // if the input matches a secondary email of the user and if yes, return the account of the // user. if (asUser.isIdentifiedUser() && asUser.asIdentifiedUser().state().externalIds().stream() .map(ExternalId::email) .filter(Objects::nonNull) .anyMatch(email -> email.equals(input))) { return Stream.of(asUser.asIdentifiedUser().state()); } // No match. return Stream.empty(); } @Override public boolean shortCircuitIfNoResults() { return true; } } private class FromRealm extends AccountIdSearcher { @Override public Optional tryParse(String input) throws IOException { return Optional.ofNullable(realm.lookup(input)); } @Override public boolean shortCircuitIfNoResults() { return false; } } private class ByFullName extends StringSearcher { ByFullName() { super(); } @Override protected boolean matches(String input) { return true; } @Override public Stream search(String input) { return accountQueryProvider.get().byFullName(input).stream(); } @Override public boolean shortCircuitIfNoResults() { return false; } } private class ByDefaultSearch extends StringSearcher { ByDefaultSearch() { super(); } @Override public boolean requiresContextUser() { return true; } @Override protected boolean matches(String input) { return true; } @Override public Stream search(String input, CurrentUser asUser) { // At this point we have no clue. Just perform a whole bunch of suggestions and pray we come // up with a reasonable result list. // TODO(dborowitz): This doesn't match the documentation; consider whether it's possible to be // more strict here. boolean canViewSecondaryEmails = false; try { if (permissionBackend.user(asUser).test(GlobalPermission.VIEW_SECONDARY_EMAILS)) { canViewSecondaryEmails = true; } } catch (PermissionBackendException e) { // remains false } return accountQueryProvider.get().byDefault(input, canViewSecondaryEmails).stream(); } @Override public boolean shortCircuitIfNoResults() { // In practice this doesn't matter since this is the last searcher in the list, but considered // on its own, it doesn't necessarily need to be terminal. return false; } } private final ImmutableList> nameOrEmailSearchers = ImmutableList.of( new ByNameAndEmail(), new ByEmail(), new FromRealm(), new ByFullName(), new ByDefaultSearch()); private final ImmutableList> searchers = ImmutableList.>builder() .add(new BySelf()) .add(new ByExactAccountId()) .add(new ByParenthesizedAccountId()) .add(new ByUsername()) .addAll(nameOrEmailSearchers) .build(); private final ImmutableList> exactSearchers = ImmutableList.>builder() .add(new BySelf()) .add(new ByExactAccountId()) .add(new ByEmail()) .build(); private final AccountCache accountCache; private final AccountControl.Factory accountControlFactory; private final Emails emails; private final IdentifiedUser.GenericFactory userFactory; private final Provider self; private final Provider accountQueryProvider; private final Realm realm; private final String anonymousCowardName; private final PermissionBackend permissionBackend; @Inject AccountResolver( AccountCache accountCache, Emails emails, AccountControl.Factory accountControlFactory, IdentifiedUser.GenericFactory userFactory, Provider self, Provider accountQueryProvider, PermissionBackend permissionBackend, Realm realm, @AnonymousCowardName String anonymousCowardName) { this.accountCache = accountCache; this.emails = emails; this.accountControlFactory = accountControlFactory; this.userFactory = userFactory; this.self = self; this.accountQueryProvider = accountQueryProvider; this.permissionBackend = permissionBackend; this.realm = realm; this.anonymousCowardName = anonymousCowardName; } /** * Resolves all accounts matching the input string, visible to the current user. * *

The following input formats are recognized: * *

    *
  • The strings {@code "self"} and {@code "me"}, if the current user is an {@link * IdentifiedUser}. In this case, may return exactly one inactive account. *
  • A bare account ID ({@code "18419"}). In this case, may return exactly one inactive * account. This case short-circuits if the input matches. *
  • An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This * case short-circuits if the input matches. *
  • A username ({@code "username"}). *
  • A full name and email address ({@code "Full Name "}). This case * short-circuits if the input matches. *
  • An email address ({@code "email@example"}. This case short-circuits if the input matches. *
  • An account name recognized by the configured {@link Realm#lookup(String)} Realm}. *
  • A full name ({@code "Full Name"}). *
  • As a fallback, a {@link * com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema, * boolean, String) default search} against the account index. *
* * @param input input string. * @return a result describing matching accounts. Never null even if the result set is empty. * @throws ConfigInvalidException if an error occurs. * @throws IOException if an error occurs. */ public Result resolve(String input) throws ConfigInvalidException, IOException { return searchImpl( input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive); } /** Resolves accounts using exact searchers. Similar to the previous method. */ @UsedAt(Project.GOOGLE) public Result resolveExact(String input) throws ConfigInvalidException, IOException { return searchImpl( input, exactSearchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive); } public Result resolve(String input, Predicate accountActivityPredicate) throws ConfigInvalidException, IOException { return searchImpl( input, searchers, self.get(), this::currentUserCanSeePredicate, accountActivityPredicate); } /** * Resolves all accounts matching the input string, visible to the provided user. * *

The following input formats are recognized: * *

    *
  • The strings {@code "self"} and {@code "me"}, if the provided user is an {@link * IdentifiedUser}. In this case, may return exactly one inactive account. *
  • A bare account ID ({@code "18419"}). In this case, may return exactly one inactive * account. This case short-circuits if the input matches. *
  • An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This * case short-circuits if the input matches. *
  • A username ({@code "username"}). *
  • A full name and email address ({@code "Full Name "}). This case * short-circuits if the input matches. *
  • An email address ({@code "email@example"}. This case short-circuits if the input matches. *
  • An account name recognized by the configured {@link Realm#lookup(String)} Realm}. *
  • A full name ({@code "Full Name"}). *
  • As a fallback, a {@link * com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema, * boolean, String) default search} against the account index. *
* * @param asUser user to resolve the users by. * @param input input string. * @return a result describing matching accounts. Never null even if the result set is empty. * @throws ConfigInvalidException if an error occurs. * @throws IOException if an error occurs. */ public Result resolveAsUser(CurrentUser asUser, String input) throws ConfigInvalidException, IOException { return resolveAsUser(asUser, input, AccountResolver::isActive); } public Result resolveAsUser( CurrentUser asUser, String input, Predicate accountActivityPredicate) throws ConfigInvalidException, IOException { return searchImpl( input, searchers, asUser, new ProvidedUserCanSeePredicate(asUser), accountActivityPredicate); } /** * As opposed to {@link #resolve}, the returned result includes all inactive accounts for the * input search. * *

This can be used to resolve Gerrit Account from email to its {@link * com.google.gerrit.entities.Account.Id}, to make sure that if {@link Account} with such email * exists in Gerrit (even inactive), user data (email address) won't be recorded as it is, but * instead will be stored as a link to the corresponding Gerrit Account. */ public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException { return searchImpl( input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::allVisible); } public Result resolveIncludeInactiveIgnoreVisibility(String input) throws ConfigInvalidException, IOException { return searchImpl( input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::allVisible); } public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException { return searchImpl( input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive); } public Result resolveAsUserIgnoreVisibility(CurrentUser asUser, String input) throws ConfigInvalidException, IOException { return resolveAsUserIgnoreVisibility(asUser, input, AccountResolver::isActive); } public Result resolveAsUserIgnoreVisibility( CurrentUser asUser, String input, Predicate accountActivityPredicate) throws ConfigInvalidException, IOException { return searchImpl( input, searchers, asUser, this::allVisiblePredicate, accountActivityPredicate); } /** * Resolves all accounts matching the input string by name or email. * *

The following input formats are recognized: * *

    *
  • A full name and email address ({@code "Full Name "}). This case * short-circuits if the input matches. *
  • An email address ({@code "email@example"}. This case short-circuits if the input matches. *
  • An account name recognized by the configured {@link Realm#lookup(String)} Realm}. *
  • A full name ({@code "Full Name"}). *
  • As a fallback, a {@link * com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema, * boolean, String) default search} against the account index. *
* * @param input input string. * @return a result describing matching accounts. Never null even if the result set is empty. * @throws ConfigInvalidException if an error occurs. * @throws IOException if an error occurs. * @deprecated for use only by MailUtil for parsing commit footers; that class needs to be * reevaluated. */ @Deprecated public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException { return searchImpl( input, nameOrEmailSearchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive); } /** * Same as {@link #resolveByNameOrEmail(String)}, but with exact matching for the full name, email * and full name. * * @param input input string. * @return a result describing matching accounts. Never null even if the result set is empty. * @throws ConfigInvalidException if an error occurs. * @throws IOException if an error occurs. * @deprecated for use only by MailUtil for parsing commit footers; that class needs to be * reevaluated. */ @Deprecated public Result resolveByExactNameOrEmail(String input) throws ConfigInvalidException, IOException { return searchImpl( input, ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()), self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive); } private Predicate currentUserCanSeePredicate() { return accountControlFactory.get()::canSee; } private class ProvidedUserCanSeePredicate implements Supplier> { CurrentUser asUser; ProvidedUserCanSeePredicate(CurrentUser asUser) { this.asUser = asUser; } @Override public Predicate get() { return accountControlFactory.get(asUser)::canSee; } } private Predicate allVisiblePredicate() { return AccountResolver::allVisible; } /** @param accountState account state for which the visibility should be checked */ private static boolean allVisible(AccountState accountState) { return true; } private static boolean isActive(AccountState accountState) { return accountState.account().isActive(); } @VisibleForTesting Result searchImpl( String input, ImmutableList> searchers, CurrentUser asUser, Supplier> visibilitySupplier, Predicate accountActivityPredicate) throws ConfigInvalidException, IOException { requireNonNull(asUser); visibilitySupplier = Suppliers.memoize(visibilitySupplier::get); List inactive = new ArrayList<>(); for (Searcher searcher : searchers) { Optional> maybeResults = searcher.trySearch(input, asUser); if (!maybeResults.isPresent()) { continue; } Stream results = maybeResults.get(); // Filter out non-visible results, except if it's the BySelf searcher. Since users can always // see themselves checking the visibility is not needed for the BySelf searcher. results = searcher instanceof BySelf ? results : results.filter(visibilitySupplier.get()); List list; if (searcher.callerShouldFilterOutInactiveCandidates()) { // Keep track of all inactive candidates discovered by any searchers. If we end up short- // circuiting, the inactive list will be discarded. List active = new ArrayList<>(); results.forEach(a -> (accountActivityPredicate.test(a) ? active : inactive).add(a)); list = active; } else { list = results.collect(toImmutableList()); } if (!list.isEmpty()) { return createResult(input, list, asUser); } if (searcher.shortCircuitIfNoResults()) { // For a short-circuiting searcher, return results even if empty. return !inactive.isEmpty() ? emptyResult(input, inactive, asUser) : createResult(input, list, asUser); } } return emptyResult(input, inactive, asUser); } private Result createResult(String input, List list, CurrentUser searchedAsUser) { return new Result(input, list, ImmutableList.of(), searchedAsUser); } private Result emptyResult( String input, List inactive, CurrentUser searchedAsUser) { return new Result(input, ImmutableList.of(), inactive, searchedAsUser); } private Stream toAccountStates(Set ids) { return accountCache.get(ids).values().stream(); } }