Merge "Fix callstack overflow for very large ab chunks"
diff --git a/.bazelrc b/.bazelrc
index 4a89eed..d6d4ce6 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -3,6 +3,7 @@
 build --experimental_strict_action_env
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
+build --java_toolchain //tools:error_prone_warnings_toolchain
 
 test --build_tests_only
 test --test_output=errors
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index d3da0d4..306fff9 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -219,13 +219,6 @@
 Note that when building an individual plugin, the `core.zip` package
 is not regenerated.
 
-To build with all Error Prone warnings activated, run:
-
-----
-  bazel build --java_toolchain //tools:error_prone_warnings_toolchain //...
-----
-
-
 [[IDEs]]
 == Using an IDE.
 
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt
index 072c22c..47ace5b 100644
--- a/Documentation/dev-build-plugins.txt
+++ b/Documentation/dev-build-plugins.txt
@@ -75,6 +75,12 @@
 Some plugins describe their build process in `src/main/resources/Documentation/build.md`
 file. It may worth checking.
 
+=== Error Prone checks
+
+Error Prone checks are enabled by default for core Gerrit and all core plugins. To
+enable the checks for custom plugins, add it in the `error_prone_packages` group
+in `tools/BUILD`.
+
 === Plugins with external dependencies ===
 
 If the plugin has external dependencies, then they must be included from Gerrit's
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 6e61e29..17c1184 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -52,6 +52,7 @@
 * commons:pool
 * commons:validator
 * dropwizard:dropwizard-core
+* errorprone:annotations
 * flogger:api
 * fonts:robotofonts
 * guice:guice
diff --git a/WORKSPACE b/WORKSPACE
index 9716906..7aa09e2 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -186,6 +186,12 @@
     sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
 )
 
+maven_jar(
+    name = "error-prone-annotations",
+    artifact = "com.google.errorprone:error_prone_annotations:2.3.3",
+    sha1 = "42aa5155a54a87d70af32d4b0d06bf43779de0e2",
+)
+
 FLOGGER_VERS = "0.4"
 
 maven_jar(
@@ -1075,18 +1081,18 @@
     sha1 = "0f5a654e4675769c716e5b387830d19b501ca191",
 )
 
-TESTCONTAINERS_VERSION = "1.11.2"
+TESTCONTAINERS_VERSION = "1.11.3"
 
 maven_jar(
     name = "testcontainers",
     artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-    sha1 = "eae47ed24bb07270d4b60b5e2c3444c5bf3c8ea9",
+    sha1 = "154b69dd976416734b2fc809fb86e173ad9aa25b",
 )
 
 maven_jar(
     name = "testcontainers-elasticsearch",
     artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-    sha1 = "a327bd8cb68eb7146b36d754aee98a8018132d8f",
+    sha1 = "90713b61f5748d8894c31a20f955bd7f81ac2ece",
 )
 
 maven_jar(
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 71b1809..fc9adb8 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -24,9 +24,8 @@
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
@@ -38,13 +37,10 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Streams;
 import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
-import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
@@ -125,7 +121,6 @@
 import com.google.gerrit.server.plugins.TestServerPlugin;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.Revisions;
@@ -161,7 +156,6 @@
 import java.util.Optional;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -277,9 +271,6 @@
   protected boolean testRequiresSsh;
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
-  // TODO(dborowitz): Push down into callers that need it.
-  @Inject protected ProjectOperations projectOperations;
-
   @Inject private AbstractChangeNotes.Args changeNotesArgs;
   @Inject private AccountIndexCollection accountIndexes;
   @Inject private AccountIndexer accountIndexer;
@@ -882,60 +873,6 @@
     return gApi.changes().id(r.getChangeId()).current();
   }
 
-  protected void allow(String ref, String permission, AccountGroup.UUID id) throws Exception {
-    allow(project, ref, permission, id);
-  }
-
-  protected void allow(Project.NameKey p, String ref, String permission, AccountGroup.UUID id) {
-    projectOperations
-        .project(p)
-        .forUpdate()
-        .add(TestProjectUpdate.allow(permission).ref(ref).group(id))
-        .update();
-  }
-
-  protected void allowGlobalCapabilities(
-      AccountGroup.UUID id, int min, int max, String... capabilityNames) throws Exception {
-    // TODO(dborowitz): When inlining:
-    // * add a variant that takes a single String
-    // * explicitly add multiple values in callers instead of looping
-    TestProjectUpdate.Builder b = projectOperations.project(allProjects).forUpdate();
-    Arrays.stream(capabilityNames)
-        .forEach(c -> b.add(TestProjectUpdate.allowCapability(c).group(id).range(min, max)));
-    b.update();
-  }
-
-  protected void allowGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
-      throws Exception {
-    allowGlobalCapabilities(id, Arrays.asList(capabilityNames));
-  }
-
-  protected void allowGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
-      throws Exception {
-    // TODO(dborowitz): When inlining:
-    // * add a variant that takes a single String
-    // * explicitly add multiple values in callers instead of looping
-    TestProjectUpdate.Builder b = projectOperations.project(allProjects).forUpdate();
-    Streams.stream(capabilityNames)
-        .forEach(c -> b.add(TestProjectUpdate.allowCapability(c).group(id)));
-    b.update();
-  }
-
-  protected void removeGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
-      throws Exception {
-    removeGlobalCapabilities(id, Arrays.asList(capabilityNames));
-  }
-
-  protected void removeGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (String capabilityName : capabilityNames) {
-        Util.remove(u.getConfig(), capabilityName, id);
-      }
-      u.save();
-    }
-  }
-
   protected void setUseSignedOffBy(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = projectConfigFactory.read(md);
@@ -954,108 +891,6 @@
     }
   }
 
-  protected void deny(String ref, String permission, AccountGroup.UUID id) throws Exception {
-    deny(project, ref, permission, id);
-  }
-
-  protected void deny(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    projectOperations
-        .project(p)
-        .forUpdate()
-        .add(TestProjectUpdate.deny(permission).ref(ref).group(id))
-        .update();
-  }
-
-  protected void block(String ref, String permission, AccountGroup.UUID id) throws Exception {
-    block(project, ref, permission, id);
-  }
-
-  protected void block(Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(TestProjectUpdate.block(permission).ref(ref).group(id))
-        .update();
-  }
-
-  protected void blockLabel(
-      String label, int min, int max, AccountGroup.UUID id, String ref, Project.NameKey project)
-      throws Exception {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(TestProjectUpdate.blockLabel(label).ref(ref).group(id).range(min, max))
-        .update();
-  }
-
-  protected void grant(Project.NameKey project, String ref, String permission) {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(TestProjectUpdate.allow(permission).ref(ref).group(adminGroupUuid()))
-        .update();
-  }
-
-  protected void grant(Project.NameKey project, String ref, String permission, boolean force) {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(TestProjectUpdate.allow(permission).ref(ref).group(adminGroupUuid()).force(force))
-        .update();
-  }
-
-  protected void grant(
-      Project.NameKey project,
-      String ref,
-      String permission,
-      boolean force,
-      AccountGroup.UUID groupUUID) {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(TestProjectUpdate.allow(permission).ref(ref).group(groupUUID).force(force))
-        .update();
-  }
-
-  protected void grantLabel(
-      String label,
-      int min,
-      int max,
-      Project.NameKey project,
-      String ref,
-      AccountGroup.UUID groupUUID,
-      boolean exclusive) {
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            TestProjectUpdate.allowLabel(label)
-                .ref(ref)
-                .group(groupUUID)
-                .range(min, max)
-                .exclusive(exclusive))
-        .update();
-  }
-
-  protected void removePermission(Project.NameKey project, String ref, String permission)
-      throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Remove %s on %s", permission, ref));
-      ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      p.clearRules();
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void blockRead(String ref) throws Exception {
-    block(ref, Permission.READ, REGISTERED_USERS);
-  }
-
   protected PushOneCommit.Result pushTo(String ref) throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     return push.to(ref);
@@ -1120,15 +955,6 @@
     }
   }
 
-  // TODO(hanwen): push this down.
-  protected RevCommit getRemoteHead(Project.NameKey project, String branch) throws Exception {
-    return projectOperations.project(project).getHead(branch);
-  }
-
-  protected RevCommit getRemoteHead() throws Exception {
-    return getRemoteHead(project, "master");
-  }
-
   protected void assertMailReplyTo(Message message, String email) throws Exception {
     assertThat(message.headers()).containsKey("Reply-To");
     EmailHeader.String replyTo = (EmailHeader.String) message.headers().get("Reply-To");
@@ -1500,7 +1326,7 @@
       LabelValue... value)
       throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = category(label, value);
+      LabelType labelType = label(label, value);
       labelType.setFunction(func);
       labelType.setRefPatterns(refPatterns);
       u.getConfig().getLabelSections().put(labelType.getName(), labelType);
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index ada2fb6..0da6287 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -1,6 +1,11 @@
 load("//tools/bzl:java.bzl", "java_library2")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
+FUNCTION_SRCS = [
+    "testsuite/ThrowingConsumer.java",
+    "testsuite/ThrowingFunction.java",
+]
+
 java_library(
     name = "lib",
     testonly = True,
@@ -68,8 +73,13 @@
 java_library2(
     name = "framework-lib",
     testonly = True,
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = FUNCTION_SRCS,
+    ),
     exported_deps = [
+        ":function",
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/httpd/auth/openid",
@@ -134,6 +144,12 @@
     ],
 )
 
+java_library(
+    name = "function",
+    srcs = FUNCTION_SRCS,
+    visibility = ["//visibility:public"],
+)
+
 java_doc(
     name = "framework-javadoc",
     testonly = True,
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 3a49f46..a48a278 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -524,7 +524,7 @@
   }
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
-    Injector sysInjector = get(daemon, "sysInjector");
+    Injector sysInjector = getInjector(daemon, "sysInjector");
     Module module =
         new FactoryModule() {
           @Override
@@ -557,13 +557,14 @@
     return sysInjector.createChildInjector(module);
   }
 
-  @SuppressWarnings("unchecked")
-  private static <T> T get(Object obj, String field)
+  private static Injector getInjector(Object obj, String field)
       throws SecurityException, NoSuchFieldException, IllegalArgumentException,
           IllegalAccessException {
     Field f = obj.getClass().getDeclaredField(field);
     f.setAccessible(true);
-    return (T) f.get(obj);
+    Object v = f.get(obj);
+    checkArgument(v instanceof Injector, "not an Injector: %s", v);
+    return (Injector) f.get(obj);
   }
 
   private static InetAddress getLocalHost() {
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
new file mode 100644
index 0000000..3215a9c
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -0,0 +1,21 @@
+package(default_testonly = 1)
+
+java_library(
+    name = "project",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:function",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//lib:guava",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/commons:lang",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
index fc4caf8..b310393 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
@@ -30,6 +30,9 @@
 
   PerProjectOperations project(Project.NameKey key);
 
+  /** Starts a fluent chain for updating All-Projects. */
+  TestProjectUpdate.Builder allProjectsForUpdate();
+
   interface PerProjectOperations {
     /**
      * Returns the commit for this project. branchName can either be shortened ("HEAD", "master") or
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 6835ae4..34e57b4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -20,22 +20,25 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectCreation.Builder;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
 import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.project.CreateProjectArgs;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectCreator;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -53,6 +56,7 @@
 import org.eclipse.jgit.treewalk.TreeWalk;
 
 public class ProjectOperationsImpl implements ProjectOperations {
+  private final AllProjectsName allProjectsName;
   private final GitRepositoryManager repoManager;
   private final MetaDataUpdate.Server metaDataUpdateFactory;
   private final ProjectCache projectCache;
@@ -61,11 +65,13 @@
 
   @Inject
   ProjectOperationsImpl(
+      AllProjectsName allProjectsName,
       GitRepositoryManager repoManager,
       MetaDataUpdate.Server metaDataUpdateFactory,
       ProjectCache projectCache,
       ProjectConfig.Factory projectConfigFactory,
       ProjectCreator projectCreator) {
+    this.allProjectsName = allProjectsName;
     this.repoManager = repoManager;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
@@ -98,6 +104,11 @@
     return new PerProjectOperations(key);
   }
 
+  @Override
+  public TestProjectUpdate.Builder allProjectsForUpdate() {
+    return project(allProjectsName).forUpdate();
+  }
+
   private class PerProjectOperations implements ProjectOperations.PerProjectOperations {
     Project.NameKey nameKey;
 
@@ -117,25 +128,43 @@
 
     @Override
     public TestProjectUpdate.Builder forUpdate() {
-      return TestProjectUpdate.builder(this::updateProject);
+      return TestProjectUpdate.builder(nameKey, allProjectsName, this::updateProject);
     }
 
     private void updateProject(TestProjectUpdate projectUpdate)
         throws IOException, ConfigInvalidException {
       try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(nameKey)) {
         ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+        removePermissions(projectConfig, projectUpdate.removedPermissions());
         addCapabilities(projectConfig, projectUpdate.addedCapabilities());
         addPermissions(projectConfig, projectUpdate.addedPermissions());
         addLabelPermissions(projectConfig, projectUpdate.addedLabelPermissions());
+        setExclusiveGroupPermissions(projectConfig, projectUpdate.exclusiveGroupPermissions());
         projectConfig.commit(metaDataUpdate);
       }
       projectCache.evict(nameKey);
     }
 
+    private void removePermissions(
+        ProjectConfig projectConfig,
+        ImmutableList<TestProjectUpdate.TestPermissionKey> removedPermissions) {
+      for (TestProjectUpdate.TestPermissionKey p : removedPermissions) {
+        Permission permission =
+            projectConfig.getAccessSection(p.section(), true).getPermission(p.name(), true);
+        if (p.group().isPresent()) {
+          GroupReference group = new GroupReference(p.group().get(), p.group().get().get());
+          group = projectConfig.resolve(group);
+          permission.removeRule(group);
+        } else {
+          permission.clearRules();
+        }
+      }
+    }
+
     private void addCapabilities(
         ProjectConfig projectConfig, ImmutableList<TestCapability> addedCapabilities) {
       for (TestCapability c : addedCapabilities) {
-        PermissionRule rule = Util.newRule(projectConfig, c.group());
+        PermissionRule rule = newRule(projectConfig, c.group());
         rule.setRange(c.min(), c.max());
         projectConfig
             .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
@@ -147,7 +176,7 @@
     private void addPermissions(
         ProjectConfig projectConfig, ImmutableList<TestPermission> addedPermissions) {
       for (TestPermission p : addedPermissions) {
-        PermissionRule rule = Util.newRule(projectConfig, p.group());
+        PermissionRule rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setForce(p.force());
         projectConfig.getAccessSection(p.ref(), true).getPermission(p.name(), true).add(rule);
@@ -157,18 +186,28 @@
     private void addLabelPermissions(
         ProjectConfig projectConfig, ImmutableList<TestLabelPermission> addedLabelPermissions) {
       for (TestLabelPermission p : addedLabelPermissions) {
-        PermissionRule rule = Util.newRule(projectConfig, p.group());
+        PermissionRule rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setRange(p.min(), p.max());
+        String permissionName =
+            p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
         Permission permission =
-            projectConfig
-                .getAccessSection(p.ref(), true)
-                .getPermission(Permission.forLabel(p.name()), true);
-        permission.setExclusiveGroup(p.exclusive());
+            projectConfig.getAccessSection(p.ref(), true).getPermission(permissionName, true);
         permission.add(rule);
       }
     }
 
+    private void setExclusiveGroupPermissions(
+        ProjectConfig projectConfig,
+        ImmutableMap<TestProjectUpdate.TestPermissionKey, Boolean> exclusiveGroupPermissions) {
+      exclusiveGroupPermissions.forEach(
+          (key, exclusive) ->
+              projectConfig
+                  .getAccessSection(key.section(), true)
+                  .getPermission(key.name(), true)
+                  .setExclusiveGroup(exclusive));
+    }
+
     private RevCommit headOrNull(String branch) {
       if (!branch.startsWith(Constants.R_REFS)) {
         branch = RefNames.REFS_HEADS + branch;
@@ -217,4 +256,10 @@
       }
     }
   }
+
+  private static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
+    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
+    group = project.resolve(group);
+    return new PermissionRule(group);
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index b58eae6..6cbf40d 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.acceptance.testsuite.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.common.data.AccessSection.GLOBAL_CAPABILITIES;
+import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelType;
@@ -25,7 +28,10 @@
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
 import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
 
 @AutoValue
 public abstract class TestProjectUpdate {
@@ -70,6 +76,7 @@
 
       /** Sets the minimum and maximum values for the capability. */
       public Builder range(int min, int max) {
+        checkNonInvertedRange(min, max);
         return min(min).max(max);
       }
 
@@ -77,17 +84,20 @@
       abstract TestCapability autoBuild();
 
       public TestCapability build() {
-        if (min().isPresent() || max().isPresent()) {
-          checkArgument(
-              GlobalCapability.hasRange(name()), "capability %s does not support ranges", name());
-        }
         PermissionRange.WithDefaults withDefaults = GlobalCapability.getRange(name());
-        if (!min().isPresent()) {
-          min(withDefaults != null ? withDefaults.getDefaultMin() : 0);
+        if (withDefaults != null) {
+          int min = min().orElse(withDefaults.getDefaultMin());
+          int max = max().orElse(withDefaults.getDefaultMax());
+          range(min, max);
+          // Don't enforce range is nonempty; this is allowed for e.g. batchChangesLimit.
+        } else {
+          checkArgument(
+              !min().isPresent() && !max().isPresent(),
+              "capability %s does not support ranges",
+              name());
+          range(0, 0);
         }
-        if (!max().isPresent()) {
-          max(withDefaults != null ? withDefaults.getDefaultMax() : 0);
-        }
+
         return autoBuild();
       }
     }
@@ -164,7 +174,7 @@
   @AutoValue
   public abstract static class TestLabelPermission {
     private static Builder builder() {
-      return new AutoValue_TestProjectUpdate_TestLabelPermission.Builder().exclusive(false);
+      return new AutoValue_TestProjectUpdate_TestLabelPermission.Builder().impersonation(false);
     }
 
     abstract String name();
@@ -179,7 +189,7 @@
 
     abstract int max();
 
-    abstract boolean exclusive();
+    abstract boolean impersonation();
 
     /** Builder for {@link TestLabelPermission}. */
     @AutoValue.Builder
@@ -200,40 +210,110 @@
 
       /** Sets the minimum and maximum values for the permission. */
       public Builder range(int min, int max) {
+        checkArgument(min != 0 || max != 0, "empty range");
+        checkNonInvertedRange(min, max);
         return min(min).max(max);
       }
 
-      /** Adds the permission to the exclusive group permission set on the access section. */
-      public abstract Builder exclusive(boolean exclusive);
+      /** Sets whether this permission should be for impersonating another user's votes. */
+      public abstract Builder impersonation(boolean impersonation);
 
       abstract TestLabelPermission autoBuild();
 
       /** Builds the {@link TestPermission}. */
       public TestLabelPermission build() {
         TestLabelPermission result = autoBuild();
-        checkArgument(
-            !Permission.isLabel(result.name()),
-            "expected label name, got permission name: %s",
-            result.name());
-        LabelType.checkName(result.name());
+        checkLabelName(result.name());
         return result;
       }
     }
   }
 
-  static Builder builder(ThrowingConsumer<TestProjectUpdate> projectUpdater) {
-    return new AutoValue_TestProjectUpdate.Builder().projectUpdater(projectUpdater);
+  /**
+   * Starts a builder for describing a permission key for deletion. Not for label permissions or
+   * global capabilities.
+   */
+  public static TestPermissionKey.Builder permissionKey(String name) {
+    return TestPermissionKey.builder().name(name);
+  }
+
+  /** Starts a builder for describing a label permission key for deletion. */
+  public static TestPermissionKey.Builder labelPermissionKey(String name) {
+    checkLabelName(name);
+    return TestPermissionKey.builder().name(Permission.forLabel(name));
+  }
+
+  /** Starts a builder for describing a capability key for deletion. */
+  public static TestPermissionKey.Builder capabilityKey(String name) {
+    return TestPermissionKey.builder().name(name).section(GLOBAL_CAPABILITIES);
+  }
+
+  /** Records the key of a permission (of any type) for deletion. */
+  @AutoValue
+  public abstract static class TestPermissionKey {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestPermissionKey.Builder();
+    }
+
+    abstract String section();
+
+    abstract String name();
+
+    abstract Optional<AccountGroup.UUID> group();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder section(String section);
+
+      abstract Optional<String> section();
+
+      /** Sets the ref pattern used on the permission. Not for global capabilities. */
+      public Builder ref(String ref) {
+        requireNonNull(ref);
+        checkArgument(ref.startsWith(Constants.R_REFS), "must be a ref: %s", ref);
+        checkArgument(
+            !section().isPresent() || !section().get().equals(GLOBAL_CAPABILITIES),
+            "can't set ref on global capability");
+        return section(ref);
+      }
+
+      abstract Builder name(String name);
+
+      /** Sets the group to which the permission applies. */
+      public abstract Builder group(AccountGroup.UUID group);
+
+      /** Builds the {@link TestPermissionKey}. */
+      public abstract TestPermissionKey build();
+    }
+  }
+
+  static Builder builder(
+      Project.NameKey nameKey,
+      AllProjectsName allProjectsName,
+      ThrowingConsumer<TestProjectUpdate> projectUpdater) {
+    return new AutoValue_TestProjectUpdate.Builder()
+        .nameKey(nameKey)
+        .allProjectsName(allProjectsName)
+        .projectUpdater(projectUpdater);
   }
 
   /** Builder for {@link TestProjectUpdate}. */
   @AutoValue.Builder
   public abstract static class Builder {
+    abstract Builder nameKey(Project.NameKey project);
+
+    abstract Builder allProjectsName(AllProjectsName allProjects);
+
     abstract ImmutableList.Builder<TestPermission> addedPermissionsBuilder();
 
     abstract ImmutableList.Builder<TestLabelPermission> addedLabelPermissionsBuilder();
 
     abstract ImmutableList.Builder<TestCapability> addedCapabilitiesBuilder();
 
+    abstract ImmutableList.Builder<TestPermissionKey> removedPermissionsBuilder();
+
+    abstract ImmutableMap.Builder<TestPermissionKey, Boolean> exclusiveGroupPermissionsBuilder();
+
     /** Adds a permission to be included in this update. */
     public Builder add(TestPermission testPermission) {
       addedPermissionsBuilder().add(testPermission);
@@ -267,22 +347,90 @@
       return add(testCapabilityBuilder.build());
     }
 
+    /** Removes a permission, label permission, or capability as part of this update. */
+    public Builder remove(TestPermissionKey testPermissionKey) {
+      removedPermissionsBuilder().add(testPermissionKey);
+      return this;
+    }
+
+    /** Removes a permission, label permission, or capability as part of this update. */
+    public Builder remove(TestPermissionKey.Builder testPermissionKeyBuilder) {
+      return remove(testPermissionKeyBuilder.build());
+    }
+
+    /** Sets the exclusive bit bit for the given permission key. */
+    public Builder setExclusiveGroup(
+        TestPermissionKey.Builder testPermissionKeyBuilder, boolean exclusive) {
+      return setExclusiveGroup(testPermissionKeyBuilder.build(), exclusive);
+    }
+
+    /** Sets the exclusive bit bit for the given permission key. */
+    public Builder setExclusiveGroup(TestPermissionKey testPermissionKey, boolean exclusive) {
+      checkArgument(
+          !testPermissionKey.group().isPresent(),
+          "do not specify group for setExclusiveGroup: %s",
+          testPermissionKey);
+      checkArgument(
+          !testPermissionKey.section().equals(GLOBAL_CAPABILITIES),
+          "setExclusiveGroup not valid for global capabilities: %s",
+          testPermissionKey);
+      exclusiveGroupPermissionsBuilder().put(testPermissionKey, exclusive);
+      return this;
+    }
+
     abstract Builder projectUpdater(ThrowingConsumer<TestProjectUpdate> projectUpdater);
 
     abstract TestProjectUpdate autoBuild();
 
+    TestProjectUpdate build() {
+      TestProjectUpdate projectUpdate = autoBuild();
+      if (projectUpdate.hasCapabilityUpdates()) {
+        checkArgument(
+            projectUpdate.nameKey().equals(projectUpdate.allProjectsName()),
+            "cannot update global capabilities on %s, only %s: %s",
+            projectUpdate.nameKey(),
+            projectUpdate.allProjectsName(),
+            projectUpdate);
+      }
+      return projectUpdate;
+    }
+
     /** Executes the update, updating the underlying project. */
     public void update() {
-      TestProjectUpdate projectUpdate = autoBuild();
+      TestProjectUpdate projectUpdate = build();
       projectUpdate.projectUpdater().acceptAndThrowSilently(projectUpdate);
     }
   }
 
+  abstract Project.NameKey nameKey();
+
+  abstract AllProjectsName allProjectsName();
+
   abstract ImmutableList<TestPermission> addedPermissions();
 
   abstract ImmutableList<TestLabelPermission> addedLabelPermissions();
 
   abstract ImmutableList<TestCapability> addedCapabilities();
 
+  abstract ImmutableList<TestPermissionKey> removedPermissions();
+
+  abstract ImmutableMap<TestPermissionKey, Boolean> exclusiveGroupPermissions();
+
   abstract ThrowingConsumer<TestProjectUpdate> projectUpdater();
+
+  boolean hasCapabilityUpdates() {
+    return !addedCapabilities().isEmpty()
+        || removedPermissions().stream().anyMatch(k -> k.section().equals(GLOBAL_CAPABILITIES));
+  }
+
+  private static void checkLabelName(String name) {
+    // "label-Code-Review" is technically a valid label name, and we don't prevent users from
+    // using it in production, but specifying it in a test is programmer error.
+    checkArgument(!Permission.isLabel(name), "expected label name, got permission name: %s", name);
+    LabelType.checkName(name);
+  }
+
+  private static void checkNonInvertedRange(int min, int max) {
+    checkArgument(min <= max, "inverted range: %s > %s", min, max);
+  }
 }
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 1be6353..1816d50 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common;
 
+import static java.lang.annotation.ElementType.FIELD;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.ElementType.TYPE;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
@@ -22,13 +23,13 @@
 import java.lang.annotation.Target;
 
 /**
- * A marker for a method that is public solely because it is called from inside a project or an
- * organisation using Gerrit.
+ * A marker to say a method/type/field is added or is increased to public solely because it is
+ * called from inside a project or an organisation using Gerrit.
  */
-@Target({METHOD, TYPE})
+@Target({METHOD, TYPE, FIELD})
 @Retention(RUNTIME)
 public @interface UsedAt {
-  /** Enumeration of projects that call a method that would otherwise be private. */
+  /** Enumeration of projects that call a method/type/field. */
   enum Project {
     GOOGLE,
     PLUGIN_CHECKS,
diff --git a/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java b/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
index e676828..75cf713 100644
--- a/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
+++ b/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
@@ -34,9 +34,8 @@
     super("Not found: " + id.get());
   }
 
-  @SuppressWarnings("unchecked")
-  @Override
   public ResourceNotFoundException caching(CacheControl c) {
-    return super.caching(c);
+    setCaching(c);
+    return this;
   }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestApiException.java b/java/com/google/gerrit/extensions/restapi/RestApiException.java
index b09723e..f3d7dec 100644
--- a/java/com/google/gerrit/extensions/restapi/RestApiException.java
+++ b/java/com/google/gerrit/extensions/restapi/RestApiException.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import static java.util.Objects.requireNonNull;
+
 /** Root exception type for REST API failures. */
 public class RestApiException extends Exception {
   private static final long serialVersionUID = 1L;
@@ -33,9 +35,7 @@
     return caching;
   }
 
-  @SuppressWarnings("unchecked")
-  public <T extends RestApiException> T caching(CacheControl c) {
-    caching = c;
-    return (T) this;
+  protected void setCaching(CacheControl caching) {
+    this.caching = requireNonNull(caching);
   }
 }
diff --git a/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java b/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
index 397d093..4ae7e5e 100644
--- a/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
+++ b/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
@@ -67,7 +67,7 @@
     headers.put(name, value);
   }
 
-  @SuppressWarnings("all")
+  @SuppressWarnings({"all", "MissingOverride"})
   // @Override is omitted for backwards compatibility with servlet-api 2.5
   // TODO: Remove @SuppressWarnings and add @Override when Google upgrades
   //       to servlet-api 3.1
diff --git a/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index ba8d7da..4044b90 100644
--- a/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -177,7 +177,7 @@
    * Expert: creates a searcher from the provided {@link IndexReader} using the provided {@link
    * SearcherFactory}. NOTE: this decRefs incoming reader on throwing an exception.
    */
-  @SuppressWarnings("resource")
+  @SuppressWarnings({"resource", "ReferenceEquality"})
   public static IndexSearcher getSearcher(SearcherFactory searcherFactory, IndexReader reader)
       throws IOException {
     boolean success = false;
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index baf37b6..cbf32a1 100644
--- a/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/java/com/google/gerrit/pgm/init/api/Section.java
@@ -127,8 +127,10 @@
 
   public <T extends Enum<?>, E extends EnumSet<? extends T>> T select(
       String title, String name, T defValue, boolean nullIfDefault) {
+    @SuppressWarnings("rawtypes")
+    Class<? extends Enum> declaringClass = defValue.getDeclaringClass();
     @SuppressWarnings("unchecked")
-    E allowedValues = (E) EnumSet.allOf(defValue.getClass());
+    E allowedValues = (E) EnumSet.allOf(declaringClass);
     return select(title, name, defValue, allowedValues, nullIfDefault);
   }
 
diff --git a/java/com/google/gerrit/reviewdb/BUILD b/java/com/google/gerrit/reviewdb/BUILD
index d241140..8c286ce 100644
--- a/java/com/google/gerrit/reviewdb/BUILD
+++ b/java/com/google/gerrit/reviewdb/BUILD
@@ -13,6 +13,7 @@
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//proto:entities_java_proto",
     ],
diff --git a/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
index 51e98c7..5c7b03b 100644
--- a/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum AccountIdProtoConverter implements ProtoConverter<Entities.Account_Id, Account.Id> {
   INSTANCE;
 
diff --git a/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
index f1018fc..6fa9353 100644
--- a/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum BranchNameKeyProtoConverter
     implements ProtoConverter<Entities.Branch_NameKey, BranchNameKey> {
   INSTANCE;
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
index a89434e..5e90c87 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum ChangeIdProtoConverter implements ProtoConverter<Entities.Change_Id, Change.Id> {
   INSTANCE;
 
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
index b9a4f4d..4aa900b 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum ChangeKeyProtoConverter implements ProtoConverter<Entities.Change_Key, Change.Key> {
   INSTANCE;
 
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
index 7d97e39..3d36293 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum ChangeMessageKeyProtoConverter
     implements ProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
   INSTANCE;
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
index 0895d8d..31b0e11 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -22,6 +23,7 @@
 import java.sql.Timestamp;
 import java.util.Objects;
 
+@Immutable
 public enum ChangeMessageProtoConverter
     implements ProtoConverter<Entities.ChangeMessage, ChangeMessage> {
   INSTANCE;
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
index 384dbca..2dfa516 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BranchNameKey;
@@ -22,6 +23,7 @@
 import com.google.protobuf.Parser;
 import java.sql.Timestamp;
 
+@Immutable
 public enum ChangeProtoConverter implements ProtoConverter<Entities.Change, Change> {
   INSTANCE;
 
diff --git a/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
index 7bc2ab1..42049a4 100644
--- a/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum LabelIdProtoConverter implements ProtoConverter<Entities.LabelId, LabelId> {
   INSTANCE;
 
diff --git a/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java
index 7413af9..246207d 100644
--- a/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
 import org.eclipse.jgit.lib.ObjectId;
@@ -30,6 +31,7 @@
  *   <li>This maintains backwards wire compatibility with a pre-NoteDb implementation.
  * </ul>
  */
+@Immutable
 public enum ObjectIdProtoConverter implements ProtoConverter<Entities.ObjectId, ObjectId> {
   INSTANCE;
 
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
index 43f6295..d3136b1 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.LabelId;
@@ -21,6 +22,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum PatchSetApprovalKeyProtoConverter
     implements ProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
   INSTANCE;
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
index 3baec99..a08d745 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
@@ -21,6 +22,7 @@
 import java.sql.Timestamp;
 import java.util.Objects;
 
+@Immutable
 public enum PatchSetApprovalProtoConverter
     implements ProtoConverter<Entities.PatchSetApproval, PatchSetApproval> {
   INSTANCE;
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
index 4101a6b..154b0bf 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum PatchSetIdProtoConverter implements ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
   INSTANCE;
 
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
index f9ed8ef..5006906 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.reviewdb.converter;
 
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -23,6 +24,7 @@
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 
+@Immutable
 public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet, PatchSet> {
   INSTANCE;
 
diff --git a/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
index 74849af..99048a0 100644
--- a/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.protobuf.Parser;
 
+@Immutable
 public enum ProjectNameKeyProtoConverter
     implements ProtoConverter<Entities.Project_NameKey, Project.NameKey> {
   INSTANCE;
diff --git a/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
index 568759c..f4f1de06 100644
--- a/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ProtoConverter.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.protobuf.MessageLite;
 import com.google.protobuf.Parser;
 
+@Immutable
 public interface ProtoConverter<P extends MessageLite, C> {
 
   P toProto(C valueClass);
diff --git a/java/com/google/gerrit/server/change/ArchiveFormat.java b/java/com/google/gerrit/server/change/ArchiveFormat.java
index 0316c5f..d895a66 100644
--- a/java/com/google/gerrit/server/change/ArchiveFormat.java
+++ b/java/com/google/gerrit/server/change/ArchiveFormat.java
@@ -35,7 +35,9 @@
   TXZ("application/x-xz", new TxzFormat()),
   ZIP("application/x-zip", new ZipFormat());
 
+  @SuppressWarnings("ImmutableEnumChecker") // ArchiveCommand.Format is effectively immutable.
   private final ArchiveCommand.Format<?> format;
+
   private final String mimeType;
 
   ArchiveFormat(String mimeType, ArchiveCommand.Format<?> format) {
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index fe7ff86..d04c894 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.BranchNameKey;
@@ -27,14 +29,12 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.List;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -56,6 +56,23 @@
         ProjectState projectState, BranchNameKey branch, IdentifiedUser user);
   }
 
+  /** A boolean validation status and a list of additional messages. */
+  @AutoValue
+  abstract static class Result {
+    static Result create(boolean isValid, ImmutableList<CommitValidationMessage> messages) {
+      return new AutoValue_BranchCommitValidator_Result(isValid, messages);
+    }
+
+    /** Whether the commit is valid. */
+    abstract boolean isValid();
+
+    /**
+     * A list of messages related to the validation. Messages may be present regardless of the
+     * {@link #isValid()} status.
+     */
+    abstract ImmutableList<CommitValidationMessage> messages();
+  }
+
   @Inject
   BranchCommitValidator(
       CommitValidators.Factory commitValidatorsFactory,
@@ -80,16 +97,17 @@
    * @param commit the commit being validated.
    * @param isMerged whether this is a merge commit created by magicBranch --merge option
    * @param change the change for which this is a new patchset.
+   * @return The validation {@link Result}.
    */
-  public boolean validCommit(
+  Result validateCommit(
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
       boolean isMerged,
-      List<ValidationMessage> messages,
       NoteMap rejectCommits,
       @Nullable Change change)
       throws IOException {
+    ImmutableList.Builder<CommitValidationMessage> messages = new ImmutableList.Builder<>();
     try (CommitReceivedEvent receiveEvent =
         new CommitReceivedEvent(cmd, project, branch.branch(), objectReader, commit, user)) {
       CommitValidators validators;
@@ -123,9 +141,9 @@
                 messageForCommit(commit, m.getMessage(), objectReader), m.getType()));
       }
       cmd.setResult(REJECTED_OTHER_REASON, messageForCommit(commit, e.getMessage(), objectReader));
-      return false;
+      return Result.create(false, messages.build());
     }
-    return true;
+    return Result.create(true, messages.build());
   }
 
   private String messageForCommit(RevCommit c, String msg, ObjectReader objectReader)
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 61249b1..73e0b1c 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -62,6 +62,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.exceptions.StorageException;
@@ -1517,6 +1518,10 @@
       // TODO(dpursehouse): validate hashtags
     }
 
+    @UsedAt(UsedAt.Project.GOOGLE)
+    @Option(name = "--create-cod-token", usage = "create a token for consistency-on-demand")
+    private boolean createCodToken;
+
     MagicBranchInput(IdentifiedUser user, ReceiveCommand cmd, LabelTypes labelTypes) {
       this.deprecatedTopicSeen = false;
       this.cmd = cmd;
@@ -1949,14 +1954,16 @@
     BranchCommitValidator validator =
         commitValidatorFactory.create(projectState, changeEnt.getDest(), user);
     try {
-      if (validator.validCommit(
-          receivePack.getRevWalk().getObjectReader(),
-          cmd,
-          newCommit,
-          false,
-          messages,
-          rejectCommits,
-          changeEnt)) {
+      BranchCommitValidator.Result validationResult =
+          validator.validateCommit(
+              receivePack.getRevWalk().getObjectReader(),
+              cmd,
+              newCommit,
+              false,
+              rejectCommits,
+              changeEnt);
+      messages.addAll(validationResult.messages());
+      if (validationResult.isValid()) {
         logger.atFine().log("Replacing change %s", changeEnt.getId());
         requestReplace(cmd, true, changeEnt, newCommit);
       }
@@ -2114,14 +2121,16 @@
           logger.atFine().log("Creating new change for %s even though it is already tracked", name);
         }
 
-        if (!validator.validCommit(
-            receivePack.getRevWalk().getObjectReader(),
-            magicBranch.cmd,
-            c,
-            magicBranch.merged,
-            messages,
-            rejectCommits,
-            null)) {
+        BranchCommitValidator.Result validationResult =
+            validator.validateCommit(
+                receivePack.getRevWalk().getObjectReader(),
+                magicBranch.cmd,
+                c,
+                magicBranch.merged,
+                rejectCommits,
+                null);
+        messages.addAll(validationResult.messages());
+        if (!validationResult.isValid()) {
           // Not a change the user can propose? Abort as early as possible.
           logger.atFine().log("Aborting early due to invalid commit");
           return Collections.emptyList();
@@ -3113,8 +3122,10 @@
           continue;
         }
 
-        if (!validator.validCommit(
-            walk.getObjectReader(), cmd, c, false, messages, rejectCommits, null)) {
+        BranchCommitValidator.Result validationResult =
+            validator.validateCommit(walk.getObjectReader(), cmd, c, false, rejectCommits, null);
+        messages.addAll(validationResult.messages());
+        if (!validationResult.isValid()) {
           break;
         }
       }
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
index 352ea4b..995b4b6 100644
--- a/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.index;
 
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -44,9 +43,21 @@
   @Override
   protected void configure() {
     if (slave) {
-      bind(AccountIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
-      bind(ChangeIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
-      bind(ProjectIndex.Factory.class).toInstance(AbstractIndexModule::createDummyIndexFactory);
+      bind(AccountIndex.Factory.class)
+          .toInstance(
+              s -> {
+                throw new UnsupportedOperationException();
+              });
+      bind(ChangeIndex.Factory.class)
+          .toInstance(
+              s -> {
+                throw new UnsupportedOperationException();
+              });
+      bind(ProjectIndex.Factory.class)
+          .toInstance(
+              s -> {
+                throw new UnsupportedOperationException();
+              });
     } else {
       install(
           new FactoryModuleBuilder()
@@ -74,11 +85,6 @@
     }
   }
 
-  @SuppressWarnings("unused")
-  private static <T> T createDummyIndexFactory(Schema<?> schema) {
-    throw new UnsupportedOperationException();
-  }
-
   protected abstract Class<? extends AccountIndex> getAccountIndex();
 
   protected abstract Class<? extends ChangeIndex> getChangeIndex();
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 577c255..3015187 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -23,6 +23,7 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.index.SiteIndexer;
@@ -108,7 +109,7 @@
     int projectsFailed = 0;
     for (Project.NameKey name : projectCache.all()) {
       try (Repository repo = repoManager.openRepository(name)) {
-        long size = estimateSize(repo);
+        int size = estimateSize(repo);
         changeCount += size;
         projects.add(new ProjectHolder(name, size));
       } catch (IOException e) {
@@ -126,15 +127,17 @@
     return indexAll(index, projects);
   }
 
-  private long estimateSize(Repository repo) throws IOException {
+  private int estimateSize(Repository repo) throws IOException {
     // Estimate size based on IDs that show up in ref names. This is not perfect, since patch set
     // refs may exist for changes whose metadata was never successfully stored. But that's ok, as
     // the estimate is just used as a heuristic for sorting projects.
-    return repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES).stream()
-        .map(r -> Change.Id.fromRef(r.getName()))
-        .filter(Objects::nonNull)
-        .distinct()
-        .count();
+    long size =
+        repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES).stream()
+            .map(r -> Change.Id.fromRef(r.getName()))
+            .filter(Objects::nonNull)
+            .distinct()
+            .count();
+    return Ints.saturatedCast(size);
   }
 
   private SiteIndexer.Result indexAll(ChangeIndex index, SortedSet<ProjectHolder> projects) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 976813f..e92a0f6 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -155,7 +155,7 @@
 
     MutableInteger leafTerms = new MutableInteger();
     Predicate<ChangeData> out = rewriteImpl(in, index, opts, leafTerms);
-    if (in == out || out instanceof IndexPredicate) {
+    if (isSameInstance(in, out) || out instanceof IndexPredicate) {
       return new IndexedChangeQuery(index, out, opts);
     } else if (out == null /* cannot rewrite */) {
       return in;
@@ -207,7 +207,7 @@
     for (int i = 0; i < n; i++) {
       Predicate<ChangeData> c = in.getChild(i);
       Predicate<ChangeData> nc = rewriteImpl(c, index, opts, leafTerms);
-      if (nc == c) {
+      if (isSameInstance(nc, c)) {
         isIndexed.set(i);
         newChildren.add(c);
       } else if (nc == null /* cannot rewrite c */) {
@@ -291,4 +291,9 @@
     return p.getChildCount() > 0
         && (p instanceof AndPredicate || p instanceof OrPredicate || p instanceof NotPredicate);
   }
+
+  @SuppressWarnings("ReferenceEquality")
+  private static <T> boolean isSameInstance(T a, T b) {
+    return a == b;
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
index fad9832..d5a7259 100644
--- a/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
+++ b/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -21,13 +21,13 @@
 /** State of a reviewer on a change. */
 public enum ReviewerStateInternal {
   /** The user has contributed at least one nonzero vote on the change. */
-  REVIEWER(new FooterKey("Reviewer"), ReviewerState.REVIEWER),
+  REVIEWER("Reviewer", ReviewerState.REVIEWER),
 
   /** The reviewer was added to the change, but has not voted. */
-  CC(new FooterKey("CC"), ReviewerState.CC),
+  CC("CC", ReviewerState.CC),
 
   /** The user was previously a reviewer on the change, but was removed. */
-  REMOVED(new FooterKey("Removed"), ReviewerState.REMOVED);
+  REMOVED("Removed", ReviewerState.REMOVED);
 
   public static ReviewerStateInternal fromReviewerState(ReviewerState state) {
     return ReviewerStateInternal.values()[state.ordinal()];
@@ -50,20 +50,20 @@
     }
   }
 
-  private final FooterKey footerKey;
+  private final String footer;
   private final ReviewerState state;
 
-  ReviewerStateInternal(FooterKey footerKey, ReviewerState state) {
-    this.footerKey = footerKey;
+  ReviewerStateInternal(String footer, ReviewerState state) {
+    this.footer = footer;
     this.state = state;
   }
 
   FooterKey getFooterKey() {
-    return footerKey;
+    return new FooterKey(footer);
   }
 
   FooterKey getByEmailFooterKey() {
-    return new FooterKey(footerKey.getName() + "-email");
+    return new FooterKey(footer + "-email");
   }
 
   public ReviewerState asReviewerState() {
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 61f0180..acf88e1 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -37,6 +37,7 @@
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.CorruptObjectException;
@@ -500,8 +501,7 @@
       try {
         final boolean reuse;
         if (Patch.COMMIT_MSG.equals(path)) {
-          if (comparisonType.isAgainstParentOrAutoMerge()
-              && (aId == within || within.equals(aId))) {
+          if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
             id = ObjectId.zeroId();
             src = Text.EMPTY;
             srcContent = Text.NO_BYTES;
@@ -520,8 +520,7 @@
           }
           reuse = false;
         } else if (Patch.MERGE_LIST.equals(path)) {
-          if (comparisonType.isAgainstParentOrAutoMerge()
-              && (aId == within || within.equals(aId))) {
+          if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
             id = ObjectId.zeroId();
             src = Text.EMPTY;
             srcContent = Text.NO_BYTES;
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index e29a48a..d01954c 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -26,6 +26,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.Nullable;
@@ -329,6 +330,10 @@
     return as;
   }
 
+  public ImmutableSet<String> getAccessSectionNames() {
+    return ImmutableSet.copyOf(accessSections.keySet());
+  }
+
   public Collection<AccessSection> getAccessSections() {
     return sort(accessSections.values());
   }
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
new file mode 100644
index 0000000..6c2ddde
--- /dev/null
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project.testing;
+
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import java.util.Arrays;
+
+public class TestLabels {
+  public static LabelType codeReview() {
+    return label(
+        "Code-Review",
+        value(2, "Looks good to me, approved"),
+        value(1, "Looks good to me, but someone else must approve"),
+        value(0, "No score"),
+        value(-1, "I would prefer this is not merged as is"),
+        value(-2, "This shall not be merged"));
+  }
+
+  public static LabelType verified() {
+    return label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+  }
+
+  public static LabelType patchSetLock() {
+    LabelType label =
+        label("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
+    label.setFunction(LabelFunction.PATCH_SET_LOCK);
+    return label;
+  }
+
+  public static LabelValue value(int value, String text) {
+    return new LabelValue((short) value, text);
+  }
+
+  public static LabelType label(String name, LabelValue... values) {
+    return new LabelType(name, Arrays.asList(values));
+  }
+
+  private TestLabels() {}
+}
diff --git a/java/com/google/gerrit/server/project/testing/Util.java b/java/com/google/gerrit/server/project/testing/Util.java
deleted file mode 100644
index 2bd71c3..0000000
--- a/java/com/google/gerrit/server/project/testing/Util.java
+++ /dev/null
@@ -1,239 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project.testing;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.project.ProjectConfig;
-import java.util.Arrays;
-
-public class Util {
-  public static final AccountGroup.UUID ADMIN = AccountGroup.uuid("test.admin");
-  public static final AccountGroup.UUID DEVS = AccountGroup.uuid("test.devs");
-
-  public static final LabelType codeReview() {
-    return category(
-        "Code-Review",
-        value(2, "Looks good to me, approved"),
-        value(1, "Looks good to me, but someone else must approve"),
-        value(0, "No score"),
-        value(-1, "I would prefer this is not merged as is"),
-        value(-2, "This shall not be merged"));
-  }
-
-  public static final LabelType verified() {
-    return category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
-  }
-
-  public static final LabelType patchSetLock() {
-    LabelType label =
-        category("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
-    label.setFunction(LabelFunction.PATCH_SET_LOCK);
-    return label;
-  }
-
-  public static LabelValue value(int value, String text) {
-    return new LabelValue((short) value, text);
-  }
-
-  public static LabelType category(String name, LabelValue... values) {
-    return new LabelType(name, Arrays.asList(values));
-  }
-
-  public static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
-    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
-    group = project.resolve(group);
-
-    return new PermissionRule(group);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    return grant(project, permissionName, rule, ref);
-  }
-
-  public static PermissionRule allowExclusive(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    return grant(project, permissionName, rule, ref, true);
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    PermissionRule r = grant(project, permissionName, rule, ref);
-    r.setBlock();
-    return r;
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    return grant(project, permissionName, newRule(project, group), ref);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String permissionName,
-      AccountGroup.UUID group,
-      String ref,
-      boolean exclusive) {
-    return grant(project, permissionName, newRule(project, group), ref, exclusive);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    return allow(project, capabilityName, group, (PermissionRange) null);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String capabilityName,
-      AccountGroup.UUID group,
-      PermissionRange customRange) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .add(rule);
-    if (GlobalCapability.hasRange(capabilityName)) {
-      if (customRange == null) {
-        PermissionRange.WithDefaults range = GlobalCapability.getRange(capabilityName);
-        if (range != null) {
-          rule.setRange(range.getDefaultMin(), range.getDefaultMax());
-        }
-        return rule;
-      }
-      rule.setRange(customRange.getMin(), customRange.getMax());
-    }
-    return rule;
-  }
-
-  public static PermissionRule remove(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .remove(rule);
-    return rule;
-  }
-
-  public static PermissionRule remove(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule rule = newRule(project, group);
-    project.getAccessSection(ref, true).getPermission(permissionName, true).remove(rule);
-    return rule;
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .add(rule);
-    return rule;
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
-    r.setBlock();
-    return r;
-  }
-
-  public static PermissionRule blockLabel(
-      ProjectConfig project, String labelName, AccountGroup.UUID group, String ref) {
-    return blockLabel(project, labelName, -1, 1, group, ref);
-  }
-
-  public static PermissionRule blockLabel(
-      ProjectConfig project,
-      String labelName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule r = grant(project, Permission.LABEL + labelName, newRule(project, group), ref);
-    r.setBlock();
-    r.setRange(min, max);
-    return r;
-  }
-
-  public static PermissionRule deny(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
-    r.setDeny();
-    return r;
-  }
-
-  public static void doNotInherit(ProjectConfig project, String permissionName, String ref) {
-    project
-        .getAccessSection(ref, true) //
-        .getPermission(permissionName, true) //
-        .setExclusiveGroup(true);
-  }
-
-  private static PermissionRule grant(
-      ProjectConfig project, String permissionName, PermissionRule rule, String ref) {
-    return grant(project, permissionName, rule, ref, false);
-  }
-
-  private static PermissionRule grant(
-      ProjectConfig project,
-      String permissionName,
-      PermissionRule rule,
-      String ref,
-      boolean exclusive) {
-    Permission permission = project.getAccessSection(ref, true).getPermission(permissionName, true);
-    if (exclusive) {
-      permission.setExclusiveGroup(exclusive);
-    }
-    permission.add(rule);
-    return rule;
-  }
-
-  private Util() {}
-}
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 7fc47dc..8ad063a 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -646,7 +646,7 @@
         int newSize = msgbuf.length() + bullet.length() + message.length();
         if (++numMessages > maxCommitMessages
             || newSize > maxCombinedCommitMessageSize
-            || iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize) {
+            || (iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize)) {
           msgbuf.append(ellipsis);
           break;
         }
diff --git a/java/com/google/gerrit/server/util/IdGenerator.java b/java/com/google/gerrit/server/util/IdGenerator.java
index 276df06..d4c2dc4 100644
--- a/java/com/google/gerrit/server/util/IdGenerator.java
+++ b/java/com/google/gerrit/server/util/IdGenerator.java
@@ -45,8 +45,8 @@
   public static int mix(int salt, int in) {
     short v0 = hi16(in);
     short v1 = lo16(in);
-    v0 += ((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1;
-    v1 += ((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3;
+    v0 += (short) (((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1);
+    v1 += (short) (((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3);
     return result(v0, v1);
   }
 
@@ -54,8 +54,8 @@
   static int unmix(int in) {
     short v0 = hi16(in);
     short v1 = lo16(in);
-    v1 -= ((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3;
-    v0 -= ((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1;
+    v1 -= (short) (((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3);
+    v0 -= (short) (((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1);
     return result(v0, v1);
   }
 
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 447f7ec..c680d30 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -87,29 +87,6 @@
     EventTypes.register(DroppedOutputEvent.TYPE, DroppedOutputEvent.class);
   }
 
-  private final CancelableRunnable writer =
-      new CancelableRunnable() {
-        @Override
-        public void run() {
-          writeEvents();
-        }
-
-        @Override
-        public void cancel() {
-          onExit(0);
-        }
-
-        @Override
-        public String toString() {
-          StringBuilder b = new StringBuilder();
-          b.append("Stream Events");
-          if (currentUser.getUserName().isPresent()) {
-            b.append(" (").append(currentUser.getUserName().get()).append(")");
-          }
-          return b.toString();
-        }
-      };
-
   /** True if {@link DroppedOutputEvent} needs to be sent. */
   private volatile boolean dropped;
 
@@ -127,8 +104,6 @@
    */
   private Future<?> task;
 
-  private PrintWriter stdout;
-
   @Override
   public void start(Environment env) throws IOException {
     try {
@@ -144,7 +119,30 @@
       return;
     }
 
-    stdout = toPrintWriter(out);
+    PrintWriter stdout = toPrintWriter(out);
+    CancelableRunnable writer =
+        new CancelableRunnable() {
+          @Override
+          public void run() {
+            writeEvents(this, stdout);
+          }
+
+          @Override
+          public void cancel() {
+            onExit(0);
+          }
+
+          @Override
+          public String toString() {
+            StringBuilder b = new StringBuilder();
+            b.append("Stream Events");
+            if (currentUser.getUserName().isPresent()) {
+              b.append(" (").append(currentUser.getUserName().get()).append(")");
+            }
+            return b.toString();
+          }
+        };
+
     eventListenerRegistration =
         eventListeners.add(
             "gerrit",
@@ -152,7 +150,7 @@
               @Override
               public void onEvent(Event event) {
                 if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
-                  offer(event);
+                  offer(writer, event);
                 }
               }
 
@@ -199,7 +197,7 @@
     }
   }
 
-  private void offer(Event event) {
+  private void offer(CancelableRunnable writer, Event event) {
     synchronized (taskLock) {
       if (!queue.offer(event)) {
         dropped = true;
@@ -221,7 +219,7 @@
     }
   }
 
-  private void writeEvents() {
+  private void writeEvents(CancelableRunnable writer, PrintWriter stdout) {
     int processed = 0;
 
     while (processed < BATCH_SIZE) {
@@ -231,13 +229,13 @@
         // accepting output. Either way terminate this instance.
         //
         removeEventListenerRegistration();
-        flush();
+        flush(stdout);
         onExit(0);
         return;
       }
 
       if (dropped) {
-        write(new DroppedOutputEvent());
+        write(stdout, new DroppedOutputEvent());
         dropped = false;
       }
 
@@ -246,11 +244,11 @@
         break;
       }
 
-      write(event);
+      write(stdout, event);
       processed++;
     }
 
-    flush();
+    flush(stdout);
 
     if (BATCH_SIZE <= processed) {
       // We processed the limit, but more might remain in the queue.
@@ -263,7 +261,7 @@
     }
   }
 
-  private void write(Object message) {
+  private void write(PrintWriter stdout, Object message) {
     String msg = null;
     try {
       msg = gson.toJson(message) + "\n";
@@ -277,7 +275,7 @@
     }
   }
 
-  private void flush() {
+  private void flush(PrintWriter stdout) {
     synchronized (stdout) {
       stdout.flush();
     }
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 24f82a7..a58e472 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -139,7 +139,7 @@
     PacketLineIn packetIn = new PacketLineIn(in);
     for (; ; ) {
       String s = packetIn.readString();
-      if (s == PacketLineIn.END) {
+      if (isPacketLineEnd(s)) {
         break;
       }
       if (!s.startsWith(argCmd)) {
@@ -163,6 +163,12 @@
     }
   }
 
+  // JGit API depends on reference equality with sentinel.
+  @SuppressWarnings({"ReferenceEquality", "StringEquality"})
+  private static boolean isPacketLineEnd(String s) {
+    return s == PacketLineIn.END;
+  }
+
   @Override
   protected void runImpl() throws IOException, PermissionBackendException, Failure {
     PacketLineOut packetOut = new PacketLineOut(out);
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index f896eaa..27065aa 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -15,6 +15,7 @@
         "//lib/powermock:powermock-module-junit4-common",
     ],
     deps = [
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/exceptions",
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index c1e3d42..5f1826b 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -20,6 +20,8 @@
 import com.google.common.base.Strings;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
@@ -234,6 +236,8 @@
     bind(ServerInformation.class).to(ServerInformationImpl.class);
     install(new RestApiModule());
     install(new DefaultProjectNameLockManager.Module());
+
+    bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
   }
 
   /** Copy of SchemaModule with a slightly different server ID provider. */
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 3e07502..f653ef1 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -20,6 +20,12 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.gpg.testing.TestKeys.allValidKeys;
@@ -416,8 +422,10 @@
       List<EmailInfo> emails = gApi.accounts().id(accountId.get()).getEmails();
       assertThat(emails.stream().map(e -> e.email).collect(toSet())).containsExactly(extId.email());
 
-      RevCommit commitUserBranch = getRemoteHead(allUsers, RefNames.refsUsers(accountId));
-      RevCommit commitRefsMetaExternalIds = getRemoteHead(allUsers, RefNames.REFS_EXTERNAL_IDS);
+      RevCommit commitUserBranch =
+          projectOperations.project(allUsers).getHead(RefNames.refsUsers(accountId));
+      RevCommit commitRefsMetaExternalIds =
+          projectOperations.project(allUsers).getHead(RefNames.REFS_EXTERNAL_IDS);
       assertThat(commitUserBranch.getCommitTime())
           .isEqualTo(commitRefsMetaExternalIds.getCommitTime());
     } finally {
@@ -1231,7 +1239,10 @@
   @Test
   @Sandboxed
   public void userCanSetNameOfOtherUserWithModifyAccountPermission() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.MODIFY_ACCOUNT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
+        .update();
     gApi.accounts().id(admin.username()).setName("Admin McAdminface");
     assertThat(gApi.accounts().id(admin.username()).get().name).isEqualTo("Admin McAdminface");
   }
@@ -1252,18 +1263,24 @@
     }
 
     // deny READ permission that is inherited from All-Projects
-    deny(allUsers, RefNames.REFS + "*", Permission.READ, ANONYMOUS_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(deny(Permission.READ).ref(RefNames.REFS + "*").group(ANONYMOUS_USERS))
+        .update();
 
     // fetching user branch without READ permission fails
     assertThrows(TransportException.class, () -> fetch(allUsersRepo, userRefName + ":userRef"));
 
     // allow each user to read its own user branch
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.READ,
-        false,
-        REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.READ)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS))
+        .update();
 
     // fetch user branch using refs/users/YY/XXXXXXX
     fetch(allUsersRepo, userRefName + ":userRef");
@@ -1513,16 +1530,23 @@
 
   @Test
   public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestAccount foo = accountCreator.create(name("foo"));
     assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
     String userRef = RefNames.refsUsers(foo.id());
     accountIndexedCounter.clear();
 
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
-    grantLabel("Code-Review", -2, 2, allUsers, userRef, adminGroupUuid(), false);
-    grant(allUsers, userRef, Permission.SUBMIT, false, adminGroupUuid());
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+        .add(allowLabel("Code-Review").ref(userRef).group(adminGroupUuid()).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref(userRef).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
@@ -1687,7 +1711,11 @@
         .update("Set Preferred Email", foo.id(), u -> u.setPreferredEmail(noEmail));
     accountIndexedCounter.clear();
 
-    grant(allUsers, userRef, Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(userRef).group(REGISTERED_USERS))
+        .update();
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
@@ -1719,7 +1747,11 @@
     String userRef = RefNames.refsUsers(foo.id());
     accountIndexedCounter.clear();
 
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
     fetch(allUsersRepo, userRef + ":userRef");
@@ -1770,14 +1802,21 @@
 
   @Test
   public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestAccount foo = accountCreator.create(name("foo"));
     assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
     String userRef = RefNames.refsUsers(foo.id());
     accountIndexedCounter.clear();
 
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
@@ -1802,8 +1841,12 @@
 
   @Test
   public void cannotCreateUserBranch() throws Exception {
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
 
     String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
@@ -1818,9 +1861,16 @@
 
   @Test
   public void createUserBranchWithAccessDatabaseCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
 
     String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
@@ -1834,9 +1884,16 @@
   @Test
   public void cannotCreateNonUserBranchUnderRefsUsersWithAccessDatabaseCapability()
       throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
 
     String userRef = RefNames.REFS_USERS + "foo";
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
@@ -1855,8 +1912,12 @@
       assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNull();
     }
 
-    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.PUSH);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     pushFactory
@@ -1871,12 +1932,15 @@
 
   @Test
   public void cannotDeleteUserBranch() throws Exception {
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.DELETE,
-        true,
-        REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.DELETE)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS)
+                .force(true))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     String userRef = RefNames.refsUsers(admin.id());
@@ -1892,13 +1956,19 @@
 
   @Test
   public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.DELETE,
-        true,
-        REGISTERED_USERS);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.DELETE)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS)
+                .force(true))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     String userRef = RefNames.refsUsers(admin.id());
@@ -2154,7 +2224,10 @@
 
   @Test
   public void checkConsistency() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.resetCurrentApiUser();
 
     // Create an account with a preferred email.
@@ -2491,7 +2564,10 @@
 
   @Test
   public void atomicReadMofifyWriteExternalIds() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId extIdA1 = ExternalId.create("foo", "A-1", accountId);
@@ -2762,14 +2838,22 @@
       assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1);
       assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
 
-      block(project, "refs/heads/secret", Permission.READ, REGISTERED_USERS);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(block(Permission.READ).ref("refs/heads/secret").group(REGISTERED_USERS))
+          .update();
       List<DeletedDraftCommentInfo> result =
           gApi.accounts().self().deleteDraftComments(new DeleteDraftCommentsInput());
       assertThat(result).hasSize(1);
       assertThat(result.get(0).change.changeId).isEqualTo(r1.getChangeId());
       assertThat(result.get(0).deleted.stream().map(c -> c.message)).containsExactly("draft a");
 
-      removePermission(project, "refs/heads/secret", Permission.READ);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.READ).ref("refs/heads/secret"))
+          .update();
       assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).isEmpty();
       // Draft still exists since change wasn't visible when drafts where deleted.
       assertThat(gApi.changes().id(r2.getChangeId()).current().draftsAsList()).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index 6a116d8..87f9a2d5 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -27,6 +28,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -47,8 +49,9 @@
 
 public class AbandonIT extends AbstractDaemonTest {
   @Inject private AbandonUtil abandonUtil;
-  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeCleanupConfig cleanupConfig;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void abandon() throws Exception {
@@ -174,7 +177,11 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).abandon();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 072efe3..7948ca5 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -22,6 +22,13 @@
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
@@ -45,8 +52,8 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
@@ -98,7 +105,6 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -150,7 +156,7 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeOperatorFactory;
 import com.google.gerrit.server.restapi.change.PostReview;
@@ -300,7 +306,11 @@
         gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
 
     com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user2.id());
     gApi.changes().id(changeId).setWorkInProgress();
     assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
@@ -348,7 +358,11 @@
     gApi.changes().id(changeId).setWorkInProgress();
 
     com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user2.id());
     gApi.changes().id(changeId).setReadyForReview();
     assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
@@ -507,12 +521,14 @@
     String refactor = "Needs some refactoring";
     String ptal = "PTAL";
 
-    grant(
-        project,
-        "refs/heads/master",
-        Permission.TOGGLE_WORK_IN_PROGRESS_STATE,
-        false,
-        REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).setWorkInProgress(refactor);
@@ -632,12 +648,14 @@
   public void reviewWithWorkInProgressByNonOwnerWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    grant(
-        project,
-        "refs/heads/master",
-        Permission.TOGGLE_WORK_IN_PROGRESS_STATE,
-        false,
-        REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).current().review(in);
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
@@ -953,7 +971,11 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     // Rebase the second
     String changeId = r2.getChangeId();
@@ -973,8 +995,12 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
-    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Rebase the second
     String changeId = r2.getChangeId();
@@ -996,7 +1022,11 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Rebase the second
     String changeId = r2.getChangeId();
@@ -1025,7 +1055,11 @@
 
   @Test
   public void deleteNewChangeAsUserWithDeleteChangesPermissionForGroup() throws Exception {
-    allow("refs/*", Permission.DELETE_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     deleteChangeAsUser(admin, user);
   }
 
@@ -1034,32 +1068,40 @@
     GroupApi groupApi = gApi.groups().create(name("delete-change"));
     groupApi.addMembers("user");
 
+    Project.NameKey nameKey = Project.nameKey(name("delete-change"));
     ProjectInput in = new ProjectInput();
-    in.name = name("delete-change");
+    in.name = nameKey.get();
     in.owners = Lists.newArrayListWithCapacity(1);
     in.owners.add(groupApi.name());
     in.createEmptyCommit = true;
-    ProjectApi api = gApi.projects().create(in);
+    gApi.projects().create(in);
 
-    Project.NameKey nameKey = Project.nameKey(api.get().name);
-
-    try (ProjectConfigUpdate u = updateProject(nameKey)) {
-      Util.allow(u.getConfig(), Permission.DELETE_CHANGES, PROJECT_OWNERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(nameKey)
+        .forUpdate()
+        .add(allow(Permission.DELETE_CHANGES).ref("refs/*").group(PROJECT_OWNERS))
+        .update();
 
     deleteChangeAsUser(nameKey, admin, user);
   }
 
   @Test
   public void deleteChangeAsUserWithDeleteOwnChangesPermissionForGroup() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     deleteChangeAsUser(user, user);
   }
 
   @Test
   public void deleteChangeAsUserWithDeleteOwnChangesPermissionForOwners() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, CHANGE_OWNER);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(CHANGE_OWNER))
+        .update();
     deleteChangeAsUser(user, user);
   }
 
@@ -1097,8 +1139,12 @@
       eventRecorder.assertRefUpdatedEvents(projectName.get(), ref, null, commit, commit, null);
       eventRecorder.assertChangeDeletedEvents(changeId, deleteAs.email());
     } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
-      removePermission(project, "refs/*", Permission.DELETE_CHANGES);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.DELETE_OWN_CHANGES).ref("refs/*"))
+          .remove(permissionKey(Permission.DELETE_CHANGES).ref("refs/*"))
+          .update();
     }
   }
 
@@ -1109,7 +1155,11 @@
 
   @Test
   public void deleteNewChangeOfAnotherUserWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     try {
       PushOneCommit.Result changeResult = createChange();
@@ -1120,7 +1170,11 @@
           assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
       assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.DELETE_OWN_CHANGES).ref("refs/*"))
+          .update();
     }
   }
 
@@ -1179,7 +1233,11 @@
   @Test
   @TestProjectInput(cloneAs = "user")
   public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     try {
       PushOneCommit.Result changeResult =
@@ -1193,7 +1251,11 @@
           assertThrows(MethodNotAllowedException.class, () -> gApi.changes().id(changeId).delete());
       assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .remove(permissionKey(Permission.DELETE_OWN_CHANGES).ref("refs/*"))
+          .update();
     }
   }
 
@@ -1474,11 +1536,12 @@
   public void pushCommitOfOtherUserThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // admin pushes commit of user
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1543,11 +1606,12 @@
   public void pushCommitWithFooterOfOtherUserThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // admin pushes commit that references 'user' in a footer
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1582,11 +1646,12 @@
   public void addReviewerThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), Permission.READ, adminGroupUuid(), "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // create change
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -2089,21 +2154,21 @@
 
   @Test
   public void removeReviewerNoVotes() throws Exception {
+    LabelType verified =
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType verified =
-          category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      String heads = RefNames.REFS_HEADS + "*";
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.verified().getName()),
-          -1,
-          1,
-          registeredUsers,
-          heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(verified.getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -2376,16 +2441,18 @@
   @Test
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
     LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      String heads = "refs/heads/*";
-      AccountGroup.UUID owners = systemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
-      AccountGroup.UUID registered = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, owners, heads);
-      Util.allow(u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, registered, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(CHANGE_OWNER).range(-1, 1))
+        .add(allowLabel("Code-Review").ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     // Set Code-Review+2 and Verified+1 as admin (change owner)
     PushOneCommit.Result r = createChange();
@@ -2481,8 +2548,13 @@
 
   @Test
   public void queryChangesNoLimit() throws Exception {
-    allowGlobalCapabilities(
-        SystemGroupBackend.REGISTERED_USERS, 0, 2, GlobalCapability.QUERY_LIMIT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.QUERY_LIMIT)
+                .group(SystemGroupBackend.REGISTERED_USERS)
+                .range(0, 2))
+        .update();
     for (int i = 0; i < 3; i++) {
       createChange();
     }
@@ -2630,7 +2702,11 @@
   public void editTopicWithPermissionAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
-    grant(project, "refs/heads/master", Permission.EDIT_TOPIC_NAME, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.EDIT_TOPIC_NAME).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).topic("mytopic");
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
@@ -2687,7 +2763,11 @@
   public void submitAllowedWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    grant(project, "refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
     assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
@@ -2703,22 +2783,24 @@
   @Test
   public void commitFooters() throws Exception {
     LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     LabelType custom1 =
-        category("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+        label("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     LabelType custom2 =
-        category("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+        label("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
       u.getConfig().getLabelSections().put(custom1.getName(), custom1);
       u.getConfig().getLabelSections().put(custom2.getName(), custom2);
-      String heads = "refs/heads/*";
-      AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(u.getConfig(), Permission.forLabel("Verified"), -1, 1, anon, heads);
-      Util.allow(u.getConfig(), Permission.forLabel("Custom1"), -1, 1, anon, heads);
-      Util.allow(u.getConfig(), Permission.forLabel("Custom2"), -1, 1, anon, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(custom1.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(custom2.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .update();
 
     PushOneCommit.Result r1 = createChange();
     r1.assertOkStatus();
@@ -2841,10 +2923,11 @@
     assertThat(approval._accountId).isEqualTo(user.id().get());
     assertThat(approval.value).isEqualTo(0);
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.blockLabel(u.getConfig(), "Code-Review", REGISTERED_USERS, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
@@ -2962,7 +3045,11 @@
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Create change as admin
     PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
@@ -3006,7 +3093,11 @@
     TestRepository<?> adminTestRepo = cloneProject(project, admin);
 
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Create change as admin
     PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
@@ -3100,7 +3191,7 @@
 
   @Test
   public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch("foo");
     createBranch("bar");
 
@@ -3129,7 +3220,7 @@
 
   @Test
   public void createMergePatchSetBaseOnChange() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch("foo");
     createBranch("bar");
 
@@ -3177,15 +3268,18 @@
 
     // add new label and assert that it's returned for existing changes
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    LabelType verified = Util.verified();
+    LabelType verified = TestLabels.verified();
     String heads = RefNames.REFS_HEADS + "*";
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      Util.allow(
-          u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
@@ -3203,9 +3297,16 @@
       // remove label and assert that it's no longer returned for existing
       // changes, even if there is an approval for it
       u.getConfig().getLabelSections().remove(verified.getName());
-      Util.remove(u.getConfig(), Permission.forLabel(verified.getName()), registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(
+            permissionKey(Permission.forLabel(verified.getName()))
+                .ref(heads)
+                .group(registeredUsers))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
@@ -3232,17 +3333,20 @@
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
     assertPermitted(change, "Code-Review", 2);
 
-    LabelType verified = Util.verified();
+    LabelType verified = TestLabels.verified();
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
 
     // add new label and assert that it's returned for existing changes
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().put(verified.getName(), verified);
-      Util.allow(
-          u.getConfig(), Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
@@ -3284,9 +3388,13 @@
     // changes, even if there is an approval for it
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().remove(verified.getName());
-      Util.remove(u.getConfig(), Permission.forLabel(verified.getName()), registeredUsers, heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(verified.getName()).ref(heads).group(registeredUsers))
+        .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
@@ -3297,7 +3405,7 @@
   @Test
   public void checkLabelsForMergedChangeWithNonAuthorCodeReview() throws Exception {
     // Configure Non-Author-Code-Review
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
     testRepo.reset("config");
     PushOneCommit push2 =
@@ -3322,20 +3430,18 @@
     push2.to(RefNames.REFS_CONFIG);
     testRepo.reset(oldHead);
 
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
 
     // Allow user to approve
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.codeReview().getName()),
-          -2,
-          2,
-          registeredUsers,
-          heads);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(heads)
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
 
     PushOneCommit.Result r = createChange();
 
@@ -3387,16 +3493,15 @@
     assertThat(approval.permittedVotingRange.min).isEqualTo(-1);
     assertThat(approval.permittedVotingRange.max).isEqualTo(1);
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel("Code-Review"),
-          minPermittedValue,
-          maxPermittedValue,
-          REGISTERED_USERS,
-          heads);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Code-Review")
+                .ref(heads)
+                .group(REGISTERED_USERS)
+                .range(minPermittedValue, maxPermittedValue))
+        .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
@@ -3410,10 +3515,11 @@
 
   @Test
   public void maxPermittedValueBlocked() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.blockLabel(u.getConfig(), "Code-Review", REGISTERED_USERS, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -3492,7 +3598,7 @@
             + "U > 0,"
             + "R = label('All-Comments-Resolved', need(_)). \n\n");
 
-    String oldHead = getRemoteHead().name();
+    String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result1 =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
@@ -3673,7 +3779,11 @@
     Project.NameKey p = projectOperations.newProject().create();
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
     // Create change as user
     PushOneCommit push = pushFactory.create(user.newIdent(), userTestRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
@@ -3744,7 +3854,8 @@
     assertThat(
             gApi.changes()
                 .id(revertId)
-                .pureRevert(getRemoteHead().toObjectId().name())
+                .pureRevert(
+                    projectOperations.project(project).getHead("master").toObjectId().name())
                 .isPureRevert)
         .isTrue();
   }
@@ -3767,7 +3878,7 @@
   public void pureRevertParameterTakesPrecedence() throws Exception {
     PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
     merge(r1);
-    String oldHead = getRemoteHead().toObjectId().name();
+    String oldHead = projectOperations.project(project).getHead("master").toObjectId().name();
 
     PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content2");
     merge(r2);
@@ -3810,7 +3921,8 @@
     // Create an initial commit to serve as claimed original
     PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
     merge(r1);
-    String claimedOriginal = getRemoteHead().toObjectId().name();
+    String claimedOriginal =
+        projectOperations.project(project).getHead("master").toObjectId().name();
 
     // Change contents of the file to provoke a conflict
     merge(createChange("commit message", "a.txt", "content2"));
@@ -3862,13 +3974,13 @@
 
   public void submittableAfterLosingPermissions(String label) throws Exception {
     String codeReviewLabel = "Code-Review";
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.forLabel(label), -1, +1, registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(codeReviewLabel), -2, +2, registered, "refs/heads/*");
-      u.save();
-    }
+    AccountGroup.UUID registered = REGISTERED_USERS;
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label).ref("refs/heads/*").group(registered).range(-1, +1))
+        .add(allowLabel(codeReviewLabel).ref("refs/heads/*").group(registered).range(-2, +2))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     PushOneCommit.Result r = createChange();
@@ -3891,15 +4003,13 @@
     assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
 
     requestScopeOperations.setApiUser(admin.id());
-    // Remove user's permission for 'Label'.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.remove(u.getConfig(), Permission.forLabel(label), registered, "refs/heads/*");
-      // Update user's permitted range for 'Code-Review' to be -1...+1.
-      Util.remove(u.getConfig(), Permission.forLabel(codeReviewLabel), registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(codeReviewLabel), -1, +1, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(label).ref("refs/heads/*").group(registered))
+        .remove(labelPermissionKey(codeReviewLabel).ref("refs/heads/*").group(registered))
+        .add(allowLabel(codeReviewLabel).ref("refs/heads/*").group(registered).range(-1, +1))
+        .update();
 
     // Verify user's new permitted range.
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
index eab6c3c..57d7ece 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
@@ -20,12 +20,15 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class DisablePrivateChangesIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
@@ -63,7 +66,7 @@
   @GerritConfig(name = "change.allowDrafts", value = "true")
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   public void pushDraftsWithDisablePrivateChangesTrue() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result result =
         pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%draft");
     result.assertErrorStatus();
@@ -93,7 +96,7 @@
   @Test
   @GerritConfig(name = "change.allowDrafts", value = "true")
   public void pushDraftsWithDisablePrivateChangesFalse() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result result =
         pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%draft");
     assertThat(result.getChange().change().isPrivate()).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index ab72491..59237cb 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -42,6 +44,7 @@
 
 public class PrivateChangeIT extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -167,7 +170,11 @@
     PushOneCommit.Result result = createChange();
     gApi.changes().id(result.getChangeId()).setPrivate(true, null);
 
-    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index f90c52e..99935b5 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
 import static com.google.gerrit.extensions.client.ChangeKind.NO_CHANGE;
 import static com.google.gerrit.extensions.client.ChangeKind.NO_CODE_CHANGE;
@@ -26,8 +27,8 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
@@ -36,9 +37,9 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
@@ -46,9 +47,8 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import java.util.EnumSet;
 import java.util.List;
@@ -62,6 +62,7 @@
 
 @NoHttpd
 public class StickyApprovalsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
@@ -70,7 +71,7 @@
       // Overwrite "Code-Review" label that is inherited from All-Projects.
       // This way changes to the "Code Review" label don't affect other tests.
       LabelType codeReview =
-          category(
+          label(
               "Code-Review",
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
@@ -81,28 +82,26 @@
       u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
 
       LabelType verified =
-          category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+          label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       verified.setCopyAllScoresIfNoChange(false);
       u.getConfig().getLabelSections().put(verified.getName(), verified);
 
-      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      String heads = RefNames.REFS_HEADS + "*";
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.codeReview().getName()),
-          -2,
-          2,
-          registeredUsers,
-          heads);
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.verified().getName()),
-          -1,
-          1,
-          registeredUsers,
-          heads);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
   }
 
   @Test
@@ -120,7 +119,7 @@
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, -1, 1);
@@ -142,7 +141,7 @@
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, 2, 1);
@@ -179,7 +178,7 @@
     assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
 
     // check that votes are sticky when trivial rebase is done by cherry-pick
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     changeId = createChange().getChangeId();
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -190,7 +189,7 @@
     assertVotes(c, user, -2, 0);
 
     // check that votes are not sticky when rework is done by cherry-pick
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     changeId = createChange().getChangeId();
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -262,7 +261,7 @@
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, 2, 1);
@@ -370,7 +369,7 @@
 
   private void assertNotSticky(Set<ChangeKind> changeKinds) throws Exception {
     for (ChangeKind changeKind : changeKinds) {
-      testRepo.reset(getRemoteHead());
+      testRepo.reset(projectOperations.project(project).getHead("master"));
 
       String changeId = createChange(changeKind);
       vote(admin, changeId, +2, 1);
@@ -461,7 +460,7 @@
 
   private void trivialRebase(String changeId) throws Exception {
     requestScopeOperations.setApiUser(admin.id());
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(),
@@ -527,7 +526,7 @@
         assert_().fail("unexpected change kind: " + changeKind);
     }
 
-    testRepo.reset(getRemoteHead());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
     PushOneCommit.Result r =
         pushFactory
             .create(
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
index 321b015..ab1dbd3 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
@@ -16,12 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -52,6 +54,7 @@
 public class GroupsConsistencyIT extends AbstractDaemonTest {
 
   @Inject protected GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   private GroupInfo gAdmin;
   private GroupInfo g1;
   private GroupInfo g2;
@@ -60,7 +63,10 @@
 
   @Before
   public void basicSetup() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     String name1 = groupOperations.newGroup().name("g1").create().get();
     String name2 = groupOperations.newGroup().name("g2").create().get();
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 06fe2f4..73e3982 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -22,6 +22,9 @@
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.api.group.GroupAssert.assertGroupInfo;
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfos;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -44,6 +47,7 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -86,8 +90,6 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
@@ -133,6 +135,7 @@
   @Inject private Groups groups;
   @Inject private GroupsConsistencyChecker consistencyChecker;
   @Inject private PeriodicGroupIndexer slaveGroupIndexer;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private Sequences seq;
   @Inject private StalenessChecker stalenessChecker;
@@ -1044,7 +1047,10 @@
   @Test
   public void pushToGroupNamesBranchIsRejectedForAllUsersRepo() throws Exception {
     // refs/meta/group-names isn't usually available for fetch, so grant ACCESS_DATABASE
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     assertPushToGroupBranch(allUsers, RefNames.REFS_GROUPNAMES, "group update not allowed");
   }
 
@@ -1074,15 +1080,18 @@
 
   private void assertPushToGroupBranch(
       Project.NameKey project, String groupRefName, String expectedErrorOnUpdate) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.CREATE, REGISTERED_USERS, RefNames.REFS_GROUPS + "*");
-      Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, RefNames.REFS_GROUPS + "*");
-      Util.allow(cfg, Permission.CREATE, REGISTERED_USERS, RefNames.REFS_DELETED_GROUPS + "*");
-      Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, RefNames.REFS_DELETED_GROUPS + "*");
-      Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, RefNames.REFS_GROUPNAMES);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(
+            allow(Permission.CREATE)
+                .ref(RefNames.REFS_DELETED_GROUPS + "*")
+                .group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_DELETED_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPNAMES).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(project);
 
@@ -1101,12 +1110,12 @@
   }
 
   private void assertCreateGroupBranch(Project.NameKey project) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.CREATE, REGISTERED_USERS, RefNames.REFS_GROUPS + "*");
-      Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, RefNames.REFS_GROUPS + "*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
     TestRepository<InMemoryRepository> repo = cloneProject(project);
     PushOneCommit.Result r =
         pushFactory
@@ -1187,15 +1196,22 @@
       }
 
       // refs/meta/group-names is only visible with ACCESS_DATABASE
-      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+      projectOperations
+          .allProjectsForUpdate()
+          .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+          .update();
 
       testCannotCreateGroupBranch(RefNames.REFS_GROUPNAMES, RefNames.REFS_GROUPNAMES);
     }
   }
 
   private void testCannotCreateGroupBranch(String refPattern, String groupRef) throws Exception {
-    grant(allUsers, refPattern, Permission.CREATE);
-    grant(allUsers, refPattern, Permission.PUSH);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(refPattern).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(refPattern).group(adminGroupUuid()))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(groupRef);
@@ -1222,13 +1238,20 @@
   @Test
   public void cannotDeleteGroupNamesBranch() throws Exception {
     // refs/meta/group-names is only visible with ACCESS_DATABASE
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     testCannotDeleteGroupBranch(RefNames.REFS_GROUPNAMES, RefNames.REFS_GROUPNAMES);
   }
 
   private void testCannotDeleteGroupBranch(String refPattern, String groupRef) throws Exception {
-    grant(allUsers, refPattern, Permission.DELETE, true, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref(refPattern).group(REGISTERED_USERS).force(true))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     PushResult r = deleteRef(allUsersRepo, groupRef);
@@ -1411,8 +1434,16 @@
 
   private void pushToGroupBranchForReviewAndSubmit(
       Project.NameKey project, String groupRef, String expectedError) throws Throwable {
-    grantLabel("Code-Review", -2, 2, project, RefNames.REFS_GROUPS + "*", REGISTERED_USERS, false);
-    grant(project, RefNames.REFS_GROUPS + "*", Permission.SUBMIT, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Code-Review")
+                .ref(RefNames.REFS_GROUPS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(project);
     fetch(repo, groupRef + ":groupRef");
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 6d59d7d..3fcc595 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -16,6 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
@@ -34,8 +37,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -64,29 +65,32 @@
     privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
     groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id()).update();
 
-    try (ProjectConfigUpdate u = updateProject(secretProject)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.READ, privilegedGroupUuid, "refs/*");
-      Util.block(cfg, Permission.READ, SystemGroupBackend.REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(secretProject)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(privilegedGroupUuid))
+        .add(block(Permission.READ).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
 
-    try (ProjectConfigUpdate u = updateProject(secretRefProject)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.deny(cfg, Permission.READ, SystemGroupBackend.ANONYMOUS_USERS, "refs/*");
-      Util.allow(cfg, Permission.READ, privilegedGroupUuid, "refs/heads/secret/*");
-      Util.block(cfg, Permission.READ, SystemGroupBackend.REGISTERED_USERS, "refs/heads/secret/*");
-      Util.allow(cfg, Permission.READ, SystemGroupBackend.REGISTERED_USERS, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(secretRefProject)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/secret/*").group(privilegedGroupUuid))
+        .add(
+            block(Permission.READ)
+                .ref("refs/heads/secret/*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
 
     // Ref permission
-    try (ProjectConfigUpdate u = updateProject(normalProject)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.VIEW_PRIVATE_CHANGES, privilegedGroupUuid, "refs/*");
-      Util.allow(cfg, Permission.FORGE_SERVER, privilegedGroupUuid, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(normalProject)
+        .forUpdate()
+        .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(privilegedGroupUuid))
+        .add(allow(Permission.FORGE_SERVER).ref("refs/*").group(privilegedGroupUuid))
+        .update();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
index 54aa192..749176b8f 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
@@ -15,21 +15,26 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 @NoHttpd
 public class CommitIncludedInIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void includedInOpenChange() throws Exception {
     Result result = createChange();
@@ -49,7 +54,11 @@
     assertThat(getIncludedIn(result.getCommit().getId()).branches).containsExactly("master");
     assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
 
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .update();
     gApi.projects().name(result.getChange().project().get()).tag("test-tag").create(new TagInput());
 
     assertThat(getIncludedIn(result.getCommit().getId()).tags).containsExactly("test-tag");
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index 4a5ad6a..7ff6207 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
@@ -22,6 +23,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
@@ -32,6 +34,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.restapi.project.DashboardsCollection;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -41,9 +44,15 @@
 
 @NoHttpd
 public class DashboardIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   @Before
   public void setup() throws Exception {
-    allow("refs/meta/dashboards/*", Permission.CREATE, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/meta/dashboards/*").group(REGISTERED_USERS))
+        .update();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index a516468..dce0ab2 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_GLOBAL;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_PARENT;
@@ -238,7 +240,11 @@
 
   @Test
   public void createAndDeleteBranchByPush() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     projectIndexedCounter.clear();
 
     assertThat(hasHead(project, "foo")).isFalse();
@@ -256,14 +262,14 @@
 
   @Test
   public void descriptionChangeCausesRefUpdate() throws Exception {
-    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit initialHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
     assertThat(gApi.projects().name(project.get()).description()).isEmpty();
     DescriptionInput in = new DescriptionInput();
     in.description = "new project description";
     gApi.projects().name(project.get()).description(in);
     assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
 
-    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
         project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
   }
@@ -282,7 +288,7 @@
 
   @Test
   public void configChangeCausesRefUpdate() throws Exception {
-    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit initialHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
 
     ConfigInfo info = gApi.projects().name(project.get()).config();
     assertThat(info.defaultSubmitType.value).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
@@ -293,7 +299,7 @@
     info = gApi.projects().name(project.get()).config();
     assertThat(info.defaultSubmitType.value).isEqualTo(SubmitType.CHERRY_PICK);
 
-    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
         project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
   }
@@ -425,8 +431,18 @@
     assertThat(gApi.projects().name(project.get()).config().state).isEqualTo(ProjectState.HIDDEN);
 
     // Revoke OWNER permission for admin and block them from reading the project's refs
-    block(project, RefNames.REFS + "*", Permission.OWNER, SystemGroupBackend.REGISTERED_USERS);
-    block(project, RefNames.REFS + "*", Permission.READ, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            block(Permission.OWNER)
+                .ref(RefNames.REFS + "*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .add(
+            block(Permission.READ)
+                .ref(RefNames.REFS + "*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
 
     // HIDDEN => ACTIVE
     ConfigInput ci2 = new ConfigInput();
@@ -725,7 +741,7 @@
 
   @Nullable
   protected RevCommit getRemoteHead(String project, String branch) throws Exception {
-    return getRemoteHead(Project.nameKey(project), branch);
+    return projectOperations.project(Project.nameKey(project)).getHead(branch);
   }
 
   boolean hasHead(Project.NameKey k, String b) {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index b2da402..82eef1d 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -35,7 +36,6 @@
 
 @NoHttpd
 public class SetParentIT extends AbstractDaemonTest {
-
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -74,7 +74,11 @@
   public void setParentAllowedForOwners() throws Exception {
     String parent = projectOperations.newProject().create().get();
     requestScopeOperations.setApiUser(user.id());
-    grant(project, "refs/*", Permission.OWNER, false, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     gApi.projects().name(project.get()).parent(parent);
     assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 5a1fe48..677fece 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
@@ -43,6 +44,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -115,10 +117,10 @@
 import org.junit.Test;
 
 public class RevisionIT extends AbstractDaemonTest {
-
   @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
   @Inject private DynamicSet<PatchSetWebLink> patchSetLinks;
   @Inject private GetRevisionActions getRevisionActions;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -562,7 +564,7 @@
     ByteArrayOutputStream os = new ByteArrayOutputStream();
     bin.writeTo(os);
     String fileContent = new String(os.toByteArray(), UTF_8);
-    String destSha1 = abbreviateName(getRemoteHead(project, destBranch), 6);
+    String destSha1 = abbreviateName(projectOperations.project(project).getHead(destBranch), 6);
     String changeSha1 = abbreviateName(r.getCommit(), 6);
     assertThat(fileContent)
         .isEqualTo(
@@ -1183,7 +1185,11 @@
   public void setDescriptionAllowedWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     assertDescription(r, "");
-    grant(project, "refs/heads/master", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
     assertDescription(r, "test");
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 28d94d3..070b98c 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
@@ -58,7 +59,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.restapi.change.ChangeEdits.EditMessage;
 import com.google.gerrit.server.restapi.change.ChangeEdits.Post;
 import com.google.gerrit.server.restapi.change.ChangeEdits.Put;
@@ -600,7 +601,7 @@
   public void editCommitMessageCopiesLabelScores() throws Exception {
     String cr = "Code-Review";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = Util.codeReview();
+      LabelType codeReview = TestLabels.codeReview();
       codeReview.setCopyAllScoresIfNoCodeChange(true);
       u.getConfig().getLabelSections().put(cr, codeReview);
       u.save();
@@ -693,7 +694,11 @@
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
 
     // Create change as user
     PushOneCommit push = pushFactory.create(user.newIdent(), userTestRepo);
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index bfbe3a3..0714cca 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -24,6 +24,10 @@
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.GitUtil.pushOne;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
@@ -34,8 +38,8 @@
 import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static java.util.Comparator.comparing;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.joining;
@@ -54,6 +58,7 @@
 import com.google.gerrit.acceptance.SkipProjectClone;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelType;
@@ -93,7 +98,7 @@
 import com.google.gerrit.server.git.receive.ReceiveConstants;
 import com.google.gerrit.server.git.validators.CommitValidators.ChangeIdValidator;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestTimeUtil;
@@ -131,11 +136,17 @@
 @SkipProjectClone
 public abstract class AbstractPushForReview extends AbstractDaemonTest {
   protected enum Protocol {
-    // TODO(dborowitz): TEST.
+    // Only test protocols which are actually served by the Gerrit server, since each separate test
+    // class is large and slow.
+    //
+    // This list excludes the test InProcessProtocol, which is used by large numbers of other
+    // acceptance tests. Small tests of InProcessProtocol are still possible, without incurring a
+    // new large slow test.
     SSH,
     HTTP
   }
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   private static String NEW_CHANGE_INDICATOR = " [NEW]";
@@ -154,19 +165,24 @@
   @Before
   public void setUpPatchSetLock() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      patchSetLock = Util.patchSetLock();
+      patchSetLock = TestLabels.patchSetLock();
       u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
-      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(patchSetLock.getName()),
-          0,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
       u.save();
     }
-    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(patchSetLock.getName())
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .range(0, 1))
+        .add(
+            allowLabel(patchSetLock.getName())
+                .ref("refs/heads/*")
+                .group(adminGroupUuid())
+                .range(0, 1))
+        .update();
   }
 
   @After
@@ -871,8 +887,14 @@
 
     // Non owner, non admin and non project owner cannot flip wip bit:
     TestAccount user2 = accountCreator.user2();
-    grant(
-        project, "refs/*", Permission.FORGE_COMMITTER, false, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.FORGE_COMMITTER)
+                .ref("refs/*")
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     TestRepository<?> user2Repo = cloneProject(project, user2);
     GitUtil.fetch(user2Repo, r.getPatchSet().refName() + ":ps");
     user2Repo.reset("ps");
@@ -880,7 +902,11 @@
     r.assertErrorStatus(ReceiveConstants.ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
 
     // Project owner trying to move from WIP to ready should succeed.
-    allow("refs/*", Permission.OWNER, SystemGroupBackend.REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
     r.assertOkStatus();
   }
@@ -1181,14 +1207,17 @@
   @Test
   public void pushWithMultipleApprovals() throws Exception {
     LabelType Q =
-        category("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+        label("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.forLabel("Custom-Label"), -1, 1, anon, heads);
       u.getConfig().getLabelSections().put(Q.getName(), Q);
       u.save();
     }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Custom-Label").ref(heads).group(ANONYMOUS_USERS).range(-1, 1))
+        .update();
 
     RevCommit c =
         commitBuilder()
@@ -1349,7 +1378,11 @@
     r.assertOkStatus();
 
     setUseSignedOffBy(InheritableBoolean.TRUE);
-    block(project, "refs/heads/master", Permission.FORGE_COMMITTER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     push =
         pushFactory.create(
@@ -1419,7 +1452,11 @@
 
   @Test
   public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result rBase = pushTo("refs/heads/master");
     rBase.assertOkStatus();
 
@@ -1833,7 +1870,11 @@
 
   @Test
   public void forcePushAbandonedChange() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     PushOneCommit push1 =
         pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r = push1.to("refs/for/master");
@@ -1906,7 +1947,7 @@
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = Util.codeReview();
+      LabelType codeReview = TestLabels.codeReview();
       codeReview.setCopyMaxScore(true);
       u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
       u.save();
@@ -1929,7 +1970,11 @@
   @Test
   public void createChangeForMergedCommit() throws Exception {
     String master = "refs/heads/master";
-    grant(project, master, Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(adminGroupUuid()).force(true))
+        .update();
 
     // Update master with a direct push.
     RevCommit c1 = testRepo.commit().message("Non-change 1").create();
@@ -2028,7 +2073,11 @@
   @Test
   public void mergedOptionWithExistingChangeInsertsPatchSet() throws Exception {
     String master = "refs/heads/master";
-    grant(project, master, Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(adminGroupUuid()).force(true))
+        .update();
 
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
@@ -2297,12 +2346,19 @@
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
     assertPushRejected(pr, ref, "NoteDb update requires access database permission");
 
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
     assertPushRejected(pr, ref, "prohibited by Gerrit: not permitted: create");
 
-    grant(project, "refs/changes/*", Permission.CREATE);
-    grant(project, "refs/changes/*", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/changes/*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref("refs/changes/*").group(adminGroupUuid()))
+        .update();
     grantSkipValidation(project, "refs/changes/*", REGISTERED_USERS);
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
     assertPushOk(pr, ref);
@@ -2668,13 +2724,14 @@
   private void grantSkipValidation(Project.NameKey project, String ref, AccountGroup.UUID groupUuid)
       throws Exception {
     // See SKIP_VALIDATION implementation in default permission backend.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.FORGE_AUTHOR, groupUuid, ref);
-      Util.allow(u.getConfig(), Permission.FORGE_COMMITTER, groupUuid, ref);
-      Util.allow(u.getConfig(), Permission.FORGE_SERVER, groupUuid, ref);
-      Util.allow(u.getConfig(), Permission.PUSH_MERGE, groupUuid, "refs/for/" + ref);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref(ref).group(groupUuid))
+        .add(allow(Permission.FORGE_COMMITTER).ref(ref).group(groupUuid))
+        .add(allow(Permission.FORGE_SERVER).ref(ref).group(groupUuid))
+        .add(allow(Permission.PUSH_MERGE).ref("refs/for/" + ref).group(groupUuid))
+        .update();
   }
 
   private PushOneCommit.Result amendChange(String changeId, String ref) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index fc2e5cb..c35b891 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
@@ -59,11 +60,12 @@
 
 public abstract class AbstractSubmoduleSubscription extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
   protected TestRepository<?> superRepo;
   protected Project.NameKey superKey;
   protected TestRepository<?> subRepo;
   protected Project.NameKey subKey;
-  @Inject protected ProjectOperations projectOperations;
 
   protected SubmitType getSubmitType() {
     return cfg.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
@@ -105,8 +107,12 @@
   }
 
   protected void grantPush(Project.NameKey project) throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH);
-    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
   }
 
   protected Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
index b895ddf..5ec7b0e 100644
--- a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
@@ -23,8 +24,10 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.PushResult;
@@ -33,6 +36,7 @@
 
 @NoHttpd
 public class ForcePushIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void forcePushNotAllowed() throws Exception {
@@ -57,7 +61,11 @@
   @Test
   public void forcePushAllowed() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     PushOneCommit push1 =
         pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
@@ -82,19 +90,31 @@
 
   @Test
   public void deleteNotAllowedWithOnlyPushPermission() throws Exception {
-    grant(project, "refs/*", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()))
+        .update();
     assertDeleteRef(REJECTED_OTHER_REASON);
   }
 
   @Test
   public void deleteAllowedWithForcePushPermission() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     assertDeleteRef(OK);
   }
 
   @Test
   public void deleteAllowedWithDeletePermission() throws Exception {
-    grant(project, "refs/*", Permission.DELETE, true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
     assertDeleteRef(OK);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index f682342..dd12cd3 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.git.testing.PushResultSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
@@ -24,6 +25,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -36,7 +38,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import java.util.function.Consumer;
@@ -54,6 +55,7 @@
 import org.junit.Test;
 
 public class PushPermissionsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
@@ -73,13 +75,15 @@
           Permission.PUSH_MERGE,
           Permission.SUBMIT);
       removeAllGlobalCapabilities(cfg, GlobalCapability.ADMINISTRATE_SERVER);
-
-      // Include some auxiliary permissions.
-      Util.allow(cfg, Permission.FORGE_AUTHOR, REGISTERED_USERS, "refs/*");
-      Util.allow(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, "refs/*");
-
       u.save();
     }
+
+    // Include some auxiliary permissions.
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
   @Test
@@ -202,7 +206,11 @@
 
   @Test
   public void refsMetaConfigUpdateRequiresProjectOwner() throws Exception {
-    grant(project, "refs/meta/config", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
 
     forceFetch("refs/meta/config");
     ObjectId commit = testRepo.branch("refs/meta/config").commit().create();
@@ -222,7 +230,11 @@
             "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
 
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Re-fetch refs/meta/config from the server because the grant changed it, and we want a
     // fast-forward.
@@ -249,7 +261,11 @@
 
   @Test
   public void updateBySubmitDenied() throws Exception {
-    grant(project, "refs/for/refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/for/refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
     ObjectId commit = testRepo.branch("HEAD").commit().create();
     assertThat(push("HEAD:refs/for/master")).onlyRef("refs/for/master").isOk();
@@ -267,7 +283,11 @@
 
   @Test
   public void addPatchSetDenied() throws Exception {
-    grant(project, "refs/for/refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/for/refs/heads/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     ChangeInput ci = new ChangeInput();
     ci.project = project.get();
@@ -288,7 +308,11 @@
 
   @Test
   public void skipValidationDenied() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
     testRepo.branch("HEAD").commit().create();
     PushResult r =
@@ -305,7 +329,11 @@
 
   @Test
   public void accessDatabaseForNoteDbDenied() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
     testRepo.branch("HEAD").commit().create();
     PushResult r =
@@ -322,8 +350,12 @@
 
   @Test
   public void administrateServerForUpdateParentDenied() throws Exception {
-    grant(project, "refs/meta/config", Permission.PUSH, false, REGISTERED_USERS);
-    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     String project2 = name("project2");
     gApi.projects().create(project2);
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 4e37a7c..1c4547b 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -18,6 +18,10 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
@@ -29,6 +33,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
@@ -50,7 +55,6 @@
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -78,6 +82,7 @@
   @Inject private AllUsersName allUsersName;
   @Inject private ChangeNoteUtil noteUtil;
   @Inject private PermissionBackend permissionBackend;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   private AccountGroup.UUID admins;
@@ -121,9 +126,12 @@
       for (AccessSection sec : u.getConfig().getAccessSections()) {
         sec.removePermission(Permission.READ);
       }
-      Util.allow(u.getConfig(), Permission.READ, admins, "refs/*");
       u.save();
     }
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(admins))
+        .update();
 
     // Remove all read permissions on All-Users.
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
@@ -139,7 +147,11 @@
 
     // First 2 changes are merged, which means the tags pointing to them are
     // visible.
-    allow("refs/for/refs/heads/*", Permission.SUBMIT, admins);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(admins))
+        .update();
     PushOneCommit.Result mr =
         pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%submit");
     mr.assertOkStatus();
@@ -182,12 +194,13 @@
 
   @Test
   public void uploadPackAllRefsVisibleNoRefsMetaConfig() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.READ, admins, RefNames.REFS_CONFIG);
-      Util.doNotInherit(u.getConfig(), Permission.READ, RefNames.REFS_CONFIG);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(admins))
+        .setExclusiveGroup(permissionKey(Permission.READ).ref(RefNames.REFS_CONFIG), true)
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
@@ -208,8 +221,12 @@
 
   @Test
   public void uploadPackAllRefsVisibleWithRefsMetaConfig() throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
-    allow(RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
 
     assertUploadPackRefs(
         "HEAD",
@@ -230,8 +247,12 @@
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleIncludingHead() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(deny(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
@@ -240,8 +261,12 @@
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleNotIncludingHead() throws Exception {
-    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
@@ -258,7 +283,11 @@
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     // Admin's edit is not visible.
     requestScopeOperations.setApiUser(admin.id());
@@ -281,8 +310,12 @@
 
   @Test
   public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Admin's edit on change3 is visible.
     requestScopeOperations.setApiUser(admin.id());
@@ -309,9 +342,16 @@
 
   @Test
   public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(cd3.getId().get()).edit().create();
@@ -348,7 +388,11 @@
   }
 
   private void uploadPackNoSearchingChangeCacheImpl() throws Exception {
-    allow("refs/heads/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     assertRefs(
@@ -375,13 +419,20 @@
   public void uploadPackSequencesWithAccessDatabase() throws Exception {
     assertRefs(allProjects, user, true);
 
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     assertRefs(allProjects, user, true, "refs/sequences/changes");
   }
 
   @Test
   public void uploadPackAllRefsAreVisibleOrphanedTag() throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     // Delete the pending change on 'branch' and 'branch' itself so that the tag gets orphaned
     gApi.changes().id(cd4.getId().get()).delete();
     gApi.projects().name(project.get()).branch("refs/heads/branch").delete();
@@ -418,8 +469,12 @@
 
   @Test
   public void receivePackRespectsVisibilityOfOpenChanges() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(deny(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
 
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(cd3, 1));
@@ -482,7 +537,11 @@
 
   @Test
   public void advertisedReferencesOmitUserBranchesOfOtherUsers() throws Exception {
-    allow(allUsersName, RefNames.REFS_USERS + "*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .update();
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
       assertThat(getUserRefs(git))
@@ -492,7 +551,10 @@
 
   @Test
   public void advertisedReferencesIncludeAllUserBranchesWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
       assertThat(getUserRefs(git))
@@ -514,7 +576,11 @@
 
   @Test
   public void advertisedReferencesOmitGroupBranchesOfNonOwnedGroups() throws Exception {
-    allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
     AccountGroup.UUID users = createGroup("Users", admins, user);
     AccountGroup.UUID foos = createGroup("Foos", users);
     AccountGroup.UUID bars = createSelfOwnedGroup("Bars", user);
@@ -527,7 +593,10 @@
 
   @Test
   public void advertisedReferencesIncludeAllGroupBranchesWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     AccountGroup.UUID users = createGroup("Users", admins);
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
@@ -541,8 +610,15 @@
 
   @Test
   public void advertisedReferencesIncludeAllGroupBranchesForAdmins() throws Exception {
-    allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ADMINISTRATE_SERVER);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
     AccountGroup.UUID users = createGroup("Users", admins);
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
@@ -556,7 +632,11 @@
 
   @Test
   public void advertisedReferencesOmitNoteDbNotesBranches() throws Exception {
-    allow(allUsersName, RefNames.REFS + "*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
     try (Git git = userTestRepository.git()) {
       assertThat(getRefs(git)).containsNoneOf(RefNames.REFS_EXTERNAL_IDS, RefNames.REFS_GROUPNAMES);
@@ -565,7 +645,11 @@
 
   @Test
   public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
@@ -582,7 +666,11 @@
     assume()
         .that(baseConfig.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true))
         .isTrue();
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
@@ -598,7 +686,11 @@
   @GerritConfig(name = "auth.skipFullRefEvaluationIfAllRefsAreVisible", value = "false")
   public void advertisedReferencesOmitPrivateChangesOfOtherUsersWhenShortcutDisabled()
       throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
@@ -612,8 +704,16 @@
 
   @Test
   public void advertisedReferencesOmitDraftCommentRefsOfOtherUsers() throws Exception {
-    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
-    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     DraftInput draftInput = new DraftInput();
@@ -632,8 +732,16 @@
 
   @Test
   public void advertisedReferencesOmitStarredChangesRefsOfOtherUsers() throws Exception {
-    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
-    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     gApi.accounts().self().starChange(cd3.getId().toString());
@@ -648,7 +756,10 @@
 
   @Test
   public void hideMetadata() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     // create change
     TestRepository<?> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userRef");
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index d783c7c..02f7b0a 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -17,12 +17,14 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -47,10 +49,15 @@
 @NoHttpd
 public class SubmitOnPushIT extends AbstractDaemonTest {
   @Inject private ApprovalsUtil approvalsUtil;
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void submitOnPush() throws Exception {
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r = pushTo("refs/for/master%submit");
     r.assertOkStatus();
     r.assertChange(Change.Status.MERGED, null, admin);
@@ -60,7 +67,11 @@
 
   @Test
   public void submitOnPushToRefsMetaConfig() throws Exception {
-    grant(project, "refs/for/refs/meta/config", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/meta/config").group(adminGroupUuid()))
+        .update();
 
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
     testRepo.reset(RefNames.REFS_CONFIG);
@@ -78,7 +89,11 @@
     push("refs/heads/master", "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "a.txt", "other content");
     r.assertErrorStatus();
@@ -94,7 +109,11 @@
     push(master, "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "b.txt", "other content");
     r.assertOkStatus();
@@ -107,7 +126,11 @@
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
 
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+        .update();
     r =
         push(
             "refs/for/master%submit",
@@ -147,7 +170,11 @@
 
   @Test
   public void mergeOnPushToBranch() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
     r.assertOkStatus();
@@ -172,10 +199,14 @@
     enableCreateNewChangeForAllNotInTarget();
     String master = "refs/heads/master";
     String other = "refs/heads/other";
-    grant(project, master, Permission.PUSH);
-    grant(project, other, Permission.CREATE);
-    grant(project, other, Permission.PUSH);
-    RevCommit masterRev = getRemoteHead();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(adminGroupUuid()))
+        .add(allow(Permission.CREATE).ref(other).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(other).group(adminGroupUuid()))
+        .update();
+    RevCommit masterRev = projectOperations.project(project).getHead("master");
     pushCommitTo(masterRev, other);
     PushOneCommit.Result r = createChange();
     r.assertOkStatus();
@@ -209,7 +240,11 @@
 
   @Test
   public void mergeOnPushToBranchWithNewPatchset() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -243,7 +278,11 @@
 
   @Test
   public void mergeOnPushToBranchWithOldPatchset() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -270,10 +309,14 @@
 
   @Test
   public void mergeMultipleOnPushToBranchWithNewPatchset() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
 
     // Create 2 changes.
-    ObjectId initialHead = getRemoteHead();
+    ObjectId initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result r1 = createChange("Change 1", "a", "a");
     r1.assertOkStatus();
     PushOneCommit.Result r2 = createChange("Change 2", "b", "b");
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 2f551a5..afd41dc 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -22,9 +22,11 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -45,6 +47,8 @@
     return submitWholeTopicEnabledConfig();
   }
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
   public void testSubscriptionWithoutGlobalServerSetting() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 9ebc3de..eef6e33 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -17,11 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -31,6 +33,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
 import java.util.ArrayDeque;
 import java.util.Map;
 import org.apache.commons.lang.RandomStringUtils;
@@ -70,6 +73,8 @@
     return submitByRebaseIfNecessaryConfig();
   }
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void subscriptionUpdateOfManyChanges() throws Exception {
     allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
@@ -285,8 +290,12 @@
               .name(prefix + "sub" + i)
               .submitType(getSubmitType())
               .create();
-      grant(subKey[i], "refs/heads/*", Permission.PUSH);
-      grant(subKey[i], "refs/for/refs/heads/*", Permission.SUBMIT);
+      projectOperations
+          .project(subKey[i])
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+          .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+          .update();
       sub[i] = cloneProject(subKey[i]);
     }
 
@@ -396,7 +405,7 @@
     gApi.changes().id(subChangeId).current().submit();
 
     expectToHaveSubmoduleState(superRepo, "master", subKey, subRepo, "master");
-    RevCommit superHead = getRemoteHead(superKey, "master");
+    RevCommit superHead = projectOperations.project(superKey).getHead("master");
     assertThat(superHead.getShortMessage()).contains("some message");
     assertThat(superHead.getId()).isNotEqualTo(superId);
   }
@@ -430,7 +439,7 @@
 
     gApi.changes().id(subChangeId).current().submit();
 
-    RevCommit superHead = getRemoteHead(superKey, "master");
+    RevCommit superHead = projectOperations.project(superKey).getHead("master");
     assertThat(superHead.getShortMessage()).isEqualTo("some message");
     assertThat(superHead.getId()).isEqualTo(superId);
   }
@@ -746,13 +755,13 @@
     approve(getChangeId(repoB, bDevHead).get());
 
     gApi.changes().id(getChangeId(repoA, aDevHead).get()).current().submit();
-    assertThat(getRemoteHead(keyA, "refs/heads/master").getShortMessage())
+    assertThat(projectOperations.project(keyA).getHead("refs/heads/master").getShortMessage())
         .contains("some message in a master.txt");
-    assertThat(getRemoteHead(keyA, "refs/heads/dev").getShortMessage())
+    assertThat(projectOperations.project(keyA).getHead("refs/heads/dev").getShortMessage())
         .contains("some message in a dev.txt");
-    assertThat(getRemoteHead(keyB, "refs/heads/master").getShortMessage())
+    assertThat(projectOperations.project(keyB).getHead("refs/heads/master").getShortMessage())
         .contains("some message in b master.txt");
-    assertThat(getRemoteHead(keyB, "refs/heads/dev").getShortMessage())
+    assertThat(projectOperations.project(keyB).getHead("refs/heads/dev").getShortMessage())
         .contains("some message in b dev.txt");
   }
 
@@ -845,13 +854,13 @@
 
     sub1.git().fetch().call();
     RevWalk rw1 = sub1.getRevWalk();
-    RevCommit master1 = rw1.parseCommit(getRemoteHead(subKey1, "master"));
+    RevCommit master1 = rw1.parseCommit(projectOperations.project(subKey1).getHead("master"));
     RevCommit change1Ps = parseCurrentRevision(rw1, changeId1);
     assertThat(rw1.isMergedInto(change1Ps, master1)).isTrue();
 
     sub2.git().fetch().call();
     RevWalk rw2 = sub2.getRevWalk();
-    RevCommit master2 = rw2.parseCommit(getRemoteHead(subKey2, "master"));
+    RevCommit master2 = rw2.parseCommit(projectOperations.project(subKey2).getHead("master"));
     RevCommit change2Ps = parseCurrentRevision(rw2, changeId2);
     assertThat(rw2.isMergedInto(change2Ps, master2)).isTrue();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
index e7ce43f..be4fde0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.acceptance.rest.account;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.common.data.GlobalCapability.ACCESS_DATABASE;
 import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
@@ -26,24 +29,30 @@
 import static com.google.gerrit.common.data.GlobalCapability.RUN_AS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
-import com.google.common.collect.Iterables;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 public class CapabilitiesIT extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void capabilitiesUser() throws Exception {
-    Iterable<String> all =
-        Iterables.filter(
-            GlobalCapability.getAllNames(),
-            c -> !ADMINISTRATE_SERVER.equals(c) && !PRIORITY.equals(c));
-
-    allowGlobalCapabilities(REGISTERED_USERS, all);
+    ImmutableList<String> all =
+        GlobalCapability.getAllNames().stream()
+            .filter(c -> !ADMINISTRATE_SERVER.equals(c) && !PRIORITY.equals(c))
+            .collect(toImmutableList());
+    TestProjectUpdate.Builder allowBuilder = projectOperations.allProjectsForUpdate();
+    all.forEach(c -> allowBuilder.add(allowCapability(c).group(REGISTERED_USERS)));
+    allowBuilder.update();
     try {
       RestResponse r = userRestSession.get("/accounts/self/capabilities");
       r.assertOK();
@@ -67,7 +76,9 @@
         }
       }
     } finally {
-      removeGlobalCapabilities(REGISTERED_USERS, all);
+      TestProjectUpdate.Builder removeBuilder = projectOperations.allProjectsForUpdate();
+      all.forEach(c -> removeBuilder.remove(capabilityKey(c).group(REGISTERED_USERS)));
+      removeBuilder.update();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 6093063..ba3c7ea 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -18,6 +18,8 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
@@ -34,6 +36,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
@@ -92,6 +95,7 @@
   @Inject private ExternalIds externalIds;
   @Inject private ExternalIdReader externalIdReader;
   @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -121,7 +125,10 @@
 
   @Test
   public void getExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     Collection<ExternalId> expectedIds = getAccountState(admin.id()).getExternalIds();
     List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
@@ -195,7 +202,10 @@
 
   @Test
   public void deleteExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
 
@@ -268,7 +278,10 @@
         .hasMessageThat()
         .isEqualTo("Remote does not have " + RefNames.REFS_EXTERNAL_IDS + " available for fetch.");
 
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     // re-clone to get new request context, otherwise the old global capabilities are still cached
     // in the IdentifiedUser object
@@ -278,7 +291,10 @@
 
   @Test
   public void pushToExternalIdsBranch() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -303,7 +319,10 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithoutAccountId() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -321,7 +340,10 @@
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithKeyThatDoesntMatchTheNoteId()
       throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -338,7 +360,10 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithInvalidConfig() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -355,7 +380,10 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithEmptyNote() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -395,7 +423,10 @@
 
   private void testPushToExternalIdsBranchRejectsInvalidExternalId(ExternalId invalidExtId)
       throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
@@ -411,7 +442,10 @@
 
   @Test
   public void readExternalIdsWhenInvalidExternalIdsExist() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.resetCurrentApiUser();
 
     insertValidExternalIds();
@@ -432,7 +466,10 @@
 
   @Test
   public void checkConsistency() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.resetCurrentApiUser();
 
     insertValidExternalIds();
@@ -984,8 +1021,12 @@
   }
 
   private void allowPushOfExternalIds() {
-    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.READ);
-    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.PUSH);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_EXTERNAL_IDS).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_EXTERNAL_IDS).group(adminGroupUuid()))
+        .update();
   }
 
   private void assertRefUpdateFailure(RemoteRefUpdate update, String msg) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index a27a6a9..1c342ee 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -15,6 +15,11 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -29,6 +34,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelType;
@@ -60,7 +66,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.AccountControl;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import org.apache.http.Header;
@@ -74,6 +80,7 @@
   @Inject private ApprovalsUtil approvalsUtil;
   @Inject private ChangeMessagesUtil cmUtil;
   @Inject private CommentsUtil commentsUtil;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   private RestSession anonRestSession;
@@ -166,7 +173,7 @@
   @Test
   public void voteOnBehalfOfLabelNotPermitted() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType verified = Util.verified();
+      LabelType verified = TestLabels.verified();
       u.getConfig().getLabelSections().put(verified.getName(), verified);
       u.save();
     }
@@ -567,53 +574,53 @@
   }
 
   private void allowCodeReviewOnBehalfOf() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReviewType = Util.codeReview();
-      String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
-      String heads = "refs/heads/*";
-      AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(u.getConfig(), forCodeReviewAs, -1, 1, uuid, heads);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .impersonation(true)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
   }
 
   private void allowSubmitOnBehalfOf() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      String heads = "refs/heads/*";
-      AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(u.getConfig(), Permission.SUBMIT_AS, uuid, heads);
-      Util.allow(u.getConfig(), Permission.SUBMIT, uuid, heads);
-      LabelType codeReviewType = Util.codeReview();
-      Util.allow(u.getConfig(), Permission.forLabel(codeReviewType.getName()), -2, 2, uuid, heads);
-      u.save();
-    }
+    String heads = "refs/heads/*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT_AS).ref(heads).group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(heads).group(REGISTERED_USERS))
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(heads)
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
   }
 
   private void blockRead(GroupInfo group) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.READ, AccountGroup.uuid(group.id), "refs/heads/master");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(AccountGroup.uuid(group.id)))
+        .update();
   }
 
   private void allowRunAs() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      Util.allow(
-          u.getConfig(),
-          GlobalCapability.RUN_AS,
-          systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-      u.save();
-    }
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.RUN_AS).group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void removeRunAs() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      Util.remove(
-          u.getConfig(),
-          GlobalCapability.RUN_AS,
-          systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-      u.save();
-    }
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(capabilityKey(GlobalCapability.RUN_AS).group(ANONYMOUS_USERS))
+        .update();
   }
 
   private static Header runAsHeader(Object user) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
index 02c44ef..cef599f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.binding;
 
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
@@ -22,10 +23,12 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
 import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
 import java.util.List;
 import java.util.Optional;
 import org.junit.Test;
@@ -80,10 +83,15 @@
           // Task deletion must be tested last
           RestCall.delete("/config/server/tasks/%s"));
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void configEndpoints() throws Exception {
     // 'Access Database' is needed for the '/config/server/check.consistency' REST endpoint
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
 
     RestApiCallHelper.execute(adminRestSession, CONFIG_ENDPOINTS);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index c838cf9..12f32ad 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.rest.util.RestCall.Method.GET;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
 import static com.google.gerrit.server.restapi.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
 import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
@@ -216,7 +217,7 @@
         testRepo
             .commit()
             .message("A change")
-            .parent(getRemoteHead())
+            .parent(projectOperations.project(project).getHead("master"))
             .add(filename, "content")
             .insertChangeId()
             .create();
@@ -234,7 +235,11 @@
 
   private void createDefaultDashboard() throws Exception {
     String dashboardRef = REFS_DASHBOARDS + "team";
-    grant(project, "refs/meta/*", Permission.CREATE);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/meta/*").group(adminGroupUuid()))
+        .update();
     gApi.projects().name(project.get()).branch(dashboardRef).create(new BranchInput());
 
     try (Repository r = repoManager.openRepository(project)) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 301a9ef..6ae6938 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -20,6 +20,9 @@
 import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
@@ -42,6 +45,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
@@ -78,7 +82,6 @@
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.Submit;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -124,6 +127,7 @@
   @Inject private ApprovalsUtil approvalsUtil;
   @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
   @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private Submit submitHandler;
 
@@ -162,16 +166,17 @@
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change.getCommit());
     assertTrees(project, actual);
   }
 
   @Test
   public void submitSingleChange() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
@@ -190,12 +195,12 @@
 
   @Test
   public void submitMultipleChangesOtherMergeConflictPreview() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
@@ -265,7 +270,7 @@
           break;
       }
 
-      RevCommit headAfterSubmit = getRemoteHead();
+      RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
       assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
       assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
       assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
@@ -274,7 +279,7 @@
 
   @Test
   public void submitMultipleChangesPreview() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
     PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
@@ -299,7 +304,7 @@
     }
 
     // check that the submit preview did not actually submit
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
@@ -314,7 +319,11 @@
   public void submitNoPermission() throws Throwable {
     // create project where submit is blocked
     Project.NameKey p = projectOperations.newProject().create();
-    block(p, "refs/*", Permission.SUBMIT, REGISTERED_USERS);
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
@@ -328,13 +337,13 @@
   public void noSelfSubmit() throws Throwable {
     // create project where submit is blocked for the change owner
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.block(u.getConfig(), Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
@@ -354,13 +363,13 @@
   public void onlySelfSubmit() throws Throwable {
     // create project where only the change owner can submit
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.block(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
@@ -422,7 +431,7 @@
     Project.NameKey keyA = createProjectForPush(getSubmitType());
     TestRepository<?> repoA = cloneProject(keyA);
 
-    RevCommit initialHead = getRemoteHead(keyA, "master");
+    RevCommit initialHead = projectOperations.project(keyA).getHead("master");
 
     // Create the dev branch on the test project
     BranchInput in = new BranchInput();
@@ -553,7 +562,11 @@
     createBranch(BranchNameKey.create(project, "hidden"));
     PushOneCommit.Result hidden = createChange("refs/for/hidden/" + name("topic"));
     approve(hidden.getChangeId());
-    blockRead("refs/heads/hidden");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/hidden").group(REGISTERED_USERS))
+        .update();
 
     submit(
         visible.getChangeId(),
@@ -611,7 +624,7 @@
     // | /
     // I   -- master
     //
-    RevCommit master = getRemoteHead(project, "master");
+    RevCommit master = projectOperations.project(project).getHead("master");
     PushOneCommit stableTip =
         pushFactory.create(admin.newIdent(), testRepo, "Tip of branch stable", "stable.txt", "");
     PushOneCommit.Result stable = stableTip.to("refs/heads/stable");
@@ -639,7 +652,7 @@
     // | /
     // I -- master
     //
-    RevCommit initial = getRemoteHead(project, "master");
+    RevCommit initial = projectOperations.project(project).getHead("master");
     // push directly to stable to S1
     PushOneCommit.Result s1 =
         pushFactory
@@ -676,7 +689,7 @@
     // create and submit a change
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     // set the status of the change back to NEW to simulate a failed submit that
     // merged the commit but failed to update the change status
@@ -685,7 +698,7 @@
     // submitting the change again should detect that the commit was already
     // merged and just fix the change status to be MERGED
     submit(change.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterSubmit);
   }
 
   @Test
@@ -699,7 +712,7 @@
     }
     submit(change2.getChangeId());
     assertMerged(change1.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     // set the status of the changes back to NEW to simulate a failed submit that
     // merged the commits but failed to update the change status
@@ -709,7 +722,7 @@
     // merged and just fix the change status to be MERGED
     submit(change1.getChangeId());
     submit(change2.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterSubmit);
   }
 
   @Test
@@ -723,7 +736,7 @@
     approve(change1.getChangeId());
     submit(change2.getChangeId());
     assertMerged(change1.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     // set the status of the second change back to NEW to simulate a failed
     // submit that merged the commits but failed to update the change status of
@@ -733,7 +746,7 @@
     // submitting the topic again should detect that the commits were already
     // merged and just fix the change status to be MERGED
     submit(change2.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterSubmit);
   }
 
   @Test
@@ -825,7 +838,7 @@
   public void submitWithCommitAndItsMergeCommitTogether() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // Create a stable branch and bootstrap it.
     gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
@@ -833,8 +846,8 @@
         pushFactory.create(user.newIdent(), testRepo, "initial commit", "a.txt", "a");
     PushOneCommit.Result change = push.to("refs/heads/stable");
 
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
+    RevCommit stable = projectOperations.project(project).getHead("stable");
+    RevCommit master = projectOperations.project(project).getHead("master");
 
     assertThat(master).isEqualTo(initialHead);
     assertThat(stable).isEqualTo(change.getCommit());
@@ -887,7 +900,7 @@
     assertMerged(mergeId);
     testRepo.git().fetch().call();
     RevWalk rw = testRepo.getRevWalk();
-    master = rw.parseCommit(getRemoteHead(project, "master"));
+    master = rw.parseCommit(projectOperations.project(project).getHead("master"));
     assertThat(rw.isMergedInto(merge, master)).isTrue();
     assertThat(rw.isMergedInto(fix, master)).isTrue();
   }
@@ -910,7 +923,7 @@
 
     testRepo.git().fetch().call();
     RevWalk rw = testRepo.getRevWalk();
-    RevCommit master = rw.parseCommit(getRemoteHead(project, "master"));
+    RevCommit master = rw.parseCommit(projectOperations.project(project).getHead("master"));
     RevCommit patchSet = parseCurrentRevision(rw, change.getChangeId());
     assertThat(rw.isMergedInto(patchSet, master)).isTrue();
 
@@ -953,13 +966,13 @@
 
     repoA.git().fetch().call();
     RevWalk rwA = repoA.getRevWalk();
-    RevCommit masterA = rwA.parseCommit(getRemoteHead(keyA, "master"));
+    RevCommit masterA = rwA.parseCommit(projectOperations.project(keyA).getHead("master"));
     RevCommit change1Ps = parseCurrentRevision(rwA, change1.getChangeId());
     assertThat(rwA.isMergedInto(change1Ps, masterA)).isTrue();
 
     repoB.git().fetch().call();
     RevWalk rwB = repoB.getRevWalk();
-    RevCommit masterB = rwB.parseCommit(getRemoteHead(keyB, "master"));
+    RevCommit masterB = rwB.parseCommit(projectOperations.project(keyB).getHead("master"));
     RevCommit change2Ps = parseCurrentRevision(rwB, change2.getChangeId());
     assertThat(rwB.isMergedInto(change2Ps, masterB)).isTrue();
 
@@ -974,7 +987,7 @@
     ci.matchAuthorToCommitterDate = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(ci);
 
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
 
@@ -988,7 +1001,7 @@
     }
 
     submit(change2.getChangeId());
-    assertAuthorAndCommitDateEquals(getRemoteHead());
+    assertAuthorAndCommitDateEquals(projectOperations.project(project).getHead("master"));
   }
 
   @Test
@@ -1078,7 +1091,8 @@
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change.getCommit());
     assertTrees(project, actual);
   }
 
@@ -1098,7 +1112,8 @@
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change.getCommit());
     assertTrees(project, actual);
   }
 
@@ -1268,7 +1283,7 @@
   protected void assertCherryPick(TestRepository<?> testRepo, boolean contentMerge)
       throws Throwable {
     assertRebase(testRepo, contentMerge);
-    RevCommit remoteHead = getRemoteHead();
+    RevCommit remoteHead = projectOperations.project(project).getHead("master");
     assertThat(remoteHead.getFooterLines("Reviewed-On")).isNotEmpty();
     assertThat(remoteHead.getFooterLines("Reviewed-By")).isNotEmpty();
   }
@@ -1276,7 +1291,7 @@
   protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge) throws Throwable {
     Repository repo = testRepo.getRepository();
     RevCommit localHead = getHead(repo, "HEAD");
-    RevCommit remoteHead = getRemoteHead();
+    RevCommit remoteHead = projectOperations.project(project).getHead("master");
     assertThat(localHead.getId()).isNotEqualTo(remoteHead.getId());
     assertThat(remoteHead.getParentCount()).isEqualTo(1);
     if (!contentMerge) {
@@ -1331,8 +1346,12 @@
   // TODO(hanwen): the submodule tests have a similar method; maybe we could share code?
   protected Project.NameKey createProjectForPush(SubmitType submitType) throws Throwable {
     Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
-    grant(project, "refs/heads/*", Permission.PUSH);
-    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
     return project;
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index cad06fb..a4fa84b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -19,23 +19,26 @@
 
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public abstract class AbstractSubmitByMerge extends AbstractSubmit {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void submitWithMerge() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getParentCount()).isEqualTo(2);
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
@@ -49,11 +52,11 @@
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getParentCount()).isEqualTo(2);
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertThat(head.getParent(1)).isEqualTo(change3.getCommit());
@@ -62,11 +65,11 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge_Conflict() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
@@ -78,7 +81,7 @@
             + "Change could not be merged due to a path conflict. "
             + "Please rebase the change locally "
             + "and upload the rebased commit for review.");
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(oldHead);
   }
 
   @Test
@@ -88,7 +91,8 @@
     PushOneCommit.Result change2 = createChange();
     approve(change1.getChangeId());
     submit(change2.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change2.getCommit());
+    assertThat(projectOperations.project(project).getHead("master").getId())
+        .isEqualTo(change2.getCommit());
   }
 
   @Test
@@ -108,7 +112,7 @@
     approve(change1.getChangeId());
     submit(change2.getChangeId());
 
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getParents()).hasLength(2);
     assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
     assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 18a9a24..e05e0b7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -17,12 +17,16 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -31,7 +35,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -40,6 +44,7 @@
 import org.junit.Test;
 
 public abstract class AbstractSubmitByRebase extends AbstractSubmit {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Override
@@ -54,18 +59,17 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithRebaseWithoutAddPatchSetPermission() throws Throwable {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(Util.codeReview().getName()),
-          -2,
-          2,
-          REGISTERED_USERS,
-          "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
 
     submitWithRebase(user);
   }
@@ -73,16 +77,16 @@
   protected ImmutableList<PushOneCommit.Result> submitWithRebase(TestAccount submitter)
       throws Throwable {
     requestScopeOperations.setApiUser(submitter.id());
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     assertRebase(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertApproved(change2.getChangeId(), submitter);
     assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
@@ -103,10 +107,10 @@
 
   @Test
   public void submitWithRebaseMultipleChanges() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content");
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       assertCurrentRevision(change1.getChangeId(), 2, headAfterFirstSubmit);
     } else {
@@ -127,7 +131,7 @@
     assertApproved(change3.getChangeId());
     assertApproved(change4.getChangeId());
 
-    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
+    RevCommit headAfterSecondSubmit = parse(projectOperations.project(project).getHead("master"));
     assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
     assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
     assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
@@ -175,7 +179,7 @@
        |/
        * Initial empty repository
     */
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
 
     PushOneCommit change2Push =
@@ -193,7 +197,7 @@
     approve(change2.getChangeId());
     submit(change2.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     assertThat(newHead.getParentCount()).isEqualTo(2);
 
     RevCommit headParent1 = parse(newHead.getParent(0).getId());
@@ -220,11 +224,11 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge_Conflict() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
@@ -232,7 +236,7 @@
         "Cannot rebase "
             + change2.getCommit().name()
             + ": The change could not be rebased due to a conflict during merge.");
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
@@ -252,7 +256,7 @@
 
   @Test
   public void submitAfterReorderOfCommits() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // Create two commits and push.
     RevCommit c1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
@@ -271,7 +275,7 @@
     approve(id1);
     approve(id2);
     submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
     assertChangeMergedEvents(id2, headAfterSubmit.name(), id1, headAfterSubmit.name());
@@ -279,7 +283,7 @@
 
   @Test
   public void submitChangesAfterBranchOnSecond() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change = createChange();
     approve(change.getChangeId());
@@ -293,7 +297,7 @@
     assertMerged(change2.getChangeId());
     assertMerged(change.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(this.project).getHead("master");
     assertRefUpdatedEvents(initialHead, newHead);
     assertChangeMergedEvents(
         change.getChangeId(), newHead.name(), change2.getChangeId(), newHead.name());
@@ -302,7 +306,7 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitFastForwardIdenticalTree() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
 
@@ -313,18 +317,18 @@
     testRepo.reset(initialHead);
     PushOneCommit.Result change0 = createChange("Change 0", "b.txt", "b");
     submit(change0.getChangeId());
-    RevCommit headAfterChange0 = getRemoteHead();
+    RevCommit headAfterChange0 = projectOperations.project(project).getHead("master");
     assertThat(headAfterChange0.getShortMessage()).isEqualTo("Change 0");
 
     submit(change1.getChangeId());
-    RevCommit headAfterChange1 = getRemoteHead();
+    RevCommit headAfterChange1 = projectOperations.project(project).getHead("master");
     assertThat(headAfterChange1.getShortMessage()).isEqualTo("Change 1");
     assertThat(headAfterChange0).isEqualTo(headAfterChange1.getParent(0));
 
     // Do manual rebase first.
     gApi.changes().id(change2.getChangeId()).current().rebase();
     submit(change2.getChangeId());
-    RevCommit headAfterChange2 = getRemoteHead();
+    RevCommit headAfterChange2 = projectOperations.project(project).getHead("master");
     assertThat(headAfterChange2.getShortMessage()).isEqualTo("Change 2");
     assertThat(headAfterChange1).isEqualTo(headAfterChange2.getParent(0));
 
@@ -351,7 +355,7 @@
     change1 =
         amendChange(change1.getChangeId(), "subject 1 amend", "fileName 2", "rework content 2");
     submit(change1.getChangeId());
-    headAfterChange1 = getRemoteHead();
+    headAfterChange1 = projectOperations.project(project).getHead("master");
 
     submitWithConflict(
         change2.getChangeId(),
@@ -359,13 +363,13 @@
             + change2.getCommit().getName()
             + ": "
             + "The change could not be rebased due to a conflict during merge.");
-    assertThat(getRemoteHead()).isEqualTo(headAfterChange1);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitChainOneByOneManualRebase() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 7dadb13..fec0d4b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -24,6 +25,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
@@ -44,6 +46,7 @@
 
 @NoHttpd
 public class AssigneeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @BeforeClass
@@ -171,7 +174,11 @@
   @Test
   public void setAssigneeAllowedWithPermission() throws Exception {
     PushOneCommit.Result r = createChange();
-    grant(project, "refs/heads/master", Permission.EDIT_ASSIGNEE, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.EDIT_ASSIGNEE).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index f05d4dc..def1a39 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -15,20 +15,25 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 @NoHttpd
 public class ChangeIncludedInIT extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void includedInOpenChange() throws Exception {
     Result result = createChange();
@@ -49,7 +54,11 @@
         .containsExactly("master");
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags).isEmpty();
 
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .update();
     gApi.projects().name(project.get()).tag("test-tag").create(new TagInput());
 
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags)
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index 380090e..8ddfa45 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -16,6 +16,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
@@ -30,6 +31,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
@@ -58,6 +60,7 @@
 
 @RunWith(ConfigSuite.class)
 public class ChangeMessagesIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   private String systemTimeZone;
@@ -168,7 +171,10 @@
 
   @Test
   public void deleteCanBeAppliedWithAdministrateServerCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ADMINISTRATE_SERVER);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
     int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
     requestScopeOperations.setApiUser(user.id());
     deleteOneChangeMessage(changeNum, 0, user, "spam");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 70abe24..30d99ac 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -140,11 +143,24 @@
 
   private void grantApprove(Project.NameKey project, AccountGroup.UUID groupUUID, boolean exclusive)
       throws Exception {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", groupUUID, exclusive);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(groupUUID).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/*"), exclusive)
+        .update();
   }
 
   private void blockApproveForChangeOwner(Project.NameKey project) throws Exception {
-    blockLabel("Code-Review", -2, 2, SystemGroupBackend.CHANGE_OWNER, "refs/heads/*", project);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabel("Code-Review")
+                .ref("refs/heads/*")
+                .group(SystemGroupBackend.CHANGE_OWNER)
+                .range(-2, 2))
+        .update();
   }
 
   private String createMyChange(TestRepository<InMemoryRepository> testRepo) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 76f6b98..e300c91 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
@@ -32,6 +33,7 @@
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -67,6 +69,7 @@
 public class ChangeReviewersIT extends AbstractDaemonTest {
 
   @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -677,7 +680,11 @@
     // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
     // rather than bypassing the check because of project or ref ownership.
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
-    grant(project, RefNames.REFS + "*", Permission.REMOVE_REVIEWER, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
 
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     assertThatUserIsOnlyReviewer(r.getChangeId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index 9a02eb5..57c0c8c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
@@ -23,6 +24,7 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -32,7 +34,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -42,17 +43,18 @@
 import org.junit.Test;
 
 public class ConfigChangeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
   public void setUp() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.OWNER, REGISTERED_USERS, "refs/*");
-      Util.allow(u.getConfig(), Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
-      Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, RefNames.REFS_CONFIG);
-      u.save();
-    }
-
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     fetchRefsMetaConfig();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 2eb85d2..43cf655 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -28,6 +29,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -68,6 +70,7 @@
 import org.junit.Test;
 
 public class CreateChangeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @BeforeClass
@@ -260,7 +263,11 @@
   public void createChangeWithoutAccessToParentCommitFails() throws Exception {
     Map<String, PushOneCommit.Result> results =
         changeInTwoBranches("invisible-branch", "a.txt", "visible-branch", "b.txt");
-    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/heads/invisible-branch").group(REGISTERED_USERS))
+        .update();
 
     ChangeInput in = newChangeInput(ChangeStatus.NEW);
     in.branch = "visible-branch";
@@ -387,7 +394,7 @@
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
 
-    ObjectId remoteId = getRemoteHead();
+    ObjectId remoteId = projectOperations.project(project).getHead("master");
     assertThat(remoteId).isNotEqualTo(commitId);
 
     ChangeInput in = newMergeChangeInput("master", commitId.getName(), "");
@@ -451,7 +458,11 @@
   @Test
   public void createChangeOnExistingBranchNotPermitted() throws Exception {
     createBranch(BranchNameKey.create(project, "foo"));
-    blockRead("refs/heads/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.branch = "foo";
@@ -461,7 +472,11 @@
 
   @Test
   public void createChangeOnNonExistingBranchNotPermitted() throws Exception {
-    blockRead("refs/heads/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.branch = "foo";
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index 0c5498b..542c6a9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.Objects.requireNonNull;
@@ -27,6 +28,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
@@ -41,6 +43,8 @@
 
 @NoHttpd
 public class HashtagsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   @BeforeClass
   public static void setTimeForTesting() {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -267,7 +271,11 @@
   @Test
   public void addHashtagWithPermissionAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
-    grant(project, "refs/heads/master", Permission.EDIT_HASHTAGS, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.EDIT_HASHTAGS).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     addHashtags(r, "MyHashtag");
     assertThatGet(r).containsExactly("MyHashtag");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 0087268..e8fd295 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -27,7 +29,6 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -48,7 +49,11 @@
   @Test
   public void indexChangeOnNonVisibleBranch() throws Exception {
     String changeId = createChange().getChangeId();
-    blockRead("refs/heads/master");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     userRestSession.post("/changes/" + changeId + "/index/").assertNotFound();
   }
 
@@ -62,15 +67,15 @@
 
     // Create a project and restrict its visibility to the group
     Project.NameKey p = projectOperations.newProject().create();
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(
-          u.getConfig(),
-          Permission.READ,
-          groupCache.get(AccountGroup.nameKey(group)).get().getGroupUUID(),
-          "refs/*");
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(
+            allow(Permission.READ)
+                .ref("refs/*")
+                .group(groupCache.get(AccountGroup.nameKey(group)).get().getGroupUUID()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Clone it and push a change as a regular user
     TestRepository<InMemoryRepository> repo = cloneProject(p, user);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 696e161..55cff17 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
@@ -24,6 +26,7 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
@@ -36,10 +39,8 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BranchNameKey;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
@@ -49,6 +50,7 @@
 
 @NoHttpd
 public class MoveChangeIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -181,10 +183,11 @@
     BranchNameKey newBranch =
         BranchNameKey.create(r.getChange().change().getProject(), "blocked_branch");
     createBranch(newBranch);
-    block(
-        "refs/for/" + newBranch.branch(),
-        Permission.PUSH,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/for/" + newBranch.branch()).group(REGISTERED_USERS))
+        .update();
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
     assertThat(thrown).hasMessageThat().contains("move not permitted");
@@ -196,10 +199,14 @@
     PushOneCommit.Result r = createChange();
     BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    block(
-        r.getChange().change().getDest().branch(),
-        Permission.ABANDON,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            block(Permission.ABANDON)
+                .ref(r.getChange().change().getDest().branch())
+                .group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
@@ -236,20 +243,20 @@
     BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
 
+    LabelType patchSetLock = TestLabels.patchSetLock();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType patchSetLock = Util.patchSetLock();
       u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
-      AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(patchSetLock.getName()),
-          0,
-          1,
-          registeredUsers,
-          "refs/heads/*");
       u.save();
     }
-    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(patchSetLock.getName())
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(0, 1))
+        .update();
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
 
     ResourceConflictException thrown =
@@ -275,16 +282,13 @@
     configLabel(testLabelB, LabelFunction.MAX_NO_BLOCK);
     configLabel(testLabelC, LabelFunction.NO_BLOCK);
 
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelB), -1, +1, registered, "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelC), -1, +1, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .add(allowLabel(testLabelB).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .add(allowLabel(testLabelC).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
 
     String changeId = createChange().getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.reject());
@@ -324,12 +328,11 @@
     String testLabelA = "Label-A";
     configLabel(testLabelA, LabelFunction.MAX_WITH_BLOCK, Arrays.asList("refs/heads/master"));
 
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(), Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/master");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/master").group(REGISTERED_USERS).range(-1, +1))
+        .update();
 
     String changeId = createChange().getChangeId();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
index e0bca3a..a6fa9fc5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
@@ -131,7 +131,7 @@
   public void pushDraftsWithPrivateByDefaultAndDisablePrivateChangesTrue() throws Exception {
     setPrivateByDefault(project2, InheritableBoolean.TRUE);
 
-    RevCommit initialHead = getRemoteHead(project2, "master");
+    RevCommit initialHead = projectOperations.project(project2).getHead("master");
     TestRepository<InMemoryRepository> testRepo = cloneProject(project2);
     PushOneCommit.Result result =
         pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%draft");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 7dbcbc1..16b7690 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -40,6 +41,7 @@
 
 public class SubmitByCherryPickIT extends AbstractSubmit {
   @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -48,11 +50,11 @@
 
   @Test
   public void submitWithCherryPickIfFastForwardPossible() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     assertThat(newHead.getParent(0)).isEqualTo(change.getCommit().getParent(0));
 
     assertRefUpdatedEvents(initialHead, newHead);
@@ -61,16 +63,16 @@
 
   @Test
   public void submitWithCherryPick() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     assertThat(newHead.getParentCount()).isEqualTo(1);
     assertThat(newHead.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 2, newHead);
@@ -108,19 +110,19 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
 
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, true);
-    RevCommit headAfterThirdSubmit = getRemoteHead();
+    RevCommit headAfterThirdSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
@@ -146,11 +148,11 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge_Conflict() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
@@ -162,7 +164,7 @@
             + "merged due to a path conflict. Please rebase the change locally and "
             + "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(newHead);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
 
@@ -172,17 +174,17 @@
 
   @Test
   public void submitOutOfOrder() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "different content");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterSecondSubmit);
@@ -200,11 +202,11 @@
 
   @Test
   public void submitOutOfOrder_Conflict() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 = createChange("Change 3", "b.txt", "different content");
@@ -217,7 +219,7 @@
             + "merged due to a path conflict. Please rebase the change locally and "
             + "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertThat(projectOperations.project(project).getHead("master")).isEqualTo(newHead);
     assertCurrentRevision(change3.getChangeId(), 1, change3.getCommit());
     assertNoSubmitter(change3.getChangeId(), 1);
 
@@ -227,7 +229,7 @@
 
   @Test
   public void submitMultipleChanges() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -255,7 +257,7 @@
 
   @Test
   public void submitDependentNonConflictingChangesOutOfOrder() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -264,11 +266,11 @@
 
     // Submit succeeds; change2 is successfully cherry-picked onto head.
     submit(change2.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     // Submit succeeds; change is successfully cherry-picked onto head
     // (which was change2's cherry-pick).
     submit(change.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
 
     // change is the new tip.
     List<RevCommit> log = getRemoteLog();
@@ -291,7 +293,7 @@
 
   @Test
   public void submitDependentConflictingChangesOutOfOrder() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b1");
@@ -323,7 +325,7 @@
 
   @Test
   public void submitSubsetOfDependentChanges() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -334,7 +336,7 @@
     // related to change 3 by topic or ancestor (due to cherrypicking!)
     approve(change2.getChangeId());
     submit(change3.getChangeId());
-    RevCommit newHead = getRemoteHead();
+    RevCommit newHead = projectOperations.project(project).getHead("master");
 
     assertNew(change.getChangeId());
     assertNew(change2.getChangeId());
@@ -346,7 +348,7 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitIdenticalTree() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
 
@@ -354,12 +356,13 @@
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
 
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo("Change 1");
 
     submit(change2.getChangeId(), new SubmitInput(), null, null);
 
-    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
+    assertThat(projectOperations.project(project).getHead("master"))
+        .isEqualTo(headAfterFirstSubmit);
 
     ChangeInfo info2 = get(change2.getChangeId(), MESSAGES);
     assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index bfc4ae3..aff0cc2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -16,19 +16,23 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.inject.Inject;
 import java.util.Map;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.junit.Test;
 
 public class SubmitByFastForwardIT extends AbstractSubmit {
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -37,10 +41,10 @@
 
   @Test
   public void submitWithFastForward() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
     assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
@@ -51,7 +55,7 @@
 
   @Test
   public void submitMultipleChangesWithFastForward() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change = createChange();
     PushOneCommit.Result change2 = createChange();
@@ -64,7 +68,7 @@
     approve(id2);
     submit(id3);
 
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(change3.getCommit());
     assertThat(updatedHead.getParent(0).getId()).isEqualTo(change2.getCommit());
     assertSubmitter(change.getChangeId(), 1);
@@ -83,7 +87,7 @@
 
   @Test
   public void submitTwoChangesWithFastForward_missingDependency() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
 
@@ -95,7 +99,7 @@
             + id1
             + ": needs Code-Review");
 
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
@@ -103,11 +107,11 @@
 
   @Test
   public void submitFastForwardNotPossible_Conflict() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
 
@@ -128,7 +132,8 @@
             + ": Project policy requires "
             + "all submissions to be a fast-forward. Please rebase the change "
             + "locally and upload again for review.");
-    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
+    assertThat(projectOperations.project(project).getHead("master"))
+        .isEqualTo(headAfterFirstSubmit);
     assertSubmitter(change.getChangeId(), 1);
 
     assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
@@ -137,10 +142,14 @@
 
   @Test
   public void submitSameCommitsAsInExperimentalBranch() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
-    grant(project, "refs/heads/*", Permission.CREATE);
-    grant(project, "refs/heads/experimental", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref("refs/heads/experimental").group(adminGroupUuid()))
+        .update();
 
     RevCommit c1 = commitBuilder().add("b.txt", "1").message("commit at tip").create();
     String id1 = GitUtil.getChangeId(testRepo, c1).get();
@@ -153,9 +162,9 @@
         .isEqualTo(c1.getId());
 
     submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
 
-    assertThat(getRemoteHead().getId()).isEqualTo(c1.getId());
+    assertThat(projectOperations.project(project).getHead("master").getId()).isEqualTo(c1.getId());
     assertSubmitter(id1, 1);
 
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
index 3b835a2..f80bdca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
@@ -17,11 +17,14 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class SubmitByMergeAlwaysIT extends AbstractSubmitByMerge {
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -30,10 +33,10 @@
 
   @Test
   public void submitWithMergeIfFastForwardPossible() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit.getParentCount()).isEqualTo(2);
     assertThat(headAfterSubmit.getParent(0)).isEqualTo(initialHead);
     assertThat(headAfterSubmit.getParent(1)).isEqualTo(change.getCommit());
@@ -47,7 +50,7 @@
 
   @Test
   public void submitMultipleChanges() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // Submit a change so that the remote head advances
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index a60438d..5c28968 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -16,6 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -65,10 +68,10 @@
 
   @Test
   public void submitWithFastForward() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit updatedHead = getRemoteHead();
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
     assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
@@ -81,7 +84,7 @@
 
   @Test
   public void submitMultipleChanges() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change = createChange("Change 1", "b", "b");
@@ -142,8 +145,8 @@
     Project.NameKey p2 = projectOperations.newProject().create();
     Project.NameKey p3 = projectOperations.newProject().create();
 
-    RevCommit initialHead2 = getRemoteHead(p2, "master");
-    RevCommit initialHead3 = getRemoteHead(p3, "master");
+    RevCommit initialHead2 = projectOperations.project(p2).getHead("master");
+    RevCommit initialHead3 = projectOperations.project(p3).getHead("master");
 
     TestRepository<?> repo1 = cloneProject(p1);
     TestRepository<?> repo2 = cloneProject(p2);
@@ -223,9 +226,9 @@
     TestRepository<?> repo2 = cloneProject(p2);
     TestRepository<?> repo3 = cloneProject(p3);
 
-    RevCommit initialHead1 = getRemoteHead(p1, "master");
-    RevCommit initialHead2 = getRemoteHead(p2, "master");
-    RevCommit initialHead3 = getRemoteHead(p3, "master");
+    RevCommit initialHead1 = projectOperations.project(p1).getHead("master");
+    RevCommit initialHead2 = projectOperations.project(p2).getHead("master");
+    RevCommit initialHead3 = projectOperations.project(p3).getHead("master");
 
     PushOneCommit.Result change1a =
         createChange(
@@ -312,12 +315,12 @@
 
   @Test
   public void submitWithMergedAncestorsOnOtherBranch() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     PushOneCommit.Result change1 =
         createChange(testRepo, "master", "base commit", "a.txt", "1", "");
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
 
     gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
@@ -361,11 +364,11 @@
 
   @Test
   public void submitWithOpenAncestorsOnOtherBranch() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 =
         createChange(testRepo, "master", "base commit", "a.txt", "1", "");
     submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
 
     gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
@@ -393,7 +396,7 @@
 
     Project.NameKey p3 = projectOperations.newProject().create();
     TestRepository<?> repo3 = cloneProject(p3);
-    RevCommit repo3Head = getRemoteHead(p3, "master");
+    RevCommit repo3Head = projectOperations.project(p3).getHead("master");
     PushOneCommit.Result change3b =
         createChange(
             repo3,
@@ -434,7 +437,7 @@
 
   @Test
   public void gerritWorkflow() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     // We'll setup a master and a stable branch.
     // Then we create a change to be applied to master, which is
@@ -460,8 +463,8 @@
     gApi.changes().id(cherryId).current().submit();
 
     // Create the merge locally
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
+    RevCommit stable = projectOperations.project(project).getHead("stable");
+    RevCommit master = projectOperations.project(project).getHead("master");
     testRepo.git().fetch().call();
     testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
     testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
@@ -607,8 +610,12 @@
 
   @Test
   public void dependencyOnChangeForNonVisibleBranchPreventsMerge() throws Throwable {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", REGISTERED_USERS, false);
-    grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Create a change
     PushOneCommit change = pushFactory.create(admin.newIdent(), testRepo, "fix", "a.txt", "foo");
@@ -628,7 +635,11 @@
         .branch(secretBranch.branch())
         .create(new BranchInput());
     gApi.changes().id(changeResult.getChangeId()).move(secretBranch.branch());
-    block(secretBranch.branch(), "read", ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block("read").ref(secretBranch.branch()).group(ANONYMOUS_USERS))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
 
@@ -661,8 +672,12 @@
 
   @Test
   public void dependencyOnHiddenChangePreventsMerge() throws Throwable {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", REGISTERED_USERS, false);
-    grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     // Create a change
     PushOneCommit change = pushFactory.create(admin.newIdent(), testRepo, "fix", "a.txt", "foo");
@@ -714,10 +729,18 @@
     Project.NameKey p1 = projectOperations.newProject().create();
     Project.NameKey p2 = projectOperations.newProject().create();
 
-    grantLabel("Code-Review", -2, 2, p1, "refs/heads/*", REGISTERED_USERS, false);
-    grant(p1, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
-    grantLabel("Code-Review", -2, 2, p2, "refs/heads/*", REGISTERED_USERS, false);
-    grant(p2, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+    projectOperations
+        .project(p1)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(p2)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     TestRepository<?> repo1 = cloneProject(p1);
     TestRepository<?> repo2 = cloneProject(p2);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index 569d9f5..1808480 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -44,6 +45,7 @@
 public class SubmitByRebaseAlwaysIT extends AbstractSubmitByRebase {
   @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
   @Inject private DynamicItem<UrlFormatter> urlFormatter;
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -53,11 +55,11 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithPossibleFastForward() throws Throwable {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
 
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getId()).isNotEqualTo(change.getCommit());
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertApproved(change.getChangeId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
index 1b71a2f..01b58ee 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
@@ -18,12 +18,15 @@
 
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class SubmitByRebaseIfNecessaryIT extends AbstractSubmitByRebase {
+  @Inject private ProjectOperations projectOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -33,10 +36,10 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithFastForward() throws Throwable {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit oldHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit head = getRemoteHead();
+    RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head.getId()).isEqualTo(change.getCommit());
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertApproved(change.getChangeId());
@@ -51,19 +54,19 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Throwable {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
+    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
-    RevCommit headAfterSecondSubmit = getRemoteHead();
+    RevCommit headAfterSecondSubmit = projectOperations.project(project).getHead("master");
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertRebase(testRepo, true);
-    RevCommit headAfterThirdSubmit = getRemoteHead();
+    RevCommit headAfterThirdSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 9bbe1dd..c1e0e9e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -16,6 +16,9 @@
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static java.util.stream.Collectors.toList;
 
@@ -160,8 +163,12 @@
     List<SuggestedReviewerInfo> reviewers;
 
     requestScopeOperations.setApiUser(user3.id());
-    block("refs/*", "read", ANONYMOUS_USERS);
-    allow("refs/*", "read", group1);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block("read").ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow("read").ref("refs/*").group(group1))
+        .update();
     reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).isEmpty();
   }
@@ -178,7 +185,10 @@
 
     // Clear cached group info.
     requestScopeOperations.setApiUser(user1.id());
-    allowGlobalCapabilities(group1, GlobalCapability.VIEW_ALL_ACCOUNTS);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ALL_ACCOUNTS).group(group1))
+        .update();
     reviewers = suggestReviewers(changeId, user2.username(), 2);
     assertThat(reviewers).hasSize(1);
     assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName());
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index 7ef915b..daeb032 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -15,19 +15,24 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH;
 import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH_ALL;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.restapi.config.PostCaches;
+import com.google.inject.Inject;
 import java.util.Arrays;
 import org.junit.Test;
 
 public class CacheOperationsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void flushAll() throws Exception {
@@ -124,8 +129,11 @@
 
   @Test
   public void flushWebSessions_Forbidden() throws Exception {
-    allowGlobalCapabilities(
-        REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+        .update();
     try {
       RestResponse r =
           userRestSession.post(
@@ -138,8 +146,11 @@
               "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")))
           .assertForbidden();
     } finally {
-      removeGlobalCapabilities(
-          REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(capabilityKey(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+          .remove(capabilityKey(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+          .update();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index caecefa..a161ec4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -15,15 +15,20 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 public class FlushCacheIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void flushCache() throws Exception {
@@ -65,8 +70,11 @@
 
   @Test
   public void flushWebSessionsCache_Forbidden() throws Exception {
-    allowGlobalCapabilities(
-        REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+        .update();
     try {
       RestResponse r = userRestSession.post("/config/server/caches/accounts/flush");
       r.assertOK();
@@ -74,8 +82,11 @@
 
       userRestSession.post("/config/server/caches/web_sessions/flush").assertForbidden();
     } finally {
-      removeGlobalCapabilities(
-          REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(capabilityKey(GlobalCapability.FLUSH_CACHES).group(REGISTERED_USERS))
+          .remove(capabilityKey(GlobalCapability.VIEW_CACHES).group(REGISTERED_USERS))
+          .update();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index bea1748..e2818d2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -21,14 +21,18 @@
 import static com.google.gerrit.acceptance.GitUtil.updateAnnotatedTag;
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.ANNOTATED;
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.LIGHTWEIGHT;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
@@ -50,6 +54,8 @@
     }
   }
 
+  @Inject private ProjectOperations projectOperations;
+
   private RevCommit initialHead;
   private TagType tagType;
 
@@ -58,7 +64,7 @@
     // clone with user to avoid inherited tag permissions of admin user
     testRepo = cloneProject(project, user);
 
-    initialHead = getRemoteHead();
+    initialHead = projectOperations.project(project).getHead("master");
     tagType = getTagType();
   }
 
@@ -207,7 +213,11 @@
     }
 
     if (!newCommit) {
-      grant(project, "refs/for/refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(REGISTERED_USERS))
+          .update();
       pushHead(testRepo, "refs/for/master%submit");
     }
 
@@ -229,26 +239,46 @@
   }
 
   private void allowTagCreation() throws Exception {
-    grant(project, "refs/tags/*", tagType.createPermission, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(tagType.createPermission).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private void allowPushOnRefsTags() throws Exception {
     removePushFromRefsTags();
-    grant(project, "refs/tags/*", Permission.PUSH, false, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private void allowForcePushOnRefsTags() throws Exception {
     removePushFromRefsTags();
-    grant(project, "refs/tags/*", Permission.PUSH, true, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(REGISTERED_USERS).force(true))
+        .update();
   }
 
   private void allowTagDeletion() throws Exception {
     removePushFromRefsTags();
-    grant(project, "refs/tags/*", Permission.DELETE, true, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/tags/*").group(REGISTERED_USERS).force(true))
+        .update();
   }
 
   private void removePushFromRefsTags() throws Exception {
-    removePermission(project, "refs/tags/*", Permission.PUSH);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(Permission.PUSH).ref("refs/tags/*"))
+        .update();
   }
 
   private void commit(PersonIdent ident, String subject) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 3fb33d9..bb043c2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -15,6 +15,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -126,7 +127,7 @@
 
   @Test
   public void addAccessSection() throws Exception {
-    RevCommit initialHead = getRemoteHead(newProjectName, RefNames.REFS_CONFIG);
+    RevCommit initialHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
 
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
@@ -136,7 +137,7 @@
 
     assertThat(pApi().access().local).isEqualTo(accessInput.add);
 
-    RevCommit updatedHead = getRemoteHead(newProjectName, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
         newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
   }
@@ -169,7 +170,11 @@
 
   @Test
   public void createAccessChange() throws Exception {
-    allow(newProjectName, RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
+    projectOperations
+        .project(newProjectName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
     // User can see the branch
     requestScopeOperations.setApiUser(user.id());
     pApi().branch("refs/heads/master").get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
index 10e3e99..22fb829 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -20,12 +20,14 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
@@ -34,6 +36,8 @@
 
 public class CheckMergeabilityIT extends AbstractDaemonTest {
 
+  @Inject private ProjectOperations projectOperations;
+
   private BranchNameKey branch;
 
   @Before
@@ -44,7 +48,7 @@
 
   @Test
   public void checkMergeableCommit() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
@@ -79,7 +83,7 @@
 
   @Test
   public void checkUnMergeableCommit() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
@@ -114,7 +118,7 @@
 
   @Test
   public void checkOursMergeStrategy() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
@@ -208,7 +212,7 @@
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
 
-    ObjectId remoteId = getRemoteHead();
+    ObjectId remoteId = projectOperations.project(project).getHead("master");
     assertThat(remoteId).isNotEqualTo(commitId);
     assertContentMerged("master", commitId.getName(), "recursive");
   }
@@ -234,7 +238,7 @@
 
   @Test
   public void checkInvalidStrategy() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     testRepo
         .branch("HEAD")
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 96ad91c..41fa128 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_HEADS;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -23,6 +25,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchApi;
@@ -40,6 +43,7 @@
 import org.junit.Test;
 
 public class CreateBranchIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   private BranchNameKey testBranch;
@@ -103,15 +107,23 @@
   @Test
   public void createMetaBranch() throws Exception {
     String metaRef = RefNames.REFS_META + "foo";
-    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
-    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(metaRef).group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(metaRef).group(REGISTERED_USERS))
+        .update();
     assertCreateSucceeds(BranchNameKey.create(project, metaRef));
   }
 
   @Test
   public void createUserBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .update();
     assertCreateFails(
         BranchNameKey.create(allUsers, RefNames.refsUsers(Account.id(1))),
         RefNames.refsUsers(admin.id()),
@@ -121,8 +133,12 @@
 
   @Test
   public void createGroupBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
     assertCreateFails(
         BranchNameKey.create(allUsers, RefNames.refsGroups(AccountGroup.uuid("foo"))),
         RefNames.refsGroups(adminGroupUuid()),
@@ -131,11 +147,19 @@
   }
 
   private void blockCreateReference() throws Exception {
-    block("refs/*", Permission.CREATE, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.CREATE).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void grantOwner() throws Exception {
-    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private BranchApi branch(BranchNameKey branch) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 894d79f..0b9c3a9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -19,6 +19,8 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectOwners;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -31,6 +33,7 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
@@ -73,6 +76,7 @@
 import org.junit.Test;
 
 public class CreateProjectIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -324,7 +328,12 @@
 
   @Test
   public void createProjectWithCapability() throws Exception {
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.CREATE_PROJECT)
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     try {
       requestScopeOperations.setApiUser(user.id());
       ProjectInput in = new ProjectInput();
@@ -332,8 +341,12 @@
       ProjectInfo p = gApi.projects().create(in).get();
       assertThat(p.name).isEqualTo(in.name);
     } finally {
-      removeGlobalCapabilities(
-          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.CREATE_PROJECT)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
     }
   }
 
@@ -356,7 +369,12 @@
   public void createProjectWithCreateProjectCapabilityAndParentNotVisible() throws Exception {
     Project parent = projectCache.get(allProjects).getProject();
     parent.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.CREATE_PROJECT)
+                .group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
     try {
       requestScopeOperations.setApiUser(user.id());
       ProjectInput in = new ProjectInput();
@@ -365,8 +383,12 @@
       assertThat(p.name).isEqualTo(in.name);
     } finally {
       parent.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE);
-      removeGlobalCapabilities(
-          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.CREATE_PROJECT)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index f95342a..c44c11a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -23,7 +25,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchApi;
@@ -123,8 +124,12 @@
   @Test
   public void deleteMetaBranch() throws Exception {
     String metaRef = RefNames.REFS_META + "foo";
-    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
-    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(metaRef).group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(metaRef).group(REGISTERED_USERS))
+        .update();
 
     BranchNameKey metaBranch = BranchNameKey.create(project, metaRef);
     branch(metaBranch).create(new BranchInput());
@@ -138,14 +143,8 @@
     projectOperations
         .project(allUsers)
         .forUpdate()
-        .add(
-            TestProjectUpdate.allow(Permission.CREATE)
-                .ref(RefNames.REFS_USERS + "*")
-                .group(REGISTERED_USERS))
-        .add(
-            TestProjectUpdate.allow(Permission.PUSH)
-                .ref(RefNames.REFS_USERS + "*")
-                .group(REGISTERED_USERS))
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(REGISTERED_USERS))
         .update();
 
     ResourceConflictException thrown =
@@ -157,8 +156,12 @@
 
   @Test
   public void deleteGroupBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_GROUPS + "*").group(REGISTERED_USERS))
+        .update();
 
     ResourceConflictException thrown =
         assertThrows(
@@ -173,24 +176,32 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(
-            TestProjectUpdate.block(Permission.PUSH)
-                .ref("refs/heads/*")
-                .group(ANONYMOUS_USERS)
-                .force(true))
+        .add(block(Permission.PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
         .update();
   }
 
   private void grantForcePush() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, true, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantDelete() throws Exception {
-    allow("refs/*", Permission.DELETE, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void grantOwner() throws Exception {
-    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private BranchApi branch(BranchNameKey branch) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index 1d2aceb..523b711 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
@@ -25,6 +26,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -47,12 +49,17 @@
   private static final ImmutableList<String> BRANCHES =
       ImmutableList.of("refs/heads/test-1", "refs/heads/test-2", "test-3", "refs/meta/foo");
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
   public void setUp() throws Exception {
-    allow("refs/*", Permission.CREATE, REGISTERED_USERS);
-    allow("refs/*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     for (String name : BRANCHES) {
       project().branch(name).create(new BranchInput());
     }
@@ -164,7 +171,7 @@
   private HashMap<String, RevCommit> initialRevisions(List<String> branches) throws Exception {
     HashMap<String, RevCommit> result = new HashMap<>();
     for (String branch : branches) {
-      result.put(branch, getRemoteHead(project, branch));
+      result.put(branch, projectOperations.project(project).getHead(branch));
     }
     return result;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 892c375..9770031 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -22,7 +24,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.TagApi;
@@ -37,6 +39,7 @@
 public class DeleteTagIT extends AbstractDaemonTest {
   private static final String TAG = "refs/tags/test";
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
@@ -102,24 +105,32 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(
-            TestProjectUpdate.block(Permission.PUSH)
-                .ref("refs/tags/*")
-                .group(ANONYMOUS_USERS)
-                .force(true))
+        .add(block(Permission.PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS).force(true))
         .update();
   }
 
   private void grantForcePush() throws Exception {
-    grant(project, "refs/tags/*", Permission.PUSH, true, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS).force(true))
+        .update();
   }
 
   private void grantDelete() throws Exception {
-    allow("refs/tags/*", Permission.DELETE, ANONYMOUS_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.DELETE).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
   }
 
   private void grantOwner() throws Exception {
-    allow("refs/tags/*", Permission.OWNER, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
   }
 
   private TagApi tag() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
index 90e6fca..46e2345 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
@@ -42,6 +43,7 @@
   private static final ImmutableList<String> TAGS =
       ImmutableList.of("refs/tags/test-1", "refs/tags/test-2", "refs/tags/test-3", "test-4");
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
@@ -120,7 +122,7 @@
     HashMap<String, RevCommit> result = new HashMap<>();
     for (String tag : tags) {
       String ref = prefixRef(tag);
-      result.put(ref, getRemoteHead(project, ref));
+      result.put(ref, projectOperations.project(project).getHead(ref));
     }
     return result;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index 18c706b..f9011c7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -15,14 +15,18 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -32,12 +36,18 @@
 import org.junit.Test;
 
 public class GetCommitIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
   private TestRepository<Repository> repo;
 
   @Before
   public void setUp() throws Exception {
     repo = GitUtil.newTestRepository(repoManager.openRepository(project));
-    blockRead("refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
   }
 
   @After
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 8fca32f..a797b98 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -15,13 +15,17 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -32,6 +36,7 @@
 
 @NoHttpd
 public class ListBranchesIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -43,7 +48,11 @@
 
   @Test
   public void listBranchesOfNonVisibleProject_NotFound() throws Exception {
-    blockRead("refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
         ResourceNotFoundException.class,
@@ -62,7 +71,7 @@
   public void listBranches() throws Exception {
     String master = pushTo("refs/heads/master").getCommit().name();
     String dev = pushTo("refs/heads/dev").getCommit().name();
-    String refsConfig = getRemoteHead(project, RefNames.REFS_CONFIG).name();
+    String refsConfig = projectOperations.project(project).getHead(RefNames.REFS_CONFIG).name();
     assertRefs(
         ImmutableList.of(
             branch("HEAD", "master", false),
@@ -74,7 +83,11 @@
 
   @Test
   public void listBranchesSomeHidden() throws Exception {
-    blockRead("refs/heads/dev");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/dev").group(REGISTERED_USERS))
+        .update();
     String master = pushTo("refs/heads/master").getCommit().name();
     pushTo("refs/heads/dev");
     requestScopeOperations.setApiUser(user.id());
@@ -87,7 +100,11 @@
 
   @Test
   public void listBranchesHeadHidden() throws Exception {
-    blockRead("refs/heads/master");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
     pushTo("refs/heads/master");
     String dev = pushTo("refs/heads/dev").getCommit().name();
     requestScopeOperations.setApiUser(user.id());
@@ -99,7 +116,10 @@
   public void listBranchesUsingPagination() throws Exception {
     BranchInfo head = branch("HEAD", "master", false);
     BranchInfo refsConfig =
-        branch(RefNames.REFS_CONFIG, getRemoteHead(project, RefNames.REFS_CONFIG).name(), false);
+        branch(
+            RefNames.REFS_CONFIG,
+            projectOperations.project(project).getHead(RefNames.REFS_CONFIG).name(),
+            false);
     BranchInfo master =
         branch("refs/heads/master", pushTo("refs/heads/master").getCommit().getName(), false);
     BranchInfo branch1 =
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index 9de59fb..1443c99 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -16,8 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Splitter;
@@ -41,7 +43,6 @@
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.project.ListProjects;
 import com.google.gson.Gson;
 import com.google.gson.JsonObject;
@@ -73,10 +74,11 @@
     requestScopeOperations.setApiUser(user.id());
     assertThatNameList(gApi.projects().list().get()).contains(project);
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     assertThatNameList(gApi.projects().list().get()).doesNotContain(project);
   }
@@ -148,7 +150,9 @@
       listProjects.displayToStream(displayOut);
 
       List<String> lines =
-          Splitter.on("\n").omitEmptyStrings().splitToList(new String(displayOut.toByteArray()));
+          Splitter.on("\n")
+              .omitEmptyStrings()
+              .splitToList(new String(displayOut.toByteArray(), UTF_8));
       assertThat(lines).isEqualTo(testProjects);
     }
   }
@@ -177,7 +181,7 @@
       listProjects.setFormat(jsonFormat);
       listProjects.displayToStream(displayOut);
 
-      String projectsJsonOutput = new String(displayOut.toByteArray());
+      String projectsJsonOutput = new String(displayOut.toByteArray(), UTF_8);
 
       Gson gson = jsonFormat.newGson();
       Set<String> projectsJsonNames = gson.fromJson(projectsJsonOutput, JsonObject.class).keySet();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 97cac4d..3d1a148 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
@@ -24,6 +26,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
@@ -59,6 +62,7 @@
           + "=XFeC\n"
           + "-----END PGP SIGNATURE-----";
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -76,7 +80,11 @@
 
   @Test
   public void listTagsOfNonVisibleProject() throws Exception {
-    blockRead("refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
         ResourceNotFoundException.class, () -> gApi.projects().name(project.get()).tags().get());
@@ -84,7 +92,11 @@
 
   @Test
   public void getTagOfNonVisibleProject() throws Exception {
-    blockRead("refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     assertThrows(
         ResourceNotFoundException.class,
         () -> gApi.projects().name(project.get()).tag("tag").get());
@@ -162,7 +174,11 @@
     assertThat(tags.get(1).ref).isEqualTo(R_TAGS + tag2.ref);
     assertThat(tags.get(1).revision).isEqualTo(tag2.revision);
 
-    blockRead("refs/heads/hidden");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/hidden").group(REGISTERED_USERS))
+        .update();
     tags = getTags().get();
     assertThat(tags).hasSize(1);
     assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
@@ -257,7 +273,11 @@
 
   @Test
   public void createTagNotAllowed() throws Exception {
-    block(R_TAGS + "*", Permission.CREATE, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.CREATE).ref(R_TAGS + "*").group(REGISTERED_USERS))
+        .update();
     TagInput input = new TagInput();
     input.ref = "test";
     AuthException thrown = assertThrows(AuthException.class, () -> tag(input.ref).create(input));
@@ -266,7 +286,11 @@
 
   @Test
   public void createAnnotatedTagNotAllowed() throws Exception {
-    block(R_TAGS + "*", Permission.CREATE_TAG, REGISTERED_USERS);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.CREATE_TAG).ref(R_TAGS + "*").group(REGISTERED_USERS))
+        .update();
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = "annotation";
@@ -374,9 +398,13 @@
   }
 
   private void grantTagPermissions() throws Exception {
-    grant(project, R_TAGS + "*", Permission.CREATE);
-    grant(project, R_TAGS + "", Permission.DELETE);
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
-    grant(project, R_TAGS + "*", Permission.CREATE_SIGNED_TAG);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.DELETE).ref(R_TAGS + "").group(adminGroupUuid()))
+        .add(allow(Permission.CREATE_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.CREATE_SIGNED_TAG).ref(R_TAGS + "*").group(adminGroupUuid()))
+        .update();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index dfb0f75..9882c77 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
@@ -30,10 +31,10 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -43,7 +44,6 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.GetRelated;
@@ -81,6 +81,7 @@
 
   @Inject private AccountOperations accountOperations;
   @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   private String systemTimeZone;
@@ -611,11 +612,10 @@
 
     Account.Id accountId = accountOperations.newAccount().create();
     AccountGroup.UUID groupUuid = groupOperations.newGroup().addMember(accountId).create();
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      PermissionRule rule = Util.allow(u.getConfig(), GlobalCapability.QUERY_LIMIT, groupUuid);
-      rule.setRange(0, 2);
-      u.save();
-    }
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.QUERY_LIMIT).group(groupUuid).range(0, 2))
+        .update();
     requestScopeOperations.setApiUser(accountId);
 
     assertRelated(lastPsId, expected);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 389859c..9d65d39 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -113,7 +113,7 @@
 
   @Test
   public void respectWholeTopic() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     // Create two independent commits and push.
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
@@ -135,7 +135,7 @@
 
   @Test
   public void anonymousWholeTopic() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
     RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
     pushHead(testRepo, "refs/for/master/" + name("topic"), false);
     String id1 = getChangeId(a);
@@ -157,7 +157,7 @@
 
   @Test
   public void topicChaining() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
@@ -185,7 +185,7 @@
 
   @Test
   public void respectTopicsOnAncestors() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
 
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 4f98dd0..46fc689 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -15,16 +15,17 @@
 package com.google.gerrit.acceptance.server.event;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -32,8 +33,6 @@
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import org.junit.After;
 import org.junit.Before;
@@ -43,37 +42,25 @@
 public class CommentAddedEventIT extends AbstractDaemonTest {
 
   @Inject private DynamicSet<CommentAddedListener> source;
+  @Inject private ProjectOperations projectOperations;
 
   private final LabelType label =
-      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
 
   private final LabelType pLabel =
-      category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
+      label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   private RegistrationHandle eventListenerRegistration;
   private CommentAddedListener.Event lastCommentAddedEvent;
 
   @Before
   public void setUp() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(label.getName()),
-          -1,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(pLabel.getName()),
-          0,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
-      u.save();
-    }
-
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(pLabel.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .update();
     eventListenerRegistration = source.add("gerrit", event -> lastCommentAddedEvent = event);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index d2d63cf..bda2163 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.server.mail;
 
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.ALL;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.NONE;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER;
@@ -32,6 +34,7 @@
 import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractNotificationTest;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
@@ -52,8 +55,6 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
@@ -62,6 +63,7 @@
 import org.junit.Test;
 
 public class ChangeNotificationsIT extends AbstractNotificationTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   /*
@@ -81,14 +83,14 @@
 
   @Before
   public void grantPermissions() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      ProjectConfig cfg = u.getConfig();
-      Util.allow(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, "refs/*");
-      Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/*");
-      Util.allow(cfg, Permission.ABANDON, REGISTERED_USERS, "refs/*");
-      Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.ABANDON).ref("refs/*").group(REGISTERED_USERS))
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
   }
 
   /*
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 21a7d95..f80f86b 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.common.data.LabelFunction.ANY_WITH_BLOCK;
 import static com.google.gerrit.common.data.LabelFunction.MAX_NO_BLOCK;
 import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
@@ -24,16 +26,17 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -42,10 +45,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import org.junit.After;
@@ -56,31 +56,24 @@
 public class CustomLabelIT extends AbstractDaemonTest {
 
   @Inject private DynamicSet<CommentAddedListener> source;
+  @Inject private ProjectOperations projectOperations;
 
   private final LabelType label =
-      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
 
-  private final LabelType P = category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
+  private final LabelType P = label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   private RegistrationHandle eventListenerRegistration;
   private CommentAddedListener.Event lastCommentAddedEvent;
 
   @Before
   public void setUp() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-      Util.allow(
-          u.getConfig(),
-          Permission.forLabel(label.getName()),
-          -1,
-          1,
-          anonymousUsers,
-          "refs/heads/*");
-      Util.allow(
-          u.getConfig(), Permission.forLabel(P.getName()), 0, 1, anonymousUsers, "refs/heads/*");
-      u.save();
-    }
-
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(P.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .update();
     eventListenerRegistration = source.add("gerrit", event -> lastCommentAddedEvent = event);
   }
 
@@ -292,11 +285,11 @@
         value(-1, "I would prefer this is not merged as is"),
         value(-2, "This shall not be merged"));
 
-    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.forLabel(testLabel), -2, +2, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabel).ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -314,11 +307,12 @@
     assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
 
     // Update admin's permitted range for 'Test-Label' to be -1...+1.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.remove(u.getConfig(), Permission.forLabel(testLabel), registered, "refs/heads/*");
-      Util.allow(u.getConfig(), Permission.forLabel(testLabel), -1, +1, registered, "refs/heads/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(testLabel).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(allowLabel(testLabel).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
 
     // Verify admin doesn't have +2 permission any more.
     assertPermitted(gApi.changes().id(changeId).get(), testLabel, -1, 0, 1);
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index f3b5009..1d656ea 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
 
 import com.google.common.collect.ImmutableSet;
@@ -511,12 +512,14 @@
     // create group that can view all private changes
     GroupInfo groupThatCanViewPrivateChanges =
         gApi.groups().create("groupThatCanViewPrivateChanges").get();
-    grant(
-        Project.nameKey(watchedProject),
-        "refs/*",
-        Permission.VIEW_PRIVATE_CHANGES,
-        false,
-        AccountGroup.uuid(groupThatCanViewPrivateChanges.id));
+    projectOperations
+        .project(Project.nameKey(watchedProject))
+        .forUpdate()
+        .add(
+            allow(Permission.VIEW_PRIVATE_CHANGES)
+                .ref("refs/*")
+                .group(AccountGroup.uuid(groupThatCanViewPrivateChanges.id)))
+        .update();
 
     // watch project as user that can't view private changes
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
index 060a9c3..a230e35 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -16,12 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -31,7 +33,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
 import java.io.File;
 import java.util.List;
@@ -41,6 +42,7 @@
 
 @UseLocalDisk
 public class ReflogIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
@@ -96,10 +98,11 @@
     GroupApi groupApi = gApi.groups().create(name("get-reflog"));
     groupApi.addMembers("user");
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(u.getConfig(), Permission.OWNER, AccountGroup.uuid(groupApi.get().id), "refs/*");
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(AccountGroup.uuid(groupApi.get().id)))
+        .update();
 
     requestScopeOperations.setApiUser(user.id());
     gApi.projects().name(project.get()).branch("master").reflog();
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index c69712c..1062052 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
@@ -41,6 +42,7 @@
   private static final String RULE_TEMPLATE =
       "submit_rule(submit(W)) :- \n" + "%s,\n" + "W = label('OK', ok(user(1000000))).";
 
+  @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
   @Before
@@ -85,7 +87,7 @@
   }
 
   private SubmitRecord.Status statusForRule() throws Exception {
-    String oldHead = getRemoteHead().name();
+    String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result1 =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 8ecd21c..010189b 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -15,17 +15,27 @@
 package com.google.gerrit.acceptance.testsuite.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
 import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
 import com.google.gerrit.common.data.Permission;
@@ -146,7 +156,7 @@
     projectOperations
         .project(key)
         .forUpdate()
-        .add(TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
         .update();
 
     Config config = projectOperations.project(key).getConfig();
@@ -163,7 +173,7 @@
     projectOperations
         .project(key)
         .forUpdate()
-        .add(TestProjectUpdate.deny(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .add(deny(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
         .update();
 
     Config config = projectOperations.project(key).getConfig();
@@ -180,7 +190,7 @@
     projectOperations
         .project(key)
         .forUpdate()
-        .add(TestProjectUpdate.block(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .add(block(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
         .update();
 
     Config config = projectOperations.project(key).getConfig();
@@ -197,11 +207,7 @@
     projectOperations
         .project(key)
         .forUpdate()
-        .add(
-            TestProjectUpdate.allow(Permission.ABANDON)
-                .ref("refs/foo")
-                .group(REGISTERED_USERS)
-                .force(true))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).force(true))
         .update();
 
     Config config = projectOperations.project(key).getConfig();
@@ -213,13 +219,69 @@
   }
 
   @Test
+  public void updateExclusivePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), true)
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "exclusiveGroupPermissions", "abandon");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), false)
+        .update();
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addMultipleExclusivePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), true)
+        .setExclusiveGroup(permissionKey(Permission.CREATE).ref("refs/foo"), true)
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsEntry("exclusiveGroupPermissions", "abandon create");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(permissionKey(Permission.ABANDON).ref("refs/foo"), false)
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsEntry("exclusiveGroupPermissions", "create");
+  }
+
+  @Test
   public void addMultiplePermissions() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     projectOperations
         .project(key)
         .forUpdate()
-        .add(TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
-        .add(TestProjectUpdate.allow(Permission.CREATE).ref("refs/foo").group(REGISTERED_USERS))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
+        .add(allow(Permission.CREATE).ref("refs/foo").group(REGISTERED_USERS))
         .update();
 
     Config config = projectOperations.project(key).getConfig();
@@ -266,11 +328,7 @@
     projectOperations
         .project(key)
         .forUpdate()
-        .add(
-            TestProjectUpdate.allowLabel("Code-Review")
-                .ref("refs/foo")
-                .group(REGISTERED_USERS)
-                .range(-1, 2))
+        .add(allowLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
         .update();
 
     Config config = projectOperations.project(key).getConfig();
@@ -287,11 +345,7 @@
     projectOperations
         .project(key)
         .forUpdate()
-        .add(
-            TestProjectUpdate.blockLabel("Code-Review")
-                .ref("refs/foo")
-                .group(REGISTERED_USERS)
-                .range(-1, 2))
+        .add(blockLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
         .update();
 
     Config config = projectOperations.project(key).getConfig();
@@ -308,12 +362,8 @@
     projectOperations
         .project(key)
         .forUpdate()
-        .add(
-            TestProjectUpdate.allowLabel("Code-Review")
-                .ref("refs/foo")
-                .group(REGISTERED_USERS)
-                .range(-1, 2)
-                .exclusive(true))
+        .add(allowLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/foo"), true)
         .update();
 
     Config config = projectOperations.project(key).getConfig();
@@ -324,58 +374,212 @@
         .containsExactly(
             "label-Code-Review", "-1..+2 group global:Registered-Users",
             "exclusiveGroupPermissions", "label-Code-Review");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/foo"), false)
+        .update();
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowLabelAsPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(
+            allowLabel("Code-Review")
+                .ref("refs/foo")
+                .group(REGISTERED_USERS)
+                .range(-1, 2)
+                .impersonation(true))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("labelAs-Code-Review", "-1..+2 group global:Registered-Users");
   }
 
   @Test
   public void addAllowCapability() throws Exception {
-    Project.NameKey key = projectOperations.newProject().create();
+    Config config = projectOperations.project(allProjects).getConfig();
+    assertThat(config)
+        .sectionValues("capability")
+        .doesNotContainEntry("administrateServer", "group Registered Users");
+
     projectOperations
-        .project(key)
-        .forUpdate()
+        .allProjectsForUpdate()
         .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
         .update();
 
-    Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("capability");
-    assertThat(config).subsections("capability").isEmpty();
-    assertThat(config)
+    assertThat(projectOperations.project(allProjects).getConfig())
         .sectionValues("capability")
-        .containsExactly("administrateServer", "group global:Registered-Users");
+        .containsEntry("administrateServer", "group Registered Users");
   }
 
   @Test
   public void addAllowCapabilityWithRange() throws Exception {
-    Project.NameKey key = projectOperations.newProject().create();
+    Config config = projectOperations.project(allProjects).getConfig();
+    assertThat(config).sectionValues("capability").doesNotContainKey("queryLimit");
+
     projectOperations
-        .project(key)
-        .forUpdate()
+        .allProjectsForUpdate()
         .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 5000))
         .update();
 
-    Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("capability");
-    assertThat(config).subsections("capability").isEmpty();
-    assertThat(config)
+    assertThat(projectOperations.project(allProjects).getConfig())
         .sectionValues("capability")
-        .containsExactly("queryLimit", "+0..+5000 group global:Registered-Users");
+        .containsEntry("queryLimit", "+0..+5000 group Registered Users");
   }
 
   @Test
   public void addAllowCapabilityWithDefaultRange() throws Exception {
+    Config config = projectOperations.project(allProjects).getConfig();
+    assertThat(config).sectionValues("capability").doesNotContainKey("queryLimit");
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS))
+        .update();
+
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsEntry("queryLimit", "+0..+" + DEFAULT_MAX_QUERY_LIMIT + " group Registered Users");
+  }
+
+  @Test
+  public void removePermission() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     projectOperations
         .project(key)
         .forUpdate()
-        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
         .update();
-
-    Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("capability");
-    assertThat(config).subsections("capability").isEmpty();
-    assertThat(config)
-        .sectionValues("capability")
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
         .containsExactly(
-            "queryLimit", "+0..+" + DEFAULT_MAX_QUERY_LIMIT + " group global:Registered-Users");
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Project-Owners");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(permissionKey(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "group global:Project-Owners");
+  }
+
+  @Test
+  public void removeLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .add(allowLabel("Code-Review").ref("refs/foo").group(PROJECT_OWNERS).range(-2, 1))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "label-Code-Review", "-1..+2 group global:Registered-Users",
+            "label-Code-Review", "-2..+1 group global:Project-Owners");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(labelPermissionKey("Code-Review").ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "-2..+1 group global:Project-Owners");
+  }
+
+  @Test
+  public void removeCapability() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .add(allowCapability(ADMINISTRATE_SERVER).group(PROJECT_OWNERS))
+        .update();
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .containsAtLeastEntriesIn(
+            ImmutableListMultimap.of(
+                "administrateServer", "group Registered Users",
+                "administrateServer", "group Project Owners"));
+
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(capabilityKey(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("capability")
+        .doesNotContainEntry("administrateServer", "group Registered Users");
+  }
+
+  @Test
+  public void removeOnePermissionForAllGroupsFromOneAccessSection() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
+        .add(allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .add(allow(Permission.CREATE).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsAtLeastEntriesIn(
+            ImmutableListMultimap.of(
+                "abandon", "group global:Project-Owners",
+                "abandon", "group global:Registered-Users",
+                "create", "group global:Registered-Users"));
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(permissionKey(Permission.ABANDON).ref("refs/foo"))
+        .update();
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).subsectionValues("access", "refs/foo").doesNotContainKey("abandon");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsEntry("create", "group global:Registered-Users");
+  }
+
+  @Test
+  public void updatingCapabilitiesNotAllowedForNonAllProjects() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    assertThrows(
+        RuntimeException.class,
+        () ->
+            projectOperations
+                .project(key)
+                .forUpdate()
+                .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+                .update());
+    assertThrows(
+        RuntimeException.class,
+        () ->
+            projectOperations
+                .project(key)
+                .forUpdate()
+                .remove(capabilityKey(ADMINISTRATE_SERVER))
+                .update());
   }
 
   private void deleteRefsMetaConfig(Project.NameKey key) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
new file mode 100644
index 0000000..b81b23d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_BATCH_CHANGES_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
+import static com.google.gerrit.common.data.Permission.ABANDON;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermissionKey;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import java.util.function.Function;
+import org.junit.Test;
+
+public class TestProjectUpdateTest {
+  private static final AllProjectsName ALL_PROJECTS_NAME = new AllProjectsName("All-Projects");
+
+  @Test
+  public void testCapabilityDisallowsZeroRangeOnCapabilityThatHasNoRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS).range(0, 0).build());
+  }
+
+  @Test
+  public void testCapabilityAllowsZeroRangeOnCapabilityThatHasRange() throws Exception {
+    TestCapability c = allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 0).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(0);
+  }
+
+  @Test
+  public void testCapabilityDisallowsInvertedRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(1, 0).build());
+  }
+
+  @Test
+  public void testCapabilityDisallowsRangeIfCapabilityDoesNotSupportRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS).range(-1, 1).build());
+  }
+
+  @Test
+  public void testCapabilityRangeIsZeroIfCapabilityDoesNotSupportRange() throws Exception {
+    TestCapability c = allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(0);
+  }
+
+  @Test
+  public void testCapabilityUsesDefaultRangeIfUnspecified() throws Exception {
+    TestCapability c = allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(DEFAULT_MAX_QUERY_LIMIT);
+
+    c = allowCapability(BATCH_CHANGES_LIMIT).group(REGISTERED_USERS).build();
+    assertThat(c.min()).isEqualTo(0);
+    assertThat(c.max()).isEqualTo(DEFAULT_MAX_BATCH_CHANGES_LIMIT);
+  }
+
+  @Test
+  public void testCapabilityUsesExplicitRangeIfSpecified() throws Exception {
+    TestCapability c = allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(5, 20).build();
+    assertThat(c.min()).isEqualTo(5);
+    assertThat(c.max()).isEqualTo(20);
+  }
+
+  @Test
+  public void testLabelPermissionRequiresValidLabelName() throws Exception {
+    Function<String, TestLabelPermission.Builder> labelBuilder =
+        name -> allowLabel(name).ref("refs/*").group(REGISTERED_USERS).range(-1, 1);
+    assertThat(labelBuilder.apply("Code-Review").build().name()).isEqualTo("Code-Review");
+    assertThrows(RuntimeException.class, () -> labelBuilder.apply("not a label").build());
+    assertThrows(RuntimeException.class, () -> labelBuilder.apply("label-Code-Review").build());
+  }
+
+  @Test
+  public void testLabelPermissionDisallowsZeroRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(0, 0).build());
+  }
+
+  @Test
+  public void testLabelPermissionDisallowsInvertedRange() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(1, 0).build());
+  }
+
+  @Test
+  public void testPermissionKeyRequiresValidRefName() throws Exception {
+    Function<String, TestPermissionKey.Builder> keyBuilder =
+        ref -> permissionKey(ABANDON).ref(ref).group(REGISTERED_USERS);
+    assertThat(keyBuilder.apply("refs/*").build().section()).isEqualTo("refs/*");
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply(null).build());
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply("foo").build());
+  }
+
+  @Test
+  public void testLabelPermissionKeyRequiresValidLabelName() throws Exception {
+    Function<String, TestPermissionKey.Builder> keyBuilder =
+        label -> labelPermissionKey(label).ref("refs/*").group(REGISTERED_USERS);
+    assertThat(keyBuilder.apply("Code-Review").build().name()).isEqualTo("label-Code-Review");
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply(null).build());
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply("not a label").build());
+    assertThrows(RuntimeException.class, () -> keyBuilder.apply("label-Code-Review").build());
+  }
+
+  @Test
+  public void testPermissionKeyDisallowsSettingRefOnGlobalCapability() throws Exception {
+    assertThrows(RuntimeException.class, () -> capabilityKey(ADMINISTRATE_SERVER).ref("refs/*"));
+  }
+
+  @Test
+  public void testProjectUpdateDisallowsGroupOnExclusiveGroupPermissionKey() throws Exception {
+    TestPermissionKey.Builder b = permissionKey(ABANDON).ref("refs/*");
+    Function<TestPermissionKey.Builder, TestProjectUpdate.Builder> updateBuilder =
+        kb -> builder().setExclusiveGroup(kb, true);
+
+    assertThat(updateBuilder.apply(b).build().exclusiveGroupPermissions())
+        .containsExactly(b.build(), true);
+
+    b.group(REGISTERED_USERS);
+    assertThrows(RuntimeException.class, () -> updateBuilder.apply(b).build());
+  }
+
+  @Test
+  public void hasCapabilityUpdates() throws Exception {
+    assertThat(builder().build().hasCapabilityUpdates()).isFalse();
+    assertThat(
+            builder()
+                .add(allow(ABANDON).ref("refs/*").group(REGISTERED_USERS))
+                .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(0, 1))
+                .remove(permissionKey(ABANDON).ref("refs/foo"))
+                .remove(labelPermissionKey("Code-Review").ref("refs/foo"))
+                .setExclusiveGroup(permissionKey(ABANDON).ref("refs/bar"), true)
+                .setExclusiveGroup(labelPermissionKey(ABANDON).ref("refs/bar"), true)
+                .build()
+                .hasCapabilityUpdates())
+        .isFalse();
+    assertThat(
+            builder(ALL_PROJECTS_NAME)
+                .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+                .build()
+                .hasCapabilityUpdates())
+        .isTrue();
+    assertThat(
+            builder(ALL_PROJECTS_NAME)
+                .remove(capabilityKey(ADMINISTRATE_SERVER))
+                .build()
+                .hasCapabilityUpdates())
+        .isTrue();
+  }
+
+  @Test
+  public void updatingCapabilitiesNotAllowedForNonAllProjects() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () -> builder().add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS)).update());
+    assertThrows(
+        RuntimeException.class,
+        () -> builder().remove(capabilityKey(ADMINISTRATE_SERVER)).update());
+  }
+
+  private static TestProjectUpdate.Builder builder() {
+    return builder(Project.nameKey("test-project"));
+  }
+
+  private static TestProjectUpdate.Builder builder(Project.NameKey nameKey) {
+    return TestProjectUpdate.builder(nameKey, ALL_PROJECTS_NAME, u -> {});
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index ba4d6fc..ae00e0d 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.elasticsearch;
 
+import static java.util.concurrent.TimeUnit.MINUTES;
+
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
@@ -65,14 +67,16 @@
   @Rule public final GerritTestName testName = new GerritTestName();
 
   @After
-  public void closeIndex() {
-    client.execute(
-        new HttpPost(
-            String.format(
-                "http://localhost:%d/%s*/_close",
-                nodeInfo.port, testName.getSanitizedMethodName())),
-        HttpClientContext.create(),
-        null);
+  public void closeIndex() throws Exception {
+    client
+        .execute(
+            new HttpPost(
+                String.format(
+                    "http://localhost:%d/%s*/_close",
+                    nodeInfo.port, testName.getSanitizedMethodName())),
+            HttpClientContext.create(),
+            null)
+        .get(5, MINUTES);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 5e3b35f..5066368 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -34,6 +34,7 @@
     ],
     deps = [
         ":custom-truth-subjects",
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/exceptions",
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index b9ff5af..d4ad7d7 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -36,7 +36,7 @@
 import org.junit.Test;
 
 public class AccountResolverTest {
-  private class TestSearcher extends StringSearcher {
+  private static class TestSearcher extends StringSearcher {
     private final String pattern;
     private final boolean shortCircuit;
     private final ImmutableList<AccountState> accounts;
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 85559cb..beeca21 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -14,14 +14,15 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.common.data.Permission.forLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.allow;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.junit.Assert.assertEquals;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -69,6 +70,7 @@
   @Inject private ChangeNotes.Factory changeNotesFactory;
   @Inject private ProjectConfig.Factory projectConfigFactory;
   @Inject private GerritApi gApi;
+  @Inject private ProjectOperations projectOperations;
 
   private LifecycleManager lifecycle;
   private Account.Id userId;
@@ -102,7 +104,7 @@
       }
     }
     LabelType lt =
-        category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+        label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
     pc.getLabelSections().put(lt.getName(), lt);
     save(pc);
   }
@@ -128,10 +130,11 @@
 
   @Test
   public void noNormalizeByPermission() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    allow(pc, forLabel("Verified"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     PatchSetApproval cr = psa(userId, "Code-Review", 2);
     PatchSetApproval v = psa(userId, "Verified", 1);
@@ -140,10 +143,11 @@
 
   @Test
   public void normalizeByType() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -5, 5, REGISTERED_USERS, "refs/heads/*");
-    allow(pc, forLabel("Verified"), -5, 5, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
+        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
+        .update();
 
     PatchSetApproval cr = psa(userId, "Code-Review", 5);
     PatchSetApproval v = psa(userId, "Verified", 5);
@@ -161,9 +165,10 @@
 
   @Test
   public void explicitZeroVoteOnNonEmptyRangeIsPresent() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     PatchSetApproval cr = psa(userId, "Code-Review", 0);
     PatchSetApproval v = psa(userId, "Verified", 0);
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 761d682..544d655 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -16,53 +16,44 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
 import static com.google.gerrit.common.data.Permission.LABEL;
 import static com.google.gerrit.common.data.Permission.OWNER;
 import static com.google.gerrit.common.data.Permission.PUSH;
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.common.data.Permission.SUBMIT;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.ADMIN;
-import static com.google.gerrit.server.project.testing.Util.DEVS;
-import static com.google.gerrit.server.project.testing.Util.allow;
-import static com.google.gerrit.server.project.testing.Util.allowExclusive;
-import static com.google.gerrit.server.project.testing.Util.block;
-import static com.google.gerrit.server.project.testing.Util.deny;
-import static com.google.gerrit.server.project.testing.Util.doNotInherit;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static com.google.gerrit.testing.InMemoryRepositoryManager.newRepository;
 
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.exceptions.InvalidNameException;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RefPattern;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.InMemoryModule;
@@ -70,25 +61,21 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.io.IOException;
 import java.util.ArrayList;
-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 org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 public class RefControlTest {
-  private void assertAdminsAreOwnersAndDevsAreNot() {
-    ProjectControl uBlah = user(local, DEVS);
-    ProjectControl uAdmin = user(local, DEVS, ADMIN);
+  private static final AccountGroup.UUID ADMIN = AccountGroup.uuid("test.admin");
+  private static final AccountGroup.UUID DEVS = AccountGroup.uuid("test.devs");
+
+  private void assertAdminsAreOwnersAndDevsAreNot() throws Exception {
+    ProjectControl uBlah = user(localKey, DEVS);
+    ProjectControl uAdmin = user(localKey, DEVS, ADMIN);
 
     assertWithMessage("not owner").that(uBlah.isOwner()).isFalse();
     assertWithMessage("is owner").that(uAdmin.isOwner()).isTrue();
@@ -178,106 +165,29 @@
     assertWithMessage("cannot vote " + score).that(range.contains(score)).isFalse();
   }
 
-  private final AllProjectsName allProjectsName =
-      new AllProjectsName(AllProjectsNameProvider.DEFAULT);
-  private final AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
   private final AccountGroup.UUID fixers = AccountGroup.uuid("test.fixers");
-  private final Map<Project.NameKey, ProjectState> all = new HashMap<>();
-  private Project.NameKey localKey = Project.nameKey("local");
-  private ProjectConfig local;
-  private Project.NameKey parentKey = Project.nameKey("parent");
-  private ProjectConfig parent;
-  private InMemoryRepositoryManager repoManager;
-  private ProjectCache projectCache;
-  private PermissionCollection.Factory sectionSorter;
-  private ChangeControl.Factory changeControlFactory;
+  private final Project.NameKey localKey = Project.nameKey("local");
+  private final Project.NameKey parentKey = Project.nameKey("parent");
 
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private CapabilityCollection.Factory capabilityCollectionFactory;
+  @Inject private AllProjectsName allProjectsName;
+  @Inject private InMemoryRepositoryManager repoManager;
+  @Inject private MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject private ProjectCache projectCache;
+  @Inject private ProjectControl.Factory projectControlFactory;
+  @Inject private ProjectOperations projectOperations;
   @Inject private SchemaCreator schemaCreator;
   @Inject private SingleVersionListener singleVersionListener;
   @Inject private ThreadLocalRequestContext requestContext;
-  @Inject private DefaultRefFilter.Factory refFilterFactory;
-  @Inject private TransferConfig transferConfig;
-  @Inject private MetricMaker metricMaker;
-  @Inject private ProjectConfig.Factory projectConfigFactory;
 
   @Before
   public void setUp() throws Exception {
-    repoManager = new InMemoryRepositoryManager();
-    projectCache =
-        new ProjectCache() {
-          @Override
-          public ProjectState getAllProjects() {
-            return get(allProjectsName);
-          }
-
-          @Override
-          public ProjectState getAllUsers() {
-            return null;
-          }
-
-          @Override
-          public ProjectState get(Project.NameKey projectName) {
-            return all.get(projectName);
-          }
-
-          @Override
-          public void evict(Project p) {}
-
-          @Override
-          public void remove(Project p) {}
-
-          @Override
-          public void remove(Project.NameKey name) {}
-
-          @Override
-          public ImmutableSortedSet<Project.NameKey> all() {
-            return ImmutableSortedSet.of();
-          }
-
-          @Override
-          public ImmutableSortedSet<Project.NameKey> byName(String prefix) {
-            return ImmutableSortedSet.of();
-          }
-
-          @Override
-          public void onCreateProject(Project.NameKey newProjectName) {}
-
-          @Override
-          public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-            return Collections.emptySet();
-          }
-
-          @Override
-          public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
-            return all.get(projectName);
-          }
-
-          @Override
-          public void evict(Project.NameKey p) {}
-
-          @Override
-          public ProjectState checkedGet(Project.NameKey projectName, boolean strict)
-              throws Exception {
-            return all.get(projectName);
-          }
-        };
-
     Injector injector = Guice.createInjector(new InMemoryModule());
     injector.injectMembers(this);
 
-    try {
-      Repository repo = repoManager.createRepository(allProjectsName);
-      ProjectConfig allProjects =
-          projectConfigFactory.create(Project.nameKey(allProjectsName.get()));
-      allProjects.load(repo);
-      LabelType cr = Util.codeReview();
-      allProjects.getLabelSections().put(cr.getName(), cr);
-      add(allProjects);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
+    // Tests previously used ProjectConfig.Factory to create ProjectConfigs without going through
+    // the ProjectCache, which was wrong. Manually call getInstance so we don't store it in a
+    // field that is accessible to test methods.
+    ProjectConfig.Factory projectConfigFactory = injector.getInstance(ProjectConfig.Factory.class);
 
     singleVersionListener.start();
     try {
@@ -286,58 +196,79 @@
       singleVersionListener.stop();
     }
 
-    Cache<SectionSortCache.EntryKey, SectionSortCache.EntryVal> c =
-        CacheBuilder.newBuilder().build();
-    sectionSorter = new PermissionCollection.Factory(new SectionSortCache(c), metricMaker);
+    // Clear out All-Projects and use the lowest-level API possible for project creation, so the
+    // only ACL entries are exactly what is initialized by this test, and we aren't subject to
+    // changing defaults in SchemaCreator or ProjectCreator.
+    try (Repository allProjectsRepo = repoManager.createRepository(allProjectsName)) {
+      new TestRepository<>(allProjectsRepo).delete(REFS_CONFIG);
+      try (MetaDataUpdate md = metaDataUpdateFactory.create(allProjectsName)) {
+        ProjectConfig allProjectsConfig = projectConfigFactory.create(allProjectsName);
+        allProjectsConfig.load(md);
+        LabelType cr = TestLabels.codeReview();
+        allProjectsConfig.getLabelSections().put(cr.getName(), cr);
+        allProjectsConfig.commit(md);
+      }
+    }
 
-    parent = projectConfigFactory.create(parentKey);
-    parent.load(newRepository(parentKey));
-    add(parent);
-
-    local = projectConfigFactory.create(localKey);
-    local.load(newRepository(localKey));
-    add(local);
-    local.getProject().setParentName(parentKey);
+    repoManager.createRepository(parentKey).close();
+    repoManager.createRepository(localKey).close();
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(localKey)) {
+      ProjectConfig newLocal = projectConfigFactory.create(localKey);
+      newLocal.load(md);
+      newLocal.getProject().setParentName(parentKey);
+      newLocal.commit(md);
+    }
 
     requestContext.setContext(() -> null);
-
-    changeControlFactory = injector.getInstance(ChangeControl.Factory.class);
   }
 
   @After
-  public void tearDown() {
+  public void tearDown() throws Exception {
     requestContext.setContext(null);
   }
 
   @Test
-  public void ownerProject() {
-    allow(local, OWNER, ADMIN, "refs/*");
-
+  public void ownerProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .update();
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
-  public void denyOwnerProject() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    deny(local, OWNER, DEVS, "refs/*");
-
+  public void denyOwnerProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(deny(OWNER).ref("refs/*").group(DEVS))
+        .update();
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
-  public void blockOwnerProject() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    block(local, OWNER, DEVS, "refs/*");
-
+  public void blockOwnerProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(block(OWNER).ref("refs/*").group(DEVS))
+        .update();
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
-  public void branchDelegation1() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    allow(local, OWNER, DEVS, "refs/heads/x/*");
+  public void branchDelegation1() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(allow(OWNER).ref("refs/heads/x/*").group(DEVS))
+        .update();
 
-    ProjectControl uDev = user(local, DEVS);
+    ProjectControl uDev = user(localKey, DEVS);
     assertNotOwner(uDev);
 
     assertOwner("refs/heads/x/*", uDev);
@@ -349,13 +280,17 @@
   }
 
   @Test
-  public void branchDelegation2() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    allow(local, OWNER, DEVS, "refs/heads/x/*");
-    allow(local, OWNER, fixers, "refs/heads/x/y/*");
-    doNotInherit(local, OWNER, "refs/heads/x/y/*");
+  public void branchDelegation2() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(ADMIN))
+        .add(allow(OWNER).ref("refs/heads/x/*").group(DEVS))
+        .add(allow(OWNER).ref("refs/heads/x/y/*").group(fixers))
+        .setExclusiveGroup(permissionKey(OWNER).ref("refs/heads/x/y/*"), true)
+        .update();
 
-    ProjectControl uDev = user(local, DEVS);
+    ProjectControl uDev = user(localKey, DEVS);
     assertNotOwner(uDev);
 
     assertOwner("refs/heads/x/*", uDev);
@@ -364,7 +299,7 @@
     assertNotOwner("refs/*", uDev);
     assertNotOwner("refs/heads/master", uDev);
 
-    ProjectControl uFix = user(local, fixers);
+    ProjectControl uFix = user(localKey, fixers);
     assertNotOwner(uFix);
 
     assertOwner("refs/heads/x/y/*", uFix);
@@ -376,38 +311,62 @@
   }
 
   @Test
-  public void inheritRead_SingleBranchDeniesUpload() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
-    doNotInherit(local, READ, "refs/heads/foobar");
-    doNotInherit(local, PUSH, "refs/for/refs/heads/foobar");
+  public void inheritRead_SingleBranchDeniesUpload() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(PUSH).ref("refs/for/refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/foobar").group(REGISTERED_USERS))
+        .setExclusiveGroup(permissionKey(READ).ref("refs/heads/foobar"), true)
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/for/refs/heads/foobar"), true)
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanUpload(u);
     assertCreateChange("refs/heads/master", u);
     assertCannotCreateChange("refs/heads/foobar", u);
   }
 
   @Test
-  public void blockPushDrafts() {
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-    allow(local, PUSH, REGISTERED_USERS, "refs/drafts/*");
+  public void blockPushDrafts() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/for/refs/*").group(REGISTERED_USERS))
+        .add(block(PUSH).ref("refs/drafts/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/drafts/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCreateChange("refs/heads/master", u);
     assertThat(u.controlForRef("refs/drafts/master").canPerform(PUSH)).isFalse();
   }
 
   @Test
-  public void blockPushDraftsUnblockAdmin() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-    allow(parent, PUSH, ADMIN, "refs/drafts/*");
-    allow(local, PUSH, REGISTERED_USERS, "refs/drafts/*");
+  public void blockPushDraftsUnblockAdmin() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/drafts/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/drafts/*").group(ADMIN))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/drafts/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
-    ProjectControl a = user(local, "a", ADMIN);
+    ProjectControl u = user(localKey);
+    ProjectControl a = user(localKey, "a", ADMIN);
 
     assertWithMessage("push is allowed")
         .that(a.controlForRef("refs/drafts/master").canPerform(PUSH))
@@ -418,12 +377,20 @@
   }
 
   @Test
-  public void inheritRead_SingleBranchDoesNotOverrideInherited() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
+  public void inheritRead_SingleBranchDoesNotOverrideInherited() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(PUSH).ref("refs/for/refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/foobar").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanUpload(u);
     assertCreateChange("refs/heads/master", u);
     assertCreateChange("refs/heads/foobar", u);
@@ -431,31 +398,50 @@
 
   @Test
   public void inheritDuplicateSections() throws Exception {
-    allow(parent, READ, ADMIN, "refs/*");
-    allow(local, READ, DEVS, "refs/heads/*");
-    assertCanAccess(user(local, "a", ADMIN));
-
-    local = projectConfigFactory.create(localKey);
-    local.load(newRepository(localKey));
-    local.getProject().setParentName(parentKey);
-    allow(local, READ, DEVS, "refs/*");
-    assertCanAccess(user(local, "d", DEVS));
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(ADMIN))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(DEVS))
+        .update();
+    assertCanAccess(user(localKey, "a", ADMIN));
+    assertCanAccess(user(localKey, "d", DEVS));
   }
 
   @Test
-  public void inheritRead_OverrideWithDeny() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/*");
+  public void inheritRead_OverrideWithDeny() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
-    assertAccessDenied(user(local));
+    assertAccessDenied(user(localKey));
   }
 
   @Test
-  public void inheritRead_AppendWithDenyOfRef() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/heads/*");
+  public void inheritRead_AppendWithDenyOfRef() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanAccess(u);
     assertCanRead("refs/master", u);
     assertCanRead("refs/tags/foobar", u);
@@ -463,12 +449,20 @@
   }
 
   @Test
-  public void inheritRead_OverridesAndDeniesOfRef() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/*");
+  public void inheritRead_OverridesAndDeniesOfRef() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCanAccess(u);
     assertCannotRead("refs/foobar", u);
     assertCannotRead("refs/tags/foobar", u);
@@ -476,100 +470,164 @@
   }
 
   @Test
-  public void inheritSubmit_OverridesAndDeniesOfRef() {
-    allow(parent, SUBMIT, REGISTERED_USERS, "refs/*");
-    deny(local, SUBMIT, REGISTERED_USERS, "refs/*");
-    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
+  public void inheritSubmit_OverridesAndDeniesOfRef() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(deny(SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCannotSubmit("refs/foobar", u);
     assertCannotSubmit("refs/tags/foobar", u);
     assertCanSubmit("refs/heads/foobar", u);
   }
 
   @Test
-  public void cannotUploadToAnyRef() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(local, READ, DEVS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/for/refs/heads/*");
+  public void cannotUploadToAnyRef() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/*").group(DEVS))
+        .add(allow(PUSH).ref("refs/for/refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertCannotUpload(u);
     assertCannotCreateChange("refs/heads/master", u);
   }
 
   @Test
-  public void usernamePatternCanUploadToAnyRef() {
-    allow(local, PUSH, REGISTERED_USERS, "refs/heads/users/${username}/*");
-    ProjectControl u = user(local, "a-registered-user");
+  public void usernamePatternCanUploadToAnyRef() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/users/${username}/*").group(REGISTERED_USERS))
+        .update();
+    ProjectControl u = user(localKey, "a-registered-user");
     assertCanUpload(u);
   }
 
   @Test
-  public void usernamePatternNonRegex() {
-    allow(local, READ, DEVS, "refs/sb/${username}/heads/*");
+  public void usernamePatternNonRegex() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/sb/${username}/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, "u", DEVS);
-    ProjectControl d = user(local, "d", DEVS);
+    ProjectControl u = user(localKey, "u", DEVS);
+    ProjectControl d = user(localKey, "d", DEVS);
     assertCannotRead("refs/sb/d/heads/foobar", u);
     assertCanRead("refs/sb/d/heads/foobar", d);
   }
 
   @Test
-  public void usernamePatternWithRegex() {
-    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
+  public void usernamePatternWithRegex() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/sb/${username}/heads/.*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, "d.v", DEVS);
-    ProjectControl d = user(local, "dev", DEVS);
+    ProjectControl u = user(localKey, "d.v", DEVS);
+    ProjectControl d = user(localKey, "dev", DEVS);
     assertCannotRead("refs/sb/dev/heads/foobar", u);
     assertCanRead("refs/sb/dev/heads/foobar", d);
   }
 
   @Test
-  public void usernameEmailPatternWithRegex() {
-    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
+  public void usernameEmailPatternWithRegex() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/sb/${username}/heads/.*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, "d.v@ger-rit.org", DEVS);
-    ProjectControl d = user(local, "dev@ger-rit.org", DEVS);
+    ProjectControl u = user(localKey, "d.v@ger-rit.org", DEVS);
+    ProjectControl d = user(localKey, "dev@ger-rit.org", DEVS);
     assertCannotRead("refs/sb/dev@ger-rit.org/heads/foobar", u);
     assertCanRead("refs/sb/dev@ger-rit.org/heads/foobar", d);
   }
 
   @Test
-  public void sortWithRegex() {
-    allow(local, READ, DEVS, "^refs/heads/.*");
-    allow(parent, READ, ANONYMOUS_USERS, "^refs/heads/.*-QA-.*");
+  public void sortWithRegex() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/heads/.*").group(DEVS))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/heads/.*-QA-.*").group(ANONYMOUS_USERS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
-    ProjectControl d = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
+    ProjectControl d = user(localKey, DEVS);
     assertCanRead("refs/heads/foo-QA-bar", u);
     assertCanRead("refs/heads/foo-QA-bar", d);
   }
 
   @Test
-  public void blockRule_ParentBlocksChild() {
-    allow(local, PUSH, DEVS, "refs/tags/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-    ProjectControl u = user(local, DEVS);
+  public void blockRule_ParentBlocksChild() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/tags/*").group(DEVS))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/tags/V10", u);
   }
 
   @Test
-  public void blockRule_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
-    allow(local, PUSH, DEVS, "refs/tags/*");
-    block(local, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
+  public void blockRule_ParentBlocksChildEvenIfAlreadyBlockedInChild() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/tags/*").group(DEVS))
+        .add(block(PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/tags/*").group(ANONYMOUS_USERS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/tags/V10", u);
   }
 
   @Test
-  public void blockLabelRange_ParentBlocksChild() {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void blockLabelRange_ParentBlocksChild() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
 
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
@@ -579,12 +637,20 @@
   }
 
   @Test
-  public void blockLabelRange_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void blockLabelRange_ParentBlocksChildEvenIfAlreadyBlockedInChild() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
 
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
@@ -594,251 +660,396 @@
   }
 
   @Test
-  public void inheritSubmit_AllowInChildDoesntAffectUnblockInParent() {
-    block(parent, SUBMIT, ANONYMOUS_USERS, "refs/heads/*");
-    allow(parent, SUBMIT, REGISTERED_USERS, "refs/heads/*");
-    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
+  public void inheritSubmit_AllowInChildDoesntAffectUnblockInParent() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(SUBMIT).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
 
-    ProjectControl u = user(local);
+    ProjectControl u = user(localKey);
     assertWithMessage("submit is allowed")
         .that(u.controlForRef("refs/heads/master").canPerform(SUBMIT))
         .isTrue();
   }
 
   @Test
-  public void unblockNoForce() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/*");
+  public void unblockNoForce() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCanUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockForce() {
-    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    r.setForce(true);
-    allow(local, PUSH, DEVS, "refs/heads/*").setForce(true);
+  public void unblockForce() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS).force(true))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCanForceUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockRead_NotPossible() {
-    block(parent, READ, ANONYMOUS_USERS, "refs/*");
-    allow(parent, READ, ADMIN, "refs/*");
-    allow(local, READ, ANONYMOUS_USERS, "refs/*");
-    allow(local, READ, ADMIN, "refs/*");
-    ProjectControl u = user(local);
+  public void unblockRead_NotPossible() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/*").group(ADMIN))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/*").group(ADMIN))
+        .update();
+
+    ProjectControl u = user(localKey);
     assertCannotRead("refs/heads/master", u);
   }
 
   @Test
-  public void unblockForceWithAllowNoForce_NotPossible() {
-    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    r.setForce(true);
-    allow(local, PUSH, DEVS, "refs/heads/*");
+  public void unblockForceWithAllowNoForce_NotPossible() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS).force(true))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotForceUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockMoreSpecificRef_Fails() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
+  public void unblockMoreSpecificRef_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockMoreSpecificRefInLocal_Fails() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
+  public void unblockMoreSpecificRefInLocal_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockMoreSpecificRefWithExclusiveFlag() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master", true);
+  public void unblockMoreSpecificRefWithExclusiveFlag() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCanUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockVoteMoreSpecificRefWithExclusiveFlag() {
-    String perm = LABEL + "Code-Review";
+  public void unblockVoteMoreSpecificRefWithExclusiveFlag() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/master"), true)
+        .update();
 
-    block(local, perm, -1, 1, ANONYMOUS_USERS, "refs/heads/*");
-    allowExclusive(local, perm, -2, 2, DEVS, "refs/heads/master");
-
-    ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(perm);
+    ProjectControl u = user(localKey, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
   }
 
   @Test
-  public void unblockFromParentDoesNotAffectChild() {
-    allow(parent, PUSH, DEVS, "refs/heads/master", true);
-    block(local, PUSH, DEVS, "refs/heads/master");
+  public void unblockFromParentDoesNotAffectChild() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/master").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockFromParentDoesNotAffectChildDifferentGroups() {
-    allow(parent, PUSH, DEVS, "refs/heads/master", true);
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
+  public void unblockFromParentDoesNotAffectChildDifferentGroups() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master", true);
+  public void unblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/master"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void blockMoreSpecificRefWithinProject() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/secret");
-    allow(local, PUSH, DEVS, "refs/heads/*", true);
+  public void blockMoreSpecificRefWithinProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/secret").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .setExclusiveGroup(permissionKey(PUSH).ref("refs/heads/*"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/secret", u);
     assertCanUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
-    allow(local, SUBMIT, DEVS, "refs/heads/master", true);
+  public void unblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/master").group(DEVS))
+        .add(allow(SUBMIT).ref("refs/heads/master").group(DEVS))
+        .setExclusiveGroup(permissionKey(SUBMIT).ref("refs/heads/master"), true)
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockLargerScope_Fails() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
-    allow(local, PUSH, DEVS, "refs/heads/*");
+  public void unblockLargerScope_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
   @Test
-  public void unblockInLocal_Fails() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, fixers, "refs/heads/*");
+  public void unblockInLocal_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(PUSH).ref("refs/heads/*").group(fixers))
+        .update();
 
-    ProjectControl f = user(local, fixers);
+    ProjectControl f = user(localKey, fixers);
     assertCannotUpdate("refs/heads/master", f);
   }
 
   @Test
-  public void unblockInParentBlockInLocal() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(parent, PUSH, DEVS, "refs/heads/*");
-    block(local, PUSH, DEVS, "refs/heads/*");
+  public void unblockInParentBlockInLocal() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(PUSH).ref("refs/heads/*").group(DEVS))
+        .update();
 
-    ProjectControl d = user(local, DEVS);
+    ProjectControl d = user(localKey, DEVS);
     assertCannotUpdate("refs/heads/master", d);
   }
 
   @Test
-  public void unblockForceEditTopicName() {
-    block(local, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
+  public void unblockForceEditTopicName() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(block(EDIT_TOPIC_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(EDIT_TOPIC_NAME).ref("refs/heads/*").group(DEVS).force(true))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     assertWithMessage("u can edit topic name")
         .that(u.controlForRef("refs/heads/master").canForceEditTopicName())
         .isTrue();
   }
 
   @Test
-  public void unblockInLocalForceEditTopicName_Fails() {
-    block(parent, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
+  public void unblockInLocalForceEditTopicName_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(EDIT_TOPIC_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(EDIT_TOPIC_NAME).ref("refs/heads/*").group(DEVS).force(true))
+        .update();
 
-    ProjectControl u = user(local, REGISTERED_USERS);
+    ProjectControl u = user(localKey, REGISTERED_USERS);
     assertWithMessage("u can't edit topic name")
         .that(u.controlForRef("refs/heads/master").canForceEditTopicName())
         .isFalse();
   }
 
   @Test
-  public void unblockRange() {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void unblockRange() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
 
   @Test
-  public void unblockRangeOnMoreSpecificRef_Fails() {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/master");
+  public void unblockRangeOnMoreSpecificRef_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void unblockRangeOnLargerScope_Fails() {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/master");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void unblockRangeOnLargerScope_Fails() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(
+            blockLabel("Code-Review").ref("refs/heads/master").group(ANONYMOUS_USERS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void nonconfiguredCannotVote() {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void nonconfiguredCannotVote() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, REGISTERED_USERS);
+    ProjectControl u = user(localKey, REGISTERED_USERS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-1, range);
     assertCannotVote(1, range);
   }
 
   @Test
-  public void unblockInLocalRange_Fails() {
-    block(parent, LABEL + "Code-Review", -1, 1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+  public void unblockInLocalRange_Fails() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void unblockRangeForChangeOwner() {
-    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+  public void unblockRangeForChangeOwner() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range =
         u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review", true);
     assertCanVote(-2, range);
@@ -846,65 +1057,97 @@
   }
 
   @Test
-  public void unblockRangeForNotChangeOwner() {
-    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+  public void unblockRangeForNotChangeOwner() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void blockChangeOwnerVote() {
-    block(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+  public void blockChangeOwnerVote() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void unionOfPermissibleVotes() {
-    allow(local, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
+  public void unionOfPermissibleVotes() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
 
   @Test
-  public void unionOfPermissibleVotesPermissionOrder() {
-    allow(local, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
+  public void unionOfPermissibleVotesPermissionOrder() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
 
   @Test
-  public void unionOfBlockedVotes() {
-    allow(parent, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
-    block(local, LABEL + "Code-Review", -2, +1, REGISTERED_USERS, "refs/heads/*");
+  public void unionOfBlockedVotes() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +1))
+        .update();
 
-    ProjectControl u = user(local, DEVS);
+    ProjectControl u = user(localKey, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
     assertCannotVote(1, range);
   }
 
   @Test
-  public void blockOwner() {
-    block(parent, OWNER, ANONYMOUS_USERS, "refs/*");
-    allow(local, OWNER, DEVS, "refs/*");
+  public void blockOwner() throws Exception {
+    projectOperations
+        .project(parentKey)
+        .forUpdate()
+        .add(block(OWNER).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(OWNER).ref("refs/*").group(DEVS))
+        .update();
 
-    assertThat(user(local, DEVS).isOwner()).isFalse();
+    assertThat(user(localKey, DEVS).isOwner()).isFalse();
   }
 
   @Test
@@ -932,53 +1175,19 @@
     RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}");
   }
 
-  private InMemoryRepository add(ProjectConfig pc) {
-    List<CommentLinkInfo> commentLinks = null;
-
-    InMemoryRepository repo;
-    try {
-      repo = repoManager.createRepository(pc.getName());
-      if (pc.getProject() == null) {
-        pc.load(repo);
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
-    all.put(
-        pc.getName(),
-        new ProjectState(
-            projectCache,
-            allProjectsName,
-            allUsersName,
-            repoManager,
-            commentLinks,
-            capabilityCollectionFactory,
-            transferConfig,
-            metricMaker,
-            pc));
-    return repo;
+  private ProjectState getProjectState(Project.NameKey nameKey) throws Exception {
+    return projectCache.checkedGet(nameKey, true);
   }
 
-  private ProjectControl user(ProjectConfig local, AccountGroup.UUID... memberOf) {
-    return user(local, null, memberOf);
+  private ProjectControl user(Project.NameKey localKey, AccountGroup.UUID... memberOf)
+      throws Exception {
+    return user(localKey, null, memberOf);
   }
 
   private ProjectControl user(
-      ProjectConfig local, @Nullable String name, AccountGroup.UUID... memberOf) {
-    return new ProjectControl(
-        Collections.emptySet(),
-        Collections.emptySet(),
-        sectionSorter,
-        changeControlFactory,
-        permissionBackend,
-        refFilterFactory,
-        new MockUser(name, memberOf),
-        newProjectState(local));
-  }
-
-  private ProjectState newProjectState(ProjectConfig local) {
-    add(local);
-    return all.get(local.getProject().getNameKey());
+      Project.NameKey localKey, @Nullable String name, AccountGroup.UUID... memberOf)
+      throws Exception {
+    return projectControlFactory.create(new MockUser(name, memberOf), getProjectState(localKey));
   }
 
   private static class MockUser extends CurrentUser {
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index 8f6119a..12c0838 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -14,12 +14,18 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
@@ -32,7 +38,6 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
@@ -43,6 +48,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -57,10 +63,10 @@
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected AllProjectsName allProjects;
   @Inject private CommitsCollection commits;
-  @Inject private ProjectConfig.Factory projectConfigFactory;
+  @Inject private ProjectOperations projectOperations;
 
   private TestRepository<InMemoryRepository> repo;
-  private ProjectConfig project;
+  private Project.NameKey project;
 
   @Before
   public void setUp() throws Exception {
@@ -68,17 +74,22 @@
 
     Account.Id user = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     testEnvironment.setApiUser(user);
+    project = projectOperations.newProject().create();
+    repo = new TestRepository<>(repoManager.openRepository(project));
+  }
 
-    Project.NameKey name = Project.nameKey("project");
-    InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
-    project = projectConfigFactory.create(name);
-    project.load(inMemoryRepo);
-    repo = new TestRepository<>(inMemoryRepo);
+  @After
+  public void tearDown() {
+    repo.getRepository().close();
   }
 
   @Test
   public void canReadCommitWhenAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     ObjectId id = repo.branch("master").commit().create();
     ProjectState state = readProjectState();
     RevWalk rw = repo.getRevWalk();
@@ -89,8 +100,12 @@
 
   @Test
   public void canReadCommitIfTwoRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/heads/branch2").group(REGISTERED_USERS))
+        .update();
 
     ObjectId id1 = repo.branch("branch1").commit().create();
     ObjectId id2 = repo.branch("branch2").commit().create();
@@ -105,8 +120,12 @@
 
   @Test
   public void canReadCommitIfRefVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .add(deny(READ).ref("refs/heads/branch2").group(REGISTERED_USERS))
+        .update();
 
     ObjectId id1 = repo.branch("branch1").commit().create();
     ObjectId id2 = repo.branch("branch2").commit().create();
@@ -121,8 +140,12 @@
 
   @Test
   public void canReadCommitIfReachableFromVisibleRef() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .add(deny(READ).ref("refs/heads/branch2").group(REGISTERED_USERS))
+        .update();
 
     RevCommit parent1 = repo.commit().create();
     repo.branch("branch1").commit().parent(parent1).create();
@@ -139,7 +162,11 @@
 
   @Test
   public void cannotReadAfterRollbackWithRestrictedRead() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/branch1").group(REGISTERED_USERS))
+        .update();
 
     RevCommit parent1 = repo.commit().create();
     ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
@@ -158,7 +185,11 @@
 
   @Test
   public void canReadAfterRollbackWithAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
 
     RevCommit parent1 = repo.commit().create();
     ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
@@ -176,41 +207,19 @@
   }
 
   private ProjectState readProjectState() throws Exception {
-    return projectCache.get(project.getName());
-  }
-
-  protected void allow(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.allow(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void deny(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.deny(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(cfg.getName())) {
-      cfg.commit(md);
-    }
-    projectCache.evict(cfg.getProject());
+    return projectCache.get(project);
   }
 
   private void setUpPermissions() throws Exception {
-    ImmutableList<AccountGroup.UUID> admins = getAdmins();
-
     // Remove read permissions for all users besides admin, because by default
     // Anonymous user group has ALLOW READ permission in refs/*.
     // This method is idempotent, so is safe to call on every test setup.
-    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
-    }
-    for (AccountGroup.UUID admin : admins) {
-      allow(pc, Permission.READ, admin, "refs/*");
-    }
+    TestProjectUpdate.Builder u = projectOperations.allProjectsForUpdate();
+    projectCache.checkedGet(allProjects).getConfig().getAccessSectionNames().stream()
+        .filter(sec -> sec.startsWith(R_REFS))
+        .forEach(sec -> u.remove(permissionKey(Permission.READ).ref(sec)));
+    getAdmins().forEach(admin -> u.add(allow(Permission.READ).ref("refs/*").group(admin)));
+    u.update();
   }
 
   private ImmutableList<AccountGroup.UUID> getAdmins() {
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 15c757d..75e1cd7 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -36,7 +36,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -340,11 +340,11 @@
     cfg.getLabelSections()
         .put(
             "My-Label",
-            Util.category(
+            TestLabels.label(
                 "My-Label",
-                Util.value(-1, "Negative"),
-                Util.value(0, "No score"),
-                Util.value(1, "Positive")));
+                TestLabels.value(-1, "Negative"),
+                TestLabels.value(0, "No score"),
+                TestLabels.value(1, "Positive")));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 65c6e3f..3502853 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -18,13 +18,12 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.Util.allow;
-import static com.google.gerrit.server.project.testing.Util.category;
-import static com.google.gerrit.server.project.testing.Util.value;
-import static com.google.gerrit.server.project.testing.Util.verified;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
@@ -42,9 +41,9 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
@@ -176,6 +175,9 @@
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
 
+  @Inject private ProjectConfig.Factory projectConfigFactory;
+  @Inject private ProjectOperations projectOperations;
+
   protected Injector injector;
   protected LifecycleManager lifecycle;
   protected Account.Id userId;
@@ -1049,19 +1051,23 @@
     TestRepository<Repo> repo = createProject("repo");
     Project.NameKey project =
         Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
 
     LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    cfg.getLabelSections().put(verified.getName(), verified);
-
-    String heads = RefNames.REFS_HEADS + "*";
-    allow(cfg, Permission.forLabel(verified().getName()), -1, 1, REGISTERED_USERS, heads);
-
+        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig cfg = projectConfigFactory.create(project);
+      cfg.load(md);
+      cfg.getLabelSections().put(verified.getName(), verified);
       cfg.commit(md);
     }
-    projectCache.evict(cfg.getProject());
+    projectCache.evict(project);
+
+    String heads = RefNames.REFS_HEADS + "*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(REGISTERED_USERS).range(-1, 1))
+        .update();
 
     ReviewInput reviewVerified = new ReviewInput().label("Verified", 1);
     ChangeInserter ins = newChange(repo, null, null, null, null, false);
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 7ca7ac3..a128593 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -9,6 +9,7 @@
     visibility = ["//visibility:public"],
     runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
+        "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 655baa0..be0b8e7 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -19,7 +19,7 @@
 import static org.easymock.EasyMock.expect;
 
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
@@ -58,7 +58,8 @@
 
   @Override
   protected void setUpEnvironment(PrologEnvironment env) throws Exception {
-    LabelTypes labelTypes = new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
+    LabelTypes labelTypes =
+        new LabelTypes(Arrays.asList(TestLabels.codeReview(), TestLabels.verified()));
     ChangeData cd = EasyMock.createMock(ChangeData.class);
     expect(cd.getLabelTypes()).andStubReturn(labelTypes);
     EasyMock.replay(cd);
diff --git a/lib/errorprone/BUILD b/lib/errorprone/BUILD
new file mode 100644
index 0000000..b5c130b
--- /dev/null
+++ b/lib/errorprone/BUILD
@@ -0,0 +1,6 @@
+java_library(
+    name = "annotations",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@error-prone-annotations//jar"],
+)
diff --git a/plugins/delete-project b/plugins/delete-project
index a4b777a..6e94a1f 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit a4b777a173feb2cfaaad591e2fded37f15500e2b
+Subproject commit 6e94a1f4f09c5e0aef86bafd80be35fa8b2842e6
diff --git a/plugins/hooks b/plugins/hooks
index 60fb334..1244739 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 60fb334f44329caca37d8aa0d43feba651c959b2
+Subproject commit 12447394c90141595ab630129e24ab66d2f39a17
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index 9edc195..a51055a 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit 9edc1950d3c0a3717cefda1688a4c37e7fc652fb
+Subproject commit a51055a8f3b71f2ccf634016c42eb5b8086a373b
diff --git a/plugins/replication b/plugins/replication
index aa07963..b4b90fd 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit aa07963def69e4423444a078a662072e09629cec
+Subproject commit b4b90fd2cc2b53789bffedc5ae829bd2d9b94009
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 2091fdf..70f1f3a 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 2091fdfc99f2cec60ae3fda2538ae1993b0fc8b8
+Subproject commit 70f1f3a860fe9e276a3ed216bf2b179ea0e8cde3
diff --git a/plugins/webhooks b/plugins/webhooks
index 1c860ae..8078749 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 1c860ae557f3851b2d98978bafba644de5e6c0b8
+Subproject commit 8078749bfd64d9dcd3372bc3f8965d192747c5b4
diff --git a/tools/BUILD b/tools/BUILD
index 6266456..09adf16 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -23,70 +23,74 @@
     visibility = ["//visibility:public"],
 )
 
-# This EP warnings list is based on:
+# Error Prone errors enabled by default; see ../.bazelrc for how this is
+# enabled. This warnings list is originally based on:
 # https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
+# However, feel free to add any additional errors. Thus far they have all been pretty useful.
 java_package_configuration(
     name = "error_prone",
     javacopts = [
         "-XepDisableWarningsInGeneratedCode",
-        "-Xep:MissingCasesInEnumSwitch:ERROR",
-        "-Xep:ReferenceEquality:WARN",
-        "-Xep:StringEquality:WARN",
-        "-Xep:WildcardImport:WARN",
-        "-Xep:AmbiguousMethodReference:WARN",
-        "-Xep:BadAnnotationImplementation:WARN",
-        "-Xep:BadComparable:WARN",
+        "-Xep:AmbiguousMethodReference:ERROR",
+        "-Xep:BadAnnotationImplementation:ERROR",
+        "-Xep:BadComparable:ERROR",
         "-Xep:BoxedPrimitiveConstructor:ERROR",
-        "-Xep:CannotMockFinalClass:WARN",
-        "-Xep:ClassCanBeStatic:WARN",
-        "-Xep:ClassNewInstance:WARN",
+        "-Xep:CannotMockFinalClass:ERROR",
+        "-Xep:ClassCanBeStatic:ERROR",
+        "-Xep:ClassNewInstance:ERROR",
         "-Xep:DefaultCharset:ERROR",
-        "-Xep:DoubleCheckedLocking:WARN",
-        "-Xep:ElementsCountedInLoop:WARN",
-        "-Xep:EqualsHashCode:WARN",
-        "-Xep:EqualsIncompatibleType:WARN",
+        "-Xep:DoubleCheckedLocking:ERROR",
+        "-Xep:ElementsCountedInLoop:ERROR",
+        "-Xep:DoubleCheckedLocking:ERROR",
+        "-Xep:ElementsCountedInLoop:ERROR",
+        "-Xep:EqualsHashCode:ERROR",
+        "-Xep:EqualsIncompatibleType:ERROR",
         "-Xep:ExpectedExceptionChecker:ERROR",
-        "-Xep:Finally:WARN",
-        "-Xep:FloatingPointLiteralPrecision:WARN",
-        "-Xep:FragmentInjection:WARN",
-        "-Xep:FragmentNotInstantiable:WARN",
-        "-Xep:FunctionalInterfaceClash:WARN",
-        "-Xep:FutureReturnValueIgnored:WARN",
-        "-Xep:GetClassOnEnum:WARN",
-        "-Xep:ImmutableAnnotationChecker:WARN",
-        "-Xep:ImmutableEnumChecker:WARN",
-        "-Xep:IncompatibleModifiers:WARN",
-        "-Xep:InjectOnConstructorOfAbstractClass:WARN",
-        "-Xep:InputStreamSlowMultibyteRead:WARN",
-        "-Xep:IterableAndIterator:WARN",
-        "-Xep:JUnit3FloatingPointComparisonWithoutDelta:WARN",
-        "-Xep:JUnitAmbiguousTestClass:WARN",
-        "-Xep:LiteralClassName:WARN",
-        "-Xep:MissingFail:WARN",
-        "-Xep:MissingOverride:WARN",
+        "-Xep:Finally:ERROR",
+        "-Xep:FloatingPointLiteralPrecision:ERROR",
+        "-Xep:FragmentInjection:ERROR",
+        "-Xep:FragmentNotInstantiable:ERROR",
+        "-Xep:FunctionalInterfaceClash:ERROR",
+        "-Xep:FutureReturnValueIgnored:ERROR",
+        "-Xep:GetClassOnEnum:ERROR",
+        "-Xep:ImmutableAnnotationChecker:ERROR",
+        "-Xep:ImmutableEnumChecker:ERROR",
+        "-Xep:IncompatibleModifiers:ERROR",
+        "-Xep:InjectOnConstructorOfAbstractClass:ERROR",
+        "-Xep:InputStreamSlowMultibyteRead:ERROR",
+        "-Xep:IterableAndIterator:ERROR",
+        "-Xep:JUnit3FloatingPointComparisonWithoutDelta:ERROR",
+        "-Xep:JUnitAmbiguousTestClass:ERROR",
+        "-Xep:LiteralClassName:ERROR",
+        "-Xep:MissingCasesInEnumSwitch:ERROR",
+        "-Xep:MissingFail:ERROR",
+        "-Xep:MissingOverride:ERROR",
         "-Xep:MutableConstantField:ERROR",
-        "-Xep:NarrowingCompoundAssignment:WARN",
-        "-Xep:NonAtomicVolatileUpdate:WARN",
-        "-Xep:NonOverridingEquals:WARN",
-        "-Xep:NullableConstructor:WARN",
-        "-Xep:NullablePrimitive:WARN",
-        "-Xep:NullableVoid:WARN",
-        "-Xep:OperatorPrecedence:WARN",
-        "-Xep:OverridesGuiceInjectableMethod:WARN",
-        "-Xep:PreconditionsInvalidPlaceholder:WARN",
-        "-Xep:ProtoFieldPreconditionsCheckNotNull:WARN",
-        "-Xep:ProtocolBufferOrdinal:WARN",
-        "-Xep:RequiredModifiers:WARN",
-        "-Xep:ShortCircuitBoolean:WARN",
-        "-Xep:SimpleDateFormatConstant:WARN",
-        "-Xep:StaticGuardedByInstance:WARN",
-        "-Xep:SynchronizeOnNonFinalField:WARN",
-        "-Xep:TruthConstantAsserts:WARN",
-        "-Xep:TypeParameterShadowing:WARN",
-        "-Xep:TypeParameterUnusedInFormals:WARN",
-        "-Xep:URLEqualsHashCode:WARN",
-        "-Xep:UnsynchronizedOverridesSynchronized:WARN",
-        "-Xep:WaitNotInLoop:WARN",
+        "-Xep:NarrowingCompoundAssignment:ERROR",
+        "-Xep:NonAtomicVolatileUpdate:ERROR",
+        "-Xep:NonOverridingEquals:ERROR",
+        "-Xep:NullableConstructor:ERROR",
+        "-Xep:NullablePrimitive:ERROR",
+        "-Xep:NullableVoid:ERROR",
+        "-Xep:OperatorPrecedence:ERROR",
+        "-Xep:OverridesGuiceInjectableMethod:ERROR",
+        "-Xep:PreconditionsInvalidPlaceholder:ERROR",
+        "-Xep:ProtoFieldPreconditionsCheckNotNull:ERROR",
+        "-Xep:ProtocolBufferOrdinal:ERROR",
+        "-Xep:ReferenceEquality:ERROR",
+        "-Xep:RequiredModifiers:ERROR",
+        "-Xep:ShortCircuitBoolean:ERROR",
+        "-Xep:SimpleDateFormatConstant:ERROR",
+        "-Xep:StaticGuardedByInstance:ERROR",
+        "-Xep:StringEquality:ERROR",
+        "-Xep:SynchronizeOnNonFinalField:ERROR",
+        "-Xep:TruthConstantAsserts:ERROR",
+        "-Xep:TypeParameterShadowing:ERROR",
+        "-Xep:TypeParameterUnusedInFormals:ERROR",
+        "-Xep:URLEqualsHashCode:ERROR",
+        "-Xep:UnsynchronizedOverridesSynchronized:ERROR",
+        "-Xep:WaitNotInLoop:ERROR",
+        "-Xep:WildcardImport:ERROR",
     ],
     packages = ["error_prone_packages"],
 )
@@ -96,5 +100,16 @@
     packages = [
         "//java/...",
         "//javatests/...",
+        "//plugins/codemirror-editor/...",
+        "//plugins/commit-message-length-validator/...",
+        "//plugins/delete-project/...",
+        "//plugins/download-commands/...",
+        "//plugins/gitiles/...",
+        "//plugins/hooks/...",
+        "//plugins/plugin-manager/...",
+        "//plugins/replication/...",
+        "//plugins/reviewnotes/...",
+        "//plugins/singleusergroup/...",
+        "//plugins/webhooks/...",
     ],
 )
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 6131bca..a4080e5 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -306,6 +306,13 @@
     destdir = ctx.outputs.html.path + ".dir"
     zips = [z for d in ctx.attr.deps for z in d.transitive_zipfiles.to_list()]
 
+    # We are splitting off the package dir from the app.path such that
+    # we can set the package dir as the root for the bundler, which means
+    # that absolute imports are interpreted relative to that root.
+    pkg_dir = ctx.attr.pkg.lstrip("/")
+    app_path = ctx.file.app.path
+    app_path = app_path[app_path.index(pkg_dir) + len(pkg_dir):]
+
     hermetic_npm_binary = " ".join([
         "python",
         "$p/" + ctx.file._run_npm.path,
@@ -315,10 +322,11 @@
         "--strip-comments",
         "--out-file",
         "$p/" + bundled.path,
-        ctx.file.app.path,
+        "--root",
+        pkg_dir,
+        app_path,
     ])
 
-    pkg_dir = ctx.attr.pkg.lstrip("/")
     cmd = " && ".join([
         # unpack dependencies.
         "export PATH",