Skip to content

Commit 694d12d

Browse files
Add accessTeams support to BOM upload auto-create
Mimics createProject: when auto-creating a project during BOM upload, teams can be specified via accessTeams and are applied to the project ACL. Same resolution rules as Project API (principal must be member or have ACCESS_MANAGEMENT). - BomSubmitRequest: add accessTeams field (JSON) - BOM multipart: add accessTeams form param (JSON array) - Apply access teams before updateNewProjectACL
1 parent d0078ec commit 694d12d

3 files changed

Lines changed: 120 additions & 6 deletions

File tree

src/main/java/org/dependencytrack/resources/v1/BomResource.java

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020

2121
import alpine.common.logging.Logger;
2222
import alpine.event.framework.Event;
23+
import alpine.model.ApiKey;
2324
import alpine.model.ConfigProperty;
25+
import alpine.model.Team;
26+
import alpine.model.UserPrincipal;
2427
import alpine.notification.Notification;
2528
import alpine.notification.NotificationLevel;
2629
import alpine.server.auth.PermissionRequired;
@@ -60,6 +63,8 @@
6063
import org.dependencytrack.persistence.QueryManager;
6164
import org.dependencytrack.resources.v1.problems.InvalidBomProblemDetails;
6265
import org.dependencytrack.resources.v1.problems.ProblemDetails;
66+
import com.fasterxml.jackson.core.type.TypeReference;
67+
import com.fasterxml.jackson.databind.ObjectMapper;
6368
import org.dependencytrack.resources.v1.vo.BomSubmitRequest;
6469
import org.dependencytrack.resources.v1.vo.BomUploadResponse;
6570
import org.dependencytrack.resources.v1.vo.IsTokenBeingProcessedResponse;
@@ -92,13 +97,17 @@
9297
import java.util.Arrays;
9398
import java.util.Base64;
9499
import java.util.Collections;
100+
import java.util.HashMap;
95101
import java.util.List;
96102
import java.util.Objects;
97103
import java.util.Set;
98104
import java.util.UUID;
99105
import java.util.stream.Collectors;
100106

107+
import jakarta.ws.rs.ClientErrorException;
108+
import static java.util.Objects.requireNonNullElseGet;
101109
import static java.util.function.Predicate.not;
110+
import static org.dependencytrack.util.PersistenceUtil.isPersistent;
102111
import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_MODE;
103112
import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_EXCLUSIVE;
104113
import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_INCLUSIVE;
@@ -382,6 +391,7 @@ public Response uploadBom(@Parameter(required = true) BomSubmitRequest request)
382391
StringUtils.trimToNull(request.getProjectVersion()), request.getProjectTags(), parent,
383392
null, true, request.isLatest(), true);
384393
Principal principal = getPrincipal();
394+
applyAccessTeamsToProject(qm, project, requireNonNullElseGet(request.getAccessTeams(), Collections::emptyList), principal);
385395
qm.updateNewProjectACL(project, principal);
386396
} else {
387397
return Response.status(Response.Status.UNAUTHORIZED).entity("The principal does not have permission to create project.").build();
@@ -444,6 +454,7 @@ public Response uploadBom(
444454
@FormDataParam("projectName") String projectName,
445455
@FormDataParam("projectVersion") String projectVersion,
446456
@FormDataParam("projectTags") String projectTags,
457+
@FormDataParam("accessTeams") String accessTeamsJson,
447458
@FormDataParam("parentName") String parentName,
448459
@FormDataParam("parentVersion") String parentVersion,
449460
@FormDataParam("parentUUID") String parentUUID,
@@ -453,6 +464,7 @@ public Response uploadBom(
453464
final List<org.dependencytrack.model.Tag> requestTags = (projectTags != null && !projectTags.isBlank())
454465
? Arrays.stream(projectTags.split(",")).map(String::trim).filter(not(String::isEmpty)).map(Tag::new).toList()
455466
: null;
467+
final List<Team> accessTeams = parseAccessTeams(accessTeamsJson);
456468

457469
if (projectUuid != null) { // behavior in v3.0.0
458470
try (QueryManager qm = new QueryManager()) {
@@ -494,6 +506,7 @@ public Response uploadBom(
494506
}
495507
project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, requestTags, parent, null, true, isLatest, true);
496508
Principal principal = getPrincipal();
509+
applyAccessTeamsToProject(qm, project, accessTeams, principal);
497510
qm.updateNewProjectACL(project, principal);
498511
} else {
499512
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) {
694707
}
695708
}
696709

710+
private static List<Team> parseAccessTeams(final String accessTeamsJson) {
711+
if (accessTeamsJson == null || accessTeamsJson.isBlank()) {
712+
return Collections.emptyList();
713+
}
714+
try {
715+
return new ObjectMapper().readValue(accessTeamsJson, new TypeReference<>() {});
716+
} catch (Exception e) {
717+
throw new ClientErrorException(Response.status(Response.Status.BAD_REQUEST)
718+
.entity("accessTeams must be a valid JSON array of team objects with uuid or name")
719+
.build());
720+
}
721+
}
722+
723+
private void applyAccessTeamsToProject(final QueryManager qm, final Project project,
724+
final List<Team> chosenTeams, final Principal principal) {
725+
if (chosenTeams.isEmpty()) {
726+
return;
727+
}
728+
for (final Team chosenTeam : chosenTeams) {
729+
if (chosenTeam.getUuid() == null && chosenTeam.getName() == null) {
730+
throw new ClientErrorException(Response.status(Response.Status.BAD_REQUEST)
731+
.entity("""
732+
accessTeams must either specify a UUID or a name,\
733+
but the team at index %d has neither.""".formatted(chosenTeams.indexOf(chosenTeam)))
734+
.build());
735+
}
736+
}
737+
final List<Team> userTeams;
738+
if (principal instanceof final UserPrincipal userPrincipal) {
739+
userTeams = userPrincipal.getTeams();
740+
} else if (principal instanceof final ApiKey apiKey) {
741+
userTeams = apiKey.getTeams();
742+
} else {
743+
userTeams = Collections.emptyList();
744+
}
745+
final boolean isAdmin = qm.hasAccessManagementPermission(principal);
746+
final List<Team> visibleTeams = isAdmin ? qm.getTeams() : userTeams;
747+
final var visibleTeamByUuid = new HashMap<UUID, Team>(visibleTeams.size());
748+
final var visibleTeamByName = new HashMap<String, Team>(visibleTeams.size());
749+
for (final Team visibleTeam : visibleTeams) {
750+
visibleTeamByUuid.put(visibleTeam.getUuid(), visibleTeam);
751+
visibleTeamByName.put(visibleTeam.getName(), visibleTeam);
752+
}
753+
for (final Team chosenTeam : chosenTeams) {
754+
Team visibleTeam = visibleTeamByUuid.getOrDefault(
755+
chosenTeam.getUuid(),
756+
visibleTeamByName.get(chosenTeam.getName()));
757+
if (visibleTeam == null) {
758+
throw new ClientErrorException(Response.status(Response.Status.BAD_REQUEST)
759+
.entity("""
760+
The team with %s can not be assigned because it does not exist, \
761+
or is not accessible to the authenticated principal.""".formatted(
762+
chosenTeam.getUuid() != null ? "UUID " + chosenTeam.getUuid() : "name " + chosenTeam.getName()))
763+
.build());
764+
}
765+
if (!isPersistent(visibleTeam)) {
766+
visibleTeam = qm.getObjectById(Team.class, visibleTeam.getId());
767+
}
768+
project.addAccessTeam(visibleTeam);
769+
}
770+
qm.persist(project);
771+
}
772+
697773
private void maybeBindTags(final QueryManager qm, final Project project, final List<Tag> tags) {
698774
if (tags == null) {
699775
return;

src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.fasterxml.jackson.annotation.JsonProperty;
2626
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
2727
import io.swagger.v3.oas.annotations.media.Schema;
28+
import alpine.model.Team;
2829
import org.dependencytrack.model.Tag;
2930

3031
import jakarta.validation.constraints.NotBlank;
@@ -75,14 +76,16 @@ public final class BomSubmitRequest {
7576

7677
private final boolean isLatest;
7778

79+
private final List<Team> accessTeams;
80+
7881
public BomSubmitRequest(String project,
7982
String projectName,
8083
String projectVersion,
8184
List<Tag> projectTags,
8285
boolean autoCreate,
8386
boolean isLatest,
8487
String bom) {
85-
this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, isLatest, bom);
88+
this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, isLatest, null, bom);
8689
}
8790

8891
@JsonCreator
@@ -96,6 +99,7 @@ public BomSubmitRequest(
9699
@JsonProperty(value = "parentName") String parentName,
97100
@JsonProperty(value = "parentVersion") String parentVersion,
98101
@JsonProperty(value = "isLatest", defaultValue = "false") @JsonAlias("isLatestProjectVersion") boolean isLatest,
102+
@JsonProperty(value = "accessTeams") List<Team> accessTeams,
99103
@JsonProperty(value = "bom", required = true) String bom) {
100104
this.project = project;
101105
this.projectName = projectName;
@@ -106,6 +110,7 @@ public BomSubmitRequest(
106110
this.parentName = parentName;
107111
this.parentVersion = parentVersion;
108112
this.isLatest = isLatest;
113+
this.accessTeams = accessTeams;
109114
this.bom = bom;
110115
}
111116

@@ -153,6 +158,14 @@ public boolean isAutoCreate() {
153158
@JsonProperty("isLatest")
154159
public boolean isLatest() { return isLatest; }
155160

161+
@Schema(description = """
162+
Teams to grant access to when auto-creating a project. Either uuid or name of a team must be specified. \
163+
Only teams which the authenticated principal is a member of can be assigned. \
164+
Principals with ACCESS_MANAGEMENT permission can assign any team.""")
165+
public List<Team> getAccessTeams() {
166+
return accessTeams;
167+
}
168+
156169
@Schema(
157170
description = "Base64 encoded BOM",
158171
required = true,

src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import alpine.common.util.UuidUtil;
2222
import alpine.model.IConfigProperty;
23+
import alpine.model.Team;
2324
import alpine.server.filters.ApiFilter;
2425
import alpine.server.filters.AuthenticationFilter;
2526
import com.fasterxml.jackson.core.StreamReadConstraints;
@@ -947,6 +948,30 @@ void uploadBomAutoCreateTest() throws Exception {
947948
Assertions.assertNotNull(project);
948949
}
949950

951+
@Test
952+
void uploadBomAutoCreateWithAccessTeamsTest() throws Exception {
953+
initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD);
954+
String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml"));
955+
String json = """
956+
{
957+
"projectName": "AccessTeams Example",
958+
"projectVersion": "1.0",
959+
"autoCreate": true,
960+
"accessTeams": [{"name": "%s"}],
961+
"bom": "%s"
962+
}
963+
""".formatted(team.getName(), bomString);
964+
Response response = jersey.target(V1_BOM).request()
965+
.header(X_API_KEY, apiKey)
966+
.put(Entity.entity(json, MediaType.APPLICATION_JSON));
967+
Assertions.assertEquals(200, response.getStatus(), 0);
968+
Project project = qm.getProject("AccessTeams Example", "1.0");
969+
Assertions.assertNotNull(project);
970+
assertThat(project.getAccessTeams())
971+
.extracting(Team::getName)
972+
.containsOnly(team.getName());
973+
}
974+
950975
@Test
951976
void uploadBomAutoCreateWithTagsTest() throws Exception {
952977
initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD);
@@ -1134,7 +1159,7 @@ void uploadBomAutoCreateTestWithParentTest() throws Exception {
11341159
String parentUUID = parent.getUuid().toString();
11351160

11361161
// Upload first child, search parent by UUID
1137-
request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, parentUUID, null, null, false, bomString);
1162+
request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, parentUUID, null, null, false, null, bomString);
11381163
response = jersey.target(V1_BOM).request()
11391164
.header(X_API_KEY, apiKey)
11401165
.put(Entity.entity(request, MediaType.APPLICATION_JSON));
@@ -1150,7 +1175,7 @@ void uploadBomAutoCreateTestWithParentTest() throws Exception {
11501175

11511176

11521177
// Upload second child, search parent by name+ver
1153-
request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Acme Parent", "1.0", false, bomString);
1178+
request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Acme Parent", "1.0", false, null, bomString);
11541179
response = jersey.target(V1_BOM).request()
11551180
.header(X_API_KEY, apiKey)
11561181
.put(Entity.entity(request, MediaType.APPLICATION_JSON));
@@ -1165,7 +1190,7 @@ void uploadBomAutoCreateTestWithParentTest() throws Exception {
11651190
Assertions.assertEquals(parentUUID, child.getParent().getUuid().toString());
11661191

11671192
// Upload third child, specify parent's UUID, name, ver. Name and ver are ignored when UUID is specified.
1168-
request = new BomSubmitRequest(null, "Acme Example", "3.0", null, true, parentUUID, "Non-existent parent", "1.0", false, bomString);
1193+
request = new BomSubmitRequest(null, "Acme Example", "3.0", null, true, parentUUID, "Non-existent parent", "1.0", false, null, bomString);
11691194
response = jersey.target(V1_BOM).request()
11701195
.header(X_API_KEY, apiKey)
11711196
.put(Entity.entity(request, MediaType.APPLICATION_JSON));
@@ -1184,15 +1209,15 @@ void uploadBomAutoCreateTestWithParentTest() throws Exception {
11841209
void uploadBomInvalidParentTest() throws Exception {
11851210
initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD);
11861211
String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml"));
1187-
BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, UUID.randomUUID().toString(), null, null, false, bomString);
1212+
BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, UUID.randomUUID().toString(), null, null, false, null, bomString);
11881213
Response response = jersey.target(V1_BOM).request()
11891214
.header(X_API_KEY, apiKey)
11901215
.put(Entity.entity(request, MediaType.APPLICATION_JSON));
11911216
Assertions.assertEquals(404, response.getStatus(), 0);
11921217
String body = getPlainTextBody(response);
11931218
Assertions.assertEquals("The parent component could not be found.", body);
11941219

1195-
request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Non-existent parent", null, false, bomString);
1220+
request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Non-existent parent", null, false, null, bomString);
11961221
response = jersey.target(V1_BOM).request()
11971222
.header(X_API_KEY, apiKey)
11981223
.put(Entity.entity(request, MediaType.APPLICATION_JSON));

0 commit comments

Comments
 (0)