Skip to content

Commit 9b62a40

Browse files
authored
Merge pull request #4 from rbt-mm/rbt-allow-selection-of-teams-as-recipients-for-alert-rules
Allow teams as recipients for email alerts
2 parents 4b8e98f + 1039a2a commit 9b62a40

File tree

9 files changed

+746
-15
lines changed

9 files changed

+746
-15
lines changed

src/main/java/org/dependencytrack/model/NotificationRule.java

+15
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
package org.dependencytrack.model;
2020

2121
import alpine.common.validation.RegexSequence;
22+
import alpine.model.Team;
2223
import alpine.notification.NotificationLevel;
2324
import alpine.server.json.TrimmedStringDeserializer;
2425
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -99,6 +100,12 @@ public class NotificationRule implements Serializable {
99100
@Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC, version ASC"))
100101
private List<Project> projects;
101102

103+
@Persistent(table = "NOTIFICATIONRULE_TEAMS", defaultFetchGroup = "true")
104+
@Join(column = "NOTIFICATIONRULE_ID")
105+
@Element(column = "TEAM_ID")
106+
@Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC"))
107+
private List<Team> teams;
108+
102109
@Persistent
103110
@Column(name = "NOTIFY_ON", length = 1024)
104111
private String notifyOn;
@@ -175,6 +182,14 @@ public void setProjects(List<Project> projects) {
175182
this.projects = projects;
176183
}
177184

185+
public List<Team> getTeams() {
186+
return teams;
187+
}
188+
189+
public void setTeams(List<Team> teams) {
190+
this.teams = teams;
191+
}
192+
178193
public String getMessage() {
179194
return message;
180195
}

src/main/java/org/dependencytrack/notification/NotificationRouter.java

+9-2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.dependencytrack.model.NotificationRule;
2828
import org.dependencytrack.model.Project;
2929
import org.dependencytrack.notification.publisher.Publisher;
30+
import org.dependencytrack.notification.publisher.SendMailPublisher;
3031
import org.dependencytrack.notification.vo.AnalysisDecisionChange;
3132
import org.dependencytrack.notification.vo.BomConsumedOrProcessed;
3233
import org.dependencytrack.notification.vo.NewVulnerabilityIdentified;
@@ -75,7 +76,13 @@ public void inform(final Notification notification) {
7576
.add(Publisher.CONFIG_TEMPLATE_KEY, notificationPublisher.getTemplate())
7677
.addAll(Json.createObjectBuilder(config))
7778
.build();
78-
publisher.inform(restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig);
79+
if (publisherClass != SendMailPublisher.class || rule.getTeams().isEmpty() || rule.getTeams() == null){
80+
publisher.inform(restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig);
81+
} else {
82+
((SendMailPublisher)publisher).inform(restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig, rule.getTeams());
83+
}
84+
85+
7986
} else {
8087
LOGGER.error("The defined notification publisher is not assignable from " + Publisher.class.getCanonicalName());
8188
}
@@ -145,7 +152,7 @@ List<NotificationRule> resolveRules(final Notification notification) {
145152
sb.append("enabled == true && scope == :scope"); //todo: improve this - this only works for testing
146153
query.setFilter(sb.toString());
147154
final List<NotificationRule> result = (List<NotificationRule>)query.execute(NotificationScope.valueOf(notification.getScope()));
148-
155+
pm.detachCopyAll(result);
149156

150157
if (NotificationScope.PORTFOLIO.name().equals(notification.getScope())
151158
&& notification.getSubject() != null && notification.getSubject() instanceof NewVulnerabilityIdentified) {

src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java

+38-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
import alpine.common.logging.Logger;
2222
import alpine.common.util.BooleanUtil;
2323
import alpine.model.ConfigProperty;
24+
import alpine.model.Team;
25+
import alpine.model.ManagedUser;
26+
import alpine.model.LdapUser;
27+
import alpine.model.OidcUser;
2428
import alpine.notification.Notification;
2529
import alpine.security.crypto.DataEncryption;
2630
import alpine.server.mail.SendMail;
@@ -33,8 +37,13 @@
3337
import org.dependencytrack.persistence.QueryManager;
3438

3539
import javax.json.JsonObject;
36-
import java.util.ArrayList;
3740
import java.util.List;
41+
import java.util.Arrays;
42+
import java.util.Collections;
43+
import java.util.Objects;
44+
import java.util.Optional;
45+
import java.util.stream.Stream;
46+
import java.util.function.Predicate;
3847

3948
import static org.dependencytrack.model.ConfigPropertyConstants.*;
4049

@@ -49,6 +58,19 @@ public void inform(final Notification notification, final JsonObject config) {
4958
return;
5059
}
5160
final String[] destinations = parseDestination(config);
61+
sendNotification(notification, config, destinations);
62+
}
63+
64+
public void inform(final Notification notification, final JsonObject config, List<Team> teams) {
65+
if (config == null) {
66+
LOGGER.warn("No configuration found. Skipping notification.");
67+
return;
68+
}
69+
final String[] destinations = parseDestination(config, teams);
70+
sendNotification(notification, config, destinations);
71+
}
72+
73+
private void sendNotification(Notification notification, JsonObject config, String[] destinations) {
5274
PebbleTemplate template = getTemplate(config);
5375
String mimeType = getTemplateMimeType(config);
5476
final String content = prepareTemplate(notification, template);
@@ -104,4 +126,19 @@ static String[] parseDestination(final JsonObject config) {
104126
return destinationString.split(",");
105127
}
106128

129+
static String[] parseDestination(final JsonObject config, final List<Team> teams) {
130+
String[] destination = teams.stream().flatMap(
131+
team -> Stream.of(
132+
Arrays.stream(config.getString("destination").split(",")).filter(Predicate.not(String::isEmpty)),
133+
Optional.ofNullable(team.getManagedUsers()).orElseGet(Collections::emptyList).stream().map(ManagedUser::getEmail).filter(Objects::nonNull),
134+
Optional.ofNullable(team.getLdapUsers()).orElseGet(Collections::emptyList).stream().map(LdapUser::getEmail).filter(Objects::nonNull),
135+
Optional.ofNullable(team.getOidcUsers()).orElseGet(Collections::emptyList).stream().map(OidcUser::getEmail).filter(Objects::nonNull)
136+
)
137+
.reduce(Stream::concat)
138+
.orElseGet(Stream::empty)
139+
)
140+
.distinct()
141+
.toArray(String[]::new);
142+
return destination.length == 0 ? null : destination;
143+
}
107144
}

src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java

+13
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
package org.dependencytrack.persistence;
2020

21+
import alpine.model.Team;
2122
import alpine.notification.NotificationLevel;
2223
import alpine.persistence.PaginatedResult;
2324
import alpine.resources.AlpineRequest;
@@ -204,6 +205,18 @@ public void removeProjectFromNotificationRules(final Project project) {
204205
}
205206
}
206207

208+
/**
209+
* Removes teams from NotificationRules
210+
*/
211+
@SuppressWarnings("unchecked")
212+
public void removeTeamFromNotificationRules(final Team team) {
213+
final Query<NotificationRule> query = pm.newQuery(NotificationRule.class, "teams.contains(:team)");
214+
for (final NotificationRule rule: (List<NotificationRule>) query.execute(team)) {
215+
rule.getTeams().remove(team);
216+
persist(rule);
217+
}
218+
}
219+
207220
/**
208221
* Delete a notification publisher and associated rules.
209222
*/

src/main/java/org/dependencytrack/persistence/QueryManager.java

+4
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,10 @@ public void removeProjectFromNotificationRules(final Project project) {
10521052
getNotificationQueryManager().removeProjectFromNotificationRules(project);
10531053
}
10541054

1055+
public void removeTeamFromNotificationRules(final Team team) {
1056+
getNotificationQueryManager().removeTeamFromNotificationRules(team);
1057+
}
1058+
10551059
/**
10561060
* Determines if a config property is enabled or not.
10571061
* @param configPropertyConstants the property to query

src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java

+90
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
package org.dependencytrack.resources.v1;
2020

2121
import alpine.common.logging.Logger;
22+
import alpine.model.Team;
2223
import alpine.persistence.PaginatedResult;
2324
import alpine.server.auth.PermissionRequired;
2425
import alpine.server.resources.AlpineResource;
@@ -35,6 +36,7 @@
3536
import org.dependencytrack.model.NotificationRule;
3637
import org.dependencytrack.model.Project;
3738
import org.dependencytrack.notification.NotificationScope;
39+
import org.dependencytrack.notification.publisher.DefaultNotificationPublishers;
3840
import org.dependencytrack.persistence.QueryManager;
3941

4042
import javax.validation.Validator;
@@ -255,4 +257,92 @@ public Response removeProjectFromRule(
255257
return Response.status(Response.Status.NOT_MODIFIED).build();
256258
}
257259
}
260+
261+
@POST
262+
@Path("/{ruleUuid}/team/{teamUuid}")
263+
@Consumes(MediaType.APPLICATION_JSON)
264+
@Produces(MediaType.APPLICATION_JSON)
265+
@ApiOperation(
266+
value = "Adds a team to a notification rule",
267+
response = NotificationRule.class
268+
)
269+
@ApiResponses(value = {
270+
@ApiResponse(code = 304, message = "The rule already has the specified team assigned"),
271+
@ApiResponse(code = 401, message = "Unauthorized"),
272+
@ApiResponse(code = 404, message = "The notification rule or team could not be found")
273+
})
274+
@PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION)
275+
public Response addTeamToRule(
276+
@ApiParam(value = "The UUID of the rule to add a team to", required = true)
277+
@PathParam("ruleUuid") String ruleUuid,
278+
@ApiParam(value = "The UUID of the team to add to the rule", required = true)
279+
@PathParam("teamUuid") String teamUuid) {
280+
try (QueryManager qm = new QueryManager()) {
281+
final NotificationRule rule = qm.getObjectByUuid(NotificationRule.class, ruleUuid);
282+
if (rule == null) {
283+
return Response.status(Response.Status.NOT_FOUND).entity("The notification rule could not be found.").build();
284+
}
285+
if (rule.getScope() != NotificationScope.PORTFOLIO) {
286+
return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Team subscriptions are only possible on notification rules with PORTFOLIO scope.").build();
287+
}
288+
if (!rule.getPublisher().getName().equals(DefaultNotificationPublishers.EMAIL.getPublisherName())) {
289+
return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Team subscriptions are only possible on notification rules with EMAIL publisher.").build();
290+
}
291+
final Team team = qm.getObjectByUuid(Team.class, teamUuid);
292+
if (team == null) {
293+
return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build();
294+
}
295+
final List<Team> teams = rule.getTeams();
296+
if (teams != null && !teams.contains(team)) {
297+
rule.getTeams().add(team);
298+
qm.persist(rule);
299+
return Response.ok(rule).build();
300+
}
301+
return Response.status(Response.Status.NOT_MODIFIED).build();
302+
}
303+
}
304+
305+
@DELETE
306+
@Path("/{ruleUuid}/team/{teamUuid}")
307+
@Consumes(MediaType.APPLICATION_JSON)
308+
@Produces(MediaType.APPLICATION_JSON)
309+
@ApiOperation(
310+
value = "Removes a team from a notification rule",
311+
response = NotificationRule.class
312+
)
313+
@ApiResponses(value = {
314+
@ApiResponse(code = 304, message = "The rule does not have the specified team assigned"),
315+
@ApiResponse(code = 401, message = "Unauthorized"),
316+
@ApiResponse(code = 404, message = "The notification rule or team could not be found")
317+
})
318+
@PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION)
319+
public Response removeTeamFromRule(
320+
@ApiParam(value = "The UUID of the rule to remove the project from", required = true)
321+
@PathParam("ruleUuid") String ruleUuid,
322+
@ApiParam(value = "The UUID of the project to remove from the rule", required = true)
323+
@PathParam("teamUuid") String teamUuid) {
324+
try (QueryManager qm = new QueryManager()) {
325+
final NotificationRule rule = qm.getObjectByUuid(NotificationRule.class, ruleUuid);
326+
if (rule == null) {
327+
return Response.status(Response.Status.NOT_FOUND).entity("The notification rule could not be found.").build();
328+
}
329+
if (rule.getScope() != NotificationScope.PORTFOLIO) {
330+
return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Team subscriptions are only possible on notification rules with PORTFOLIO scope.").build();
331+
}
332+
if (!rule.getPublisher().getName().equals(DefaultNotificationPublishers.EMAIL.getPublisherName())) {
333+
return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Team subscriptions are only possible on notification rules with EMAIL publisher.").build();
334+
}
335+
final Team team = qm.getObjectByUuid(Team.class, teamUuid);
336+
if (team == null) {
337+
return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build();
338+
}
339+
final List<Team> teams = rule.getTeams();
340+
if (teams != null && teams.contains(team)) {
341+
rule.getTeams().remove(team);
342+
qm.persist(rule);
343+
return Response.ok(rule).build();
344+
}
345+
return Response.status(Response.Status.NOT_MODIFIED).build();
346+
}
347+
}
258348
}

0 commit comments

Comments
 (0)