diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/EclipseUser.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/EclipseUser.java index 6ae7006536dc4f352a935049fb17b061c794e0ff..a4811484aed831e263f7cb595c5f142a7cdf1231 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/api/models/EclipseUser.java +++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/EclipseUser.java @@ -11,10 +11,8 @@ **********************************************************************/ package org.eclipsefoundation.git.eca.api.models; -import javax.annotation.Nullable; import org.eclipsefoundation.git.eca.model.GitUser; - import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; @@ -38,7 +36,6 @@ public abstract class EclipseUser { public abstract boolean getIsCommitter(); - @Nullable @JsonIgnore public abstract Boolean getIsBot(); @@ -49,8 +46,14 @@ public abstract class EclipseUser { * @return a stubbed Eclipse user bot object. */ public static EclipseUser createBotStub(GitUser user) { - return EclipseUser.builder().setUid(0).setName(user.getName()).setMail(user.getMail()) - .setECA(ECA.builder().build()).setIsBot(true).build(); + return EclipseUser + .builder() + .setUid(0) + .setName(user.getName()) + .setMail(user.getMail()) + .setECA(ECA.builder().build()) + .setIsBot(true) + .build(); } public static Builder builder() { @@ -71,7 +74,7 @@ public abstract class EclipseUser { public abstract Builder setIsCommitter(boolean isCommitter); @JsonIgnore - public abstract Builder setIsBot(@Nullable Boolean isBot); + public abstract Builder setIsBot(boolean isBot); public abstract EclipseUser build(); } @@ -79,11 +82,9 @@ public abstract class EclipseUser { @AutoValue @JsonDeserialize(builder = AutoValue_EclipseUser_ECA.Builder.class) public abstract static class ECA { - @Nullable - public abstract Boolean getSigned(); + public abstract boolean getSigned(); - @Nullable - public abstract Boolean getCanContributeSpecProject(); + public abstract boolean getCanContributeSpecProject(); public static Builder builder() { return new AutoValue_EclipseUser_ECA.Builder().setCanContributeSpecProject(false).setSigned(false); @@ -92,9 +93,9 @@ public abstract class EclipseUser { @AutoValue.Builder @JsonPOJOBuilder(withPrefix = "set") public abstract static class Builder { - public abstract Builder setSigned(@Nullable Boolean signed); + public abstract Builder setSigned(boolean signed); - public abstract Builder setCanContributeSpecProject(@Nullable Boolean canContributeSpecProject); + public abstract Builder setCanContributeSpecProject(boolean canContributeSpecProject); public abstract ECA build(); } diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationRequest.java b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationRequest.java index c42488fe95ef58ea08fd56cbd45ea1b2aa4ebe8c..f2a4748bdf497ce2640da6af49b11854b9fc653f 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationRequest.java +++ b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationRequest.java @@ -35,7 +35,6 @@ import com.google.auto.value.AutoValue; @JsonNaming(LowerCamelCaseStrategy.class) @JsonDeserialize(builder = AutoValue_ValidationRequest.Builder.class) public abstract class ValidationRequest { - @Nullable @JsonProperty("repoUrl") public abstract URI getRepoUrl(); @@ -55,7 +54,7 @@ public abstract class ValidationRequest { @JsonPOJOBuilder(withPrefix = "set") public abstract static class Builder { @JsonProperty("repoUrl") - public abstract Builder setRepoUrl(@Nullable URI repoUrl); + public abstract Builder setRepoUrl(URI repoUrl); public abstract Builder setCommits(List<Commit> commits); diff --git a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java index 0db4d40ad82a3a6cf8937d79ae72d8de51c9724a..fbd297a143bacee72b1643d7602b3a7a998322de 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java +++ b/src/main/java/org/eclipsefoundation/git/eca/model/ValidationResponse.java @@ -50,14 +50,30 @@ public abstract class ValidationResponse { public boolean getPassed() { return getErrorCount() <= 0; - }; + } @Memoized public int getErrorCount() { return getCommits().values().stream().mapToInt(s -> s.getErrors().size()).sum(); } - /** @param message message to add to the API response */ + /** + * Add a message with a success status to the response array. + * + * @param hash the hash of the commit that the message applies to + * @param message the actual message contents + */ + public void addMessage(String hash, String message) { + addMessage(hash, message, APIStatusCode.SUCCESS_DEFAULT); + } + + /** + * Add a message with the given status to the response message map. + * + * @param hash the hash of the commit that the message applies to + * @param message the actual message contents + * @param code the code to associate with the message + */ public void addMessage(String hash, String message, APIStatusCode code) { getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addMessage(message, code); } @@ -69,7 +85,11 @@ public abstract class ValidationResponse { /** @param error message to add to the API response */ public void addError(String hash, String error, APIStatusCode code) { - getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addError(error, code); + if (getTrackedProject()) { + getCommits().computeIfAbsent(getHashKey(hash), k -> CommitStatus.builder().build()).addError(error, code); + } else { + addWarning(hash, error, code); + } } public static String getHashKey(String hash) { @@ -79,8 +99,8 @@ public abstract class ValidationResponse { /** * Converts the APIResponse to a web response with appropriate status. * - * @return a web response with status {@link Status.OK} if the commits pass - * validation, {@link Status.FORBIDDEN} otherwise. + * @return a web response with status {@link Status.OK} if the commits pass validation, {@link Status.FORBIDDEN} + * otherwise. */ public Response toResponse() { // update error count before returning @@ -92,8 +112,11 @@ public abstract class ValidationResponse { } public static Builder builder() { - return new AutoValue_ValidationResponse.Builder().setStrictMode(false).setTrackedProject(false) - .setTime(ZonedDateTime.now()).setCommits(new HashMap<>()); + return new AutoValue_ValidationResponse.Builder() + .setStrictMode(false) + .setTrackedProject(false) + .setTime(ZonedDateTime.now()) + .setCommits(new HashMap<>()); } @AutoValue.Builder diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java index 6f4dbcff8e2f74e58702f081898526b2c1024081..6345a0f67b373a4d25a2ebc35a95e6fa7a5d0290 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java +++ b/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java @@ -13,13 +13,9 @@ package org.eclipsefoundation.git.eca.resource; import java.net.MalformedURLException; -import java.net.URI; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; import javax.inject.Inject; import javax.ws.rs.Consumes; @@ -28,7 +24,6 @@ import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; -import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -37,20 +32,15 @@ import org.eclipsefoundation.core.model.RequestWrapper; import org.eclipsefoundation.core.service.CachingService; import org.eclipsefoundation.git.eca.api.models.EclipseUser; import org.eclipsefoundation.git.eca.dto.CommitValidationStatus; -import org.eclipsefoundation.git.eca.helper.CommitHelper; -import org.eclipsefoundation.git.eca.model.Commit; -import org.eclipsefoundation.git.eca.model.GitUser; import org.eclipsefoundation.git.eca.model.Project; import org.eclipsefoundation.git.eca.model.ValidationRequest; import org.eclipsefoundation.git.eca.model.ValidationResponse; import org.eclipsefoundation.git.eca.namespace.APIStatusCode; -import org.eclipsefoundation.git.eca.namespace.ProviderType; import org.eclipsefoundation.git.eca.service.InterestGroupService; import org.eclipsefoundation.git.eca.service.ProjectsService; import org.eclipsefoundation.git.eca.service.UserService; import org.eclipsefoundation.git.eca.service.ValidationService; import org.jboss.resteasy.annotations.jaxrs.QueryParam; -import org.jboss.resteasy.specimpl.MultivaluedMapImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -116,48 +106,11 @@ public class ValidationResource { // only process if we have no errors if (messages.isEmpty()) { LOGGER.debug("Processing: {}", req); - // filter the projects based on the repo URL. At least one repo in project must - // match the repo URL to be valid - List<Project> filteredProjects = retrieveProjectsForRequest(req); - ValidationResponse r = ValidationResponse - .builder() - .setStrictMode(req.getStrictMode() != null && req.getStrictMode() ? true : false) - .setTrackedProject(!filteredProjects.isEmpty()) - .setFingerprint(validation.generateRequestHash(req)) - .build(); - List<CommitValidationStatus> statuses = validation - .getRequestCommitValidationStatus(wrapper, req, - filteredProjects.isEmpty() ? null : filteredProjects.get(0).getProjectId()); - - for (Commit c : req.getCommits()) { - // get the current status if present - Optional<CommitValidationStatus> status = statuses - .stream() - .filter(s -> c.getHash().equals(s.getCommitHash())) - .findFirst(); - // skip the commit validation if already passed - if (status.isPresent() && status.get().getErrors().isEmpty()) { - r - .addMessage(c.getHash(), "Commit was previously validated, skipping processing", - APIStatusCode.SUCCESS_SKIPPED); - continue; - } - // process the request, capturing if we should continue processing - boolean continueProcessing = processCommit(c, r, filteredProjects, req.getProvider()); - // update the persistent validation state - // if there is a reason to stop processing, break the loop - if (!continueProcessing) { - break; - } - } - validation - .updateCommitValidationStatus(wrapper, r, req, statuses, - filteredProjects.isEmpty() ? null : filteredProjects.get(0)); - return r.toResponse(); + return validation.validateIncomingRequest(req, wrapper).toResponse(); } else { // create a stubbed response with the errors ValidationResponse out = ValidationResponse.builder().build(); - messages.forEach(m -> addError(out, m, null)); + messages.forEach(m -> out.addError(m, null,APIStatusCode.ERROR_DEFAULT)); return out.toResponse(); } } @@ -176,10 +129,10 @@ public class ValidationResource { if (statuses.isEmpty()) { return Response.status(404).build(); } - List<Project> projects = retrieveProjectsForRepoURL(statuses.get(0).getRepoUrl(), + List<Project> ps = projects.retrieveProjectsForRepoURL(statuses.get(0).getRepoUrl(), statuses.get(0).getProvider()); return Response.ok().entity(membershipTemplateWeb.data("statuses", statuses, "repoUrl", - statuses.get(0).getRepoUrl(), "project", projects.isEmpty() ? null : projects.get(0)).render()).build(); + statuses.get(0).getRepoUrl(), "project", ps.isEmpty() ? null : ps.get(0)).render()).build(); } @GET @@ -224,340 +177,4 @@ public class ValidationResource { return messages; } - /** - * Process the current request, validating that the passed commit is valid. The - * author and committers Eclipse Account is retrieved, which are then used to - * check if the current commit is valid for the current project. - * - * @param c the commit to process - * @param response the response container - * @param filteredProjects tracked projects for the current request - * @return true if we should continue processing, false otherwise. - */ - private boolean processCommit(Commit c, ValidationResponse response, List<Project> filteredProjects, - ProviderType provider) { - // ensure the commit is valid, and has required fields - if (!CommitHelper.validateCommit(c)) { - addError(response, "One or more commits were invalid. Please check the payload and try again", c.getHash()); - return false; - } - // retrieve the author + committer for the current request - GitUser author = c.getAuthor(); - GitUser committer = c.getCommitter(); - - addMessage(response, String.format("Reviewing commit: %1$s", c.getHash()), c.getHash()); - addMessage(response, String.format("Authored by: %1$s <%2$s>", author.getName(), author.getMail()), - c.getHash()); - - // skip processing if a merge commit - if (c.getParents().size() > 1) { - addMessage(response, - String.format("Commit '%1$s' has multiple parents, merge commit detected, passing", c.getHash()), - c.getHash()); - return true; - } - - // retrieve the eclipse account for the author - EclipseUser eclipseAuthor = getIdentifiedUser(author); - // if the user is a bot, generate a stubbed user - if (isAllowedUser(author.getMail()) || users.userIsABot(author.getMail(), filteredProjects)) { - addMessage(response, String - .format("Automated user '%1$s' detected for author of commit %2$s", author.getMail(), c.getHash()), - c.getHash()); - eclipseAuthor = EclipseUser.createBotStub(author); - } else if (eclipseAuthor == null) { - addMessage(response, - String - .format("Could not find an Eclipse user with mail '%1$s' for author of commit %2$s", - author.getMail(), c.getHash()), - c.getHash()); - addError(response, "Author must have an Eclipse Account", c.getHash(), APIStatusCode.ERROR_AUTHOR); - return true; - } - - // retrieve the eclipse account for the committer - EclipseUser eclipseCommitter = getIdentifiedUser(committer); - // check if whitelisted or bot - if (isAllowedUser(committer.getMail()) || users.userIsABot(committer.getMail(), filteredProjects)) { - addMessage(response, - String - .format("Automated user '%1$s' detected for committer of commit %2$s", committer.getMail(), - c.getHash()), - c.getHash()); - eclipseCommitter = EclipseUser.createBotStub(committer); - } else if (eclipseCommitter == null) { - addMessage(response, - String - .format("Could not find an Eclipse user with mail '%1$s' for committer of commit %2$s", - committer.getMail(), c.getHash()), - c.getHash()); - addError(response, "Committing user must have an Eclipse Account", c.getHash(), - APIStatusCode.ERROR_COMMITTER); - return true; - } - // validate author access to the current repo - validateUserAccess(response, c, eclipseAuthor, filteredProjects, APIStatusCode.ERROR_AUTHOR); - - // check committer general access - boolean isCommittingUserCommitter = isCommitter(response, eclipseCommitter, c.getHash(), filteredProjects); - validateUserAccessPartial(response, c, eclipseCommitter, isCommittingUserCommitter, - APIStatusCode.ERROR_COMMITTER); - return true; - } - - /** - * Validates author access for the current commit. If there are errors, they are - * recorded in the response for the current request to be returned once all - * validation checks are completed. - * - * @param r the current response object for the request - * @param c the commit that is being validated - * @param eclipseUser the user to validate on a branch - * @param filteredProjects tracked projects for the current request - * @param errorCode the error code to display if the user does not have - * access - */ - private void validateUserAccess(ValidationResponse r, Commit c, EclipseUser eclipseUser, - List<Project> filteredProjects, APIStatusCode errorCode) { - // call isCommitter inline and pass to partial call - validateUserAccessPartial(r, c, eclipseUser, isCommitter(r, eclipseUser, c.getHash(), filteredProjects), - errorCode); - } - - /** - * Allows for isCommitter to be called external to this method. This was - * extracted to ensure that isCommitter isn't called twice for the same user - * when checking committer proxy push rules and committer general access. - * - * @param r the current response object for the request - * @param c the commit that is being validated - * @param eclipseUser the user to validate on a branch - * @param isCommitter the results of the isCommitter call from this class. - * @param errorCode the error code to display if the user does not have access - */ - private void validateUserAccessPartial(ValidationResponse r, Commit c, EclipseUser eclipseUser, boolean isCommitter, - APIStatusCode errorCode) { - String userType = "author"; - if (APIStatusCode.ERROR_COMMITTER.equals(errorCode)) { - userType = "committer"; - } - if (isCommitter) { - addMessage(r, String - .format("Eclipse user '%s'(%s) is a committer on the project.", eclipseUser.getName(), userType), - c.getHash()); - } else { - addMessage(r, - String - .format("Eclipse user '%s'(%s) is not a committer on the project.", eclipseUser.getName(), - userType), - c.getHash()); - // check if the author is signed off if not a committer - if (eclipseUser.getECA().getSigned()) { - addMessage(r, String - .format("Eclipse user '%s'(%s) has a current Eclipse Contributor Agreement (ECA) on file.", - eclipseUser.getName(), userType), - c.getHash()); - } else { - addMessage(r, String - .format("Eclipse user '%s'(%s) does not have a current Eclipse Contributor Agreement (ECA) on file.\n" - + "If there are multiple commits, please ensure that each author has a ECA.", - eclipseUser.getName(), userType), - c.getHash()); - addError(r, - String - .format("An Eclipse Contributor Agreement is required for Eclipse user '%s'(%s).", - eclipseUser.getName(), userType), - c.getHash(), errorCode); - } - } - } - - /** - * Checks whether the given user is a committer on the project. If they are and - * the project is also a specification for a working group, an additional access - * check is made against the user. - * - * <p> - * Additionally, a check is made to see if the user is a registered bot user for - * the given project. If they match - * for the given project, they are granted committer-like access to the - * repository. - * - * @param r the current response object for the request - * @param user the user to validate on a branch - * @param hash the hash of the commit that is being validated - * @param filteredProjects tracked projects for the current request - * @return true if user is considered a committer, false otherwise. - */ - private boolean isCommitter(ValidationResponse r, EclipseUser user, String hash, List<Project> filteredProjects) { - // iterate over filtered projects - for (Project p : filteredProjects) { - LOGGER.debug("Checking project '{}' for user '{}'", p.getName(), user.getName()); - // check if any of the committers usernames match the current user - if (p.getCommitters().stream().anyMatch(u -> u.getUsername().equals(user.getName()))) { - // check if the current project is a committer project, and if the user can - // commit to specs - if (p.getSpecWorkingGroup() != null && !user.getECA().getCanContributeSpecProject()) { - // set error + update response status - r - .addError(hash, String - .format("Project is a specification for the working group '%1$s', but user does not have permission to modify a specification project", - p.getSpecWorkingGroup()), - APIStatusCode.ERROR_SPEC_PROJECT); - return false; - } else { - LOGGER - .debug("User '{}' was found to be a committer on current project repo '{}'", user.getMail(), - p.getName()); - return true; - } - } - } - // check if user is a bot, either through early detection or through on-demand - // check - if ((user.getIsBot() != null && user.getIsBot()) || users.userIsABot(user.getMail(), filteredProjects)) { - LOGGER.debug("User '{} <{}>' was found to be a bot", user.getName(), user.getMail()); - return true; - } - return false; - } - - private boolean isAllowedUser(String mail) { - return allowListUsers.indexOf(mail) != -1; - } - - /** - * Retrieves projects valid for the current request, or an empty list if no data - * or matching project repos could be found. - * - * @param req the current request - * @return list of matching projects for the current request, or an empty list - * if none found. - */ - private List<Project> retrieveProjectsForRequest(ValidationRequest req) { - String repoUrl = req.getRepoUrl().getPath(); - if (repoUrl == null) { - LOGGER.warn("Can not match null repo URL to projects"); - return Collections.emptyList(); - } - return retrieveProjectsForRepoURL(repoUrl, req.getProvider()); - } - - /** - * Retrieves projects for given provider, using the repo URL to match to a - * stored repository. - * - * @param repoUrl the repo URL to match - * @param provider the provider that is being served for the request. - * @return a list of matching projects, or an empty list if none are found. - */ - private List<Project> retrieveProjectsForRepoURL(String repoUrl, ProviderType provider) { - if (repoUrl == null) { - LOGGER.warn("Can not match null repo URL to projects"); - return Collections.emptyList(); - } - // check for all projects that make use of the given repo - List<Project> availableProjects = projects.getProjects(); - availableProjects - .addAll(cache - .get("all", new MultivaluedMapImpl<>(), Project.class, - () -> ig.adaptInterestGroups(ig.getInterestGroups())) - .orElse(Collections.emptyList())); - if (availableProjects == null || availableProjects.isEmpty()) { - LOGGER.warn("Could not find any projects to match against"); - return Collections.emptyList(); - } - LOGGER.debug("Checking projects for repos that end with: {}", repoUrl); - - // filter the projects based on the repo URL. At least one repo in project must - // match the repo URL to be valid - if (ProviderType.GITLAB.equals(provider)) { - // get the path of the project, removing the leading slash - String projectNamespace = URI.create(repoUrl).getPath().substring(1).toLowerCase(); - return availableProjects - .stream() - .filter(p -> projectNamespace.startsWith(p.getGitlab().getProjectGroup() + "/") && p - .getGitlab() - .getIgnoredSubGroups() - .stream() - .noneMatch(sg -> projectNamespace.startsWith(sg + "/"))) - .collect(Collectors.toList()); - } else if (ProviderType.GITHUB.equals(provider)) { - return availableProjects - .stream() - .filter(p -> p - .getGithubRepos() - .stream() - .anyMatch(re -> re.getUrl() != null && re.getUrl().endsWith(repoUrl))) - .collect(Collectors.toList()); - } else if (ProviderType.GERRIT.equals(provider)) { - return availableProjects - .stream() - .filter(p -> p - .getGerritRepos() - .stream() - .anyMatch(re -> re.getUrl() != null && re.getUrl().endsWith(repoUrl))) - .collect(Collectors.toList()); - } else { - return availableProjects - .stream() - .filter(p -> p.getRepos().stream().anyMatch(re -> re.getUrl().endsWith(repoUrl))) - .collect(Collectors.toList()); - } - } - - /** - * Retrieves an Eclipse Account user object given the Git users email address - * (at minimum). This is facilitated using the Eclipse Foundation accounts API, - * along short lived in-memory caching for performance and some protection - * against duplicate requests. - * - * @param user the user to retrieve Eclipse Account information for - * @return the Eclipse Account user information if found, or null if there was - * an error or no user exists. - */ - private EclipseUser getIdentifiedUser(GitUser user) { - // get the Eclipse account for the user - try { - // use cache to avoid asking for the same user repeatedly on repeated requests - EclipseUser foundUser = users.getUser(user.getMail()); - if (foundUser == null) { - LOGGER.warn("No users found for mail '{}'", user.getMail()); - } - return foundUser; - } catch (WebApplicationException e) { - Response r = e.getResponse(); - if (r != null && r.getStatus() == 404) { - LOGGER.error("No users found for mail '{}'", user.getMail()); - } else { - LOGGER.error("Error while checking for user", e); - } - } - return null; - } - - private void addMessage(ValidationResponse r, String message, String hash) { - addMessage(r, message, hash, APIStatusCode.SUCCESS_DEFAULT); - } - - private void addError(ValidationResponse r, String message, String hash) { - addError(r, message, hash, APIStatusCode.ERROR_DEFAULT); - } - - private void addMessage(ValidationResponse r, String message, String hash, APIStatusCode code) { - if (LOGGER.isDebugEnabled()) { - LOGGER.debug(message); - } - r.addMessage(hash, message, code); - } - - private void addError(ValidationResponse r, String message, String hash, APIStatusCode code) { - LOGGER.error(message); - // only add as strict error for tracked projects - if (r.getTrackedProject() || r.getStrictMode()) { - r.addError(hash, message, code); - } else { - r.addWarning(hash, message, code); - } - } } diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/ProjectsService.java b/src/main/java/org/eclipsefoundation/git/eca/service/ProjectsService.java index f22f1bfecd8e2b6a0a2524894d7d17e06bf7532f..925ddfda35a9dee3a55b5052cda6cd19b6d7ed92 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/service/ProjectsService.java +++ b/src/main/java/org/eclipsefoundation/git/eca/service/ProjectsService.java @@ -14,21 +14,41 @@ package org.eclipsefoundation.git.eca.service; import java.util.List; import org.eclipsefoundation.git.eca.model.Project; +import org.eclipsefoundation.git.eca.model.ValidationRequest; +import org.eclipsefoundation.git.eca.namespace.ProviderType; /** - * Intermediate layer between resource and API layers that handles retrieval of - * all projects and caching of that data for availability purposes. + * Intermediate layer between resource and API layers that handles retrieval of all projects and caching of that data + * for availability purposes. * * @author Martin Lowe * */ public interface ProjectsService { - /** - * Retrieves all currently available projects from cache if available, otherwise - * going to API to retrieve a fresh copy of the data. - * - * @return list of projects available from API. - */ - List<Project> getProjects(); + /** + * Retrieves all currently available projects from cache if available, otherwise going to API to retrieve a fresh copy + * of the data. + * + * @return list of projects available from API. + */ + List<Project> getProjects(); + + /** + * Retrieves projects valid for the current request, or an empty list if no data or matching project repos could be + * found. + * + * @param req the current request + * @return list of matching projects for the current request, or an empty list if none found. + */ + List<Project> retrieveProjectsForRequest(ValidationRequest req); + + /** + * Retrieves projects for given provider, using the repo URL to match to a stored repository. + * + * @param repoUrl the repo URL to match + * @param provider the provider that is being served for the request. + * @return a list of matching projects, or an empty list if none are found. + */ + List<Project> retrieveProjectsForRepoURL(String repoUrl, ProviderType provider); } diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/ValidationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/ValidationService.java index 41af192fc945f078038234f2fe89707a24ea6e50..55b9cb902d84cff2b63d92232e766c6e48a5bf96 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/service/ValidationService.java +++ b/src/main/java/org/eclipsefoundation/git/eca/service/ValidationService.java @@ -24,55 +24,60 @@ import org.eclipsefoundation.git.eca.model.ValidationResponse; import io.undertow.util.HexConverter; /** + * Service containing logic for validating commits, and retrieving/updating previous validation statuses. * - * @author martin + * @author Martin Lowe * */ public interface ValidationService { /** - * Retrieves a set of validation status objects given the validation request - * fingerprint. + * Validate an incoming request, checking to make sure that ECA is present for users interacting with the API, as well + * as maintain access rights to spec projects, blocking non-elevated access to the repositories. * - * @param wrapper current request wrapper object + * @param req the request to validate + * @param wrapper the current request wrapper + * @return the validation results to return to the user. + */ + public ValidationResponse validateIncomingRequest(ValidationRequest req, RequestWrapper wrapper); + + /** + * Retrieves a set of validation status objects given the validation request fingerprint. + * + * @param wrapper current request wrapper object * @param fingerprint the validation request fingerprint * @return the list of historic validation status objects, or an empty list. */ public List<CommitValidationStatus> getHistoricValidationStatus(RequestWrapper wrapper, String fingerprint); /** - * Retrieves a set of commit validation status objects given a validation - * request and target project. + * Retrieves a set of commit validation status objects given a validation request and target project. * - * @param wrapper current request wrapper object - * @param req the current validation request + * @param wrapper current request wrapper object + * @param req the current validation request * @param projectId the project targeted by the validation request - * @return the list of existing validation status objects for the validation - * request, or an empty list. + * @return the list of existing validation status objects for the validation request, or an empty list. */ - public List<CommitValidationStatus> getRequestCommitValidationStatus(RequestWrapper wrapper, ValidationRequest req, - String projectId); + public List<CommitValidationStatus> getRequestCommitValidationStatus(RequestWrapper wrapper, ValidationRequest req, String projectId); /** - * Updates or creates validation status objects for the commits validated as - * part of the current validation request. Uses information from both the - * original request and the final response to generate details to be preserved - * in commit status objects. + * Updates or creates validation status objects for the commits validated as part of the current validation request. + * Uses information from both the original request and the final response to generate details to be preserved in commit + * status objects. * - * @param wrapper current request wrapper object - * @param r the final validation response - * @param req the current validation request + * @param wrapper current request wrapper object + * @param r the final validation response + * @param req the current validation request * @param statuses list of existing commit validation objects to update - * @param p the project targeted by the validation request. + * @param p the project targeted by the validation request. */ public void updateCommitValidationStatus(RequestWrapper wrapper, ValidationResponse r, ValidationRequest req, List<CommitValidationStatus> statuses, Project p); /** - * Generates a request fingerprint for looking up requests that have already - * been processed in the past. Collision here is extremely unlikely, and low - * risk on the change it does. For that reason, a more secure but heavier - * hashing alg. wasn't chosen. + * Generates a request fingerprint for looking up requests that have already been processed in the past. Collision here + * is extremely unlikely, and low risk on the change it does. For that reason, a more secure but heavier hashing alg. + * wasn't chosen. * * @param req the request to generate a fingerprint for * @return the fingerprint for the request diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/CachedUserService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/CachedUserService.java index f49504828a7834d9805ffe41ae014fb45780dd56..dc4427f09ac477f376f7dac5a5e826703cbea5c3 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/service/impl/CachedUserService.java +++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/CachedUserService.java @@ -24,6 +24,7 @@ import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; import javax.ws.rs.WebApplicationException; +import org.apache.commons.lang3.StringUtils; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.rest.client.inject.RestClient; import org.eclipsefoundation.core.service.CachingService; @@ -77,6 +78,9 @@ public class CachedUserService implements UserService { @Override public EclipseUser getUser(String mail) { + if (StringUtils.isBlank(mail)) { + return null; + } Optional<EclipseUser> u = cache.get(mail, new MultivaluedMapImpl<>(), EclipseUser.class, () -> retrieveUser(mail)); if (u.isPresent()) { @@ -89,6 +93,9 @@ public class CachedUserService implements UserService { @Override public EclipseUser getUserByGithubUsername(String username) { + if (StringUtils.isBlank(username)) { + return null; + } Optional<EclipseUser> u = cache.get("gh:" + username, new MultivaluedMapImpl<>(), EclipseUser.class, () -> accounts.getUserByGithubUname(getBearerToken(), username)); if (u.isPresent()) { @@ -101,7 +108,7 @@ public class CachedUserService implements UserService { @Override public boolean userIsABot(String mail, List<Project> filteredProjects) { - if (mail == null || "".equals(mail.trim())) { + if (StringUtils.isBlank(mail)) { return false; } List<JsonNode> botObjs = getBots(); @@ -133,6 +140,10 @@ public class CachedUserService implements UserService { * @return the user account if found by mail, or null if none found. */ private EclipseUser retrieveUser(String mail) { + if (StringUtils.isBlank(mail)) { + LOGGER.debug("Blank mail passed, cannot fetch user"); + return null; + } LOGGER.debug("Getting fresh user for {}", mail); // check for noreply (no reply will never have user account, and fails fast) EclipseUser eclipseUser = checkForNoReplyUser(mail); @@ -162,6 +173,10 @@ public class CachedUserService implements UserService { * mapped, otherwise null */ private EclipseUser checkForNoReplyUser(String mail) { + if (StringUtils.isBlank(mail)) { + LOGGER.debug("Blank mail passed, cannot fetch user"); + return null; + } LOGGER.debug("Checking user with mail {} for no-reply", mail); boolean isNoReply = patterns.stream().anyMatch(pattern -> pattern.matcher(mail.trim()).find()); if (isNoReply) { diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultSystemHookService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultSystemHookService.java index 168602c5b2890a19e5676a37154a01180c448c51..ce74c29d7f25715884b0fcf2bc60121bccb24e59 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultSystemHookService.java +++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultSystemHookService.java @@ -84,28 +84,25 @@ public class DefaultSystemHookService implements SystemHookService { } /** - * Gathers all relevant data related to the created project and persists the - * information into a database + * Gathers all relevant data related to the created project and persists the information into a database * * @param wrapper the request wrapper containing all uri, ip, and query params - * @param hook the incoming system hook + * @param hook the incoming system hook */ @Transactional void trackPrivateProjectCreation(RequestWrapper wrapper, SystemHook hook) { try { - LOGGER.debug("Tracking creation of project: [id: {}, path: {}]", hook.getProjectId(), - hook.getPathWithNamespace()); + LOGGER.debug("Tracking creation of project: [id: {}, path: {}]", hook.getProjectId(), hook.getPathWithNamespace()); - Optional<GitlabProjectResponse> response = cache.get(Integer.toString(hook.getProjectId()), - new MultivaluedMapImpl<>(), GitlabProjectResponse.class, - () -> api.getProjectInfo(apiToken, hook.getProjectId())); + Optional<GitlabProjectResponse> response = cache + .get(Integer.toString(hook.getProjectId()), new MultivaluedMapImpl<>(), GitlabProjectResponse.class, + () -> api.getProjectInfo(apiToken, hook.getProjectId())); if (response.isPresent()) { PrivateProjectEvent dto = mapToDto(hook, response.get()); dao.add(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class)), Arrays.asList(dto)); } else { - LOGGER.error("No info for project: [id: {}, path: {}]", hook.getProjectId(), - hook.getPathWithNamespace()); + LOGGER.error("No info for project: [id: {}, path: {}]", hook.getProjectId(), hook.getPathWithNamespace()); } } catch (Exception e) { @@ -114,61 +111,54 @@ public class DefaultSystemHookService implements SystemHookService { } /** - * Retrieves the event record for the project that has been deleted. Adds the - * deletionDate field and updates the DB record. + * Retrieves the event record for the project that has been deleted. Adds the deletionDate field and updates the DB + * record. * * @param wrapper the request wrapper containing all uri, ip, and query params - * @param hook the incoming system hook + * @param hook the incoming system hook */ @Transactional void trackPrivateProjectDeletion(RequestWrapper wrapper, SystemHook hook) { try { - LOGGER.debug("Tracking deletion of project: [id: {}, path: {}]", hook.getProjectId(), - hook.getPathWithNamespace()); + LOGGER.debug("Tracking deletion of project: [id: {}, path: {}]", hook.getProjectId(), hook.getPathWithNamespace()); MultivaluedMap<String, String> params = new MultivaluedMapImpl<>(); params.add(GitEcaParameterNames.PROJECT_ID.getName(), Integer.toString(hook.getProjectId())); params.add(GitEcaParameterNames.PROJECT_PATH.getName(), hook.getPathWithNamespace()); - List<PrivateProjectEvent> results = dao - .get(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class), params)); + List<PrivateProjectEvent> results = dao.get(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class), params)); if (results.isEmpty()) { - LOGGER.error("No results for project event: [id: {}, path: {}]", hook.getProjectId(), - hook.getPathWithNamespace()); + LOGGER.error("No results for project event: [id: {}, path: {}]", hook.getProjectId(), hook.getPathWithNamespace()); } else { results.get(0).setDeletionDate(hook.getUpdatedAt().toLocalDateTime()); dao.add(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class)), results); } } catch (Exception e) { - LOGGER.error(String.format("Error updating project: [id: %s, path: %s]", hook.getProjectId(), - hook.getPathWithNamespace()), e); + LOGGER.error(String.format("Error updating project: [id: %s, path: %s]", hook.getProjectId(), hook.getPathWithNamespace()), e); } } /** - * Retrieves the event record for the project that has been renamed. Updates the - * projectPath field and creates a new DB record. + * Retrieves the event record for the project that has been renamed. Updates the projectPath field and creates a new DB + * record. * * @param wrapper the request wrapper containing all uri, ip, and query params - * @param hook the incoming system hook + * @param hook the incoming system hook */ @Transactional void trackPrivateProjectRenaming(RequestWrapper wrapper, SystemHook hook) { try { - LOGGER.debug("Tracking renaming of project: [id: {}, path: {}]", hook.getProjectId(), - hook.getOldPathWithNamespace()); + LOGGER.debug("Tracking renaming of project: [id: {}, path: {}]", hook.getProjectId(), hook.getOldPathWithNamespace()); MultivaluedMap<String, String> params = new MultivaluedMapImpl<>(); params.add(GitEcaParameterNames.PROJECT_ID.getName(), Integer.toString(hook.getProjectId())); params.add(GitEcaParameterNames.PROJECT_PATH.getName(), hook.getOldPathWithNamespace()); - List<PrivateProjectEvent> results = dao - .get(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class), params)); + List<PrivateProjectEvent> results = dao.get(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class), params)); if (results.isEmpty()) { - LOGGER.error("No results for project event: [id: {}, path: {}]", hook.getProjectId(), - hook.getOldPathWithNamespace()); + LOGGER.error("No results for project event: [id: {}, path: {}]", hook.getProjectId(), hook.getOldPathWithNamespace()); } else { // Create a new event and track in the DB PrivateProjectEvent event = new PrivateProjectEvent(results.get(0).getCompositeId().getUserId(), @@ -180,23 +170,22 @@ public class DefaultSystemHookService implements SystemHookService { dao.add(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class)), Arrays.asList(event)); } } catch (Exception e) { - LOGGER.error(String.format("Error updating project: [id: %s, path: %s]", hook.getProjectId(), - hook.getOldPathWithNamespace()), e); + LOGGER + .error(String.format("Error updating project: [id: %s, path: %s]", hook.getProjectId(), hook.getOldPathWithNamespace()), + e); } } /** - * Takes the received hook and Gitlab api response body and maps the values to a - * PrivateProjectEvent dto. + * Takes the received hook and Gitlab api response body and maps the values to a PrivateProjectEvent dto. * - * @param hookthe received system hook + * @param hookthe received system hook * @param gitlabInfo The gitlab project data * @return A PrivateProjectEvent object */ private PrivateProjectEvent mapToDto(SystemHook hook, GitlabProjectResponse gitlabInfo) { - PrivateProjectEvent eventDto = new PrivateProjectEvent(gitlabInfo.getCreatorId(), hook.getProjectId(), - hook.getPathWithNamespace()); + PrivateProjectEvent eventDto = new PrivateProjectEvent(gitlabInfo.getCreatorId(), hook.getProjectId(), hook.getPathWithNamespace()); eventDto.setCreationDate(hook.getCreatedAt().toLocalDateTime()); diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java index 76f46ffa0ba3a2b1fd16e8251409d3dd6907ccea..1d09d596e04432b25d2055d4a6ba9d3a22411a6d 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java +++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java @@ -14,27 +14,35 @@ package org.eclipsefoundation.git.eca.service.impl; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map.Entry; import java.util.Optional; import java.util.stream.Collectors; import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; +import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; import org.apache.commons.lang3.StringUtils; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipsefoundation.core.helper.DateTimeHelper; import org.eclipsefoundation.core.model.RequestWrapper; +import org.eclipsefoundation.core.service.CachingService; +import org.eclipsefoundation.git.eca.api.models.EclipseUser; import org.eclipsefoundation.git.eca.dto.CommitValidationMessage; import org.eclipsefoundation.git.eca.dto.CommitValidationStatus; import org.eclipsefoundation.git.eca.dto.CommitValidationStatusGrouping; import org.eclipsefoundation.git.eca.helper.CommitHelper; import org.eclipsefoundation.git.eca.model.Commit; -import org.eclipsefoundation.git.eca.model.CommitStatus; +import org.eclipsefoundation.git.eca.model.GitUser; import org.eclipsefoundation.git.eca.model.Project; import org.eclipsefoundation.git.eca.model.ValidationRequest; import org.eclipsefoundation.git.eca.model.ValidationResponse; +import org.eclipsefoundation.git.eca.namespace.APIStatusCode; import org.eclipsefoundation.git.eca.namespace.GitEcaParameterNames; +import org.eclipsefoundation.git.eca.service.InterestGroupService; +import org.eclipsefoundation.git.eca.service.ProjectsService; +import org.eclipsefoundation.git.eca.service.UserService; import org.eclipsefoundation.git.eca.service.ValidationService; import org.eclipsefoundation.persistence.dao.PersistenceDao; import org.eclipsefoundation.persistence.model.RDBMSQuery; @@ -47,11 +55,68 @@ import org.slf4j.LoggerFactory; public class DefaultValidationService implements ValidationService { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultValidationService.class); + @ConfigProperty(name = "eclipse.mail.allowlist") + List<String> allowListUsers; + + @Inject + ProjectsService projects; + @Inject + InterestGroupService ig; + @Inject + UserService users; + + @Inject + CachingService cache; @Inject PersistenceDao dao; @Inject FilterService filters; + @Override + public ValidationResponse validateIncomingRequest(ValidationRequest req, RequestWrapper wrapper) { + + // get the projects associated with the current request, if any + List<Project> filteredProjects = projects.retrieveProjectsForRequest(req); + // build the response object that we can populate with messages (the message/error arrays are mutable unlike the rest of + // the class) + ValidationResponse r = ValidationResponse + .builder() + .setStrictMode(Boolean.TRUE.equals(req.getStrictMode())) + .setTrackedProject(!filteredProjects.isEmpty()) + .setFingerprint(generateRequestHash(req)) + .build(); + // check to make sure commits are valid + if (req.getCommits().stream().anyMatch(c -> !CommitHelper.validateCommit(c))) { + // for each invalid commit, add errors to output and return + req + .getCommits() + .stream() + .filter(c -> !CommitHelper.validateCommit(c)) + .forEach(c -> r + .addError(c.getHash(), "One or more commits were invalid. Please check the payload and try again", + APIStatusCode.ERROR_DEFAULT)); + return r; + } + + // get previous validation status messages + List<CommitValidationStatus> statuses = getRequestCommitValidationStatus(wrapper, req, + filteredProjects.isEmpty() ? null : filteredProjects.get(0).getProjectId()); + + req.getCommits().stream().forEach(c -> { + // get the current status if present + Optional<CommitValidationStatus> status = statuses.stream().filter(s -> s.getCommitHash().equals(c.getHash())).findFirst(); + // skip the commit validation if already passed + if (status.isPresent() && status.get().getErrors().isEmpty()) { + r.addMessage(c.getHash(), "Commit was previously validated, skipping processing", APIStatusCode.SUCCESS_SKIPPED); + return; + } + // process the current commit + processCommit(c, r, filteredProjects); + }); + updateCommitValidationStatus(wrapper, r, req, statuses, filteredProjects.isEmpty() ? null : filteredProjects.get(0)); + return r; + } + @Override public List<CommitValidationStatus> getHistoricValidationStatus(RequestWrapper wrapper, String fingerprint) { if (StringUtils.isAllBlank(fingerprint)) { @@ -79,11 +144,7 @@ public class DefaultValidationService implements ValidationService { List<CommitValidationStatus> statuses, Project p) { // iterate over commit responses, and update statuses in DB List<CommitValidationStatus> updatedStatuses = new ArrayList<>(); - for (Entry<String, CommitStatus> e : r.getCommits().entrySet()) { - if (ValidationResponse.NIL_HASH_PLACEHOLDER.equalsIgnoreCase(e.getKey())) { - LOGGER.warn("Not logging errors/validation associated with unknown commit"); - continue; - } + r.getCommits().entrySet().stream().filter(e -> !ValidationResponse.NIL_HASH_PLACEHOLDER.equalsIgnoreCase(e.getKey())).forEach(e -> { // update the status if present, otherwise make new one. Optional<CommitValidationStatus> status = statuses.stream().filter(s -> e.getKey().equals(s.getCommitHash())).findFirst(); CommitValidationStatus base; @@ -100,14 +161,14 @@ public class DefaultValidationService implements ValidationService { base.setLastModified(DateTimeHelper.now()); updatedStatuses.add(base); // get the commit for current status - Optional<Commit> commit = req.getCommits().stream().filter(c -> c.getHash().equals(e.getKey())).findFirst(); + Optional<Commit> commit = req.getCommits().stream().filter(c -> e.getKey().equals(c.getHash())).findFirst(); if (commit.isEmpty()) { LOGGER.error("Could not find request commit associated with commit messages for commit hash '{}'", e.getKey()); - continue; + return; } Commit c = commit.get(); // if there are errors, update validation messages - if (e.getValue().getErrors().size() > 0 || (base.getErrors() != null && base.getErrors().size() > 0)) { + if (!e.getValue().getErrors().isEmpty() || (base.getErrors() != null && !base.getErrors().isEmpty())) { // generate new errors, looking for errors not found in current list List<CommitValidationMessage> currentErrors = base.getErrors() != null ? base.getErrors() : new ArrayList<>(); List<CommitValidationMessage> newErrors = e @@ -136,7 +197,7 @@ public class DefaultValidationService implements ValidationService { LOGGER.trace("Encountered {} errors: {}", currentErrors.size(), currentErrors); base.setErrors(currentErrors); } - } + }); String fingerprint = generateRequestHash(req); // update the base commit status and messages dao.add(new RDBMSQuery<>(wrapper, filters.get(CommitValidationStatus.class)), updatedStatuses); @@ -144,4 +205,220 @@ public class DefaultValidationService implements ValidationService { .add(new RDBMSQuery<>(wrapper, filters.get(CommitValidationStatusGrouping.class)), updatedStatuses.stream().map(s -> new CommitValidationStatusGrouping(fingerprint, s)).collect(Collectors.toList())); } + + /** + * REQUEST VALIDATION LOGIC + */ + + /** + * Process the current request, validating that the passed commit is valid. The author and committers Eclipse Account is + * retrieved, which are then used to check if the current commit is valid for the current project. + * + * @param c the commit to process + * @param response the response container + * @param filteredProjects tracked projects for the current request + */ + private void processCommit(Commit c, ValidationResponse response, List<Project> filteredProjects) { + // retrieve the author + committer for the current request + GitUser author = c.getAuthor(); + GitUser committer = c.getCommitter(); + + response.addMessage(c.getHash(), String.format("Reviewing commit: %1$s", c.getHash())); + response.addMessage(c.getHash(), String.format("Authored by: %1$s <%2$s>", author.getName(), author.getMail())); + + // skip processing if a merge commit + if (c.getParents().size() > 1) { + response + .addMessage(c.getHash(), + String.format("Commit '%1$s' has multiple parents, merge commit detected, passing", c.getHash())); + return; + } + + // retrieve the eclipse account for the author + EclipseUser eclipseAuthor = getIdentifiedUser(author); + // if the user is a bot, generate a stubbed user + if (isAllowedUser(author.getMail()) || users.userIsABot(author.getMail(), filteredProjects)) { + response + .addMessage(c.getHash(), + String.format("Automated user '%1$s' detected for author of commit %2$s", author.getMail(), c.getHash())); + eclipseAuthor = EclipseUser.createBotStub(author); + } else if (eclipseAuthor == null) { + response + .addMessage(c.getHash(), + String + .format("Could not find an Eclipse user with mail '%1$s' for author of commit %2$s", author.getMail(), + c.getHash())); + response.addError(c.getHash(), "Author must have an Eclipse Account", APIStatusCode.ERROR_AUTHOR); + return; + } + + // retrieve the eclipse account for the committer + EclipseUser eclipseCommitter = getIdentifiedUser(committer); + // check if whitelisted or bot + if (isAllowedUser(committer.getMail()) || users.userIsABot(committer.getMail(), filteredProjects)) { + response + .addMessage(c.getHash(), + String.format("Automated user '%1$s' detected for committer of commit %2$s", committer.getMail(), c.getHash())); + eclipseCommitter = EclipseUser.createBotStub(committer); + } else if (eclipseCommitter == null) { + response + .addMessage(c.getHash(), + String + .format("Could not find an Eclipse user with mail '%1$s' for committer of commit %2$s", + committer.getMail(), c.getHash())); + response.addError(c.getHash(), "Committing user must have an Eclipse Account", APIStatusCode.ERROR_COMMITTER); + return; + } + // validate author access to the current repo + validateUserAccess(response, c, eclipseAuthor, filteredProjects, APIStatusCode.ERROR_AUTHOR); + + // check committer general access + boolean isCommittingUserCommitter = isCommitter(response, eclipseCommitter, c.getHash(), filteredProjects); + validateUserAccessPartial(response, c, eclipseCommitter, isCommittingUserCommitter, APIStatusCode.ERROR_COMMITTER); + } + + /** + * Validates author access for the current commit. If there are errors, they are recorded in the response for the + * current request to be returned once all validation checks are completed. + * + * @param r the current response object for the request + * @param c the commit that is being validated + * @param eclipseUser the user to validate on a branch + * @param filteredProjects tracked projects for the current request + * @param errorCode the error code to display if the user does not have access + */ + private void validateUserAccess(ValidationResponse r, Commit c, EclipseUser eclipseUser, List<Project> filteredProjects, + APIStatusCode errorCode) { + // call isCommitter inline and pass to partial call + validateUserAccessPartial(r, c, eclipseUser, isCommitter(r, eclipseUser, c.getHash(), filteredProjects), errorCode); + } + + /** + * Allows for isCommitter to be called external to this method. This was extracted to ensure that isCommitter isn't + * called twice for the same user when checking committer proxy push rules and committer general access. + * + * @param r the current response object for the request + * @param c the commit that is being validated + * @param eclipseUser the user to validate on a branch + * @param isCommitter the results of the isCommitter call from this class. + * @param errorCode the error code to display if the user does not have access + */ + private void validateUserAccessPartial(ValidationResponse r, Commit c, EclipseUser eclipseUser, boolean isCommitter, + APIStatusCode errorCode) { + String userType = "author"; + if (APIStatusCode.ERROR_COMMITTER.equals(errorCode)) { + userType = "committer"; + } + if (isCommitter) { + r + .addMessage(c.getHash(), + String.format("Eclipse user '%s'(%s) is a committer on the project.", eclipseUser.getName(), userType)); + } else { + r + .addMessage(c.getHash(), + String.format("Eclipse user '%s'(%s) is not a committer on the project.", eclipseUser.getName(), userType)); + // check if the author is signed off if not a committer + if (eclipseUser.getECA().getSigned()) { + r + .addMessage(c.getHash(), + String + .format("Eclipse user '%s'(%s) has a current Eclipse Contributor Agreement (ECA) on file.", + eclipseUser.getName(), userType)); + } else { + r + .addMessage(c.getHash(), String + .format("Eclipse user '%s'(%s) does not have a current Eclipse Contributor Agreement (ECA) on file.\n" + + "If there are multiple commits, please ensure that each author has a ECA.", eclipseUser.getName(), + userType)); + r + .addError(c.getHash(), + String + .format("An Eclipse Contributor Agreement is required for Eclipse user '%s'(%s).", + eclipseUser.getName(), userType), + errorCode); + } + } + } + + /** + * Checks whether the given user is a committer on the project. If they are and the project is also a specification for + * a working group, an additional access check is made against the user. + * + * <p> + * Additionally, a check is made to see if the user is a registered bot user for the given project. If they match for + * the given project, they are granted committer-like access to the repository. + * + * @param r the current response object for the request + * @param user the user to validate on a branch + * @param hash the hash of the commit that is being validated + * @param filteredProjects tracked projects for the current request + * @return true if user is considered a committer, false otherwise. + */ + private boolean isCommitter(ValidationResponse r, EclipseUser user, String hash, List<Project> filteredProjects) { + // iterate over filtered projects + for (Project p : filteredProjects) { + LOGGER.debug("Checking project '{}' for user '{}'", p.getName(), user.getName()); + // check if any of the committers usernames match the current user + if (p.getCommitters().stream().anyMatch(u -> u.getUsername().equals(user.getName()))) { + // check if the current project is a committer project, and if the user can + // commit to specs + if (p.getSpecWorkingGroup() != null && !user.getECA().getCanContributeSpecProject()) { + // set error + update response status + r + .addError(hash, String + .format("Project is a specification for the working group '%1$s', but user does not have permission to modify a specification project", + p.getSpecWorkingGroup()), + APIStatusCode.ERROR_SPEC_PROJECT); + return false; + } else { + LOGGER.debug("User '{}' was found to be a committer on current project repo '{}'", user.getMail(), p.getName()); + return true; + } + } + } + // check if user is a bot, either through early detection or through on-demand + // check + if ((user.getIsBot() != null && user.getIsBot()) || users.userIsABot(user.getMail(), filteredProjects)) { + LOGGER.debug("User '{} <{}>' was found to be a bot", user.getName(), user.getMail()); + return true; + } + return false; + } + + private boolean isAllowedUser(String mail) { + return StringUtils.isNotBlank(mail) && allowListUsers.indexOf(mail) != -1; + } + + /** + * Retrieves an Eclipse Account user object given the Git users email address (at minimum). This is facilitated using + * the Eclipse Foundation accounts API, along short lived in-memory caching for performance and some protection against + * duplicate requests. + * + * @param user the user to retrieve Eclipse Account information for + * @return the Eclipse Account user information if found, or null if there was an error or no user exists. + */ + private EclipseUser getIdentifiedUser(GitUser user) { + // don't process an empty email as it will never have a match + if (StringUtils.isBlank(user.getMail())) { + LOGGER.debug("Cannot get identified user if user is empty, returning null"); + return null; + } + // get the Eclipse account for the user + try { + // use cache to avoid asking for the same user repeatedly on repeated requests + EclipseUser foundUser = users.getUser(user.getMail()); + if (foundUser == null) { + LOGGER.warn("No users found for mail '{}'", user.getMail()); + } + return foundUser; + } catch (WebApplicationException e) { + Response r = e.getResponse(); + if (r != null && r.getStatus() == 404) { + LOGGER.error("No users found for mail '{}'", user.getMail()); + } else { + LOGGER.error("Error while checking for user", e); + } + } + return null; + } } diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/PaginationProjectsService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/PaginationProjectsService.java index 513f5f2d6fc2584b41abcdf4dc4db3ed493842ee..a73f02afe46bd6127893e98474eca78543ff1084 100644 --- a/src/main/java/org/eclipsefoundation/git/eca/service/impl/PaginationProjectsService.java +++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/PaginationProjectsService.java @@ -12,6 +12,8 @@ **********************************************************************/ package org.eclipsefoundation.git.eca.service.impl; +import java.net.URI; +import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -25,9 +27,14 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.context.ManagedExecutor; import org.eclipse.microprofile.rest.client.inject.RestClient; import org.eclipsefoundation.core.service.APIMiddleware; +import org.eclipsefoundation.core.service.CachingService; import org.eclipsefoundation.git.eca.api.ProjectsAPI; import org.eclipsefoundation.git.eca.model.Project; +import org.eclipsefoundation.git.eca.model.ValidationRequest; +import org.eclipsefoundation.git.eca.namespace.ProviderType; +import org.eclipsefoundation.git.eca.service.InterestGroupService; import org.eclipsefoundation.git.eca.service.ProjectsService; +import org.jboss.resteasy.specimpl.MultivaluedMapImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,10 +47,8 @@ import com.google.common.util.concurrent.ListenableFutureTask; import io.quarkus.runtime.Startup; /** - * Projects service implementation that handles pagination of data manually, as - * well as makes use of - * a loading cache to have data be always available with as little latency to - * the user as possible. + * Projects service implementation that handles pagination of data manually, as well as makes use of a loading cache to + * have data be always available with as little latency to the user as possible. * * @author Martin Lowe * @author Zachary Sabourin @@ -53,74 +58,69 @@ import io.quarkus.runtime.Startup; public class PaginationProjectsService implements ProjectsService { private static final Logger LOGGER = LoggerFactory.getLogger(PaginationProjectsService.class); - @Inject - ManagedExecutor exec; - @ConfigProperty(name = "cache.pagination.refresh-frequency-seconds", defaultValue = "3600") long refreshAfterWrite; + @Inject + @RestClient + ProjectsAPI projects; + @Inject + InterestGroupService ig; + @Inject + CachingService cache; @Inject APIMiddleware middleware; @Inject - @RestClient - ProjectsAPI projects; + ManagedExecutor exec; // this class has a separate cache as this data is long to load and should be // always available. LoadingCache<String, List<Project>> internalCache; /** - * Initializes the internal loader cache and pre-populates the data with the one - * available key. If - * more than one key is used, eviction of previous results will happen and - * create degraded - * performance. + * Initializes the internal loader cache and pre-populates the data with the one available key. If more than one key is + * used, eviction of previous results will happen and create degraded performance. */ @PostConstruct public void init() { // set up the internal cache - this.internalCache = CacheBuilder.newBuilder() + this.internalCache = CacheBuilder + .newBuilder() .maximumSize(1) .refreshAfterWrite(refreshAfterWrite, TimeUnit.SECONDS) - .build( - new CacheLoader<String, List<Project>>() { - @Override - public List<Project> load(String key) throws Exception { - return getProjectsInternal(); - } - - /** - * Implementation required for refreshAfterRewrite to be async rather than sync - * and blocking while awaiting for expensive reload to complete. - */ - @Override - public ListenableFuture<List<Project>> reload(String key, List<Project> oldValue) - throws Exception { - ListenableFutureTask<List<Project>> task = ListenableFutureTask.create( - () -> { - LOGGER.debug("Retrieving new project data async"); - List<Project> newProjects = oldValue; - try { - newProjects = getProjectsInternal(); - } catch (Exception e) { - LOGGER.error( - "Error while reloading internal projects data, data will be stale for current cycle.", - e); - } - LOGGER.debug("Done refreshing project values"); - return newProjects; - }); - // run the task using the Quarkus managed executor - exec.execute(task); - return task; + .build(new CacheLoader<String, List<Project>>() { + @Override + public List<Project> load(String key) throws Exception { + return getProjectsInternal(); + } + + /** + * Implementation required for refreshAfterRewrite to be async rather than sync and blocking while awaiting for + * expensive reload to complete. + */ + @Override + public ListenableFuture<List<Project>> reload(String key, List<Project> oldValue) throws Exception { + ListenableFutureTask<List<Project>> task = ListenableFutureTask.create(() -> { + LOGGER.debug("Retrieving new project data async"); + List<Project> newProjects = oldValue; + try { + newProjects = getProjectsInternal(); + } catch (Exception e) { + LOGGER.error("Error while reloading internal projects data, data will be stale for current cycle.", e); } + LOGGER.debug("Done refreshing project values"); + return newProjects; }); + // run the task using the Quarkus managed executor + exec.execute(task); + return task; + } + }); // pre-cache the projects to reduce load time for other users LOGGER.debug("Starting pre-cache of projects"); if (getProjects() == null) { - LOGGER.warn( - "Unable to populate pre-cache for Eclipse projects. Calls may experience degraded performance."); + LOGGER.warn("Unable to populate pre-cache for Eclipse projects. Calls may experience degraded performance."); } LOGGER.debug("Completed pre-cache of projects assets"); } @@ -134,6 +134,59 @@ public class PaginationProjectsService implements ProjectsService { } } + @Override + public List<Project> retrieveProjectsForRequest(ValidationRequest req) { + String repoUrl = req.getRepoUrl().getPath(); + if (repoUrl == null) { + LOGGER.warn("Can not match null repo URL to projects"); + return Collections.emptyList(); + } + return retrieveProjectsForRepoURL(repoUrl, req.getProvider()); + } + + @Override + public List<Project> retrieveProjectsForRepoURL(String repoUrl, ProviderType provider) { + if (repoUrl == null) { + LOGGER.warn("Can not match null repo URL to projects"); + return Collections.emptyList(); + } + // check for all projects that make use of the given repo + List<Project> availableProjects = getProjects(); + availableProjects + .addAll(cache + .get("all", new MultivaluedMapImpl<>(), Project.class, () -> ig.adaptInterestGroups(ig.getInterestGroups())) + .orElse(Collections.emptyList())); + if (availableProjects.isEmpty()) { + LOGGER.warn("Could not find any projects to match against"); + return Collections.emptyList(); + } + LOGGER.debug("Checking projects for repos that end with: {}", repoUrl); + + // filter the projects based on the repo URL. At least one repo in project must + // match the repo URL to be valid + switch (provider) { + case GITLAB: + String projectNamespace = URI.create(repoUrl).getPath().substring(1).toLowerCase(); + return availableProjects + .stream() + .filter(p -> projectNamespace.startsWith(p.getGitlab().getProjectGroup() + "/") + && p.getGitlab().getIgnoredSubGroups().stream().noneMatch(sg -> projectNamespace.startsWith(sg + "/"))) + .collect(Collectors.toList()); + case GITHUB: + return availableProjects + .stream() + .filter(p -> p.getGithubRepos().stream().anyMatch(re -> re.getUrl() != null && re.getUrl().endsWith(repoUrl))) + .collect(Collectors.toList()); + case GERRIT: + return availableProjects + .stream() + .filter(p -> p.getGerritRepos().stream().anyMatch(re -> re.getUrl() != null && re.getUrl().endsWith(repoUrl))) + .collect(Collectors.toList()); + default: + return Collections.emptyList(); + } + } + /** * Logic for retrieving projects from API. * @@ -141,17 +194,13 @@ public class PaginationProjectsService implements ProjectsService { */ private List<Project> getProjectsInternal() { - return middleware.getAll(params -> projects.getProjects(params), Project.class) - .stream() - .map(proj -> { - proj.getGerritRepos() - .forEach(repo -> { - if (repo.getUrl().endsWith(".git")) { - repo.setUrl(repo.getUrl().substring(0, repo.getUrl().length() - 4)); - } - }); - return proj; - }) - .collect(Collectors.toList()); + return middleware.getAll(params -> projects.getProjects(params), Project.class).stream().map(proj -> { + proj.getGerritRepos().forEach(repo -> { + if (repo.getUrl().endsWith(".git")) { + repo.setUrl(repo.getUrl().substring(0, repo.getUrl().length() - 4)); + } + }); + return proj; + }).collect(Collectors.toList()); } }