Skip to content

Commit

Permalink
Merge pull request #948 from DependencyTrack/port-team-selection-in-c…
Browse files Browse the repository at this point in the history
…reate-proect-button

Port : Enhance "Create Project" dialog to include team selection
  • Loading branch information
nscuro authored Oct 9, 2024
2 parents a1dc3b3 + 7a6f921 commit 345d1ee
Show file tree
Hide file tree
Showing 6 changed files with 466 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 3 additions & 2 deletions src/main/java/org/dependencytrack/model/Project.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<Team> accessTeams;

@Persistent(defaultFetchGroup = "true")
Expand Down Expand Up @@ -548,10 +547,12 @@ public void setVersions(List<ProjectVersion> versions) {
this.versions = versions;
}

@JsonIgnore
public List<Team> getAccessTeams() {
return accessTeams;
}

@JsonSetter
public void setAccessTeams(List<Team> accessTeams) {
this.accessTeams = accessTeams;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,6 +74,7 @@
import java.security.Principal;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand All @@ -80,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;

/**
Expand Down Expand Up @@ -369,6 +374,13 @@ public Response getProjectsByClassifier(
summary = "Creates a new project",
description = """
<p>If a parent project exists, <code>parent.uuid</code> is required</p>
<p>
When portfolio access control is enabled, one or more teams to grant access
to can be provided via <code>accessTeams</code>. Either <code>uuid</code> or
<code>name</code> of a team must be specified. Only teams which the authenticated
principal is a member of can be assigned. Principals with <strong>ACCESS_MANAGEMENT</strong>
permission can assign <em>any</em> team.
</p>
<p>Requires permission <strong>PORTFOLIO_MANAGEMENT</strong> or <strong>PORTFOLIO_MANAGEMENT_CREATE</strong></p>"""
)
@ApiResponses(value = {
Expand All @@ -377,15 +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 = "409", description = """
<ul>
<li>An inactive Parent cannot be selected as parent, or</li>
<li>A project with the specified name already exists</li>
</ul>"""),
</ul>""")
})
@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"),
Expand All @@ -397,7 +410,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);
Expand All @@ -409,6 +423,67 @@ public Response createProject(Project jsonProject) {
jsonProject.setParent(parent);
}

Principal principal = getPrincipal();

final List<Team> 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<Team> 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<Team> visibleTeams = isAdmin ? qm.getTeams() : userTeams;
final var visibleTeamByUuid = new HashMap<UUID, Team>(visibleTeams.size());
final var visibleTeamByName = new HashMap<String, Team>(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());
}
jsonProject.addAccessTeam(visibleTeam);
}
}

final Project project;
try {
project = qm.createProject(jsonProject, jsonProject.getTags(), true);
Expand All @@ -429,8 +504,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;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 345d1ee

Please sign in to comment.