diff --git a/sources/pom.xml b/sources/pom.xml index 68d09ddf9..97a48df81 100644 --- a/sources/pom.xml +++ b/sources/pom.xml @@ -69,6 +69,11 @@ google-api-services-cloudasset v1-rev20231111-2.0.0 + + com.google.apis + google-api-services-pubsub + v1-rev20230830-2.0.0 + com.google.apis google-api-services-iamcredentials @@ -79,6 +84,11 @@ google-api-services-secretmanager v1-rev20230804-2.0.0 + + com.google.code.gson + gson + 2.10.1 + com.sun.mail jakarta.mail diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/adapters/PubSubAdapter.java b/sources/src/main/java/com/google/solutions/jitaccess/core/adapters/PubSubAdapter.java new file mode 100644 index 000000000..14a5a6582 --- /dev/null +++ b/sources/src/main/java/com/google/solutions/jitaccess/core/adapters/PubSubAdapter.java @@ -0,0 +1,111 @@ +// +// 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 com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.api.services.pubsub.Pubsub; +import com.google.api.services.pubsub.model.PubsubMessage; +import com.google.api.services.pubsub.model.PublishRequest; +import com.google.common.base.Preconditions; +import com.google.solutions.jitaccess.core.AccessDeniedException; +import com.google.solutions.jitaccess.core.AccessException; +import com.google.solutions.jitaccess.core.ApplicationVersion; +import com.google.solutions.jitaccess.core.NotAuthenticatedException; +import com.google.solutions.jitaccess.core.data.Topic; +import jakarta.enterprise.context.ApplicationScoped; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Arrays; + +@ApplicationScoped +public class PubSubAdapter { + private final GoogleCredentials credentials; + private final HttpTransport.Options httpOptions; + + public PubSubAdapter( + GoogleCredentials credentials, + HttpTransport.Options httpOptions) + { + Preconditions.checkNotNull(credentials, "credentials"); + Preconditions.checkNotNull(httpOptions, "httpOptions"); + + this.credentials = credentials; + this.httpOptions = httpOptions; + } + + private Pubsub createClient() throws IOException { + try { + return new Pubsub.Builder( + HttpTransport.newTransport(), + new GsonFactory(), + HttpTransport.newAuthenticatingRequestInitializer(this.credentials, this.httpOptions)) + .setApplicationName(ApplicationVersion.USER_AGENT) + .build(); + } + catch (GeneralSecurityException e) { + throw new IOException("Creating a PubSub client failed", e); + } + } + + public String publish( + Topic topic, + PubsubMessage message + ) throws AccessException, IOException { + var client = createClient(); + + try { + var request = new PublishRequest(); + request.setMessages(Arrays.asList(message)); + + var result = client + .projects() + .topics() + .publish(topic.getFullResourceName(), request) + .execute(); + if (result.getMessageIds().size() < 1){ + throw new IOException( + String.format("Publishing message to topic %s returned empty response", topic)); + } + + return result.getMessageIds().get(0); + } + catch (GoogleJsonResponseException e) { + switch (e.getStatusCode()) { + case 401: + throw new NotAuthenticatedException("Not authenticated", e); + case 403: + case 404: + throw new AccessDeniedException( + String.format( + "Pub/Sub topic '%s' cannot be accessed or does not exist: %s", + topic, + e.getMessage()), + e); + default: + throw (GoogleJsonResponseException)e.fillInStackTrace(); + } + } + } +} diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/data/Topic.java b/sources/src/main/java/com/google/solutions/jitaccess/core/data/Topic.java new file mode 100644 index 000000000..81d8d2ada --- /dev/null +++ b/sources/src/main/java/com/google/solutions/jitaccess/core/data/Topic.java @@ -0,0 +1,33 @@ +// +// 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.data; + +public record Topic(String projectId, String topicName) { + @Override + public String toString() { + return getFullResourceName(); + } + + public String getFullResourceName() { + return String.format("projects/%s/topics/%s", this.projectId, this.topicName); + } +} 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 index 2f1f95fb9..3af44595d 100644 --- 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 @@ -34,7 +34,10 @@ 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.Set; +import java.util.stream.Collectors; /** * Concrete class that delivers notifications over SMTP. @@ -176,6 +179,11 @@ public String format(NotificationService.Notification notification) { .truncatedTo(ChronoUnit.SECONDS) .format(DateTimeFormatter.RFC_1123_DATE_TIME); } + else if (property.getValue() instanceof Collection) { + propertyValue = ((Collection)property.getValue()).stream() + .map(i -> i.toString()) + .collect(Collectors.joining(", ")); + } else { // // Convert to a safe string. 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 a88757ed5..28ed34629 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 @@ -25,14 +25,13 @@ import com.google.solutions.jitaccess.core.data.UserId; import jakarta.enterprise.context.ApplicationScoped; - import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; /** - * Service for notifying users about activation requests.. + * Service for notifying users about activation requests. */ @ApplicationScoped public abstract class NotificationService { @@ -49,6 +48,12 @@ public abstract class NotificationService { * Concrete class that prints notifications to STDOUT. Useful for local development only. */ public static class SilentNotificationService extends NotificationService { + private final boolean printToConsole; + + public SilentNotificationService(boolean printToConsole) { + this.printToConsole = printToConsole; + } + @Override public boolean canSendNotifications() { return false; @@ -56,10 +61,12 @@ public boolean canSendNotifications() { @Override public void sendNotification(Notification notification) throws NotificationException { - // - // Print it so that we can see the message during development. - // - System.out.println(notification); + if (this.printToConsole) { + // + // Print it so that we can see the message during development. + // + System.out.println(notification); + } } } diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/services/PubSubNotificationService.java b/sources/src/main/java/com/google/solutions/jitaccess/core/services/PubSubNotificationService.java new file mode 100644 index 000000000..11c4441aa --- /dev/null +++ b/sources/src/main/java/com/google/solutions/jitaccess/core/services/PubSubNotificationService.java @@ -0,0 +1,95 @@ +package com.google.solutions.jitaccess.core.services; + +import com.google.api.client.json.GenericJson; +import com.google.api.services.pubsub.model.PubsubMessage; +import com.google.common.base.Preconditions; +import com.google.gson.Gson; +import com.google.solutions.jitaccess.core.AccessException; +import com.google.solutions.jitaccess.core.adapters.PubSubAdapter; +import com.google.solutions.jitaccess.core.data.Topic; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Concrete class that delivers notifications over Pub/Sub. + */ +public class PubSubNotificationService extends NotificationService { + private final PubSubAdapter adapter; + private final Options options; + + public PubSubNotificationService( + PubSubAdapter adapter, + Options options + ) { + Preconditions.checkNotNull(adapter, "adapter"); + Preconditions.checkNotNull(options, "options"); + Preconditions.checkNotNull(options.topic, "options"); + + this.adapter = adapter; + this.options = options; + } + + // ------------------------------------------------------------------------- + // NotificationService implementation. + // ------------------------------------------------------------------------- + + @Override + public boolean canSendNotifications() { + return true; + } + + @Override + public void sendNotification(Notification notification) throws NotificationException { + var attributes = new GenericJson(); + for (var property : notification.properties.entrySet()) + { + Object propertyValue; + if (property.getValue() instanceof Instant) { + // + // Serialize ISO-8601 representation instead of individual + // object fields. + // + propertyValue = ((Instant)property.getValue()).toString(); + } + else if (property.getValue() instanceof Collection) { + propertyValue = ((Collection)property.getValue()).stream() + .map(i -> i.toString()) + .collect(Collectors.toList()); + } + else { + propertyValue = property.getValue().toString(); + } + + attributes.set(property.getKey().toLowerCase(), propertyValue); + } + + var payload = new GenericJson() + .set("type", notification.getType()) + .set("attributes", attributes); + + var payloadAsJson = new Gson().toJson(payload); + + try { + var message = new PubsubMessage() + .encodeData(payloadAsJson.getBytes(StandardCharsets.UTF_8)); + + this.adapter.publish(options.topic, message); + } catch (AccessException | IOException e){ + throw new NotificationException("Publishing event to Pub/Sub failed", e); + } + } + + // ------------------------------------------------------------------------- + // Inner classes. + // ------------------------------------------------------------------------- + + /** + * @param topicResourceName PubSub topic in format projects/{project}/topics/{topic} + */ + public record Options(Topic topic) { + } +} diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/services/RoleActivationService.java b/sources/src/main/java/com/google/solutions/jitaccess/core/services/RoleActivationService.java index 098bdfd5a..c6ed0af55 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/core/services/RoleActivationService.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/core/services/RoleActivationService.java @@ -39,6 +39,7 @@ import java.security.SecureRandom; import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Base64; import java.util.EnumSet; import java.util.List; @@ -170,7 +171,7 @@ public Activation activateProjectRoleForSelf( // accumulating junk, and to prevent hitting the binding limit. // - var activationTime = Instant.now(); + var activationTime = Instant.now().truncatedTo(ChronoUnit.SECONDS);; var expiryTime = activationTime.plus(activationTimeout); var bindingDescription = String.format( "Self-approved, justification: %s", @@ -318,7 +319,7 @@ public ActivationRequest createActivationRequestForPeer( // // Issue an activation request. // - var startTime = Instant.now(); + var startTime = Instant.now().truncatedTo(ChronoUnit.SECONDS); var endTime = startTime.plus(activationTimeout); return new ActivationRequest( 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 061866c2b..11e5ff651 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 @@ -27,7 +27,11 @@ import com.google.solutions.jitaccess.core.ApplicationVersion; import com.google.solutions.jitaccess.core.Exceptions; import com.google.solutions.jitaccess.core.adapters.LogAdapter; -import com.google.solutions.jitaccess.core.data.*; +import com.google.solutions.jitaccess.core.data.UserId; +import com.google.solutions.jitaccess.core.data.ProjectRole; +import com.google.solutions.jitaccess.core.data.ProjectId; +import com.google.solutions.jitaccess.core.data.RoleBinding; +import com.google.solutions.jitaccess.core.data.UserPrincipal; import com.google.solutions.jitaccess.core.services.ActivationTokenService; import com.google.solutions.jitaccess.core.services.NotificationService; import com.google.solutions.jitaccess.core.services.RoleActivationService; @@ -35,6 +39,7 @@ import jakarta.enterprise.context.Dependent; import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.Context; @@ -50,6 +55,7 @@ import java.util.List; import java.util.Set; import java.util.UUID; + import java.util.stream.Collectors; /** @@ -322,6 +328,13 @@ public ActivationStatusResponse selfApproveActivation( assert activation != null; activations.add(activation); + for (var service : this.notificationServices) { + service.sendNotification(new ActivationSelfApprovedNotification( + activation, + iapPrincipal.getId(), + request.justification)); + } + this.logAdapter .newInfoEntry( LogEvents.API_ACTIVATE_ROLE, @@ -450,10 +463,11 @@ public ActivationStatusResponse requestActivation( var activationToken = this.activationTokenService.createToken(activationRequest); for (var service : this.notificationServices) { + var activationRequestUrl = createActivationRequestUrl(uriInfo, activationToken.token); service.sendNotification(new RequestActivationNotification( activationRequest, activationToken.expiryTime, - createActivationRequestUrl(uriInfo, activationToken.token))); + activationRequestUrl)); } this.logAdapter @@ -865,7 +879,8 @@ private ActivationStatus(RoleActivationService.Activation activation) { // ------------------------------------------------------------------------- /** - * Email to reviewers, requesting their approval. + * Notification indicating that a multi-party approval request has been made + * and is pending approval. */ public class RequestActivationNotification extends NotificationService.Notification { @@ -882,8 +897,9 @@ protected RequestActivationNotification( request.beneficiary, ProjectId.fromFullResourceName(request.roleBinding.fullResourceName).id)); - this.properties.put("BENEFICIARY", request.beneficiary.email); - this.properties.put("PROJECT_ID", ProjectId.fromFullResourceName(request.roleBinding.fullResourceName).id); + this.properties.put("BENEFICIARY", request.beneficiary); + this.properties.put("REVIEWERS", request.reviewers); + this.properties.put("PROJECT_ID", ProjectId.fromFullResourceName(request.roleBinding.fullResourceName)); this.properties.put("ROLE", request.roleBinding.role); this.properties.put("START_TIME", request.startTime); this.properties.put("END_TIME", request.endTime); @@ -900,7 +916,7 @@ public String getType() { } /** - * Email to the beneficiary, confirming an approval. + * Notification indicating that a multi-party approval was granted. */ public class ActivationApprovedNotification extends NotificationService.Notification { protected ActivationApprovedNotification( @@ -917,8 +933,9 @@ protected ActivationApprovedNotification( ProjectId.fromFullResourceName(request.roleBinding.fullResourceName).id)); this.properties.put("APPROVER", approver.email); - this.properties.put("BENEFICIARY", request.beneficiary.email); - this.properties.put("PROJECT_ID", ProjectId.fromFullResourceName(request.roleBinding.fullResourceName).id); + this.properties.put("BENEFICIARY", request.beneficiary); + this.properties.put("REVIEWERS", request.reviewers); + this.properties.put("PROJECT_ID", ProjectId.fromFullResourceName(request.roleBinding.fullResourceName)); this.properties.put("ROLE", request.roleBinding.role); this.properties.put("START_TIME", request.startTime); this.properties.put("END_TIME", request.endTime); @@ -937,6 +954,42 @@ public String getType() { } } + /** + * Notification indicating that a self-approval was performed. + */ + public class ActivationSelfApprovedNotification extends NotificationService.Notification { + protected ActivationSelfApprovedNotification( + RoleActivationService.Activation activation, + UserId beneficiary, + String justification) + { + super( + List.of(beneficiary), + List.of(), + String.format( + "Activated role '%s' on '%s'", + activation.projectRole.roleBinding, + activation.projectRole.getProjectId())); + + this.properties.put("BENEFICIARY", beneficiary); + this.properties.put("PROJECT_ID", activation.projectRole.getProjectId()); + this.properties.put("ROLE", activation.projectRole.roleBinding.role); + this.properties.put("START_TIME", activation.startTime); + this.properties.put("END_TIME", activation.endTime); + this.properties.put("JUSTIFICATION", justification); + } + + @Override + protected boolean isReply() { + return true; + } + + @Override + public String getType() { + return "ActivationSelfApproved"; + } + } + // ------------------------------------------------------------------------- // Options. // ------------------------------------------------------------------------- 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 f59268742..c76ba9807 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 @@ -84,6 +84,7 @@ public RuntimeConfiguration(Function readSetting) { // Notification settings. // this.timeZoneForNotifications = new ZoneIdSetting(List.of("NOTIFICATION_TIMEZONE")); + this.topicName = new StringSetting(List.of("NOTIFICATION_TOPIC"), null); // // SMTP settings. @@ -125,6 +126,13 @@ public RuntimeConfiguration(Function readSetting) { */ public final StringSetting scope; + /** + * Topic (within the resource hierarchy) that binding information will + * publish to. + */ + public final StringSetting topicName; + + /** * Duration for which an activated role remains activated. */ 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 0131a28e2..16a86d2d3 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 @@ -32,8 +32,10 @@ import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.ImpersonatedCredentials; import com.google.auth.oauth2.ServiceAccountCredentials; +import com.google.common.base.Strings; import com.google.solutions.jitaccess.core.ApplicationVersion; import com.google.solutions.jitaccess.core.adapters.*; +import com.google.solutions.jitaccess.core.data.Topic; import com.google.solutions.jitaccess.core.data.UserId; import com.google.solutions.jitaccess.core.services.*; @@ -55,6 +57,7 @@ public class RuntimeEnvironment { private static final String CONFIG_IMPERSONATE_SA = "jitaccess.impersonateServiceAccount"; private static final String CONFIG_DEBUG_MODE = "jitaccess.debug"; + private static final String CONFIG_PROJECT = "jitaccess.project"; private final String projectId; private final String projectNumber; @@ -156,7 +159,7 @@ else if (isDebugModeEnabled()) { // // Initialize using development settings and credential. // - this.projectId = "dev"; + this.projectId = System.getProperty(CONFIG_PROJECT, "dev"); this.projectNumber = "0"; try { @@ -290,6 +293,22 @@ public ActivationTokenService.Options getTokenServiceOptions() { effectiveRequestTimeout); } + @Produces + @ApplicationScoped + public NotificationService getPubSubNotificationService( + PubSubAdapter pubSubAdapter + ) { + if (this.configuration.topicName.isValid()) { + return new PubSubNotificationService( + pubSubAdapter, + new PubSubNotificationService.Options( + new Topic(this.projectId, this.configuration.topicName.getValue()))); + } + else { + return new NotificationService.SilentNotificationService(isDebugModeEnabled()); + } + } + @Produces @ApplicationScoped public NotificationService getEmailNotificationService( @@ -328,7 +347,7 @@ else if (this.configuration.isSmtpAuthenticationConfigured() && this.configurati new MailNotificationService.Options(this.configuration.timeZoneForNotifications.getValue())); } else { - return new NotificationService.SilentNotificationService(); + return new NotificationService.SilentNotificationService(isDebugModeEnabled()); } } diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/adapters/IntegrationTestEnvironment.java b/sources/src/test/java/com/google/solutions/jitaccess/core/adapters/IntegrationTestEnvironment.java index e0741f05b..1f182d881 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/core/adapters/IntegrationTestEnvironment.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/core/adapters/IntegrationTestEnvironment.java @@ -24,7 +24,9 @@ import com.google.auth.oauth2.AccessToken; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.ImpersonatedCredentials; +import com.google.common.base.Strings; import com.google.solutions.jitaccess.core.data.ProjectId; +import com.google.solutions.jitaccess.core.data.Topic; import com.google.solutions.jitaccess.core.data.UserId; import java.io.File; @@ -55,6 +57,8 @@ public void refresh() { public static final UserId TEMPORARY_ACCESS_USER; public static final UserId NO_ACCESS_USER; + public static final Topic PUBSUB_TOPIC; + static { // // Open test settings file. @@ -91,6 +95,14 @@ public void refresh() { NO_ACCESS_CREDENTIALS = impersonate(APPLICATION_CREDENTIALS, NO_ACCESS_USER.email); TEMPORARY_ACCESS_CREDENTIALS = impersonate(APPLICATION_CREDENTIALS, TEMPORARY_ACCESS_USER.email); + + var topicName = getOptional(settings, "test.topic", ""); + if (!Strings.isNullOrEmpty(topicName)) { + PUBSUB_TOPIC = new Topic(PROJECT_ID.id, topicName); + } + else { + PUBSUB_TOPIC = null; + } } catch (IOException e) { throw new RuntimeException("Failed to load test settings", e); @@ -107,6 +119,15 @@ private static String getMandatory(Properties properties, String property) { return value; } + private static String getOptional(Properties properties, String property, String defaultVal) { + String value = properties.getProperty(property); + if (value == null || value.isEmpty()) { + return defaultVal; + } + + return value; + } + private static GoogleCredentials impersonate(GoogleCredentials source, String serviceAccount) { return ImpersonatedCredentials.create( source, serviceAccount, null, List.of("https://www.googleapis.com/auth/cloud-platform"), 0); diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/adapters/TestPubSubAdapter.java b/sources/src/test/java/com/google/solutions/jitaccess/core/adapters/TestPubSubAdapter.java new file mode 100644 index 000000000..efba2b18d --- /dev/null +++ b/sources/src/test/java/com/google/solutions/jitaccess/core/adapters/TestPubSubAdapter.java @@ -0,0 +1,78 @@ +// +// 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 com.google.api.services.pubsub.model.PubsubMessage; +import com.google.common.base.Strings; +import com.google.solutions.jitaccess.core.AccessDeniedException; +import com.google.solutions.jitaccess.core.NotAuthenticatedException; +import com.google.solutions.jitaccess.core.data.Topic; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestPubSubAdapter { + @Test + public void whenUnauthenticated_ThenPublishThrowsException() { + var adapter = new PubSubAdapter( + IntegrationTestEnvironment.INVALID_CREDENTIAL, + HttpTransport.Options.DEFAULT); + + assertThrows( + NotAuthenticatedException.class, + () -> adapter.publish( + new Topic(IntegrationTestEnvironment.PROJECT_ID.id, "topic-1"), + new PubsubMessage())); + } + + @Test + public void whenCallerLacksPermission_ThenAddProjectIamBindingThrowsException() { + var adapter = new PubSubAdapter( + IntegrationTestEnvironment.NO_ACCESS_CREDENTIALS, + HttpTransport.Options.DEFAULT); + assertThrows( + AccessDeniedException.class, + () -> adapter.publish( + new Topic(IntegrationTestEnvironment.PROJECT_ID.id, "topic-1"), + new PubsubMessage())); + } + + @Test + public void whenAuthenticated_ThenPublishSucceeds() throws Exception { + // if project id configured but no topic name, just skip the test + Assumptions.assumeTrue(IntegrationTestEnvironment.PROJECT_ID != null); + Assumptions.assumeTrue(IntegrationTestEnvironment.PUBSUB_TOPIC != null); + + var adapter = new PubSubAdapter( + IntegrationTestEnvironment.APPLICATION_CREDENTIALS, + HttpTransport.Options.DEFAULT); + + var messageId = adapter.publish( + IntegrationTestEnvironment.PUBSUB_TOPIC, + new PubsubMessage().encodeData("test".getBytes(StandardCharsets.UTF_8))); + + assertNotNull(messageId); + } +} diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestMessageTemplate.java b/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestMessageTemplate.java index f9f7be44b..064cff215 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestMessageTemplate.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestMessageTemplate.java @@ -59,7 +59,7 @@ public String getType() { } // ------------------------------------------------------------------------- - // NotificationTemplate. + // format. // ------------------------------------------------------------------------- @Test diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestPubSubNotificationService.java b/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestPubSubNotificationService.java new file mode 100644 index 000000000..71e7c5b2c --- /dev/null +++ b/sources/src/test/java/com/google/solutions/jitaccess/core/services/TestPubSubNotificationService.java @@ -0,0 +1,89 @@ +// +// 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.services; + +import com.google.solutions.jitaccess.core.adapters.PubSubAdapter; +import com.google.solutions.jitaccess.core.data.Topic; +import com.google.solutions.jitaccess.core.data.UserId; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; + +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class TestPubSubNotificationService { + + // ------------------------------------------------------------------------- + // sendNotification. + // ------------------------------------------------------------------------- + + private class SampleNotification extends NotificationService.Notification { + + protected SampleNotification( + Collection toRecipients, + Collection ccRecipients, + String subject) { + super(toRecipients, ccRecipients, subject); + + this.properties.put("string", "this is a string"); + this.properties.put("instant", Instant.ofEpochSecond(0)); + this.properties.put( + "user_list", + List.of(new UserId("alice@example.com"), new UserId("bob@example.com"))); + } + + @Override + public String getType() { + return "SampleNotification"; + } + } + + @Test + public void sendNotificationPublishesToPubSub() throws Exception { + var adapter = Mockito.mock(PubSubAdapter.class); + var topic = new Topic("project-1", "topic-1"); + var service = new PubSubNotificationService( + adapter, + new PubSubNotificationService.Options(topic)); + + service.sendNotification( + new SampleNotification( + List.of(new UserId("to@example.com")), + List.of(new UserId("cc@example.com")), + "subject")); + + var expectedMessage = + "eyJ0eXBlIjoiU2FtcGxlTm90aWZpY2F0aW9uIiwiYXR0cmlidXRlcyI6eyJzdHJpbmciOiJ0aGlzIGlzIGEgc3Rya" + + "W5nIiwidXNlcl9saXN0IjpbImFsaWNlQGV4YW1wbGUuY29tIiwiYm9iQGV4YW1wbGUuY29tIl0sImluc3RhbnQi" + + "OiIxOTcwLTAxLTAxVDAwOjAwOjAwWiJ9fQ"; + + verify(adapter, times(1)).publish( + eq(topic), + argThat(m -> m.getData().equals(expectedMessage))); + } +} diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/TestApiResource.java b/sources/src/test/java/com/google/solutions/jitaccess/web/TestApiResource.java index c4d407c7e..7c5decf54 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/web/TestApiResource.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/web/TestApiResource.java @@ -24,12 +24,17 @@ import com.google.auth.oauth2.TokenVerifier; import com.google.solutions.jitaccess.core.AccessDeniedException; import com.google.solutions.jitaccess.core.adapters.LogAdapter; -import com.google.solutions.jitaccess.core.data.ProjectId; import com.google.solutions.jitaccess.core.data.ProjectRole; import com.google.solutions.jitaccess.core.data.RoleBinding; import com.google.solutions.jitaccess.core.data.UserId; -import com.google.solutions.jitaccess.core.services.*; import jakarta.enterprise.inject.Instance; +import com.google.solutions.jitaccess.core.data.ProjectId; +import com.google.solutions.jitaccess.core.services.ActivationTokenService; +import com.google.solutions.jitaccess.core.services.NotificationService; +import com.google.solutions.jitaccess.core.services.RoleActivationService; +import com.google.solutions.jitaccess.core.services.RoleDiscoveryService; +import com.google.solutions.jitaccess.core.services.Result; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito;