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;