diff --git a/src/main/java/org/dependencytrack/model/Project.java b/src/main/java/org/dependencytrack/model/Project.java index 791afdaefc..68e9a84845 100644 --- a/src/main/java/org/dependencytrack/model/Project.java +++ b/src/main/java/org/dependencytrack/model/Project.java @@ -32,7 +32,6 @@ import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.PackageURL; import org.dependencytrack.resources.v1.serializers.CustomPackageURLSerializer; - import javax.jdo.annotations.Column; import javax.jdo.annotations.Element; import javax.jdo.annotations.Extension; @@ -45,8 +44,8 @@ import javax.jdo.annotations.PersistenceCapable; import javax.jdo.annotations.Persistent; import javax.jdo.annotations.PrimaryKey; -import javax.jdo.annotations.Unique; import javax.jdo.annotations.Serialized; +import javax.jdo.annotations.Unique; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; @@ -260,6 +259,8 @@ public enum FetchGroup { private transient ProjectMetrics metrics; + private transient List versions; + @JsonIgnore private transient List dependencyGraph; @@ -460,6 +461,14 @@ public void setMetrics(ProjectMetrics metrics) { this.metrics = metrics; } + public List getVersions() { + return versions; + } + + public void setVersions(List versions) { + this.versions = versions; + } + public List getAccessTeams() { return accessTeams; } @@ -499,13 +508,13 @@ public String toString() { return sb.toString(); } } - + private final static class BooleanDefaultTrueSerializer extends JsonSerializer { @Override public void serialize(Boolean value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeBoolean(value != null ? value : true); } - + } } diff --git a/src/main/java/org/dependencytrack/model/ProjectVersion.java b/src/main/java/org/dependencytrack/model/ProjectVersion.java new file mode 100644 index 0000000000..71a80cba38 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/ProjectVersion.java @@ -0,0 +1,63 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) Steve Springett. All Rights Reserved. + */ +package org.dependencytrack.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.io.Serializable; +import java.util.UUID; + +/** + * Value object holding UUID and version for a project + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ProjectVersion implements Serializable { + + private static final long serialVersionUID = 1L; + + private UUID uuid; + + private String version; + + public ProjectVersion() { + this.uuid = null; + this.version = null; + } + + public ProjectVersion(UUID uuid, String version) { + this.uuid = uuid; + this.version = version; + + } + + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + public UUID getUuid() { + return uuid; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getVersion() { + return version; + } +} diff --git a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java index 64486f20a1..92a2eaa29d 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java @@ -41,6 +41,7 @@ import org.dependencytrack.model.FindingAttribution; import org.dependencytrack.model.Project; import org.dependencytrack.model.ProjectProperty; +import org.dependencytrack.model.ProjectVersion; import org.dependencytrack.model.ServiceComponent; import org.dependencytrack.model.Tag; import org.dependencytrack.model.Vulnerability; @@ -48,7 +49,6 @@ import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.util.NotificationUtil; - import javax.jdo.FetchPlan; import javax.jdo.PersistenceManager; import javax.jdo.Query; @@ -85,6 +85,7 @@ final class ProjectQueryManager extends QueryManager implements IQueryManager { * Returns a list of all projects. * @return a List of Projects */ + @Override public PaginatedResult getProjects(final boolean includeMetrics, final boolean excludeInactive, final boolean onlyRoot) { final PaginatedResult result; final Query query = pm.newQuery(Project.class); @@ -131,6 +132,7 @@ public PaginatedResult getProjects(final boolean includeMetrics, final boolean e * Returns a list of all projects. * @return a List of Projects */ + @Override public PaginatedResult getProjects(final boolean includeMetrics) { return getProjects(includeMetrics, false, false); } @@ -139,6 +141,7 @@ public PaginatedResult getProjects(final boolean includeMetrics) { * Returns a list of all projects. * @return a List of Projects */ + @Override public PaginatedResult getProjects() { return getProjects(false); } @@ -148,6 +151,7 @@ public PaginatedResult getProjects() { * This method if designed NOT to provide paginated results. * @return a List of Projects */ + @Override public List getAllProjects() { return getAllProjects(false); } @@ -157,6 +161,7 @@ public List getAllProjects() { * This method if designed NOT to provide paginated results. * @return a List of Projects */ + @Override public List getAllProjects(boolean excludeInactive) { final Query query = pm.newQuery(Project.class); if (excludeInactive) { @@ -171,6 +176,7 @@ public List getAllProjects(boolean excludeInactive) { * @param name the name of the Projects (required) * @return a List of Project objects */ + @Override public PaginatedResult getProjects(final String name, final boolean excludeInactive, final boolean onlyRoot) { final Query query = pm.newQuery(Project.class); if (orderBy == null) { @@ -193,12 +199,31 @@ public PaginatedResult getProjects(final String name, final boolean excludeInact return execute(query, params); } + /** + * Returns a project by its uuid. + * @param uuid the uuid of the Project (required) + * @return a Project object, or null if not found + */ + @Override + public Project getProject(final String uuid) { + final Project project = getObjectByUuid(Project.class, uuid, Project.FetchGroup.ALL.name()); + if (project != null) { + // set Metrics to minimize the number of round trips a client needs to make + project.setMetrics(getMostRecentProjectMetrics(project)); + // set ProjectVersions to minimize the number of round trips a client needs to make + project.setVersions(getProjectVersions(project)); + } + return project; + } + + /** * Returns a project by its name and version. * @param name the name of the Project (required) * @param version the version of the Project (or null) * @return a Project object, or null if not found */ + @Override public Project getProject(final String name, final String version) { final Query query = pm.newQuery(Project.class); @@ -212,7 +237,14 @@ public Project getProject(final String name, final String version) { preprocessACLs(query, queryFilter, params, false); query.setFilter(queryFilter); query.setRange(0, 1); - return singleResult(query.executeWithMap(params)); + final Project project = singleResult(query.executeWithMap(params)); + if (project != null) { + // set Metrics to prevent extra round trip + project.setMetrics(getMostRecentProjectMetrics(project)); + // set ProjectVersions to prevent extra round trip + project.setVersions(getProjectVersions(project)); + } + return project; } /** @@ -220,6 +252,7 @@ public Project getProject(final String name, final String version) { * @param team the team the has access to Projects * @return a List of Project objects */ + @Override public PaginatedResult getProjects(final Team team, final boolean excludeInactive, final boolean bypass, final boolean onlyRoot) { final Query query = pm.newQuery(Project.class); if (orderBy == null) { @@ -247,6 +280,7 @@ public PaginatedResult getProjects(final Team team, final boolean excludeInactiv * @param tag the tag associated with the Project * @return a List of Projects that contain the tag */ + @Override public PaginatedResult getProjects(final Tag tag, final boolean includeMetrics, final boolean excludeInactive, final boolean onlyRoot) { final PaginatedResult result; final Query query = pm.newQuery(Project.class); @@ -288,6 +322,7 @@ public PaginatedResult getProjects(final Tag tag, final boolean includeMetrics, * @param classifier the classifier of the Project * @return a List of Projects of the specified classifier */ + @Override public PaginatedResult getProjects(final Classifier classifier, final boolean includeMetrics, final boolean excludeInactive, final boolean onlyRoot) { final PaginatedResult result; final Query query = pm.newQuery(Project.class); @@ -324,6 +359,7 @@ public PaginatedResult getProjects(final Classifier classifier, final boolean in * @param tag the tag associated with the Project * @return a List of Projects that contain the tag */ + @Override public PaginatedResult getProjects(final Tag tag) { return getProjects(tag, false, false, false); } @@ -362,6 +398,7 @@ private synchronized List resolveTags(final List tags) { * @param name the name of the Tag * @return a Tag object */ + @Override public Tag getTagByName(final String name) { final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name)); final Query query = pm.newQuery(Tag.class, "name == :name"); @@ -374,6 +411,7 @@ public Tag getTagByName(final String name) { * @param name the name of the Tag to create * @return the created Tag object */ + @Override public Tag createTag(final String name) { final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name)); final Tag resolvedTag = getTagByName(loweredTrimmedTag); @@ -415,6 +453,7 @@ private List createTags(final List names) { * @param commitIndex specifies if the search index should be committed (an expensive operation) * @return the created Project */ + @Override public Project createProject(String name, String description, String version, List tags, Project parent, PackageURL purl, boolean active, boolean commitIndex) { final Project project = new Project(); project.setName(name); @@ -453,6 +492,7 @@ public Project createProject(String name, String description, String version, Li * @param commitIndex specifies if the search index should be committed (an expensive operation) * @return the created Project */ + @Override public Project createProject(final Project project, List tags, boolean commitIndex) { if (project.getParent() != null && !Boolean.TRUE.equals(project.getParent().isActive())){ throw new IllegalArgumentException("An inactive Parent cannot be selected as parent"); @@ -478,6 +518,7 @@ public Project createProject(final Project project, List tags, boolean comm * @param commitIndex specifies if the search index should be committed (an expensive operation) * @return the updated Project */ + @Override public Project updateProject(UUID uuid, String name, String description, String version, List tags, PackageURL purl, boolean active, boolean commitIndex) { final Project project = getObjectByUuid(Project.class, uuid); project.setName(name); @@ -505,6 +546,7 @@ public Project updateProject(UUID uuid, String name, String description, String * @param commitIndex specifies if the search index should be committed (an expensive operation) * @return the updated Project */ + @Override public Project updateProject(Project transientProject, boolean commitIndex) { final Project project = getObjectByUuid(Project.class, transientProject.getUuid()); project.setAuthor(transientProject.getAuthor()); @@ -550,6 +592,7 @@ public Project updateProject(Project transientProject, boolean commitIndex) { return result; } + @Override public Project clone(UUID from, String newVersion, boolean includeTags, boolean includeProperties, boolean includeComponents, boolean includeServices, boolean includeAuditHistory, boolean includeACL) { @@ -661,6 +704,7 @@ public Project clone(UUID from, String newVersion, boolean includeTags, boolean * @param project the Project to delete * @param commitIndex specifies if the search index should be committed (an expensive operation) */ + @Override public void recursivelyDelete(final Project project, final boolean commitIndex) { if (project.getChildren() != null) { for (final Project child: project.getChildren()) { @@ -702,6 +746,7 @@ public void recursivelyDelete(final Project project, final boolean commitIndex) * @param description a description of the property * @return the created ProjectProperty object */ + @Override public ProjectProperty createProjectProperty(final Project project, final String groupName, final String propertyName, final String propertyValue, final ProjectProperty.PropertyType propertyType, final String description) { @@ -722,6 +767,7 @@ public ProjectProperty createProjectProperty(final Project project, final String * @param propertyName the name of the property * @return a ProjectProperty object */ + @Override public ProjectProperty getProjectProperty(final Project project, final String groupName, final String propertyName) { final Query query = this.pm.newQuery(ProjectProperty.class, "project == :project && groupName == :groupName && propertyName == :propertyName"); query.setRange(0, 1); @@ -733,6 +779,7 @@ public ProjectProperty getProjectProperty(final Project project, final String gr * @param project the project the property belongs to * @return a List ProjectProperty objects */ + @Override @SuppressWarnings("unchecked") public List getProjectProperties(final Project project) { final Query query = this.pm.newQuery(ProjectProperty.class, "project == :project"); @@ -772,6 +819,7 @@ public void bind(Project project, List tags) { * @param bomFormat the format and version of the bom format * @return the updated Project */ + @Override public Project updateLastBomImport(Project p, Date date, String bomFormat) { final Project project = getObjectById(Project.class, p.getId()); project.setLastBomImport(date); @@ -779,10 +827,10 @@ public Project updateLastBomImport(Project p, Date date, String bomFormat) { return persist(project); } + @Override public boolean hasAccess(final Principal principal, final Project project) { if (isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED)) { - if (principal instanceof UserPrincipal) { - final UserPrincipal userPrincipal = (UserPrincipal) principal; + if (principal instanceof final UserPrincipal userPrincipal) { if (super.hasAccessManagementPermission(userPrincipal)) { return true; } @@ -795,8 +843,7 @@ public boolean hasAccess(final Principal principal, final Project project) { } } } - } else if (principal instanceof ApiKey ){ - final ApiKey apiKey = (ApiKey) principal; + } else if (principal instanceof final ApiKey apiKey ){ if (super.hasAccessManagementPermission(apiKey)) { return true; } @@ -825,8 +872,7 @@ public boolean hasAccess(final Principal principal, final Project project) { private void preprocessACLs(final Query query, final String inputFilter, final Map params, final boolean bypass) { if (super.principal != null && isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) && !bypass) { final List teams; - if (super.principal instanceof UserPrincipal) { - final UserPrincipal userPrincipal = ((UserPrincipal) super.principal); + if (super.principal instanceof final UserPrincipal userPrincipal) { teams = userPrincipal.getTeams(); if (super.hasAccessManagementPermission(userPrincipal)) { query.setFilter(inputFilter); @@ -870,9 +916,9 @@ private void preprocessACLs(final Query query, final String inputFilter * @param principal * @return True if ACL was updated */ + @Override public boolean updateNewProjectACL(Project project, Principal principal) { - if (isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) && principal instanceof ApiKey) { - ApiKey apiKey = (ApiKey) principal; + if (isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) && principal instanceof ApiKey apiKey) { final var apiTeam = apiKey.getTeams().stream().findFirst(); if (apiTeam.isPresent()) { LOGGER.debug("adding Team to ACL of newly created project"); @@ -887,6 +933,7 @@ public boolean updateNewProjectACL(Project project, Principal principal) { return false; } + @Override public boolean hasAccessManagementPermission(final UserPrincipal userPrincipal) { for (Permission permission: getEffectivePermissions(userPrincipal)) { if (Permissions.ACCESS_MANAGEMENT.name().equals(permission.getName())) { @@ -896,11 +943,13 @@ public boolean hasAccessManagementPermission(final UserPrincipal userPrincipal) return false; } + @Override public boolean hasAccessManagementPermission(final ApiKey apiKey) { return hasPermission(apiKey, Permissions.ACCESS_MANAGEMENT.name()); } + @Override public PaginatedResult getChildrenProjects(final UUID uuid, final boolean includeMetrics, final boolean excludeInactive) { final PaginatedResult result; final Query query = pm.newQuery(Project.class); @@ -940,6 +989,7 @@ public PaginatedResult getChildrenProjects(final UUID uuid, final boolean includ return result; } + @Override public PaginatedResult getChildrenProjects(final Classifier classifier, final UUID uuid, final boolean includeMetrics, final boolean excludeInactive) { final PaginatedResult result; final Query query = pm.newQuery(Project.class); @@ -968,6 +1018,7 @@ public PaginatedResult getChildrenProjects(final Classifier classifier, final UU return result; } + @Override public PaginatedResult getChildrenProjects(final Tag tag, final UUID uuid, final boolean includeMetrics, final boolean excludeInactive) { final PaginatedResult result; final Query query = pm.newQuery(Project.class); @@ -1000,6 +1051,7 @@ public PaginatedResult getChildrenProjects(final Tag tag, final UUID uuid, final return result; } + @Override public PaginatedResult getProjectsWithoutDescendantsOf(final boolean exludeInactive, final Project project){ final PaginatedResult result; final Query query = pm.newQuery(Project.class); @@ -1034,6 +1086,7 @@ public PaginatedResult getProjectsWithoutDescendantsOf(final boolean exludeInact return result; } + @Override public PaginatedResult getProjectsWithoutDescendantsOf(final String name, final boolean excludeInactive, Project project){ final PaginatedResult result; final Query query = pm.newQuery(Project.class); @@ -1095,4 +1148,12 @@ private static boolean hasActiveChild(Project project) { } return hasActiveChild; } + + private List getProjectVersions(Project project) { + final Query query = pm.newQuery(Project.class); + query.setFilter("name == :name"); + query.setParameters(project.getName()); + query.setResult("uuid, version"); + return query.executeResultList(ProjectVersion.class); + } } diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index da42e71319..1f1582e348 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -76,7 +76,6 @@ import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.publisher.Publisher; import org.dependencytrack.tasks.scanners.AnalyzerIdentity; - import javax.jdo.FetchPlan; import javax.jdo.PersistenceManager; import javax.jdo.Query; @@ -359,6 +358,10 @@ public PaginatedResult getProjects(final String name, final boolean excludeInact return getProjectQueryManager().getProjects(name, excludeInactive, onlyRoot); } + public Project getProject(final String uuid) { + return getProjectQueryManager().getProject(uuid); + } + public Project getProject(final String name, final String version) { return getProjectQueryManager().getProject(name, version); } @@ -1361,7 +1364,7 @@ public void recursivelyDeleteTeam(Team team) { pm.currentTransaction().begin(); pm.deletePersistentAll(team.getApiKeys()); String aclDeleteQuery = """ - DELETE FROM \"PROJECT_ACCESS_TEAMS\" WHERE \"PROJECT_ACCESS_TEAMS\".\"TEAM_ID\" = ? + DELETE FROM \"PROJECT_ACCESS_TEAMS\" WHERE \"PROJECT_ACCESS_TEAMS\".\"TEAM_ID\" = ? """; final Query query = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, aclDeleteQuery); query.executeWithArray(team.getId()); diff --git a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java index 58a8ba3a98..f7c85b3f06 100644 --- a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java @@ -39,14 +39,12 @@ import org.dependencytrack.model.Tag; import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.resources.v1.vo.CloneProjectRequest; - import java.security.Principal; import java.util.Collection; import java.util.List; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; - import javax.jdo.FetchGroup; import javax.validation.Validator; import javax.ws.rs.Consumes; @@ -115,7 +113,7 @@ public Response getProject( @ApiParam(value = "The UUID of the project to retrieve", required = true) @PathParam("uuid") String uuid) { try (QueryManager qm = new QueryManager()) { - final Project project = qm.getObjectByUuid(Project.class, uuid, Project.FetchGroup.ALL.name()); + final Project project = qm.getProject(uuid); if (project != null) { if (qm.hasAccess(super.getPrincipal(), project)) { return Response.ok(project).build(); diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index 81a4e92550..89a0334250 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -18,8 +18,8 @@ */ package org.dependencytrack.resources.v1; +import static org.assertj.core.api.Assertions.assertThat; import alpine.common.util.UuidUtil; -import alpine.notification.Notification; import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFilter; import org.cyclonedx.model.ExternalReference.Type; @@ -35,7 +35,11 @@ import org.glassfish.jersey.test.ServletDeploymentContext; import org.junit.Assert; import org.junit.Test; - +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.json.Json; import javax.json.JsonArray; import javax.json.JsonObject; @@ -43,14 +47,6 @@ import javax.ws.rs.client.Entity; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.assertj.core.api.Assertions.assertThat; public class ProjectResourceTest extends ResourceTest { @@ -63,8 +59,6 @@ protected DeploymentContext configureDeployment() { .build(); } - private static final ConcurrentLinkedQueue NOTIFICATIONS = new ConcurrentLinkedQueue<>(); - @Test public void getProjectsDefaultRequestTest() { for (int i=0; i<1000; i++) { @@ -172,6 +166,29 @@ public void getProjectsByNameActiveOnlyRequestTest() { Assert.assertEquals(100, json.size()); } + @Test + public void getProjectLookupTest() { + for (int i=0; i<500; i++) { + qm.createProject("Acme Example", null, String.valueOf(i), null, null, null, false, false); + } + Response response = target(V1_PROJECT+"/lookup") + .queryParam("name", "Acme Example") + .queryParam("version", "10") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Acme Example", json.getString("name")); + Assert.assertEquals("10", json.getString("version")); + Assert.assertEquals(500, json.getJsonArray("versions").size()); + Assert.assertNotNull(json.getJsonArray("versions").getJsonObject(100).getString("uuid")); + Assert.assertNotEquals("", json.getJsonArray("versions").getJsonObject(100).getString("uuid")); + Assert.assertEquals("100", json.getJsonArray("versions").getJsonObject(100).getString("version")); + } + @Test public void getProjectsAscOrderedRequestTest() { qm.createProject("ABC", null, "1.0", null, null, null, true, false); @@ -218,6 +235,9 @@ public void getProjectByUuidTest() { JsonObject json = parseJsonObject(response); Assert.assertNotNull(json); Assert.assertEquals("ABC", json.getString("name")); + Assert.assertEquals(1, json.getJsonArray("versions").size()); + Assert.assertEquals(project.getUuid().toString(), json.getJsonArray("versions").getJsonObject(0).getJsonString("uuid").getString()); + Assert.assertEquals("1.0", json.getJsonArray("versions").getJsonObject(0).getJsonString("version").getString()); } @Test