com.google.gerrit.server.account.AccountResolver Maven / Gradle / Ivy
// 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.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:
*
*
* - For each recognized format in the order listed in the method Javadoc, check whether the
* input matches that format.
*
- If so, resolve accounts according to that format.
*
- Filter out invisible and inactive accounts.
*
- If the result list is non-empty, return.
*
- If the format is listed above as being short-circuiting, return.
*
- 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.
return accountQueryProvider.get().enforceVisibility(true).byDefault(input).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;
@Inject
AccountResolver(
AccountCache accountCache,
Emails emails,
AccountControl.Factory accountControlFactory,
IdentifiedUser.GenericFactory userFactory,
Provider self,
Provider accountQueryProvider,
Realm realm,
@AnonymousCowardName String anonymousCowardName) {
this.realm = realm;
this.accountCache = accountCache;
this.accountControlFactory = accountControlFactory;
this.userFactory = userFactory;
this.self = self;
this.accountQueryProvider = accountQueryProvider;
this.emails = emails;
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, visibilitySupplierCanSee(), accountActivityPredicate());
}
public Result resolve(String input, Predicate accountActivityPredicate)
throws ConfigInvalidException, IOException {
return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate);
}
public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate());
}
public Result resolveIgnoreVisibility(
String input, Predicate accountActivityPredicate)
throws ConfigInvalidException, IOException {
return searchImpl(input, searchers, visibilitySupplierAll(), 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, visibilitySupplierCanSee(), accountActivityPredicate());
}
/**
* 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()),
visibilitySupplierCanSee(),
accountActivityPredicate());
}
private Supplier> visibilitySupplierCanSee() {
return () -> accountControlFactory.get()::canSee;
}
private Supplier> visibilitySupplierAll() {
return () -> all();
}
private Predicate all() {
return accountState -> {
return true;
};
}
private Predicate accountActivityPredicate() {
return (AccountState accountState) -> 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();
}
}