LCOV - code coverage report
Current view: top level - server/account - AccountManager.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 167 226 73.9 %
Date: 2022-11-19 15:00:39 Functions: 20 28 71.4 %

          Line data    Source code
       1             : // Copyright (C) 2009 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.server.account;
      16             : 
      17             : import static com.google.common.base.Preconditions.checkArgument;
      18             : import static com.google.common.collect.ImmutableSet.toImmutableSet;
      19             : import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
      20             : 
      21             : import com.google.common.annotations.VisibleForTesting;
      22             : import com.google.common.base.Strings;
      23             : import com.google.common.collect.ImmutableList;
      24             : import com.google.common.collect.ImmutableSet;
      25             : import com.google.common.collect.Sets;
      26             : import com.google.common.flogger.FluentLogger;
      27             : import com.google.gerrit.common.data.GlobalCapability;
      28             : import com.google.gerrit.entities.AccessSection;
      29             : import com.google.gerrit.entities.Account;
      30             : import com.google.gerrit.entities.AccountGroup;
      31             : import com.google.gerrit.entities.Permission;
      32             : import com.google.gerrit.exceptions.NoSuchGroupException;
      33             : import com.google.gerrit.exceptions.StorageException;
      34             : import com.google.gerrit.extensions.client.AccountFieldName;
      35             : import com.google.gerrit.server.IdentifiedUser;
      36             : import com.google.gerrit.server.ServerInitiated;
      37             : import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
      38             : import com.google.gerrit.server.account.externalids.ExternalId;
      39             : import com.google.gerrit.server.account.externalids.ExternalIdFactory;
      40             : import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
      41             : import com.google.gerrit.server.account.externalids.ExternalIds;
      42             : import com.google.gerrit.server.auth.NoSuchUserException;
      43             : import com.google.gerrit.server.config.GerritServerConfig;
      44             : import com.google.gerrit.server.group.db.GroupDelta;
      45             : import com.google.gerrit.server.group.db.GroupsUpdate;
      46             : import com.google.gerrit.server.notedb.Sequences;
      47             : import com.google.gerrit.server.project.ProjectCache;
      48             : import com.google.gerrit.server.ssh.SshKeyCache;
      49             : import com.google.inject.Inject;
      50             : import com.google.inject.Provider;
      51             : import com.google.inject.Singleton;
      52             : import java.io.IOException;
      53             : import java.util.ArrayList;
      54             : import java.util.Collection;
      55             : import java.util.List;
      56             : import java.util.Objects;
      57             : import java.util.Optional;
      58             : import java.util.Set;
      59             : import java.util.concurrent.atomic.AtomicBoolean;
      60             : import java.util.function.Consumer;
      61             : import org.eclipse.jgit.errors.ConfigInvalidException;
      62             : import org.eclipse.jgit.lib.Config;
      63             : 
      64             : /** Tracks authentication related details for user accounts. */
      65             : @Singleton
      66             : public class AccountManager {
      67         152 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      68             : 
      69             :   private final Sequences sequences;
      70             :   private final Accounts accounts;
      71             :   private final Provider<AccountsUpdate> accountsUpdateProvider;
      72             :   private final AccountCache byIdCache;
      73             :   private final Realm realm;
      74             :   private final IdentifiedUser.GenericFactory userFactory;
      75             :   private final SshKeyCache sshKeyCache;
      76             :   private final ProjectCache projectCache;
      77             :   private final AtomicBoolean awaitsFirstAccountCheck;
      78             :   private final ExternalIds externalIds;
      79             :   private final GroupsUpdate.Factory groupsUpdateFactory;
      80             :   private final boolean autoUpdateAccountActiveStatus;
      81             :   private final SetInactiveFlag setInactiveFlag;
      82             :   private final ExternalIdFactory externalIdFactory;
      83             :   private final ExternalIdKeyFactory externalIdKeyFactory;
      84             : 
      85             :   @VisibleForTesting
      86             :   @Inject
      87             :   public AccountManager(
      88             :       Sequences sequences,
      89             :       @GerritServerConfig Config cfg,
      90             :       Accounts accounts,
      91             :       @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
      92             :       AccountCache byIdCache,
      93             :       Realm accountMapper,
      94             :       IdentifiedUser.GenericFactory userFactory,
      95             :       SshKeyCache sshKeyCache,
      96             :       ProjectCache projectCache,
      97             :       ExternalIds externalIds,
      98             :       GroupsUpdate.Factory groupsUpdateFactory,
      99             :       SetInactiveFlag setInactiveFlag,
     100             :       ExternalIdFactory externalIdFactory,
     101         151 :       ExternalIdKeyFactory externalIdKeyFactory) {
     102         151 :     this.sequences = sequences;
     103         151 :     this.accounts = accounts;
     104         151 :     this.accountsUpdateProvider = accountsUpdateProvider;
     105         151 :     this.byIdCache = byIdCache;
     106         151 :     this.realm = accountMapper;
     107         151 :     this.userFactory = userFactory;
     108         151 :     this.sshKeyCache = sshKeyCache;
     109         151 :     this.projectCache = projectCache;
     110         151 :     this.awaitsFirstAccountCheck =
     111         151 :         new AtomicBoolean(cfg.getBoolean("capability", "makeFirstUserAdmin", true));
     112         151 :     this.externalIds = externalIds;
     113         151 :     this.groupsUpdateFactory = groupsUpdateFactory;
     114         151 :     this.autoUpdateAccountActiveStatus =
     115         151 :         cfg.getBoolean("auth", "autoUpdateAccountActiveStatus", false);
     116         151 :     this.setInactiveFlag = setInactiveFlag;
     117         151 :     this.externalIdFactory = externalIdFactory;
     118         151 :     this.externalIdKeyFactory = externalIdKeyFactory;
     119         151 :   }
     120             : 
     121             :   /** Returns a user identified by this external identity string */
     122             :   public Optional<Account.Id> lookup(String externalId) throws AccountException {
     123             :     try {
     124           0 :       return externalIds.get(externalIdKeyFactory.parse(externalId)).map(ExternalId::accountId);
     125           0 :     } catch (IOException e) {
     126           0 :       throw new AccountException("Cannot lookup account " + externalId, e);
     127             :     }
     128             :   }
     129             : 
     130             :   /**
     131             :    * Authenticate the user, potentially creating a new account if they are new.
     132             :    *
     133             :    * @param who identity of the user, with any details we received about them.
     134             :    * @return the result of authenticating the user.
     135             :    * @throws AccountException the account does not exist, and cannot be created, or exists, but
     136             :    *     cannot be located, is unable to be activated or deactivated, or is inactive, or cannot be
     137             :    *     added to the admin group (only for the first account).
     138             :    */
     139             :   public AuthResult authenticate(AuthRequest who) throws AccountException, IOException {
     140             :     try {
     141          15 :       who = realm.authenticate(who);
     142           0 :     } catch (NoSuchUserException e) {
     143           0 :       deactivateAccountIfItExists(who);
     144           0 :       throw e;
     145          15 :     }
     146             :     try {
     147          15 :       Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
     148          15 :       if (!optionalExtId.isPresent()) {
     149          15 :         logger.atFine().log(
     150             :             "External ID for account %s not found. A new account will be automatically created.",
     151          15 :             who.getUserName());
     152          15 :         return create(who);
     153             :       }
     154             : 
     155           5 :       ExternalId extId = optionalExtId.get();
     156           5 :       Optional<AccountState> accountState = byIdCache.get(extId.accountId());
     157           5 :       if (!accountState.isPresent()) {
     158           1 :         logger.atSevere().log(
     159             :             "Authentication with external ID %s failed. Account %s doesn't exist.",
     160           1 :             extId.key().get(), extId.accountId().get());
     161           1 :         throw new AccountException("Authentication error, account not found");
     162             :       }
     163             : 
     164             :       // Account exists
     165           5 :       Optional<Account> act = updateAccountActiveStatus(who, accountState.get().account());
     166           5 :       if (!act.isPresent()) {
     167             :         // The account was deleted since we checked for it last time. This should never happen
     168             :         // since we don't support deletion of accounts.
     169           0 :         throw new AccountException("Authentication error, account not found");
     170             :       }
     171           5 :       if (!act.get().isActive()) {
     172           1 :         throw new AccountException("Authentication error, account inactive");
     173             :       }
     174             : 
     175             :       // return the identity to the caller.
     176           5 :       update(who, extId);
     177           5 :       return new AuthResult(extId.accountId(), who.getExternalIdKey(), false);
     178           0 :     } catch (StorageException | ConfigInvalidException e) {
     179           0 :       throw new AccountException("Authentication error", e);
     180             :     }
     181             :   }
     182             : 
     183             :   private void deactivateAccountIfItExists(AuthRequest authRequest) {
     184           0 :     if (!shouldUpdateActiveStatus(authRequest)) {
     185           0 :       return;
     186             :     }
     187             :     try {
     188           0 :       Optional<ExternalId> extId = externalIds.get(authRequest.getExternalIdKey());
     189           0 :       if (!extId.isPresent()) {
     190           0 :         return;
     191             :       }
     192           0 :       setInactiveFlag.deactivate(extId.get().accountId());
     193           0 :     } catch (Exception e) {
     194           0 :       logger.atSevere().withCause(e).log(
     195             :           "Unable to deactivate account %s",
     196             :           authRequest
     197           0 :               .getUserName()
     198           0 :               .orElse(" for external ID key " + authRequest.getExternalIdKey().get()));
     199           0 :     }
     200           0 :   }
     201             : 
     202             :   private Optional<Account> updateAccountActiveStatus(AuthRequest authRequest, Account account)
     203             :       throws AccountException {
     204           5 :     if (!shouldUpdateActiveStatus(authRequest) || authRequest.isActive() == account.isActive()) {
     205           5 :       return Optional.of(account);
     206             :     }
     207             : 
     208           1 :     if (authRequest.isActive()) {
     209             :       try {
     210           1 :         setInactiveFlag.activate(account.id());
     211           0 :       } catch (Exception e) {
     212           0 :         throw new AccountException("Unable to activate account " + account.id(), e);
     213           1 :       }
     214             :     } else {
     215             :       try {
     216           1 :         setInactiveFlag.deactivate(account.id());
     217           0 :       } catch (Exception e) {
     218           0 :         throw new AccountException("Unable to deactivate account " + account.id(), e);
     219           1 :       }
     220             :     }
     221           1 :     return byIdCache.get(account.id()).map(AccountState::account);
     222             :   }
     223             : 
     224             :   private boolean shouldUpdateActiveStatus(AuthRequest authRequest) {
     225           5 :     return autoUpdateAccountActiveStatus && authRequest.authProvidesAccountActiveStatus();
     226             :   }
     227             : 
     228             :   private void update(AuthRequest who, ExternalId extId)
     229             :       throws IOException, ConfigInvalidException, AccountException {
     230           5 :     IdentifiedUser user = userFactory.create(extId.accountId());
     231           5 :     List<Consumer<AccountDelta.Builder>> accountUpdates = new ArrayList<>();
     232             : 
     233             :     // If the email address was modified by the authentication provider,
     234             :     // update our records to match the changed email.
     235             :     //
     236           5 :     String newEmail = who.getEmailAddress();
     237           5 :     String oldEmail = extId.email();
     238           5 :     if (newEmail != null && !newEmail.equals(oldEmail)) {
     239           5 :       ExternalId extIdWithNewEmail =
     240           5 :           externalIdFactory.create(extId.key(), extId.accountId(), newEmail, extId.password());
     241           5 :       checkEmailNotUsed(extId.accountId(), extIdWithNewEmail);
     242           5 :       accountUpdates.add(u -> u.replaceExternalId(extId, extIdWithNewEmail));
     243             : 
     244           5 :       if (oldEmail != null && oldEmail.equals(user.getAccount().preferredEmail())) {
     245           1 :         accountUpdates.add(u -> u.setPreferredEmail(newEmail));
     246             :       }
     247             :     }
     248             : 
     249           5 :     if (!Strings.isNullOrEmpty(who.getDisplayName())
     250           5 :         && !Objects.equals(user.getAccount().fullName(), who.getDisplayName())) {
     251           5 :       accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
     252             :     }
     253             : 
     254           5 :     if (!realm.allowsEdit(AccountFieldName.USER_NAME)
     255           0 :         && who.getUserName().isPresent()
     256           0 :         && !who.getUserName().equals(user.getUserName())) {
     257           0 :       if (user.getUserName().isPresent()) {
     258           0 :         logger.atWarning().log(
     259             :             "Not changing already set username %s to %s",
     260           0 :             user.getUserName().get(), who.getUserName().get());
     261             :       } else {
     262           0 :         logger.atWarning().log("Not setting username to %s", who.getUserName().get());
     263             :       }
     264             :     }
     265             : 
     266           5 :     if (!accountUpdates.isEmpty()) {
     267           5 :       Optional<AccountState> updatedAccount =
     268             :           accountsUpdateProvider
     269           5 :               .get()
     270           5 :               .update(
     271             :                   "Update Account on Login",
     272           5 :                   user.getAccountId(),
     273           5 :                   AccountsUpdate.joinConsumers(accountUpdates));
     274           5 :       if (!updatedAccount.isPresent()) {
     275           0 :         throw new StorageException("Account " + user.getAccountId() + " has been deleted");
     276             :       }
     277             :     }
     278           5 :   }
     279             : 
     280             :   private AuthResult create(AuthRequest who)
     281             :       throws AccountException, IOException, ConfigInvalidException {
     282          15 :     Account.Id newId = Account.id(sequences.nextAccountId());
     283          15 :     logger.atFine().log("Assigning new Id %s to account", newId);
     284             : 
     285          15 :     ExternalId extId =
     286          15 :         externalIdFactory.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
     287          15 :     logger.atFine().log("Created external Id: %s", extId);
     288          15 :     checkEmailNotUsed(newId, extId);
     289             :     ExternalId userNameExtId =
     290          15 :         who.getUserName().isPresent() ? createUsername(newId, who.getUserName().get()) : null;
     291             : 
     292          15 :     boolean isFirstAccount = awaitsFirstAccountCheck.getAndSet(false) && !accounts.hasAnyAccount();
     293             : 
     294             :     AccountState accountState;
     295             :     try {
     296          15 :       accountState =
     297             :           accountsUpdateProvider
     298          15 :               .get()
     299          15 :               .insert(
     300             :                   "Create Account on First Login",
     301             :                   newId,
     302             :                   u -> {
     303          15 :                     u.setFullName(who.getDisplayName())
     304          15 :                         .setPreferredEmail(extId.email())
     305          15 :                         .addExternalId(extId);
     306          15 :                     if (userNameExtId != null) {
     307          15 :                       u.addExternalId(userNameExtId);
     308             :                     }
     309          15 :                   });
     310           0 :     } catch (DuplicateExternalIdKeyException e) {
     311           0 :       throw new AccountException(
     312             :           "Cannot assign external ID \""
     313           0 :               + e.getDuplicateKey().get()
     314             :               + "\" to account "
     315             :               + newId
     316             :               + "; external ID already in use.");
     317             :     } finally {
     318             :       // If adding the account failed, it may be that it actually was the
     319             :       // first account. So we reset the 'check for first account'-guard, as
     320             :       // otherwise the first account would not get administration permissions.
     321          15 :       awaitsFirstAccountCheck.set(isFirstAccount);
     322             :     }
     323             : 
     324          15 :     if (userNameExtId != null) {
     325          15 :       who.getUserName().ifPresent(sshKeyCache::evict);
     326             :     }
     327             : 
     328          15 :     IdentifiedUser user = userFactory.create(newId);
     329             : 
     330          15 :     if (isFirstAccount) {
     331             :       // This is the first user account on our site. Assume this user
     332             :       // is going to be the site's administrator and just make them that
     333             :       // to bootstrap the authentication database.
     334             :       //
     335          15 :       Permission admin =
     336             :           projectCache
     337          15 :               .getAllProjects()
     338          15 :               .getConfig()
     339          15 :               .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
     340          15 :               .orElseThrow(() -> new IllegalStateException("access section does not exist"))
     341          15 :               .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
     342             : 
     343          15 :       AccountGroup.UUID adminGroupUuid = admin.getRules().get(0).getGroup().getUUID();
     344          15 :       addGroupMember(adminGroupUuid, user);
     345             :     }
     346             : 
     347          15 :     realm.onCreateAccount(who, accountState.account());
     348          15 :     return new AuthResult(newId, extId.key(), true);
     349             :   }
     350             : 
     351             :   private ExternalId createUsername(Account.Id accountId, String username)
     352             :       throws AccountUserNameException {
     353          15 :     checkArgument(!Strings.isNullOrEmpty(username));
     354             : 
     355          15 :     if (!ExternalId.isValidUsername(username)) {
     356           0 :       throw new AccountUserNameException(
     357           0 :           String.format(
     358             :               "Cannot assign user name \"%s\" to account %s; name does not conform.",
     359             :               username, accountId));
     360             :     }
     361          15 :     return externalIdFactory.create(SCHEME_USERNAME, username, accountId);
     362             :   }
     363             : 
     364             :   private void checkEmailNotUsed(Account.Id accountId, ExternalId extIdToBeCreated)
     365             :       throws IOException, AccountException {
     366          18 :     String email = extIdToBeCreated.email();
     367          18 :     if (email == null) {
     368          15 :       return;
     369             :     }
     370             : 
     371          14 :     Set<ExternalId> existingExtIdsWithEmail = externalIds.byEmail(email);
     372          14 :     if (existingExtIdsWithEmail.isEmpty()) {
     373          14 :       return;
     374             :     }
     375             : 
     376           1 :     for (ExternalId externalId : existingExtIdsWithEmail) {
     377           1 :       if (externalId.accountId().get() != accountId.get()) {
     378           1 :         logger.atWarning().log(
     379             :             "Email %s is already assigned to account %s;"
     380             :                 + " cannot create external ID %s with the same email for account %s.",
     381             :             email,
     382           1 :             externalId.accountId().get(),
     383           1 :             extIdToBeCreated.key().get(),
     384           1 :             extIdToBeCreated.accountId().get());
     385           1 :         throw new AccountException("Email '" + email + "' in use by another account");
     386             :       }
     387           1 :     }
     388           1 :   }
     389             : 
     390             :   private void addGroupMember(AccountGroup.UUID groupUuid, IdentifiedUser user)
     391             :       throws IOException, ConfigInvalidException, AccountException {
     392             :     // The user initiated this request by logging in. -> Attribute all modifications to that user.
     393          15 :     GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
     394             :     GroupDelta groupDelta =
     395          15 :         GroupDelta.builder()
     396          15 :             .setMemberModification(
     397          15 :                 memberIds -> Sets.union(memberIds, ImmutableSet.of(user.getAccountId())))
     398          15 :             .build();
     399             :     try {
     400          15 :       groupsUpdate.updateGroup(groupUuid, groupDelta);
     401           0 :     } catch (NoSuchGroupException e) {
     402           0 :       throw new AccountException(String.format("Group %s not found", groupUuid), e);
     403          15 :     }
     404          15 :   }
     405             : 
     406             :   /**
     407             :    * Link another authentication identity to an existing account.
     408             :    *
     409             :    * @param to account to link the identity onto.
     410             :    * @param who the additional identity.
     411             :    * @return the result of linking the identity to the user.
     412             :    * @throws AccountException the identity belongs to a different account, or it cannot be linked at
     413             :    *     this time.
     414             :    */
     415             :   public AuthResult link(Account.Id to, AuthRequest who)
     416             :       throws AccountException, IOException, ConfigInvalidException {
     417          14 :     Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
     418          14 :     if (optionalExtId.isPresent()) {
     419           2 :       ExternalId extId = optionalExtId.get();
     420           2 :       if (!extId.accountId().equals(to)) {
     421           2 :         throw new AccountException(
     422           2 :             "Identity '" + extId.key().get() + "' in use by another account");
     423             :       }
     424           1 :       update(who, extId);
     425           1 :     } else {
     426          14 :       ExternalId newExtId =
     427          14 :           externalIdFactory.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress());
     428          14 :       checkEmailNotUsed(to, newExtId);
     429          14 :       accountsUpdateProvider
     430          14 :           .get()
     431          14 :           .update(
     432             :               "Link External ID",
     433             :               to,
     434             :               (a, u) -> {
     435          14 :                 u.addExternalId(newExtId);
     436          14 :                 if (who.getEmailAddress() != null && a.account().preferredEmail() == null) {
     437          11 :                   u.setPreferredEmail(who.getEmailAddress());
     438             :                 }
     439          14 :               });
     440             :     }
     441          14 :     return new AuthResult(to, who.getExternalIdKey(), false);
     442             :   }
     443             : 
     444             :   /**
     445             :    * Update the link to another unique authentication identity to an existing account.
     446             :    *
     447             :    * <p>Existing external identities with the same scheme will be removed and replaced with the new
     448             :    * one.
     449             :    *
     450             :    * @param to account to link the identity onto.
     451             :    * @param who the additional identity.
     452             :    * @return the result of linking the identity to the user.
     453             :    * @throws AccountException the identity belongs to a different account, or it cannot be linked at
     454             :    *     this time.
     455             :    */
     456             :   public AuthResult updateLink(Account.Id to, AuthRequest who)
     457             :       throws AccountException, IOException, ConfigInvalidException {
     458           0 :     accountsUpdateProvider
     459           0 :         .get()
     460           0 :         .update(
     461             :             "Delete External IDs on Update Link",
     462             :             to,
     463             :             (a, u) -> {
     464           0 :               Set<ExternalId> filteredExtIdsByScheme =
     465           0 :                   a.externalIds().stream()
     466           0 :                       .filter(e -> e.key().isScheme(who.getExternalIdKey().scheme()))
     467           0 :                       .collect(toImmutableSet());
     468           0 :               if (filteredExtIdsByScheme.isEmpty()) {
     469           0 :                 return;
     470             :               }
     471             : 
     472           0 :               if (filteredExtIdsByScheme.size() > 1
     473           0 :                   || filteredExtIdsByScheme.stream()
     474           0 :                       .noneMatch(e -> e.key().equals(who.getExternalIdKey()))) {
     475           0 :                 u.deleteExternalIds(filteredExtIdsByScheme);
     476             :               }
     477           0 :             });
     478             : 
     479           0 :     return link(to, who);
     480             :   }
     481             : 
     482             :   /**
     483             :    * Unlink an external identity from an existing account.
     484             :    *
     485             :    * @param from account to unlink the external identity from
     486             :    * @param extIdKey the key of the external ID that should be deleted
     487             :    * @throws AccountException the identity belongs to a different account, or the identity was not
     488             :    *     found
     489             :    */
     490             :   public void unlink(Account.Id from, ExternalId.Key extIdKey)
     491             :       throws AccountException, IOException, ConfigInvalidException {
     492           0 :     unlink(from, ImmutableList.of(extIdKey));
     493           0 :   }
     494             : 
     495             :   /**
     496             :    * Unlink an external identities from an existing account.
     497             :    *
     498             :    * @param from account to unlink the external identity from
     499             :    * @param extIdKeys the keys of the external IDs that should be deleted
     500             :    * @throws AccountException any of the identity belongs to a different account, or any of the
     501             :    *     identity was not found
     502             :    */
     503             :   public void unlink(Account.Id from, Collection<ExternalId.Key> extIdKeys)
     504             :       throws AccountException, IOException, ConfigInvalidException {
     505           4 :     if (extIdKeys.isEmpty()) {
     506           0 :       return;
     507             :     }
     508             : 
     509           4 :     List<ExternalId> extIds = new ArrayList<>(extIdKeys.size());
     510           4 :     for (ExternalId.Key extIdKey : extIdKeys) {
     511           4 :       Optional<ExternalId> extId = externalIds.get(extIdKey);
     512           4 :       if (extId.isPresent()) {
     513           4 :         if (!extId.get().accountId().equals(from)) {
     514           0 :           throw new AccountException("Identity '" + extIdKey.get() + "' in use by another account");
     515             :         }
     516           4 :         extIds.add(extId.get());
     517             :       } else {
     518           0 :         throw new AccountException("Identity '" + extIdKey.get() + "' not found");
     519             :       }
     520           4 :     }
     521             : 
     522           4 :     accountsUpdateProvider
     523           4 :         .get()
     524           4 :         .update(
     525           4 :             "Unlink External ID" + (extIds.size() > 1 ? "s" : ""),
     526             :             from,
     527             :             (a, u) -> {
     528           4 :               u.deleteExternalIds(extIds);
     529           4 :               if (a.account().preferredEmail() != null
     530           4 :                   && extIds.stream()
     531           4 :                       .anyMatch(e -> a.account().preferredEmail().equals(e.email()))) {
     532           4 :                 u.setPreferredEmail(null);
     533             :               }
     534           4 :             });
     535           4 :   }
     536             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750