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.10.1
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.Streams;
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.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; @VisibleForTesting Result(String input, List list, List filteredInactive) { this.input = requireNonNull(input); this.list = canonicalize(list); this.filteredInactive = canonicalize(filteredInactive); } 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); } } public IdentifiedUser asUniqueUser() throws UnresolvableAccountException { ensureUnique(); if (isSelf()) { // 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 self.get().asIdentifiedUser(); } return userFactory.create(asUnique()); } public IdentifiedUser asUniqueUserOnBehalfOf(CurrentUser caller) throws UnresolvableAccountException { ensureUnique(); if (isSelf()) { // TODO(dborowitz): This preserves old behavior, but it seems wrong to discard the caller. return self.get().asIdentifiedUser(); } return userFactory.runAs( 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; } default boolean callerMayAssumeCandidatesAreVisible() { return false; } Optional tryParse(String input) throws IOException; Stream search(I input) throws IOException, ConfigInvalidException; boolean shortCircuitIfNoResults(); default Optional> trySearch(String input) throws IOException, ConfigInvalidException { Optional parsed = tryParse(input); return parsed.isPresent() ? Optional.of(search(parsed.get())) : Optional.empty(); } } @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 Streams.stream(accountCache.get(input)); } } private class BySelf extends StringSearcher { @Override public boolean callerShouldFilterOutInactiveCandidates() { return false; } @Override public boolean callerMayAssumeCandidatesAreVisible() { return true; } @Override protected boolean matches(String input) { return "self".equals(input) || "me".equals(input); } @Override public Stream search(String input) { CurrentUser user = self.get(); if (!user.isIdentifiedUser()) { return Stream.empty(); } return Stream.of(user.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 Streams.stream(accountCache.getByUsername(input)); } @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('>'); Set 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 protected boolean matches(String input) { return input.contains("@"); } @Override public Stream search(String input) throws IOException { return toAccountStates(emails.getAccountFor(input)); } @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 implements Searcher { @Override public boolean callerMayAssumeCandidatesAreVisible() { return true; // Rely on enforceVisibility from the index. } @Override public Optional tryParse(String input) { List results = accountQueryProvider.get().enforceVisibility(true).byFullName(input); return results.size() == 1 ? Optional.of(results.get(0)) : Optional.empty(); } @Override public Stream search(AccountState input) { return Stream.of(input); } @Override public boolean shortCircuitIfNoResults() { return false; } } private class ByDefaultSearch extends StringSearcher { @Override public boolean callerMayAssumeCandidatesAreVisible() { return true; // Rely on enforceVisibility from the index. } @Override protected boolean matches(String input) { return true; } @Override public Stream search(String input) { // 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 canSeeSecondaryEmails = false; try { if (permissionBackend.user(self.get()).test(GlobalPermission.MODIFY_ACCOUNT)) { canSeeSecondaryEmails = true; } } catch (PermissionBackendException e) { // remains false } return accountQueryProvider.get().enforceVisibility(true) .byDefault(input, canSeeSecondaryEmails).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 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, this::canSeePredicate, AccountResolver::isActive); } public Result resolve(String input, Predicate accountActivityPredicate) throws ConfigInvalidException, IOException { return searchImpl(input, searchers, this::canSeePredicate, 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, this::canSeePredicate, AccountResolver::allVisible); } public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException { return searchImpl(input, searchers, this::allVisiblePredicate, AccountResolver::isActive); } public Result resolveIgnoreVisibility( String input, Predicate accountActivityPredicate) throws ConfigInvalidException, IOException { return searchImpl(input, searchers, 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, this::canSeePredicate, 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()), this::canSeePredicate, AccountResolver::isActive); } private Predicate canSeePredicate() { return this::canSee; } private boolean canSee(AccountState accountState) { return accountControlFactory.get().canSee(accountState); } 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, Supplier> visibilitySupplier, Predicate accountActivityPredicate) throws ConfigInvalidException, IOException { visibilitySupplier = Suppliers.memoize(visibilitySupplier::get); List inactive = new ArrayList<>(); for (Searcher searcher : searchers) { Optional> maybeResults = searcher.trySearch(input); if (!maybeResults.isPresent()) { continue; } Stream results = maybeResults.get(); if (!searcher.callerMayAssumeCandidatesAreVisible()) { 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); } if (searcher.shortCircuitIfNoResults()) { // For a short-circuiting searcher, return results even if empty. return !inactive.isEmpty() ? emptyResult(input, inactive) : createResult(input, list); } } return emptyResult(input, inactive); } private Result createResult(String input, List list) { return new Result(input, list, ImmutableList.of()); } private Result emptyResult(String input, List inactive) { return new Result(input, ImmutableList.of(), inactive); } private Stream toAccountStates(Set ids) { return accountCache.get(ids).values().stream(); } }