From cb8dcdb26cff9cbe6fb7a2a116ea0c5457e24038 Mon Sep 17 00:00:00 2001 From: Adrian Trzeciak Date: Fri, 27 Jan 2023 21:09:29 +0100 Subject: [PATCH] slack-support --- sources/jitaccess.iml | 233 ++++++++++++++++++ sources/pom.xml | 13 +- .../jitaccess/core/adapters/SlackAdapter.java | 69 ++++++ .../core/services/NotificationService.java | 124 ++++++---- .../solutions/jitaccess/web/ApiResource.java | 158 ++++++------ .../jitaccess/web/RuntimeConfiguration.java | 15 ++ .../jitaccess/web/RuntimeEnvironment.java | 7 + .../services/TestNotificationService.java | 88 +++---- 8 files changed, 530 insertions(+), 177 deletions(-) create mode 100644 sources/jitaccess.iml create mode 100644 sources/src/main/java/com/google/solutions/jitaccess/core/adapters/SlackAdapter.java diff --git a/sources/jitaccess.iml b/sources/jitaccess.iml new file mode 100644 index 000000000..f1dbec012 --- /dev/null +++ b/sources/jitaccess.iml @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sources/pom.xml b/sources/pom.xml index cba2143fd..9e491d370 100644 --- a/sources/pom.xml +++ b/sources/pom.xml @@ -19,7 +19,8 @@ specific language governing permissions and limitations under the License. --> - 4.0.0 com.google.solutions @@ -84,6 +85,16 @@ javax.mail 1.6.2 + + com.slack.api + slack-api-client + 1.27.3 + + + com.squareup.okhttp3 + okhttp + 4.10.0 + diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/adapters/SlackAdapter.java b/sources/src/main/java/com/google/solutions/jitaccess/core/adapters/SlackAdapter.java new file mode 100644 index 000000000..0ec06235a --- /dev/null +++ b/sources/src/main/java/com/google/solutions/jitaccess/core/adapters/SlackAdapter.java @@ -0,0 +1,69 @@ +// +// Copyright 2023 Google LLC +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. +// + +package com.google.solutions.jitaccess.core.adapters; + +import java.net.HttpURLConnection; + +import com.google.common.base.Preconditions; +import com.slack.api.Slack; +import com.slack.api.webhook.Payload; + +public class SlackAdapter { + private final Slack slack; + private final Options options; + + public SlackAdapter(Options options) { + Preconditions.checkNotNull(options, "options"); + this.options = options; + this.slack = Slack.getInstance(); + } + + public void sendSlackMessage(String content) throws SlackException { + Preconditions.checkNotNull(content, "content"); + + try { + var response = slack.send(this.options.slackHookUrl, Payload.builder().text(content).build()); + if (!response.getCode().equals(HttpURLConnection.HTTP_OK)) { + throw new SlackException( + "The Slack notification could not be sent. HTTP Code: " + response.getCode(), null); + } + } catch (Exception e) { + throw new SlackException("The Slack notification could not be sent", e); + } + } + + public static class Options { + private final String slackHookUrl; + + public Options(String slackHookUrl) { + Preconditions.checkNotNull(slackHookUrl, "slackHookUrl"); + + this.slackHookUrl = slackHookUrl; + } + } + + public static class SlackException extends Exception { + public SlackException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/services/NotificationService.java b/sources/src/main/java/com/google/solutions/jitaccess/core/services/NotificationService.java index 07c97a6c3..cbc5cc146 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/core/services/NotificationService.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/core/services/NotificationService.java @@ -2,19 +2,19 @@ // Copyright 2022 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file +// or more contributor license agreements. See the NOTICE file // distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file +// regarding copyright ownership. The ASF licenses this file // to you 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 +// with the License. You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// 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 +// KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. // @@ -25,10 +25,10 @@ import com.google.common.escape.Escaper; import com.google.common.html.HtmlEscapers; import com.google.solutions.jitaccess.core.AccessException; +import com.google.solutions.jitaccess.core.adapters.SlackAdapter; import com.google.solutions.jitaccess.core.adapters.SmtpAdapter; import com.google.solutions.jitaccess.core.data.UserId; -import javax.enterprise.context.ApplicationScoped; import java.io.IOException; import java.time.Instant; import java.time.OffsetDateTime; @@ -41,6 +41,7 @@ import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; +import javax.enterprise.context.ApplicationScoped; /** * Service for notifying users about activation requests.. @@ -53,33 +54,31 @@ public abstract class NotificationService { /** * Load a resource from a JAR resource. + * * @return null if not found. */ - public static String loadResource(String resourceName) throws NotificationException{ - try (var stream = NotificationService.class - .getClassLoader() - .getResourceAsStream(resourceName)) { + public static String loadResource(String resourceName) throws NotificationException { + try (var stream = + NotificationService.class.getClassLoader().getResourceAsStream(resourceName)) { if (stream == null) { return null; } var content = stream.readAllBytes(); - if (content.length > 3 && - content[0] == (byte)0xEF && - content[1] == (byte)0xBB && - content[2] == (byte)0xBF) { + if (content.length > 3 + && content[0] == (byte) 0xEF + && content[1] == (byte) 0xBB + && content[2] == (byte) 0xBF) { // // Strip UTF-8 BOM. // return new String(content, 3, content.length - 3); - } - else { + } else { return new String(content); } - } - catch (IOException e) { + } catch (IOException e) { throw new NotificationException( String.format("Reading the template %s from the JAR file failed", resourceName), e); } @@ -96,10 +95,8 @@ public static class MailNotificationService extends NotificationService { private final Options options; private final SmtpAdapter smtpAdapter; - public MailNotificationService( - SmtpAdapter smtpAdapter, - Options options - ) { + public MailNotificationService(SmtpAdapter smtpAdapter, + Options options) { Preconditions.checkNotNull(smtpAdapter); Preconditions.checkNotNull(options); @@ -116,8 +113,8 @@ public boolean canSendNotifications() { public void sendNotification(Notification notification) throws NotificationException { Preconditions.checkNotNull(notification, "notification"); - var htmlTemplate = loadResource( - String.format("notifications/%s.html", notification.getTemplateId())); + var htmlTemplate = + loadResource(String.format("notifications/%s.html", notification.getTemplateId())); if (htmlTemplate == null) { // // Unknown kind of notification, ignore. @@ -125,11 +122,9 @@ public void sendNotification(Notification notification) throws NotificationExcep return; } - var formattedMessage = new NotificationTemplate( - htmlTemplate, - this.options.timeZone, - HtmlEscapers.htmlEscaper()) - .format(notification); + var formattedMessage = + new NotificationTemplate(htmlTemplate, this.options.timeZone, HtmlEscapers.htmlEscaper()) + .format(notification); try { this.smtpAdapter.sendMail( @@ -140,13 +135,45 @@ public void sendNotification(Notification notification) throws NotificationExcep notification.isReply() ? EnumSet.of(SmtpAdapter.Flags.REPLY) : EnumSet.of(SmtpAdapter.Flags.NONE)); - } - catch (SmtpAdapter.MailException | AccessException | IOException e) { + } catch (SmtpAdapter.MailException | AccessException | IOException e) { throw new NotificationException("The notification could not be sent", e); } } } + public static class SlackNotificationService extends NotificationService { + private final SlackAdapter slackAdapter; + + public SlackNotificationService(SlackAdapter slackAdapter) { + Preconditions.checkNotNull(slackAdapter); + + this.slackAdapter = slackAdapter; + } + + public boolean canSendNotifications() { + return true; + } + + @Override + public void sendNotification(Notification notification) throws NotificationException { + Preconditions.checkNotNull(notification, "notification"); + + var message = + String.format( + "User `%s` has activated role `%s` in project `%s`.\n\nJustification: _%s_", + notification.properties.get("BENEFICIARY"), + notification.properties.get("ROLE"), + notification.properties.get("PROJECT_ID"), + notification.properties.get("JUSTIFICATION")); + + try { + this.slackAdapter.sendSlackMessage(message); + } catch (SlackAdapter.SlackException e) { + throw new NotificationException("The message cound not be sent", e); + } + } + } + /** * Concrete class that prints notifications to STDOUT. Useful for local development only. */ @@ -166,10 +193,10 @@ public void sendNotification(Notification notification) throws NotificationExcep } /** - * Generic notification. The object contains the data for a notification, - * but doesn't define its format. + * Generic notification. The object contains the data for a notification, but doesn't define its + * format. */ - public static abstract class Notification { + public abstract static class Notification { private final Collection toRecipients; private final Collection ccRecipients; private final String subject; @@ -185,8 +212,7 @@ protected boolean isReply() { protected Notification( Collection toRecipients, Collection ccRecipients, - String subject - ) { + String subject) { Preconditions.checkNotNull(toRecipients, "toRecipients"); Preconditions.checkNotNull(ccRecipients, "ccRecipients"); Preconditions.checkNotNull(subject, "subject"); @@ -202,9 +228,7 @@ public String toString() { "Notification to %s: %s\n\n%s", this.toRecipients.stream().map(e -> e.email).collect(Collectors.joining(", ")), this.subject, - this.properties - .entrySet() - .stream() + this.properties.entrySet().stream() .map(e -> String.format(" %s: %s", e.getKey(), e.getValue())) .collect(Collectors.joining("\n", "", ""))); } @@ -218,11 +242,9 @@ public static class NotificationTemplate { private final Escaper escaper; private final ZoneId timezoneId; - public NotificationTemplate( - String template, - ZoneId timezoneId, - Escaper escaper - ) { + public NotificationTemplate(String template, + ZoneId timezoneId, + Escaper escaper) { Preconditions.checkNotNull(template, "template"); Preconditions.checkNotNull(timezoneId, "timezoneId"); Preconditions.checkNotNull(escaper, "escaper"); @@ -246,12 +268,11 @@ public String format(NotificationService.Notification notification) { // // Apply time zone and convert to string. // - propertyValue = OffsetDateTime - .ofInstant((Instant)property.getValue(), this.timezoneId) - .truncatedTo(ChronoUnit.SECONDS) - .format(DateTimeFormatter.RFC_1123_DATE_TIME); - } - else { + propertyValue = + OffsetDateTime.ofInstant((Instant) property.getValue(), this.timezoneId) + .truncatedTo(ChronoUnit.SECONDS) + .format(DateTimeFormatter.RFC_1123_DATE_TIME); + } else { // // Convert to a safe string. // @@ -277,7 +298,8 @@ public Options(ZoneId timeZone) { } public static class NotificationException extends Exception { - public NotificationException(String message, Throwable cause) { + public NotificationException(String message, + Throwable cause) { super(message, cause); } } diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/ApiResource.java b/sources/src/main/java/com/google/solutions/jitaccess/web/ApiResource.java index 52fd08eab..eb4793653 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/ApiResource.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/ApiResource.java @@ -76,8 +76,7 @@ public class ApiResource { private URL createActivationRequestUrl( UriInfo uriInfo, - String activationToken - ) throws MalformedURLException { + String activationToken) throws MalformedURLException { Preconditions.checkNotNull(uriInfo); Preconditions.checkNotNull(activationToken); @@ -108,15 +107,16 @@ private URL createActivationRequestUrl( @GET public void getRoot() { // - // Version 1.0 allowed static assets (including index.html) to be cached aggressively. + // Version 1.0 allowed static assets (including index.html) to be cached + // aggressively. // After an upgrade, it's therefore likely that browsers still load the outdated // frontend. Let the old frontend show a warning with a cache-busting link. // throw new NotFoundException( "You're viewing an outdated version of the application, " + - String.format( - "please refresh your browser", - UUID.randomUUID())); + String.format( + "please refresh your browser", + UUID.randomUUID())); } /** @@ -126,14 +126,12 @@ public void getRoot() { @Produces(MediaType.APPLICATION_JSON) @Path("policy") public PolicyResponse getPolicy( - @Context SecurityContext securityContext - ) { + @Context SecurityContext securityContext) { var iapPrincipal = (UserPrincipal) securityContext.getUserPrincipal(); return new PolicyResponse( this.roleActivationService.getOptions().justificationHint, - iapPrincipal.getId() - ); + iapPrincipal.getId()); } /** @@ -143,8 +141,7 @@ public PolicyResponse getPolicy( @Produces(MediaType.APPLICATION_JSON) @Path("projects") public ProjectsResponse listProjects( - @Context SecurityContext securityContext - ) throws AccessException { + @Context SecurityContext securityContext) throws AccessException { Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService"); var iapPrincipal = (UserPrincipal) securityContext.getUserPrincipal(); @@ -155,8 +152,7 @@ public ProjectsResponse listProjects( return new ProjectsResponse(projects .stream().map(p -> p.id) .collect(Collectors.toSet())); - } - catch (Exception e) { + } catch (Exception e) { this.logAdapter .newErrorEntry( LogEvents.API_LIST_ROLES, @@ -175,8 +171,7 @@ public ProjectsResponse listProjects( @Path("projects/{projectId}/roles") public ProjectRolesResponse listRoles( @PathParam("projectId") String projectIdString, - @Context SecurityContext securityContext - ) throws AccessException { + @Context SecurityContext securityContext) throws AccessException { Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService"); Preconditions.checkArgument( @@ -194,8 +189,7 @@ public ProjectRolesResponse listRoles( return new ProjectRolesResponse( bindings.getItems(), bindings.getWarnings()); - } - catch (Exception e) { + } catch (Exception e) { this.logAdapter .newErrorEntry( LogEvents.API_LIST_ROLES, @@ -217,8 +211,7 @@ public ProjectRolesResponse listRoles( public ProjectRolePeersResponse listPeers( @PathParam("projectId") String projectIdString, @QueryParam("role") String role, - @Context SecurityContext securityContext - ) throws AccessException { + @Context SecurityContext securityContext) throws AccessException { Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService"); Preconditions.checkArgument( @@ -240,8 +233,7 @@ public ProjectRolePeersResponse listPeers( assert !peers.contains(iapPrincipal.getId()); return new ProjectRolePeersResponse(peers); - } - catch (Exception e) { + } catch (Exception e) { this.logAdapter .newErrorEntry( LogEvents.API_LIST_PEERS, @@ -265,8 +257,7 @@ public ProjectRolePeersResponse listPeers( public ActivationStatusResponse selfApproveActivation( @PathParam("projectId") String projectIdString, SelfActivationRequest request, - @Context SecurityContext securityContext - ) throws AccessDeniedException { + @Context SecurityContext securityContext) throws AccessDeniedException { Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService"); Preconditions.checkArgument( @@ -283,7 +274,8 @@ public ActivationStatusResponse selfApproveActivation( var projectId = new ProjectId(projectIdString); // - // NB. The input list of roles might contain duplicates, therefore reduce to a set. + // NB. The input list of roles might contain duplicates, therefore reduce to a + // set. // var roleBindings = request.roles .stream() @@ -312,8 +304,11 @@ public ActivationStatusResponse selfApproveActivation( .addLabels(le -> addLabels(le, activation)) .addLabel("justification", request.justification) .write(); - } - catch (Exception e) { + + this.notificationService + .sendNotification(new SelfApprovedSlackNotification(request, iapPrincipal, projectId, roleBinding.role)); + + } catch (Exception e) { this.logAdapter .newErrorEntry( LogEvents.API_ACTIVATE_ROLE, @@ -330,9 +325,8 @@ public ActivationStatusResponse selfApproveActivation( .write(); if (e instanceof AccessDeniedException) { - throw (AccessDeniedException)e.fillInStackTrace(); - } - else { + throw (AccessDeniedException) e.fillInStackTrace(); + } else { throw new AccessDeniedException("Activating role failed", e); } } @@ -353,7 +347,8 @@ public ActivationStatusResponse selfApproveActivation( } /** - * Request approval to activate one or more project roles. Only allowed for MPA-eligible roles. + * Request approval to activate one or more project roles. Only allowed for + * MPA-eligible roles. */ @POST @Consumes(MediaType.APPLICATION_JSON) @@ -363,8 +358,7 @@ public ActivationStatusResponse requestActivation( @PathParam("projectId") String projectIdString, ActivationRequest request, @Context SecurityContext securityContext, - @Context UriInfo uriInfo - ) throws AccessDeniedException { + @Context UriInfo uriInfo) throws AccessDeniedException { Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService"); assert this.activationTokenService != null; assert this.notificationService != null; @@ -396,8 +390,7 @@ public ActivationStatusResponse requestActivation( var projectId = new ProjectId(projectIdString); var roleBinding = new RoleBinding(projectId, request.role); - try - { + try { // // Validate request. // @@ -432,8 +425,7 @@ public ActivationStatusResponse requestActivation( iapPrincipal.getId(), activationRequest, ProjectRole.Status.ACTIVATION_PENDING); - } - catch (Exception e) { + } catch (Exception e) { this.logAdapter .newErrorEntry( LogEvents.API_REQUEST_ROLE, @@ -450,9 +442,8 @@ public ActivationStatusResponse requestActivation( .write(); if (e instanceof AccessDeniedException) { - throw (AccessDeniedException)e.fillInStackTrace(); - } - else { + throw (AccessDeniedException) e.fillInStackTrace(); + } else { throw new AccessDeniedException("Requesting access failed", e); } } @@ -466,8 +457,7 @@ public ActivationStatusResponse requestActivation( @Path("activation-request") public ActivationStatusResponse getActivationRequest( @QueryParam("activation") String obfuscatedActivationToken, - @Context SecurityContext securityContext - ) throws AccessException { + @Context SecurityContext securityContext) throws AccessException { assert this.activationTokenService != null; Preconditions.checkArgument( @@ -481,7 +471,7 @@ public ActivationStatusResponse getActivationRequest( var activationRequest = this.activationTokenService.verifyToken(activationToken); if (!activationRequest.beneficiary.equals(iapPrincipal.getId()) && - !activationRequest.reviewers.contains(iapPrincipal.getId())) { + !activationRequest.reviewers.contains(iapPrincipal.getId())) { throw new AccessDeniedException("The calling user is not authorized to access this approval request"); } @@ -489,8 +479,7 @@ public ActivationStatusResponse getActivationRequest( iapPrincipal.getId(), activationRequest, ProjectRole.Status.ACTIVATION_PENDING); // TODO(later): Could check if's been activated already. - } - catch (Exception e) { + } catch (Exception e) { this.logAdapter .newErrorEntry( LogEvents.API_GET_REQUEST, @@ -512,8 +501,7 @@ public ActivationStatusResponse getActivationRequest( public ActivationStatusResponse approveActivationRequest( @QueryParam("activation") String obfuscatedActivationToken, @Context SecurityContext securityContext, - @Context UriInfo uriInfo - ) throws AccessException { + @Context UriInfo uriInfo) throws AccessException { assert this.activationTokenService != null; assert this.roleActivationService != null; assert this.notificationService != null; @@ -528,8 +516,7 @@ public ActivationStatusResponse approveActivationRequest( RoleActivationService.ActivationRequest activationRequest; try { activationRequest = this.activationTokenService.verifyToken(activationToken); - } - catch (Exception e) { + } catch (Exception e) { this.logAdapter .newErrorEntry( LogEvents.API_ACTIVATE_ROLE, @@ -571,8 +558,7 @@ public ActivationStatusResponse approveActivationRequest( activationRequest.reviewers.contains(iapPrincipal.getId()), activationRequest.justification, List.of(new ActivationStatusResponse.ActivationStatus(activation))); - } - catch (Exception e) { + } catch (Exception e) { this.logAdapter .newErrorEntry( LogEvents.API_ACTIVATE_ROLE, @@ -588,9 +574,8 @@ public ActivationStatusResponse approveActivationRequest( .write(); if (e instanceof AccessDeniedException) { - throw (AccessDeniedException)e.fillInStackTrace(); - } - else { + throw (AccessDeniedException) e.fillInStackTrace(); + } else { throw new AccessDeniedException("Approving the activation request failed", e); } } @@ -602,8 +587,7 @@ public ActivationStatusResponse approveActivationRequest( private static LogAdapter.LogEntry addLabels( LogAdapter.LogEntry entry, - RoleActivationService.Activation activation - ) { + RoleActivationService.Activation activation) { return entry .addLabel("activation_id", activation.id.toString()) .addLabel("activation_start", activation.startTime.atOffset(ZoneOffset.UTC).toString()) @@ -613,15 +597,13 @@ private static LogAdapter.LogEntry addLabels( private static LogAdapter.LogEntry addLabels( LogAdapter.LogEntry entry, - RoleActivationService.ActivationRequest request - ) { + RoleActivationService.ActivationRequest request) { return entry .addLabel("activation_id", request.id.toString()) .addLabel("activation_start", request.startTime.atOffset(ZoneOffset.UTC).toString()) .addLabel("activation_end", request.endTime.atOffset(ZoneOffset.UTC).toString()) .addLabel("justification", request.justification) - .addLabel("reviewers", request - .reviewers + .addLabel("reviewers", request.reviewers .stream() .map(u -> u.email) .collect(Collectors.joining(", "))) @@ -630,8 +612,7 @@ private static LogAdapter.LogEntry addLabels( private static LogAdapter.LogEntry addLabels( LogAdapter.LogEntry entry, - RoleBinding roleBinding - ) { + RoleBinding roleBinding) { return entry .addLabel("role", roleBinding.role) .addLabel("resource", roleBinding.fullResourceName) @@ -640,15 +621,13 @@ private static LogAdapter.LogEntry addLabels( private static LogAdapter.LogEntry addLabels( LogAdapter.LogEntry entry, - Exception exception - ) { + Exception exception) { return entry.addLabel("error", exception.getClass().getSimpleName()); } private static LogAdapter.LogEntry addLabels( LogAdapter.LogEntry entry, - ProjectId project - ) { + ProjectId project) { return entry.addLabel("project", project.id); } @@ -662,8 +641,7 @@ public static class PolicyResponse { private PolicyResponse( String justificationHint, - UserId signedInUser - ) { + UserId signedInUser) { Preconditions.checkNotNull(justificationHint, "justificationHint"); Preconditions.checkNotNull(signedInUser, "signedInUser"); @@ -687,8 +665,7 @@ public static class ProjectRolesResponse { private ProjectRolesResponse( List roleBindings, - List warnings - ) { + List warnings) { Preconditions.checkNotNull(roleBindings, "roleBindings"); this.warnings = warnings; @@ -730,8 +707,7 @@ private ActivationStatusResponse( boolean isBeneficiary, boolean isReviewer, String justification, - List items - ) { + List items) { Preconditions.checkNotNull(beneficiary); Preconditions.checkNotNull(reviewers); Preconditions.checkNotNull(justification); @@ -749,8 +725,7 @@ private ActivationStatusResponse( private ActivationStatusResponse( UserId caller, RoleActivationService.ActivationRequest request, - ProjectRole.Status status - ) { + ProjectRole.Status status) { this( request.beneficiary, request.reviewers, @@ -778,8 +753,7 @@ private ActivationStatus( RoleBinding roleBinding, ProjectRole.Status status, long startTime, - long endTime - ) { + long endTime) { assert startTime < endTime; this.activationId = activationId.toString(); @@ -808,13 +782,11 @@ private ActivationStatus(RoleActivationService.Activation activation) { /** * Email to reviewers, requesting their approval. */ - public class RequestActivationNotification extends NotificationService.Notification - { + public class RequestActivationNotification extends NotificationService.Notification { protected RequestActivationNotification( RoleActivationService.ActivationRequest request, Instant requestExpiryTime, - URL activationRequestUrl) throws MalformedURLException - { + URL activationRequestUrl) throws MalformedURLException { super( request.reviewers, List.of(request.beneficiary), @@ -847,8 +819,7 @@ public class ActivationApprovedNotification extends NotificationService.Notifica protected ActivationApprovedNotification( RoleActivationService.ActivationRequest request, UserId approver, - URL activationRequestUrl) throws MalformedURLException - { + URL activationRequestUrl) throws MalformedURLException { super( List.of(request.beneficiary), request.reviewers, // Move reviewers to CC. @@ -877,4 +848,27 @@ public String getTemplateId() { return "ActivationApproved"; } } + + public class SelfApprovedSlackNotification extends NotificationService.Notification { + protected SelfApprovedSlackNotification( + SelfActivationRequest request, + UserPrincipal iapPrincipal, + ProjectId projectId, + String role) { + super( + List.of(), + Set.of(), + request.justification); + + this.properties.put("BENEFICIARY", iapPrincipal.getId().email); + this.properties.put("PROJECT_ID", projectId.id); + this.properties.put("ROLE", role); + this.properties.put("JUSTIFICATION", request.justification); + } + + @Override + public String getTemplateId() { + return "SelfApprovedSlack"; + } + } } diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/RuntimeConfiguration.java b/sources/src/main/java/com/google/solutions/jitaccess/web/RuntimeConfiguration.java index 4d35e8841..7cbcda63f 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/RuntimeConfiguration.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/RuntimeConfiguration.java @@ -85,6 +85,12 @@ public RuntimeConfiguration(Function readSetting) { this.smtpPassword = new StringSetting(List.of("SMTP_PASSWORD"), null); this.smtpSecret = new StringSetting(List.of("SMTP_SECRET"), null); this.smtpExtraOptions = new StringSetting(List.of("SMTP_OPTIONS"), null); + + // + // Slack settings. + // + + this.slackHookUrl = new StringSetting(List.of("SLACK_HOOK_URL"), null); } // ------------------------------------------------------------------------- @@ -171,6 +177,11 @@ public RuntimeConfiguration(Function readSetting) { */ public final StringSetting smtpExtraOptions; + /** + * Hook url for Slack + */ + public final StringSetting slackHookUrl; + /** * Backend Service Id for token validation */ @@ -181,6 +192,10 @@ public RuntimeConfiguration(Function readSetting) { */ public final IntSetting maxNumberOfReviewersPerActivationRequest; + public boolean isSlackConfigured() { + return this.slackHookUrl.isValid(); + } + public boolean isSmtpConfigured() { var requiredSettings = List.of(smtpHost, smtpPort, smtpSenderName, smtpSenderAddress); return requiredSettings.stream().allMatch(s -> s.isValid()); diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/RuntimeEnvironment.java b/sources/src/main/java/com/google/solutions/jitaccess/web/RuntimeEnvironment.java index eb08ec02f..d91a9fadd 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/RuntimeEnvironment.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/RuntimeEnvironment.java @@ -325,6 +325,13 @@ else if (this.configuration.isSmtpAuthenticationConfigured() && this.configurati new SmtpAdapter(secretManagerAdapter, options), new NotificationService.Options(this.configuration.timeZoneForNotifications.getValue())); } + else if (this.configuration.isSlackConfigured()) { + return new NotificationService.SlackNotificationService( + new SlackAdapter( + new SlackAdapter.Options(this.configuration.slackHookUrl.getValue()) + ) + ); + } else { return new NotificationService.SilentNotificationService(); } diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestNotificationService.java b/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestNotificationService.java index 446817e74..7157d3dd4 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestNotificationService.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestNotificationService.java @@ -2,26 +2,28 @@ // Copyright 2022 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file +// or more contributor license agreements. See the NOTICE file // distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file +// regarding copyright ownership. The ASF licenses this file // to you 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 +// with the License. You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// 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 +// KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. // package com.google.solutions.jitaccess.core.services; +import com.google.solutions.jitaccess.core.adapters.SlackAdapter; import com.google.solutions.jitaccess.core.adapters.SmtpAdapter; +import com.google.solutions.jitaccess.core.adapters.SlackAdapter.Options; import com.google.solutions.jitaccess.core.data.UserId; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -30,6 +32,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.anyString; @@ -41,20 +44,24 @@ public class TestNotificationService { private static class TestNotification extends NotificationService.Notification { private final String templateId; - protected TestNotification( - UserId recipient, - String subject, - Map properties, - String templateId - ) { - super( - List.of(recipient), - List.of(), - subject); + protected TestNotification(UserId recipient, String subject, Map properties, + String templateId) { + super(List.of(recipient), List.of(), subject); this.properties.putAll(properties); this.templateId = templateId; } + protected TestNotification(String justification, String email, String projectId, String role) { + super(List.of(), Set.of(), justification); + + this.properties.put("BENEFICIARY", email); + this.properties.put("PROJECT_ID", projectId); + this.properties.put("ROLE", role); + this.properties.put("JUSTIFICATION", justification); + + this.templateId = "SelfApprovedSlack"; + } + @Override public String getTemplateId() { return this.templateId; @@ -65,48 +72,44 @@ public String getTemplateId() { // sendNotification. // ------------------------------------------------------------------------- + @Test + public void sendNotificationSendsSlackMessage() throws Exception { + var slackAdapter = Mockito.mock(SlackAdapter.class); + var service = new NotificationService.SlackNotificationService(slackAdapter); + + service.sendNotification(new TestNotification("Test message", "user@example.com", + "example-projectid", "roles/owner")); + + verify(slackAdapter, times(1)).sendSlackMessage(eq( + "User `user@example.com` has activated role `roles/owner` in project `example-projectid`.\n\nJustification: _Test message_")); + } + @Test public void whenTemplateNotFound_ThenSendNotificationDoesNotSendMail() throws Exception { var mailAdapter = Mockito.mock(SmtpAdapter.class); - var service = new NotificationService.MailNotificationService( - mailAdapter, + var service = new NotificationService.MailNotificationService(mailAdapter, new NotificationService.Options(NotificationService.Options.DEFAULT_TIMEZONE)); var to = new UserId("user@example.com"); - service.sendNotification(new TestNotification( - to, - "Test email", - new HashMap(), + service.sendNotification(new TestNotification(to, "Test email", new HashMap(), "unknown-templateid")); - verify(mailAdapter, times(0)).sendMail( - eq(List.of(to)), - eq(List.of()), - eq("Test email"), - anyString(), - eq(EnumSet.of(SmtpAdapter.Flags.NONE))); + verify(mailAdapter, times(0)).sendMail(eq(List.of(to)), eq(List.of()), eq("Test email"), + anyString(), eq(EnumSet.of(SmtpAdapter.Flags.NONE))); } @Test public void whenTemplateFound_ThenSendNotificationSendsMail() throws Exception { var mailAdapter = Mockito.mock(SmtpAdapter.class); - var service = new NotificationService.MailNotificationService( - mailAdapter, + var service = new NotificationService.MailNotificationService(mailAdapter, new NotificationService.Options(NotificationService.Options.DEFAULT_TIMEZONE)); var to = new UserId("user@example.com"); - service.sendNotification(new TestNotification( - to, - "Test email", - new HashMap(), - "RequestActivation")); - - verify(mailAdapter, times(1)).sendMail( - eq(List.of(to)), - eq(List.of()), - eq("Test email"), - anyString(), - eq(EnumSet.of(SmtpAdapter.Flags.NONE))); + service.sendNotification( + new TestNotification(to, "Test email", new HashMap(), "RequestActivation")); + + verify(mailAdapter, times(1)).sendMail(eq(List.of(to)), eq(List.of()), eq("Test email"), + anyString(), eq(EnumSet.of(SmtpAdapter.Flags.NONE))); } // ------------------------------------------------------------------------- @@ -114,8 +117,7 @@ public void whenTemplateFound_ThenSendNotificationSendsMail() throws Exception { // ------------------------------------------------------------------------- @Test - public void whenTemplateNotFound_ThenLoadResourceReturnsNull() throws Exception - { + public void whenTemplateNotFound_ThenLoadResourceReturnsNull() throws Exception { assertNull(NotificationService.loadResource("doesnotexist")); }