Add a global capability for the Check Code Owner Config Files REST endpoint

With this new capability we can allow service users to check code owner
config files.

Bug: Google b/392106108
Change-Id: I154e29d9678994e0660809351bda51c5723043dd
Signed-off-by: Edwin Kempin <ekempin@google.com>
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
index 1b931ba..abba114 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
@@ -73,6 +73,7 @@
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
   private final Provider<ListBranches> listBranches;
+  private final CheckCodeOwnerConfigFilesCapability checkCodeOwnerConfigFilesCapability;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory;
   private final CodeOwnerConfigValidator codeOwnerConfigValidator;
@@ -82,12 +83,14 @@
       Provider<CurrentUser> currentUser,
       PermissionBackend permissionBackend,
       Provider<ListBranches> listBranches,
+      CheckCodeOwnerConfigFilesCapability checkCodeOwnerConfigFilesCapability,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory,
       CodeOwnerConfigValidator codeOwnerConfigValidator) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
     this.listBranches = listBranches;
+    this.checkCodeOwnerConfigFilesCapability = checkCodeOwnerConfigFilesCapability;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.codeOwnerConfigScannerFactory = codeOwnerConfigScannerFactory;
     this.codeOwnerConfigValidator = codeOwnerConfigValidator;
@@ -101,11 +104,21 @@
       throw new AuthException("Authentication required");
     }
 
-    // This REST endpoint requires the caller to be a project owner.
-    permissionBackend
-        .currentUser()
-        .project(projectResource.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
+    // This REST endpoint requires the caller to be a project owner or have the
+    // checkCodeOwnerConfigFilesCapability.
+    if (!permissionBackend
+            .currentUser()
+            .project(projectResource.getNameKey())
+            .test(ProjectPermission.WRITE_CONFIG)
+        && !permissionBackend
+            .currentUser()
+            .test(checkCodeOwnerConfigFilesCapability.getPermission())) {
+      throw new AuthException(
+          String.format(
+              "cannot check code owner config files, must be project owner or have the %s global"
+                  + " capability",
+              checkCodeOwnerConfigFilesCapability.getDescription()));
+    }
 
     logger.atFine().log(
         "checking code owner config files for project %s"
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesCapability.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesCapability.java
new file mode 100644
index 0000000..6b4840b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesCapability.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2024 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.plugins.codeowners.restapi;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * Global capability that allows a user to call the {@link CheckCodeOwnerConfigFiles} REST endpoint.
+ */
+@Singleton
+public class CheckCodeOwnerConfigFilesCapability extends CapabilityDefinition {
+  public static final String ID = "checkCodeOwnerConfigFiles";
+
+  private final String pluginName;
+
+  @Inject
+  CheckCodeOwnerConfigFilesCapability(@PluginName String pluginName) {
+    this.pluginName = pluginName;
+  }
+
+  @Override
+  public String getDescription() {
+    return "Check Code Owner Config Files";
+  }
+
+  public PluginPermission getPermission() {
+    return new PluginPermission(pluginName, ID);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
index 37b3823..aa93ca0 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
@@ -56,6 +56,10 @@
 
     get(PROJECT_KIND, "code_owners.project_config").to(GetCodeOwnerProjectConfig.class);
     put(PROJECT_KIND, "code_owners.project_config").to(PutCodeOwnerProjectConfig.class);
+
+    bind(CapabilityDefinition.class)
+        .annotatedWith(Exports.named(CheckCodeOwnerConfigFilesCapability.ID))
+        .to(CheckCodeOwnerConfigFilesCapability.class);
     post(PROJECT_KIND, "code_owners.check_config").to(CheckCodeOwnerConfigFiles.class);
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java
index a275287..749318b 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
 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.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -38,6 +40,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
+import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerConfigFilesCapability;
 import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.List;
@@ -66,11 +69,49 @@
   }
 
   @Test
-  public void requiresCallerToBeProjectOwner() throws Exception {
+  public void requiresCallerToBeProjectOwnerOrHaveTheCheckCodeOwnerConfigFilesCapability()
+      throws Exception {
     requestScopeOperations.setApiUser(user.id());
     AuthException authException =
         assertThrows(AuthException.class, () -> checkCodeOwnerConfigFilesIn(project));
-    assertThat(authException).hasMessageThat().isEqualTo("write refs/meta/config not permitted");
+    assertThat(authException)
+        .hasMessageThat()
+        .isEqualTo(
+            "cannot check code owner config files, must be project owner or have the Check Code"
+                + " Owner Config Files global capability");
+  }
+
+  @Test
+  public void projectOwnerCanCheckCodeOwnerConfigFiles() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    checkCodeOwnerConfigFilesIn(project); // shouldn't throw an AuthException
+  }
+
+  @Test
+  public void adminCanCheckCodeOwnerConfigFiles() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    checkCodeOwnerConfigFilesIn(project); // shouldn't throw an AuthException
+  }
+
+  @Test
+  public void userThatHasTheCheckCodeOwnerConfigFilesCapabilityCanCheckCodeOwnerConfigFiles()
+      throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability("code-owners-" + CheckCodeOwnerConfigFilesCapability.ID)
+                .group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    checkCodeOwnerConfigFilesIn(project); // shouldn't throw an AuthException
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CheckCodeOwnerFilesCapabilityRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CheckCodeOwnerFilesCapabilityRestIT.java
new file mode 100644
index 0000000..712cc6a
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CheckCodeOwnerFilesCapabilityRestIT.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2024 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.plugins.codeowners.acceptance.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+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.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerConfigFilesCapability;
+import com.google.gerrit.server.restapi.config.ListCapabilities.CapabilityInfo;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import java.util.Map;
+import org.junit.Test;
+
+/**
+ * Acceptance test for {@link
+ * com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerConfigFilesCapability}.
+ */
+public class CheckCodeOwnerFilesCapabilityRestIT extends AbstractCodeOwnersIT {
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void listCapabilities() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/capabilities");
+    r.assertOK();
+    Map<String, CapabilityInfo> capabilities =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, CapabilityInfo>>() {}.getType());
+    CapabilityInfo capabilityInfo =
+        capabilities.get("code-owners-" + CheckCodeOwnerConfigFilesCapability.ID);
+    assertThat(capabilityInfo.id)
+        .isEqualTo("code-owners-" + CheckCodeOwnerConfigFilesCapability.ID);
+    assertThat(capabilityInfo.name).isEqualTo("Check Code Owner Config Files");
+  }
+
+  @Test
+  public void getAccountCapabilities() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability("code-owners-" + CheckCodeOwnerConfigFilesCapability.ID)
+                .group(REGISTERED_USERS))
+        .update();
+
+    RestResponse r = adminRestSession.get("/accounts/self/capabilities");
+    r.assertOK();
+    Map<String, Object> capabilities =
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+    assertThat(capabilities)
+        .containsEntry("code-owners-" + CheckCodeOwnerConfigFilesCapability.ID, true);
+  }
+}
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index ad944aa..e17d5ec 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -106,7 +106,8 @@
 
 Checks/validates the code owner config files in a project.
 
-Requires that the caller is an owner of the project.
+Requires that the caller is an owner of the project or has the
+link:checkCodeOwnerConfigFiles[Check Code Owner Config Files] global capability.
 
 Input options can be set in the request body as a
 [CheckCodeOwnerConfigFilesInput](#check-code-owner-config-files-input) entity.
@@ -1250,6 +1251,13 @@
 assigned on the `All-Projects` project in the `Global Capabilities` access
 section.
 
+### <a id="checkCodeOwnerConfigFiles">Check Code Owner Config Files
+
+Global capability that allows a user to call the [Check Code Owner Config
+Files](#check-code-owner-config-files) REST endpoint.
+
+Administrators have this capability implicitly assigned.
+
 ---
 
 ## <a id="serviceUsers">Service Users