Create local user if user does not exist anymore in LDAP

So far, importer plugin aborted the importing process with an exception
if an user listed as a change owner, reviewer or group member did not
exist anymore in LDAP. That use case arises, for example, when an user
leaves the company. The same problem occurred when dealing with non
existing Gerrit internal users.

Create local inactive user accounts to replace missing LDAP users and
add them to a special "Imported Users" group. Inactive users are still
able to replay changes. Create Gerrit internal users if they have not
been created previous to the import process.

Change-Id: Iad1b1c3963cca7dff6ebcc59efcf2b5620e0fd42
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/AccountUtil.java b/src/main/java/com/googlesource/gerrit/plugins/importer/AccountUtil.java
index fe76caf..e87ec1d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/importer/AccountUtil.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/AccountUtil.java
@@ -17,27 +17,40 @@
 import com.google.gerrit.common.errors.NoSuchAccountException;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.CreateAccount;
 import com.google.gerrit.server.account.GetSshKeys.SshKeyInfo;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.lib.PersonIdent;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Objects;
@@ -46,26 +59,42 @@
 class AccountUtil {
   private static Logger log = LoggerFactory.getLogger(AccountUtil.class);
 
+  private static final String IMPORTED_USERS = "Imported Users";
+  private static final AccountGroup.NameKey IMPORTED_USERS_NAME =
+      new AccountGroup.NameKey(IMPORTED_USERS);
+
   private final AccountCache accountCache;
   private final AccountManager accountManager;
   private final AuthType authType;
+  private final IdentifiedUser currentUser;
+  private final GroupCache groupCache;
+  private final PersonIdent serverIdent;
   private final Provider<ReviewDb> db;
 
   @Inject
+  private CreateAccount.Factory createAccountFactory;
+
+  @Inject
   public AccountUtil(
       AccountCache accountCache,
       AccountManager accountManager,
       AuthConfig authConfig,
+      GroupCache groupCache,
+      IdentifiedUser currentUser,
+      @GerritPersonIdent PersonIdent serverIdent,
       Provider<ReviewDb> db) {
     this.accountCache = accountCache;
     this.accountManager = accountManager;
     this.authType = authConfig.getAuthType();
+    this.currentUser = currentUser;
     this.db = db;
+    this.groupCache = groupCache;
+    this.serverIdent = serverIdent;
   }
 
   Account.Id resolveUser(GerritApi api, AccountInfo acc)
       throws NoSuchAccountException, BadRequestException, IOException,
-      OrmException {
+      OrmException, ResourceConflictException, UnprocessableEntityException {
     if (acc.username == null) {
       throw new NoSuchAccountException(String.format(
           "User %s <%s> (%s) doesn't have a username and cannot be looked up.",
@@ -80,8 +109,7 @@
         case LDAP:
           return createAccountByLdapAndAddSshKeys(api, acc);
         default:
-          throw new NoSuchAccountException(String.format("User %s not found",
-              acc.username));
+          return createLocalUser(acc);
       }
     }
     if (!Objects.equals(a.getAccount().getPreferredEmail(), acc.email)) {
@@ -89,14 +117,13 @@
           "Email mismatch for user %s: expected %s but found %s",
           acc.username, acc.email, a.getAccount().getPreferredEmail()));
     }
-
     return a.getAccount().getId();
   }
 
   private Account.Id createAccountByLdapAndAddSshKeys(GerritApi api,
-      AccountInfo acc)
-      throws NoSuchAccountException, BadRequestException, IOException,
-      OrmException {
+      AccountInfo acc) throws NoSuchAccountException, BadRequestException,
+      IOException, OrmException, ResourceConflictException,
+      UnprocessableEntityException {
     if (!acc.username.matches(Account.USER_NAME_PATTERN)) {
       throw new NoSuchAccountException(String.format("User %s not found",
           acc.username));
@@ -109,13 +136,12 @@
       addSshKeys(api, acc);
       return id;
     } catch (AccountException e) {
-      throw new NoSuchAccountException(
-          String.format("User %s not found", acc.username));
+      return createLocalUser(acc);
     }
   }
 
-  private void addSshKeys(GerritApi api, AccountInfo acc) throws
-  BadRequestException, IOException, OrmException {
+  private void addSshKeys(GerritApi api, AccountInfo acc)
+      throws BadRequestException, IOException, OrmException {
     List<SshKeyInfo> sshKeys = api.getSshKeys(acc.username);
     AccountState a = accountCache.getByUsername(acc.username);
     db.get().accountSshKeys().upsert(toAccountSshKey(a, sshKeys));
@@ -132,4 +158,60 @@
     }
     return result;
   }
+
+  private Account.Id createLocalUser(AccountInfo acc)
+      throws BadRequestException, ResourceConflictException,
+      UnprocessableEntityException, OrmException {
+    CreateAccount.Input input = new CreateAccount.Input();
+    log.info(String.format("User '%s' not found", acc.username));
+    String username = acc.username;
+    input.username = username;
+    input.email = acc.email;
+    input.name = acc.name;
+
+    AccountInfo accInfo =
+        createAccountFactory.create(username)
+            .apply(TopLevelResource.INSTANCE, input).value();
+    log.info(String.format("Local user '%s' created", username));
+
+    Account.Id userId = new Account.Id(accInfo._accountId);
+    Account account = accountCache.get(userId).getAccount();
+    account.setActive(false);
+    addToImportedUsersGroup(userId);
+    accountCache.evict(userId);
+    return userId;
+  }
+
+  private void addToImportedUsersGroup(Account.Id id) throws OrmException {
+    AccountGroup group = getImportedUsersGroup();
+    AccountGroupMember member =
+        new AccountGroupMember(new AccountGroupMember.Key(id, group.getId()));
+    db.get().accountGroupMembers().insert(Collections.singleton(member));
+  }
+
+  private AccountGroup getImportedUsersGroup() throws OrmException {
+    AccountGroup accGroup = groupCache.get(IMPORTED_USERS_NAME);
+    if (accGroup == null) {
+      accGroup = createImportedUsersGroup();
+    }
+    return accGroup;
+  }
+
+  private AccountGroup createImportedUsersGroup() throws OrmException {
+    AccountGroup.Id groupId = new AccountGroup.Id(db.get().nextAccountGroupId());
+    AccountGroup.UUID uuid =
+        GroupUUID.make(
+            IMPORTED_USERS,
+            currentUser.newCommitterIdent(
+                serverIdent.getWhen(),
+                serverIdent.getTimeZone()));
+    AccountGroup group = new AccountGroup(IMPORTED_USERS_NAME, groupId, uuid);
+    group.setDescription(IMPORTED_USERS);
+
+    db.get().accountGroupNames()
+        .insert(Collections.singleton(new AccountGroupName(group)));
+    db.get().accountGroups().insert(Collections.singleton(group));
+    groupCache.onCreateGroup(IMPORTED_USERS_NAME);
+    return group;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/AddApprovalsStep.java b/src/main/java/com/googlesource/gerrit/plugins/importer/AddApprovalsStep.java
index b5deef0..a9eb69b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/importer/AddApprovalsStep.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/AddApprovalsStep.java
@@ -23,6 +23,8 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
@@ -81,7 +83,8 @@
   }
 
   void add(GerritApi api) throws OrmException, NoSuchChangeException, IOException,
-      NoSuchAccountException, BadRequestException {
+      NoSuchAccountException, BadRequestException, ResourceConflictException,
+      UnprocessableEntityException{
     if (resume) {
       db.patchSetApprovals().delete(
           db.patchSetApprovals().byChange(change.getId()));
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/ImportGroup.java b/src/main/java/com/googlesource/gerrit/plugins/importer/ImportGroup.java
index 4194a6c..f94d734 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/importer/ImportGroup.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/ImportGroup.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupById;
@@ -115,7 +116,7 @@
   public Response<String> apply(ConfigResource rsrc, Input input)
       throws ResourceConflictException, PreconditionFailedException,
       BadRequestException, NoSuchAccountException, OrmException, IOException,
-      MethodNotAllowedException {
+      MethodNotAllowedException, UnprocessableEntityException {
     GroupInfo groupInfo;
     this.api = apiFactory.create(input.from, input.user, input.pass);
     groupInfo = api.getGroup(group.get());
@@ -125,9 +126,10 @@
     return Response.<String> ok("OK");
   }
 
-  private void validate(Input input, GroupInfo groupInfo) throws ResourceConflictException,
-      PreconditionFailedException, BadRequestException, IOException,
-      OrmException, NoSuchAccountException, MethodNotAllowedException {
+  private void validate(Input input, GroupInfo groupInfo)
+      throws ResourceConflictException, PreconditionFailedException,
+      BadRequestException, IOException, OrmException, NoSuchAccountException,
+      MethodNotAllowedException, UnprocessableEntityException {
     if (!isInternalGroup(new AccountGroup.UUID(groupInfo.id))) {
       throw new MethodNotAllowedException(String.format(
           "Group with name %s is not an internal group and cannot be imported",
@@ -183,7 +185,8 @@
 
   private CreateGroupArgs toCreateGroupArgs(GroupInfo groupInfo)
       throws IOException, OrmException, BadRequestException,
-      NoSuchAccountException {
+      NoSuchAccountException, ResourceConflictException,
+      UnprocessableEntityException {
     CreateGroupArgs args = new CreateGroupArgs();
     args.setGroupName(groupInfo.name);
     args.groupDescription = groupInfo.description;
@@ -206,7 +209,8 @@
 
   private AccountGroup createGroup(Input input, GroupInfo info) throws OrmException,
       ResourceConflictException, NoSuchAccountException, BadRequestException,
-      IOException, PreconditionFailedException, MethodNotAllowedException {
+      IOException, PreconditionFailedException, MethodNotAllowedException,
+      UnprocessableEntityException {
     String uniqueName = getUniqueGroupName(info.name);
     if (!info.name.equals(uniqueName)) {
       log.warn(String.format("Group %s with UUID %s is imported with name %s",
@@ -285,7 +289,7 @@
 
   private void addMembers(AccountGroup.Id groupId, List<AccountInfo> members)
       throws OrmException, NoSuchAccountException, BadRequestException,
-      IOException {
+      IOException, ResourceConflictException, UnprocessableEntityException {
     List<AccountGroupMember> memberships = new ArrayList<>();
     for (AccountInfo member : members) {
       Account.Id userId = accountUtil.resolveUser(api, member);
@@ -304,7 +308,7 @@
       String groupName, List<GroupInfo> includedGroups)
       throws BadRequestException, ResourceConflictException,
       PreconditionFailedException, NoSuchAccountException, OrmException,
-      IOException, MethodNotAllowedException {
+      IOException, MethodNotAllowedException, UnprocessableEntityException {
     List<AccountGroupById> includeList = new ArrayList<>();
     for (GroupInfo includedGroup : includedGroups) {
       if (isInternalGroup(new AccountGroup.UUID(includedGroup.id))) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/ImportGroupsStep.java b/src/main/java/com/googlesource/gerrit/plugins/importer/ImportGroupsStep.java
index bcccf39..c55971c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/importer/ImportGroupsStep.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/ImportGroupsStep.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.PreconditionFailedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.GroupCache;
@@ -77,7 +78,8 @@
   }
 
   void importGroups() throws PreconditionFailedException, BadRequestException,
-      NoSuchAccountException, OrmException, IOException {
+      NoSuchAccountException, OrmException, IOException,
+      UnprocessableEntityException {
     ProjectConfig projectConfig = projectCache.get(project).getConfig();
     Set<AccountGroup.UUID> groupUUIDs = projectConfig.getAllGroupUUIDs();
     pm.beginTask("Import Groups", groupUUIDs.size());
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayChangesStep.java b/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayChangesStep.java
index 5cc44f3..2630429 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayChangesStep.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayChangesStep.java
@@ -21,7 +21,9 @@
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -220,7 +222,8 @@
   }
 
   private Change createChange(ChangeInfo c) throws OrmException,
-      NoSuchAccountException, BadRequestException, IOException {
+      NoSuchAccountException, BadRequestException, IOException,
+      ResourceConflictException, UnprocessableEntityException {
     Change.Id changeId = new Change.Id(db.nextChangeId());
 
     Change change =
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayMessagesStep.java b/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayMessagesStep.java
index 306d819..ad29c03 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayMessagesStep.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayMessagesStep.java
@@ -18,6 +18,8 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -75,7 +77,8 @@
   }
 
   void replay(GerritApi api) throws NoSuchAccountException, NoSuchChangeException,
-      OrmException, IOException, BadRequestException {
+      OrmException, IOException, BadRequestException, ResourceConflictException,
+      UnprocessableEntityException {
     for (ChangeMessageInfo msg : changeInfo.messages) {
       ChangeMessage.Key msgKey = new ChangeMessage.Key(change.getId(), msg.id);
       if (resume && db.changeMessages().get(msgKey) != null) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayRevisionsStep.java b/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayRevisionsStep.java
index a4b4601..9bce9e1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayRevisionsStep.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayRevisionsStep.java
@@ -18,6 +18,8 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
@@ -80,7 +82,7 @@
   }
 
   void replay(GerritApi api) throws IOException, OrmException, NoSuchAccountException,
-      BadRequestException {
+      BadRequestException, ResourceConflictException, UnprocessableEntityException {
     List<RevisionInfo> revisions = new ArrayList<>(changeInfo.revisions.values());
     sortRevisionInfoByNumber(revisions);
     List<PatchSet> patchSets = new ArrayList<>();
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index 64069c1..7a7526b 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -95,9 +95,16 @@
 Account] or the link:access-control.html#capability_administrateServer[
 Administrate Server] capability assigned on the source system.
 
-Gerrit internal users (e.g. service users) are never automatically
-created but must be created in the target Gerrit server before the
-import.
+If for any reason an user is no longer existent in the LDAP instance but it
+is listed as a change owner, reviewer or as part of a group, a local user
+account is created with the same username. Giving that such user is not longer
+active in the system, their account status is set to inactive.
+
+Gerrit internal users (e.g. service users) should be created in the target
+Gerrit server before the import. Otherwise, an inactive local user is created
+with the same username.
+
+All locally created users are added to an internal group called "Imported Users".
 
 <a id="project-import-commands">
 #### Commands
@@ -277,9 +284,16 @@
 member are automatically retrieved from the source Gerrit server and
 added to the new account in the target Gerrit server.
 
-Gerrit internal users (e.g. service users) are never automatically
-created but must be created in the target Gerrit server before the
-import.
+If for any reason an user is no longer existent in the LDAP instance but it
+is listed as a group member, a local user account is created with the same
+username. Giving that such user is not longer active in the system, their account
+status is set to inactive.
+
+Gerrit internal users (e.g. service users) should be created in the target
+Gerrit server before the import. Otherwise, an inactive local user is created
+with the same username.
+
+All locally created users are added to an internal group called "Imported Users".
 
 If the group name is occupied in the target system (a group with the
 same name, but a different UUID exists already), the group is