diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/services/MailNotificationService.java b/sources/src/main/java/com/google/solutions/jitaccess/core/services/MailNotificationService.java new file mode 100644 index 000000000..2f1f95fb9 --- /dev/null +++ b/sources/src/main/java/com/google/solutions/jitaccess/core/services/MailNotificationService.java @@ -0,0 +1,203 @@ +// +// Copyright 2022 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.services; + +import com.google.common.base.Preconditions; +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.SmtpAdapter; + +import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.EnumSet; + +/** + * Concrete class that delivers notifications over SMTP. + */ +public class MailNotificationService extends NotificationService { + private final Options options; + private final SmtpAdapter smtpAdapter; + + /** + * 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)) { + + 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) { + + // + // Strip UTF-8 BOM. + // + return new String(content, 3, content.length - 3); + } + else { + return new String(content); + } + } + catch (IOException e) { + throw new NotificationException( + String.format("Reading the template %s from the JAR file failed", resourceName), e); + } + } + + public MailNotificationService( + SmtpAdapter smtpAdapter, + Options options + ) { + Preconditions.checkNotNull(smtpAdapter); + Preconditions.checkNotNull(options); + + this.smtpAdapter = smtpAdapter; + this.options = options; + } + + // ------------------------------------------------------------------------- + // NotificationService implementation. + // ------------------------------------------------------------------------- + + @Override + public boolean canSendNotifications() { + return true; + } + + @Override + public void sendNotification(Notification notification) throws NotificationException { + Preconditions.checkNotNull(notification, "notification"); + + var htmlTemplate = loadResource( + String.format("notifications/%s.html", notification.getType())); + if (htmlTemplate == null) { + // + // Unknown kind of notification, ignore. + // + return; + } + + var formattedMessage = new MessageTemplate( + htmlTemplate, + this.options.timeZone, + HtmlEscapers.htmlEscaper()) + .format(notification); + + try { + this.smtpAdapter.sendMail( + notification.getToRecipients(), + notification.getCcRecipients(), + notification.getSubject(), + formattedMessage, + notification.isReply() + ? EnumSet.of(SmtpAdapter.Flags.REPLY) + : EnumSet.of(SmtpAdapter.Flags.NONE)); + } + catch (SmtpAdapter.MailException | AccessException | IOException e) { + throw new NotificationException("The notification could not be sent", e); + } + } + + // ------------------------------------------------------------------------- + // Inner classes. + // ------------------------------------------------------------------------- + + /** + * Template for turning a notification object into some textual representation. + */ + public static class MessageTemplate { + private final String template; + private final Escaper escaper; + private final ZoneId timezoneId; + + public MessageTemplate( + String template, + ZoneId timezoneId, + Escaper escaper + ) { + Preconditions.checkNotNull(template, "template"); + Preconditions.checkNotNull(timezoneId, "timezoneId"); + Preconditions.checkNotNull(escaper, "escaper"); + + this.template = template; + this.timezoneId = timezoneId; + this.escaper = escaper; + } + + public String format(NotificationService.Notification notification) { + Preconditions.checkNotNull(notification, "notification"); + + // + // Replace all {{PROPERTY}} placeholders in the template. + // + + var message = this.template; + for (var property : notification.properties.entrySet()) { + String propertyValue; + if (property.getValue() instanceof Instant) { + // + // 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 { + // + // Convert to a safe string. + // + propertyValue = escaper.escape(property.getValue().toString()); + } + + message = message.replace("{{" + property.getKey() + "}}", propertyValue); + } + + return message; + } + } + + public static class Options { + public static final ZoneId DEFAULT_TIMEZONE = ZoneOffset.UTC; + + private final ZoneId timeZone; + + public Options(ZoneId timeZone) { + Preconditions.checkNotNull(timeZone); + this.timeZone = timeZone; + } + } +} 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 f65f89e1f..a88757ed5 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 @@ -22,22 +22,11 @@ package com.google.solutions.jitaccess.core.services; import com.google.common.base.Preconditions; -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.SmtpAdapter; import com.google.solutions.jitaccess.core.data.UserId; import jakarta.enterprise.context.ApplicationScoped; -import java.io.IOException; -import java.time.Instant; -import java.time.OffsetDateTime; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoUnit; + import java.util.Collection; -import java.util.EnumSet; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; @@ -51,102 +40,11 @@ public abstract class NotificationService { public abstract boolean canSendNotifications(); - /** - * 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)) { - - 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) { - - // - // Strip UTF-8 BOM. - // - return new String(content, 3, content.length - 3); - } - else { - return new String(content); - } - } - catch (IOException e) { - throw new NotificationException( - String.format("Reading the template %s from the JAR file failed", resourceName), e); - } - } // ------------------------------------------------------------------------- // Inner classes. // ------------------------------------------------------------------------- - /** - * Concrete class that delivers notifications over SMTP. - */ - public static class MailNotificationService extends NotificationService { - private final Options options; - private final SmtpAdapter smtpAdapter; - - public MailNotificationService( - SmtpAdapter smtpAdapter, - Options options - ) { - Preconditions.checkNotNull(smtpAdapter); - Preconditions.checkNotNull(options); - - this.smtpAdapter = smtpAdapter; - this.options = options; - } - - @Override - public boolean canSendNotifications() { - return true; - } - - @Override - public void sendNotification(Notification notification) throws NotificationException { - Preconditions.checkNotNull(notification, "notification"); - - var htmlTemplate = loadResource( - String.format("notifications/%s.html", notification.getTemplateId())); - if (htmlTemplate == null) { - // - // Unknown kind of notification, ignore. - // - return; - } - - var formattedMessage = new NotificationTemplate( - htmlTemplate, - this.options.timeZone, - HtmlEscapers.htmlEscaper()) - .format(notification); - - try { - this.smtpAdapter.sendMail( - notification.toRecipients, - notification.ccRecipients, - notification.subject, - formattedMessage, - notification.isReply() - ? EnumSet.of(SmtpAdapter.Flags.REPLY) - : EnumSet.of(SmtpAdapter.Flags.NONE)); - } - catch (SmtpAdapter.MailException | AccessException | IOException e) { - throw new NotificationException("The notification could not be sent", e); - } - } - } - /** * Concrete class that prints notifications to STDOUT. Useful for local development only. */ @@ -180,7 +78,22 @@ protected boolean isReply() { return false; } - public abstract String getTemplateId(); + public Collection getToRecipients() { + return toRecipients; + } + + public Collection getCcRecipients() { + return ccRecipients; + } + + public String getSubject() { + return subject; + } + + /** + * @return string identifying the type of notification. + */ + public abstract String getType(); protected Notification( Collection toRecipients, @@ -210,72 +123,6 @@ public String toString() { } } - /** - * Template for turning a notification object into some textual representation. - */ - public static class NotificationTemplate { - private final String template; - private final Escaper escaper; - private final ZoneId timezoneId; - - public NotificationTemplate( - String template, - ZoneId timezoneId, - Escaper escaper - ) { - Preconditions.checkNotNull(template, "template"); - Preconditions.checkNotNull(timezoneId, "timezoneId"); - Preconditions.checkNotNull(escaper, "escaper"); - - this.template = template; - this.timezoneId = timezoneId; - this.escaper = escaper; - } - - public String format(NotificationService.Notification notification) { - Preconditions.checkNotNull(notification, "notification"); - - // - // Replace all {{PROPERTY}} placeholders in the template. - // - - var message = this.template; - for (var property : notification.properties.entrySet()) { - String propertyValue; - if (property.getValue() instanceof Instant) { - // - // 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 { - // - // Convert to a safe string. - // - propertyValue = escaper.escape(property.getValue().toString()); - } - - message = message.replace("{{" + property.getKey() + "}}", propertyValue); - } - - return message; - } - } - - public static class Options { - public static final ZoneId DEFAULT_TIMEZONE = ZoneOffset.UTC; - - private final ZoneId timeZone; - - public Options(ZoneId timeZone) { - Preconditions.checkNotNull(timeZone); - this.timeZone = timeZone; - } - } - public static class NotificationException extends Exception { 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 6a7e465e8..061866c2b 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 @@ -894,7 +894,7 @@ protected RequestActivationNotification( } @Override - public String getTemplateId() { + public String getType() { return "RequestActivation"; } } @@ -932,7 +932,7 @@ protected boolean isReply() { } @Override - public String getTemplateId() { + public String getType() { return "ActivationApproved"; } } 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 d5a4c8790..9e59ee07d 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 @@ -319,9 +319,9 @@ else if (this.configuration.isSmtpAuthenticationConfigured() && this.configurati this.configuration.smtpPassword.getValue()); } - return new NotificationService.MailNotificationService( + return new MailNotificationService( new SmtpAdapter(secretManagerAdapter, options), - new NotificationService.Options(this.configuration.timeZoneForNotifications.getValue())); + new MailNotificationService.Options(this.configuration.timeZoneForNotifications.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/TestMailNotificationService.java similarity index 88% rename from sources/src/test/java/com/google/solutions/jitaccess/core/services/TestNotificationService.java rename to sources/src/test/java/com/google/solutions/jitaccess/core/services/TestMailNotificationService.java index 446817e74..6d57d7477 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/TestMailNotificationService.java @@ -37,7 +37,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -public class TestNotificationService { +public class TestMailNotificationService { private static class TestNotification extends NotificationService.Notification { private final String templateId; @@ -56,7 +56,7 @@ protected TestNotification( } @Override - public String getTemplateId() { + public String getType() { return this.templateId; } } @@ -68,9 +68,9 @@ public String getTemplateId() { @Test public void whenTemplateNotFound_ThenSendNotificationDoesNotSendMail() throws Exception { var mailAdapter = Mockito.mock(SmtpAdapter.class); - var service = new NotificationService.MailNotificationService( + var service = new MailNotificationService( mailAdapter, - new NotificationService.Options(NotificationService.Options.DEFAULT_TIMEZONE)); + new MailNotificationService.Options(MailNotificationService.Options.DEFAULT_TIMEZONE)); var to = new UserId("user@example.com"); service.sendNotification(new TestNotification( @@ -90,9 +90,9 @@ public void whenTemplateNotFound_ThenSendNotificationDoesNotSendMail() throws Ex @Test public void whenTemplateFound_ThenSendNotificationSendsMail() throws Exception { var mailAdapter = Mockito.mock(SmtpAdapter.class); - var service = new NotificationService.MailNotificationService( + var service = new MailNotificationService( mailAdapter, - new NotificationService.Options(NotificationService.Options.DEFAULT_TIMEZONE)); + new MailNotificationService.Options(MailNotificationService.Options.DEFAULT_TIMEZONE)); var to = new UserId("user@example.com"); service.sendNotification(new TestNotification( @@ -116,7 +116,6 @@ public void whenTemplateFound_ThenSendNotificationSendsMail() throws Exception { @Test public void whenTemplateNotFound_ThenLoadResourceReturnsNull() throws Exception { - assertNull(NotificationService.loadResource("doesnotexist")); + assertNull(MailNotificationService.loadResource("doesnotexist")); } - } diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestNotificationTemplate.java b/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestMessageTemplate.java similarity index 92% rename from sources/src/test/java/com/google/solutions/jitaccess/core/services/TestNotificationTemplate.java rename to sources/src/test/java/com/google/solutions/jitaccess/core/services/TestMessageTemplate.java index fead3d38f..f9f7be44b 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestNotificationTemplate.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestMessageTemplate.java @@ -34,7 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -public class TestNotificationTemplate { +public class TestMessageTemplate { private static class TestNotification extends NotificationService.Notification { private final String templateId; @@ -53,7 +53,7 @@ protected TestNotification( } @Override - public String getTemplateId() { + public String getType() { return this.templateId; } } @@ -74,13 +74,13 @@ public void whenPropertiesContainHtmlTags_ThenFormatEscapesTags() { properties, "ignored-templateid"); - var template = new NotificationService.NotificationTemplate( + var template = new MailNotificationService.MessageTemplate( notification.properties .entrySet() .stream() .map(e -> String.format("%s={{%s}}\n", e.getKey(), e.getKey())) .collect(Collectors.joining()), - NotificationService.Options.DEFAULT_TIMEZONE, + MailNotificationService.Options.DEFAULT_TIMEZONE, HtmlEscapers.htmlEscaper()); assertEquals( @@ -99,7 +99,7 @@ public void whenPropertiesContainDates_ThenFormatAppliesTimezone() { properties, "ignored-templateid"); - var template = new NotificationService.NotificationTemplate( + var template = new MailNotificationService.MessageTemplate( notification.properties .entrySet() .stream() diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/TestRuntimeConfiguration.java b/sources/src/test/java/com/google/solutions/jitaccess/web/TestRuntimeConfiguration.java index 961295dc3..cfe1d9633 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/web/TestRuntimeConfiguration.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/web/TestRuntimeConfiguration.java @@ -21,6 +21,7 @@ package com.google.solutions.jitaccess.web; +import com.google.solutions.jitaccess.core.services.MailNotificationService; import com.google.solutions.jitaccess.core.services.NotificationService; import org.junit.jupiter.api.Test; @@ -100,7 +101,7 @@ public void whenSet_ThenTimeZoneForNotificationsIsUtc() { var configuration = new RuntimeConfiguration(Map.of()); assertEquals( - NotificationService.Options.DEFAULT_TIMEZONE, + MailNotificationService.Options.DEFAULT_TIMEZONE, configuration.timeZoneForNotifications.getValue()); } @@ -120,7 +121,7 @@ public void whenSet_ThenTimeZoneForNotificationsReturnsSetting() { var configuration = new RuntimeConfiguration(settings); assertNotEquals( - NotificationService.Options.DEFAULT_TIMEZONE, + MailNotificationService.Options.DEFAULT_TIMEZONE, configuration.timeZoneForNotifications.getValue()); }