diff --git a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java index 7515206c91..fdc9ecb4f4 100644 --- a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java +++ b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java @@ -95,7 +95,7 @@ public enum ConfigPropertyConstants { KENNA_SYNC_CADENCE("integrations", "kenna.sync.cadence", "60", PropertyType.INTEGER, "The cadence (in minutes) to upload to Kenna Security"), KENNA_TOKEN("integrations", "kenna.token", null, PropertyType.ENCRYPTEDSTRING, "The token to use when authenticating to Kenna Security"), KENNA_CONNECTOR_ID("integrations", "kenna.connector.id", null, PropertyType.STRING, "The Kenna Security connector identifier to upload to"), - ACCESS_MANAGEMENT_ACL_ENABLED("access-management", "acl.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable access control to projects in the portfolio"), + ACCESS_MANAGEMENT_ACL_ENABLED("access-management", "acl.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable access control to projects in the portfolio", 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"), 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"), TASK_SCHEDULER_LDAP_SYNC_CADENCE("task-scheduler", "ldap.sync.cadence", "6", PropertyType.INTEGER, "Sync cadence (in hours) for LDAP"), diff --git a/src/main/java/org/dependencytrack/model/Project.java b/src/main/java/org/dependencytrack/model/Project.java index 74fdef9446..01148e4e5d 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.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; @@ -273,7 +274,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") @@ -537,10 +537,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 c9cd063908..b209b331e4 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; @@ -39,6 +41,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.validation.ValidUuid; @@ -62,9 +65,11 @@ import jakarta.ws.rs.core.Response; import javax.jdo.FetchGroup; import java.security.Principal; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Set; +import java.util.UUID; import java.util.function.BiConsumer; import java.util.function.Function; @@ -279,11 +284,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) public Response createProject(Project jsonProject) { @@ -299,7 +306,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); @@ -309,7 +317,40 @@ public Response createProject(Project jsonProject) { Project parent = qm.getObjectByUuid(Project.class, jsonProject.getParent().getUuid()); jsonProject.setParent(parent); } - if (!qm.doesProjectExist(StringUtils.trimToNull(jsonProject.getName()), StringUtils.trimToNull(jsonProject.getVersion()))) { + if (!qm.doesProjectExist(StringUtils.trimToNull(jsonProject.getName()), + StringUtils.trimToNull(jsonProject.getVersion()))) { + final List chosenTeams = jsonProject.getAccessTeams() == null ? new ArrayList() + : jsonProject.getAccessTeams(); + boolean required = qm.isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED); + if (required && chosenTeams.isEmpty()) { + return 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())) { + return isAdmin ? Response.status(404).entity("This team does not exist!").build() + : Response.status(403) + .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 { project = qm.createProject(jsonProject, jsonProject.getTags(), true); @@ -317,7 +358,6 @@ public Response createProject(Project jsonProject) { LOGGER.debug(e.getMessage()); return Response.status(Response.Status.CONFLICT).entity("An inactive Parent cannot be selected as parent").build(); } - Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); LOGGER.info("Project " + project.toString() + " created by " + super.getPrincipal().getName()); return Response.status(Response.Status.CREATED).entity(project).build(); diff --git a/src/main/java/org/dependencytrack/resources/v1/TeamResource.java b/src/main/java/org/dependencytrack/resources/v1/TeamResource.java index b55b421988..469a55a258 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TeamResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TeamResource.java @@ -22,6 +22,7 @@ import alpine.common.logging.Logger; import alpine.model.ApiKey; import alpine.model.Team; +import alpine.model.UserPrincipal; import alpine.server.auth.PermissionRequired; import alpine.server.resources.AlpineResource; import io.swagger.v3.oas.annotations.Operation; @@ -52,6 +53,8 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import java.security.Principal; +import java.util.ArrayList; import java.util.List; import static org.datanucleus.PropertyNames.PROPERTY_RETAIN_VALUES; @@ -220,6 +223,33 @@ public Response deleteTeam(Team jsonTeam) { } } + @GET + @Path("/visible") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Returns a list of Teams that are visible", description = "

") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "The Visible Teams", content = @Content(array = @ArraySchema(schema = @Schema(implementation = Team.class)))), + @ApiResponse(responseCode = "401", description = "Unauthorized") + }) + public Response availableTeams() { + try (QueryManager qm = new QueryManager()) { + Principal user = getPrincipal(); + boolean isAllTeams = qm.hasAccessManagementPermission(user); + List teams = new ArrayList(); + if (isAllTeams) { + teams = qm.getTeams(); + } else { + if (user instanceof final UserPrincipal userPrincipal) { + teams = userPrincipal.getTeams(); + } else if (user instanceof final ApiKey apiKey) { + teams = apiKey.getTeams(); + } + } + + return Response.ok(teams).build(); + } + } + @PUT @Path("/{uuid}/key") @Produces(MediaType.APPLICATION_JSON) diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index acce124c10..76b3fe5ac7 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.Team; +import alpine.model.Permission; +import alpine.server.auth.JsonWebToken; import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFilter; import jakarta.json.Json; @@ -50,6 +54,7 @@ import org.dependencytrack.model.ServiceComponent; import org.dependencytrack.model.Tag; import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.persistence.DefaultObjectGenerator; import org.dependencytrack.tasks.CloneProjectTask; import org.dependencytrack.tasks.scanners.AnalyzerIdentity; import org.glassfish.jersey.client.HttpUrlConnectorProvider; @@ -75,6 +80,8 @@ import static org.hamcrest.Matchers.equalTo; public class ProjectResourceTest extends ResourceTest { + private ManagedUser testUser; + private String jwt; @ClassRule public static JerseyTestRule jersey = new JerseyTestRule( @@ -89,6 +96,38 @@ public void after() throws Exception { 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++) { @@ -478,6 +517,90 @@ public void createProjectEmptyTest() { Assert.assertEquals(400, response.getStatus(), 0); } + @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); + } + @Test public void updateProjectTest() { Project project = qm.createProject("ABC", null, "1.0", null, null, null, true, false); diff --git a/src/test/java/org/dependencytrack/resources/v1/TeamResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TeamResourceTest.java index b1662d8d36..2d55542cb2 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TeamResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TeamResourceTest.java @@ -22,6 +22,7 @@ import alpine.model.ApiKey; import alpine.model.ConfigProperty; import alpine.model.ManagedUser; +import alpine.model.Permission; import alpine.model.Team; import alpine.server.auth.JsonWebToken; import alpine.server.filters.ApiFilter; @@ -31,6 +32,7 @@ import org.dependencytrack.auth.Permissions; import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Project; +import org.dependencytrack.persistence.DefaultObjectGenerator; import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.server.ResourceConfig; import org.junit.Assert; @@ -42,6 +44,9 @@ import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; + +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; @@ -49,6 +54,8 @@ import static org.hamcrest.CoreMatchers.equalTo; public class TeamResourceTest extends ResourceTest { + private String jwt; + private Team userNotPartof; @ClassRule public static JerseyTestRule jersey = new JerseyTestRule( @@ -56,6 +63,21 @@ public class TeamResourceTest extends ResourceTest { .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++) { @@ -206,6 +228,66 @@ public void deleteTeamWithAclTest() { Assert.assertEquals(204, response.getStatus(), 0); } + @Test + 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(1, teams.size()); + Assert.assertEquals(this.team.getUuid().toString(), teams.getFirst().asJsonObject().getString("uuid")); + } + + @Test + 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(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 generateApiKeyTest() { Team team = qm.createTeam("My Team", false);