diff --git a/src/main/java/org/dependencytrack/model/Policy.java b/src/main/java/org/dependencytrack/model/Policy.java index 5da41f6c1..b8ab2944d 100644 --- a/src/main/java/org/dependencytrack/model/Policy.java +++ b/src/main/java/org/dependencytrack/model/Policy.java @@ -142,6 +142,10 @@ public enum ViolationState { @Column(name = "INCLUDE_CHILDREN", allowsNull = "true") // New column, must allow nulls on existing data bases) private boolean includeChildren; + @Persistent + @Column(name = "ONLY_LATEST_PROJECT_VERSION", defaultValue = "false") + private boolean onlyLatestProjectVersion = false; + public long getId() { return id; } @@ -224,4 +228,12 @@ public boolean isIncludeChildren() { public void setIncludeChildren(boolean includeChildren) { this.includeChildren = includeChildren; } + + public boolean isOnlyLatestProjectVersion() { + return onlyLatestProjectVersion; + } + + public void setOnlyLatestProjectVersion(boolean onlyLatestProjectVersion) { + this.onlyLatestProjectVersion = onlyLatestProjectVersion; + } } diff --git a/src/main/java/org/dependencytrack/model/Project.java b/src/main/java/org/dependencytrack/model/Project.java index 5e5a33765..a4ba375dd 100644 --- a/src/main/java/org/dependencytrack/model/Project.java +++ b/src/main/java/org/dependencytrack/model/Project.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; @@ -94,7 +95,8 @@ @Persistent(name = "properties"), @Persistent(name = "tags"), @Persistent(name = "accessTeams"), - @Persistent(name = "metadata") + @Persistent(name = "metadata"), + @Persistent(name = "isLatest") }), @FetchGroup(name = "METADATA", members = { @Persistent(name = "metadata") @@ -309,6 +311,11 @@ public enum FetchGroup { @Schema(accessMode = Schema.AccessMode.READ_ONLY) private ProjectMetadata metadata; + @Persistent + @Index(name = "PROJECT_IS_LATEST_IDX") + @Column(name = "IS_LATEST", defaultValue = "false") + private boolean isLatest = false; + private transient String bomRef; private transient ProjectMetrics metrics; @@ -589,6 +596,15 @@ public String setAuthor(String author){ return this.author=author; } + @JsonProperty("isLatest") + public boolean isLatest() { + return isLatest; + } + + public void setIsLatest(Boolean latest) { + isLatest = latest != null ? latest : false; + } + @Override public String toString() { if (getPurl() != null) { diff --git a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java index be3ce8463..88a941b27 100644 --- a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java @@ -176,11 +176,13 @@ public Policy getPolicy(final String name) { * @param violationState the violation state * @return the created Policy */ - public Policy createPolicy(String name, Policy.Operator operator, Policy.ViolationState violationState) { + public Policy createPolicy(String name, Policy.Operator operator, Policy.ViolationState violationState, + boolean onlyLatestProjectVersion) { final Policy policy = new Policy(); policy.setName(name); policy.setOperator(operator); policy.setViolationState(violationState); + policy.setOnlyLatestProjectVersion(onlyLatestProjectVersion); return persist(policy); } diff --git a/src/main/java/org/dependencytrack/persistence/ProjectQueryFilterBuilder.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryFilterBuilder.java index 39d6acf92..53b1f2b1a 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryFilterBuilder.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryFilterBuilder.java @@ -121,6 +121,11 @@ ProjectQueryFilterBuilder withParent(UUID uuid){ return this; } + public ProjectQueryFilterBuilder onlyLatestVersion() { + filterCriteria.add("(isLatest == true)"); + return this; + } + String buildFilter() { return String.join(" && ", this.filterCriteria); } diff --git a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java index 3aea48d49..381e7970c 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java @@ -68,6 +68,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; final class ProjectQueryManager extends QueryManager implements IQueryManager { @@ -272,6 +273,37 @@ public Project getProject(final String name, final String version) { return project; } + /** + * Returns the latest version of a project by its name. + * + * @param name the name of the Project (required) + * @return a Project object representing the latest version, or null if not found + */ + @Override + public Project getLatestProjectVersion(final String name) { + final Query query = pm.newQuery(Project.class); + + final var filterBuilder = new ProjectQueryFilterBuilder() + .withName(name) + .onlyLatestVersion(); + + final String queryFilter = filterBuilder.buildFilter(); + final Map params = filterBuilder.getParams(); + + preprocessACLs(query, queryFilter, params, false); + query.setFilter(queryFilter); + query.setRange(0, 1); + + 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; + } + /** * Returns a list of projects that are accessible by the specified team. * @@ -393,6 +425,11 @@ public PaginatedResult getProjects(final Tag tag) { return getProjects(tag, false, false, false); } + @Override + public Project createProject(String name, String description, String version, List tags, Project parent, + PackageURL purl, boolean active, boolean commitIndex) { + return createProject(name, description, version, tags, parent, purl, active, false, commitIndex); + } /** * Creates a new Project. @@ -405,35 +442,21 @@ public PaginatedResult getProjects(final Tag tag) { * @param purl an optional Package URL * @param active specified if the project is active * @param commitIndex specifies if the search index should be committed (an expensive operation) + * @param isLatest specified if the project version is latest * @return the created Project */ @Override - public Project createProject(String name, String description, String version, List tags, Project parent, PackageURL purl, boolean active, boolean commitIndex) { + public Project createProject(String name, String description, String version, List tags, Project parent, + PackageURL purl, boolean active, boolean isLatest, boolean commitIndex) { final Project project = new Project(); project.setName(name); project.setDescription(description); project.setVersion(version); - if (parent != null) { - if (!Boolean.TRUE.equals(parent.isActive())) { - throw new IllegalArgumentException("An inactive Parent cannot be selected as parent"); - } - project.setParent(parent); - } + project.setParent(parent); project.setPurl(purl); project.setActive(active); - final Project result = persist(project); - - final List resolvedTags = resolveTags(tags); - bind(project, resolvedTags); - - new KafkaEventDispatcher().dispatchNotification(new Notification() - .scope(NotificationScope.PORTFOLIO) - .group(NotificationGroup.PROJECT_CREATED) - .level(NotificationLevel.INFORMATIONAL) - .title(NotificationConstants.Title.PROJECT_CREATED) - .content(result.getName() + " was created") - .subject(pm.detachCopy(result))); - return result; + project.setIsLatest(isLatest); + return createProject(project, tags, commitIndex); } /** @@ -449,10 +472,18 @@ public Project createProject(final Project project, List tags, boolean comm if (project.getParent() != null && !Boolean.TRUE.equals(project.getParent().isActive())) { throw new IllegalArgumentException("An inactive Parent cannot be selected as parent"); } - final Project result = persist(project); - final List resolvedTags = resolveTags(tags); - bind(project, resolvedTags); - + final Project oldLatestProject = project.isLatest() ? getLatestProjectVersion(project.getName()) : null; + final Project result = callInTransaction(() -> { + // Remove isLatest flag from current latest project version, if the new project will be the latest + if(oldLatestProject != null) { + oldLatestProject.setIsLatest(false); + persist(oldLatestProject); + } + final Project newProject = persist(project); + final List resolvedTags = resolveTags(tags); + bind(project, resolvedTags); + return newProject; + }); new KafkaEventDispatcher().dispatchNotification(new Notification() .scope(NotificationScope.PORTFOLIO) .group(NotificationGroup.PROJECT_CREATED) @@ -492,6 +523,14 @@ public Project updateProject(Project transientProject, boolean commitIndex) { } project.setActive(transientProject.isActive()); + final Project oldLatestProject; + if(Boolean.TRUE.equals(transientProject.isLatest()) && Boolean.FALSE.equals(project.isLatest())) { + oldLatestProject = getLatestProjectVersion(project.getName()); + } else { + oldLatestProject = null; + } + project.setIsLatest(transientProject.isLatest()); + if (transientProject.getParent() != null && transientProject.getParent().getUuid() != null) { if (project.getUuid().equals(transientProject.getParent().getUuid())) { throw new IllegalArgumentException("A project cannot select itself as a parent"); @@ -509,10 +548,17 @@ public Project updateProject(Project transientProject, boolean commitIndex) { project.setParent(null); } - final List resolvedTags = resolveTags(transientProject.getTags()); - bind(project, resolvedTags); + final Project result = callInTransaction(() -> { + // Remove isLatest flag from current latest project version, if this project will be the latest now + if(oldLatestProject != null) { + oldLatestProject.setIsLatest(false); + persist(oldLatestProject); + } - final Project result = persist(project); + final List resolvedTags = resolveTags(transientProject.getTags()); + bind(project, resolvedTags); + return persist(project); + }); return result; } @@ -526,10 +572,11 @@ public Project clone( final boolean includeServices, final boolean includeAuditHistory, final boolean includeACL, - final boolean includePolicyViolations + final boolean includePolicyViolations, + final boolean makeCloneLatest ) { + final AtomicReference oldLatestProject = new AtomicReference<>(); final var jsonMapper = new JsonMapper(); - return callInTransaction(() -> { final Project source = getObjectByUuid(Project.class, from, Project.FetchGroup.ALL.name()); if (source == null) { @@ -543,6 +590,11 @@ public Project clone( Project was supposed to be cloned to version %s, \ but that version already exists""".formatted(newVersion)); } + if(makeCloneLatest) { + oldLatestProject.set(source.isLatest() ? source : getLatestProjectVersion(source.getName())); + } else { + oldLatestProject.set(null); + } Project project = new Project(); project.setAuthors(source.getAuthors()); project.setManufacturer(source.getManufacturer()); @@ -554,6 +606,7 @@ public Project clone( project.setVersion(newVersion); project.setClassifier(source.getClassifier()); project.setActive(source.isActive()); + project.setIsLatest(makeCloneLatest); project.setCpe(source.getCpe()); project.setPurl(source.getPurl()); project.setSwidTagId(source.getSwidTagId()); @@ -561,6 +614,11 @@ public Project clone( project.setDirectDependencies(source.getDirectDependencies()); } project.setParent(source.getParent()); + // Remove isLatest flag from current latest project version, if this project will be the latest now + if(oldLatestProject.get() != null) { + oldLatestProject.get().setIsLatest(false); + persist(oldLatestProject.get()); + } project = persist(project); if (source.getMetadata() != null) { diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 3a921fff3..875facdd9 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -557,6 +557,10 @@ public PaginatedResult getProjects(final Team team, final boolean excludeInactiv return getProjectQueryManager().getProjects(team, excludeInactive, bypass, onlyRoot); } + public Project getLatestProjectVersion(final String name) { + return getProjectQueryManager().getLatestProjectVersion(name); + } + public PaginatedResult getProjectsWithoutDescendantsOf(final boolean excludeInactive, final Project project) { return getProjectQueryManager().getProjectsWithoutDescendantsOf(excludeInactive, project); } @@ -625,6 +629,11 @@ public Project createProject(final Project project, List tags, boolean comm return getProjectQueryManager().createProject(project, tags, commitIndex); } + public Project createProject(String name, String description, String version, List tags, Project parent, + PackageURL purl, boolean active, boolean isLatest, boolean commitIndex) { + return getProjectQueryManager().createProject(name, description, version, tags, parent, purl, active, isLatest, commitIndex); + } + public Project updateProject(Project transientProject, boolean commitIndex) { return getProjectQueryManager().updateProject(transientProject, commitIndex); } @@ -635,9 +644,9 @@ public boolean updateNewProjectACL(Project transientProject, Principal principal public Project clone(UUID from, String newVersion, boolean includeTags, boolean includeProperties, boolean includeComponents, boolean includeServices, boolean includeAuditHistory, - boolean includeACL, boolean includePolicyViolations) { + boolean includeACL, boolean includePolicyViolations, boolean makeCloneLatest) { return getProjectQueryManager().clone(from, newVersion, includeTags, includeProperties, - includeComponents, includeServices, includeAuditHistory, includeACL, includePolicyViolations); + includeComponents, includeServices, includeAuditHistory, includeACL, includePolicyViolations, makeCloneLatest); } public Project updateLastBomImport(Project p, Date date, String bomFormat) { @@ -792,7 +801,11 @@ public Policy getPolicy(final String name) { } public Policy createPolicy(String name, Policy.Operator operator, Policy.ViolationState violationState) { - return getPolicyQueryManager().createPolicy(name, operator, violationState); + return this.createPolicy(name, operator, violationState, false); + } + + public Policy createPolicy(String name, Policy.Operator operator, Policy.ViolationState violationState, boolean onlyLatestProjectVersion) { + return getPolicyQueryManager().createPolicy(name, operator, violationState, onlyLatestProjectVersion); } public void removeProjectFromPolicies(final Project project) { diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index 8f92f8b58..f6eb72ac8 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -327,8 +327,19 @@ public Response uploadBom(@Parameter(required = true) BomSubmitRequest request) return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified parent project is forbidden").build(); } } - - project = qm.createProject(StringUtils.trimToNull(request.getProjectName()), null, StringUtils.trimToNull(request.getProjectVersion()), request.getProjectTags(), parent, null, true, true); + final String trimmedProjectName = StringUtils.trimToNull(request.getProjectName()); + if (request.isLatestProjectVersion()) { + final Project oldLatest = qm.getLatestProjectVersion(trimmedProjectName); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot create latest version for project with this name. Access to current latest " + + "version is forbidden!") + .build(); + } + } + project = qm.createProject(trimmedProjectName, null, + StringUtils.trimToNull(request.getProjectVersion()), request.getProjectTags(), parent, + null, true, request.isLatestProjectVersion(), true); Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); } else { @@ -391,6 +402,7 @@ public Response uploadBom( @FormDataParam("parentName") String parentName, @FormDataParam("parentVersion") String parentVersion, @FormDataParam("parentUUID") String parentUUID, + @DefaultValue("false") @FormDataParam("isLatest") boolean isLatest, @Parameter(schema = @Schema(type = "string")) @FormDataParam("bom") final List artifactParts ) { if (projectUuid != null) { // behavior in v3.0.0 @@ -422,10 +434,19 @@ public Response uploadBom( return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified parent project is forbidden").build(); } } + if (isLatest) { + final Project oldLatest = qm.getLatestProjectVersion(trimmedProjectName); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot create latest version for project with this name. Access to current latest " + + "version is forbidden!") + .build(); + } + } final List tags = (projectTags != null && !projectTags.isBlank()) ? Arrays.stream(projectTags.split(",")).map(String::trim).filter(not(String::isEmpty)).map(org.dependencytrack.model.Tag::new).toList() : null; - project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, tags, parent, null, true, true); + project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, tags, parent, null, true, isLatest, true); Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); } else { diff --git a/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java b/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java index 1a12521f2..1725665b0 100644 --- a/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java @@ -156,7 +156,7 @@ public Response createPolicy(Policy jsonPolicy) { } policy = qm.createPolicy( StringUtils.trimToNull(jsonPolicy.getName()), - operator, violationState); + operator, violationState, jsonPolicy.isOnlyLatestProjectVersion()); return Response.status(Response.Status.CREATED).entity(policy).build(); } else { return Response.status(Response.Status.CONFLICT).entity("A policy with the specified name already exists.").build(); @@ -193,6 +193,7 @@ public Response updatePolicy(Policy jsonPolicy) { policy.setOperator(jsonPolicy.getOperator()); policy.setViolationState(jsonPolicy.getViolationState()); policy.setIncludeChildren(jsonPolicy.isIncludeChildren()); + policy.setOnlyLatestProjectVersion(jsonPolicy.isOnlyLatestProjectVersion()); policy = qm.persist(policy); return Response.ok(policy).build(); } else { diff --git a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java index 4e9779a81..4603a0cde 100644 --- a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java @@ -258,6 +258,41 @@ public Response getProject( } } + @GET + @Path("/latest/{name}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns the latest version of a project by its name", + description = "

Requires permission VIEW_PORTFOLIO

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "The latest version of the specified project", + content = @Content(schema = @Schema(implementation = Project.class)) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Access to the specified project is forbidden"), + @ApiResponse(responseCode = "404", description = "The project could not be found") + }) + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + public Response getLatestProjectByName( + @Parameter(description = "The name of the project to retrieve the latest version of", required = true) + @PathParam("name") String name) { + try (QueryManager qm = new QueryManager()) { + final Project project = qm.getLatestProjectVersion(name); + if (project != null) { + if (qm.hasAccess(super.getPrincipal(), project)) { + return Response.ok(project).build(); + } else { + return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); + } + } else { + return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); + } + } + } + @GET @Path("/lookup") @Produces(MediaType.APPLICATION_JSON) @@ -378,6 +413,7 @@ public Response getProjectsByClassifier( content = @Content(schema = @Schema(implementation = Project.class)) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "The project version cannot be created as latest version because access to current latest version is forbidden."), @ApiResponse(responseCode = "409", description = """
  • An inactive Parent cannot be selected as parent, or
  • @@ -403,6 +439,15 @@ public Response createProject(Project jsonProject) { jsonProject.setClassifier(Classifier.APPLICATION); } try (final var qm = new QueryManager()) { + if(jsonProject.isLatest()) { + final Project oldLatest = qm.getLatestProjectVersion(jsonProject.getName()); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot create latest version for project with this name. Access to current latest " + + "version is forbidden!") + .build(); + } + } final Project createdProject = qm.callInTransaction(() -> { if (jsonProject.getParent() != null && jsonProject.getParent().getUuid() != null) { Project parent = qm.getObjectByUuid(Project.class, jsonProject.getParent().getUuid()); @@ -454,6 +499,8 @@ public Response createProject(Project jsonProject) { content = @Content(schema = @Schema(implementation = Project.class)) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "The project version cannot be set as latest version " + + "because access to current latest version is forbidden."), @ApiResponse(responseCode = "404", description = "The UUID of the project could not be found"), @ApiResponse(responseCode = "409", description = """
      @@ -502,6 +549,16 @@ public Response updateProject(Project jsonProject) { if (name == null) { jsonProject.setName(project.getName()); } + // if project is newly set to latest, ensure user has access to current latest version to modify it + if (jsonProject.isLatest() && !project.isLatest()) { + final Project oldLatest = qm.getLatestProjectVersion(name); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + throw new ClientErrorException(Response + .status(Response.Status.FORBIDDEN) + .entity("Cannot set this project version to latest. Access to current latest version is forbidden.") + .build()); + } + } try { return qm.updateProject(jsonProject, true); @@ -587,6 +644,17 @@ public Response patchProject( .entity("Access to the specified project is forbidden") .build()); } + // if project is newly set to latest, ensure user has access to current latest version to modify it + if (jsonProject.isLatest() && !project.isLatest()) { + final var oldName = jsonProject.getName() != null ? jsonProject.getName() : project.getName(); + final Project oldLatest = qm.getLatestProjectVersion(oldName); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + throw new ClientErrorException(Response + .status(Response.Status.FORBIDDEN) + .entity("Cannot set this project version to latest. Access to current latest version is forbidden.") + .build()); + } + } var modified = false; project = qm.detachWithGroups(project, List.of(FetchGroup.DEFAULT, Project.FetchGroup.PARENT.name())); @@ -603,6 +671,7 @@ public Response patchProject( modified |= setIfDifferent(jsonProject, project, Project::isActive, Project::setActive); modified |= setIfDifferent(jsonProject, project, Project::getManufacturer, Project::setManufacturer); modified |= setIfDifferent(jsonProject, project, Project::getSupplier, Project::setSupplier); + modified |= setIfDifferent(jsonProject, project, Project::isLatest, Project::setIsLatest); if (jsonProject.getParent() != null && jsonProject.getParent().getUuid() != null) { final Project parent = qm.getObjectByUuid(Project.class, jsonProject.getParent().getUuid()); if (parent == null) { @@ -758,6 +827,8 @@ public Response deleteProject( content = @Content(schema = @Schema(implementation = BomUploadResponse.class)) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "The project clone cannot be set to latest version " + + "because access to current latest version is forbidden."), @ApiResponse(responseCode = "404", description = "The UUID of the project could not be found") }) @PermissionRequired({Permissions.Constants.PORTFOLIO_MANAGEMENT, Permissions.Constants.PORTFOLIO_MANAGEMENT_CREATE}) @@ -788,6 +859,16 @@ public Response cloneProject(CloneProjectRequest jsonRequest) { .entity("A project with the specified name and version already exists.") .build()); } + // if project is newly set to latest, ensure user has access to current latest version to modify it + if (jsonRequest.makeCloneLatest() && !sourceProject.isLatest()) { + final Project oldLatest = qm.getLatestProjectVersion(sourceProject.getName()); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + throw new ClientErrorException(Response + .status(Response.Status.CONFLICT) + .entity("Cannot set cloned project version to latest. Access to current latest version is forbidden.") + .build()); + } + } LOGGER.info("Project " + sourceProject + " is being cloned by " + super.getPrincipal().getName()); final var event = new CloneProjectEvent(jsonRequest); diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java b/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java index ce5f218cb..0134a99df 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java @@ -73,13 +73,16 @@ public final class BomSubmitRequest { private final boolean autoCreate; + private final boolean isLatestProjectVersion; + public BomSubmitRequest(String project, String projectName, String projectVersion, List projectTags, boolean autoCreate, + boolean isLatestProjectVersion, String bom) { - this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, bom); + this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, isLatestProjectVersion, bom); } @JsonCreator @@ -91,6 +94,7 @@ public BomSubmitRequest(@JsonProperty(value = "project") String project, @JsonProperty(value = "parentUUID") String parentUUID, @JsonProperty(value = "parentName") String parentName, @JsonProperty(value = "parentVersion") String parentVersion, + @JsonProperty(value = "isLatestProjectVersion", defaultValue = "false") boolean isLatestProjectVersion, @JsonProperty(value = "bom", required = true) String bom) { this.project = project; this.projectName = projectName; @@ -100,6 +104,7 @@ public BomSubmitRequest(@JsonProperty(value = "project") String project, this.parentUUID = parentUUID; this.parentName = parentName; this.parentVersion = parentVersion; + this.isLatestProjectVersion = isLatestProjectVersion; this.bom = bom; } @@ -142,6 +147,9 @@ public boolean isAutoCreate() { return autoCreate; } + @JsonProperty("isLatestProjectVersion") + public boolean isLatestProjectVersion() { return isLatestProjectVersion; } + @Schema( description = "Base64 encoded BOM", requiredMode = Schema.RequiredMode.REQUIRED, diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/CloneProjectRequest.java b/src/main/java/org/dependencytrack/resources/v1/vo/CloneProjectRequest.java index 21af76434..c86f951c6 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/CloneProjectRequest.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/CloneProjectRequest.java @@ -61,6 +61,8 @@ public class CloneProjectRequest { private final boolean includePolicyViolations; + private final boolean makeCloneLatest; + @JsonCreator public CloneProjectRequest(@JsonProperty(value = "project", required = true) String project, @JsonProperty(value = "version", required = true) String version, @@ -71,8 +73,10 @@ public CloneProjectRequest(@JsonProperty(value = "project", required = true) Str @JsonProperty(value = "includeServices") boolean includeServices, @JsonProperty(value = "includeAuditHistory") boolean includeAuditHistory, @JsonProperty(value = "includeACL") boolean includeACL, - @JsonProperty(value = "includePolicyViolations") boolean includePolicyViolations) { - if (includeDependencies) { // For backward compatibility + @JsonProperty(value = "includePolicyViolations") boolean includePolicyViolations, + @JsonProperty(value = "makeCloneLatest", defaultValue = "false") boolean makeCloneLatest) { + + if (includeDependencies) { // For backward compatibility includeComponents = true; } this.project = project; @@ -85,6 +89,7 @@ public CloneProjectRequest(@JsonProperty(value = "project", required = true) Str this.includeAuditHistory = includeAuditHistory; this.includeACL = includeACL; this.includePolicyViolations = includePolicyViolations; + this.makeCloneLatest = makeCloneLatest; } public String getProject() { @@ -126,4 +131,8 @@ public boolean includeACL() { public boolean includePolicyViolations() { return includePolicyViolations; } + + public boolean makeCloneLatest() { + return makeCloneLatest; + } } diff --git a/src/main/java/org/dependencytrack/tasks/CloneProjectTask.java b/src/main/java/org/dependencytrack/tasks/CloneProjectTask.java index d6ee19e6a..2dbaaec7b 100644 --- a/src/main/java/org/dependencytrack/tasks/CloneProjectTask.java +++ b/src/main/java/org/dependencytrack/tasks/CloneProjectTask.java @@ -73,7 +73,8 @@ public void inform(final Event e) { request.includeServices(), request.includeAuditHistory(), request.includeACL(), - request.includePolicyViolations() + request.includePolicyViolations(), + request.makeCloneLatest() ); qm.updateWorkflowStateToComplete(workflowState); diff --git a/src/main/resources/migration/changelog-v5.6.0.xml b/src/main/resources/migration/changelog-v5.6.0.xml index fc1bc3137..fff82babe 100644 --- a/src/main/resources/migration/changelog-v5.6.0.xml +++ b/src/main/resources/migration/changelog-v5.6.0.xml @@ -107,4 +107,16 @@ onDelete="NO ACTION" onUpdate="NO ACTION" referencedColumnNames="ID" referencedTableName="TAG" validate="true"/> + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/ResourceTest.java b/src/test/java/org/dependencytrack/ResourceTest.java index 5fe3d1627..389286c3a 100644 --- a/src/test/java/org/dependencytrack/ResourceTest.java +++ b/src/test/java/org/dependencytrack/ResourceTest.java @@ -26,6 +26,7 @@ import org.apache.kafka.clients.producer.MockProducer; import org.dependencytrack.auth.Permissions; import org.dependencytrack.event.kafka.KafkaProducerInitializer; +import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.plugin.PluginManagerTestUtil; import org.junit.After; @@ -69,6 +70,7 @@ public abstract class ResourceTest { protected final String V1_POLICY = "/v1/policy"; protected final String V1_POLICY_VIOLATION = "/v1/violation"; protected final String V1_PROJECT = "/v1/project"; + protected final String V1_PROJECT_LATEST = "/v1/project/latest/"; protected final String V1_REPOSITORY = "/v1/repository"; protected final String V1_SCAN = "/v1/scan"; protected final String V1_SEARCH = "/v1/search"; @@ -155,6 +157,16 @@ public void initializeWithPermissions(Permissions... permissions) { qm.persist(team); } + protected void enablePortfolioAccessControl() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + } + protected String getPlainTextBody(Response response) { return response.readEntity(String.class); } diff --git a/src/test/java/org/dependencytrack/event/CloneProjectEventTest.java b/src/test/java/org/dependencytrack/event/CloneProjectEventTest.java index 1f46dabfe..ebf1bfe00 100644 --- a/src/test/java/org/dependencytrack/event/CloneProjectEventTest.java +++ b/src/test/java/org/dependencytrack/event/CloneProjectEventTest.java @@ -29,7 +29,9 @@ public class CloneProjectEventTest { @Test public void testEvent() { UUID uuid = UUID.randomUUID(); - CloneProjectRequest request = new CloneProjectRequest(uuid.toString(), "1.0", true, true, true, true, true, true, true, true); + CloneProjectRequest request = new CloneProjectRequest(uuid.toString(), "1.0", true, + true, true, true, true, true, + true, true, false); CloneProjectEvent event = new CloneProjectEvent(request); Assert.assertEquals(request, event.getRequest()); } diff --git a/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java b/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java index 32ab91b35..4bf52a131 100644 --- a/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java +++ b/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java @@ -219,7 +219,7 @@ public void testCloneProjectPreservesVulnerabilityAttributionDate() throws Excep vuln.setSeverity(Severity.HIGH); qm.persist(vuln); qm.addVulnerability(vuln, comp, AnalyzerIdentity.INTERNAL_ANALYZER, "Vuln1", "http://vuln.com/vuln1", new Date()); - Project clonedProject = qm.clone(project.getUuid(), "1.1.0", false, false, true, false, false, false, false); + Project clonedProject = qm.clone(project.getUuid(), "1.1.0", false, false, true, false, false, false, false, false); List findings = qm.getFindings(clonedProject); assertThat(findings.size()).isEqualTo(1); Finding finding = findings.get(0); diff --git a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java index d5f323e90..2d4b78cb3 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -856,7 +856,7 @@ public void uploadBomTest() throws Exception { Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -913,7 +913,7 @@ public void uploadNonCycloneDxBomTest() { SPDXVersion: SPDX-2.2 DataLicense: CC0-1.0 """.getBytes()); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -942,7 +942,7 @@ public void uploadInvalidCycloneDxBomTest() { ] } """.getBytes()); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -969,7 +969,7 @@ public void uploadInvalidFormatBomTest() throws Exception { Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); File file = new File(IOUtils.resourceToURL("/unit/bom-invalid.json").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -988,7 +988,7 @@ public void uploadBomInvalidProjectTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(UUID.randomUUID().toString(), null, null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(UUID.randomUUID().toString(), null, null, null, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1003,7 +1003,7 @@ public void uploadBomAutoCreateTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1020,7 +1020,7 @@ public void uploadBomAutoCreateTest() throws Exception { public void uploadBomUnauthorizedTest() throws Exception { File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1035,7 +1035,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); // Upload parent project - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Parent", "1.0", null, true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Parent", "1.0", null, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1047,7 +1047,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { String parentUUID = parent.getUuid().toString(); // Upload first child, search parent by UUID - request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, parentUUID, null, null, bomString); + request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, parentUUID, null, null, false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1063,7 +1063,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { // Upload second child, search parent by name+ver - request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Acme Parent", "1.0", bomString); + request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Acme Parent", "1.0", false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1078,7 +1078,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { Assert.assertEquals(parentUUID, child.getParent().getUuid().toString()); // Upload third child, specify parent's UUID, name, ver. Name and ver are ignored when UUID is specified. - request = new BomSubmitRequest(null, "Acme Example", "3.0", null, true, parentUUID, "Non-existent parent", "1.0", bomString); + request = new BomSubmitRequest(null, "Acme Example", "3.0", null, true, parentUUID, "Non-existent parent", "1.0", false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1098,7 +1098,7 @@ public void uploadBomInvalidParentTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, UUID.randomUUID().toString(), null, null, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, UUID.randomUUID().toString(), null, null, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1106,7 +1106,7 @@ public void uploadBomInvalidParentTest() throws Exception { String body = getPlainTextBody(response); Assert.assertEquals("The parent component could not be found.", body); - request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Non-existent parent", null, bomString); + request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Non-existent parent", null, false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1141,7 +1141,7 @@ public void uploadBomSchemaValidationTest(final Path filePath) throws Exception Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); File file = filePath.toFile(); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1333,7 +1333,7 @@ public void uploadBomAutoCreateWithTagsTest() throws Exception { tag.setName(name); return tag; }).collect(Collectors.toList()); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", tags, true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", tags, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1603,4 +1603,48 @@ public void uploadBomWithValidationTagsInvalidTest() { """.formatted(encodedBom), MediaType.APPLICATION_JSON)); assertThat(response.getStatus()).isEqualTo(200); } + + @Test + public void uploadBomAutoCreateLatestWithAclTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + enablePortfolioAccessControl(); + + final var accessLatestProject = new Project(); + accessLatestProject.setName("acme-app-a"); + accessLatestProject.setVersion("1.0.0"); + accessLatestProject.setIsLatest(true); + accessLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessLatestProject); + + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + BomSubmitRequest request = new BomSubmitRequest(null, accessLatestProject.getName(), + "1.0.1", null, true, true, bomString); + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertNotNull(json.getString("token")); + } + + @Test + public void uploadBomAutoCreateLatestWithAclNoAccessTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + enablePortfolioAccessControl(); + + final var noAccessLatestProject = new Project(); + noAccessLatestProject.setName("acme-app-a"); + noAccessLatestProject.setVersion("1.0.0"); + noAccessLatestProject.setIsLatest(true); + qm.persist(noAccessLatestProject); + + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + BomSubmitRequest request = new BomSubmitRequest(null, noAccessLatestProject.getName(), + "1.0.1", null, true, true, bomString); + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(403, response.getStatus(), 0); + } } diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index 6f57aaa49..bc7731fb5 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -90,7 +90,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.dependencytrack.assertion.Assertions.assertConditionWithTimeout; -import static org.dependencytrack.model.ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED; import static org.dependencytrack.persistence.jdbi.JdbiFactory.useJdbiHandle; import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; import static org.dependencytrack.proto.notification.v1.Group.GROUP_PROJECT_CREATED; @@ -135,15 +134,7 @@ public void getProjectsDefaultRequestTest() { @Test // https://github.com/DependencyTrack/dependency-track/issues/2583 public void getProjectsWithAclEnabledTest() { - // Enable portfolio access control. - qm.createConfigProperty( - ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - null - ); - + enablePortfolioAccessControl(); // Create project and give access to current principal's team. final Project accessProject = qm.createProject("acme-app-a", null, "1.0.0", null, null, null, true, false); accessProject.setAccessTeams(List.of(team)); @@ -264,13 +255,7 @@ public void getProjectLookupNotFoundTest() { @Test public void getProjectLookupNotPermittedTest() { - qm.createConfigProperty( - ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() - ); + enablePortfolioAccessControl(); final var project = new Project(); project.setName("acme-app"); @@ -362,13 +347,7 @@ public void getProjectsConciseTest() { @Test public void getProjectsConciseWithAclTest() { - qm.createConfigProperty( - ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() - ); + enablePortfolioAccessControl(); final var projectA = new Project(); projectA.setName("acme-app-a"); @@ -557,13 +536,7 @@ public void getProjectsConciseFilterByTagTest() { @Test public void getProjectsConciseFilterByTeamTest() { - qm.createConfigProperty( - ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() - ); + enablePortfolioAccessControl(); // Create project and give access to current principal's team. final var projectB= new Project(); projectB.setName("acme-app-b"); @@ -902,13 +875,7 @@ public void getProjectChildrenConciseTest() { @Test public void getProjectChildrenConciseWithAclTest() { - qm.createConfigProperty( - ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() - ); + enablePortfolioAccessControl(); final var parentProject = new Project(); parentProject.setName("acme-app"); @@ -1351,12 +1318,14 @@ public void getProjectByUuidTest() { "name": "acme-app-child", "version": "1.0.0", "uuid": "${json-unit.matches:childUuid}", - "active": true + "active": true, + "isLatest": false } ], "properties": [], "tags": [], "active": true, + "isLatest": false, "versions": [ { "uuid": "${json-unit.matches:projectUuid}", @@ -1370,13 +1339,7 @@ public void getProjectByUuidTest() { @Test public void getProjectByUuidNotPermittedTest() { - qm.createConfigProperty( - ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() - ); + enablePortfolioAccessControl(); final var project = new Project(); project.setName("acme-app"); @@ -1609,13 +1572,7 @@ public void updateProjectNotFoundTest() { @Test public void updateProjectNotPermittedTest() { - qm.createConfigProperty( - ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() - ); + enablePortfolioAccessControl(); final var project = new Project(); project.setName("acme-app"); @@ -1781,13 +1738,7 @@ public void patchProjectNotFoundTest() { @Test public void patchProjectNotPermittedTest() { - qm.createConfigProperty( - ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() - ); + enablePortfolioAccessControl(); final var project = new Project(); project.setName("acme-app"); @@ -1840,7 +1791,8 @@ public void patchProjectParentTest() { }, "properties": [], "tags": [], - "active": true + "active": true, + "isLatest": false } """); @@ -1994,6 +1946,7 @@ public void patchProjectSuccessfullyPatchedTest() { } ], "active": false, + "isLatest": false, "children": [] } """); @@ -2343,13 +2296,7 @@ public void cloneProjectConflictTest() { @Test public void cloneProjectWithAclTest() { - qm.createConfigProperty( - ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - null - ); + enablePortfolioAccessControl(); final var accessProject = new Project(); accessProject.setName("acme-app-a"); @@ -2536,12 +2483,14 @@ public void issue3883RegressionTest() { "version": "1.0.0", "classifier": "APPLICATION", "uuid": "${json-unit.any-string}", - "active": true + "active": true, + "isLatest": false } ], "properties": [], "tags": [], "active": true, + "isLatest": false, "versions": [ { "uuid": "${json-unit.any-string}", @@ -2572,6 +2521,7 @@ public void issue3883RegressionTest() { "properties": [], "tags": [], "active": true, + "isLatest": false, "versions": [ { "uuid": "${json-unit.any-string}", @@ -2582,4 +2532,377 @@ public void issue3883RegressionTest() { } """); } + + @Test + public void createProjectAsLatestTest() { + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0"); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + // ensure initial value is false when not specified + Assert.assertFalse(json.getBoolean("isLatest")); + + project.setVersion("2.0"); + project.setIsLatest(true); + response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + json = parseJsonObject(response); + // ensure value of latest version is true when specified + Assert.assertTrue(json.getBoolean("isLatest")); + String v20uuid = json.getString("uuid"); + + project.setVersion("2.1"); + response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + json = parseJsonObject(response); + // ensure value of latest version is true when specified + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure v2.0 is no longer latest + Assert.assertFalse(qm.getProject(v20uuid).isLatest()); + } + + @Test + public void createProjectAsLatestWithACLTest() { + enablePortfolioAccessControl(); + + final var accessProject = new Project(); + accessProject.setName("acme-app-a"); + accessProject.setVersion("1.0.0"); + accessProject.setIsLatest(true); + accessProject.setAccessTeams(List.of(team)); + qm.persist(accessProject); + + final var noAccessProject = new Project(); + noAccessProject.setName("acme-app-b"); + noAccessProject.setVersion("2.0.0"); + noAccessProject.setIsLatest(true); + qm.persist(noAccessProject); + + Project project = new Project(); + project.setName(accessProject.getName()); + project.setVersion("1.0.1"); + project.setIsLatest(true); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertTrue(json.getBoolean("isLatest")); + + project.setName(noAccessProject.getName()); + project.setVersion("3.0.0"); + response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(403, response.getStatus(), 0); + } + + @Test + public void updateProjectAsLatestTest() { + // create project not as latest + Project project = qm.createProject("ABC", null, "1.0", null, null, null, + true, false, false); + + // make it latest by update + var jsonProject = qm.detach(project); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(jsonProject, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertTrue(json.getBoolean("isLatest")); + + // add another project version, "forget" to make it latest + final Project newProject = qm.createProject("ABC", null, "1.0.1", null, null, null, + true, false, false); + // make the new version latest afterwards via update + jsonProject = qm.detach(newProject); + jsonProject.setIsLatest(true); + response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(jsonProject, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + json = parseJsonObject(response); + // ensure is now latest + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure old is no longer latest + Assert.assertFalse(qm.getProject(project.getName(), project.getVersion()).isLatest()); + } + + @Test + public void updateProjectAsLatestWithACLAndAccessTest() { + enablePortfolioAccessControl(); + + final var accessLatestProject = new Project(); + accessLatestProject.setName("acme-app-a"); + accessLatestProject.setVersion("1.0.0"); + accessLatestProject.setIsLatest(true); + accessLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessLatestProject); + + final var accessNotLatestProject = new Project(); + accessNotLatestProject.setName("acme-app-a"); + accessNotLatestProject.setVersion("1.0.1"); + accessNotLatestProject.setIsLatest(false); + accessNotLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessNotLatestProject); + + // make the new version latest afterwards via update + final var jsonProject = qm.detach(accessNotLatestProject); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(jsonProject, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + // ensure is now latest + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure old is no longer latest (bypass db cache) + qm.getPersistenceManager().refreshAll(); + Assert.assertFalse(qm.getProject(accessLatestProject.getName(), accessLatestProject.getVersion()).isLatest()); + } + + @Test + public void updateProjectAsLatestWithACLAndNoAccessTest() { + enablePortfolioAccessControl(); + + final var noAccessLatestProject = new Project(); + noAccessLatestProject.setName("acme-app-a"); + noAccessLatestProject.setVersion("1.0.0"); + noAccessLatestProject.setIsLatest(true); + qm.persist(noAccessLatestProject); + + final var accessNotLatestProject = new Project(); + accessNotLatestProject.setName("acme-app-a"); + accessNotLatestProject.setVersion("1.0.1"); + accessNotLatestProject.setIsLatest(false); + accessNotLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessNotLatestProject); + + // make the new version latest afterwards via update (but have no access to old latest) + final var jsonProject = qm.detach(accessNotLatestProject); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(jsonProject, MediaType.APPLICATION_JSON)); + Assert.assertEquals(403, response.getStatus(), 0); + // ensure old is still latest + Assert.assertTrue(qm.getProject(noAccessLatestProject.getName(), noAccessLatestProject.getVersion()).isLatest()); + } + + @Test + public void patchProjectAsLatestTest() { + // create project not as latest + Project project = qm.createProject("ABC", null, "1.0", null, null, null, + true, false, false); + + // make it latest by patch + var jsonProject = new Project(); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT + "/" + project.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true) + .method(HttpMethod.PATCH, Entity.json(jsonProject)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertTrue(json.getBoolean("isLatest")); + + // add another project version, "forget" to make it latest + final Project newProject = qm.createProject("ABC", null, "1.0.1", null, null, null, + true, false, false); + // make the new version latest afterwards via update + jsonProject = new Project(); + jsonProject.setIsLatest(true); + response = jersey.target(V1_PROJECT + "/" + newProject.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true) + .method(HttpMethod.PATCH, Entity.json(jsonProject)); + Assert.assertEquals(200, response.getStatus(), 0); + json = parseJsonObject(response); + // ensure is now latest + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure old is no longer latest + Assert.assertFalse(qm.getProject(project.getName(), project.getVersion()).isLatest()); + } + + @Test + public void patchProjectAsLatestWithACLAndAccessTest() { + enablePortfolioAccessControl(); + + final var accessLatestProject = new Project(); + accessLatestProject.setName("acme-app-a"); + accessLatestProject.setVersion("1.0.0"); + accessLatestProject.setIsLatest(true); + accessLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessLatestProject); + + final var accessNotLatestProject = new Project(); + accessNotLatestProject.setName("acme-app-a"); + accessNotLatestProject.setVersion("1.0.1"); + accessNotLatestProject.setIsLatest(false); + accessNotLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessNotLatestProject); + + // make the new version latest afterwards via update + final var jsonProject = new Project(); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT + "/" + accessNotLatestProject.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true) + .method(HttpMethod.PATCH, Entity.json(jsonProject)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + // ensure is now latest + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure old is no longer latest (bypass db cache) + qm.getPersistenceManager().refreshAll(); + Assert.assertFalse(qm.getProject(accessLatestProject.getName(), accessLatestProject.getVersion()).isLatest()); + } + + @Test + public void patchProjectAsLatestWithACLAndNoAccessTest() { + enablePortfolioAccessControl(); + + final var noAccessLatestProject = new Project(); + noAccessLatestProject.setName("acme-app-a"); + noAccessLatestProject.setVersion("1.0.0"); + noAccessLatestProject.setIsLatest(true); + qm.persist(noAccessLatestProject); + + final var accessNotLatestProject = new Project(); + accessNotLatestProject.setName("acme-app-a"); + accessNotLatestProject.setVersion("1.0.1"); + accessNotLatestProject.setIsLatest(false); + accessNotLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessNotLatestProject); + + // make the new version latest afterwards via update (but have no access to old latest) + final var jsonProject = new Project(); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT + "/" + accessNotLatestProject.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true) + .method(HttpMethod.PATCH, Entity.json(jsonProject)); + Assert.assertEquals(403, response.getStatus(), 0); + // ensure old is still latest + qm.getPersistenceManager().refreshAll(); + Assert.assertTrue(qm.getProject(noAccessLatestProject.getName(), noAccessLatestProject.getVersion()).isLatest()); + } + + @Test + public void cloneProjectAsLatestTest() { + EventService.getInstance().subscribe(CloneProjectEvent.class, CloneProjectTask.class); + + final var project = new Project(); + project.setName("acme-app-a"); + project.setVersion("1.0.0"); + project.setIsLatest(true); + qm.persist(project); + + final Response response = jersey.target("%s/clone".formatted(V1_PROJECT)).request() + .header(X_API_KEY, apiKey) + .put(Entity.json(""" + { + "project": "%s", + "version": "1.1.0", + "makeCloneLatest": true + } + """.formatted(project.getUuid()))); + assertThat(response.getStatus()).isEqualTo(202); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertNotNull(json.getString("token")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("token"))); + + await("Cloning completion") + .atMost(Duration.ofSeconds(15)) + .pollInterval(Duration.ofMillis(50)) + .untilAsserted(() -> { + final Project clonedProject = qm.getProject("acme-app-a", "1.1.0"); + assertThat(clonedProject).isNotNull(); + assertThat(clonedProject.isLatest()).isTrue(); + + // ensure source is no longer latest + qm.getPersistenceManager().refresh(project); + assertThat(project.isLatest()).isFalse(); + }); + } + + @Test + public void getLatestProjectTest() { + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + qm.createProject("Acme Example", null, "1.0.2", null, null, null, true, true, false); + qm.createProject("Different project", null, "1.0.3", null, null, null, true, true, false); + + Response response = jersey.target(V1_PROJECT_LATEST + "Acme Example") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Acme Example", json.getString("name")); + Assert.assertEquals("1.0.2", json.getString("version")); + } + + @Test + public void getLatestProjectWithAclEnabledTest() { + enablePortfolioAccessControl(); + + // Create project and give access to current principal's team. + Project accessProject = qm.createProject("acme-app-a", null, "1.0.0", null, null, null, true, false, false); + accessProject.setAccessTeams(List.of(team)); + qm.persist(accessProject); + + accessProject = qm.createProject("acme-app-a", null, "1.0.2", null, null, null, true, true, false); + accessProject.setAccessTeams(List.of(team)); + qm.persist(accessProject); + + final Response response = jersey.target(V1_PROJECT_LATEST + "acme-app-a") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("acme-app-a", json.getString("name")); + Assert.assertEquals("1.0.2", json.getString("version")); + } + + @Test + public void getLatestProjectWithAclEnabledNoAccessTest() { + enablePortfolioAccessControl(); + + // Create projects and give NO access + qm.createProject("acme-app-a", null, "1.0.0", null, null, null, true, false, false); + qm.createProject("acme-app-a", null, "1.0.2", null, null, null, true, true, false); + + final Response response = jersey.target(V1_PROJECT_LATEST + "acme-app-a") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); + } } diff --git a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java index b02edd317..02b38ff1d 100644 --- a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java +++ b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java @@ -1490,7 +1490,7 @@ public void informIssue3981Test() throws Exception { new BomUploadProcessingTask().inform(bomUploadEvent); assertBomProcessedNotification(); - final Project clonedProject = qm.clone(project.getUuid(), "3.2.1", true, true, true, true, true, true, true); + final Project clonedProject = qm.clone(project.getUuid(), "3.2.1", true, true, true, true, true, true, true, false); bomBytes = """ { diff --git a/src/test/java/org/dependencytrack/tasks/CloneProjectTaskTest.java b/src/test/java/org/dependencytrack/tasks/CloneProjectTaskTest.java index 08f867393..07fb1b93e 100644 --- a/src/test/java/org/dependencytrack/tasks/CloneProjectTaskTest.java +++ b/src/test/java/org/dependencytrack/tasks/CloneProjectTaskTest.java @@ -38,7 +38,7 @@ public class CloneProjectTaskTest extends PersistenceCapableTest { @Test public void testCloneProjectTask() { Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); - CloneProjectRequest request = new CloneProjectRequest(project.getUuid().toString(), "1.1", false, false, false, false, false, false, false, false); + CloneProjectRequest request = new CloneProjectRequest(project.getUuid().toString(), "1.1", false, false, false, false, false, false, false, false, false); final var cloneProjectEvent = new CloneProjectEvent(request); new CloneProjectTask().inform(cloneProjectEvent); var clonedProject = qm.getProject("Acme Example", "1.1"); @@ -56,7 +56,7 @@ public void testCloneProjectTask() { @Test public void testCloneProjectDoesNotExist() { var uuid = UUID.randomUUID(); - CloneProjectRequest request = new CloneProjectRequest(uuid.toString(), "1.1", false, false, false, false, false, false, false, false); + CloneProjectRequest request = new CloneProjectRequest(uuid.toString(), "1.1", false, false, false, false, false, false, false, false, false); final var cloneProjectEvent = new CloneProjectEvent(request); new CloneProjectTask().inform(cloneProjectEvent); var clonedProject = qm.getProject("Acme Example", "1.1"); @@ -75,7 +75,7 @@ public void testCloneProjectDoesNotExist() { public void testCloneProjectVersionExist() { Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); // Clone request with project version already existing. - CloneProjectRequest request = new CloneProjectRequest(project.getUuid().toString(), "1.0", false, false, false, false, false, false, false, false); + CloneProjectRequest request = new CloneProjectRequest(project.getUuid().toString(), "1.0", false, false, false, false, false, false, false, false, false); final var cloneProjectEvent = new CloneProjectEvent(request); new CloneProjectTask().inform(cloneProjectEvent); assertThat(qm.getAllWorkflowStatesForAToken(cloneProjectEvent.getChainIdentifier())).satisfiesExactly(