From 7505ad112e96cec6ab5c66dbfe4cac88ef880f13 Mon Sep 17 00:00:00 2001 From: Sahiba Mittal Date: Thu, 1 Aug 2024 13:58:17 +0100 Subject: [PATCH 1/4] Add REST endpoints for tag retrieval Co-Authored-By: Niklas --- .../model/validation/LowerCase.java | 44 ++ .../model/validation/LowerCaseValidator.java | 39 ++ .../FindingsSearchQueryManager.java | 27 +- .../persistence/QueryManager.java | 104 ++++ .../persistence/TagQueryManager.java | 194 +++++++ .../resources/v1/TagResource.java | 104 ++++ .../resources/v1/vo/TagListResponseItem.java | 32 ++ .../v1/vo/TaggedPolicyListResponseItem.java | 32 ++ .../v1/vo/TaggedProjectListResponseItem.java | 36 ++ .../resources/v1/TagResourceTest.java | 478 +++++++++++++++++- 10 files changed, 1059 insertions(+), 31 deletions(-) create mode 100644 src/main/java/org/dependencytrack/model/validation/LowerCase.java create mode 100644 src/main/java/org/dependencytrack/model/validation/LowerCaseValidator.java create mode 100644 src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java create mode 100644 src/main/java/org/dependencytrack/resources/v1/vo/TaggedPolicyListResponseItem.java create mode 100644 src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java diff --git a/src/main/java/org/dependencytrack/model/validation/LowerCase.java b/src/main/java/org/dependencytrack/model/validation/LowerCase.java new file mode 100644 index 000000000..8b6526ebc --- /dev/null +++ b/src/main/java/org/dependencytrack/model/validation/LowerCase.java @@ -0,0 +1,44 @@ +/* + * 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.model.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @since 4.12.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Constraint(validatedBy = LowerCaseValidator.class) +public @interface LowerCase { + + String message() default "Value must only contain lowercase letters."; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/model/validation/LowerCaseValidator.java b/src/main/java/org/dependencytrack/model/validation/LowerCaseValidator.java new file mode 100644 index 000000000..6c1ecf9b2 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/validation/LowerCaseValidator.java @@ -0,0 +1,39 @@ +/* + * 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.model.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * @since 4.12.0 + */ +public class LowerCaseValidator implements ConstraintValidator { + + @Override + public boolean isValid(final String value, final ConstraintValidatorContext validatorContext) { + if (value == null) { + // null-ness is expected to be validated using @NotNull + return true; + } + + return value.toLowerCase().equals(value); + } + +} \ No newline at end of file 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..677d8fcba 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,6 +1395,18 @@ public boolean hasAccessManagementPermission(final ApiKey apiKey) { return getProjectQueryManager().hasAccessManagementPermission(apiKey); } + 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 getTags(String policyUuid) { return getTagQueryManager().getTags(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..fda19de7e 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,6 +60,197 @@ public class TagQueryManager extends QueryManager implements IQueryManager { super(pm, request); } + /** + * @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(); + } + } + public PaginatedResult getTags(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..8720592e7 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -39,8 +39,17 @@ import jakarta.ws.rs.core.Response; import org.dependencytrack.auth.Permissions; import org.dependencytrack.model.Tag; +import org.dependencytrack.model.validation.LowerCase; 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") @@ -50,6 +59,101 @@ }) public class TagResource extends AlpineResource { + @GET + @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. Must be lowercase.", required = true) + @PathParam("name") @LowerCase final String tagName + ) { + 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. Must be lowercase.", required = true) + @PathParam("name") @LowerCase final String tagName + ) { + 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("/{policyUuid}") @Produces(MediaType.APPLICATION_JSON) 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..7d2400b39 --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.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; + +/** + * @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..7215345b6 --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java @@ -0,0 +1,36 @@ + +/* + * 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..cfafd6549 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java @@ -20,30 +20,494 @@ 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(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "Value must only contain lowercase letters.", + "messageTemplate": "Value must only contain lowercase letters.", + "path": "getTaggedProjects.tagName", + "invalidValue": "Foo" + } + ] + """); + } + + @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 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(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "Value must only contain lowercase letters.", + "messageTemplate": "Value must only contain lowercase letters.", + "path": "getTaggedPolicies.tagName", + "invalidValue": "Foo" + } + ] + """); + } @Test public void getAllTagsWithOrderingTest() { - for (int i=1; i<5; i++) { - qm.createTag("Tag "+i); + 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); @@ -64,8 +528,8 @@ public void getAllTagsWithOrderingTest() { @Test public void getTagsWithPolicyProjectsFilterTest() { - for (int i=1; i<5; i++) { - qm.createTag("Tag "+i); + 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); From 0e3ec861c574cad235440c2d6989dee8ada88909 Mon Sep 17 00:00:00 2001 From: Sahiba Mittal Date: Thu, 1 Aug 2024 14:01:20 +0100 Subject: [PATCH 2/4] fix checkstyle --- .../org/dependencytrack/resources/v1/vo/TagListResponseItem.java | 1 - .../resources/v1/vo/TaggedProjectListResponseItem.java | 1 - 2 files changed, 2 deletions(-) diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java index 7d2400b39..d60e50244 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java @@ -1,4 +1,3 @@ - /* * This file is part of Dependency-Track. * diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java index 7215345b6..2f9b6083d 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java @@ -1,4 +1,3 @@ - /* * This file is part of Dependency-Track. * From d217186ad3f7af33864341cf5aef41af56f52d03 Mon Sep 17 00:00:00 2001 From: Sahiba Mittal Date: Thu, 1 Aug 2024 15:12:32 +0100 Subject: [PATCH 3/4] Deprecate /api/v1/tag/{policyUuid} in favor of /api/v1/tag/policy/{uuid} Co-Authored-By: Niklas --- .../persistence/QueryManager.java | 4 +- .../persistence/TagQueryManager.java | 3 +- .../resources/v1/TagResource.java | 39 +++++++++++++++++-- .../resources/v1/TagResourceTest.java | 23 +++++++++-- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 677d8fcba..4004783ca 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -1407,8 +1407,8 @@ public List getTaggedPolicies(final String tagN return getTagQueryManager().getTaggedPolicies(tagName); } - public PaginatedResult getTags(String policyUuid) { - return getTagQueryManager().getTags(policyUuid); + public PaginatedResult getTagsForPolicy(String policyUuid) { + return getTagQueryManager().getTagsForPolicy(policyUuid); } /** diff --git a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java index fda19de7e..214bfcb07 100644 --- a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java @@ -251,7 +251,8 @@ public List getTaggedPolicies(final String tagName) { } } - public PaginatedResult getTags(String policyUuid) { + @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 8720592e7..dd0773922 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -155,7 +155,7 @@ public Response getTaggedPolicies( } @GET - @Path("/{policyUuid}") + @Path("/policy/{uuid}") @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Returns a list of all tags associated with a given policy", @@ -170,11 +170,42 @@ public Response getTaggedPolicies( @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/test/java/org/dependencytrack/resources/v1/TagResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java index cfafd6549..ab1eb57a7 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java @@ -505,7 +505,7 @@ public void getTaggedPoliciesWithNonLowerCaseTagNameTest() { } @Test - public void getAllTagsWithOrderingTest() { + public void getTagsForPolicyWithOrderingTest() { for (int i = 1; i < 5; i++) { qm.createTag("Tag " + i); } @@ -513,7 +513,7 @@ public void getAllTagsWithOrderingTest() { 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(); @@ -527,7 +527,7 @@ public void getAllTagsWithOrderingTest() { } @Test - public void getTagsWithPolicyProjectsFilterTest() { + public void getTagsForPolicyWithPolicyProjectsFilterTest() { for (int i = 1; i < 5; i++) { qm.createTag("Tag " + i); } @@ -538,7 +538,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(); @@ -550,4 +550,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); + } } From bf5c9c0b5a47fa63d923c25c351c5e90567e927f Mon Sep 17 00:00:00 2001 From: Sahiba Mittal Date: Thu, 1 Aug 2024 15:18:14 +0100 Subject: [PATCH 4/4] Relax lowercase requirement Co-Authored-By: Niklas --- .../model/validation/LowerCase.java | 44 ------------------- .../model/validation/LowerCaseValidator.java | 39 ---------------- .../resources/v1/TagResource.java | 17 ++++--- .../resources/v1/TagResourceTest.java | 28 +++--------- 4 files changed, 18 insertions(+), 110 deletions(-) delete mode 100644 src/main/java/org/dependencytrack/model/validation/LowerCase.java delete mode 100644 src/main/java/org/dependencytrack/model/validation/LowerCaseValidator.java diff --git a/src/main/java/org/dependencytrack/model/validation/LowerCase.java b/src/main/java/org/dependencytrack/model/validation/LowerCase.java deleted file mode 100644 index 8b6526ebc..000000000 --- a/src/main/java/org/dependencytrack/model/validation/LowerCase.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.model.validation; - -import jakarta.validation.Constraint; -import jakarta.validation.Payload; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * @since 4.12.0 - */ -@Documented -@Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.FIELD, ElementType.PARAMETER}) -@Constraint(validatedBy = LowerCaseValidator.class) -public @interface LowerCase { - - String message() default "Value must only contain lowercase letters."; - - Class[] groups() default {}; - - Class[] payload() default {}; - -} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/model/validation/LowerCaseValidator.java b/src/main/java/org/dependencytrack/model/validation/LowerCaseValidator.java deleted file mode 100644 index 6c1ecf9b2..000000000 --- a/src/main/java/org/dependencytrack/model/validation/LowerCaseValidator.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.model.validation; - -import jakarta.validation.ConstraintValidator; -import jakarta.validation.ConstraintValidatorContext; - -/** - * @since 4.12.0 - */ -public class LowerCaseValidator implements ConstraintValidator { - - @Override - public boolean isValid(final String value, final ConstraintValidatorContext validatorContext) { - if (value == null) { - // null-ness is expected to be validated using @NotNull - return true; - } - - return value.toLowerCase().equals(value); - } - -} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/resources/v1/TagResource.java b/src/main/java/org/dependencytrack/resources/v1/TagResource.java index dd0773922..554624944 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -39,7 +39,6 @@ import jakarta.ws.rs.core.Response; import org.dependencytrack.auth.Permissions; import org.dependencytrack.model.Tag; -import org.dependencytrack.model.validation.LowerCase; import org.dependencytrack.model.validation.ValidUuid; import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.persistence.TagQueryManager; @@ -106,9 +105,13 @@ public Response getAllTags() { @PaginatedApi @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) public Response getTaggedProjects( - @Parameter(description = "Name of the tag to get projects for. Must be lowercase.", required = true) - @PathParam("name") @LowerCase final String tagName + @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); @@ -139,9 +142,13 @@ public Response getTaggedProjects( @PaginatedApi @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) public Response getTaggedPolicies( - @Parameter(description = "Name of the tag to get policies for. Must be lowercase.", required = true) - @PathParam("name") @LowerCase final String tagName + @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); diff --git a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java index ab1eb57a7..9a8962379 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java @@ -364,17 +364,9 @@ public void getTaggedProjectsWithNonLowerCaseTagNameTest() { .request() .header(X_API_KEY, apiKey) .get(); - assertThat(response.getStatus()).isEqualTo(400); - assertThatJson(getPlainTextBody(response)).isEqualTo(""" - [ - { - "message": "Value must only contain lowercase letters.", - "messageTemplate": "Value must only contain lowercase letters.", - "path": "getTaggedProjects.tagName", - "invalidValue": "Foo" - } - ] - """); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); } @Test @@ -491,17 +483,9 @@ public void getTaggedPoliciesWithNonLowerCaseTagNameTest() { .request() .header(X_API_KEY, apiKey) .get(); - assertThat(response.getStatus()).isEqualTo(400); - assertThatJson(getPlainTextBody(response)).isEqualTo(""" - [ - { - "message": "Value must only contain lowercase letters.", - "messageTemplate": "Value must only contain lowercase letters.", - "path": "getTaggedPolicies.tagName", - "invalidValue": "Foo" - } - ] - """); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); } @Test