Skip to content

Commit

Permalink
Merge pull request #3896 from nscuro/tag-delete-endpoint
Browse files Browse the repository at this point in the history
Add REST endpoint for tag deletion
  • Loading branch information
nscuro authored Jun 30, 2024
2 parents a2b2a66 + d8ac537 commit e643409
Show file tree
Hide file tree
Showing 17 changed files with 737 additions and 45 deletions.
4 changes: 3 additions & 1 deletion src/main/java/org/dependencytrack/auth/Permissions.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public enum Permissions {
ACCESS_MANAGEMENT("Allows the management of users, teams, and API keys"),
SYSTEM_CONFIGURATION("Allows the configuration of the system including notifications, repositories, and email settings"),
PROJECT_CREATION_UPLOAD("Provides the ability to optionally create project (if non-existent) on BOM or scan upload"),
POLICY_MANAGEMENT("Allows the creation, modification, and deletion of policy");
POLICY_MANAGEMENT("Allows the creation, modification, and deletion of policy"),
TAG_MANAGEMENT("Allows the modification and deletion of tags");

private final String description;

Expand All @@ -62,6 +63,7 @@ public static class Constants {
public static final String SYSTEM_CONFIGURATION = "SYSTEM_CONFIGURATION";
public static final String PROJECT_CREATION_UPLOAD = "PROJECT_CREATION_UPLOAD";
public static final String POLICY_MANAGEMENT = "POLICY_MANAGEMENT";
public static final String TAG_MANAGEMENT = "TAG_MANAGEMENT";
}

}
Original file line number Diff line number Diff line change
@@ -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.exception;

import java.util.Map;

/**
* @since 4.12.0
*/
public class TagOperationFailedException extends IllegalStateException {

private final Map<String, String> errorByTagName;

private TagOperationFailedException(final String message, final Map<String, String> errorByTagName) {
super(message);
this.errorByTagName = errorByTagName;
}

public static TagOperationFailedException forDeletion(final Map<String, String> errorByTagName) {
return new TagOperationFailedException("The tag(s) %s could not be deleted"
.formatted(String.join(",", errorByTagName.keySet())), errorByTagName);
}

public Map<String, String> getErrorByTagName() {
return errorByTagName;
}

}
2 changes: 1 addition & 1 deletion src/main/java/org/dependencytrack/model/Policy.java
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ public enum ViolationState {
/**
* A list of zero-to-n tags
*/
@Persistent(table = "POLICY_TAGS", defaultFetchGroup = "true")
@Persistent(table = "POLICY_TAGS", defaultFetchGroup = "true", mappedBy = "policies")
@Join(column = "POLICY_ID")
@Element(column = "TAG_ID")
@Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC"))
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/org/dependencytrack/model/Tag.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ public class Tag implements Serializable {
@Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The name may only contain printable characters")
private String name;

@Persistent
@JsonIgnore
@Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC"))
private List<Policy> policies;

@Persistent
@JsonIgnore
@Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC"))
Expand All @@ -84,6 +89,14 @@ public void setName(String name) {
this.name = name;
}

public List<Policy> getPolicies() {
return policies;
}

public void setPolicies(List<Policy> policies) {
this.policies = policies;
}

public List<Project> getProjects() {
return projects;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,21 @@
import org.dependencytrack.model.PolicyCondition;
import org.dependencytrack.model.PolicyViolation;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.Tag;
import org.dependencytrack.model.ViolationAnalysis;
import org.dependencytrack.model.ViolationAnalysisComment;
import org.dependencytrack.model.ViolationAnalysisState;

import javax.jdo.PersistenceManager;
import javax.jdo.Query;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;

import static org.dependencytrack.util.PersistenceUtil.assertPersistent;
import static org.dependencytrack.util.PersistenceUtil.assertPersistentAll;

final class PolicyQueryManager extends QueryManager implements IQueryManager {

/**
Expand Down Expand Up @@ -611,4 +616,33 @@ public long getAuditedCount(final Component component, final PolicyViolation.Typ
return getCount(query, component, type, ViolationAnalysisState.NOT_SET);
}

/**
* @since 4.12.0
*/
@Override
public boolean bind(final Policy policy, final Collection<Tag> tags) {
assertPersistent(policy, "policy must be persistent");
assertPersistentAll(tags, "tags must be persistent");

return callInTransaction(() -> {
boolean modified = false;

for (final Tag tag : tags) {
if (!policy.getTags().contains(tag)) {
policy.getTags().add(tag);

if (tag.getPolicies() == null) {
tag.setPolicies(new ArrayList<>(List.of(policy)));
} else if (!tag.getPolicies().contains(policy)) {
tag.getPolicies().add(policy);
}

modified = true;
}
}

return modified;
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,10 @@ public void bind(Project project, List<Tag> tags) {
getProjectQueryManager().bind(project, tags);
}

public boolean bind(final Policy policy, final Collection<Tag> tags) {
return getPolicyQueryManager().bind(policy, tags);
}

/**
* Commits the Lucene index.
* @param commitIndex specifies if the search index should be committed (an expensive operation)
Expand Down Expand Up @@ -1336,6 +1340,10 @@ public List<TagQueryManager.TagListRow> getTags() {
return getTagQueryManager().getTags();
}

public void deleteTags(final Collection<String> tagNames) {
getTagQueryManager().deleteTags(tagNames);
}

public List<TagQueryManager.TaggedProjectRow> getTaggedProjects(final String tagName) {
return getTagQueryManager().getTaggedProjects(tagName);
}
Expand Down
166 changes: 153 additions & 13 deletions src/main/java/org/dependencytrack/persistence/TagQueryManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@
package org.dependencytrack.persistence;

import alpine.common.logging.Logger;
import alpine.model.ApiKey;
import alpine.model.UserPrincipal;
import alpine.persistence.OrderDirection;
import alpine.persistence.PaginatedResult;
import alpine.resources.AlpineRequest;
import org.dependencytrack.auth.Permissions;
import org.dependencytrack.exception.TagOperationFailedException;
import org.dependencytrack.model.Policy;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.Tag;
Expand All @@ -35,6 +39,8 @@
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class TagQueryManager extends QueryManager implements IQueryManager {
Expand All @@ -46,6 +52,7 @@ public class TagQueryManager extends QueryManager implements IQueryManager {

/**
* Constructs a new QueryManager.
*
* @param pm a PersistenceManager object
*/
TagQueryManager(final PersistenceManager pm) {
Expand All @@ -54,7 +61,8 @@ public class TagQueryManager extends QueryManager implements IQueryManager {

/**
* Constructs a new QueryManager.
* @param pm a PersistenceManager object
*
* @param pm a PersistenceManager object
* @param request an AlpineRequest object
*/
TagQueryManager(final PersistenceManager pm, final AlpineRequest request) {
Expand Down Expand Up @@ -128,6 +136,138 @@ public List<TagListRow> getTags() {
}
}

public record TagDeletionCandidateRow(
String name,
long projectCount,
long accessibleProjectCount,
long policyCount
) {

@SuppressWarnings("unused") // DataNucleus will use this for MSSQL.
public TagDeletionCandidateRow(
final String name,
final int projectCount,
final int accessibleProjectCount,
final int policyCount
) {
this(name, (long) projectCount, (long) accessibleProjectCount, (long) policyCount);
}

}

public void deleteTags(final Collection<String> tagNames) {
runInTransaction(() -> {
final Map.Entry<String, Map<String, Object>> projectAclConditionAndParams = getProjectAclSqlCondition();
final String projectAclCondition = projectAclConditionAndParams.getKey();
final Map<String, Object> projectAclConditionParams = projectAclConditionAndParams.getValue();

final var tagNameFilters = new ArrayList<String>(tagNames.size());
final var params = new HashMap<>(projectAclConditionParams);

int paramIndex = 0;
for (final String tagName : tagNames) {
final var paramName = "tagName" + (++paramIndex);
tagNameFilters.add("\"NAME\" = :" + paramName);
params.put(paramName, tagName);
}

final Query<?> candidateQuery = pm.newQuery(Query.SQL, /* language=SQL */ """
SELECT "NAME"
, (SELECT COUNT(*)
FROM "PROJECTS_TAGS"
INNER JOIN "PROJECT"
ON "PROJECT"."ID" = "PROJECTS_TAGS"."PROJECT_ID"
WHERE "PROJECTS_TAGS"."TAG_ID" = "TAG"."ID") AS "projectCount"
, (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 "accessibleProjectCount"
, (SELECT COUNT(*)
FROM "POLICY_TAGS"
INNER JOIN "POLICY"
ON "POLICY"."ID" = "POLICY_TAGS"."POLICY_ID"
WHERE "POLICY_TAGS"."TAG_ID" = "TAG"."ID") AS "policyCount"
FROM "TAG"
WHERE %s
""".formatted(projectAclCondition, String.join(" OR ", tagNameFilters)));
candidateQuery.setNamedParameters(params);
final List<TagDeletionCandidateRow> candidateRows;
try {
candidateRows = List.copyOf(candidateQuery.executeResultList(TagDeletionCandidateRow.class));
} finally {
candidateQuery.closeAll();
}

final var errorByTagName = new HashMap<String, String>();

if (tagNames.size() > candidateRows.size()) {
final Set<String> candidateRowNames = candidateRows.stream()
.map(TagDeletionCandidateRow::name)
.collect(Collectors.toSet());
for (final String tagName : tagNames) {
if (!candidateRowNames.contains(tagName)) {
errorByTagName.put(tagName, "Tag does not exist");
}
}

throw TagOperationFailedException.forDeletion(errorByTagName);
}

boolean hasPortfolioManagementPermission = false;
boolean hasPolicyManagementPermission = false;
if (principal == null) {
hasPortfolioManagementPermission = true;
hasPolicyManagementPermission = true;
} else {
if (principal instanceof final ApiKey apiKey) {
hasPortfolioManagementPermission = hasPermission(apiKey, Permissions.Constants.PORTFOLIO_MANAGEMENT);
hasPolicyManagementPermission = hasPermission(apiKey, Permissions.Constants.POLICY_MANAGEMENT);
} else if (principal instanceof final UserPrincipal user) {
hasPortfolioManagementPermission = hasPermission(user, Permissions.Constants.PORTFOLIO_MANAGEMENT, /* includeTeams */ true);
hasPolicyManagementPermission = hasPermission(user, Permissions.Constants.POLICY_MANAGEMENT, /* includeTeams */ true);
}
}

for (final TagDeletionCandidateRow row : candidateRows) {
if (row.projectCount() > 0 && !hasPortfolioManagementPermission) {
errorByTagName.put(row.name(), """
The tag is assigned to %d project(s), but the authenticated principal \
is missing the %s permission.""".formatted(row.projectCount(), Permissions.PORTFOLIO_MANAGEMENT));
continue;
}

final long inaccessibleProjectAssignmentCount =
row.projectCount - row.accessibleProjectCount();
if (inaccessibleProjectAssignmentCount > 0) {
errorByTagName.put(row.name(), """
The tag is assigned to %d project(s) that are not accessible \
by the authenticated principal.""".formatted(inaccessibleProjectAssignmentCount));
continue;
}

if (row.policyCount() > 0 && !hasPolicyManagementPermission) {
errorByTagName.put(row.name(), """
The tag is assigned to %d policies, but the authenticated principal \
is missing the %s permission.""".formatted(row.policyCount(), Permissions.POLICY_MANAGEMENT));
}
}

if (!errorByTagName.isEmpty()) {
throw TagOperationFailedException.forDeletion(errorByTagName);
}

final Query<Tag> deletionQuery = pm.newQuery(Tag.class);
deletionQuery.setFilter(":names.contains(name)");
try {
deletionQuery.deletePersistentAll(candidateRows.stream().map(TagDeletionCandidateRow::name).toList());
} finally {
deletionQuery.closeAll();
}
});
}

/**
* @since 4.12.0
*/
Expand All @@ -151,18 +291,18 @@ public List<TaggedProjectRow> getTaggedProjects(final String tagName) {

// 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);
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);
Expand Down
Loading

0 comments on commit e643409

Please sign in to comment.