Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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) OWASP Foundation. All Rights Reserved.
*/
package org.dependencytrack.exception;

import java.util.Map;

public class ProjectOperationException extends IllegalStateException {

private final Map<String, String> errorByUUID;

private ProjectOperationException(final String message, final Map<String, String> errorByUUID) {
super(message);
this.errorByUUID = errorByUUID;
}

public static ProjectOperationException forDeletion(final Map<String, String> errorByUUID) {
return new ProjectOperationException("One or more projects could not be deleted", errorByUUID);
}

public Map<String, String> getErrorByUUID() {
return errorByUUID;
}

}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,10 @@ public void recursivelyDelete(final Project project, final boolean commitIndex)
getProjectQueryManager().recursivelyDelete(project, commitIndex);
}

public void deleteProjectsByUUIDs(Collection<UUID> uuids) {
getProjectQueryManager().deleteProjectsByUUIDs(uuids);
}

public ProjectProperty createProjectProperty(final Project project, final String groupName, final String propertyName,
final String propertyValue, final ProjectProperty.PropertyType propertyType,
final String description) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import jakarta.validation.constraints.Size;
import org.apache.commons.lang3.StringUtils;
import org.dependencytrack.auth.Permissions;
import org.dependencytrack.event.CloneProjectEvent;
Expand All @@ -46,6 +47,8 @@
import org.dependencytrack.model.validation.ValidUuid;
import org.dependencytrack.persistence.QueryManager;
import org.dependencytrack.resources.v1.openapi.PaginatedApi;
import org.dependencytrack.resources.v1.problems.ProblemDetails;
import org.dependencytrack.resources.v1.problems.ProjectOperationProblemDetails;
import org.dependencytrack.resources.v1.vo.BomUploadResponse;
import org.dependencytrack.resources.v1.vo.CloneProjectRequest;

Expand Down Expand Up @@ -809,6 +812,30 @@ public Response deleteProject(
}
}

@POST
@Path("/batchDelete")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Deletes a list of projects specified by their UUIDs",
description = "<p>Requires permission <strong>PORTFOLIO_MANAGEMENT</strong></p>"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Projects removed successfully"),
@ApiResponse(
responseCode = "400",
description = "Operation failed",
content = @Content(schema = @Schema(implementation = ProjectOperationProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON)
)
})
@PermissionRequired(Permissions.Constants.PORTFOLIO_MANAGEMENT)
public Response deleteProjects(@Size(min = 1, max = 1000) final Set<UUID> uuids) {
try (final var qm = new QueryManager(getAlpineRequest())) {
qm.deleteProjectsByUUIDs(uuids);
}
return Response.status(Response.Status.NO_CONTENT).build();
}

@PUT
@Path("/clone")
@Consumes(MediaType.APPLICATION_JSON)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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) OWASP Foundation. All Rights Reserved.
*/
package org.dependencytrack.resources.v1.exception;

import org.dependencytrack.exception.ProjectOperationException;
import org.dependencytrack.resources.v1.problems.ProjectOperationProblemDetails;

import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;

@Provider
public class ProjectOperationExceptionMapper implements ExceptionMapper<ProjectOperationException> {

@Override
public Response toResponse(final ProjectOperationException exception) {
final var problemDetails = new ProjectOperationProblemDetails();
problemDetails.setStatus(400);
problemDetails.setTitle("Project operation failed");
problemDetails.setDetail(exception.getMessage());
problemDetails.setErrors(exception.getErrorByUUID());
return problemDetails.toResponse();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
description = "An RFC 9457 problem object",
subTypes = {
InvalidBomProblemDetails.class,
TagOperationProblemDetails.class
TagOperationProblemDetails.class,
ProjectOperationProblemDetails.class
}
)
@JsonInclude(JsonInclude.Include.NON_NULL)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* 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) OWASP Foundation. All Rights Reserved.
*/
package org.dependencytrack.resources.v1.problems;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.Map;

public class ProjectOperationProblemDetails extends ProblemDetails {

@Schema(description = "Errors encountered during the operation, grouped by project UUID")
private Map<String, String> errors;

public Map<String, String> getErrors() {
return errors;
}

public void setErrors(final Map<String, String> errors) {
this.errors = errors;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import alpine.server.auth.JsonWebToken;
import alpine.server.filters.ApiFilter;
import alpine.server.filters.AuthenticationFilter;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.cyclonedx.model.ExternalReference.Type;
import org.dependencytrack.JerseyTestRule;
import org.dependencytrack.ResourceTest;
Expand All @@ -47,8 +49,10 @@
import org.dependencytrack.model.ServiceComponent;
import org.dependencytrack.model.Tag;
import org.dependencytrack.model.Vulnerability;
import org.dependencytrack.resources.v1.exception.ProjectOperationExceptionMapper;
import org.dependencytrack.tasks.CloneProjectTask;
import org.dependencytrack.tasks.scanners.AnalyzerIdentity;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.client.HttpUrlConnectorProvider;
import org.glassfish.jersey.server.ResourceConfig;
import org.hamcrest.CoreMatchers;
Expand All @@ -60,6 +64,7 @@

import jakarta.json.Json;
import jakarta.json.JsonArray;
import jakarta.json.JsonArrayBuilder;

Check notice on line 67 in src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java#L67

Unused import - jakarta.json.JsonArrayBuilder.
import jakarta.json.JsonObject;
import jakarta.json.JsonObjectBuilder;
import jakarta.ws.rs.HttpMethod;
Expand Down Expand Up @@ -87,7 +92,8 @@
public static JerseyTestRule jersey = new JerseyTestRule(
new ResourceConfig(ProjectResource.class)
.register(ApiFilter.class)
.register(AuthenticationFilter.class));
.register(AuthenticationFilter.class)
.register(ProjectOperationExceptionMapper.class));

@After
@Override
Expand Down Expand Up @@ -1295,6 +1301,110 @@
Assert.assertEquals(404, response.getStatus(), 0);
}

List<UUID> createProjects(int size, boolean accessible) {
List<UUID> projectUUIDs = new ArrayList<>();
for (int i=0; i<size; i++) {
Project project = qm.createProject("ABC", null, String.valueOf(i)+".0", null, null, null, true, false);

Check warning on line 1307 in src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java#L1307

No need to call String.valueOf to append to a string.
if (accessible) {
project.setAccessTeams(List.of(team));
}
projectUUIDs.add(project.getUuid());
qm.persist(project);
}
return projectUUIDs;
}

@Test
public void batchDeleteProjectsTest() throws JsonProcessingException {
// Enable portfolio access control.
qm.createConfigProperty(
ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(),
ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(),
"true",
ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(),
null
);

List<UUID> uuidsOfAccessibleProjects = createProjects(9, true);
List<UUID> uuidsOfInaccessibleProjects = createProjects(1, false);

// Delete only accessible projects
Response response = jersey.target(V1_PROJECT + "/batchDelete")
.request()
.header(X_API_KEY, apiKey)
.post(Entity.json(uuidsOfAccessibleProjects));
Assert.assertEquals(204, response.getStatus(), 0);

// Try to delete them again (they should now be gone)
response = jersey.target(V1_PROJECT + "/batchDelete")
.request()
.header(X_API_KEY, apiKey)
.property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true)
.post(Entity.json(uuidsOfAccessibleProjects));
Assert.assertEquals(400, response.getStatus(), 0);
assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json");
Map<String, String> expectedErrors = uuidsOfAccessibleProjects.stream()
.collect(Collectors.toMap(UUID::toString, uuid -> "Project not found"));
ObjectMapper objectMapper = new ObjectMapper();
String expectedErrorsJson = objectMapper.writeValueAsString(expectedErrors);
assertThatJson(
getPlainTextBody(response)
).isEqualTo("""
{
"status": 400,
"title": "Project operation failed",
"detail": "One or more projects could not be deleted",
"errors": %s
}
""".formatted(expectedErrorsJson)
);

// Delete only inaccessible projects
response = jersey.target(V1_PROJECT + "/batchDelete")
.request()
.header(X_API_KEY, apiKey)
.property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true)
.post(Entity.json(uuidsOfInaccessibleProjects));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json");
assertThatJson(
getPlainTextBody(response)
).isEqualTo("""
{
"status": 400,
"title": "Project operation failed",
"detail": "One or more projects could not be deleted",
"errors": {
"%": "Access denied to project"
}
}
""".replaceAll("%", uuidsOfInaccessibleProjects.getFirst().toString()));

// Delete mixed accessible + inaccessible projects
List<UUID> uuidsOfMixedProjects = new ArrayList<>();
uuidsOfAccessibleProjects = createProjects(9, true);
uuidsOfMixedProjects.addAll(uuidsOfAccessibleProjects);
uuidsOfMixedProjects.addAll(uuidsOfInaccessibleProjects);
response = jersey.target(V1_PROJECT + "/batchDelete")
.request()
.header(X_API_KEY, apiKey)
.post(Entity.json(uuidsOfMixedProjects));
Assert.assertEquals(400, response.getStatus(), 0);
assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json");
expectedErrors = uuidsOfInaccessibleProjects.stream()
.collect(Collectors.toMap(UUID::toString, uuid -> "Access denied to project"));
expectedErrorsJson = objectMapper.writeValueAsString(expectedErrors);
assertThatJson(getPlainTextBody(response)).isEqualTo("""
{
"status": 400,
"title": "Project operation failed",
"detail": "One or more projects could not be deleted",
"errors": %s
}
""".formatted(expectedErrorsJson));

}

@Test
public void patchProjectNotModifiedTest() {
final var tags = Stream.of("tag1", "tag2").map(qm::createTag).collect(Collectors.toUnmodifiableList());
Expand Down
Loading