diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index 2b9ad60b9c..7a7f84b1eb 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -20,7 +20,10 @@ import alpine.common.logging.Logger; import alpine.event.framework.Event; +import alpine.model.ApiKey; import alpine.model.ConfigProperty; +import alpine.model.Team; +import alpine.model.UserPrincipal; import alpine.notification.Notification; import alpine.notification.NotificationLevel; import alpine.server.auth.PermissionRequired; @@ -60,6 +63,8 @@ import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.resources.v1.problems.InvalidBomProblemDetails; import org.dependencytrack.resources.v1.problems.ProblemDetails; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.dependencytrack.resources.v1.vo.BomSubmitRequest; import org.dependencytrack.resources.v1.vo.BomUploadResponse; import org.dependencytrack.resources.v1.vo.IsTokenBeingProcessedResponse; @@ -92,13 +97,17 @@ import java.util.Arrays; import java.util.Base64; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; +import jakarta.ws.rs.ClientErrorException; +import static java.util.Objects.requireNonNullElseGet; import static java.util.function.Predicate.not; +import static org.dependencytrack.util.PersistenceUtil.isPersistent; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_MODE; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_EXCLUSIVE; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_INCLUSIVE; @@ -382,6 +391,7 @@ public Response uploadBom(@Parameter(required = true) BomSubmitRequest request) StringUtils.trimToNull(request.getProjectVersion()), request.getProjectTags(), parent, null, true, request.isLatest(), true); Principal principal = getPrincipal(); + applyAccessTeamsToProject(qm, project, requireNonNullElseGet(request.getAccessTeams(), Collections::emptyList), principal); qm.updateNewProjectACL(project, principal); } else { return Response.status(Response.Status.UNAUTHORIZED).entity("The principal does not have permission to create project.").build(); @@ -444,6 +454,7 @@ public Response uploadBom( @FormDataParam("projectName") String projectName, @FormDataParam("projectVersion") String projectVersion, @FormDataParam("projectTags") String projectTags, + @FormDataParam("accessTeams") String accessTeamsJson, @FormDataParam("parentName") String parentName, @FormDataParam("parentVersion") String parentVersion, @FormDataParam("parentUUID") String parentUUID, @@ -453,6 +464,7 @@ public Response uploadBom( final List requestTags = (projectTags != null && !projectTags.isBlank()) ? Arrays.stream(projectTags.split(",")).map(String::trim).filter(not(String::isEmpty)).map(Tag::new).toList() : null; + final List accessTeams = parseAccessTeams(accessTeamsJson); if (projectUuid != null) { // behavior in v3.0.0 try (QueryManager qm = new QueryManager()) { @@ -494,6 +506,7 @@ public Response uploadBom( } project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, requestTags, parent, null, true, isLatest, true); Principal principal = getPrincipal(); + applyAccessTeamsToProject(qm, project, accessTeams, principal); qm.updateNewProjectACL(project, principal); } else { return Response.status(Response.Status.UNAUTHORIZED).entity("The principal does not have permission to create project.").build(); @@ -694,6 +707,69 @@ private static boolean shouldValidate(final Project project) { } } + private static List parseAccessTeams(final String accessTeamsJson) { + if (accessTeamsJson == null || accessTeamsJson.isBlank()) { + return Collections.emptyList(); + } + try { + return new ObjectMapper().readValue(accessTeamsJson, new TypeReference<>() {}); + } catch (Exception e) { + throw new ClientErrorException(Response.status(Response.Status.BAD_REQUEST) + .entity("accessTeams must be a valid JSON array of team objects with uuid or name") + .build()); + } + } + + private void applyAccessTeamsToProject(final QueryManager qm, final Project project, + final List chosenTeams, final Principal principal) { + if (chosenTeams.isEmpty()) { + return; + } + for (final Team chosenTeam : chosenTeams) { + if (chosenTeam.getUuid() == null && chosenTeam.getName() == null) { + throw new ClientErrorException(Response.status(Response.Status.BAD_REQUEST) + .entity(""" + accessTeams must either specify a UUID or a name,\ + but the team at index %d has neither.""".formatted(chosenTeams.indexOf(chosenTeam))) + .build()); + } + } + final List userTeams; + if (principal instanceof final UserPrincipal userPrincipal) { + userTeams = userPrincipal.getTeams(); + } else if (principal instanceof final ApiKey apiKey) { + userTeams = apiKey.getTeams(); + } else { + userTeams = Collections.emptyList(); + } + final boolean isAdmin = qm.hasAccessManagementPermission(principal); + final List visibleTeams = isAdmin ? qm.getTeams() : userTeams; + final var visibleTeamByUuid = new HashMap(visibleTeams.size()); + final var visibleTeamByName = new HashMap(visibleTeams.size()); + for (final Team visibleTeam : visibleTeams) { + visibleTeamByUuid.put(visibleTeam.getUuid(), visibleTeam); + visibleTeamByName.put(visibleTeam.getName(), visibleTeam); + } + for (final Team chosenTeam : chosenTeams) { + Team visibleTeam = visibleTeamByUuid.getOrDefault( + chosenTeam.getUuid(), + visibleTeamByName.get(chosenTeam.getName())); + if (visibleTeam == null) { + throw new ClientErrorException(Response.status(Response.Status.BAD_REQUEST) + .entity(""" + The team with %s can not be assigned because it does not exist, \ + or is not accessible to the authenticated principal.""".formatted( + chosenTeam.getUuid() != null ? "UUID " + chosenTeam.getUuid() : "name " + chosenTeam.getName())) + .build()); + } + if (!isPersistent(visibleTeam)) { + visibleTeam = qm.getObjectById(Team.class, visibleTeam.getId()); + } + project.addAccessTeam(visibleTeam); + } + qm.persist(project); + } + private void maybeBindTags(final QueryManager qm, final Project project, final List tags) { if (tags == null) { return; 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 fd0a62b8a1..2a719dde54 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java @@ -25,6 +25,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import io.swagger.v3.oas.annotations.media.Schema; +import alpine.model.Team; import org.dependencytrack.model.Tag; import jakarta.validation.constraints.NotBlank; @@ -75,6 +76,8 @@ public final class BomSubmitRequest { private final boolean isLatest; + private final List accessTeams; + public BomSubmitRequest(String project, String projectName, String projectVersion, @@ -82,7 +85,7 @@ public BomSubmitRequest(String project, boolean autoCreate, boolean isLatest, String bom) { - this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, isLatest, bom); + this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, isLatest, null, bom); } @JsonCreator @@ -96,6 +99,7 @@ public BomSubmitRequest( @JsonProperty(value = "parentName") String parentName, @JsonProperty(value = "parentVersion") String parentVersion, @JsonProperty(value = "isLatest", defaultValue = "false") @JsonAlias("isLatestProjectVersion") boolean isLatest, + @JsonProperty(value = "accessTeams") List accessTeams, @JsonProperty(value = "bom", required = true) String bom) { this.project = project; this.projectName = projectName; @@ -106,6 +110,7 @@ public BomSubmitRequest( this.parentName = parentName; this.parentVersion = parentVersion; this.isLatest = isLatest; + this.accessTeams = accessTeams; this.bom = bom; } @@ -153,6 +158,14 @@ public boolean isAutoCreate() { @JsonProperty("isLatest") public boolean isLatest() { return isLatest; } + @Schema(description = """ + Teams to grant access to when auto-creating a project. Either uuid or name of a team must be specified. \ + Only teams which the authenticated principal is a member of can be assigned. \ + Principals with ACCESS_MANAGEMENT permission can assign any team.""") + public List getAccessTeams() { + return accessTeams; + } + @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 12ff940988..5683d22d4d 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -20,6 +20,7 @@ import alpine.common.util.UuidUtil; import alpine.model.IConfigProperty; +import alpine.model.Team; import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFilter; import com.fasterxml.jackson.core.StreamReadConstraints; @@ -947,6 +948,169 @@ void uploadBomAutoCreateTest() throws Exception { Assertions.assertNotNull(project); } + @Test + void uploadBomAutoCreateWithAccessTeamsTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + String json = """ + { + "projectName": "AccessTeams Example", + "projectVersion": "1.0", + "autoCreate": true, + "accessTeams": [{"name": "%s"}], + "bom": "%s" + } + """.formatted(team.getName(), bomString); + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(json, MediaType.APPLICATION_JSON)); + Assertions.assertEquals(200, response.getStatus(), 0); + Project project = qm.getProject("AccessTeams Example", "1.0"); + Assertions.assertNotNull(project); + assertThat(project.getAccessTeams()) + .extracting(Team::getName) + .containsOnly(team.getName()); + } + + @Test + void uploadBomAutoCreateWithAccessTeamsByUuidTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + String json = """ + { + "projectName": "AccessTeams ByUuid", + "projectVersion": "1.0", + "autoCreate": true, + "accessTeams": [{"uuid": "%s"}], + "bom": "%s" + } + """.formatted(team.getUuid(), bomString); + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(json, MediaType.APPLICATION_JSON)); + assertThat(response.getStatus()).isEqualTo(200); + Project project = qm.getProject("AccessTeams ByUuid", "1.0"); + assertThat(project).isNotNull(); + assertThat(project.getAccessTeams()) + .extracting(Team::getName) + .containsOnly(team.getName()); + } + + @Test + void uploadBomAutoCreateWithAccessTeamsAsAdminTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD, Permissions.ACCESS_MANAGEMENT); + final Team otherTeam = qm.createTeam("OtherTeam"); + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + String json = """ + { + "projectName": "AccessTeams Admin", + "projectVersion": "1.0", + "autoCreate": true, + "accessTeams": [{"uuid": "%s"}], + "bom": "%s" + } + """.formatted(otherTeam.getUuid(), bomString); + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(json, MediaType.APPLICATION_JSON)); + assertThat(response.getStatus()).isEqualTo(200); + Project project = qm.getProject("AccessTeams Admin", "1.0"); + assertThat(project).isNotNull(); + assertThat(project.getAccessTeams()) + .extracting(Team::getName) + .containsOnly("OtherTeam"); + } + + @Test + void uploadBomAutoCreateWithInaccessibleAccessTeamTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + final Team otherTeam = qm.createTeam("OtherTeam"); + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + String json = """ + { + "projectName": "AccessTeams Inaccessible", + "projectVersion": "1.0", + "autoCreate": true, + "accessTeams": [{"uuid": "%s"}], + "bom": "%s" + } + """.formatted(otherTeam.getUuid(), bomString); + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(json, MediaType.APPLICATION_JSON)); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).contains("can not be assigned"); + } + + @Test + void uploadBomAutoCreateWithAccessTeamsMissingIdentifierTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + String json = """ + { + "projectName": "AccessTeams NoId", + "projectVersion": "1.0", + "autoCreate": true, + "accessTeams": [{}], + "bom": "%s" + } + """.formatted(bomString); + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(json, MediaType.APPLICATION_JSON)); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).contains("must either specify a UUID or a name"); + } + + @Test + void uploadBomAutoCreateWithAccessTeamsMultipartTest() 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", "AccessTeams Multipart") + .field("projectVersion", "1.0") + .field("autoCreate", "true") + .field("accessTeams", """ + [{"name": "%s"}] + """.formatted(team.getName())); + + 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); + + final Project project = qm.getProject("AccessTeams Multipart", "1.0"); + assertThat(project).isNotNull(); + assertThat(project.getAccessTeams()) + .extracting(Team::getName) + .containsOnly(team.getName()); + } + + @Test + void uploadBomAutoCreateWithInvalidAccessTeamsJsonMultipartTest() 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", "AccessTeams Bad Json") + .field("projectVersion", "1.0") + .field("autoCreate", "true") + .field("accessTeams", "not-valid-json"); + + 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(400); + assertThat(getPlainTextBody(response)).contains("accessTeams must be a valid JSON array"); + } + @Test void uploadBomAutoCreateWithTagsTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); @@ -1134,7 +1298,7 @@ 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, null, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1150,7 +1314,7 @@ 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, null, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1165,7 +1329,7 @@ void uploadBomAutoCreateTestWithParentTest() throws Exception { Assertions.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, null, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1184,7 +1348,7 @@ void uploadBomAutoCreateTestWithParentTest() throws Exception { 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, null, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1192,7 +1356,7 @@ void uploadBomInvalidParentTest() throws Exception { String body = getPlainTextBody(response); Assertions.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, null, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON));