diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 0f33188d5..4beb70fb3 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -1427,6 +1427,14 @@ public List getTaggedPolicies(final String tagN return getTagQueryManager().getTaggedPolicies(tagName); } + public void tagPolicies(final String tagName, final Collection policyUuids) { + getTagQueryManager().tagPolicies(tagName, policyUuids); + } + + public void untagPolicies(final String tagName, final Collection policyUuids) { + getTagQueryManager().untagPolicies(tagName, policyUuids); + } + 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 e5d94f4ce..50a9c496e 100644 --- a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java @@ -147,6 +147,9 @@ public TaggedProjectRow(String uuid, String name, String version, int totalCount } + /** + * @since 4.12.0 + */ public record TagDeletionCandidateRow( String name, long projectCount, @@ -166,6 +169,10 @@ public TagDeletionCandidateRow( } + /** + * @since 4.12.0 + */ + @Override public void deleteTags(final Collection tagNames) { runInTransaction(() -> { final Map.Entry> projectAclConditionAndParams = getProjectAclSqlCondition(); @@ -448,6 +455,54 @@ public List getTaggedPolicies(final String tagName) { } } + /** + * @since 4.12.0 + */ + @Override + public void tagPolicies(final String tagName, final Collection policyUuids) { + runInTransaction(() -> { + final Tag tag = getTagByName(tagName); + if (tag == null) { + throw new NoSuchElementException("A tag with name %s does not exist".formatted(tagName)); + } + + final Query policiesQuery = pm.newQuery(Policy.class); + policiesQuery.setFilter(":uuids.contains(uuid)"); + policiesQuery.setParameters(policyUuids); + final List policies = executeAndCloseList(policiesQuery); + + for (final Policy policy : policies) { + bind(policy, List.of(tag)); + } + }); + } + + /** + * @since 4.12.0 + */ + @Override + public void untagPolicies(final String tagName, final Collection policyUuids) { + runInTransaction(() -> { + final Tag tag = getTagByName(tagName); + if (tag == null) { + throw new NoSuchElementException("A tag with name %s does not exist".formatted(tagName)); + } + + final Query policiesQuery = pm.newQuery(Policy.class); + policiesQuery.setFilter(":uuids.contains(uuid)"); + policiesQuery.setParameters(policyUuids); + final List policies = executeAndCloseList(policiesQuery); + + for (final Policy policy : policies) { + if (policy.getTags() == null || policy.getTags().isEmpty()) { + continue; + } + + policy.getTags().remove(tag); + } + }); + } + @Override public PaginatedResult getTagsForPolicy(String policyUuid) { diff --git a/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java b/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java index 60a3e12ca..d8c30af39 100644 --- a/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java @@ -322,7 +322,10 @@ public Response removeProjectFromPolicy( @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Adds a tag to a policy", - description = "

Requires permission POLICY_MANAGEMENT or POLICY_MANAGEMENT_UPDATE

" + description = """ +

Deprecated. Use POST /api/v1/tag/{name}/policy instead.

+

Requires permission POLICY_MANAGEMENT

+ """ ) @ApiResponses(value = { @ApiResponse( @@ -335,6 +338,7 @@ public Response removeProjectFromPolicy( @ApiResponse(responseCode = "404", description = "The policy or tag could not be found") }) @PermissionRequired({Permissions.Constants.POLICY_MANAGEMENT, Permissions.Constants.POLICY_MANAGEMENT_UPDATE}) + @Deprecated(forRemoval = true) public Response addTagToPolicy( @Parameter(description = "The UUID of the policy to add a project to", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("policyUuid") @ValidUuid String policyUuid, @@ -363,7 +367,10 @@ public Response addTagToPolicy( @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Removes a tag from a policy", - description = "

Requires permission POLICY_MANAGEMENT or POLICY_MANAGEMENT_DELETE

" + description = """ +

Deprecated. Use DELETE /api/v1/tag/{name}/policy instead.

+

Requires permission POLICY_MANAGEMENT

+ """ ) @ApiResponses(value = { @ApiResponse( @@ -376,6 +383,7 @@ public Response addTagToPolicy( @ApiResponse(responseCode = "404", description = "The policy or tag could not be found") }) @PermissionRequired({Permissions.Constants.POLICY_MANAGEMENT, Permissions.Constants.POLICY_MANAGEMENT_DELETE}) + @Deprecated(forRemoval = true) public Response removeTagFromPolicy( @Parameter(description = "The UUID of the policy to remove the tag from", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("policyUuid") @ValidUuid String policyUuid, diff --git a/src/main/java/org/dependencytrack/resources/v1/TagResource.java b/src/main/java/org/dependencytrack/resources/v1/TagResource.java index 00ff7d0ce..6ada14607 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -315,6 +315,98 @@ public Response getTaggedPolicies( return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); } + @POST + @Path("/{name}/policy") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Tags one or more policies.", + description = "

Requires permission POLICY_MANAGEMENT

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Policies tagged successfully." + ), + @ApiResponse( + responseCode = "404", + description = "A tag with the provided name does not exist.", + content = @Content(schema = @Schema(implementation = ProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON) + ) + }) + @PermissionRequired(Permissions.Constants.POLICY_MANAGEMENT) + public Response tagPolicies( + @Parameter(description = "Name of the tag to assign", required = true) + @PathParam("name") final String tagName, + @Parameter( + description = "UUIDs of policies to tag", + required = true, + array = @ArraySchema(schema = @Schema(type = "string", format = "uuid")) + ) + @Size(min = 1, max = 100) final Set<@ValidUuid String> policyUuids + ) { + try (final var qm = new QueryManager(getAlpineRequest())) { + qm.tagPolicies(tagName, policyUuids); + } catch (NoSuchElementException nseException) { + // TODO: Move this to an ExceptionMapper once https://github.com/stevespringett/Alpine/pull/588 is available. + return Response + .status(404) + .header("Content-Type", ProblemDetails.MEDIA_TYPE_JSON) + .entity(new ProblemDetails(404, "Resource does not exist", nseException.getMessage())) + .build(); + } catch (RuntimeException e) { + throw e; + } + + return Response.noContent().build(); + } + + @DELETE + @Path("/{name}/policy") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Untags one or more policies.", + description = "

Requires permission POLICY_MANAGEMENT

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Policies untagged successfully." + ), + @ApiResponse( + responseCode = "404", + description = "A tag with the provided name does not exist.", + content = @Content(schema = @Schema(implementation = ProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON) + ) + }) + @PermissionRequired(Permissions.Constants.POLICY_MANAGEMENT) + public Response untagPolicies( + @Parameter(description = "Name of the tag", required = true) + @PathParam("name") final String tagName, + @Parameter( + description = "UUIDs of policies to untag", + required = true, + array = @ArraySchema(schema = @Schema(type = "string", format = "uuid")) + ) + @Size(min = 1, max = 100) final Set<@ValidUuid String> policyUuids + ) { + try (final var qm = new QueryManager(getAlpineRequest())) { + qm.untagPolicies(tagName, policyUuids); + } catch (NoSuchElementException nseException) { + // TODO: Move this to an ExceptionMapper once https://github.com/stevespringett/Alpine/pull/588 is available. + return Response + .status(404) + .header("Content-Type", ProblemDetails.MEDIA_TYPE_JSON) + .entity(new ProblemDetails(404, "Resource does not exist", nseException.getMessage())) + .build(); + } catch (RuntimeException e) { + throw e; + } + + return Response.noContent().build(); + } + @GET @Path("/policy/{uuid}") @Produces(MediaType.APPLICATION_JSON) diff --git a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java index 6e6fce69f..e1d099749 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java @@ -628,14 +628,14 @@ public void tagProjectsTest() { @Test public void tagProjectsWithTagNotExistsTest() { initializeWithPermissions(Permissions.PORTFOLIO_MANAGEMENT); - final var projectA = new Project(); - projectA.setName("acme-app-a"); - qm.persist(projectA); + final var project = new Project(); + project.setName("acme-app-a"); + qm.persist(project); final Response response = jersey.target(V1_TAG + "/foo/project") .request() .header(X_API_KEY, apiKey) - .post(Entity.json(List.of(projectA.getUuid()))); + .post(Entity.json(List.of(project.getUuid()))); assertThat(response.getStatus()).isEqualTo(404); assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json"); assertThatJson(getPlainTextBody(response)).isEqualTo(""" @@ -706,21 +706,21 @@ public void tagProjectsWithAclTest() { @Test public void tagProjectsWhenAlreadyTaggedTest() { initializeWithPermissions(Permissions.PORTFOLIO_MANAGEMENT); - final var projectA = new Project(); - projectA.setName("acme-app-a"); - qm.persist(projectA); + final var project = new Project(); + project.setName("acme-app-a"); + qm.persist(project); final Tag tag = qm.createTag("foo"); - qm.bind(projectA, List.of(tag)); + qm.bind(project, List.of(tag)); final Response response = jersey.target(V1_TAG + "/foo/project") .request() .header(X_API_KEY, apiKey) - .post(Entity.json(List.of(projectA.getUuid()))); + .post(Entity.json(List.of(project.getUuid()))); assertThat(response.getStatus()).isEqualTo(204); qm.getPersistenceManager().evictAll(); - assertThat(projectA.getTags()).satisfiesExactly(projectTag -> assertThat(projectTag.getName()).isEqualTo("foo")); + assertThat(project.getTags()).satisfiesExactly(projectTag -> assertThat(projectTag.getName()).isEqualTo("foo")); } @Test @@ -790,15 +790,15 @@ public void untagProjectsWithAclTest() { @Test public void untagProjectsWithTagNotExistsTest() { initializeWithPermissions(Permissions.PORTFOLIO_MANAGEMENT); - final var projectA = new Project(); - projectA.setName("acme-app-a"); - qm.persist(projectA); + final var project = new Project(); + project.setName("acme-app-a"); + qm.persist(project); final Response response = jersey.target(V1_TAG + "/foo/project") .request() .header(X_API_KEY, apiKey) .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) - .method(HttpMethod.DELETE, Entity.json(List.of(projectA.getUuid()))); + .method(HttpMethod.DELETE, Entity.json(List.of(project.getUuid()))); assertThat(response.getStatus()).isEqualTo(404); assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json"); assertThatJson(getPlainTextBody(response)).isEqualTo(""" @@ -864,9 +864,9 @@ public void untagProjectsWithTooManyProjectUuidsTest() { @Test public void untagProjectsWhenNotTaggedTest() { initializeWithPermissions(Permissions.PORTFOLIO_MANAGEMENT); - final var projectA = new Project(); - projectA.setName("acme-app-a"); - qm.persist(projectA); + final var project = new Project(); + project.setName("acme-app-a"); + qm.persist(project); qm.createTag("foo"); @@ -874,11 +874,11 @@ public void untagProjectsWhenNotTaggedTest() { .request() .header(X_API_KEY, apiKey) .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) - .method(HttpMethod.DELETE, Entity.json(List.of(projectA.getUuid()))); + .method(HttpMethod.DELETE, Entity.json(List.of(project.getUuid()))); assertThat(response.getStatus()).isEqualTo(204); qm.getPersistenceManager().evictAll(); - assertThat(projectA.getTags()).isEmpty(); + assertThat(project.getTags()).isEmpty(); } @Test @@ -1005,6 +1005,217 @@ public void getTaggedPoliciesWithNonLowerCaseTagNameTest() { assertThat(getPlainTextBody(response)).isEqualTo("[]"); } + @Test + public void tagPoliciesTest() { + initializeWithPermissions(Permissions.POLICY_MANAGEMENT); + + final var policyA = new Policy(); + policyA.setName("policy-a"); + policyA.setOperator(Policy.Operator.ALL); + policyA.setViolationState(Policy.ViolationState.INFO); + qm.persist(policyA); + + final var policyB = new Policy(); + policyB.setName("policy-b"); + policyB.setOperator(Policy.Operator.ALL); + policyB.setViolationState(Policy.ViolationState.INFO); + qm.persist(policyB); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(List.of(policyA.getUuid(), policyB.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(policyA.getTags()).satisfiesExactly(policyTag -> assertThat(policyTag.getName()).isEqualTo("foo")); + assertThat(policyB.getTags()).satisfiesExactly(policyTag -> assertThat(policyTag.getName()).isEqualTo("foo")); + } + + @Test + public void tagPoliciesWithTagNotExistsTest() { + initializeWithPermissions(Permissions.POLICY_MANAGEMENT); + + final var policy = new Policy(); + policy.setName("policy"); + policy.setOperator(Policy.Operator.ALL); + policy.setViolationState(Policy.ViolationState.INFO); + qm.persist(policy); + + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(List.of(policy.getUuid()))); + assertThat(response.getStatus()).isEqualTo(404); + assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 404, + "title": "Resource does not exist", + "detail": "A tag with name foo does not exist" + } + """); + } + + @Test + public void tagPoliciesWithNoPolicyUuidsTest() { + initializeWithPermissions(Permissions.POLICY_MANAGEMENT); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(Collections.emptyList())); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "size must be between 1 and 100", + "messageTemplate": "{jakarta.validation.constraints.Size.message}", + "path": "tagPolicies.policyUuids", + "invalidValue": "[]" + } + ] + """); + } + + @Test + public void untagPoliciesTest() { + initializeWithPermissions(Permissions.POLICY_MANAGEMENT); + + final var policyA = new Policy(); + policyA.setName("policy-a"); + policyA.setOperator(Policy.Operator.ALL); + policyA.setViolationState(Policy.ViolationState.INFO); + qm.persist(policyA); + + final var policyB = new Policy(); + policyB.setName("policy-b"); + policyB.setOperator(Policy.Operator.ALL); + policyB.setViolationState(Policy.ViolationState.INFO); + qm.persist(policyB); + + final Tag tag = qm.createTag("foo"); + qm.bind(policyA, List.of(tag)); + qm.bind(policyB, List.of(tag)); + + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(policyA.getUuid(), policyB.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(policyA.getTags()).isEmpty(); + assertThat(policyB.getTags()).isEmpty(); + } + + @Test + public void untagPoliciesWithTagNotExistsTest() { + initializeWithPermissions(Permissions.POLICY_MANAGEMENT); + + final var policy = new Policy(); + policy.setName("policy"); + policy.setOperator(Policy.Operator.ALL); + policy.setViolationState(Policy.ViolationState.INFO); + qm.persist(policy); + + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(policy.getUuid()))); + assertThat(response.getStatus()).isEqualTo(404); + assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 404, + "title": "Resource does not exist", + "detail": "A tag with name foo does not exist" + } + """); + } + + @Test + public void untagPoliciesWithNoProjectUuidsTest() { + initializeWithPermissions(Permissions.POLICY_MANAGEMENT); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(Collections.emptyList())); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "size must be between 1 and 100", + "messageTemplate": "{jakarta.validation.constraints.Size.message}", + "path": "untagPolicies.policyUuids", + "invalidValue": "[]" + } + ] + """); + } + + @Test + public void untagPoliciesWithTooManyPolicyUuidsTest() { + initializeWithPermissions(Permissions.POLICY_MANAGEMENT); + + qm.createTag("foo"); + + final List policyUuids = IntStream.range(0, 101) + .mapToObj(ignored -> UUID.randomUUID()) + .map(UUID::toString) + .toList(); + + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(policyUuids)); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "size must be between 1 and 100", + "messageTemplate": "{jakarta.validation.constraints.Size.message}", + "path": "untagPolicies.policyUuids", + "invalidValue": "${json-unit.any-string}" + } + ] + """); + } + + @Test + public void untagPoliciesWhenNotTaggedTest() { + initializeWithPermissions(Permissions.POLICY_MANAGEMENT); + + final var policy = new Policy(); + policy.setName("policy"); + policy.setOperator(Policy.Operator.ALL); + policy.setViolationState(Policy.ViolationState.INFO); + qm.persist(policy); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/policy") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(policy.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(policy.getTags()).isEmpty(); + } + @Test public void getTagsForPolicyWithOrderingTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO);