Support adding configured external Ids during remote sync

This change allows admins to configure a list of external Ids that will
always be added during a remote sync. This helps prevent login failures
caused by the deletion of external Ids from the remote.

Change-Id: I3a047b19e79d181c66511286b604d7b152e49b3a
diff --git a/README.md b/README.md
index f61d6c1..99158b2 100644
--- a/README.md
+++ b/README.md
@@ -178,3 +178,14 @@
 [cache "accounts"]
     refreshAfterWrite = 23h
 ```
+
+# How to set up external IDs that should be persistently saved
+
+External Ids that should always be retained after a remote sync can be
+configured in the `${GERRIT_SITE}/etc/remote-gerrit-account-cache_externalIds`
+file. This file expects the account id, external id and email with a tab delimiter.
+
+Example:
+```
+1000001	externalId=mailto:user@example.com	email=user@example.com
+```
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/remotegerritaccountcache/AccountCacheImpl.java b/src/main/java/com/googlesource/gerrit/plugins/remotegerritaccountcache/AccountCacheImpl.java
index 1d6589f..42de925 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/remotegerritaccountcache/AccountCacheImpl.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/remotegerritaccountcache/AccountCacheImpl.java
@@ -39,13 +39,15 @@
 import com.google.gerrit.server.account.CachedAccountDetails;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
-import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.CachedPreferences;
 import com.google.gerrit.server.config.DefaultPreferencesCache;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.TabFile;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -64,11 +66,15 @@
 import java.net.http.HttpClient;
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
@@ -132,7 +138,7 @@
   }
 
   public static final String BYID_AND_REV_NAME = "accounts";
-  private final ExternalIds externalIds;
+  private final ExternalIdsNoteDbImpl externalIds;
   private final LoadingCache<CachedAccountDetails.Key, CachedAccountDetails> accountDetailsCache;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
@@ -141,7 +147,7 @@
 
   @Inject
   AccountCacheImpl(
-      ExternalIds externalIds,
+      ExternalIdsNoteDbImpl externalIds,
       @Named(BYID_AND_REV_NAME)
           LoadingCache<CachedAccountDetails.Key, CachedAccountDetails> accountDetailsCache,
       GitRepositoryManager repoManager,
@@ -243,13 +249,14 @@
     private final AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory;
     private final Provider<AccountIndexer> accountIndexerProvider;
     private final ListeningExecutorService executor;
-    private final ExternalIds externalIds;
+    private final ExternalIdsNoteDbImpl externalIds;
     private final Config config;
     private final ExternalIdKeyFactory externalIdKeyFactory;
     private final GitRepositoryManager repoManager;
     private final AllUsersName allUsersName;
     private final HttpClient client;
     private final APIRateLimiter rateLimiter;
+    private final Map<Account.Id, List<ExternalId>> extIdsByAccount;
 
     @Inject
     Loader(
@@ -257,12 +264,13 @@
             AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory,
         Provider<AccountIndexer> accountIndexerProvider,
         @IndexExecutor(BATCH) ListeningExecutorService executor,
-        ExternalIds externalIds,
+        ExternalIdsNoteDbImpl externalIds,
         Config config,
         ExternalIdKeyFactory externalIdKeyFactory,
         GitRepositoryManager repoManager,
         AllUsersName allUsersName,
-        APIRateLimiter rateLimiter) {
+        APIRateLimiter rateLimiter,
+        ConfiguredExternalIds configuredExternalIds) {
       this.accountsUpdateFactory = accountsUpdateFactory;
       this.accountIndexerProvider = accountIndexerProvider;
       this.executor = executor;
@@ -273,6 +281,7 @@
       this.allUsersName = allUsersName;
       this.rateLimiter = rateLimiter;
       this.client = getHttpClient();
+      this.extIdsByAccount = configuredExternalIds.get();
     }
 
     @SuppressWarnings("unused")
@@ -285,7 +294,7 @@
         }
       }
       Account.Id accountId = Account.id(accountInfo._accountId);
-      Collection<ExternalId> extIds = new ArrayList<>();
+      Collection<ExternalId> extIds = extIdsByAccount.getOrDefault(accountId, new ArrayList<>());
       Collection<ExternalId> oldExtIds = new ArrayList<>();
       try {
         extIds.addAll(getExternalIdsFromRemoteSite(accountId));
@@ -300,7 +309,7 @@
                   .setPreferredEmail(accountInfo.email)
                   .setStatus(accountInfo.status)
                   .deleteExternalIds(oldExtIds)
-                  .addExternalIds(extIds);
+                  .updateExternalIds(extIds);
 
       try (Repository repo = repoManager.openRepository(allUsersName)) {
         Ref userRef = repo.exactRef(RefNames.refsUsers(key.accountId()));
@@ -404,8 +413,77 @@
     }
   }
 
+  protected static class ConfiguredExternalIds extends TabFile {
+    private static final String FILENAME = "remote-gerrit-account-cache_externalIds";
+    private static final String KEY_EXTERNAL_ID = "externalId";
+    private static final String KEY_EMAIL = "email";
+
+    private final ExternalIdKeyFactory externalIdKeyFactory;
+    private final Path configuredExternalIdsFile;
+
+    @Inject
+    ConfiguredExternalIds(ExternalIdKeyFactory externalIdKeyFactory, SitePaths site) {
+      this.externalIdKeyFactory = externalIdKeyFactory;
+      this.configuredExternalIdsFile = site.etc_dir.resolve(FILENAME);
+    }
+
+    Map<Account.Id, List<ExternalId>> get() {
+      List<TabFile.Row> rows;
+      try {
+        rows =
+            parse(
+                getFileContent(),
+                FILENAME,
+                TRIM,
+                TRIM,
+                error ->
+                    logger.atSevere().log(
+                        "Error parsing file %s: %s",
+                        configuredExternalIdsFile, error.getMessage()));
+      } catch (IOException e) {
+        logger.atInfo().withCause(e).log("Unable to read %s", configuredExternalIdsFile);
+        return Collections.emptyMap();
+      }
+
+      Map<Account.Id, List<ExternalId>> extIdsByAccount = new HashMap<>();
+      for (TabFile.Row row : rows) {
+        try {
+          ExternalId externalId = parseRow(row);
+          extIdsByAccount
+              .computeIfAbsent(externalId.accountId(), k -> new ArrayList<>())
+              .add(externalId);
+        } catch (AccountNotFoundException e) {
+          logger.atSevere().withCause(e).log("Failed to process %s", row);
+        }
+      }
+      return extIdsByAccount;
+    }
+
+    protected String getFileContent() throws IOException {
+      return Files.readString(configuredExternalIdsFile);
+    }
+
+    protected ExternalId parseRow(TabFile.Row row) throws AccountNotFoundException {
+      Account.Id accountId =
+          Account.Id.tryParse(row.left)
+              .orElseThrow(() -> new AccountNotFoundException(row.left + " not found"));
+      Map<String, String> properties = new HashMap<>();
+      String[] args = row.right.split("\t");
+      for (String arg : args) {
+        String[] keyValue = arg.split("=", 2);
+        properties.put(keyValue[0], keyValue[1]);
+      }
+      return ExternalId.create(
+          externalIdKeyFactory.parse(properties.get(KEY_EXTERNAL_ID)),
+          accountId,
+          properties.get(KEY_EMAIL),
+          null,
+          null);
+    }
+  }
+
   /** Signals that the account was not found in the primary storage. */
-  private static class AccountNotFoundException extends Exception {
+  public static class AccountNotFoundException extends Exception {
     private static final long serialVersionUID = 1L;
 
     public AccountNotFoundException(String message) {
@@ -414,7 +492,7 @@
   }
 
   /** Signals that the account was not found in the remote Gerrit. */
-  private static class AccountNotFoundInRemoteGerritException extends Exception {
+  public static class AccountNotFoundInRemoteGerritException extends Exception {
     private static final long serialVersionUID = 1L;
 
     public AccountNotFoundInRemoteGerritException(String message) {
diff --git a/test/docker/gerrit/etc/remote-gerrit-account-cache_externalIds b/test/docker/gerrit/etc/remote-gerrit-account-cache_externalIds
new file mode 100644
index 0000000..344c41a
--- /dev/null
+++ b/test/docker/gerrit/etc/remote-gerrit-account-cache_externalIds
@@ -0,0 +1 @@
+1000001	externalId=mailto:user1@secondary.com	email=user1@secondary.com
diff --git a/test/test_remote_gerrit_account_cache.sh b/test/test_remote_gerrit_account_cache.sh
index 7aa5550..3b08a41 100755
--- a/test/test_remote_gerrit_account_cache.sh
+++ b/test/test_remote_gerrit_account_cache.sh
@@ -31,4 +31,22 @@
 )
 result_out_json "Email added on remote gerrit site is available on the internal site" \
     "$EXPECTED" "$ACTUAL"
+
+# The test case assumes that `user1` does not have `user1@secondary.com` email
+# address configured on remote gerrit site and internal site has the external
+# id configured in $GERRIT_SITE/etc/remote-gerrit-account-cache_externalIds
+ACTUAL=$(gcurl --request GET "$GERRIT_BASE_URL/a/accounts/user1/external.ids" | \
+    jq '.[] | select(.identity == "mailto:user1@secondary.com")')
+EXPECTED=$(cat <<EOF
+{
+   "can_delete": true,
+   "email_address": "user1@secondary.com",
+   "identity": "mailto:user1@secondary.com",
+   "trusted": true
+}
+EOF
+)
+result_out_json "Configured external id is available on the internal site" \
+    "$EXPECTED" "$ACTUAL"
+
 exit "$RESULT"
\ No newline at end of file