Merge branch 'stable-3.3'

* stable-3.3:
  Ensure in-memory model matches state committed to All-Projects
  Fix formatting of documentation
  Remove unsused variable
  Allow blocking of usernames using wildcards and regex
  Use MetaDataUpdate.Server instead of MetaDataUpdate.User in cache loader
  Add command to register existing user as service user
  Cache the service user db
  Load ProjectLevelConfig directly
  Remove standalone build mode
  Remove unnecessary public qualifier from GetConfig.ConfigInfo
  Fix errorprone errors
  Define Provider<ProjectLevelConfig.Bare>, reduce code repetition
  Stop using deprecated JGit API getAllRefs
  Make compatible with Gerrit stable-3.3 branch
  Upgrade bazlets to latest stable-3.2 to build with 3.2.5.1 API
  Upgrade bazlets to latest stable-3.1 to build with 3.1.10 API
  Bump Bazel version to 3.7.0
  Upgrade bazlets to latest stable-3.0 to build with 3.0.13 API
  Bump Bazel version to 3.5.0

Change-Id: Iff000a965e1f3ff31880116c8f3748b01b5fbc30
diff --git a/.bazelrc b/.bazelrc
deleted file mode 100644
index 3ae03ff..0000000
--- a/.bazelrc
+++ /dev/null
@@ -1,2 +0,0 @@
-build --workspace_status_command="python ./tools/workspace_status.py"
-test --build_tests_only
diff --git a/.bazelversion b/.bazelversion
index 47b322c..7c69a55 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.4.1
+3.7.0
diff --git a/.gitignore b/.gitignore
index acae119..a94d0d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,3 @@
-/bazel-*
 /eclipse-out
 /target
 /.classpath
diff --git a/WORKSPACE b/WORKSPACE
deleted file mode 100644
index a8d8194..0000000
--- a/WORKSPACE
+++ /dev/null
@@ -1,15 +0,0 @@
-workspace(name = "serviceuser")
-
-load("//:bazlets.bzl", "load_bazlets")
-
-load_bazlets(
-    commit = "0f81174e3d1b892a1342ebc75bb4bbb158ae0efe",
-    #local_path = "/home/<user>/projects/bazlets",
-)
-
-load(
-    "@com_googlesource_gerrit_bazlets//:gerrit_api.bzl",
-    "gerrit_api",
-)
-
-gerrit_api(version = "3.3.0-SNAPSHOT")
diff --git a/bazlets.bzl b/bazlets.bzl
deleted file mode 100644
index f089af4..0000000
--- a/bazlets.bzl
+++ /dev/null
@@ -1,18 +0,0 @@
-load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
-
-NAME = "com_googlesource_gerrit_bazlets"
-
-def load_bazlets(
-        commit,
-        local_path = None):
-    if not local_path:
-        git_repository(
-            name = NAME,
-            remote = "https://gerrit.googlesource.com/bazlets",
-            commit = commit,
-        )
-    else:
-        native.local_repository(
-            name = NAME,
-            path = local_path,
-        )
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilter.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilter.java
new file mode 100644
index 0000000..c365fb7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilter.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2021 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.googlesource.gerrit.plugins.serviceuser;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+@Singleton
+public class BlockedNameFilter {
+  private final PluginConfig cfg;
+  private final Set<String> blockedExactNames = new HashSet<>();
+  private final List<String> blockedNamePrefixes = new ArrayList<>();
+  private final List<Pattern> blockedRegexNames = new ArrayList<>();
+
+  @Inject
+  public BlockedNameFilter(PluginConfigFactory cfgFactory, @PluginName String pluginName) {
+    this.cfg = cfgFactory.getFromGerritConfig(pluginName);
+    parseConfig();
+  }
+
+  public boolean isBlocked(String username) {
+    username = username.toLowerCase();
+    return isBlockedByExactName(username)
+        || isBlockedByWildcard(username)
+        || isBlockedByRegex(username);
+  }
+
+  private void parseConfig() {
+    for (String s : cfg.getStringList("block")) {
+      if (s.startsWith("^")) {
+        blockedRegexNames.add(Pattern.compile(s, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE));
+      } else if (s.endsWith("*")) {
+        blockedNamePrefixes.add(s.substring(0, s.length() - 1).toLowerCase());
+      } else {
+        blockedExactNames.add(s.toLowerCase());
+      }
+    }
+  }
+
+  private boolean isBlockedByExactName(String username) {
+    return blockedExactNames.contains(username);
+  }
+
+  private boolean isBlockedByWildcard(String username) {
+    for (String prefix : blockedNamePrefixes) {
+      if (username.startsWith(prefix)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean isBlockedByRegex(String username) {
+    for (Pattern p : blockedRegexNames) {
+      if (p.matcher(username).find()) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java
index 50fc6a0..d7cdff8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java
@@ -16,9 +16,7 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
@@ -36,12 +34,12 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.restapi.account.CreateAccount;
 import com.google.inject.Inject;
@@ -55,7 +53,6 @@
 import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Date;
-import java.util.List;
 import java.util.Locale;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -78,47 +75,44 @@
   }
 
   private final PluginConfig cfg;
+  private final Provider<ProjectLevelConfig.Bare> configProvider;
   private final CreateAccount createAccount;
-  private final List<String> blockedNames;
   private final Provider<CurrentUser> userProvider;
   private final MetaDataUpdate.User metaDataUpdateFactory;
   private final Project.NameKey allProjects;
-  private final ProjectLevelConfig storage;
   private final DateFormat rfc2822DateFormatter;
   private final Provider<GetConfig> getConfig;
   private final AccountLoader.Factory accountLoader;
+  private final StorageCache storageCache;
+  private final BlockedNameFilter blockedNameFilter;
 
   @Inject
   CreateServiceUser(
       PluginConfigFactory cfgFactory,
+      Provider<ProjectLevelConfig.Bare> configProvider,
       @PluginName String pluginName,
       CreateAccount createAccount,
       Provider<CurrentUser> userProvider,
       @GerritPersonIdent PersonIdent gerritIdent,
       MetaDataUpdate.User metaDataUpdateFactory,
-      ProjectCache projectCache,
+      AllProjectsName allProjects,
       Provider<GetConfig> getConfig,
-      AccountLoader.Factory accountLoader) {
+      AccountLoader.Factory accountLoader,
+      StorageCache storageCache,
+      BlockedNameFilter blockedNameFilter) {
     this.cfg = cfgFactory.getFromGerritConfig(pluginName);
+    this.configProvider = configProvider;
     this.createAccount = createAccount;
-    this.blockedNames =
-        Lists.transform(
-            Arrays.asList(cfg.getStringList("block")),
-            new Function<String, String>() {
-              @Override
-              public String apply(String blockedName) {
-                return blockedName.toLowerCase();
-              }
-            });
     this.userProvider = userProvider;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
-    this.allProjects = projectCache.getAllProjects().getProject().getNameKey();
+    this.allProjects = allProjects;
     this.rfc2822DateFormatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
     this.rfc2822DateFormatter.setCalendar(
         Calendar.getInstance(gerritIdent.getTimeZone(), Locale.US));
     this.getConfig = getConfig;
     this.accountLoader = accountLoader;
+    this.storageCache = storageCache;
+    this.blockedNameFilter = blockedNameFilter;
   }
 
   @Override
@@ -146,7 +140,7 @@
       throw new BadRequestException("sshKey invalid.");
     }
 
-    if (blockedNames.contains(username.toLowerCase())) {
+    if (blockedNameFilter.isBlocked(username)) {
       throw new BadRequestException(
           "The username '" + username + "' is not allowed as name for service users.");
     }
@@ -178,16 +172,21 @@
     Account.Id creatorId = ((IdentifiedUser) user).getAccountId();
     String creationDate = rfc2822DateFormatter.format(new Date());
 
-    Config db = storage.get();
-    db.setInt(USER, username, KEY_CREATOR_ID, creatorId.get());
-    if (creator != null) {
-      db.setString(USER, username, KEY_CREATED_BY, creator);
-    }
-    db.setString(USER, username, KEY_CREATED_AT, creationDate);
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(allProjects)) {
+      ProjectLevelConfig.Bare update = configProvider.get();
+      update.load(md);
 
-    MetaDataUpdate md = metaDataUpdateFactory.create(allProjects);
-    md.setMessage("Create service user '" + username + "'\n");
-    storage.commit(md);
+      Config db = update.getConfig();
+      db.setInt(USER, username, KEY_CREATOR_ID, creatorId.get());
+      if (creator != null) {
+        db.setString(USER, username, KEY_CREATED_BY, creator);
+      }
+      db.setString(USER, username, KEY_CREATED_AT, creationDate);
+
+      md.setMessage("Create service user '" + username + "'\n");
+      update.commit(md);
+      storageCache.invalidate();
+    }
     ServiceUserInfo info = new ServiceUserInfo(response);
     AccountLoader al = accountLoader.create(true);
     info.createdBy = al.get(creatorId);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUserNotes.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUserNotes.java
index 93fc84c..d60203b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUserNotes.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUserNotes.java
@@ -131,8 +131,9 @@
     }
   }
 
-  private void markUninteresting(Repository git, String branch, RevWalk rw, ObjectId oldObjectId) {
-    for (Ref r : git.getAllRefs().values()) {
+  private void markUninteresting(Repository git, String branch, RevWalk rw, ObjectId oldObjectId)
+      throws IOException {
+    for (Ref r : git.getRefDatabase().getRefs()) {
       try {
         if (r.getName().equals(branch)) {
           if (!ObjectId.zeroId().equals(oldObjectId)) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetConfig.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetConfig.java
index f89f047..3cca663 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetConfig.java
@@ -95,7 +95,7 @@
     return v ? v : null;
   }
 
-  public class ConfigInfo {
+  static class ConfigInfo {
     public String info;
     public String onSuccess;
     public Boolean allowEmail;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetOwner.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetOwner.java
index 74b97b2..bf7227b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetOwner.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetOwner.java
@@ -18,7 +18,6 @@
 import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.USER;
 
 import com.google.gerrit.entities.GroupDescription;
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
@@ -26,37 +25,30 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.restapi.group.GroupJson;
 import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 
 @Singleton
 class GetOwner implements RestReadView<ServiceUserResource> {
   private final GroupsCollection groups;
-  private final String pluginName;
-  private final ProjectCache projectCache;
   private final GroupJson json;
+  private final StorageCache storageCache;
 
   @Inject
-  GetOwner(
-      GroupsCollection groups,
-      @PluginName String pluginName,
-      ProjectCache projectCache,
-      GroupJson json) {
+  GetOwner(GroupsCollection groups, GroupJson json, StorageCache storageCache) {
     this.groups = groups;
-    this.pluginName = pluginName;
-    this.projectCache = projectCache;
     this.json = json;
+    this.storageCache = storageCache;
   }
 
   @Override
   public Response<GroupInfo> apply(ServiceUserResource rsrc)
-      throws RestApiException, PermissionBackendException {
-    ProjectLevelConfig storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
-    String owner = storage.get().getString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER);
+      throws IOException, RestApiException, PermissionBackendException {
+    String owner =
+        storageCache.get().getString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER);
     if (owner != null) {
       GroupDescription.Basic group =
           groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(owner)).getGroup();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetServiceUser.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetServiceUser.java
index c700da8..784c6b0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetServiceUser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetServiceUser.java
@@ -21,7 +21,6 @@
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -30,42 +29,37 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.restapi.account.GetAccount;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
 class GetServiceUser implements RestReadView<ServiceUserResource> {
   private final Provider<GetAccount> getAccount;
-  private final String pluginName;
-  private final ProjectCache projectCache;
   private final GetOwner getOwner;
   private final AccountLoader.Factory accountLoader;
+  private final StorageCache storageCache;
 
   @Inject
   GetServiceUser(
       Provider<GetAccount> getAccount,
-      @PluginName String pluginName,
-      ProjectCache projectCache,
       GetOwner getOwner,
-      AccountLoader.Factory accountLoader) {
+      AccountLoader.Factory accountLoader,
+      StorageCache storageCache) {
     this.getAccount = getAccount;
-    this.pluginName = pluginName;
-    this.projectCache = projectCache;
     this.getOwner = getOwner;
     this.accountLoader = accountLoader;
+    this.storageCache = storageCache;
   }
 
   @Override
   public Response<ServiceUserInfo> apply(ServiceUserResource rsrc)
-      throws RestApiException, PermissionBackendException {
-    ProjectLevelConfig storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
+      throws IOException, RestApiException, PermissionBackendException {
     String username = rsrc.getUser().getUserName().get();
-    Config db = storage.get();
+    Config db = storageCache.get();
     if (!db.getSubsections(USER).contains(username)) {
       throw new ResourceNotFoundException(username);
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ListServiceUsers.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ListServiceUsers.java
index 5e77ce2..a40b989 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ListServiceUsers.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ListServiceUsers.java
@@ -18,7 +18,6 @@
 import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.USER;
 
 import com.google.common.collect.Maps;
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -30,8 +29,6 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -45,39 +42,35 @@
 @Singleton
 class ListServiceUsers implements RestReadView<ConfigResource> {
   private final Provider<CurrentUser> userProvider;
-  private final String pluginName;
-  private final ProjectCache projectCache;
   private final AccountCache accountCache;
   private final Provider<ServiceUserCollection> serviceUsers;
   private final Provider<GetServiceUser> getServiceUser;
+  private final StorageCache storageCache;
 
   @Inject
   ListServiceUsers(
       Provider<CurrentUser> userProvider,
-      @PluginName String pluginName,
-      ProjectCache projectCache,
       AccountCache accountCache,
       Provider<ServiceUserCollection> serviceUsers,
-      Provider<GetServiceUser> getServiceUser) {
+      Provider<GetServiceUser> getServiceUser,
+      StorageCache storageCache) {
     this.userProvider = userProvider;
-    this.pluginName = pluginName;
-    this.projectCache = projectCache;
     this.accountCache = accountCache;
     this.serviceUsers = serviceUsers;
     this.getServiceUser = getServiceUser;
+    this.storageCache = storageCache;
   }
 
   @Override
   public Response<Map<String, ServiceUserInfo>> apply(ConfigResource rscr)
       throws IOException, RestApiException, PermissionBackendException, ConfigInvalidException {
-    ProjectLevelConfig storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
     CurrentUser user = userProvider.get();
     if (user == null || !user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
 
     Map<String, ServiceUserInfo> accounts = Maps.newTreeMap();
-    Config db = storage.get();
+    Config db = storageCache.get();
     for (String username : db.getSubsections(USER)) {
       Optional<AccountState> account = accountCache.getByUsername(username);
       if (account.isPresent()) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
index 56d14e1..89510bb 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
@@ -19,13 +19,16 @@
 import static com.googlesource.gerrit.plugins.serviceuser.ServiceUserResource.SERVICE_USER_SSH_KEY_KIND;
 
 import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestApiModule;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 
 class Module extends AbstractModule {
@@ -71,5 +74,11 @@
           }
         });
     install(new HttpModule());
+    install(StorageCache.module());
+  }
+
+  @Provides
+  ProjectLevelConfig.Bare createProjectLevelConfig(@PluginName String pluginName) {
+    return new ProjectLevelConfig.Bare(pluginName + ".db");
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java
index eb7dbc0..32ee082 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -47,6 +46,7 @@
 import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.serviceuser.PutOwner.Input;
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -57,39 +57,39 @@
 
   private final Provider<GetConfig> getConfig;
   private final GroupsCollection groups;
-  private final String pluginName;
-  private final ProjectCache projectCache;
+  private final Provider<ProjectLevelConfig.Bare> configProvider;
   private final Project.NameKey allProjects;
   private final MetaDataUpdate.User metaDataUpdateFactory;
   private final GroupJson json;
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
+  private final StorageCache storageCache;
 
   @Inject
   PutOwner(
       Provider<GetConfig> getConfig,
       GroupsCollection groups,
-      @PluginName String pluginName,
+      Provider<ProjectLevelConfig.Bare> configProvider,
       ProjectCache projectCache,
       MetaDataUpdate.User metaDataUpdateFactory,
       GroupJson json,
       Provider<CurrentUser> self,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      StorageCache storageCache) {
     this.getConfig = getConfig;
     this.groups = groups;
-    this.pluginName = pluginName;
-    this.projectCache = projectCache;
+    this.configProvider = configProvider;
     this.allProjects = projectCache.getAllProjects().getProject().getNameKey();
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.json = json;
     this.self = self;
     this.permissionBackend = permissionBackend;
+    this.storageCache = storageCache;
   }
 
   @Override
   public Response<GroupInfo> apply(ServiceUserResource rsrc, Input input)
       throws RestApiException, IOException, PermissionBackendException {
-    ProjectLevelConfig storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
     Boolean ownerAllowed;
     try {
       ownerAllowed = getConfig.get().apply(new ConfigResource()).value().allowOwner;
@@ -103,22 +103,33 @@
     if (input == null) {
       input = new Input();
     }
-    Config db = storage.get();
-    String oldGroup = db.getString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER);
+
     GroupDescription.Basic group = null;
-    if (Strings.isNullOrEmpty(input.group)) {
-      db.unset(USER, rsrc.getUser().getUserName().get(), KEY_OWNER);
-    } else {
-      group = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(input.group)).getGroup();
-      UUID groupUUID = group.getGroupUUID();
-      if (!AccountGroup.uuid(groupUUID.get()).isInternalGroup()) {
-        throw new MethodNotAllowedException("Group with UUID '" + groupUUID + "' is external");
+    String oldGroup;
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(allProjects)) {
+      ProjectLevelConfig.Bare update = configProvider.get();
+      update.load(md);
+
+      Config db = update.getConfig();
+      oldGroup = db.getString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER);
+      if (Strings.isNullOrEmpty(input.group)) {
+        db.unset(USER, rsrc.getUser().getUserName().get(), KEY_OWNER);
+      } else {
+        group =
+            groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(input.group)).getGroup();
+        UUID groupUUID = group.getGroupUUID();
+        if (!AccountGroup.uuid(groupUUID.get()).isInternalGroup()) {
+          throw new MethodNotAllowedException("Group with UUID '" + groupUUID + "' is external");
+        }
+        db.setString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER, groupUUID.get());
       }
-      db.setString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER, groupUUID.get());
+      md.setMessage("Set owner for service user '" + rsrc.getUser().getUserName() + "'\n");
+      update.commit(md);
+      storageCache.invalidate();
+    } catch (ConfigInvalidException e) {
+      throw asRestApiException("Invalid configuration", e);
     }
-    MetaDataUpdate md = metaDataUpdateFactory.create(allProjects);
-    md.setMessage("Set owner for service user '" + rsrc.getUser().getUserName() + "'\n");
-    storage.commit(md);
+
     return group != null
         ? (oldGroup != null
             ? Response.ok(json.format(group))
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RefUpdateListener.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RefUpdateListener.java
index 31d1a56..421b326 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RefUpdateListener.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RefUpdateListener.java
@@ -26,6 +26,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.slf4j.Logger;
@@ -90,7 +91,8 @@
           }
         };
     if (cfg.getBoolean("createNotesAsync", false)) {
-      workQueue.getDefaultQueue().submit(task);
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError = workQueue.getDefaultQueue().submit(task);
     } else {
       task.run();
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUser.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUser.java
new file mode 100644
index 0000000..17c8ab3
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUser.java
@@ -0,0 +1,193 @@
+// Copyright (C) 2021 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.googlesource.gerrit.plugins.serviceuser;
+
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.KEY_CREATED_AT;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.KEY_CREATED_BY;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.KEY_CREATOR_ID;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.KEY_OWNER;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.USER;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.serviceuser.GetServiceUser.ServiceUserInfo;
+import com.googlesource.gerrit.plugins.serviceuser.RegisterServiceUser.Input;
+import java.io.IOException;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+
+@RequiresCapability(CreateServiceUserCapability.ID)
+@Singleton
+class RegisterServiceUser
+    implements RestCollectionCreateView<ConfigResource, ServiceUserResource, Input> {
+
+  static class Input {
+    String username;
+    String creator;
+    String owner;
+  }
+
+  private final Provider<ProjectLevelConfig.Bare> configProvider;
+  private final AccountResolver accountResolver;
+  private final GroupResolver groupResolver;
+  private final Provider<CurrentUser> userProvider;
+  private final MetaDataUpdate.User metaDataUpdateFactory;
+  private final Project.NameKey allProjects;
+  private final DateFormat rfc2822DateFormatter;
+  private final AccountLoader.Factory accountLoader;
+  private final StorageCache storageCache;
+  private final PermissionBackend permissionBackend;
+  private final BlockedNameFilter blockedNameFilter;
+
+  @Inject
+  RegisterServiceUser(
+      Provider<ProjectLevelConfig.Bare> configProvider,
+      AccountResolver accountResolver,
+      GroupResolver groupResolver,
+      Provider<CurrentUser> userProvider,
+      @GerritPersonIdent PersonIdent gerritIdent,
+      MetaDataUpdate.User metaDataUpdateFactory,
+      AllProjectsName allProjects,
+      AccountLoader.Factory accountLoader,
+      StorageCache storageCache,
+      PermissionBackend permissionBackend,
+      BlockedNameFilter blockedNameFilter) {
+    this.configProvider = configProvider;
+    this.accountResolver = accountResolver;
+    this.groupResolver = groupResolver;
+    this.userProvider = userProvider;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allProjects = allProjects;
+    this.rfc2822DateFormatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
+    this.rfc2822DateFormatter.setCalendar(
+        Calendar.getInstance(gerritIdent.getTimeZone(), Locale.US));
+    this.accountLoader = accountLoader;
+    this.storageCache = storageCache;
+    this.permissionBackend = permissionBackend;
+    this.blockedNameFilter = blockedNameFilter;
+  }
+
+  @Override
+  public Response<ServiceUserInfo> apply(ConfigResource parentResource, IdString id, Input input)
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+    CurrentUser requestingUser = userProvider.get();
+    if (requestingUser == null || !requestingUser.isIdentifiedUser()) {
+      throw new AuthException("authentication required");
+    }
+
+    if (input == null) {
+      input = new Input();
+    }
+
+    IdentifiedUser user;
+    try {
+      user = accountResolver.resolve(input.username).asUniqueUser();
+    } catch (UnresolvableAccountException e) {
+      throw new BadRequestException("Username does not exist");
+    }
+
+    if (!requestingUser.getAccountId().equals(user.getAccountId())
+        && !permissionBackend.user(requestingUser).testOrFalse(ADMINISTRATE_SERVER)) {
+      throw new MethodNotAllowedException("Forbidden");
+    }
+
+    if (blockedNameFilter.isBlocked(input.username)) {
+      throw new BadRequestException(
+          "The username '" + input.username + "' is not allowed as name for service users.");
+    }
+
+    String creator;
+    Account.Id creatorId;
+    if (Strings.isNullOrEmpty(input.creator)) {
+      creator = requestingUser.getUserName().orElse(null);
+      creatorId = requestingUser.asIdentifiedUser().getAccountId();
+    } else {
+      creator = input.creator;
+      creatorId = accountResolver.resolve(input.creator).asUniqueUser().getAccountId();
+    }
+    String creationDate = rfc2822DateFormatter.format(new Date());
+
+    String owner = null;
+    if (!Strings.isNullOrEmpty(input.owner)) {
+      try {
+        owner = groupResolver.parse(input.owner).getGroupUUID().toString();
+      } catch (UnresolvableAccountException e) {
+        throw new BadRequestException("The group '" + input.owner + "' does not exist");
+      }
+    }
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(allProjects)) {
+      ProjectLevelConfig.Bare update = configProvider.get();
+      update.load(md);
+
+      Config db = update.getConfig();
+      if (db.getSubsections(USER).contains(input.username)) {
+        return Response.none();
+      }
+      db.setInt(USER, input.username, KEY_CREATOR_ID, creatorId.get());
+      if (creator != null) {
+        db.setString(USER, input.username, KEY_CREATED_BY, creator);
+      }
+      if (owner != null) {
+        db.setString(USER, input.username, KEY_OWNER, owner);
+      }
+      db.setString(USER, input.username, KEY_CREATED_AT, creationDate);
+
+      md.setMessage("Create service user '" + input.username + "'\n");
+      update.commit(md);
+      storageCache.invalidate();
+    }
+
+    ServiceUserInfo info = new ServiceUserInfo(new AccountInfo(user.getAccountId().get()));
+    AccountLoader al = accountLoader.create(true);
+    info.createdBy = al.get(creatorId);
+    al.fill();
+    info.createdAt = creationDate;
+    return Response.created(info);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUserCommand.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUserCommand.java
new file mode 100644
index 0000000..fb8503c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUserCommand.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2021 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.googlesource.gerrit.plugins.serviceuser;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@RequiresCapability(CreateServiceUserCapability.ID)
+@CommandMetaData(name = "register", description = "Register Service User")
+class RegisterServiceUserCommand extends SshCommand {
+
+  @Argument(index = 0, required = true, metaVar = "USERNAME", usage = "name of the service user")
+  private String username;
+
+  @Option(
+      name = "--creator",
+      required = false,
+      metaVar = "CREATOR",
+      usage = "name of the creator of the service user")
+  private String creator;
+
+  @Option(
+      name = "--owner",
+      required = false,
+      metaVar = "OWNER",
+      usage = "group that owns the service user")
+  private String owner;
+
+  @Inject private RegisterServiceUser registerServiceUser;
+
+  @Override
+  protected void run()
+      throws IOException, UnloggedFailure, ConfigInvalidException, PermissionBackendException {
+    RegisterServiceUser.Input input = new RegisterServiceUser.Input();
+    input.username = username;
+    input.creator = creator;
+    input.owner = owner;
+
+    try {
+      registerServiceUser.apply(new ConfigResource(), IdString.fromDecoded(username), input);
+    } catch (RestApiException e) {
+      throw die(e.getMessage());
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserCollection.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserCollection.java
index c5e5d9a..f6f50ac 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserCollection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserCollection.java
@@ -21,12 +21,12 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.GroupDescription;
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.CurrentUser;
@@ -34,8 +34,6 @@
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.inject.Inject;
@@ -43,6 +41,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 class ServiceUserCollection implements ChildCollection<ConfigResource, ServiceUserResource> {
@@ -50,40 +49,36 @@
   private final DynamicMap<RestView<ServiceUserResource>> views;
   private final Provider<ListServiceUsers> list;
   private final Provider<AccountsCollection> accounts;
-  private final String pluginName;
-  private final ProjectCache projectCache;
   private final Provider<CurrentUser> userProvider;
   private final GroupsCollection groups;
   private final PermissionBackend permissionBackend;
+  private final StorageCache storageCache;
 
   @Inject
   ServiceUserCollection(
       DynamicMap<RestView<ServiceUserResource>> views,
       Provider<ListServiceUsers> list,
       Provider<AccountsCollection> accounts,
-      @PluginName String pluginName,
-      ProjectCache projectCache,
       Provider<CurrentUser> userProvider,
       GroupsCollection groups,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      StorageCache storageCache) {
     this.views = views;
     this.list = list;
     this.accounts = accounts;
-    this.pluginName = pluginName;
-    this.projectCache = projectCache;
     this.userProvider = userProvider;
     this.groups = groups;
     this.permissionBackend = permissionBackend;
+    this.storageCache = storageCache;
   }
 
   @Override
   public ServiceUserResource parse(ConfigResource parent, IdString id)
       throws ResourceNotFoundException, AuthException, IOException, PermissionBackendException,
-          ConfigInvalidException {
-    ProjectLevelConfig storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
+          ConfigInvalidException, RestApiException {
     IdentifiedUser serviceUser = accounts.get().parse(TopLevelResource.INSTANCE, id).getUser();
-    if (serviceUser == null
-        || !storage.get().getSubsections(USER).contains(serviceUser.getUserName().get())) {
+    Config db = storageCache.get();
+    if (serviceUser == null || !db.getSubsections(USER).contains(serviceUser.getUserName().get())) {
       throw new ResourceNotFoundException(id);
     }
     CurrentUser user = userProvider.get();
@@ -92,7 +87,7 @@
     }
     if (!permissionBackend.user(user).testOrFalse(ADMINISTRATE_SERVER)) {
       String username = serviceUser.getUserName().get();
-      String owner = storage.get().getString(USER, username, KEY_OWNER);
+      String owner = db.getString(USER, username, KEY_OWNER);
       if (owner != null) {
         GroupDescription.Basic group =
             groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(owner)).getGroup();
@@ -101,7 +96,7 @@
         }
       } else if (!((IdentifiedUser) user)
           .getAccountId()
-          .equals(Account.id(storage.get().getInt(USER, username, KEY_CREATOR_ID, -1)))) {
+          .equals(Account.id(db.getInt(USER, username, KEY_CREATOR_ID, -1)))) {
         throw new ResourceNotFoundException(id);
       }
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/SshModule.java
index f775e4c..40a1509 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/SshModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/SshModule.java
@@ -21,5 +21,6 @@
   @Override
   protected void configureCommands() {
     command(CreateServiceUserCommand.class);
+    command(RegisterServiceUserCommand.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/StorageCache.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/StorageCache.java
new file mode 100644
index 0000000..0378347
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/StorageCache.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2021 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.googlesource.gerrit.plugins.serviceuser;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import com.google.gerrit.server.project.ProjectLevelConfig.Bare;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class StorageCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String CACHE_NAME = "storage";
+  private static final Object ALL = new Object();
+
+  static CacheModule module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(CACHE_NAME, Object.class, Config.class).loader(Loader.class);
+        bind(StorageCache.class);
+      }
+    };
+  }
+
+  private final LoadingCache<Object, Config> cache;
+
+  @Inject
+  StorageCache(@Named(CACHE_NAME) LoadingCache<Object, Config> cache) {
+    this.cache = cache;
+  }
+
+  public Config get() {
+    try {
+      return cache.get(ALL);
+    } catch (ExecutionException e) {
+      logger.atSevere().withCause(e).log("Cannot load service users");
+      return new Config();
+    }
+  }
+
+  public void invalidate() {
+    cache.invalidate(ALL);
+  }
+
+  static class Loader extends CacheLoader<Object, Config> {
+    private final Provider<Bare> configProvider;
+    private final MetaDataUpdate.Server metaDataUpdateFactory;
+    private final AllProjectsName allProjects;
+
+    @Inject
+    Loader(
+        Provider<ProjectLevelConfig.Bare> configProvider,
+        MetaDataUpdate.Server metaDataUpdateFactory,
+        AllProjectsName allProjects) {
+      this.configProvider = configProvider;
+      this.metaDataUpdateFactory = metaDataUpdateFactory;
+      this.allProjects = allProjects;
+    }
+
+    @Override
+    public Config load(Object key) throws Exception {
+      ProjectLevelConfig.Bare storage = configProvider.get();
+      try (MetaDataUpdate md = metaDataUpdateFactory.create(allProjects)) {
+        storage.load(md);
+      }
+      return storage.getConfig();
+    }
+  }
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index 17a4e29..e19d856 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -36,11 +36,11 @@
     createdAt = Wed, 13 Nov 2013 14:45:00 +0100
 ```
 
-<a id="createdBy">
+<a id="createdBy"></a>
 `user.<service-user-name>.createdBy`
 : The username of the user who created the service user.
 
-<a id="createdAt">
+<a id="createdAt"></a>
 `user.<service-user-name>.createdAt`
 : The date when the service user was created.
 
diff --git a/src/main/resources/Documentation/cmd-register.md b/src/main/resources/Documentation/cmd-register.md
new file mode 100644
index 0000000..3378a2c
--- /dev/null
+++ b/src/main/resources/Documentation/cmd-register.md
@@ -0,0 +1,50 @@
+@PLUGIN@ register
+=================
+
+NAME
+----
+@PLUGIN@ register - Registers an existing user as a service user
+
+SYNOPSIS
+--------
+```
+ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ register
+  --creator <CREATOR>
+  --owner <OWNER>
+  <USERNAME>
+```
+
+DESCRIPTION
+-----------
+Registers an existing user as a service user.
+
+ACCESS
+------
+Caller must be a member of a group that is granted the
+'Create Service User' capability (provided by this plugin) or the
+'Administrate Server' capability. If not possessing the 'Administrate
+Server' capability, the user to be registered as a service user must
+also be the caller.
+
+SCRIPTING
+---------
+This command is intended to be used in scripts.
+
+OPTIONS
+-------
+
+`--creator`
+:   Username of the user that will be set as the creator of the
+    serviceuser. Defaults to the caller.
+
+`--owner`
+:   ID or name of the group that will own the service user. Defaults
+    to no owner group being set.
+
+EXAMPLES
+--------
+Register a service user:
+
+```
+  $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ register --creator admin --owner Administrators username
+```
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index f52421e..aa2e4b5 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -9,30 +9,41 @@
     group = Service Users
 ```
 
-<a id="block">
+<a id="block"></a>
 `plugin.@PLUGIN@.block`
 :	A username which is forbidden to be used as name for a service
-	user. The blocked username is case insensitive. Multiple
-	usernames can be blocked by specifying multiple
+	user. The blocked username is case insensitive. The match can
+	either be exact, have a wildcard ('*') at the end or use regular
+	expressions, which have to start with '^'. If the regex pattern is not
+	ending with '$', every username starting with a matching prefix will be
+	blocked. Multiple usernames can be blocked by specifying multiple
 	`plugin.@PLUGIN@.block` entries.
+	Examples:
 
-<a id="group">
+```
+   [plugin "serviceuser"]
+        block = johndoe
+        block = jane*
+        block = ^gerrit[0-9]*
+```
+
+<a id="group"></a>
 `plugin.@PLUGIN@.group`
 :	The name of an internal group to which newly created service users
 	should be automatically added. Multiple groups can be specified by
 	having multiple `plugin.@PLUGIN@.group` entries.
 
-<a id="infoMessage">
+<a id="infoMessage"></a>
 `plugin.@PLUGIN@.infoMessage`
 :	HTML formatted message that should be displayed on the service user
 	creation screen.
 
-<a id="onSuccessMessage">
+<a id="onSuccessMessage"></a>
 `plugin.@PLUGIN@.onSuccessMessage`
 :	Message that should be displayed after a service user was
 	successfully created.
 
-<a id="allowEmail">
+<a id="allowEmail"></a>
 `plugin.@PLUGIN@.allowEmail`
 :	Whether it is allowed for service user owners to set email
 	addresses for their service users. Independent of this setting
@@ -40,7 +51,7 @@
 	any service user.
 	By default false.
 
-<a id="allowHttpPassword">
+<a id="allowHttpPassword"></a>
 `plugin.@PLUGIN@.allowHttpPassword`
 :	Whether it is allowed for service user owners to generate HTTP
     passwords for their service users. Independent of this setting
@@ -48,12 +59,12 @@
     passwords for any service user.
     By default false.
 
-<a id="allowOwner">
+<a id="allowOwner"></a>
 `plugin.@PLUGIN@.allowOwner`
 :	Whether it is allowed to set an owner group for a service user.
 	By default false.
 
-<a id="createNotes">
+<a id="createNotes"></a>
 `plugin.@PLUGIN@.createNotes`
 :	Whether commits of a service user should be annotated by a Git note
 	that contains information about the current owners of the service
@@ -62,7 +73,7 @@
 	user the 'Forge Committer' access right must be blocked for service
 	users. By default true.
 
-<a id="createNotes">
+<a id="createNotes"></a>
 `plugin.@PLUGIN@.createNotesAsync`
 :	Whether the Git notes on commits that are pushed by a service user
 	should be created asynchronously. By default false.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilterTest.java b/src/test/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilterTest.java
new file mode 100644
index 0000000..997c275
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilterTest.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2021 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.googlesource.gerrit.plugins.serviceuser;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class BlockedNameFilterTest {
+
+  private static String[] BLOCKED_NAMES =
+      new String[] {
+        "exact", "ex*act", "wild*", "^regex[0-9]+", "^ABC", "^[0-9]+$", "regex[0-9]+", "^⁋+"
+      };
+
+  private BlockedNameFilter blockedNameFilter;
+
+  @Mock private PluginConfigFactory configFactory;
+
+  @Mock private PluginConfig config;
+
+  @Before
+  public void setup() {
+    when(configFactory.getFromGerritConfig("serviceuser")).thenReturn(config);
+    when(config.getStringList("block")).thenReturn(BLOCKED_NAMES);
+    blockedNameFilter = new BlockedNameFilter(configFactory, "serviceuser");
+  }
+
+  @Test
+  public void exactMatchIsBlocked() {
+    assertThat(blockedNameFilter.isBlocked("exact")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("ExAct")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("ex*act")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("regex[0-9]+")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("notexact")).isFalse();
+    assertThat(blockedNameFilter.isBlocked("exxact")).isFalse();
+  }
+
+  @Test
+  public void wildcardMatchIsBlocked() {
+    assertThat(blockedNameFilter.isBlocked("wild")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("wildcard")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("Wilde")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("wil")).isFalse();
+  }
+
+  @Test
+  public void regexMatchIsBlocked() {
+    assertThat(blockedNameFilter.isBlocked("regex1")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("Regex1")).isTrue();
+
+    // Pattern matching is done at the beginning of the username
+    assertThat(blockedNameFilter.isBlocked("foo-regex1")).isFalse();
+
+    // Names with unicode characters can be blocked
+    assertThat(blockedNameFilter.isBlocked("⁋")).isTrue();
+
+    // Regex matches only complete name, when ending with '$'.
+    assertThat(blockedNameFilter.isBlocked("01234")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("01234abcd")).isFalse();
+
+    // Regex matches prefix without trailing '$'
+    assertThat(blockedNameFilter.isBlocked("regex1suffix")).isTrue();
+
+    // Uppercase regex matches case-insenstive
+    assertThat(blockedNameFilter.isBlocked("abc")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("ABC")).isTrue();
+  }
+}
diff --git a/tools/BUILD b/tools/BUILD
deleted file mode 100644
index cc10083..0000000
--- a/tools/BUILD
+++ /dev/null
@@ -1 +0,0 @@
-# Empty file - bazel treat directories with BUILD file as a package
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
deleted file mode 100644
index c5ed0b7..0000000
--- a/tools/bzl/BUILD
+++ /dev/null
@@ -1 +0,0 @@
-# Empty file required by Bazel
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
deleted file mode 100644
index c921d01..0000000
--- a/tools/bzl/classpath.bzl
+++ /dev/null
@@ -1,6 +0,0 @@
-load(
-    "@com_googlesource_gerrit_bazlets//tools:classpath.bzl",
-    _classpath_collector = "classpath_collector",
-)
-
-classpath_collector = _classpath_collector
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
deleted file mode 100644
index 240c448..0000000
--- a/tools/bzl/junit.bzl
+++ /dev/null
@@ -1,5 +0,0 @@
-load(
-    "@com_googlesource_gerrit_bazlets//tools:junit.bzl",
-    _junit_tests = "junit_tests",
-)
-junit_tests = _junit_tests
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
deleted file mode 100644
index 35ea8ce..0000000
--- a/tools/bzl/maven_jar.bzl
+++ /dev/null
@@ -1,3 +0,0 @@
-load("@com_googlesource_gerrit_bazlets//tools:maven_jar.bzl", _maven_jar = "maven_jar")
-
-maven_jar = _maven_jar
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
deleted file mode 100644
index 4d2dbdd..0000000
--- a/tools/bzl/plugin.bzl
+++ /dev/null
@@ -1,10 +0,0 @@
-load(
-    "@com_googlesource_gerrit_bazlets//:gerrit_plugin.bzl",
-    _gerrit_plugin = "gerrit_plugin",
-    _plugin_deps = "PLUGIN_DEPS",
-    _plugin_test_deps = "PLUGIN_TEST_DEPS",
-)
-
-gerrit_plugin = _gerrit_plugin
-PLUGIN_DEPS = _plugin_deps
-PLUGIN_TEST_DEPS = _plugin_test_deps
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
deleted file mode 100644
index a38b222..0000000
--- a/tools/eclipse/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//tools/bzl:classpath.bzl", "classpath_collector")
-load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS")
-
-classpath_collector(
-    name = "main_classpath_collect",
-    testonly = 1,
-    deps = PLUGIN_DEPS + [
-        "//:serviceuser__plugin",
-    ],
-)
diff --git a/tools/eclipse/project.sh b/tools/eclipse/project.sh
deleted file mode 100755
index 79282cf..0000000
--- a/tools/eclipse/project.sh
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/bin/bash
-# Copyright (C) 2018 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.
-`bazel query @com_googlesource_gerrit_bazlets//tools/eclipse:project --output location | sed s/BUILD:.*//`project.py -n serviceuser -r .
diff --git a/tools/workspace_status.py b/tools/workspace_status.py
deleted file mode 100644
index 8574d17..0000000
--- a/tools/workspace_status.py
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/usr/bin/env python
-
-# This script will be run by bazel when the build process starts to
-# generate key-value information that represents the status of the
-# workspace. The output should be like
-#
-# KEY1 VALUE1
-# KEY2 VALUE2
-#
-# If the script exits with non-zero code, it's considered as a failure
-# and the output will be discarded.
-
-from __future__ import print_function
-import subprocess
-import sys
-
-CMD = ['git', 'describe', '--always', '--match', 'v[0-9].*', '--dirty']
-
-
-def revision():
-    try:
-        return subprocess.check_output(CMD).strip().decode("utf-8")
-    except OSError as err:
-        print('could not invoke git: %s' % err, file=sys.stderr)
-        sys.exit(1)
-    except subprocess.CalledProcessError as err:
-        print('error using git: %s' % err, file=sys.stderr)
-        sys.exit(1)
-
-
-print("STABLE_BUILD_SERVICEUSER_LABEL %s" % revision())