Add REST endpoint to check if an email is a code owner of a path

This REST endpoint is intended to investigate code owner configurations
that do not work as intended. The response contains debug logs that may
point out issues with the code owner configuration. For example, with
this REST endpoint it is possible to find out why a certain email that
is listed as code owner in a code owner config file is ignored (e.g.
because it is ambiguous or because it belongs to an inactive account).

Since the returned debug logs may contain sensitive information, this
REST endpoint is only available to host admins that anyway have access
to this information.

The idea of adding this REST endpoint is to allow host admins to
investigate issues with code owner config files on their own, and hence
reduce support load for the Gerrit devs.

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I477eda7b56f00a09894a5aa003cfd4eab4d9487b
diff --git a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
index d22f98b..0508bc7 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
@@ -89,6 +89,69 @@
   RenameEmailResultInfo renameEmailInCodeOwnerConfigFiles(RenameEmailInput input)
       throws RestApiException;
 
+  /** Checks the code ownership of a user for a path in a branch. */
+  CodeOwnerCheckRequest checkCodeOwner() throws RestApiException;
+
+  /** Request for checking the code ownership of a user for a path in a branch. */
+  abstract class CodeOwnerCheckRequest {
+    private String email;
+    private String path;
+    private String user;
+
+    /**
+     * Sets the email for which the code ownership should be checked.
+     *
+     * @param email the email for which the code ownership should be checked
+     */
+    public CodeOwnerCheckRequest email(String email) {
+      this.email = email;
+      return this;
+    }
+
+    /** Returns the email for which the code ownership should be checked. */
+    @Nullable
+    public String getEmail() {
+      return email;
+    }
+
+    /**
+     * Sets the path for which the code ownership should be checked.
+     *
+     * @param path the path for which the code ownership should be checked
+     */
+    public CodeOwnerCheckRequest path(String path) {
+      this.path = path;
+      return this;
+    }
+
+    /** Returns the path for which the code ownership should be checked. */
+    @Nullable
+    public String getPath() {
+      return path;
+    }
+
+    /**
+     * Sets the user for which the code owner visibility should be checked.
+     *
+     * <p>If not specified the code owner visibility is not checked.
+     *
+     * @param user the user for which the code owner visibility should be checked
+     */
+    public CodeOwnerCheckRequest user(@Nullable String user) {
+      this.user = user;
+      return this;
+    }
+
+    /** Returns the user for which the code owner visibility should be checked. */
+    @Nullable
+    public String getUser() {
+      return user;
+    }
+
+    /** Executes the request and retrieves the result. */
+    public abstract CodeOwnerCheckInfo check() throws RestApiException;
+  }
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -109,5 +172,10 @@
         throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public CodeOwnerCheckRequest checkCodeOwner() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
index 7052a1b..beae1f6 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwner;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerBranchConfig;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerConfigFiles;
 import com.google.gerrit.plugins.codeowners.restapi.RenameEmail;
@@ -35,6 +36,7 @@
   private final GetCodeOwnerBranchConfig getCodeOwnerBranchConfig;
   private final Provider<GetCodeOwnerConfigFiles> getCodeOwnerConfigFilesProvider;
   private final RenameEmail renameEmail;
+  private final Provider<CheckCodeOwner> checkCodeOwnerProvider;
   private final BranchResource branchResource;
 
   @Inject
@@ -42,10 +44,12 @@
       GetCodeOwnerBranchConfig getCodeOwnerBranchConfig,
       Provider<GetCodeOwnerConfigFiles> getCodeOwnerConfigFilesProvider,
       RenameEmail renameEmail,
+      Provider<CheckCodeOwner> checkCodeOwnerProvider,
       @Assisted BranchResource branchResource) {
     this.getCodeOwnerConfigFilesProvider = getCodeOwnerConfigFilesProvider;
     this.getCodeOwnerBranchConfig = getCodeOwnerBranchConfig;
     this.renameEmail = renameEmail;
+    this.checkCodeOwnerProvider = checkCodeOwnerProvider;
     this.branchResource = branchResource;
   }
 
@@ -81,4 +85,22 @@
       throw asRestApiException("Cannot rename email", e);
     }
   }
+
+  @Override
+  public CodeOwnerCheckRequest checkCodeOwner() throws RestApiException {
+    return new CodeOwnerCheckRequest() {
+      @Override
+      public CodeOwnerCheckInfo check() throws RestApiException {
+        CheckCodeOwner checkCodeOwner = checkCodeOwnerProvider.get();
+        checkCodeOwner.setEmail(getEmail());
+        checkCodeOwner.setPath(getPath());
+        checkCodeOwner.setUser(getUser());
+        try {
+          return checkCodeOwner.apply(branchResource).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot check code owner", e);
+        }
+      }
+    };
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java
new file mode 100644
index 0000000..4b33e11
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2020 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.api;
+
+import java.util.List;
+
+/**
+ * REST API representation of the result of checking a code owner via {code
+ * com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwner}.
+ *
+ * <p>This class determines the JSON format of check result in the REST API.
+ */
+public class CodeOwnerCheckInfo {
+  /**
+   * Whether the given email owns the specified path in the branch.
+   *
+   * <p>True if:
+   *
+   * <ul>
+   *   <li>the given email is resolvable (see {@link #isResolvable}) and
+   *   <li>any code owner config file assigns codeownership to the email for the path (see {@link
+   *       #codeOwnerConfigFilePaths}) or the email is configured as default code owner (see {@link
+   *       CodeOwnerCheckInfo#isDefaultCodeOwner} field) or the email is configured as global code
+   *       owner (see {@link #isGlobalCodeOwner} field)
+   * </ul>
+   */
+  public boolean isCodeOwner;
+
+  /**
+   * Whether the given email is resolvable for the specified user or the calling user if no user was
+   * specified.
+   */
+  public boolean isResolvable;
+
+  /**
+   * Paths of the code owner config files that assign code ownership to the given email for the
+   * specified path.
+   *
+   * <p>If code ownership is assigned to the email via a code owner config files, but the email is
+   * not resolvable (see {@link #isResolvable} field), the user is not a code owner.
+   */
+  public List<String> codeOwnerConfigFilePaths;
+
+  /**
+   * Whether the given email is configured as a default code owner.
+   *
+   * <p>If the email is configured as default code owner, but the email is not resolvable (see
+   * {@link #isResolvable} field), the user is not a code owner.
+   */
+  public boolean isDefaultCodeOwner;
+
+  /**
+   * Whether the given email is configured as a global code owner.
+   *
+   * <p>If the email is configured as global code owner, but the email is not resolvable (see {@link
+   * #isResolvable} field), the user is not a code owner.
+   */
+  public boolean isGlobalCodeOwner;
+
+  /** Debug logs that may help to understand why the user is or isn't a code owner. */
+  public List<String> debugLogs;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java
new file mode 100644
index 0000000..1bc41d7
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java
@@ -0,0 +1,240 @@
+// Copyright (C) 2020 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 static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerCheckInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigHierarchy;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.OptionalResultWithMessages;
+import com.google.gerrit.plugins.codeowners.backend.PathCodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.PathCodeOwnersResult;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.kohsuke.args4j.Option;
+
+/**
+ * REST endpoint that checks the code ownership of a user for a path in a branch.
+ *
+ * <p>This REST endpoint handles {@code GET
+ * /projects/<project-name>/branches/<branch-name>/code_owners.check} requests.
+ */
+public class CheckCodeOwner implements RestReadView<BranchResource> {
+  private final PermissionBackend permissionBackend;
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerConfigHierarchy codeOwnerConfigHierarchy;
+  private final PathCodeOwners.Factory pathCodeOwnersFactory;
+  private final Provider<CodeOwnerResolver> codeOwnerResolverProvider;
+  private final CodeOwners codeOwners;
+  private final AccountsCollection accountsCollection;
+
+  private String email;
+  private String path;
+  private String user;
+  private IdentifiedUser identifiedUser;
+
+  @Inject
+  public CheckCodeOwner(
+      PermissionBackend permissionBackend,
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+      PathCodeOwners.Factory pathCodeOwnersFactory,
+      Provider<CodeOwnerResolver> codeOwnerResolverProvider,
+      CodeOwners codeOwners,
+      AccountsCollection accountsCollection) {
+    this.permissionBackend = permissionBackend;
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerConfigHierarchy = codeOwnerConfigHierarchy;
+    this.pathCodeOwnersFactory = pathCodeOwnersFactory;
+    this.codeOwnerResolverProvider = codeOwnerResolverProvider;
+    this.codeOwners = codeOwners;
+    this.accountsCollection = accountsCollection;
+  }
+
+  @Option(name = "--email", usage = "email for which the code ownership should be checked")
+  public void setEmail(String email) {
+    this.email = email;
+  }
+
+  @Option(name = "--path", usage = "path for which the code ownership should be checked")
+  public void setPath(String path) {
+    this.path = path;
+  }
+
+  @Option(
+      name = "--user",
+      usage =
+          "user for which the code owner visibility should be checked,"
+              + " if not specified the code owner visibility is not checked")
+  public void setUser(String user) {
+    this.user = user;
+  }
+
+  @Override
+  public Response<CodeOwnerCheckInfo> apply(BranchResource branchResource)
+      throws BadRequestException, AuthException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    validateInput();
+
+    Path absolutePath = JgitPath.of(path).getAsAbsolutePath();
+    List<String> messages = new ArrayList<>();
+    List<Path> codeOwnerConfigFilePaths = new ArrayList<>();
+    AtomicBoolean isCodeOwnershipAssignedToEmail = new AtomicBoolean(false);
+    AtomicBoolean isDefaultCodeOwner = new AtomicBoolean(false);
+    codeOwnerConfigHierarchy.visit(
+        branchResource.getBranchKey(),
+        ObjectId.fromString(branchResource.getRevision()),
+        absolutePath,
+        codeOwnerConfig -> {
+          Path codeOwnerConfigFilePath = codeOwners.getFilePath(codeOwnerConfig.key());
+          messages.add(
+              String.format(
+                  "checking code owner config file %s:%s:%s",
+                  codeOwnerConfig.key().project(),
+                  codeOwnerConfig.key().shortBranchName(),
+                  codeOwnerConfigFilePath));
+          PathCodeOwnersResult pathCodeOwnersResult =
+              pathCodeOwnersFactory.create(codeOwnerConfig, absolutePath).resolveCodeOwnerConfig();
+          pathCodeOwnersResult
+              .unresolvedImports()
+              .forEach(
+                  unresolvedImport ->
+                      messages.add(unresolvedImport.format(codeOwnersPluginConfiguration)));
+          Optional<CodeOwnerReference> codeOwnerReference =
+              pathCodeOwnersResult.getPathCodeOwners().stream()
+                  .filter(cor -> cor.email().equals(email))
+                  .findAny();
+          if (codeOwnerReference.isPresent()) {
+            isCodeOwnershipAssignedToEmail.set(true);
+
+            if (RefNames.isConfigRef(codeOwnerConfig.key().ref())) {
+              messages.add(
+                  String.format(
+                      "found email %s as code owner in default code owner config", email));
+              isDefaultCodeOwner.set(true);
+            } else {
+              messages.add(
+                  String.format(
+                      "found email %s as code owner in %s", email, codeOwnerConfigFilePath));
+              codeOwnerConfigFilePaths.add(codeOwnerConfigFilePath);
+            }
+          }
+
+          if (pathCodeOwnersResult.ignoreParentCodeOwners()) {
+            messages.add("parent code owners are ignored");
+          }
+
+          return !pathCodeOwnersResult.ignoreParentCodeOwners();
+        });
+
+    boolean isGlobalCodeOwner = isGlobalCodeOwner(branchResource.getNameKey());
+    if (isGlobalCodeOwner) {
+      messages.add(String.format("found email %s as global code owner", email));
+      isCodeOwnershipAssignedToEmail.set(true);
+    }
+
+    OptionalResultWithMessages<Boolean> isResolvableResult = isResolvable();
+    boolean isResolvable = isResolvableResult.get();
+    messages.addAll(isResolvableResult.messages());
+
+    CodeOwnerCheckInfo codeOwnerCheckInfo = new CodeOwnerCheckInfo();
+    codeOwnerCheckInfo.isCodeOwner = isCodeOwnershipAssignedToEmail.get() && isResolvable;
+    codeOwnerCheckInfo.isResolvable = isResolvable;
+    codeOwnerCheckInfo.codeOwnerConfigFilePaths =
+        codeOwnerConfigFilePaths.stream().map(Path::toString).collect(toList());
+    codeOwnerCheckInfo.isDefaultCodeOwner = isDefaultCodeOwner.get();
+    codeOwnerCheckInfo.isGlobalCodeOwner = isGlobalCodeOwner;
+    codeOwnerCheckInfo.debugLogs = messages;
+    return Response.ok(codeOwnerCheckInfo);
+  }
+
+  private void validateInput()
+      throws BadRequestException, AuthException, IOException, ConfigInvalidException {
+    if (email == null) {
+      throw new BadRequestException("email required");
+    }
+    if (path == null) {
+      throw new BadRequestException("path required");
+    }
+    if (user != null) {
+      try {
+        identifiedUser =
+            accountsCollection
+                .parse(TopLevelResource.INSTANCE, IdString.fromDecoded(user))
+                .getUser();
+      } catch (ResourceNotFoundException e) {
+        throw new BadRequestException(String.format("user %s not found", user), e);
+      }
+    }
+  }
+
+  private boolean isGlobalCodeOwner(Project.NameKey projectName) {
+    return codeOwnersPluginConfiguration.getGlobalCodeOwners(projectName).stream()
+        .filter(cor -> cor.email().equals(email))
+        .findAny()
+        .isPresent();
+  }
+
+  private OptionalResultWithMessages<Boolean> isResolvable() {
+    if (email.equals(CodeOwnerResolver.ALL_USERS_WILDCARD)) {
+      return OptionalResultWithMessages.create(true);
+    }
+
+    CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get();
+    if (identifiedUser != null) {
+      codeOwnerResolver.forUser(identifiedUser);
+    } else {
+      codeOwnerResolver.enforceVisibility(false);
+    }
+    OptionalResultWithMessages<CodeOwner> resolveResult =
+        codeOwnerResolver.resolveWithMessages(CodeOwnerReference.create(email));
+
+    List<String> messages = new ArrayList<>();
+    messages.add(String.format("trying to resolve email %s", email));
+    messages.addAll(resolveResult.messages());
+    return OptionalResultWithMessages.create(resolveResult.isPresent(), messages);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
index 03d8858..68d057e 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
@@ -32,6 +32,7 @@
     get(BRANCH_KIND, "code_owners.config_files").to(GetCodeOwnerConfigFiles.class);
     get(BRANCH_KIND, "code_owners.branch_config").to(GetCodeOwnerBranchConfig.class);
     post(BRANCH_KIND, "code_owners.rename").to(RenameEmail.class);
+    get(BRANCH_KIND, "code_owners.check").to(CheckCodeOwner.class);
 
     factory(CodeOwnerJson.Factory.class);
     DynamicMap.mapOf(binder(), CodeOwnersInBranchCollection.PathResource.PATH_KIND);
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java
new file mode 100644
index 0000000..b060198
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2020 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.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerCheckInfo;
+
+/** {@link Subject} for doing assertions on {@link CodeOwnerCheckInfo}s. */
+public class CodeOwnerCheckInfoSubject extends Subject {
+  /**
+   * Starts fluent chain to do assertions on a {@link CodeOwnerCheckInfo}.
+   *
+   * @param codeOwnerCheckInfo the code owner check info on which assertions should be done
+   * @return the created {@link CodeOwnerCheckInfoSubject}
+   */
+  public static CodeOwnerCheckInfoSubject assertThat(CodeOwnerCheckInfo codeOwnerCheckInfo) {
+    return assertAbout(codeOwnerCheckInfos()).that(codeOwnerCheckInfo);
+  }
+
+  private static Factory<CodeOwnerCheckInfoSubject, CodeOwnerCheckInfo> codeOwnerCheckInfos() {
+    return CodeOwnerCheckInfoSubject::new;
+  }
+
+  private final CodeOwnerCheckInfo codeOwnerCheckInfo;
+
+  private CodeOwnerCheckInfoSubject(
+      FailureMetadata metadata, CodeOwnerCheckInfo codeOwnerCheckInfo) {
+    super(metadata, codeOwnerCheckInfo);
+    this.codeOwnerCheckInfo = codeOwnerCheckInfo;
+  }
+
+  public void isCodeOwner() {
+    check("isCodeOwner").that(codeOwnerCheckInfo().isCodeOwner).isTrue();
+  }
+
+  public void isNotCodeOwner() {
+    check("isCodeOwner").that(codeOwnerCheckInfo().isCodeOwner).isFalse();
+  }
+
+  public void isResolvable() {
+    check("isResolvable").that(codeOwnerCheckInfo().isResolvable).isTrue();
+  }
+
+  public void isNotResolvable() {
+    check("isResolvable").that(codeOwnerCheckInfo().isResolvable).isFalse();
+  }
+
+  public IterableSubject hasCodeOwnerConfigFilePathsThat() {
+    return check("codeOwnerConfigFilePaths").that(codeOwnerCheckInfo().codeOwnerConfigFilePaths);
+  }
+
+  public void isDefaultCodeOwner() {
+    check("isDefaultCodeOwner").that(codeOwnerCheckInfo().isDefaultCodeOwner).isTrue();
+  }
+
+  public void isNotDefaultCodeOwner() {
+    check("isDefaultCodeOwner").that(codeOwnerCheckInfo().isDefaultCodeOwner).isFalse();
+  }
+
+  public void isGlobalCodeOwner() {
+    check("isGlobalCodeOwner").that(codeOwnerCheckInfo().isGlobalCodeOwner).isTrue();
+  }
+
+  public void isNotGlobalCodeOwner() {
+    check("isGlobalCodeOwner").that(codeOwnerCheckInfo().isGlobalCodeOwner).isFalse();
+  }
+
+  public void hasDebugLogsThatContainAllOf(String... expectedMessages) {
+    for (String expectedMessage : expectedMessages) {
+      check("debugLogs").that(codeOwnerCheckInfo().debugLogs).contains(expectedMessage);
+    }
+  }
+
+  public IterableSubject hasDebugLogsThat() {
+    return check("debugLogs").that(codeOwnerCheckInfo().debugLogs);
+  }
+
+  private CodeOwnerCheckInfo codeOwnerCheckInfo() {
+    isNotNull();
+    return codeOwnerCheckInfo;
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
new file mode 100644
index 0000000..ea4fc3a
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
@@ -0,0 +1,682 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerCheckInfoSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+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.entities.Account;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerCheckInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+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.backend.CodeOwnerResolver;
+import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
+import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwner} REST
+ * endpoint.
+ */
+public class CheckCodeOwnerIT extends AbstractCodeOwnersIT {
+  private static final String ROOT_PATH = "/";
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdate;
+  @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+
+  private BackendConfig backendConfig;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
+  }
+
+  @Test
+  public void requiresEmail() throws Exception {
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> checkCodeOwner("/", /* email= */ null));
+    assertThat(exception).hasMessageThat().isEqualTo("email required");
+  }
+
+  @Test
+  public void requiresPath() throws Exception {
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class, () -> checkCodeOwner(/* path= */ null, user.email()));
+    assertThat(exception).hasMessageThat().isEqualTo("path required");
+  }
+
+  @Test
+  public void requiresCallerToBeAdmin() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException authException =
+        assertThrows(AuthException.class, () -> checkCodeOwner(ROOT_PATH, user.email()));
+    assertThat(authException).hasMessageThat().isEqualTo("administrate server not permitted");
+  }
+
+  @Test
+  public void checkCodeOwner() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath("/foo/"));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwnerThatHasCodeOwnershipThroughMultipleFiles() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+    setAsCodeOwners("/foo/", codeOwner);
+    setAsCodeOwners("/foo/bar/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(
+            getCodeOwnerConfigFilePath("/foo/bar/"),
+            getCodeOwnerConfigFilePath("/foo/"),
+            getCodeOwnerConfigFilePath(ROOT_PATH))
+        .inOrder();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/bar/")),
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwnerWithParentCodeOwnersIgnored() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .ignoreParentCodeOwners()
+        .addCodeOwnerEmail(codeOwner.email())
+        .create();
+
+    setAsCodeOwners("/foo/bar/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(
+            getCodeOwnerConfigFilePath("/foo/bar/"), getCodeOwnerConfigFilePath("/foo/"))
+        .inOrder();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/bar/")),
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            "parent code owners are ignored",
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwner_secondaryEmail() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    String secondaryEmail = "codeOwnerSecondary@example.com";
+    accountOperations
+        .account(codeOwner.id())
+        .forUpdate()
+        .addSecondaryEmail(secondaryEmail)
+        .update();
+
+    setAsRootCodeOwner(secondaryEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, secondaryEmail);
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                secondaryEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void checkNonCodeOwner() throws Exception {
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(String.format("resolved to account %s", user.id()));
+  }
+
+  @Test
+  public void checkNonExistingEmail() throws Exception {
+    String nonExistingEmail = "non-exiting@example.com";
+
+    setAsRootCodeOwner(nonExistingEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, nonExistingEmail);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                nonExistingEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve code owner email %s: no account with this email exists",
+                nonExistingEmail));
+  }
+
+  @Test
+  public void checkAmbiguousExistingEmail() throws Exception {
+    String ambiguousEmail = "ambiguous@example.com";
+
+    setAsRootCodeOwner(ambiguousEmail);
+
+    // Add the email to 2 accounts to make it ambiguous.
+    addEmail(user.id(), ambiguousEmail);
+    addEmail(admin.id(), ambiguousEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, ambiguousEmail);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                ambiguousEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve code owner email %s: email is ambiguous", ambiguousEmail));
+  }
+
+  @Test
+  public void checkOrphanedEmail() throws Exception {
+    // Create an external ID with an email for a non-existing account.
+    String orphanedEmail = "orphaned@example.com";
+    Account.Id accountId = Account.id(999999);
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      extIdNotes.upsert(ExternalId.createEmail(accountId, orphanedEmail));
+      extIdNotes.commit(md);
+    }
+
+    setAsRootCodeOwner(orphanedEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, orphanedEmail);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                orphanedEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve code owner email %s: email belongs to account %s,"
+                    + " but no account with this ID exists",
+                orphanedEmail, accountId));
+  }
+
+  @Test
+  public void checkInactiveAccount() throws Exception {
+    TestAccount inactiveUser =
+        accountCreator.create(
+            "inactiveUser", "inactiveUser@example.com", "Inactive User", /* displayName= */ null);
+    accountOperations.account(inactiveUser.id()).forUpdate().inactive().update();
+
+    setAsRootCodeOwners(inactiveUser);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, inactiveUser.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                inactiveUser.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "account %s for email %s is inactive", inactiveUser.id(), inactiveUser.email()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.allowedEmailDomain", value = "example.net")
+  public void checkEmailWithAllowedDomain() throws Exception {
+    String emailWithAllowedEmailDomain = "foo@example.net";
+    TestAccount userWithAllowedEmail =
+        accountCreator.create(
+            "userWithAllowedEmail",
+            emailWithAllowedEmailDomain,
+            "User with allowed emil",
+            /* displayName= */ null);
+
+    setAsRootCodeOwners(userWithAllowedEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, emailWithAllowedEmailDomain);
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                emailWithAllowedEmailDomain, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "domain %s of email %s is allowed",
+                emailWithAllowedEmailDomain.substring(emailWithAllowedEmailDomain.indexOf('@') + 1),
+                emailWithAllowedEmailDomain));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.allowedEmailDomain", value = "example.net")
+  public void checkEmailWithNonAllowedDomain() throws Exception {
+    String emailWithNonAllowedEmailDomain = "foo@example.com";
+    TestAccount userWithAllowedEmail =
+        accountCreator.create(
+            "userWithNonAllowedEmail",
+            emailWithNonAllowedEmailDomain,
+            "User with non-allowed emil",
+            /* displayName= */ null);
+
+    setAsRootCodeOwners(userWithAllowedEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwner(ROOT_PATH, emailWithNonAllowedEmailDomain);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                emailWithNonAllowedEmailDomain, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "domain %s of email %s is not allowed",
+                emailWithNonAllowedEmailDomain.substring(
+                    emailWithNonAllowedEmailDomain.indexOf('@') + 1),
+                emailWithNonAllowedEmailDomain));
+  }
+
+  @Test
+  public void checkAllUsersWildcard() throws Exception {
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwner(ROOT_PATH, CodeOwnerResolver.ALL_USERS_WILDCARD);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+  }
+
+  @Test
+  public void checkAllUsersWildcard_ownedByAllUsers() throws Exception {
+    setAsRootCodeOwner(CodeOwnerResolver.ALL_USERS_WILDCARD);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwner(ROOT_PATH, CodeOwnerResolver.ALL_USERS_WILDCARD);
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                CodeOwnerResolver.ALL_USERS_WILDCARD, getCodeOwnerConfigFilePath(ROOT_PATH)));
+  }
+
+  @Test
+  public void checkDefaultCodeOwner() throws Exception {
+    TestAccount defaultCodeOwner =
+        accountCreator.create(
+            "defaultCodeOwner",
+            "defaultCodeOwner@example.com",
+            "Default Code Owner",
+            /* displayName= */ null);
+    setAsDefaultCodeOwners(defaultCodeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, defaultCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in default code owner config",
+                defaultCodeOwner.email()),
+            String.format("resolved to account %s", defaultCodeOwner.id()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "globalCodeOwner@example.com")
+  public void checkGlobalCodeOwner() throws Exception {
+    TestAccount globalCodeOwner =
+        accountCreator.create(
+            "globalCodeOwner",
+            "globalCodeOwner@example.com",
+            "Global Code Owner",
+            /* displayName= */ null);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, globalCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format("found email %s as global code owner", globalCodeOwner.email()),
+            String.format("resolved to account %s", globalCodeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwnerForOtherUser() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email(), user.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath("/foo/"));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format("account %s is visible to user %s", codeOwner.id(), user.username()),
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void cannotCheckForNonExistingUser() throws Exception {
+    String nonExistingEmail = "non-existing@example.com";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class, () -> checkCodeOwner("/", user.email(), nonExistingEmail));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("user %s not found", nonExistingEmail));
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void checkNonVisibleCodeOwnerForOtherUser() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsRootCodeOwners(codeOwner);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwner(ROOT_PATH, codeOwner.email(), user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve code owner email %s: account %s is not visible to user %s",
+                codeOwner.email(), codeOwner.id(), user.username()));
+  }
+
+  @Test
+  public void checkNonVisibleCodeOwnerForOtherUser_secondaryEmail() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    String secondaryEmail = "codeOwnerSecondary@example.com";
+    accountOperations
+        .account(codeOwner.id())
+        .forUpdate()
+        .addSecondaryEmail(secondaryEmail)
+        .update();
+
+    setAsRootCodeOwner(secondaryEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, secondaryEmail, user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                secondaryEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve code owner email %s: account %s is referenced by secondary email"
+                    + " but user %s cannot see secondary emails",
+                secondaryEmail, codeOwner.id(), user.username()));
+  }
+
+  @Test
+  public void debugLogsContainUnresolvedImports() throws Exception {
+    // imports are not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfigReference unresolvableCodeOwnerConfigReference =
+        CodeOwnerConfigReference.create(
+            CodeOwnerConfigImportMode.ALL, "non-existing/" + getCodeOwnerConfigFileName());
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath(ROOT_PATH)
+            .addImport(unresolvableCodeOwnerConfigReference)
+            .create();
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, user.email());
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThat()
+        .contains(
+            String.format(
+                "The import of %s:%s:%s in %s:%s:%s cannot be resolved:"
+                    + " code owner config does not exist (revision = %s)",
+                project,
+                "master",
+                JgitPath.of(unresolvableCodeOwnerConfigReference.filePath()).getAsAbsolutePath(),
+                project,
+                "master",
+                getCodeOwnerConfigFilePath(codeOwnerConfigKey.folderPath().toString()),
+                projectOperations.project(project).getHead("master").name()));
+  }
+
+  private CodeOwnerCheckInfo checkCodeOwner(String path, String email) throws RestApiException {
+    return checkCodeOwner(path, email, null);
+  }
+
+  private CodeOwnerCheckInfo checkCodeOwner(String path, String email, @Nullable String user)
+      throws RestApiException {
+    return projectCodeOwnersApiFactory
+        .project(project)
+        .branch("master")
+        .checkCodeOwner()
+        .path(path)
+        .email(email)
+        .user(user)
+        .check();
+  }
+
+  private String getCodeOwnerConfigFilePath(String folderPath) {
+    assertThat(folderPath).startsWith("/");
+    assertThat(folderPath).endsWith("/");
+    return folderPath + getCodeOwnerConfigFileName();
+  }
+
+  private void setAsRootCodeOwner(String email) {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath(ROOT_PATH)
+        .addCodeOwnerEmail(email)
+        .create();
+  }
+
+  private String getCodeOwnerConfigFileName() {
+    CodeOwnerBackend backend = backendConfig.getDefaultBackend();
+    if (backend instanceof FindOwnersBackend) {
+      return FindOwnersBackend.CODE_OWNER_CONFIG_FILE_NAME;
+    } else if (backend instanceof ProtoBackend) {
+      return ProtoBackend.CODE_OWNER_CONFIG_FILE_NAME;
+    }
+    throw new IllegalStateException("unknown code owner backend: " + backend.getClass().getName());
+  }
+
+  private void addEmail(Account.Id accountId, String email) throws Exception {
+    accountsUpdate
+        .get()
+        .update(
+            "Test update",
+            accountId,
+            (a, u) ->
+                u.addExternalId(
+                    ExternalId.create(
+                        "foo",
+                        "bar" + accountId.get(),
+                        accountId,
+                        email,
+                        /* hashedPassword= */ null)));
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
index d70be9f..7d2bd66 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
@@ -53,7 +53,8 @@
       ImmutableList.of(
           RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.config_files"),
           RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.branch_config"),
-          RestCall.post("/projects/%s/branches/%s/code-owners~code_owners.rename"));
+          RestCall.post("/projects/%s/branches/%s/code-owners~code_owners.rename"),
+          RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.check"));
 
   private static final ImmutableList<RestCall> BRANCH_CODE_OWNER_CONFIGS_ENDPOINTS =
       ImmutableList.of(RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.config/%s"));
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index f6cd213..8902d36 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -219,6 +219,66 @@
   ]
 ```
 
+### <a id="check-code-owner">Check Code Owner
+_'GET /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/branches/[\{branch-id\}](../../../Documentation/rest-api-projects.html#branch-id)/code_owners.check/'_
+
+Checks the code ownership of a user for a path in a branch.
+
+The following request parameters can be specified:
+
+| Field Name  |           | Description |
+| ----------- | --------- | ----------- |
+| `email`     | mandatory | Email for which the code ownership should be checked.
+| `path`      | mandatory | Path for which the code ownership should be checked.
+| `user`      | optional  | User for which the code owner visibility should be checked. If not specified the code owner visibility is not checked. Can be used to investigate why a code owner is not shown/suggested to this user.
+
+Requires that the caller has the [Administrate
+Server](../../../Documentation/access-control.html#capability_administrateServer)
+global capability.
+
+This REST endpoint is intended to investigate code owner configurations that do
+not work as intended. The response contains debug logs that may point out issues
+with the code owner configuration. For example, with this REST endpoint it is
+possible to find out why a certain email that is listed as code owner in a code
+owner config file is ignored (e.g. because it is ambiguous or because it belongs
+to an inactive account).
+
+#### Request
+
+```
+  GET /projects/foo%2Fbar/branches/master/code_owners.check?email=xyz@example.com&path=/foo/bar/baz.md HTTP/1.0
+```
+
+#### Response
+
+As response a [CodeOwnerCheckInfo](#code-owner-check-info) entity is returned.
+
+```
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "is_code_owner": "false",
+    "is_resolvable": "false",
+    "code_owner_config_file_paths": [
+      "/OWNERS",
+    ],
+    "is_default_code_owner": "false",
+    "is_global_code_owner": "false",
+    "debug_logs": [
+      "checking code owner config file foo/bar:master:/OWNERS",
+      "found email xyz@example.com as code owner in /OWNERS",
+      "trying to resolve email xyz@example.com",
+      "resolving code owner reference CodeOwnerReference{email=xyz@example.com}",
+      "all domains are allowed",
+      "cannot resolve code owner email xyz@example.com: email is ambiguous",
+      "email xyz@example.com is not a code owner of path '/foo/bar/baz.md'"
+    ]
+  }
+```
+
 ### <a id="rename-email-in-code-owner-config-files">Rename Email In Code Owner Config Files
 _'POST /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/branches/[\{branch-id\}](../../../Documentation/rest-api-projects.html#branch-id)/code_owners.rename/'_
 
@@ -617,11 +677,26 @@
 
 ---
 
+### <a id="code-owner-check-info"> CodeOwnerCheckInfo
+The `CodeOwnerCheckInfo` entity contains the result of checking the code
+ownership of a user for a path in a branch.
+
+| Field Name      | Description |
+| --------------- | ----------- |
+| `is_code_owner` | Whether the given email owns the specified path in the branch. True if: a) the given email is resolvable (see field `is_resolvable') and b) any code owner config file assigns codeownership to the email for the path (see field `code_owner_config_file_paths`) or the email is configured as default code owner (see field `is_default_code_owner` or the email is configured as global code owner (see field `is_global_code_owner`).
+| `is_resolvable` | Whether the given email is resolvable for the specified user or the calling user if no user was specified.
+| `code_owner_config_file_paths` | Paths of the code owner config files that assign code ownership to the specified email and path as a list. Note that if code ownership is assigned to the email via a code owner config files, but the email is not resolvable (see field `is_resolvable` field), the user is not a code owner.
+| `is_default_code_owner` | Whether the given email is configured as a default code owner in the code owner config file in `refs/meta/config`. Note that if the email is configured as default code owner, but the email is not resolvable (see `is_resolvable` field), the user is not a code owner.
+| `is_global_code_owner` | Whether the given email is configured as a global
+code owner. Note that if the email is configured as global code owner, but the email is not resolvable (see `is_resolvable` field), the user is not a code owner.
+| `debug_logs` | List of debug logs that may help to understand why the user is or isn't a code owner.
+
+---
+
 ### <a id="code-owner-config-info"> CodeOwnerConfigInfo
 The `CodeOwnerConfigInfo` entity contains information about a code owner config
 for a path.
 
-
 | Field Name  |          | Description |
 | ----------- | -------- | ----------- |
 | `ignore_parent_code_owners` | optional, not set if `false` | Whether code owners from parent code owner configs (code owner configs in parent folders) should be ignored.