| // 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. |
| * |
| * <p>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: |
| * |
| * <ol> |
| * <li>For each recognized format in the order listed in the method Javadoc, check whether the |
| * input matches that format. |
| * <li>If so, resolve accounts according to that format. |
| * <li>Filter out invisible and inactive accounts. |
| * <li>If the result list is non-empty, return. |
| * <li>If the format is listed above as being short-circuiting, return. |
| * <li>Otherwise, return to step 1 with the next format. |
| * </ol> |
| * |
| * <p>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)) |
| .collect(joining("\n", "Account '" + result.input() + "' is ambiguous:\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<AccountState> list; |
| private final ImmutableList<AccountState> filteredInactive; |
| |
| @VisibleForTesting |
| Result(String input, List<AccountState> list, List<AccountState> filteredInactive) { |
| this.input = requireNonNull(input); |
| this.list = canonicalize(list); |
| this.filteredInactive = canonicalize(filteredInactive); |
| } |
| |
| private ImmutableList<AccountState> canonicalize(List<AccountState> list) { |
| TreeSet<AccountState> 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<AccountState> asList() { |
| return list; |
| } |
| |
| public ImmutableSet<Account.Id> asNonEmptyIdSet() throws UnresolvableAccountException { |
| if (list.isEmpty()) { |
| throw new UnresolvableAccountException(this); |
| } |
| return asIdSet(); |
| } |
| |
| public ImmutableSet<Account.Id> 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<AccountState> filteredInactive() { |
| return filteredInactive; |
| } |
| |
| private AccountResolver accountResolver() { |
| return AccountResolver.this; |
| } |
| } |
| |
| @VisibleForTesting |
| interface Searcher<I> { |
| default boolean callerShouldFilterOutInactiveCandidates() { |
| return true; |
| } |
| |
| default boolean callerMayAssumeCandidatesAreVisible() { |
| return false; |
| } |
| |
| Optional<I> tryParse(String input) throws IOException; |
| |
| Stream<AccountState> search(I input) throws IOException, ConfigInvalidException; |
| |
| boolean shortCircuitIfNoResults(); |
| |
| default Optional<Stream<AccountState>> trySearch(String input) |
| throws IOException, ConfigInvalidException { |
| Optional<I> parsed = tryParse(input); |
| return parsed.isPresent() ? Optional.of(search(parsed.get())) : Optional.empty(); |
| } |
| } |
| |
| @VisibleForTesting |
| abstract static class StringSearcher implements Searcher<String> { |
| @Override |
| public final Optional<String> tryParse(String input) { |
| return matches(input) ? Optional.of(input) : Optional.empty(); |
| } |
| |
| protected abstract boolean matches(String input); |
| } |
| |
| private abstract class AccountIdSearcher implements Searcher<Account.Id> { |
| @Override |
| public final Stream<AccountState> 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<AccountState> 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<Account.Id> 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<Account.Id> 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<AccountState> 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<AccountState> search(String nameOrEmail) throws IOException { |
| // TODO(dborowitz): This would probably work as a Searcher<Address> |
| int lt = nameOrEmail.indexOf('<'); |
| int gt = nameOrEmail.indexOf('>'); |
| Set<Account.Id> ids = emails.getAccountFor(nameOrEmail.substring(lt + 1, gt)); |
| ImmutableList<AccountState> 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<AccountState> 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<AccountState> search(String input) throws IOException { |
| return toAccountStates(emails.getAccountFor(input)); |
| } |
| |
| @Override |
| public boolean shortCircuitIfNoResults() { |
| return true; |
| } |
| } |
| |
| private class FromRealm extends AccountIdSearcher { |
| @Override |
| public Optional<Account.Id> tryParse(String input) throws IOException { |
| return Optional.ofNullable(realm.lookup(input)); |
| } |
| |
| @Override |
| public boolean shortCircuitIfNoResults() { |
| return false; |
| } |
| } |
| |
| private class ByFullName implements Searcher<AccountState> { |
| @Override |
| public boolean callerMayAssumeCandidatesAreVisible() { |
| return true; // Rely on enforceVisibility from the index. |
| } |
| |
| @Override |
| public Optional<AccountState> tryParse(String input) { |
| List<AccountState> results = |
| accountQueryProvider.get().enforceVisibility(true).byFullName(input); |
| return results.size() == 1 ? Optional.of(results.get(0)) : Optional.empty(); |
| } |
| |
| @Override |
| public Stream<AccountState> 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<AccountState> 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<Searcher<?>> nameOrEmailSearchers = |
| ImmutableList.of( |
| new ByNameAndEmail(), |
| new ByEmail(), |
| new FromRealm(), |
| new ByFullName(), |
| new ByDefaultSearch()); |
| |
| private final ImmutableList<Searcher<?>> searchers = |
| ImmutableList.<Searcher<?>>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<CurrentUser> self; |
| private final Provider<InternalAccountQuery> accountQueryProvider; |
| private final Realm realm; |
| private final String anonymousCowardName; |
| |
| @Inject |
| AccountResolver( |
| AccountCache accountCache, |
| Emails emails, |
| AccountControl.Factory accountControlFactory, |
| IdentifiedUser.GenericFactory userFactory, |
| Provider<CurrentUser> self, |
| Provider<InternalAccountQuery> 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. |
| * |
| * <p>The following input formats are recognized: |
| * |
| * <ul> |
| * <li>The strings {@code "self"} and {@code "me"}, if the current user is an {@link |
| * IdentifiedUser}. In this case, may return exactly one inactive account. |
| * <li>A bare account ID ({@code "18419"}). In this case, may return exactly one inactive |
| * account. This case short-circuits if the input matches. |
| * <li>An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This |
| * case short-circuits if the input matches. |
| * <li>A username ({@code "username"}). |
| * <li>A full name and email address ({@code "Full Name <email@example>"}). This case |
| * short-circuits if the input matches. |
| * <li>An email address ({@code "email@example"}. This case short-circuits if the input matches. |
| * <li>An account name recognized by the configured {@link Realm#lookup(String)} Realm}. |
| * <li>A full name ({@code "Full Name"}). |
| * <li>As a fallback, a {@link |
| * com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema, |
| * boolean, String) default search} against the account index. |
| * </ul> |
| * |
| * @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<AccountState> 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<AccountState> accountActivityPredicate) |
| throws ConfigInvalidException, IOException { |
| return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate); |
| } |
| |
| /** |
| * Resolves all accounts matching the input string by name or email. |
| * |
| * <p>The following input formats are recognized: |
| * |
| * <ul> |
| * <li>A full name and email address ({@code "Full Name <email@example>"}). This case |
| * short-circuits if the input matches. |
| * <li>An email address ({@code "email@example"}. This case short-circuits if the input matches. |
| * <li>An account name recognized by the configured {@link Realm#lookup(String)} Realm}. |
| * <li>A full name ({@code "Full Name"}). |
| * <li>As a fallback, a {@link |
| * com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema, |
| * boolean, String) default search} against the account index. |
| * </ul> |
| * |
| * @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<Predicate<AccountState>> visibilitySupplierCanSee() { |
| return () -> accountControlFactory.get()::canSee; |
| } |
| |
| private Supplier<Predicate<AccountState>> visibilitySupplierAll() { |
| return () -> all(); |
| } |
| |
| private Predicate<AccountState> all() { |
| return accountState -> { |
| return true; |
| }; |
| } |
| |
| private Predicate<AccountState> accountActivityPredicate() { |
| return (AccountState accountState) -> accountState.account().isActive(); |
| } |
| |
| @VisibleForTesting |
| Result searchImpl( |
| String input, |
| ImmutableList<Searcher<?>> searchers, |
| Supplier<Predicate<AccountState>> visibilitySupplier, |
| Predicate<AccountState> accountActivityPredicate) |
| throws ConfigInvalidException, IOException { |
| visibilitySupplier = Suppliers.memoize(visibilitySupplier::get); |
| List<AccountState> inactive = new ArrayList<>(); |
| |
| for (Searcher<?> searcher : searchers) { |
| Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input); |
| if (!maybeResults.isPresent()) { |
| continue; |
| } |
| Stream<AccountState> results = maybeResults.get(); |
| |
| if (!searcher.callerMayAssumeCandidatesAreVisible()) { |
| results = results.filter(visibilitySupplier.get()); |
| } |
| |
| List<AccountState> 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<AccountState> 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<AccountState> list) { |
| return new Result(input, list, ImmutableList.of()); |
| } |
| |
| private Result emptyResult(String input, List<AccountState> inactive) { |
| return new Result(input, ImmutableList.of(), inactive); |
| } |
| |
| private Stream<AccountState> toAccountStates(Set<Account.Id> ids) { |
| return accountCache.get(ids).values().stream(); |
| } |
| } |