From dfa6e6f2a7cfef5e617e8cac9f6ada81b4b2fd3a Mon Sep 17 00:00:00 2001 From: Christoffer Rumohr Date: Tue, 7 Jan 2025 11:38:10 +0100 Subject: [PATCH] Add option to deactivate old project versions on BOM upload Fixes DependencyTrack/dependency-track#4532 Signed-off-by: Christoffer Rumohr --- .../resources/v1/BomResource.java | 39 ++++ .../resources/v1/vo/BomSubmitRequest.java | 11 +- .../resources/v1/BomResourceTest.java | 195 ++++++++++++++++-- 3 files changed, 230 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index f29f7e351f..c7672f43be 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -344,6 +344,14 @@ public Response uploadBom(@Parameter(required = true) BomSubmitRequest request) null, true, request.isLatestProjectVersion(), true); Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); + if (request.isDeactivateOtherVersions()) { + if (!request.isLatestProjectVersion()) { + var message = "Value \"isLatest=true\" required when \"deactivateOtherVersions=true\"."; + LOGGER.error(message); + return Response.status(Response.Status.NOT_ACCEPTABLE).entity(message).build(); + } + qm.runInTransaction(() -> deactivateOtherVersions(qm, trimmedProjectName)); + } } else { return Response.status(Response.Status.UNAUTHORIZED).entity("The principal does not have permission to create project.").build(); } @@ -404,6 +412,7 @@ public Response uploadBom( @FormDataParam("parentVersion") String parentVersion, @FormDataParam("parentUUID") String parentUUID, @DefaultValue("false") @FormDataParam("isLatest") boolean isLatest, + @DefaultValue("false") @FormDataParam("deactivateOtherVersions") boolean deactivateOtherVersions, @Parameter(schema = @Schema(type = "string")) @FormDataParam("bom") final List artifactParts ) { if (projectUuid != null) { // behavior in v3.0.0 @@ -450,6 +459,15 @@ public Response uploadBom( project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, tags, parent, null, true, isLatest, true); Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); + + if (deactivateOtherVersions) { + if (!isLatest) { + var message = "Value \"isLatest=true\" required when \"deactivateOtherVersions=true\"."; + LOGGER.error(message); + return Response.status(Response.Status.NOT_ACCEPTABLE).entity(message).build(); + } + qm.runInTransaction(() -> deactivateOtherVersions(qm, trimmedProjectName)); + } } else { return Response.status(Response.Status.UNAUTHORIZED).entity("The principal does not have permission to create project.").build(); } @@ -459,6 +477,7 @@ public Response uploadBom( } } + @GET @Path("/token/{uuid}") @Produces(MediaType.APPLICATION_JSON) @@ -501,6 +520,26 @@ public Response isTokenBeingProcessed ( return Response.ok(response).build(); } + /** + * Deactivates all non-latest versions of a specific project. + * Ensures that only the latest version of a project remains active. + * If the principal does not have access to a project version, an exception is thrown. + * + * @param qm The QueryManager instance used to query and manage projects. + * @param projectName A string representing the name of the project versions to be evaluated. + */ + private void deactivateOtherVersions(QueryManager qm, String projectName) { + qm.getProjects(projectName, true, false, null).getList(Project.class).forEach(p -> { + if (p.isLatest()) { + return; + } else if (!qm.hasAccess(super.getPrincipal(), p)) { + throw new WebApplicationException("Could not deactivate project, no access: " + p.getUuid() + " / " + p.getName()); + } + p.setActive(false); + qm.updateProject(p, true); + }); + } + /** * Common logic that processes a BOM given a project and encoded payload. */ 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 b6171d1314..339090760f 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java @@ -74,14 +74,17 @@ public final class BomSubmitRequest { private final boolean isLatestProjectVersion; + private final boolean deactivateOtherVersions; + public BomSubmitRequest(String project, String projectName, String projectVersion, List projectTags, boolean autoCreate, boolean isLatestProjectVersion, + boolean deactivateOtherProjectVersions, String bom) { - this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, isLatestProjectVersion, bom); + this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, isLatestProjectVersion, deactivateOtherProjectVersions, bom); } @JsonCreator @@ -94,6 +97,7 @@ public BomSubmitRequest(@JsonProperty(value = "project") String project, @JsonProperty(value = "parentName") String parentName, @JsonProperty(value = "parentVersion") String parentVersion, @JsonProperty(value = "isLatestProjectVersion", defaultValue = "false") boolean isLatestProjectVersion, + @JsonProperty(value = "deactivateOtherVersions", defaultValue = "false") boolean deactivateOtherVersions, @JsonProperty(value = "bom", required = true) String bom) { this.project = project; this.projectName = projectName; @@ -104,6 +108,7 @@ public BomSubmitRequest(@JsonProperty(value = "project") String project, this.parentName = parentName; this.parentVersion = parentVersion; this.isLatestProjectVersion = isLatestProjectVersion; + this.deactivateOtherVersions = deactivateOtherVersions; this.bom = bom; } @@ -149,6 +154,10 @@ public boolean isAutoCreate() { @JsonProperty("isLatestProjectVersion") public boolean isLatestProjectVersion() { return isLatestProjectVersion; } + public boolean isDeactivateOtherVersions() { + return deactivateOtherVersions; + } + @Schema( description = "Base64 encoded BOM", required = true, diff --git a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java index a2cf7a90b4..805ccf3520 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -822,7 +822,7 @@ public void uploadBomTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -837,7 +837,7 @@ public void uploadBomTest() throws Exception { public void uploadBomInvalidProjectTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(UUID.randomUUID().toString(), null, null, null, false, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(UUID.randomUUID().toString(), null, null, null, false, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -851,7 +851,7 @@ public void uploadBomInvalidProjectTest() throws Exception { public void uploadBomAutoCreateTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -874,7 +874,7 @@ public void uploadBomAutoCreateWithTagsTest() throws Exception { return tag; }).collect(Collectors.toList()); BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", - tags, true, false, bomString); + tags, true, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -890,6 +890,64 @@ public void uploadBomAutoCreateWithTagsTest() throws Exception { .containsExactlyInAnyOrder("tag1", "tag2"); } + @Test + public void uploadBomAutoCreateIsLatestDeactivateOtherVersions() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + + BomSubmitRequest requestV1 = new BomSubmitRequest(null, "Acme Example", "1.0", + null, true, true, true, bomString); + + BomSubmitRequest requestV2 = new BomSubmitRequest(null, "Acme Example", "2.0", + null, true, true, true, bomString); + + Response responseV1 = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(requestV1, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, responseV1.getStatus(), 0); + + Response responseV2 = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(requestV2, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, responseV2.getStatus(), 0); + + JsonObject jsonV1 = parseJsonObject(responseV1); + Assert.assertNotNull(jsonV1); + Assert.assertNotNull(jsonV1.getString("token")); + Assert.assertTrue(UuidUtil.isValidUUID(jsonV1.getString("token"))); + + JsonObject jsonV2 = parseJsonObject(responseV2); + Assert.assertNotNull(jsonV2); + Assert.assertNotNull(jsonV2.getString("token")); + Assert.assertTrue(UuidUtil.isValidUUID(jsonV2.getString("token"))); + + Project projectV1 = qm.getProject("Acme Example", "1.0"); + Assert.assertNotNull(projectV1); + assertThat(projectV1.isLatest()).isFalse(); + assertThat(projectV1.isActive()).isFalse(); + + Project projectV2 = qm.getProject("Acme Example", "2.0"); + Assert.assertNotNull(projectV2); + assertThat(projectV2.isLatest()).isTrue(); + assertThat(projectV2.isActive()).isTrue(); + } + + @Test + public void uploadBomAutoCreateNonLatestDeactivateOtherVersions() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", + null, true, false, true, bomString); + + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(406, response.getStatus(), 0); + String body = getPlainTextBody(response); + Assert.assertEquals("Value \"isLatest=true\" required when \"deactivateOtherVersions=true\".", body); + } + @Test public void uploadBomAutoCreateWithTagsMultipartTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); @@ -923,11 +981,120 @@ public void uploadBomAutoCreateWithTagsMultipartTest() throws Exception { .containsExactlyInAnyOrder("tag1", "tag2"); } + @Test + public void uploadBomAutoCreateIsLatestMultipartTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + + final var multiPart = new FormDataMultiPart() + .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) + .field("projectName", "Acme Example") + .field("projectVersion", "1.0") + .field("projectTags", "tag1,tag2") + .field("autoCreate", "true") + .field("isLatest", "true"); + + final var client = ClientBuilder.newClient(new ClientConfig() + .connectorProvider(new HttpUrlConnectorProvider())); + + final Response response = client.target(jersey.target(V1_BOM).getUri()).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(multiPart, multiPart.getMediaType())); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "token": "${json-unit.any-string}" + } + """); + + final Project project = qm.getProject("Acme Example", "1.0"); + assertThat(project).isNotNull(); + assertThat(project.getTags()) + .extracting(Tag::getName) + .containsExactlyInAnyOrder("tag1", "tag2"); + assertThat(project.isLatest()).isTrue(); + } + + @Test + public void uploadBomAutoCreateIsLatestDeactivateOtherVersionsMultipartTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + + final var multipartV1 = new FormDataMultiPart() + .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) + .field("projectName", "Acme Example") + .field("projectVersion", "1.0") + .field("autoCreate", "true") + .field("isLatest", "true") + .field("deactivateOtherVersions", "true"); + + final var multipartV2 = new FormDataMultiPart() + .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) + .field("projectName", "Acme Example") + .field("projectVersion", "2.0") + .field("autoCreate", "true") + .field("isLatest", "true") + .field("deactivateOtherVersions", "true"); + + final var client = ClientBuilder.newClient(new ClientConfig() + .connectorProvider(new HttpUrlConnectorProvider())); + + final Response responseV1 = client.target(jersey.target(V1_BOM).getUri()).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(multipartV1, multipartV1.getMediaType())); + assertThat(responseV1.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(responseV1)).isEqualTo(""" + { + "token": "${json-unit.any-string}" + } + """); + + final Response responseV2 = client.target(jersey.target(V1_BOM).getUri()).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(multipartV2, multipartV2.getMediaType())); + assertThat(responseV2.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(responseV2)).isEqualTo(""" + { + "token": "${json-unit.any-string}" + } + """); + + final Project projectV1 = qm.getProject("Acme Example", "1.0"); + assertThat(projectV1).isNotNull(); + assertThat(projectV1.isLatest()).isFalse(); + assertThat(projectV1.isActive()).isFalse(); + + final Project projectV2 = qm.getProject("Acme Example", "2.0"); + assertThat(projectV2).isNotNull(); + assertThat(projectV2.isLatest()).isTrue(); + assertThat(projectV2.isActive()).isTrue(); + } + + @Test + public void uploadBomAutoCreateIsNotLatestDeactivateOtherVersionsMultipartTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + + final var multiPart = new FormDataMultiPart() + .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) + .field("projectName", "Acme Example") + .field("projectVersion", "1.0") + .field("autoCreate", "true") + //.field("isLatest", "false") // this is implied with @DefaultValue("false") + .field("deactivateOtherVersions", "true"); + + final var client = ClientBuilder.newClient(new ClientConfig() + .connectorProvider(new HttpUrlConnectorProvider())); + + final Response response = client.target(jersey.target(V1_BOM).getUri()).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(multiPart, multiPart.getMediaType())); + assertThat(response.getStatus()).isEqualTo(406); + assertThat(getPlainTextBody(response)).isEqualTo("Value \"isLatest=true\" required when \"deactivateOtherVersions=true\"."); + } + @Test public void uploadBomUnauthorizedTest() throws Exception { String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", - null, true, false, bomString); + null, true, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -950,7 +1117,7 @@ public void uploadBomAutoCreateLatestWithAclTest() throws Exception { String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); BomSubmitRequest request = new BomSubmitRequest(null, accessLatestProject.getName(), - "1.0.1", null, true, true, bomString); + "1.0.1", null, true, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -973,7 +1140,7 @@ public void uploadBomAutoCreateLatestWithAclNoAccessTest() throws Exception { String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); BomSubmitRequest request = new BomSubmitRequest(null, noAccessLatestProject.getName(), - "1.0.1", null, true, true, bomString); + "1.0.1", null, true, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -986,7 +1153,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); // Upload parent project BomSubmitRequest request = new BomSubmitRequest(null, "Acme Parent", "1.0", - null, true, false, bomString); + null, true, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -998,7 +1165,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, false, bomString); + request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, parentUUID, null, null, false, false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1014,7 +1181,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", false, bomString); + request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Acme Parent", "1.0", false, false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1029,7 +1196,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", false, bomString); + request = new BomSubmitRequest(null, "Acme Example", "3.0", null, true, parentUUID, "Non-existent parent", "1.0", false, false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1048,7 +1215,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { public void uploadBomInvalidParentTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, UUID.randomUUID().toString(), null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, UUID.randomUUID().toString(), null, null, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1056,7 +1223,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, false, bomString); + request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Non-existent parent", null, false, false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1470,7 +1637,7 @@ public void uploadBomCollectionProjectTest() throws Exception { qm.updateProject(project, false); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON));