From fb05a4879041da357bc03fa1ef4c4466d6ee8043 Mon Sep 17 00:00:00 2001 From: Sahiba Mittal Date: Wed, 9 Oct 2024 11:01:36 +0100 Subject: [PATCH 1/2] Support assigning of teams for portfolio ACL when creating a project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Thomas Schauer-Köckeis <75749982+Gepardgame@users.noreply.github.com> --- .../model/ConfigPropertyConstants.java | 2 +- .../org/dependencytrack/model/Project.java | 5 +- .../resources/v1/ProjectResource.java | 51 ++++++- .../resources/v1/ProjectResourceTest.java | 125 ++++++++++++++++++ .../resources/v1/TeamResourceTest.java | 71 ++++++++-- 5 files changed, 235 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java index 3286f6745..4a2ec0692 100644 --- a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java +++ b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java @@ -89,7 +89,7 @@ public enum ConfigPropertyConstants { KENNA_SYNC_CADENCE("integrations", "kenna.sync.cadence", "60", PropertyType.INTEGER, "The cadence (in minutes) to upload to Kenna Security", ConfigPropertyAccessMode.READ_WRITE), KENNA_TOKEN("integrations", "kenna.token", null, PropertyType.ENCRYPTEDSTRING, "The token to use when authenticating to Kenna Security", ConfigPropertyAccessMode.READ_WRITE), KENNA_CONNECTOR_ID("integrations", "kenna.connector.id", null, PropertyType.STRING, "The Kenna Security connector identifier to upload to", ConfigPropertyAccessMode.READ_WRITE), - ACCESS_MANAGEMENT_ACL_ENABLED("access-management", "acl.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable access control to projects in the portfolio", ConfigPropertyAccessMode.READ_WRITE), + ACCESS_MANAGEMENT_ACL_ENABLED("access-management", "acl.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable access control to projects in the portfolio", ConfigPropertyAccessMode.READ_WRITE, true), NOTIFICATION_TEMPLATE_BASE_DIR("notification", "template.baseDir", SystemUtils.getEnvironmentVariable("DEFAULT_TEMPLATES_OVERRIDE_BASE_DIRECTORY", System.getProperty("user.home")), PropertyType.STRING, "The base directory to use when searching for notification templates", ConfigPropertyAccessMode.READ_WRITE), NOTIFICATION_TEMPLATE_DEFAULT_OVERRIDE_ENABLED("notification", "template.default.override.enabled", SystemUtils.getEnvironmentVariable("DEFAULT_TEMPLATES_OVERRIDE_ENABLED", "false"), PropertyType.BOOLEAN, "Flag to enable/disable override of default notification templates", ConfigPropertyAccessMode.READ_WRITE), TASK_SCHEDULER_LDAP_SYNC_CADENCE("task-scheduler", "ldap.sync.cadence", "6", PropertyType.INTEGER, "Sync cadence (in hours) for LDAP", ConfigPropertyAccessMode.READ_WRITE), diff --git a/src/main/java/org/dependencytrack/model/Project.java b/src/main/java/org/dependencytrack/model/Project.java index 5e5a33765..504be45bf 100644 --- a/src/main/java/org/dependencytrack/model/Project.java +++ b/src/main/java/org/dependencytrack/model/Project.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; @@ -37,7 +38,6 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; - import org.dependencytrack.persistence.converter.OrganizationalContactsJsonConverter; import org.dependencytrack.persistence.converter.OrganizationalEntityJsonConverter; import org.dependencytrack.resources.v1.serializers.CustomPackageURLSerializer; @@ -297,7 +297,6 @@ public enum FetchGroup { @Join(column = "PROJECT_ID") @Element(column = "TEAM_ID") @Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC")) - @JsonIgnore private List accessTeams; @Persistent(defaultFetchGroup = "true") @@ -548,10 +547,12 @@ public void setVersions(List versions) { this.versions = versions; } + @JsonIgnore public List getAccessTeams() { return accessTeams; } + @JsonSetter public void setAccessTeams(List accessTeams) { this.accessTeams = accessTeams; } diff --git a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java index 4e9779a81..0939715ec 100644 --- a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java @@ -20,7 +20,9 @@ import alpine.common.logging.Logger; import alpine.event.framework.Event; +import alpine.model.ApiKey; import alpine.model.Team; +import alpine.model.UserPrincipal; import alpine.persistence.PaginatedResult; import alpine.server.auth.PermissionRequired; import alpine.server.resources.AlpineResource; @@ -54,6 +56,7 @@ import org.dependencytrack.auth.Permissions; import org.dependencytrack.event.CloneProjectEvent; import org.dependencytrack.model.Classifier; +import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Project; import org.dependencytrack.model.Tag; import org.dependencytrack.model.WorkflowState; @@ -70,6 +73,7 @@ import javax.jdo.FetchGroup; import java.security.Principal; +import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.List; @@ -378,11 +382,13 @@ public Response getProjectsByClassifier( content = @Content(schema = @Schema(implementation = Project.class)) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "You don't have the permission to assign this team to a project."), @ApiResponse(responseCode = "409", description = """
  • An inactive Parent cannot be selected as parent, or
  • A project with the specified name already exists
"""), + @ApiResponse(responseCode = "422", description = "You need to specify at least one team to which the project should belong"), }) @PermissionRequired({Permissions.Constants.PORTFOLIO_MANAGEMENT, Permissions.Constants.PORTFOLIO_MANAGEMENT_CREATE}) public Response createProject(Project jsonProject) { @@ -397,7 +403,8 @@ public Response createProject(Project jsonProject) { validator.validateProperty(jsonProject, "classifier"), validator.validateProperty(jsonProject, "cpe"), validator.validateProperty(jsonProject, "purl"), - validator.validateProperty(jsonProject, "swidTagId") + validator.validateProperty(jsonProject, "swidTagId"), + validator.validateProperty(jsonProject, "accessTeams") ); if (jsonProject.getClassifier() == null) { jsonProject.setClassifier(Classifier.APPLICATION); @@ -408,6 +415,46 @@ public Response createProject(Project jsonProject) { Project parent = qm.getObjectByUuid(Project.class, jsonProject.getParent().getUuid()); jsonProject.setParent(parent); } + final List chosenTeams = jsonProject.getAccessTeams() == null ? new ArrayList<>() + : jsonProject.getAccessTeams(); + boolean required = qm.isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED); + if (required && chosenTeams.isEmpty()) { + throw new ClientErrorException(Response + .status(422) + .entity("You need to specify at least one team to which the project should belong") + .build()); + } + Principal principal = getPrincipal(); + if (!chosenTeams.isEmpty()) { + List userTeams = new ArrayList<>(); + if (principal instanceof final UserPrincipal userPrincipal) { + userTeams = userPrincipal.getTeams(); + } else if (principal instanceof final ApiKey apiKey) { + userTeams = apiKey.getTeams(); + } + boolean isAdmin = qm.hasAccessManagementPermission(principal); + List visibleTeams = isAdmin ? qm.getTeams() : userTeams; + List visibleUuids = visibleTeams.isEmpty() ? new ArrayList<>() + : visibleTeams.stream().map(Team::getUuid).toList(); + jsonProject.setAccessTeams(new ArrayList<>()); + for (Team choosenTeam : chosenTeams) { + if (!visibleUuids.contains(choosenTeam.getUuid())) { + if (isAdmin) { + throw new ClientErrorException(Response + .status(Response.Status.NOT_FOUND) + .entity("This team does not exist!") + .build()); + } else { + throw new ClientErrorException(Response + .status(Response.Status.FORBIDDEN) + .entity("You don't have the permission to assign this team to a project.") + .build()); + } + } + Team ormTeam = qm.getObjectByUuid(Team.class, choosenTeam.getUuid()); + jsonProject.addAccessTeam(ormTeam); + } + } final Project project; try { @@ -429,8 +476,6 @@ public Response createProject(Project jsonProject) { LOGGER.error("Failed to create project %s".formatted(jsonProject), e); throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR); } - - Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); return project; }); diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index 6f57aaa49..a5b05e08e 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -21,6 +21,10 @@ import alpine.common.util.UuidUtil; import alpine.event.framework.EventService; import alpine.model.IConfigProperty.PropertyType; +import alpine.model.ManagedUser; +import alpine.model.Permission; +import alpine.model.Team; +import alpine.server.auth.JsonWebToken; import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFilter; import jakarta.json.Json; @@ -44,6 +48,7 @@ import org.dependencytrack.model.Classifier; import org.dependencytrack.model.Component; import org.dependencytrack.model.ComponentIdentity; +import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.ExternalReference; import org.dependencytrack.model.OrganizationalContact; import org.dependencytrack.model.OrganizationalEntity; @@ -57,6 +62,7 @@ import org.dependencytrack.model.WorkflowStatus; import org.dependencytrack.model.WorkflowStep; import org.dependencytrack.notification.NotificationConstants; +import org.dependencytrack.persistence.DefaultObjectGenerator; import org.dependencytrack.persistence.jdbi.VulnerabilityPolicyDao; import org.dependencytrack.policy.vulnerability.VulnerabilityPolicy; import org.dependencytrack.policy.vulnerability.VulnerabilityPolicyAnalysis; @@ -102,6 +108,9 @@ public class ProjectResourceTest extends ResourceTest { + private ManagedUser testUser; + private String jwt; + @ClassRule public static JerseyTestRule jersey = new JerseyTestRule( new ResourceConfig(ProjectResource.class) @@ -115,6 +124,38 @@ public void after() { super.after(); } + public JsonObjectBuilder setUpEnvironment(boolean isAdmin, boolean isRequired, String name, Team team1) { + testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); + jwt = new JsonWebToken().createToken(testUser); + qm.addUserToTeam(testUser, team); + final var generator = new DefaultObjectGenerator(); + generator.loadDefaultPermissions(); + List permissionsList = new ArrayList<>(); + final Permission permission = qm.getPermission("PORTFOLIO_MANAGEMENT"); + permissionsList.add(permission); + testUser.setPermissions(permissionsList); + if (isAdmin) { + final Permission adminPermission = qm.getPermission("ACCESS_MANAGEMENT"); + permissionsList.add(adminPermission); + testUser.setPermissions(permissionsList); + } + if (isRequired) { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null); + } + final JsonObjectBuilder jsonProject = Json.createObjectBuilder() + .add("name", name).add("classifier", "CONTAINER").addNull("parent").add("active", true).add("tags", Json.createArrayBuilder()); + if (team1 != null) { + final JsonObject jsonTeam = Json.createObjectBuilder().add("uuid", team1.getUuid().toString()).build(); + jsonProject.add("accessTeams", Json.createArrayBuilder().add(jsonTeam).build()); + } + return jsonProject; + } + @Test public void getProjectsDefaultRequestTest() { for (int i = 0; i < 1000; i++) { @@ -2582,4 +2623,88 @@ public void issue3883RegressionTest() { } """); } + + @Test + public void createProjectWithExistingTeamRequiredTest() { + Team AllowedTeam = qm.createTeam("AllowedTeam", false); + final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithExistingTeamRequired", AllowedTeam); + qm.addUserToTeam(testUser, AllowedTeam); + Response response = jersey.target(V1_PROJECT) + .request() + .header("Authorization", "Bearer " + jwt) + .put(Entity.json(requestBodyBuilder.build().toString())); + Assert.assertEquals(201, response.getStatus()); + JsonObject returnedProject = parseJsonObject(response); + } + + @Test + public void createProjectWithoutExistingTeamRequiredTest() { + final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithoutExistingTeamRequired", null); + Response response = jersey.target(V1_PROJECT) + .request() + .header("Authorization", "Bearer " + jwt) + .put(Entity.json(requestBodyBuilder.build().toString())); + Assert.assertEquals(422, response.getStatus(), 0); + } + + @Test + public void createProjectWithNotAllowedExistingTeamTest() { + Team notAllowedTeam = qm.createTeam("NotAllowedTeam", false); + final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithNotAllowedExistingTeam", notAllowedTeam); + Response response = jersey.target(V1_PROJECT) + .request() + .header("Authorization", "Bearer " + jwt) + .put(Entity.json(requestBodyBuilder.build().toString())); + Assert.assertEquals(403, response.getStatus()); + } + + @Test + public void createProjectWithNotAllowedExistingTeamAdminTest() { + Team AllowedTeam = qm.createTeam("NotAllowedTeam", false); + final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithNotAllowedExistingTeam", AllowedTeam); + qm.addUserToTeam(testUser, AllowedTeam); + Response response = jersey.target(V1_PROJECT) + .request() + .header("Authorization", "Bearer " + jwt) + .put(Entity.json(requestBodyBuilder.build().toString())); + Assert.assertEquals(201, response.getStatus()); + JsonObject returnedProject = parseJsonObject(response); + } + + @Test + public void createProjectWithNotExistingTeamNoAdminTest() { + Team notAllowedTeam = new Team(); + notAllowedTeam.setUuid(new UUID(1, 1)); + notAllowedTeam.setName("NotAllowedTeam"); + final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithNotAllowedExistingTeam", notAllowedTeam); + Response response = jersey.target(V1_PROJECT) + .request() + .header("Authorization", "Bearer " + jwt) + .put(Entity.json(requestBodyBuilder.build().toString())); + Assert.assertEquals(403, response.getStatus()); + } + + @Test + public void createProjectWithNotExistingTeamTest() { + Team notAllowedTeam = new Team(); + notAllowedTeam.setUuid(new UUID(1, 1)); + notAllowedTeam.setName("NotAllowedTeam"); + final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(true, true, "ProjectWithNotAllowedExistingTeam", notAllowedTeam); + Response response = jersey.target(V1_PROJECT) + .request() + .header("Authorization", "Bearer " + jwt) + .put(Entity.json(requestBodyBuilder.build().toString())); + Assert.assertEquals(404, response.getStatus()); + } + + @Test + public void createProjectWithApiKeyTest() { + final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithNotAllowedExistingTeam", team); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.json(requestBodyBuilder.build().toString())); + Assert.assertEquals(201, response.getStatus()); + JsonObject returnedProject = parseJsonObject(response); + } } diff --git a/src/test/java/org/dependencytrack/resources/v1/TeamResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TeamResourceTest.java index 2e02bae4a..409f4ffbe 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TeamResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TeamResourceTest.java @@ -54,12 +54,30 @@ public class TeamResourceTest extends ResourceTest { + private String jwt; + private Team userNotPartof; + @ClassRule public static JerseyTestRule jersey = new JerseyTestRule( new ResourceConfig(TeamResource.class) .register(ApiFilter.class) .register(AuthenticationFilter.class)); + public void setUpUser(boolean isAdmin) { + ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); + jwt = new JsonWebToken().createToken(testUser); + qm.addUserToTeam(testUser, team); + userNotPartof = qm.createTeam("UserNotPartof", false); + if (isAdmin) { + final var generator = new DefaultObjectGenerator(); + generator.loadDefaultPermissions(); + List permissionsList = new ArrayList<>(); + final Permission adminPermission = qm.getPermission("ACCESS_MANAGEMENT"); + permissionsList.add(adminPermission); + testUser.setPermissions(permissionsList); + } + } + @Test public void getTeamsTest() { for (int i = 0; i < 1000; i++) { @@ -332,36 +350,63 @@ public void getVisibleNonApiKeyTeams() { } @Test - public void getVisibleNotAdminApiKeyTeams() { - qm.createTeam("foo", true); + public void getVisibleAdminApiKeyTeams() { + userNotPartof = qm.createTeam("UserNotPartof", false); + final var generator = new DefaultObjectGenerator(); + generator.loadDefaultPermissions(); + List permissionsList = new ArrayList<>(); + final Permission adminPermission = qm.getPermission("ACCESS_MANAGEMENT"); + permissionsList.add(adminPermission); + this.team.setPermissions(permissionsList); + Response response = jersey.target(V1_TEAM + "/visible") .request() .header(X_API_KEY, apiKey) .get(); Assert.assertEquals(200, response.getStatus(), 0); JsonArray teams = parseJsonArray(response); - Assert.assertEquals(1, teams.size()); + Assert.assertEquals(2, teams.size()); Assert.assertEquals(this.team.getUuid().toString(), teams.getFirst().asJsonObject().getString("uuid")); + Assert.assertEquals(userNotPartof.getUuid().toString(), teams.get(1).asJsonObject().getString("uuid")); } @Test - public void getVisibleAdminApiKeyTeams() { - var user = qm.createTeam("user", true); - final DefaultObjectGenerator generator = new DefaultObjectGenerator(); - generator.loadDefaultPermissions(); - List permissionsList = new ArrayList<>(); - final Permission adminPermission = qm.getPermission(Permissions.ACCESS_MANAGEMENT.name()); - permissionsList.add(adminPermission); - this.team.setPermissions(permissionsList); + public void getVisibleAdminTeams() { + setUpUser(true); + Response response = jersey.target(V1_TEAM + "/visible") + .request() + .header("Authorization", "Bearer " + jwt) + .get(); + Assert.assertEquals(200, response.getStatus(), 0); + JsonArray teams = parseJsonArray(response); + Assert.assertEquals(2, teams.size()); + Assert.assertEquals(this.team.getUuid().toString(), teams.getFirst().asJsonObject().getString("uuid")); + Assert.assertEquals(userNotPartof.getUuid().toString(), teams.get(1).asJsonObject().getString("uuid")); + } + + @Test + public void getVisibleNotAdminTeams() { + setUpUser(false); + Response response = jersey.target(V1_TEAM + "/visible") + .request() + .header("Authorization", "Bearer " + jwt) + .get(); + Assert.assertEquals(200, response.getStatus(), 0); + JsonArray teams = parseJsonArray(response); + Assert.assertEquals(1, teams.size()); + Assert.assertEquals(this.team.getUuid().toString(), teams.getFirst().asJsonObject().getString("uuid")); + } + @Test + public void getVisibleNotAdminApiKeyTeams() { Response response = jersey.target(V1_TEAM + "/visible") .request() .header(X_API_KEY, apiKey) .get(); Assert.assertEquals(200, response.getStatus(), 0); JsonArray teams = parseJsonArray(response); - Assert.assertEquals(2, teams.size()); + Assert.assertEquals(1, teams.size()); Assert.assertEquals(this.team.getUuid().toString(), teams.getFirst().asJsonObject().getString("uuid")); - Assert.assertEquals(user.getUuid().toString(), teams.get(1).asJsonObject().getString("uuid")); } + } From 7a6f92189534d2d5ad07a25fae791272be3d0770 Mon Sep 17 00:00:00 2001 From: Sahiba Mittal Date: Wed, 9 Oct 2024 11:22:16 +0100 Subject: [PATCH 2/2] Fix breaking change in PUT /api/v1/project endpoint Co-Authored-By: Niklas --- .../resources/v1/ProjectResource.java | 96 +++-- .../dependencytrack/util/PersistenceUtil.java | 2 +- .../resources/v1/ProjectResourceTest.java | 384 +++++++++++++----- 3 files changed, 355 insertions(+), 127 deletions(-) diff --git a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java index 0939715ec..c66745698 100644 --- a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java @@ -56,7 +56,6 @@ import org.dependencytrack.auth.Permissions; import org.dependencytrack.event.CloneProjectEvent; import org.dependencytrack.model.Classifier; -import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Project; import org.dependencytrack.model.Tag; import org.dependencytrack.model.WorkflowState; @@ -73,9 +72,9 @@ import javax.jdo.FetchGroup; import java.security.Principal; -import java.util.ArrayList; import java.util.Collection; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -84,7 +83,9 @@ import java.util.function.Function; import static alpine.event.framework.Event.isEventBeingProcessed; +import static java.util.Objects.requireNonNullElseGet; import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; +import static org.dependencytrack.util.PersistenceUtil.isPersistent; import static org.dependencytrack.util.PersistenceUtil.isUniqueConstraintViolation; /** @@ -373,6 +374,13 @@ public Response getProjectsByClassifier( summary = "Creates a new project", description = """

If a parent project exists, parent.uuid is required

+

+ When portfolio access control is enabled, one or more teams to grant access + to can be provided via accessTeams. 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. +

Requires permission PORTFOLIO_MANAGEMENT or PORTFOLIO_MANAGEMENT_CREATE

""" ) @ApiResponses(value = { @@ -381,17 +389,16 @@ public Response getProjectsByClassifier( description = "The created project", content = @Content(schema = @Schema(implementation = Project.class)) ), + @ApiResponse(responseCode = "400", description = "Bad Request"), @ApiResponse(responseCode = "401", description = "Unauthorized"), - @ApiResponse(responseCode = "403", description = "You don't have the permission to assign this team to a project."), @ApiResponse(responseCode = "409", description = """
  • An inactive Parent cannot be selected as parent, or
  • A project with the specified name already exists
  • -
"""), - @ApiResponse(responseCode = "422", description = "You need to specify at least one team to which the project should belong"), + """) }) @PermissionRequired({Permissions.Constants.PORTFOLIO_MANAGEMENT, Permissions.Constants.PORTFOLIO_MANAGEMENT_CREATE}) - public Response createProject(Project jsonProject) { + public Response createProject(final Project jsonProject) { final Validator validator = super.getValidator(); failOnValidationError( validator.validateProperty(jsonProject, "authors"), @@ -415,44 +422,65 @@ public Response createProject(Project jsonProject) { Project parent = qm.getObjectByUuid(Project.class, jsonProject.getParent().getUuid()); jsonProject.setParent(parent); } - final List chosenTeams = jsonProject.getAccessTeams() == null ? new ArrayList<>() - : jsonProject.getAccessTeams(); - boolean required = qm.isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED); - if (required && chosenTeams.isEmpty()) { - throw new ClientErrorException(Response - .status(422) - .entity("You need to specify at least one team to which the project should belong") - .build()); - } + Principal principal = getPrincipal(); + + final List chosenTeams = requireNonNullElseGet( + jsonProject.getAccessTeams(), Collections::emptyList); + jsonProject.setAccessTeams(null); + + 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()); + } + } + if (!chosenTeams.isEmpty()) { - List userTeams = new ArrayList<>(); + 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(); } + boolean isAdmin = qm.hasAccessManagementPermission(principal); List visibleTeams = isAdmin ? qm.getTeams() : userTeams; - List visibleUuids = visibleTeams.isEmpty() ? new ArrayList<>() - : visibleTeams.stream().map(Team::getUuid).toList(); - jsonProject.setAccessTeams(new ArrayList<>()); - for (Team choosenTeam : chosenTeams) { - if (!visibleUuids.contains(choosenTeam.getUuid())) { - if (isAdmin) { - throw new ClientErrorException(Response - .status(Response.Status.NOT_FOUND) - .entity("This team does not exist!") - .build()); - } else { - throw new ClientErrorException(Response - .status(Response.Status.FORBIDDEN) - .entity("You don't have the permission to assign this team to a project.") - .build()); - } + 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 (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)) { + // Teams sourced from the principal will not be in persistent state + // and need to be attached to the persistence context. + visibleTeam = qm.getObjectById(Team.class, visibleTeam.getId()); } - Team ormTeam = qm.getObjectByUuid(Team.class, choosenTeam.getUuid()); - jsonProject.addAccessTeam(ormTeam); + jsonProject.addAccessTeam(visibleTeam); } } diff --git a/src/main/java/org/dependencytrack/util/PersistenceUtil.java b/src/main/java/org/dependencytrack/util/PersistenceUtil.java index 580d8ef73..67d2dba38 100644 --- a/src/main/java/org/dependencytrack/util/PersistenceUtil.java +++ b/src/main/java/org/dependencytrack/util/PersistenceUtil.java @@ -159,7 +159,7 @@ public static void assertNonPersistent(final Object object, final String message } } - private static boolean isPersistent(final Object object) { + public static boolean isPersistent(final Object object) { final ObjectState objectState = JDOHelper.getObjectState(object); return objectState == PERSISTENT_CLEAN || objectState == PERSISTENT_DIRTY diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index a5b05e08e..afcc702bf 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -22,7 +22,6 @@ import alpine.event.framework.EventService; import alpine.model.IConfigProperty.PropertyType; import alpine.model.ManagedUser; -import alpine.model.Permission; import alpine.model.Team; import alpine.server.auth.JsonWebToken; import alpine.server.filters.ApiFilter; @@ -38,6 +37,7 @@ import org.datanucleus.store.types.wrappers.Date; import org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; +import org.dependencytrack.auth.Permissions; import org.dependencytrack.event.CloneProjectEvent; import org.dependencytrack.event.kafka.KafkaTopics; import org.dependencytrack.model.Analysis; @@ -62,7 +62,6 @@ import org.dependencytrack.model.WorkflowStatus; import org.dependencytrack.model.WorkflowStep; import org.dependencytrack.notification.NotificationConstants; -import org.dependencytrack.persistence.DefaultObjectGenerator; import org.dependencytrack.persistence.jdbi.VulnerabilityPolicyDao; import org.dependencytrack.policy.vulnerability.VulnerabilityPolicy; import org.dependencytrack.policy.vulnerability.VulnerabilityPolicyAnalysis; @@ -108,9 +107,6 @@ public class ProjectResourceTest extends ResourceTest { - private ManagedUser testUser; - private String jwt; - @ClassRule public static JerseyTestRule jersey = new JerseyTestRule( new ResourceConfig(ProjectResource.class) @@ -124,38 +120,6 @@ public void after() { super.after(); } - public JsonObjectBuilder setUpEnvironment(boolean isAdmin, boolean isRequired, String name, Team team1) { - testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); - jwt = new JsonWebToken().createToken(testUser); - qm.addUserToTeam(testUser, team); - final var generator = new DefaultObjectGenerator(); - generator.loadDefaultPermissions(); - List permissionsList = new ArrayList<>(); - final Permission permission = qm.getPermission("PORTFOLIO_MANAGEMENT"); - permissionsList.add(permission); - testUser.setPermissions(permissionsList); - if (isAdmin) { - final Permission adminPermission = qm.getPermission("ACCESS_MANAGEMENT"); - permissionsList.add(adminPermission); - testUser.setPermissions(permissionsList); - } - if (isRequired) { - qm.createConfigProperty( - ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - null); - } - final JsonObjectBuilder jsonProject = Json.createObjectBuilder() - .add("name", name).add("classifier", "CONTAINER").addNull("parent").add("active", true).add("tags", Json.createArrayBuilder()); - if (team1 != null) { - final JsonObject jsonTeam = Json.createObjectBuilder().add("uuid", team1.getUuid().toString()).build(); - jsonProject.add("accessTeams", Json.createArrayBuilder().add(jsonTeam).build()); - } - return jsonProject; - } - @Test public void getProjectsDefaultRequestTest() { for (int i = 0; i < 1000; i++) { @@ -2625,86 +2589,322 @@ public void issue3883RegressionTest() { } @Test - public void createProjectWithExistingTeamRequiredTest() { - Team AllowedTeam = qm.createTeam("AllowedTeam", false); - final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithExistingTeamRequired", AllowedTeam); - qm.addUserToTeam(testUser, AllowedTeam); - Response response = jersey.target(V1_PROJECT) + public void createProjectAsUserWithAclEnabledAndExistingTeamByUuidTest() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getDescription()); + + final ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); + qm.addUserToTeam(testUser, team); + + final String userJwt = new JsonWebToken().createToken(testUser); + + final Response response = jersey.target(V1_PROJECT) .request() - .header("Authorization", "Bearer " + jwt) - .put(Entity.json(requestBodyBuilder.build().toString())); - Assert.assertEquals(201, response.getStatus()); - JsonObject returnedProject = parseJsonObject(response); + .header("Authorization", "Bearer " + userJwt) + .put(Entity.json(/* language=JSON */ """ + { + "name": "acme-app", + "accessTeams": [ + { + "uuid": "%s" + } + ] + } + """.formatted(team.getUuid()))); + assertThat(response.getStatus()).isEqualTo(201); + assertThatJson(getPlainTextBody(response)) + .isEqualTo(/* language=JSON */ """ + { + "uuid": "${json-unit.any-string}", + "name": "acme-app", + "classifier": "APPLICATION", + "children": [], + "properties": [], + "tags": [], + "active": true + } + """); + + assertThat(qm.getAllProjects()).satisfiesExactly(project -> + assertThat(project.getAccessTeams()).extracting(Team::getName).containsOnly(team.getName())); } @Test - public void createProjectWithoutExistingTeamRequiredTest() { - final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithoutExistingTeamRequired", null); - Response response = jersey.target(V1_PROJECT) + public void createProjectAsUserWithAclEnabledAndExistingTeamByNameTest() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getDescription()); + + final ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); + qm.addUserToTeam(testUser, team); + + final String userJwt = new JsonWebToken().createToken(testUser); + + final Response response = jersey.target(V1_PROJECT) .request() - .header("Authorization", "Bearer " + jwt) - .put(Entity.json(requestBodyBuilder.build().toString())); - Assert.assertEquals(422, response.getStatus(), 0); + .header("Authorization", "Bearer " + userJwt) + .put(Entity.json(/* language=JSON */ """ + { + "name": "acme-app", + "accessTeams": [ + { + "name": "%s" + } + ] + } + """.formatted(team.getName()))); + assertThat(response.getStatus()).isEqualTo(201); + assertThatJson(getPlainTextBody(response)) + .isEqualTo(/* language=JSON */ """ + { + "uuid": "${json-unit.any-string}", + "name": "acme-app", + "classifier": "APPLICATION", + "children": [], + "properties": [], + "tags": [], + "active": true + } + """); + + assertThat(qm.getAllProjects()).satisfiesExactly(project -> + assertThat(project.getAccessTeams()).extracting(Team::getName).containsOnly(team.getName())); } @Test - public void createProjectWithNotAllowedExistingTeamTest() { - Team notAllowedTeam = qm.createTeam("NotAllowedTeam", false); - final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithNotAllowedExistingTeam", notAllowedTeam); - Response response = jersey.target(V1_PROJECT) + public void createProjectAsUserWithAclEnabledAndWithoutTeamTest() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getDescription()); + + final ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); + qm.addUserToTeam(testUser, team); + + final String userJwt = new JsonWebToken().createToken(testUser); + + final Response response = jersey.target(V1_PROJECT) .request() - .header("Authorization", "Bearer " + jwt) - .put(Entity.json(requestBodyBuilder.build().toString())); - Assert.assertEquals(403, response.getStatus()); + .header("Authorization", "Bearer " + userJwt) + .put(Entity.json(/* language=JSON */ """ + { + "name": "acme-app" + } + """)); + assertThat(response.getStatus()).isEqualTo(201); + assertThatJson(getPlainTextBody(response)) + .isEqualTo(/* language=JSON */ """ + { + "uuid": "${json-unit.any-string}", + "name": "acme-app", + "classifier": "APPLICATION", + "children": [], + "properties": [], + "tags": [], + "active": true + } + """); + + assertThat(qm.getAllProjects()).satisfiesExactly(project -> + assertThat(project.getAccessTeams()).isEmpty()); } @Test - public void createProjectWithNotAllowedExistingTeamAdminTest() { - Team AllowedTeam = qm.createTeam("NotAllowedTeam", false); - final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithNotAllowedExistingTeam", AllowedTeam); - qm.addUserToTeam(testUser, AllowedTeam); - Response response = jersey.target(V1_PROJECT) + public void createProjectAsUserWithNotAllowedExistingTeamTest() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getDescription()); + + final ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); + + final String userJwt = new JsonWebToken().createToken(testUser); + + final Response response = jersey.target(V1_PROJECT) .request() - .header("Authorization", "Bearer " + jwt) - .put(Entity.json(requestBodyBuilder.build().toString())); - Assert.assertEquals(201, response.getStatus()); - JsonObject returnedProject = parseJsonObject(response); + .header("Authorization", "Bearer " + userJwt) + .put(Entity.json(/* language=JSON */ """ + { + "name": "acme-app", + "accessTeams": [ + { + "uuid": "%s" + } + ] + } + """.formatted(team.getUuid()))); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).isEqualTo(""" + The team with UUID %s can not be assigned because it does not exist, \ + or is not accessible to the authenticated principal.""", team.getUuid()); } @Test - public void createProjectWithNotExistingTeamNoAdminTest() { - Team notAllowedTeam = new Team(); - notAllowedTeam.setUuid(new UUID(1, 1)); - notAllowedTeam.setName("NotAllowedTeam"); - final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithNotAllowedExistingTeam", notAllowedTeam); - Response response = jersey.target(V1_PROJECT) + public void createProjectAsUserWithAclEnabledAndNotMemberOfTeamAdminTest() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getDescription()); + + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); + qm.addUserToTeam(testUser, team); + + final String userJwt = new JsonWebToken().createToken(testUser); + + final Team otherTeam = qm.createTeam("otherTeam", false); + + final Response response = jersey.target(V1_PROJECT) .request() - .header("Authorization", "Bearer " + jwt) - .put(Entity.json(requestBodyBuilder.build().toString())); - Assert.assertEquals(403, response.getStatus()); + .header("Authorization", "Bearer " + userJwt) + .put(Entity.json(/* language=JSON */ """ + { + "name": "acme-app", + "accessTeams": [ + { + "uuid": "%s" + } + ] + } + """.formatted(otherTeam.getUuid()))); + assertThat(response.getStatus()).isEqualTo(201); + assertThatJson(getPlainTextBody(response)) + .isEqualTo(/* language=JSON */ """ + { + "uuid": "${json-unit.any-string}", + "name": "acme-app", + "classifier": "APPLICATION", + "children": [], + "properties": [], + "tags": [], + "active": true + } + """); + + assertThat(qm.getAllProjects()).satisfiesExactly(project -> + assertThat(project.getAccessTeams()).extracting(Team::getName).containsOnly("otherTeam")); } @Test - public void createProjectWithNotExistingTeamTest() { - Team notAllowedTeam = new Team(); - notAllowedTeam.setUuid(new UUID(1, 1)); - notAllowedTeam.setName("NotAllowedTeam"); - final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(true, true, "ProjectWithNotAllowedExistingTeam", notAllowedTeam); - Response response = jersey.target(V1_PROJECT) + public void createProjectAsUserWithAclEnabledAndTeamNotExistingNoAdminTest() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getDescription()); + + final ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); + + final String userJwt = new JsonWebToken().createToken(testUser); + + final Response response = jersey.target(V1_PROJECT) + .request() + .header("Authorization", "Bearer " + userJwt) + .put(Entity.json(/* language=JSON */ """ + { + "name": "acme-app", + "accessTeams": [ + { + "uuid": "419c32eb-5a30-47d5-8a9a-fc0cda651314" + } + ] + } + """)); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).isEqualTo(""" + The team with UUID 419c32eb-5a30-47d5-8a9a-fc0cda651314 \ + can not be assigned because it does not exist, or is not \ + accessible to the authenticated principal."""); + } + + @Test + public void createProjectAsUserWithAclEnabledAndTeamNotExistingAdminTest() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getDescription()); + + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); + qm.addUserToTeam(testUser, team); + + final String userJwt = new JsonWebToken().createToken(testUser); + + final Response response = jersey.target(V1_PROJECT) .request() - .header("Authorization", "Bearer " + jwt) - .put(Entity.json(requestBodyBuilder.build().toString())); - Assert.assertEquals(404, response.getStatus()); + .header("Authorization", "Bearer " + userJwt) + .put(Entity.json(/* language=JSON */ """ + { + "name": "acme-app", + "accessTeams": [ + { + "uuid": "419c32eb-5a30-47d5-8a9a-fc0cda651314" + } + ] + } + """)); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).isEqualTo(""" + The team with UUID 419c32eb-5a30-47d5-8a9a-fc0cda651314 \ + can not be assigned because it does not exist, or is not \ + accessible to the authenticated principal."""); } @Test - public void createProjectWithApiKeyTest() { - final JsonObjectBuilder requestBodyBuilder = setUpEnvironment(false, true, "ProjectWithNotAllowedExistingTeam", team); - Response response = jersey.target(V1_PROJECT) + public void createProjectAsApiKeyWithAclEnabledAndWithExistentTeamTest() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getDescription()); + + final Response response = jersey.target(V1_PROJECT) .request() .header(X_API_KEY, apiKey) - .put(Entity.json(requestBodyBuilder.build().toString())); - Assert.assertEquals(201, response.getStatus()); - JsonObject returnedProject = parseJsonObject(response); + .put(Entity.json(/* language=JSON */ """ + { + "name": "acme-app", + "accessTeams": [ + { + "uuid": "%s" + } + ] + } + """.formatted(team.getUuid()))); + assertThat(response.getStatus()).isEqualTo(201); + assertThatJson(getPlainTextBody(response)) + .isEqualTo(/* language=JSON */ """ + { + "uuid": "${json-unit.any-string}", + "name": "acme-app", + "classifier": "APPLICATION", + "children": [], + "properties": [], + "tags": [], + "active": true + } + """); + + assertThat(qm.getAllProjects()).satisfiesExactly(project -> + assertThat(project.getAccessTeams()).extracting(Team::getName).containsOnly(team.getName())); } }