Skip to content

Commit c66cf10

Browse files
authored
Merge pull request #1093 from DependencyTrack/issue-1713
Prevent duplicates in `PROJECT_ACCESS_TEAMS` table
2 parents fb1f479 + e448364 commit c66cf10

File tree

12 files changed

+379
-98
lines changed

12 files changed

+379
-98
lines changed

src/main/java/org/dependencytrack/model/Project.java

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,14 @@
3535
import com.github.packageurl.MalformedPackageURLException;
3636
import com.github.packageurl.PackageURL;
3737
import io.swagger.v3.oas.annotations.media.Schema;
38-
import jakarta.validation.constraints.NotBlank;
39-
import jakarta.validation.constraints.NotNull;
40-
import jakarta.validation.constraints.Pattern;
41-
import jakarta.validation.constraints.Size;
4238
import org.dependencytrack.persistence.converter.OrganizationalContactsJsonConverter;
4339
import org.dependencytrack.persistence.converter.OrganizationalEntityJsonConverter;
4440
import org.dependencytrack.resources.v1.serializers.CustomPackageURLSerializer;
4541

42+
import jakarta.validation.constraints.NotBlank;
43+
import jakarta.validation.constraints.NotNull;
44+
import jakarta.validation.constraints.Pattern;
45+
import jakarta.validation.constraints.Size;
4646
import javax.jdo.annotations.Column;
4747
import javax.jdo.annotations.Convert;
4848
import javax.jdo.annotations.Element;
@@ -61,10 +61,11 @@
6161
import javax.jdo.annotations.Unique;
6262
import java.io.IOException;
6363
import java.io.Serializable;
64-
import java.util.ArrayList;
6564
import java.util.Collection;
6665
import java.util.Date;
66+
import java.util.HashSet;
6767
import java.util.List;
68+
import java.util.Set;
6869
import java.util.UUID;
6970

7071
/**
@@ -298,10 +299,9 @@ public enum FetchGroup {
298299
private Date inactiveSince;
299300

300301
@Persistent(table = "PROJECT_ACCESS_TEAMS")
301-
@Join(column = "PROJECT_ID")
302+
@Join(column = "PROJECT_ID", primaryKey = "PROJECT_ACCESS_TEAMS_PK")
302303
@Element(column = "TEAM_ID")
303-
@Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC"))
304-
private List<Team> accessTeams;
304+
private Set<Team> accessTeams;
305305

306306
@Persistent(defaultFetchGroup = "true")
307307
@Column(name = "EXTERNAL_REFERENCES")
@@ -570,20 +570,28 @@ public void setVersions(List<ProjectVersion> versions) {
570570
}
571571

572572
@JsonIgnore
573-
public List<Team> getAccessTeams() {
573+
public Set<Team> getAccessTeams() {
574574
return accessTeams;
575575
}
576576

577577
@JsonSetter
578-
public void setAccessTeams(List<Team> accessTeams) {
578+
public void setAccessTeams(Set<Team> accessTeams) {
579579
this.accessTeams = accessTeams;
580580
}
581581

582-
public void addAccessTeam(Team accessTeam) {
582+
public boolean addAccessTeam(Team accessTeam) {
583583
if (this.accessTeams == null) {
584-
this.accessTeams = new ArrayList<>();
584+
this.accessTeams = new HashSet<>();
585585
}
586-
this.accessTeams.add(accessTeam);
586+
return this.accessTeams.add(accessTeam);
587+
}
588+
589+
public boolean removeAccessTeam(Team accessTeam) {
590+
if (this.accessTeams == null) {
591+
return false;
592+
}
593+
594+
return this.accessTeams.remove(accessTeam);
587595
}
588596

589597
public ProjectMetadata getMetadata() {

src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -803,9 +803,9 @@ public Project clone(
803803
}
804804

805805
if (includeACL) {
806-
List<Team> accessTeams = source.getAccessTeams();
806+
Set<Team> accessTeams = source.getAccessTeams();
807807
if (!CollectionUtils.isEmpty(accessTeams)) {
808-
project.setAccessTeams(new ArrayList<>(accessTeams));
808+
project.setAccessTeams(new HashSet<>(accessTeams));
809809
}
810810
}
811811

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

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,16 @@
3434
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
3535
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
3636
import io.swagger.v3.oas.annotations.tags.Tag;
37+
import org.dependencytrack.auth.Permissions;
38+
import org.dependencytrack.model.Project;
39+
import org.dependencytrack.model.validation.ValidUuid;
40+
import org.dependencytrack.persistence.QueryManager;
41+
import org.dependencytrack.resources.v1.openapi.PaginatedApi;
42+
import org.dependencytrack.resources.v1.problems.ProblemDetails;
43+
import org.dependencytrack.resources.v1.vo.AclMappingRequest;
44+
3745
import jakarta.validation.Validator;
46+
import jakarta.ws.rs.ClientErrorException;
3847
import jakarta.ws.rs.DELETE;
3948
import jakarta.ws.rs.GET;
4049
import jakarta.ws.rs.PUT;
@@ -44,15 +53,7 @@
4453
import jakarta.ws.rs.QueryParam;
4554
import jakarta.ws.rs.core.MediaType;
4655
import jakarta.ws.rs.core.Response;
47-
import org.dependencytrack.auth.Permissions;
48-
import org.dependencytrack.model.Project;
49-
import org.dependencytrack.model.validation.ValidUuid;
50-
import org.dependencytrack.persistence.QueryManager;
51-
import org.dependencytrack.resources.v1.openapi.PaginatedApi;
52-
import org.dependencytrack.resources.v1.vo.AclMappingRequest;
53-
54-
import java.util.ArrayList;
55-
import java.util.List;
56+
import java.util.NoSuchElementException;
5657

5758
/**
5859
* JAX-RS resources for processing LDAP group mapping requests.
@@ -114,10 +115,19 @@ public Response retrieveProjects(@Parameter(description = "The UUID of the team
114115
description = "<p>Requires permission <strong>ACCESS_MANAGEMENT</strong></p>"
115116
)
116117
@ApiResponses(value = {
117-
@ApiResponse(responseCode = "200", description = "Mapping created successfully", content = @Content(schema = @Schema(implementation = AclMappingRequest.class))),
118+
@ApiResponse(
119+
responseCode = "200",
120+
description = "Mapping created successfully",
121+
content = @Content(schema = @Schema(implementation = AclMappingRequest.class))),
118122
@ApiResponse(responseCode = "401", description = "Unauthorized"),
119-
@ApiResponse(responseCode = "404", description = "The UUID of the team or project could not be found"),
120-
@ApiResponse(responseCode = "409", description = "A mapping with the same team and project already exists")
123+
@ApiResponse(
124+
responseCode = "404",
125+
description = "Team or project could not be found",
126+
content = @Content(schema = @Schema(implementation = ProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON)),
127+
@ApiResponse(
128+
responseCode = "409",
129+
description = "A mapping with the same team and project already exists",
130+
content = @Content(schema = @Schema(implementation = ProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON))
121131
})
122132
@PermissionRequired({Permissions.Constants.ACCESS_MANAGEMENT, Permissions.Constants.ACCESS_MANAGEMENT_CREATE})
123133
public Response addMapping(AclMappingRequest request) {
@@ -126,21 +136,31 @@ public Response addMapping(AclMappingRequest request) {
126136
validator.validateProperty(request, "team"),
127137
validator.validateProperty(request, "project")
128138
);
129-
try (QueryManager qm = new QueryManager()) {
130-
final Team team = qm.getObjectByUuid(Team.class, request.getTeam());
131-
final Project project = qm.getObjectByUuid(Project.class, request.getProject());
132-
if (team != null && project != null) {
133-
for (final Team t : project.getAccessTeams()) {
134-
if (t.getUuid() == team.getUuid()) {
135-
return Response.status(Response.Status.CONFLICT).entity("A mapping with the same team and project already exists.").build();
136-
}
139+
try (final var qm = new QueryManager()) {
140+
qm.runInTransaction(() -> {
141+
final Team team = qm.getObjectByUuid(Team.class, request.getTeam());
142+
if (team == null) {
143+
throw new NoSuchElementException("Team could not be found");
137144
}
138-
project.addAccessTeam(team);
139-
qm.persist(project);
140-
return Response.ok().build();
141-
} else {
142-
return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the team could not be found.").build();
143-
}
145+
146+
final Project project = qm.getObjectByUuid(Project.class, request.getProject());
147+
if (project == null) {
148+
throw new NoSuchElementException("Project could not be found");
149+
}
150+
151+
// TODO: The conflict error is legacy behavior, but wouldn't it make more
152+
// sense to return a 304 - Not Modified instead?
153+
final boolean added = project.addAccessTeam(team);
154+
if (!added) {
155+
final var problemDetails = new ProblemDetails();
156+
problemDetails.setStatus(409);
157+
problemDetails.setTitle("Conflict");
158+
problemDetails.setDetail("A mapping with the same team and project already exists");
159+
throw new ClientErrorException(problemDetails.toResponse());
160+
}
161+
});
162+
163+
return Response.ok().build();
144164
}
145165
}
146166

@@ -154,30 +174,34 @@ public Response addMapping(AclMappingRequest request) {
154174
@ApiResponses(value = {
155175
@ApiResponse(responseCode = "200", description = "Mapping removed successfully"),
156176
@ApiResponse(responseCode = "401", description = "Unauthorized"),
157-
@ApiResponse(responseCode = "404", description = "The UUID of the team or project could not be found"),
177+
@ApiResponse(
178+
responseCode = "404",
179+
description = "Team or project could not be found",
180+
content = @Content(schema = @Schema(implementation = ProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON))
158181
})
159182
@PermissionRequired({Permissions.Constants.ACCESS_MANAGEMENT, Permissions.Constants.ACCESS_MANAGEMENT_DELETE})
160183
public Response deleteMapping(
161184
@Parameter(description = "The UUID of the team to delete the mapping for", schema = @Schema(type = "string", format = "uuid"), required = true)
162185
@PathParam("teamUuid") @ValidUuid String teamUuid,
163186
@Parameter(description = "The UUID of the project to delete the mapping for", schema = @Schema(type = "string", format = "uuid"), required = true)
164187
@PathParam("projectUuid") @ValidUuid String projectUuid) {
165-
try (QueryManager qm = new QueryManager()) {
166-
final Team team = qm.getObjectByUuid(Team.class, teamUuid);
167-
final Project project = qm.getObjectByUuid(Project.class, projectUuid);
168-
if (team != null && project != null) {
169-
final List<Team> teams = new ArrayList<>();
170-
for (final Team t : project.getAccessTeams()) {
171-
if (t.getUuid() != team.getUuid()) {
172-
teams.add(t);
173-
}
188+
try (final var qm = new QueryManager()) {
189+
qm.runInTransaction(() -> {
190+
final Team team = qm.getObjectByUuid(Team.class, teamUuid);
191+
if (team == null) {
192+
throw new NoSuchElementException("Team could not be found");
174193
}
175-
project.setAccessTeams(teams);
176-
qm.persist(project);
177-
return Response.ok().build();
178-
} else {
179-
return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the team or project could not be found.").build();
180-
}
194+
195+
final Project project = qm.getObjectByUuid(Project.class, projectUuid);
196+
if (project == null) {
197+
throw new NoSuchElementException("Project could not be found");
198+
}
199+
200+
project.removeAccessTeam(team);
201+
});
181202
}
203+
204+
return Response.ok().build();
182205
}
206+
183207
}

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -476,8 +476,8 @@ public Response createProject(final Project jsonProject) {
476476

477477
Principal principal = getPrincipal();
478478

479-
final List<Team> chosenTeams = requireNonNullElseGet(
480-
jsonProject.getAccessTeams(), Collections::emptyList);
479+
final Set<Team> chosenTeams = requireNonNullElseGet(
480+
jsonProject.getAccessTeams(), Collections::emptySet);
481481
jsonProject.setAccessTeams(null);
482482

483483
for (final Team chosenTeam : chosenTeams) {
@@ -486,8 +486,8 @@ public Response createProject(final Project jsonProject) {
486486
.status(Response.Status.BAD_REQUEST)
487487
.entity("""
488488
accessTeams must either specify a UUID or a name,\
489-
but the team at index %d has neither.\
490-
""".formatted(chosenTeams.indexOf(chosenTeam)))
489+
but the team %s has neither.\
490+
""".formatted(chosenTeam))
491491
.build());
492492
}
493493
}
@@ -555,7 +555,6 @@ public Response createProject(final Project jsonProject) {
555555
LOGGER.error("Failed to create project %s".formatted(jsonProject), e);
556556
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);
557557
}
558-
qm.updateNewProjectACL(project, principal);
559558
return project;
560559
});
561560

src/main/resources/migration/changelog-v5.6.0.xml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,4 +674,32 @@
674674
<column name="COMPONENT_ID"/>
675675
</createIndex>
676676
</changeSet>
677+
678+
<changeSet id="v5.6.0-14" author="nscuro">
679+
<dropIndex tableName="PROJECT_ACCESS_TEAMS" indexName="PROJECT_ACCESS_TEAMS_PROJECT_ID_IDX"/>
680+
<dropIndex tableName="PROJECT_ACCESS_TEAMS" indexName="PROJECT_ACCESS_TEAMS_TEAM_ID_IDX"/>
681+
<sql>
682+
WITH
683+
cte_duplicate AS (
684+
SELECT "PROJECT_ID"
685+
, "TEAM_ID"
686+
FROM "PROJECT_ACCESS_TEAMS"
687+
GROUP BY "PROJECT_ID"
688+
, "TEAM_ID"
689+
HAVING COUNT(*) > 1
690+
),
691+
cte_deleted_duplicate AS (
692+
DELETE FROM "PROJECT_ACCESS_TEAMS"
693+
USING cte_duplicate
694+
WHERE cte_duplicate."PROJECT_ID" = "PROJECT_ACCESS_TEAMS"."PROJECT_ID"
695+
AND cte_duplicate."TEAM_ID" = "PROJECT_ACCESS_TEAMS"."TEAM_ID"
696+
)
697+
INSERT INTO "PROJECT_ACCESS_TEAMS" ("PROJECT_ID", "TEAM_ID")
698+
SELECT "PROJECT_ID", "TEAM_ID" FROM cte_duplicate
699+
</sql>
700+
<addPrimaryKey
701+
tableName="PROJECT_ACCESS_TEAMS"
702+
columnNames="PROJECT_ID, TEAM_ID"
703+
constraintName="PROJECT_ACCESS_TEAMS_PK"/>
704+
</changeSet>
677705
</databaseChangeLog>

src/test/java/org/dependencytrack/ResourceTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848

4949
public abstract class ResourceTest {
5050

51+
protected final String V1_ACL = "/v1/acl";
5152
protected final String V1_ANALYSIS = "/v1/analysis";
5253
protected final String V1_BADGE = "/v1/badge";
5354
protected final String V1_BOM = "/v1/bom";

src/test/java/org/dependencytrack/persistence/datanucleus/method/ProjectIsAccessibleByMethodTest.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.Arrays;
2929
import java.util.List;
3030
import java.util.Map;
31+
import java.util.Set;
3132

3233
import static org.assertj.core.api.Assertions.assertThat;
3334
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -47,7 +48,7 @@ public void shouldEvaluateToTrueWhenProjectIsAccessible() {
4748

4849
final var project = new Project();
4950
project.setName("acme-app");
50-
project.setAccessTeams(List.of(teamA, teamB));
51+
project.setAccessTeams(Set.of(teamA, teamB));
5152
qm.persist(project);
5253

5354
final Query<Project> query = qm.getPersistenceManager().newQuery(Project.class);
@@ -67,7 +68,7 @@ public void shouldEvaluateToTrueWhenProjectParentIsAccessible() {
6768

6869
final var parentProject = new Project();
6970
parentProject.setName("acme-app-parent");
70-
parentProject.setAccessTeams(List.of(team));
71+
parentProject.setAccessTeams(Set.of(team));
7172
qm.persist(parentProject);
7273

7374
final var project = new Project();
@@ -92,7 +93,7 @@ public void shouldEvaluateToTrueWhenProjectGrandParentIsAccessible() {
9293

9394
final var grandParentProject = new Project();
9495
grandParentProject.setName("acme-app-grand-parent");
95-
grandParentProject.setAccessTeams(List.of(team));
96+
grandParentProject.setAccessTeams(Set.of(team));
9697
qm.persist(grandParentProject);
9798

9899
final var parentProject = new Project();
@@ -126,7 +127,7 @@ public void shouldEvaluateToFalseWhenProjectIsNotAccessible() {
126127

127128
final var project = new Project();
128129
project.setName("acme-app");
129-
project.setAccessTeams(List.of(teamA));
130+
project.setAccessTeams(Set.of(teamA));
130131
qm.persist(project);
131132

132133
final Query<Project> query = qm.getPersistenceManager().newQuery(Project.class);
@@ -151,7 +152,7 @@ public void shouldEvaluateToFalseWhenOnlyChildProjectIsAccessible() {
151152
final var project = new Project();
152153
project.setParent(parentProject);
153154
project.setName("acme-app");
154-
project.setAccessTeams(List.of(team));
155+
project.setAccessTeams(Set.of(team));
155156
qm.persist(project);
156157

157158
final Query<Project> query = qm.getPersistenceManager().newQuery(Project.class);
@@ -171,7 +172,7 @@ public void shouldBeAllowedOnProjectMembersOfNonProjectObjects() {
171172

172173
final var project = new Project();
173174
project.setName("acme-app");
174-
project.setAccessTeams(List.of(team));
175+
project.setAccessTeams(Set.of(team));
175176
qm.persist(project);
176177

177178
final var component = new Component();

0 commit comments

Comments
 (0)