diff --git a/src/main/java/org/dependencytrack/persistence/FindingsSearchQueryManager.java b/src/main/java/org/dependencytrack/persistence/FindingsSearchQueryManager.java index d122f98a2..132cd7115 100644 --- a/src/main/java/org/dependencytrack/persistence/FindingsSearchQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/FindingsSearchQueryManager.java @@ -25,7 +25,6 @@ import com.github.packageurl.PackageURL; import org.dependencytrack.model.Analysis; import org.dependencytrack.model.Component; -import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Finding; import org.dependencytrack.model.GroupedFinding; import org.dependencytrack.model.RepositoryMetaComponent; @@ -41,8 +40,6 @@ import java.util.Map; import java.util.UUID; -import static org.dependencytrack.util.PrincipalUtil.getPrincipalTeamIds; - public class FindingsSearchQueryManager extends QueryManager implements IQueryManager { private static final Map sortingAttributes = Map.ofEntries( @@ -345,31 +342,13 @@ private void processInputFilter(StringBuilder queryFilter, Map p } private void preprocessACLs(StringBuilder queryFilter, final Map params) { - if (!isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) - || hasAccessManagementPermission(this.principal)) { - return; - } - if (queryFilter.isEmpty()) { queryFilter.append(" WHERE "); } else { queryFilter.append(" AND "); } - - final var teamIds = new ArrayList<>(getPrincipalTeamIds(principal)); - if (teamIds.isEmpty()) { - queryFilter.append(":false"); - params.put("false", false); - return; - } - - queryFilter.append(""" - EXISTS ( - SELECT 1 - FROM "PROJECT_ACCESS_TEAMS" - WHERE "PROJECT_ACCESS_TEAMS"."PROJECT_ID" = "PROJECT"."ID" - AND "PROJECT_ACCESS_TEAMS"."TEAM_ID" = ANY(:teamIds) - )"""); - params.put("teamIds", teamIds.toArray(new Long[0])); + final Map.Entry> projectAclConditionAndParams = getProjectAclSqlCondition(); + queryFilter.append(projectAclConditionAndParams.getKey()).append(" "); + params.putAll(projectAclConditionAndParams.getValue()); } } \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 314ee5dc0..4004783ca 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -113,7 +113,10 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -122,6 +125,7 @@ import java.util.function.Predicate; import static org.datanucleus.PropertyNames.PROPERTY_QUERY_SQL_ALLOWALL; +import static org.dependencytrack.model.ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED; import static org.dependencytrack.proto.vulnanalysis.v1.ScanStatus.SCAN_STATUS_FAILED; /** @@ -456,6 +460,27 @@ public QueryManager withL2CacheDisabled() { return this; } + /** + * Get the IDs of the {@link Team}s a given {@link Principal} is a member of. + * + * @return A {@link Set} of {@link Team} IDs + */ + protected Set getTeamIds(final Principal principal) { + final var principalTeamIds = new HashSet(); + if (principal instanceof final UserPrincipal userPrincipal + && userPrincipal.getTeams() != null) { + for (final Team userInTeam : userPrincipal.getTeams()) { + principalTeamIds.add(userInTeam.getId()); + } + } else if (principal instanceof final ApiKey apiKey + && apiKey.getTeams() != null) { + for (final Team userInTeam : apiKey.getTeams()) { + principalTeamIds.add(userInTeam.getId()); + } + } + return principalTeamIds; + } + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// //// BEGIN WRAPPER METHODS //// //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -1370,8 +1395,20 @@ public boolean hasAccessManagementPermission(final ApiKey apiKey) { return getProjectQueryManager().hasAccessManagementPermission(apiKey); } - public PaginatedResult getTags(String policyUuid) { - return getTagQueryManager().getTags(policyUuid); + public List getTags() { + return getTagQueryManager().getTags(); + } + + public List getTaggedProjects(final String tagName) { + return getTagQueryManager().getTaggedProjects(tagName); + } + + public List getTaggedPolicies(final String tagName) { + return getTagQueryManager().getTaggedPolicies(tagName); + } + + public PaginatedResult getTagsForPolicy(String policyUuid) { + return getTagQueryManager().getTagsForPolicy(policyUuid); } /** @@ -1945,4 +1982,71 @@ public long deleteComponentPropertyByUuid(final Component component, final UUID public void synchronizeComponentProperties(final Component component, final List properties) { getComponentQueryManager().synchronizeComponentProperties(component, properties); } + + /** + * @see #getProjectAclSqlCondition(String) + * @since 4.12.0 + */ + public Map.Entry> getProjectAclSqlCondition() { + return getProjectAclSqlCondition("PROJECT"); + } + + /** + * @param projectTableAlias Name or alias of the {@code PROJECT} table to use in the condition. + * @return A SQL condition that may be used to check if the {@link #principal} has access to a project + * @since 4.12.0 + */ + public Map.Entry> getProjectAclSqlCondition(final String projectTableAlias) { + if (request == null) { + return Map.entry(/* true */ "1=1", Collections.emptyMap()); + } + + if (principal == null || !isEnabled(ACCESS_MANAGEMENT_ACL_ENABLED) || hasAccessManagementPermission(principal)) { + return Map.entry(/* true */ "1=1", Collections.emptyMap()); + } + + final var teamIds = new ArrayList<>(getTeamIds(principal)); + if (teamIds.isEmpty()) { + return Map.entry(/* false */ "1=2", Collections.emptyMap()); + } + + + // NB: Need to work around the fact that the RDBMSes can't agree on how to do member checks. Oh joy! :))) + final var params = new HashMap(); + final var teamIdChecks = new ArrayList(); + for (int i = 0; i < teamIds.size(); i++) { + teamIdChecks.add("\"PROJECT_ACCESS_TEAMS\".\"TEAM_ID\" = :teamId" + i); + params.put("teamId" + i, teamIds.get(i)); + } + + return Map.entry(""" + EXISTS ( + SELECT 1 + FROM "PROJECT_ACCESS_TEAMS" + WHERE "PROJECT_ACCESS_TEAMS"."PROJECT_ID" = "%s"."ID" + AND (%s) + )""".formatted(projectTableAlias, String.join(" OR ", teamIdChecks)), params); + } + + /** + * @since 4.12.0 + * @return A SQL {@code OFFSET ... LIMIT ...} clause if pagination is requested, otherwise an empty string + */ + public String getOffsetLimitSqlClause() { + if (pagination == null || !pagination.isPaginated()) { + return ""; + } + + final String clauseTemplate; + if (DbUtil.isMssql()) { + clauseTemplate = "OFFSET %d ROWS FETCH NEXT %d ROWS ONLY"; + } else if (DbUtil.isMysql()) { + // NB: Order of limit and offset is different for MySQL... + return "LIMIT %s OFFSET %s".formatted(pagination.getLimit(), pagination.getOffset()); + } else { + clauseTemplate = "OFFSET %d FETCH NEXT %d ROWS ONLY"; + } + + return clauseTemplate.formatted(pagination.getOffset(), pagination.getLimit()); + } } diff --git a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java index b9645cf80..214bfcb07 100644 --- a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java @@ -19,6 +19,7 @@ package org.dependencytrack.persistence; import alpine.common.logging.Logger; +import alpine.persistence.OrderDirection; import alpine.persistence.PaginatedResult; import alpine.resources.AlpineRequest; import org.apache.commons.lang3.StringUtils; @@ -30,7 +31,9 @@ import javax.jdo.Query; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Stream; public class TagQueryManager extends QueryManager implements IQueryManager { @@ -57,7 +60,199 @@ public class TagQueryManager extends QueryManager implements IQueryManager { super(pm, request); } - public PaginatedResult getTags(String policyUuid) { + /** + * @since 4.12.0 + */ + public record TagListRow(String name, long projectCount, long policyCount, long totalCount) { + + @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. + public TagListRow(String name, int projectCount, int policyCount, int totalCount) { + this(name, (long) projectCount, (long) policyCount, (long) totalCount); + } + + } + + /** + * @since 4.12.0 + */ + @Override + public List getTags() { + final Map.Entry> projectAclConditionAndParams = getProjectAclSqlCondition(); + final String projectAclCondition = projectAclConditionAndParams.getKey(); + final Map projectAclConditionParams = projectAclConditionAndParams.getValue(); + + // language=SQL + var sqlQuery = """ + SELECT "NAME" AS "name" + , (SELECT COUNT(*) + FROM "PROJECTS_TAGS" + INNER JOIN "PROJECT" + ON "PROJECT"."ID" = "PROJECTS_TAGS"."PROJECT_ID" + WHERE "PROJECTS_TAGS"."TAG_ID" = "TAG"."ID" + AND %s + ) AS "projectCount" + , (SELECT COUNT(*) + FROM "POLICY_TAGS" + WHERE "POLICY_TAGS"."TAG_ID" = "TAG"."ID" + ) AS "policyCount" + , COUNT(*) OVER() AS "totalCount" + FROM "TAG" + """.formatted(projectAclCondition); + + final var params = new HashMap<>(projectAclConditionParams); + + if (filter != null) { + sqlQuery += " WHERE \"NAME\" LIKE :nameFilter"; + params.put("nameFilter", "%" + filter.toLowerCase() + "%"); + } + + if (orderBy == null) { + sqlQuery += " ORDER BY \"name\" ASC"; + } else if ("name".equals(orderBy) || "projectCount".equals(orderBy) || "policyCount".equals(orderBy)) { + sqlQuery += " ORDER BY \"%s\" %s, \"ID\" ASC".formatted(orderBy, + orderDirection == OrderDirection.DESCENDING ? "DESC" : "ASC"); + } else { + // TODO: Throw NotSortableException once Alpine opens up its constructor. + throw new IllegalArgumentException("Cannot sort by " + orderBy); + } + + sqlQuery += " " + getOffsetLimitSqlClause(); + + final Query query = pm.newQuery(Query.SQL, sqlQuery); + query.setNamedParameters(params); + try { + return new ArrayList<>(query.executeResultList(TagListRow.class)); + } finally { + query.closeAll(); + } + } + + /** + * @since 4.12.0 + */ + public record TaggedProjectRow(String uuid, String name, String version, long totalCount) { + + @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. + public TaggedProjectRow(String uuid, String name, String version, int totalCount) { + this(uuid, name, version, (long) totalCount); + } + + } + + /** + * @since 4.12.0 + */ + @Override + public List getTaggedProjects(final String tagName) { + final Map.Entry> projectAclConditionAndParams = getProjectAclSqlCondition(); + final String projectAclCondition = projectAclConditionAndParams.getKey(); + final Map projectAclConditionParams = projectAclConditionAndParams.getValue(); + + // language=SQL + var sqlQuery = """ + SELECT "PROJECT"."UUID" AS "uuid" + , "PROJECT"."NAME" AS "name" + , "PROJECT"."VERSION" AS "version" + , COUNT(*) OVER() AS "totalCount" + FROM "PROJECT" + INNER JOIN "PROJECTS_TAGS" + ON "PROJECTS_TAGS"."PROJECT_ID" = "PROJECT"."ID" + INNER JOIN "TAG" + ON "TAG"."ID" = "PROJECTS_TAGS"."TAG_ID" + WHERE "TAG"."NAME" = :tag + AND %s + """.formatted(projectAclCondition); + + final var params = new HashMap<>(projectAclConditionParams); + params.put("tag", tagName); + + if (filter != null) { + sqlQuery += " AND \"PROJECT\".\"NAME\" LIKE :nameFilter"; + params.put("nameFilter", "%" + filter + "%"); + } + + if (orderBy == null) { + sqlQuery += " ORDER BY \"name\" ASC, \"version\" DESC"; + } else if ("name".equals(orderBy) || "version".equals(orderBy)) { + sqlQuery += " ORDER BY \"%s\" %s, \"ID\" ASC".formatted(orderBy, + orderDirection == OrderDirection.DESCENDING ? "DESC" : "ASC"); + } else { + // TODO: Throw NotSortableException once Alpine opens up its constructor. + throw new IllegalArgumentException("Cannot sort by " + orderBy); + } + + sqlQuery += " " + getOffsetLimitSqlClause(); + + final Query query = pm.newQuery(Query.SQL, sqlQuery); + query.setNamedParameters(params); + try { + return new ArrayList<>(query.executeResultList(TaggedProjectRow.class)); + } finally { + query.closeAll(); + } + } + + /** + * @since 4.12.0 + */ + public record TaggedPolicyRow(String uuid, String name, long totalCount) { + + @SuppressWarnings("unused") // DataNucleus will use this for MSSQL. + public TaggedPolicyRow(String uuid, String name, int totalCount) { + this(uuid, name, (long) totalCount); + } + + } + + /** + * @since 4.12.0 + */ + @Override + public List getTaggedPolicies(final String tagName) { + // language=SQL + var sqlQuery = """ + SELECT "POLICY"."UUID" AS "uuid" + , "POLICY"."NAME" AS "name" + , COUNT(*) OVER() AS "totalCount" + FROM "POLICY" + INNER JOIN "POLICY_TAGS" + ON "POLICY_TAGS"."POLICY_ID" = "POLICY"."ID" + INNER JOIN "TAG" + ON "TAG"."ID" = "POLICY_TAGS"."TAG_ID" + WHERE "TAG"."NAME" = :tag + """; + + final var params = new HashMap(); + params.put("tag", tagName); + + if (filter != null) { + sqlQuery += " AND \"POLICY\".\"NAME\" LIKE :nameFilter"; + params.put("nameFilter", "%" + filter + "%"); + } + + if (orderBy == null) { + sqlQuery += " ORDER BY \"name\" ASC"; + } else if ("name".equals(orderBy)) { + sqlQuery += " ORDER BY \"%s\" %s".formatted(orderBy, + orderDirection == OrderDirection.DESCENDING ? "DESC" : "ASC"); + } else { + // TODO: Throw NotSortableException once Alpine opens up its constructor. + throw new IllegalArgumentException("Cannot sort by " + orderBy); + } + + sqlQuery += " " + getOffsetLimitSqlClause(); + + final Query query = pm.newQuery(Query.SQL, sqlQuery); + query.setNamedParameters(params); + try { + return new ArrayList<>(query.executeResultList(TaggedPolicyRow.class)); + } finally { + query.closeAll(); + } + } + + @Override + public PaginatedResult getTagsForPolicy(String policyUuid) { LOGGER.debug("Retrieving tags under policy " + policyUuid); diff --git a/src/main/java/org/dependencytrack/resources/v1/TagResource.java b/src/main/java/org/dependencytrack/resources/v1/TagResource.java index adfc47675..554624944 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -41,6 +41,14 @@ import org.dependencytrack.model.Tag; import org.dependencytrack.model.validation.ValidUuid; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.persistence.TagQueryManager; +import org.dependencytrack.resources.v1.openapi.PaginatedApi; +import org.dependencytrack.resources.v1.vo.TagListResponseItem; +import org.dependencytrack.resources.v1.vo.TaggedPolicyListResponseItem; +import org.dependencytrack.resources.v1.vo.TaggedProjectListResponseItem; + +import java.util.List; +import java.util.UUID; @Path("/v1/tag") @io.swagger.v3.oas.annotations.tags.Tag(name = "tag") @@ -51,7 +59,110 @@ public class TagResource extends AlpineResource { @GET - @Path("/{policyUuid}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns a list of all tags", + description = "

Requires permission VIEW_PORTFOLIO

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "A list of all tags", + headers = @Header(name = TOTAL_COUNT_HEADER, description = "The total number of tags", schema = @Schema(format = "integer")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagListResponseItem.class))) + ) + }) + @PaginatedApi + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + public Response getAllTags() { + final List tagListRows; + try (final var qm = new QueryManager(getAlpineRequest())) { + tagListRows = qm.getTags(); + } + + final List tags = tagListRows.stream() + .map(row -> new TagListResponseItem(row.name(), row.projectCount(), row.policyCount())) + .toList(); + final long totalCount = tagListRows.isEmpty() ? 0 : tagListRows.getFirst().totalCount(); + return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); + } + + @GET + @Path("/{name}/project") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns a list of all projects assigned to the given tag.", + description = "

Requires permission VIEW_PORTFOLIO

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "A list of all projects assigned to the given tag", + headers = @Header(name = TOTAL_COUNT_HEADER, description = "The total number of projects", schema = @Schema(format = "integer")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TaggedProjectListResponseItem.class))) + ) + }) + @PaginatedApi + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + public Response getTaggedProjects( + @Parameter(description = "Name of the tag to get projects for.", required = true) + @PathParam("name") final String tagName + ) { + // TODO: Should enforce lowercase for tagName once we are sure that + // users don't have any mixed-case tags in their system anymore. + // Will likely need a migration to cleanup existing tags for this. + + final List taggedProjectListRows; + try (final var qm = new QueryManager(getAlpineRequest())) { + taggedProjectListRows = qm.getTaggedProjects(tagName); + } + + final List tags = taggedProjectListRows.stream() + .map(row -> new TaggedProjectListResponseItem(UUID.fromString(row.uuid()), row.name(), row.version())) + .toList(); + final long totalCount = taggedProjectListRows.isEmpty() ? 0 : taggedProjectListRows.getFirst().totalCount(); + return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); + } + + @GET + @Path("/{name}/policy") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns a list of all policies assigned to the given tag.", + description = "

Requires permission VIEW_PORTFOLIO

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "A list of all policies assigned to the given tag", + headers = @Header(name = TOTAL_COUNT_HEADER, description = "The total number of policies", schema = @Schema(format = "integer")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TaggedPolicyListResponseItem.class))) + ) + }) + @PaginatedApi + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + public Response getTaggedPolicies( + @Parameter(description = "Name of the tag to get policies for.", required = true) + @PathParam("name") final String tagName + ) { + // TODO: Should enforce lowercase for tagName once we are sure that + // users don't have any mixed-case tags in their system anymore. + // Will likely need a migration to cleanup existing tags for this. + + final List taggedPolicyListRows; + try (final var qm = new QueryManager(getAlpineRequest())) { + taggedPolicyListRows = qm.getTaggedPolicies(tagName); + } + + final List tags = taggedPolicyListRows.stream() + .map(row -> new TaggedPolicyListResponseItem(UUID.fromString(row.uuid()), row.name())) + .toList(); + final long totalCount = taggedPolicyListRows.isEmpty() ? 0 : taggedPolicyListRows.getFirst().totalCount(); + return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); + } + + @GET + @Path("/policy/{uuid}") @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Returns a list of all tags associated with a given policy", @@ -66,11 +177,42 @@ public class TagResource extends AlpineResource { @ApiResponse(responseCode = "401", description = "Unauthorized") }) @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) - public Response getTags(@Parameter(description = "The UUID of the policy", schema = @Schema(type = "string", format = "uuid"), required = true) - @PathParam("policyUuid") @ValidUuid String policyUuid) { + public Response getTagsForPolicy( + @Parameter(description = "The UUID of the policy", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("uuid") @ValidUuid final String uuid + ) { try (QueryManager qm = new QueryManager(getAlpineRequest())) { - final PaginatedResult result = qm.getTags(policyUuid); + final PaginatedResult result = qm.getTagsForPolicy(uuid); return Response.ok(result.getObjects()).header(TOTAL_COUNT_HEADER, result.getTotal()).build(); } } + + @GET + @Path("/{policyUuid}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns a list of all tags associated with a given policy", + description = """ +

Deprecated. Use /api/v1/tag/policy/{uuid} instead.

+

Requires permission VIEW_PORTFOLIO

+ """ + ) + @PaginatedApi + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "A list of all tags associated with a given policy", + headers = @Header(name = TOTAL_COUNT_HEADER, description = "The total number of tags", schema = @Schema(format = "integer")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = Tag.class))) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized") + }) + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + @Deprecated(forRemoval = true) + public Response getTags( + @Parameter(description = "The UUID of the policy", required = true) + @PathParam("policyUuid") final UUID policyUuid + ) { + return getTagsForPolicy(String.valueOf(policyUuid)); + } } diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java new file mode 100644 index 000000000..d60e50244 --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java @@ -0,0 +1,31 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import io.swagger.v3.oas.annotations.Parameter; + +/** + * @since 4.12.0 + */ +public record TagListResponseItem( + @Parameter(description = "Name of the tag", required = true) String name, + @Parameter(description = "Number of projects assigned to this tag") long projectCount, + @Parameter(description = "Number of policies assigned to this tag") long policyCount +) { +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedPolicyListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedPolicyListResponseItem.java new file mode 100644 index 000000000..ad710802d --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedPolicyListResponseItem.java @@ -0,0 +1,32 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import io.swagger.v3.oas.annotations.Parameter; + +import java.util.UUID; + +/** + * @since 4.12.0 + */ +public record TaggedPolicyListResponseItem( + @Parameter(description = "UUID of the policy", required = true) UUID uuid, + @Parameter(description = "Name of the policy", required = true) String name +) { +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java new file mode 100644 index 000000000..2f9b6083d --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java @@ -0,0 +1,35 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.Parameter; + +import java.util.UUID; + +/** + * @since 4.12.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TaggedProjectListResponseItem( + @Parameter(description = "UUID of the project", required = true) UUID uuid, + @Parameter(description = "Name of the project", required = true) String name, + @Parameter(description = "Version of the project") String version +) { +} \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java index dd7e2ec8e..9a8962379 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java @@ -20,36 +20,484 @@ import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFilter; +import jakarta.json.JsonArray; +import jakarta.ws.rs.core.Response; import org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; import org.dependencytrack.model.Policy; +import org.dependencytrack.model.Project; +import org.dependencytrack.model.Tag; +import org.dependencytrack.resources.v1.exception.ConstraintViolationExceptionMapper; import org.glassfish.jersey.server.ResourceConfig; import org.junit.Assert; import org.junit.ClassRule; import org.junit.Test; -import jakarta.json.JsonArray; -import jakarta.ws.rs.core.Response; import java.util.List; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.dependencytrack.model.ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED; +import static org.hamcrest.CoreMatchers.equalTo; + public class TagResourceTest extends ResourceTest { @ClassRule public static JerseyTestRule jersey = new JerseyTestRule( new ResourceConfig(TagResource.class) .register(ApiFilter.class) - .register(AuthenticationFilter.class)); + .register(AuthenticationFilter.class) + .register(ConstraintViolationExceptionMapper.class)); + + @Test + public void getTagsTest() { + qm.createConfigProperty( + ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() + ); + + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final var projectB = new Project(); + projectB.setName("acme-app-b"); + qm.persist(projectB); + + final var projectC = new Project(); + projectC.setName("acme-app-c"); + qm.persist(projectC); + + final Tag tagFoo = qm.createTag("foo"); + final Tag tagBar = qm.createTag("bar"); + + qm.bind(projectA, List.of(tagFoo, tagBar)); + qm.bind(projectB, List.of(tagFoo)); + qm.bind(projectC, List.of(tagFoo)); + + projectA.addAccessTeam(team); + projectB.addAccessTeam(team); + // NB: Not assigning projectC + + final var policy = new Policy(); + policy.setName("policy"); + policy.setOperator(Policy.Operator.ALL); + policy.setViolationState(Policy.ViolationState.INFO); + policy.setTags(List.of(tagBar)); + qm.persist(policy); + + final Response response = jersey.target(V1_TAG) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("2"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "name": "bar", + "projectCount": 1, + "policyCount": 1 + }, + { + "name": "foo", + "projectCount": 2, + "policyCount": 0 + } + ] + """); + } + + @Test + public void getTagsWithPaginationTest() { + for (int i = 0; i < 5; i++) { + qm.createTag("tag-" + (i + 1)); + } + + Response response = jersey.target(V1_TAG) + .queryParam("pageNumber", "1") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "name": "tag-1", + "projectCount": 0, + "policyCount": 0 + }, + { + "name": "tag-2", + "projectCount": 0, + "policyCount": 0 + }, + { + "name": "tag-3", + "projectCount": 0, + "policyCount": 0 + } + ] + """); + + response = jersey.target(V1_TAG) + .queryParam("pageNumber", "2") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "name": "tag-4", + "projectCount": 0, + "policyCount": 0 + }, + { + "name": "tag-5", + "projectCount": 0, + "policyCount": 0 + } + ] + """); + } + + @Test + public void getTagsWithFilterTest() { + qm.createTag("foo"); + qm.createTag("bar"); + + final Response response = jersey.target(V1_TAG) + .queryParam("filter", "O") // Should be case-insensitive. + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("1"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "name": "foo", + "projectCount": 0, + "policyCount": 0 + } + ] + """); + } + + @Test + public void getTagsSortByProjectCountTest() { + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final var projectB = new Project(); + projectB.setName("acme-app-b"); + qm.persist(projectB); + + final Tag tagFoo = qm.createTag("foo"); + final Tag tagBar = qm.createTag("bar"); + + qm.bind(projectA, List.of(tagFoo, tagBar)); + qm.bind(projectB, List.of(tagFoo)); + + final Response response = jersey.target(V1_TAG) + .queryParam("sortName", "projectCount") + .queryParam("sortOrder", "desc") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("2"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "name": "foo", + "projectCount": 2, + "policyCount": 0 + }, + { + "name": "bar", + "projectCount": 1, + "policyCount": 0 + } + ] + """); + } + + @Test + public void getTaggedProjectsTest() { + qm.createConfigProperty( + ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + ACCESS_MANAGEMENT_ACL_ENABLED.getDescription() + ); + + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final var projectB = new Project(); + projectB.setName("acme-app-b"); + qm.persist(projectB); + + final var projectC = new Project(); + projectC.setName("acme-app-c"); + qm.persist(projectC); + + final Tag tagFoo = qm.createTag("foo"); + final Tag tagBar = qm.createTag("bar"); + + qm.bind(projectA, List.of(tagFoo, tagBar)); + qm.bind(projectB, List.of(tagFoo)); + qm.bind(projectC, List.of(tagFoo)); + + projectA.addAccessTeam(team); + projectB.addAccessTeam(team); + // NB: Not assigning projectC + + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("2"); + assertThatJson(getPlainTextBody(response)) + .withMatcher("projectUuidA", equalTo(projectA.getUuid().toString())) + .withMatcher("projectUuidB", equalTo(projectB.getUuid().toString())) + .isEqualTo(""" + [ + { + "uuid": "${json-unit.matches:projectUuidA}", + "name": "acme-app-a" + }, + { + "uuid": "${json-unit.matches:projectUuidB}", + "name": "acme-app-b" + } + ] + """); + } + + @Test + public void getTaggedProjectsWithPaginationTest() { + final Tag tag = qm.createTag("foo"); + + for (int i = 0; i < 5; i++) { + final var project = new Project(); + project.setName("acme-app-" + (i + 1)); + qm.persist(project); + + qm.bind(project, List.of(tag)); + } + + Response response = jersey.target(V1_TAG + "/foo/project") + .queryParam("pageNumber", "1") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "uuid": "${json-unit.any-string}", + "name": "acme-app-1" + }, + { + "uuid": "${json-unit.any-string}", + "name": "acme-app-2" + }, + { + "uuid": "${json-unit.any-string}", + "name": "acme-app-3" + } + ] + """); + + response = jersey.target(V1_TAG + "/foo/project") + .queryParam("pageNumber", "2") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "uuid": "${json-unit.any-string}", + "name": "acme-app-4" + }, + { + "uuid": "${json-unit.any-string}", + "name": "acme-app-5" + } + ] + """); + } + + @Test + public void getTaggedProjectsWithTagNotExistsTest() { + final Response response = jersey.target(V1_TAG + "/foo/project") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); + } + + @Test + public void getTaggedProjectsWithNonLowerCaseTagNameTest() { + final Response response = jersey.target(V1_TAG + "/Foo/project") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); + } + + @Test + public void getTaggedPoliciesTest() { + final Tag tagFoo = qm.createTag("foo"); + final Tag tagBar = qm.createTag("bar"); + + final var policyA = new Policy(); + policyA.setName("policy-a"); + policyA.setOperator(Policy.Operator.ALL); + policyA.setViolationState(Policy.ViolationState.INFO); + policyA.setTags(List.of(tagFoo)); + qm.persist(policyA); + + final var policyB = new Policy(); + policyB.setName("policy-b"); + policyB.setOperator(Policy.Operator.ALL); + policyB.setViolationState(Policy.ViolationState.INFO); + policyB.setTags(List.of(tagBar)); + qm.persist(policyB); + + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("1"); + assertThatJson(getPlainTextBody(response)) + .withMatcher("policyUuidA", equalTo(policyA.getUuid().toString())) + .isEqualTo(""" + [ + { + "uuid": "${json-unit.matches:policyUuidA}", + "name": "policy-a" + } + ] + """); + } @Test - public void getAllTagsWithOrderingTest() { - for (int i=1; i<5; i++) { - qm.createTag("Tag "+i); + public void getTaggedPoliciesWithPaginationTest() { + final Tag tag = qm.createTag("foo"); + + for (int i = 0; i < 5; i++) { + final var policy = new Policy(); + policy.setName("policy-" + (i + 1)); + policy.setOperator(Policy.Operator.ALL); + policy.setViolationState(Policy.ViolationState.INFO); + policy.setTags(List.of(tag)); + qm.persist(policy); + } + + Response response = jersey.target(V1_TAG + "/foo/policy") + .queryParam("pageNumber", "1") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "uuid": "${json-unit.any-string}", + "name": "policy-1" + }, + { + "uuid": "${json-unit.any-string}", + "name": "policy-2" + }, + { + "uuid": "${json-unit.any-string}", + "name": "policy-3" + } + ] + """); + + response = jersey.target(V1_TAG + "/foo/policy") + .queryParam("pageNumber", "2") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "uuid": "${json-unit.any-string}", + "name": "policy-4" + }, + { + "uuid": "${json-unit.any-string}", + "name": "policy-5" + } + ] + """); + } + + @Test + public void getTaggedPoliciesWithTagNotExistsTest() { + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); + } + + @Test + public void getTaggedPoliciesWithNonLowerCaseTagNameTest() { + final Response response = jersey.target(V1_TAG + "/Foo/policy") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); + } + + @Test + public void getTagsForPolicyWithOrderingTest() { + for (int i = 1; i < 5; i++) { + qm.createTag("Tag " + i); } qm.createProject("Project A", null, "1", List.of(qm.getTagByName("Tag 1"), qm.getTagByName("Tag 2")), null, null, true, false); qm.createProject("Project B", null, "1", List.of(qm.getTagByName("Tag 2"), qm.getTagByName("Tag 3"), qm.getTagByName("Tag 4")), null, null, true, false); Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO); - Response response = jersey.target(V1_TAG + "/" + policy.getUuid()) + Response response = jersey.target(V1_TAG + "/policy/" + policy.getUuid()) .request() .header(X_API_KEY, apiKey) .get(); @@ -63,9 +511,9 @@ public void getAllTagsWithOrderingTest() { } @Test - public void getTagsWithPolicyProjectsFilterTest() { - for (int i=1; i<5; i++) { - qm.createTag("Tag "+i); + public void getTagsForPolicyWithPolicyProjectsFilterTest() { + for (int i = 1; i < 5; i++) { + qm.createTag("Tag " + i); } qm.createProject("Project A", null, "1", List.of(qm.getTagByName("Tag 1"), qm.getTagByName("Tag 2")), null, null, true, false); qm.createProject("Project B", null, "1", List.of(qm.getTagByName("Tag 1"), qm.getTagByName("Tag 3")), null, null, true, false); @@ -74,7 +522,7 @@ public void getTagsWithPolicyProjectsFilterTest() { Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO); policy.setProjects(List.of(qm.getProject("Project A", "1"), qm.getProject("Project C", "1"))); - Response response = jersey.target(V1_TAG + "/" + policy.getUuid()) + Response response = jersey.target(V1_TAG + "/policy/" + policy.getUuid()) .request() .header(X_API_KEY, apiKey) .get(); @@ -86,4 +534,19 @@ public void getTagsWithPolicyProjectsFilterTest() { Assert.assertEquals(3, json.size()); Assert.assertEquals("tag 1", json.getJsonObject(0).getString("name")); } + + @Test + public void getTagWithNonUuidNameTest() { + // NB: This is just to ensure that requests to /api/v1/tag/ + // are not matched with the deprecated "getTagsForPolicy" endpoint. + // Once we implement an endpoint to request individual tags, + // this test should fail and adjusted accordingly. + qm.createTag("not-a-uuid"); + + final Response response = jersey.target(V1_TAG + "/not-a-uuid") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(404); + } }