| // 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 com.google.common.collect.ImmutableList.toImmutableList; |
| import static com.google.common.collect.ImmutableSet.toImmutableSet; |
| import static java.util.stream.Collectors.toList; |
| |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableSet; |
| 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.api.CodeOwnerCheckInfo; |
| import com.google.gerrit.plugins.codeowners.backend.CodeOwner; |
| import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotations; |
| 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.CodeOwnersInternalServerErrorException; |
| import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners; |
| 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.backend.UnresolvedImportFormatter; |
| import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration; |
| import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval; |
| import com.google.gerrit.plugins.codeowners.util.JgitPath; |
| import com.google.gerrit.server.IdentifiedUser; |
| import com.google.gerrit.server.account.AccountResource; |
| import com.google.gerrit.server.change.ChangeFinder; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import com.google.gerrit.server.permissions.ChangePermission; |
| import com.google.gerrit.server.permissions.LabelPermission; |
| import com.google.gerrit.server.permissions.PermissionBackend; |
| import com.google.gerrit.server.permissions.PermissionBackendException; |
| import com.google.gerrit.server.permissions.ProjectPermission; |
| import com.google.gerrit.server.permissions.RefPermission; |
| 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.HashSet; |
| import java.util.List; |
| import java.util.Optional; |
| import java.util.Set; |
| 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 CheckCodeOwnerCapability checkCodeOwnerCapability; |
| 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 final UnresolvedImportFormatter unresolvedImportFormatter; |
| private final ChangeFinder changeFinder; |
| |
| private String email; |
| private String path; |
| private String change; |
| private ChangeNotes changeNotes; |
| private String user; |
| private IdentifiedUser identifiedUser; |
| |
| @Inject |
| public CheckCodeOwner( |
| CheckCodeOwnerCapability checkCodeOwnerCapability, |
| PermissionBackend permissionBackend, |
| CodeOwnersPluginConfiguration codeOwnersPluginConfiguration, |
| CodeOwnerConfigHierarchy codeOwnerConfigHierarchy, |
| PathCodeOwners.Factory pathCodeOwnersFactory, |
| Provider<CodeOwnerResolver> codeOwnerResolverProvider, |
| CodeOwners codeOwners, |
| AccountsCollection accountsCollection, |
| UnresolvedImportFormatter unresolvedImportFormatter, |
| ChangeFinder changeFinder) { |
| this.checkCodeOwnerCapability = checkCodeOwnerCapability; |
| this.permissionBackend = permissionBackend; |
| this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration; |
| this.codeOwnerConfigHierarchy = codeOwnerConfigHierarchy; |
| this.pathCodeOwnersFactory = pathCodeOwnersFactory; |
| this.codeOwnerResolverProvider = codeOwnerResolverProvider; |
| this.codeOwners = codeOwners; |
| this.accountsCollection = accountsCollection; |
| this.unresolvedImportFormatter = unresolvedImportFormatter; |
| this.changeFinder = changeFinder; |
| } |
| |
| @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 = "--change", |
| usage = |
| "change for which permissions should be checked," |
| + " if not specified change permissions are not checked") |
| public void setChange(String change) { |
| this.change = change; |
| } |
| |
| @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(checkCodeOwnerCapability.getPermission()); |
| |
| validateInput(branchResource); |
| |
| Path absolutePath = JgitPath.of(path).getAsAbsolutePath(); |
| List<String> messages = new ArrayList<>(); |
| List<Path> codeOwnerConfigFilePaths = new ArrayList<>(); |
| AtomicBoolean isCodeOwnershipAssignedToEmail = new AtomicBoolean(false); |
| AtomicBoolean isCodeOwnershipAssignedToAllUsers = new AtomicBoolean(false); |
| AtomicBoolean isDefaultCodeOwner = new AtomicBoolean(false); |
| AtomicBoolean hasRevelantCodeOwnerDefinitions = new AtomicBoolean(false); |
| AtomicBoolean parentCodeOwnersAreIgnored = new AtomicBoolean(false); |
| Set<String> annotations = new HashSet<>(); |
| codeOwnerConfigHierarchy.visit( |
| branchResource.getBranchKey(), |
| ObjectId.fromString(branchResource.getRevision()), |
| absolutePath, |
| codeOwnerConfig -> { |
| messages.add( |
| String.format( |
| "checking code owner config file %s", codeOwnerConfig.key().format(codeOwners))); |
| OptionalResultWithMessages<PathCodeOwnersResult> pathCodeOwnersResult = |
| pathCodeOwnersFactory |
| .createWithoutCache(codeOwnerConfig, absolutePath) |
| .resolveCodeOwnerConfig(); |
| messages.addAll(pathCodeOwnersResult.messages()); |
| pathCodeOwnersResult |
| .get() |
| .unresolvedImports() |
| .forEach( |
| unresolvedImport -> |
| messages.add(unresolvedImportFormatter.format(unresolvedImport))); |
| Optional<CodeOwnerReference> codeOwnerReference = |
| pathCodeOwnersResult.get().getPathCodeOwners().stream() |
| .filter(cor -> cor.email().equals(email)) |
| .findAny(); |
| if (codeOwnerReference.isPresent() |
| && !CodeOwnerResolver.ALL_USERS_WILDCARD.equals(email)) { |
| isCodeOwnershipAssignedToEmail.set(true); |
| |
| if (RefNames.isConfigRef(codeOwnerConfig.key().ref())) { |
| messages.add( |
| String.format( |
| "found email %s as a code owner in the default code owner config", email)); |
| isDefaultCodeOwner.set(true); |
| } else { |
| Path codeOwnerConfigFilePath = codeOwners.getFilePath(codeOwnerConfig.key()); |
| messages.add( |
| String.format( |
| "found email %s as a code owner in %s", email, codeOwnerConfigFilePath)); |
| codeOwnerConfigFilePaths.add(codeOwnerConfigFilePath); |
| } |
| |
| ImmutableSet<String> localAnnotations = |
| pathCodeOwnersResult.get().getAnnotationsFor(email); |
| if (!localAnnotations.isEmpty()) { |
| messages.add( |
| String.format("email %s is annotated with %s", email, sort(localAnnotations))); |
| annotations.addAll(localAnnotations); |
| } |
| } |
| |
| if (pathCodeOwnersResult.get().getPathCodeOwners().stream() |
| .anyMatch(cor -> cor.email().equals(CodeOwnerResolver.ALL_USERS_WILDCARD))) { |
| isCodeOwnershipAssignedToAllUsers.set(true); |
| |
| if (RefNames.isConfigRef(codeOwnerConfig.key().ref())) { |
| messages.add( |
| String.format( |
| "found the all users wildcard ('%s') as a code owner in the default code" |
| + " owner config which makes %s a code owner", |
| CodeOwnerResolver.ALL_USERS_WILDCARD, email)); |
| isDefaultCodeOwner.set(true); |
| } else { |
| Path codeOwnerConfigFilePath = codeOwners.getFilePath(codeOwnerConfig.key()); |
| messages.add( |
| String.format( |
| "found the all users wildcard ('%s') as a code owner in %s which makes %s a" |
| + " code owner", |
| CodeOwnerResolver.ALL_USERS_WILDCARD, codeOwnerConfigFilePath, email)); |
| if (!codeOwnerConfigFilePaths.contains(codeOwnerConfigFilePath)) { |
| codeOwnerConfigFilePaths.add(codeOwnerConfigFilePath); |
| } |
| } |
| |
| ImmutableSet<String> localAnnotations = |
| pathCodeOwnersResult.get().getAnnotationsFor(CodeOwnerResolver.ALL_USERS_WILDCARD); |
| if (!localAnnotations.isEmpty()) { |
| messages.add( |
| String.format( |
| "found annotations for the all users wildcard ('%s') which apply to %s: %s", |
| CodeOwnerResolver.ALL_USERS_WILDCARD, email, sort(localAnnotations))); |
| annotations.addAll(localAnnotations); |
| } |
| } |
| |
| if (codeOwnerResolverProvider |
| .get() |
| .resolvePathCodeOwners(codeOwnerConfig, absolutePath) |
| .hasRevelantCodeOwnerDefinitions()) { |
| hasRevelantCodeOwnerDefinitions.set(true); |
| } |
| |
| if (pathCodeOwnersResult.get().ignoreParentCodeOwners()) { |
| messages.add("parent code owners are ignored"); |
| parentCodeOwnersAreIgnored.set(true); |
| } |
| |
| return !pathCodeOwnersResult.get().ignoreParentCodeOwners(); |
| }); |
| |
| boolean isGlobalCodeOwner = false; |
| |
| if (isGlobalCodeOwner(branchResource.getNameKey(), email)) { |
| isGlobalCodeOwner = true; |
| messages.add(String.format("found email %s as global code owner", email)); |
| isCodeOwnershipAssignedToEmail.set(true); |
| } |
| |
| if (isGlobalCodeOwner(branchResource.getNameKey(), CodeOwnerResolver.ALL_USERS_WILDCARD)) { |
| isGlobalCodeOwner = true; |
| messages.add( |
| String.format( |
| "found email %s as global code owner", CodeOwnerResolver.ALL_USERS_WILDCARD)); |
| isCodeOwnershipAssignedToAllUsers.set(true); |
| } |
| |
| boolean isResolvable; |
| Boolean canReadRef = null; |
| Boolean canSeeChange = null; |
| Boolean canApproveChange = null; |
| if (email.equals(CodeOwnerResolver.ALL_USERS_WILDCARD)) { |
| isResolvable = true; |
| } else { |
| OptionalResultWithMessages<CodeOwner> isResolvableResult = isResolvable(); |
| isResolvable = isResolvableResult.isPresent(); |
| messages.addAll(isResolvableResult.messages()); |
| |
| if (isResolvable) { |
| PermissionBackend.WithUser withUser = |
| permissionBackend.absentUser(isResolvableResult.get().accountId()); |
| canReadRef = withUser.ref(branchResource.getBranchKey()).test(RefPermission.READ); |
| |
| if (changeNotes != null) { |
| PermissionBackend.ForChange forChange = withUser.change(changeNotes); |
| canSeeChange = forChange.test(ChangePermission.READ); |
| RequiredApproval requiredApproval = |
| codeOwnersPluginConfiguration |
| .getProjectConfig(branchResource.getNameKey()) |
| .getRequiredApproval(); |
| canApproveChange = |
| forChange.test( |
| new LabelPermission.WithValue( |
| requiredApproval.labelType(), requiredApproval.value())); |
| } |
| } |
| } |
| |
| ImmutableSet<String> unsupportedAnnotations = |
| annotations.stream() |
| .filter(annotation -> !CodeOwnerAnnotations.isSupported(annotation)) |
| .collect(toImmutableSet()); |
| if (!unsupportedAnnotations.isEmpty()) { |
| messages.add( |
| String.format( |
| "dropping unsupported annotations for %s: %s", email, sort(unsupportedAnnotations))); |
| annotations.removeAll(unsupportedAnnotations); |
| } |
| |
| boolean isFallbackCodeOwner = |
| !isCodeOwnershipAssignedToEmail.get() |
| && !isCodeOwnershipAssignedToAllUsers.get() |
| && !hasRevelantCodeOwnerDefinitions.get() |
| && !parentCodeOwnersAreIgnored.get() |
| && isFallbackCodeOwner(branchResource.getNameKey()); |
| |
| CodeOwnerCheckInfo codeOwnerCheckInfo = new CodeOwnerCheckInfo(); |
| codeOwnerCheckInfo.isCodeOwner = |
| (isCodeOwnershipAssignedToEmail.get() |
| || isCodeOwnershipAssignedToAllUsers.get() |
| || isFallbackCodeOwner) |
| && isResolvable; |
| codeOwnerCheckInfo.isResolvable = isResolvable; |
| codeOwnerCheckInfo.canReadRef = canReadRef; |
| codeOwnerCheckInfo.canSeeChange = canSeeChange; |
| codeOwnerCheckInfo.canApproveChange = canApproveChange; |
| codeOwnerCheckInfo.codeOwnerConfigFilePaths = |
| codeOwnerConfigFilePaths.stream().map(Path::toString).collect(toList()); |
| codeOwnerCheckInfo.isFallbackCodeOwner = isFallbackCodeOwner && isResolvable; |
| codeOwnerCheckInfo.isDefaultCodeOwner = isDefaultCodeOwner.get(); |
| codeOwnerCheckInfo.isGlobalCodeOwner = isGlobalCodeOwner; |
| codeOwnerCheckInfo.isOwnedByAllUsers = isCodeOwnershipAssignedToAllUsers.get(); |
| codeOwnerCheckInfo.annotations = sort(annotations); |
| codeOwnerCheckInfo.debugLogs = messages; |
| return Response.ok(codeOwnerCheckInfo); |
| } |
| |
| private void validateInput(BranchResource branchResource) |
| throws BadRequestException, AuthException, IOException, ConfigInvalidException, |
| PermissionBackendException { |
| 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); |
| } |
| } |
| if (change != null) { |
| Optional<ChangeNotes> changeNotes = changeFinder.findOne(change); |
| if (!changeNotes.isPresent() |
| || !permissionBackend |
| .currentUser() |
| .change(changeNotes.get()) |
| .test(ChangePermission.READ)) { |
| throw new BadRequestException(String.format("change %s not found", change)); |
| } |
| if (!changeNotes.get().getChange().getDest().equals(branchResource.getBranchKey())) { |
| throw new BadRequestException( |
| "target branch of specified change must match branch from the request URL"); |
| } |
| this.changeNotes = changeNotes.get(); |
| } |
| } |
| |
| private boolean isGlobalCodeOwner(Project.NameKey projectName, String email) { |
| return codeOwnersPluginConfiguration.getProjectConfig(projectName).getGlobalCodeOwners() |
| .stream() |
| .filter(cor -> cor.email().equals(email)) |
| .findAny() |
| .isPresent(); |
| } |
| |
| private boolean isFallbackCodeOwner(Project.NameKey projectName) { |
| FallbackCodeOwners fallbackCodeOwners = |
| codeOwnersPluginConfiguration.getProjectConfig(projectName).getFallbackCodeOwners(); |
| switch (fallbackCodeOwners) { |
| case NONE: |
| return false; |
| case PROJECT_OWNERS: |
| return isProjectOwner(projectName); |
| case ALL_USERS: |
| return true; |
| } |
| throw new IllegalStateException( |
| String.format( |
| "unknown value %s for fallbackCodeOwners in project %s", |
| fallbackCodeOwners.name(), projectName)); |
| } |
| |
| private boolean isProjectOwner(Project.NameKey projectName) { |
| try { |
| AccountResource accountResource = |
| accountsCollection.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(email)); |
| // There is no dedicated project owner permission, but project owners are detected by checking |
| // the permission to write the project config. Only project owners can do this. |
| return permissionBackend |
| .absentUser(accountResource.getUser().getAccountId()) |
| .project(projectName) |
| .test(ProjectPermission.WRITE_CONFIG); |
| } catch (PermissionBackendException |
| | ResourceNotFoundException |
| | AuthException |
| | IOException |
| | ConfigInvalidException e) { |
| throw new CodeOwnersInternalServerErrorException( |
| String.format("failed if email %s is owner of project %s", email, projectName.get()), e); |
| } |
| } |
| |
| private OptionalResultWithMessages<CodeOwner> isResolvable() { |
| 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()); |
| if (resolveResult.isPresent()) { |
| return OptionalResultWithMessages.create(resolveResult.get(), messages); |
| } |
| return OptionalResultWithMessages.createEmpty(messages); |
| } |
| |
| private ImmutableList<String> sort(Set<String> set) { |
| return set.stream().sorted().collect(toImmutableList()); |
| } |
| } |