diff --git a/sources/pom.xml b/sources/pom.xml
index 241983a68..6f1bff40f 100644
--- a/sources/pom.xml
+++ b/sources/pom.xml
@@ -24,7 +24,7 @@
4.0.0
com.google.solutions
jitaccess
- 1.5.0
+ 1.5.1
3.2.3
17
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/AnnotatedResult.java b/sources/src/main/java/com/google/solutions/jitaccess/core/Annotated.java
similarity index 58%
rename from sources/src/main/java/com/google/solutions/jitaccess/core/AnnotatedResult.java
rename to sources/src/main/java/com/google/solutions/jitaccess/core/Annotated.java
index e8d862919..192ce6e0a 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/AnnotatedResult.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/Annotated.java
@@ -1,5 +1,5 @@
//
-// Copyright 2021 Google LLC
+// Copyright 2023 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
@@ -23,35 +23,19 @@
import com.google.common.base.Preconditions;
-import java.util.List;
+import java.util.Collection;
import java.util.Set;
/**
- * Result list of T with an optional set of warnings.
+ * @param items collection of items
+ * @param warnings warnings encountered
*/
-public class AnnotatedResult {
- /**
- * List of bindings. Might be incomplete if Warnings is non-empty.
- */
- private final List items;
-
- /**
- * Non-fatal issues encountered. Use a set to avoid duplicates.
- */
- private final Set warnings;
-
- public AnnotatedResult(List roleBindings, Set warnings) {
- Preconditions.checkNotNull(roleBindings);
-
- this.items = roleBindings;
- this.warnings = warnings;
- }
-
- public List getItems() {
- return this.items;
- }
-
- public Set getWarnings() {
- return warnings;
+public record Annotated>(
+ TColl items,
+ Set warnings
+) {
+ public Annotated {
+ Preconditions.checkNotNull(items, "items");
+ Preconditions.checkNotNull(warnings, "warnings");
}
}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/Exceptions.java b/sources/src/main/java/com/google/solutions/jitaccess/core/Exceptions.java
index 774577af3..d5964f96a 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/Exceptions.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/Exceptions.java
@@ -27,7 +27,7 @@ private Exceptions() {}
public static String getFullMessage(Throwable e) {
var buffer = new StringBuilder();
- for (var exception = e; e != null; e = e.getCause()) {
+ for (; e != null; e = e.getCause()) {
if (buffer.length() > 0) {
buffer.append(", caused by ");
buffer.append(e.getClass().getSimpleName());
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/RoleBinding.java b/sources/src/main/java/com/google/solutions/jitaccess/core/RoleBinding.java
similarity index 94%
rename from sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/RoleBinding.java
rename to sources/src/main/java/com/google/solutions/jitaccess/core/RoleBinding.java
index 532394a31..9ce52dbba 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/RoleBinding.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/RoleBinding.java
@@ -19,10 +19,9 @@
// under the License.
//
-package com.google.solutions.jitaccess.core.entitlements;
+package com.google.solutions.jitaccess.core;
import com.google.common.base.Preconditions;
-import com.google.solutions.jitaccess.core.ProjectId;
import java.util.Comparator;
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/Activation.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/Activation.java
new file mode 100644
index 000000000..b87895aad
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/Activation.java
@@ -0,0 +1,36 @@
+//
+// 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.catalog;
+
+import com.google.common.base.Preconditions;
+
+/**
+ * Represents a successful activation of one or more entitlements.
+ */
+public record Activation(
+ ActivationRequest request
+) {
+ public Activation {
+ Preconditions.checkNotNull(request, "request");
+ Preconditions.checkArgument(!request.entitlements().isEmpty());
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/ActivationId.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/ActivationId.java
new file mode 100644
index 000000000..c0fe8f34c
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/ActivationId.java
@@ -0,0 +1,50 @@
+//
+// 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.catalog;
+
+import com.google.common.base.Preconditions;
+
+import java.security.SecureRandom;
+import java.util.Base64;
+
+/**
+ * Unique ID of an activation.
+ */
+public record ActivationId(String id) {
+ public ActivationId {
+ Preconditions.checkNotNull(id);
+ }
+
+ private static final SecureRandom random = new SecureRandom();
+
+ public static ActivationId newId(ActivationType type) {
+ var id = new byte[12];
+ random.nextBytes(id);
+
+ return new ActivationId(type.name().toLowerCase() + "-" + Base64.getEncoder().encodeToString(id));
+ }
+
+ @Override
+ public String toString() {
+ return this.id;
+ }
+}
\ No newline at end of file
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/ActivationRequest.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/ActivationRequest.java
new file mode 100644
index 000000000..0d2ebaf51
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/ActivationRequest.java
@@ -0,0 +1,137 @@
+//
+// 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.catalog;
+
+import com.google.common.base.Preconditions;
+import com.google.solutions.jitaccess.core.UserId;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Represents a request for activating one or more entitlements.
+ */
+public abstract class ActivationRequest {
+ private final ActivationId id;
+ private final Instant startTime;
+ private final Duration duration;
+ private final UserId requestingUser;
+ private final Set entitlements;
+ private final String justification;
+
+ protected ActivationRequest(
+ ActivationId id,
+ UserId requestingUser,
+ Set entitlements,
+ String justification,
+ Instant startTime,
+ Duration duration
+ ) {
+
+ Preconditions.checkNotNull(id, "id");
+ Preconditions.checkNotNull(requestingUser, "user");
+ Preconditions.checkNotNull(entitlements, "entitlements");
+ Preconditions.checkNotNull(justification, "justification");
+ Preconditions.checkNotNull(startTime);
+ Preconditions.checkNotNull(startTime);
+
+ Preconditions.checkArgument(
+ !entitlements.isEmpty(),
+ "At least one entitlement must be specified");
+
+ Preconditions.checkArgument(
+ !duration.isZero() &&! duration.isNegative(),
+ "The duration must be positive");
+
+ this.id = id;
+ this.startTime = startTime;
+ this.duration = duration;
+ this.requestingUser = requestingUser;
+ this.entitlements = entitlements;
+ this.justification = justification;
+ }
+
+ /**
+ * @return unique ID of the request.
+ */
+ public ActivationId id() {
+ return this.id;
+ }
+
+ /**
+ * @return start time for requested access.
+ */
+ public Instant startTime() {
+ return this.startTime;
+ }
+
+ /**
+ * @return duration of requested activation.
+ */
+ public Duration duration() {
+ return this.duration;
+ }
+
+ /**
+ * @return end time for requested access.
+ */
+ public Instant endTime() {
+ return this.startTime.plus(this.duration);
+ }
+
+ /**
+ * @return user that requested access.
+ */
+ public UserId requestingUser() {
+ return this.requestingUser;
+ }
+
+ /**
+ * @return one or more entitlements.
+ */
+ public Collection entitlements() {
+ return this.entitlements;
+ }
+
+ /**
+ * @return user-provided justification for the request.
+ */
+ public String justification() {
+ return this.justification;
+ }
+
+ public abstract ActivationType type();
+
+ @Override
+ public String toString() {
+ return String.format(
+ "[%s] entitlements=%s, startTime=%s, duration=%s, justification=%s",
+ this.id,
+ this.entitlements.stream().map(e -> e.toString()).collect(Collectors.joining(",")),
+ this.startTime,
+ this.duration,
+ this.justification);
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/ActivationType.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/ActivationType.java
new file mode 100644
index 000000000..7c485bde0
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/ActivationType.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.catalog;
+
+public enum ActivationType {
+ /** Entitlement can be activated using self-approval */
+ JIT,
+
+ /** Entitlement can be activated using multi-party approval. */
+ MPA,
+
+ /** Entitlement can no longer be activated. */
+ NONE
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/Entitlement.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/Entitlement.java
new file mode 100644
index 000000000..1967036f1
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/Entitlement.java
@@ -0,0 +1,77 @@
+//
+// 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.catalog;
+
+import com.google.common.base.Preconditions;
+
+import java.util.Comparator;
+
+/**
+ * Represents an entitlement. An entitlement is dormant unless the user
+ * activates it, and it automatically becomes inactive again after a certain
+ * period of time has elapsed.
+ */
+public record Entitlement (
+ TEntitlementId id,
+ String name,
+ ActivationType activationType,
+ Status status
+) implements Comparable> {
+ public Entitlement {
+ Preconditions.checkNotNull(id, "id");
+ Preconditions.checkNotNull(name, "name");
+ }
+
+ @Override
+ public String toString() {
+ return this.name;
+ }
+
+ @Override
+ public int compareTo(Entitlement o) {
+ return Comparator
+ .comparing((Entitlement e) -> e.status)
+ .thenComparing(e -> e.id)
+ .compare(this, o);
+ }
+
+ //---------------------------------------------------------------------------
+ // Inner classes.
+ //---------------------------------------------------------------------------
+
+ public enum Status {
+ /**
+ * Entitlement can be activated.
+ */
+ AVAILABLE,
+
+ /**
+ * Entitlement is active.
+ */
+ ACTIVE,
+
+ /**
+ * Approval pending.
+ */
+ ACTIVATION_PENDING
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/EntitlementActivator.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/EntitlementActivator.java
new file mode 100644
index 000000000..edc7252d8
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/EntitlementActivator.java
@@ -0,0 +1,242 @@
+//
+// 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.catalog;
+
+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.AlreadyExistsException;
+import com.google.solutions.jitaccess.core.UserId;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Set;
+
+/**
+ * Activates entitlements, for example by modifying IAM policies.
+ */
+public abstract class EntitlementActivator {
+ private final JustificationPolicy policy;
+ private final EntitlementCatalog catalog;
+
+ protected EntitlementActivator(
+ EntitlementCatalog catalog,
+ JustificationPolicy policy
+ ) {
+ Preconditions.checkNotNull(catalog, "catalog");
+ Preconditions.checkNotNull(policy, "policy");
+
+ this.catalog = catalog;
+ this.policy = policy;
+ }
+
+ /**
+ * Create a new request to activate an entitlement that permits self-approval.
+ */
+ public final JitActivationRequest createJitRequest(
+ UserId requestingUser,
+ Set entitlements,
+ String justification,
+ Instant startTime,
+ Duration duration
+ ) {
+ Preconditions.checkArgument(
+ startTime.isAfter(Instant.now().minus(Duration.ofMinutes(1))),
+ "Start time must not be in the past");
+
+ //
+ // NB. There's no need to verify access at this stage yet.
+ //
+ return new JitRequest<>(
+ ActivationId.newId(ActivationType.JIT),
+ requestingUser,
+ entitlements,
+ justification,
+ startTime,
+ duration);
+ }
+
+ /**
+ * Create a new request to activate an entitlement that requires
+ * multi-party approval.
+ */
+ public MpaActivationRequest createMpaRequest(
+ UserId requestingUser,
+ Set entitlements,
+ Set reviewers,
+ String justification,
+ Instant startTime,
+ Duration duration
+ ) throws AccessException, IOException {
+
+ Preconditions.checkArgument(
+ startTime.isAfter(Instant.now().minus(Duration.ofMinutes(1))),
+ "Start time must not be in the past");
+
+ var request = new MpaRequest<>(
+ ActivationId.newId(ActivationType.MPA),
+ requestingUser,
+ entitlements,
+ reviewers,
+ justification,
+ startTime,
+ duration);
+
+ //
+ // Pre-verify access to avoid sending an MPA requests for which
+ // the access check will fail later.
+ //
+ this.catalog.verifyUserCanRequest(request);
+
+ return request;
+ }
+
+ /**
+ * Activate an entitlement that permits self-approval.
+ */
+ public final Activation activate(
+ JitActivationRequest request
+ ) throws AccessException, AlreadyExistsException, IOException
+ {
+ Preconditions.checkNotNull(policy, "policy");
+
+ //
+ // Check that the justification is ok.
+ //
+ policy.checkJustification(request.requestingUser(), request.justification());
+
+ //
+ // Check that the user is (still) allowed to activate this entitlement.
+ //
+ this.catalog.verifyUserCanRequest(request);
+
+ //
+ // Request is legit, apply it.
+ //
+ provisionAccess(request);
+
+ return new Activation<>(request);
+ }
+
+ /**
+ * Approve another user's request.
+ */
+ public final Activation approve(
+ UserId approvingUser,
+ MpaActivationRequest request
+ ) throws AccessException, AlreadyExistsException, IOException
+ {
+ Preconditions.checkNotNull(policy, "policy");
+
+ if (approvingUser.equals(request.requestingUser())) {
+ throw new IllegalArgumentException(
+ "MPA activation requires the caller and beneficiary to be the different");
+ }
+
+ if (!request.reviewers().contains(approvingUser)) {
+ throw new AccessDeniedException(
+ String.format("The request does not permit approval by %s", approvingUser));
+ }
+
+ //
+ // Check that the justification is ok.
+ //
+ policy.checkJustification(request.requestingUser(), request.justification());
+
+ //
+ // Check that the user is (still) allowed to request this entitlement.
+ //
+ this.catalog.verifyUserCanRequest(request);
+
+ //
+ // Check that the approving user is (still) allowed to approve this entitlement.
+ //
+ this.catalog.verifyUserCanApprove(approvingUser, request);
+
+ //
+ // Request is legit, apply it.
+ //
+ provisionAccess(approvingUser, request);
+
+ return new Activation<>(request);
+ }
+
+ /**
+ * Apply a request.
+ */
+ protected abstract void provisionAccess(
+ JitActivationRequest request
+ ) throws AccessException, AlreadyExistsException, IOException;
+
+
+ /**
+ * Apply a request.
+ */
+ protected abstract void provisionAccess(
+ UserId approvingUser,
+ MpaActivationRequest request
+ ) throws AccessException, AlreadyExistsException, IOException;
+
+ /**
+ * Create a converter for turning MPA requests into JWTs, and
+ * vice versa.
+ */
+ public abstract JsonWebTokenConverter> createTokenConverter();
+
+ // -------------------------------------------------------------------------
+ // Inner classes.
+ // -------------------------------------------------------------------------
+
+ protected static class JitRequest
+ extends JitActivationRequest {
+ public JitRequest(
+ ActivationId id,
+ UserId requestingUser,
+ Set entitlements,
+ String justification,
+ Instant startTime,
+ Duration duration
+ ) {
+ super(id, requestingUser, entitlements, justification, startTime, duration);
+ }
+ }
+
+ protected static class MpaRequest
+ extends MpaActivationRequest {
+ public MpaRequest(
+ ActivationId id,
+ UserId requestingUser,
+ Set entitlements,
+ Set reviewers,
+ String justification,
+ Instant startTime,
+ Duration duration
+ ) {
+ super(id, requestingUser, entitlements, reviewers, justification, startTime, duration);
+
+ if (entitlements.size() != 1) {
+ throw new IllegalArgumentException("Only one entitlement can be activated at a time");
+ }
+ }
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/EntitlementCatalog.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/EntitlementCatalog.java
new file mode 100644
index 000000000..35742f780
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/EntitlementCatalog.java
@@ -0,0 +1,47 @@
+//
+// 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.catalog;
+
+import com.google.solutions.jitaccess.core.AccessException;
+import com.google.solutions.jitaccess.core.UserId;
+
+import java.io.IOException;
+
+/**
+ * A catalog of entitlement that can be browsed by the user.
+ */
+public interface EntitlementCatalog {
+ /**
+ * Verify if a user is allowed to make the given request.
+ */
+ void verifyUserCanRequest(
+ ActivationRequest request
+ ) throws AccessException, IOException;
+
+ /**
+ * Verify if a user is allowed to approve a given request.
+ */
+ void verifyUserCanApprove(
+ UserId approvingUser,
+ MpaActivationRequest request
+ ) throws AccessException, IOException;
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/EntitlementId.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/EntitlementId.java
new file mode 100644
index 000000000..b6c21e1e1
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/EntitlementId.java
@@ -0,0 +1,71 @@
+//
+// 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.catalog;
+
+import java.util.Comparator;
+
+/**
+ * Unique identifier of an entitlement.
+ */
+public abstract class EntitlementId implements Comparable {
+ /**
+ * @return the catalog the entitlement belongs to
+ */
+ public abstract String catalog();
+
+ /**
+ * @return the ID within the catalog
+ */
+ public abstract String id();
+
+ @Override
+ public String toString() {
+ return String.format("%s:%s", this.catalog(), this.id());
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ var that = (EntitlementId) o;
+ return this.catalog().equals(that.catalog()) && this.id().equals(that.id());
+ }
+
+ @Override
+ public int hashCode() {
+ return id().hashCode();
+ }
+
+ @Override
+ public int compareTo(EntitlementId o) {
+ return Comparator
+ .comparing((EntitlementId e) -> e.catalog())
+ .thenComparing(e -> e.id())
+ .compare(this, o);
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/InvalidJustificationException.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/InvalidJustificationException.java
new file mode 100644
index 000000000..09e5375dd
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/InvalidJustificationException.java
@@ -0,0 +1,30 @@
+//
+// 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.catalog;
+
+import com.google.solutions.jitaccess.core.AccessDeniedException;
+
+public class InvalidJustificationException extends AccessDeniedException {
+ public InvalidJustificationException(String message) {
+ super(message);
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/JitActivationRequest.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/JitActivationRequest.java
new file mode 100644
index 000000000..1ec2e02a3
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/JitActivationRequest.java
@@ -0,0 +1,55 @@
+//
+// 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.catalog;
+
+import com.google.solutions.jitaccess.core.UserId;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Set;
+
+/**
+ * Request for "JIT-activating" an entitlement.
+ */
+public abstract class JitActivationRequest
+ extends ActivationRequest {
+ protected JitActivationRequest(
+ ActivationId id,
+ UserId requestingUser,
+ Set entitlements,
+ String justification,
+ Instant startTime,
+ Duration duration) {
+ super(
+ id,
+ requestingUser,
+ entitlements,
+ justification,
+ startTime,
+ duration);
+ }
+
+ @Override
+ public final ActivationType type() {
+ return ActivationType.JIT;
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/JsonWebTokenConverter.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/JsonWebTokenConverter.java
new file mode 100644
index 000000000..28975f43c
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/JsonWebTokenConverter.java
@@ -0,0 +1,36 @@
+//
+// 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.catalog;
+
+import com.google.api.client.json.webtoken.JsonWebToken;
+
+public interface JsonWebTokenConverter {
+ /**
+ * Convert object to JWT payload.
+ */
+ JsonWebToken.Payload convert(T object);
+
+ /**
+ * Create JWT payload to object.
+ */
+ T convert(JsonWebToken.Payload payload);
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/JustificationPolicy.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/JustificationPolicy.java
new file mode 100644
index 000000000..59bf38002
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/JustificationPolicy.java
@@ -0,0 +1,42 @@
+//
+// 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.catalog;
+
+import com.google.solutions.jitaccess.core.UserId;
+
+/**
+ * Policy for verifying justification messages.
+ */
+public interface JustificationPolicy {
+ /**
+ * Check that a justification meets criteria.
+ */
+ void checkJustification(
+ UserId user,
+ String justification
+ ) throws InvalidJustificationException;
+
+ /**
+ * @return hint indicating what kind of justification is expected.
+ */
+ String hint();
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/MpaActivationRequest.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/MpaActivationRequest.java
new file mode 100644
index 000000000..1b8b48549
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/MpaActivationRequest.java
@@ -0,0 +1,68 @@
+//
+// 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.catalog;
+
+import com.google.common.base.Preconditions;
+import com.google.solutions.jitaccess.core.UserId;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * Request for "MPA-activating" an entitlement.
+ */
+public abstract class MpaActivationRequest
+ extends ActivationRequest {
+ private final Collection reviewers;
+
+ protected MpaActivationRequest(
+ ActivationId id,
+ UserId requestingUser,
+ Set entitlements,
+ Set reviewers,
+ String justification,
+ Instant startTime,
+ Duration duration) {
+ super(
+ id,
+ requestingUser,
+ entitlements,
+ justification,
+ startTime,
+ duration);
+
+ Preconditions.checkNotNull(reviewers, "reviewers");
+ Preconditions.checkArgument(!reviewers.isEmpty());
+ this.reviewers = reviewers;
+ }
+
+ public Collection reviewers() {
+ return this.reviewers;
+ }
+
+ @Override
+ public final ActivationType type() {
+ return ActivationType.MPA;
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/RegexJustificationPolicy.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/RegexJustificationPolicy.java
new file mode 100644
index 000000000..f32552ea5
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/RegexJustificationPolicy.java
@@ -0,0 +1,66 @@
+//
+// 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.catalog;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.solutions.jitaccess.core.UserId;
+import jakarta.enterprise.context.ApplicationScoped;
+
+import java.util.regex.Pattern;
+
+/**
+ * Policy that checks justifications against a Regex pattern.
+ */
+@ApplicationScoped
+public class RegexJustificationPolicy implements JustificationPolicy {
+ private final Options options;
+
+ public RegexJustificationPolicy(Options options) {
+ Preconditions.checkNotNull(options, "options");
+ this.options = options;
+ }
+
+ @Override
+ public void checkJustification(
+ UserId user,
+ String justification
+ ) throws InvalidJustificationException {
+ if (
+ Strings.isNullOrEmpty(justification) ||
+ !this.options.justificationPattern.matcher(justification).matches()) {
+ throw new InvalidJustificationException(
+ String.format("Justification does not meet criteria: %s", this.options.justificationHint));
+ }
+ }
+
+ @Override
+ public String hint() {
+ return this.options.justificationHint();
+ }
+
+ public record Options(
+ String justificationHint,
+ Pattern justificationPattern
+ ){
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/ActivationTokenService.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/TokenSigner.java
similarity index 58%
rename from sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/ActivationTokenService.java
rename to sources/src/main/java/com/google/solutions/jitaccess/core/catalog/TokenSigner.java
index 9f90065b4..6de23baa0 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/ActivationTokenService.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/TokenSigner.java
@@ -1,5 +1,5 @@
//
-// Copyright 2022 Google LLC
+// Copyright 2023 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
@@ -19,7 +19,7 @@
// under the License.
//
-package com.google.solutions.jitaccess.core.entitlements;
+package com.google.solutions.jitaccess.core.catalog;
import com.google.auth.oauth2.TokenVerifier;
import com.google.common.base.Preconditions;
@@ -33,33 +33,18 @@
import java.time.Instant;
/**
- * Creates and verifies activation tokens.
- *
- * An activation token is a signed activation request that is passed to reviewers.
- * It contains all information necessary to review (and approve) the activation
- * request.
- *
- * We must ensure that the information that reviewers see (and base their approval
- * on) is authentic. Therefore, activation tokens are signed, using the service account
- * as signing authority.
- *
- * Although activation tokens are JWTs, and might look like credentials, they aren't
- * credentials: They don't grant access to any information, and are insufficient to
- * approve an activation request.
+ * Signs JWTs using a service account's Google-managed service account key.
*/
@ApplicationScoped
-public class ActivationTokenService {
+public class TokenSigner {
private final IamCredentialsClient iamCredentialsClient;
private final Options options;
private final TokenVerifier tokenVerifier;
- public ActivationTokenService(
+ public TokenSigner(
IamCredentialsClient iamCredentialsClient,
Options options
) {
- Preconditions.checkNotNull(iamCredentialsClient, "iamCredentialsAdapter");
- Preconditions.checkNotNull(options, "options");
-
this.options = options;
this.iamCredentialsClient = iamCredentialsClient;
@@ -74,28 +59,43 @@ public ActivationTokenService(
.build();
}
- public TokenWithExpiry createToken(RoleActivationService.ActivationRequest request) throws AccessException, IOException {
- Preconditions.checkNotNull(request, "request");
- Preconditions.checkArgument(request.startTime.isBefore(Instant.now().plusSeconds(10)));
- Preconditions.checkArgument(request.startTime.isAfter(Instant.now().minusSeconds(10)));
+ /**
+ * Create a signed JWT for a given payload.
+ */
+ public TokenWithExpiry sign(
+ JsonWebTokenConverter converter,
+ T payload
+ ) throws AccessException, IOException {
+
+ Preconditions.checkNotNull(converter, "converter");
+ Preconditions.checkNotNull(payload, "payload");
//
// Add obligatory claims.
//
- var expiryTime = request.startTime.plus(this.options.tokenValidity);
- var jwtPayload = request.toJsonWebTokenPayload()
+ var issueTime = Instant.now();
+ var expiryTime = issueTime.plus(this.options.tokenValidity);
+ var jwtPayload = converter.convert(payload)
.setAudience(this.options.serviceAccount.email)
.setIssuer(this.options.serviceAccount.email)
+ .setIssuedAtTimeSeconds(issueTime.getEpochSecond())
.setExpirationTimeSeconds(expiryTime.getEpochSecond());
return new TokenWithExpiry(
this.iamCredentialsClient.signJwt(this.options.serviceAccount, jwtPayload),
+ issueTime,
expiryTime);
}
- public RoleActivationService.ActivationRequest verifyToken(
+ /**
+ * Decode and verify a JWT.
+ */
+ public T verify(
+ JsonWebTokenConverter converter,
String token
) throws TokenVerifier.VerificationException {
+
+ Preconditions.checkNotNull(converter, "converter");
Preconditions.checkNotNull(token, "token");
//
@@ -110,34 +110,28 @@ public RoleActivationService.ActivationRequest verifyToken(
throw new TokenVerifier.VerificationException("The token uses the wrong algorithm");
}
- return RoleActivationService.ActivationRequest.fromJsonWebTokenPayload(decodedToken.getPayload());
- }
-
- public Options getOptions() {
- return options;
+ return converter.convert(decodedToken.getPayload());
}
// -------------------------------------------------------------------------
// Inner classes.
// -------------------------------------------------------------------------
- public static class TokenWithExpiry {
- public final String token;
- public final Instant expiryTime;
-
- public TokenWithExpiry(String token, Instant expiryTime) {
- this.token = token;
- this.expiryTime = expiryTime;
+ public record TokenWithExpiry(
+ String token,
+ Instant issueTime,
+ Instant expiryTime) {
+ public TokenWithExpiry {
+ Preconditions.checkNotNull(token, "token");
+ Preconditions.checkArgument(expiryTime.isAfter(issueTime));
+ Preconditions.checkArgument(expiryTime.isAfter(Instant.now()));
}
}
- public static class Options {
- public final UserId serviceAccount;
- public final Duration tokenValidity;
-
- public Options(UserId serviceAccount, Duration tokenValidity) {
- this.serviceAccount = serviceAccount;
- this.tokenValidity = tokenValidity;
+ public record Options(UserId serviceAccount, Duration tokenValidity) {
+ public Options {
+ Preconditions.checkNotNull(serviceAccount);
+ Preconditions.checkArgument(!tokenValidity.isNegative());
}
}
}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/IamPolicyCatalog.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/IamPolicyCatalog.java
new file mode 100644
index 000000000..21562aaf0
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/IamPolicyCatalog.java
@@ -0,0 +1,279 @@
+//
+// 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.catalog.project;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.solutions.jitaccess.core.*;
+import com.google.solutions.jitaccess.core.catalog.ActivationRequest;
+import com.google.solutions.jitaccess.core.catalog.ActivationType;
+import com.google.solutions.jitaccess.core.catalog.Entitlement;
+import com.google.solutions.jitaccess.core.catalog.MpaActivationRequest;
+import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
+import jakarta.enterprise.context.ApplicationScoped;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Catalog that uses the Policy Analyzer API to find entitlements
+ * based on IAM Allow-policies.
+ *
+ * Entitlements as used by this class are role bindings that:
+ * are annotated with a special IAM condition (making the binding
+ * "eligible").
+ */
+@ApplicationScoped
+public class IamPolicyCatalog extends ProjectRoleCatalog {
+ private final PolicyAnalyzer policyAnalyzer;
+ private final ResourceManagerClient resourceManagerClient;
+ private final Options options;
+
+ public IamPolicyCatalog(
+ PolicyAnalyzer policyAnalyzer,
+ ResourceManagerClient resourceManagerClient,
+ Options options
+ ) {
+ Preconditions.checkNotNull(policyAnalyzer, "assetInventoryClient");
+ Preconditions.checkNotNull(resourceManagerClient, "resourceManagerClient");
+ Preconditions.checkNotNull(options, "options");
+
+ this.policyAnalyzer = policyAnalyzer;
+ this.resourceManagerClient = resourceManagerClient;
+ this.options = options;
+ }
+
+ void validateRequest(ActivationRequest request) {
+ Preconditions.checkNotNull(request, "request");
+ Preconditions.checkArgument(
+ request.duration().toSeconds() >= this.options.minActivationDuration().toSeconds(),
+ String.format(
+ "The activation duration must be no shorter than %d minutes",
+ this.options.minActivationDuration().toMinutes()));
+ Preconditions.checkArgument(
+ request.duration().toSeconds() <= this.options.maxActivationDuration().toSeconds(),
+ String.format(
+ "The activation duration must be no longer than %d minutes",
+ this.options.maxActivationDuration().toMinutes()));
+
+ if (request instanceof MpaActivationRequest mpaRequest) {
+ Preconditions.checkArgument(
+ mpaRequest.reviewers() != null &&
+ mpaRequest.reviewers().size() >= this.options.minNumberOfReviewersPerActivationRequest,
+ String.format(
+ "At least %d reviewers must be specified",
+ this.options.minNumberOfReviewersPerActivationRequest ));
+ Preconditions.checkArgument(
+ mpaRequest.reviewers().size() <= this.options.maxNumberOfReviewersPerActivationRequest,
+ String.format(
+ "The number of reviewers must not exceed %s",
+ this.options.maxNumberOfReviewersPerActivationRequest));
+ }
+ }
+
+ void verifyUserCanActivateEntitlements(
+ UserId user,
+ ProjectId projectId,
+ ActivationType activationType,
+ Collection entitlements
+ ) throws AccessException, IOException {
+ //
+ // Verify that the user has eligible role bindings
+ // for all entitlements.
+ //
+ // NB. It doesn't matter whether the user has already
+ // activated the role.
+ //
+ var userEntitlements = this.policyAnalyzer
+ .findEntitlements(
+ user,
+ projectId,
+ EnumSet.of(Entitlement.Status.AVAILABLE))
+ .items()
+ .stream()
+ .filter(ent -> ent.activationType() == activationType)
+ .collect(Collectors.toMap(ent -> ent.id(), ent -> ent));
+
+ for (var requestedEntitlement : entitlements) {
+ var grantedEntitlement = userEntitlements.get(requestedEntitlement);
+ if (grantedEntitlement == null) {
+ throw new AccessDeniedException(
+ String.format(
+ "The user %s is not allowed to activate %s using %s",
+ user,
+ requestedEntitlement.id(),
+ activationType));
+ }
+ }
+ }
+
+ public Options options() {
+ return this.options;
+ }
+
+ //---------------------------------------------------------------------------
+ // Overrides.
+ //---------------------------------------------------------------------------
+
+ @Override
+ public SortedSet listProjects(
+ UserId user
+ ) throws AccessException, IOException {
+ if (Strings.isNullOrEmpty(this.options.availableProjectsQuery)) {
+ //
+ // Find projects for which the user has any role bindings (eligible
+ // or regular bindings). This method is slow, but accurate.
+ //
+ return this.policyAnalyzer.findProjectsWithEntitlements(user);
+ }
+ else {
+ //
+ // List all projects that the application's service account
+ // can enumerate. This method is fast, but almost certainly
+ // returns some projects that the user doesn't have any
+ // entitlements for. Depending on the nature of the projects,
+ // this might be acceptable or considered information disclosure.
+ //
+ return this.resourceManagerClient.searchProjectIds(
+ this.options.availableProjectsQuery);
+ }
+ }
+
+ @Override
+ public Annotated>> listEntitlements(
+ UserId user,
+ ProjectId projectId
+ ) throws AccessException, IOException {
+ return this.policyAnalyzer.findEntitlements(
+ user,
+ projectId,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+ }
+
+ @Override
+ public SortedSet listReviewers(
+ UserId requestingUser,
+ ProjectRoleBinding entitlement
+ ) throws AccessException, IOException {
+
+ //
+ // Check that the requesting user is allowed to request approval,
+ // and isn't just trying to do enumeration.
+ //
+
+ verifyUserCanActivateEntitlements(
+ requestingUser,
+ entitlement.projectId(),
+ ActivationType.MPA,
+ List.of(entitlement));
+
+ return this.policyAnalyzer
+ .findApproversForEntitlement(entitlement.roleBinding())
+ .stream()
+ .filter(u -> !u.equals(requestingUser)) // Exclude requesting user
+ .collect(Collectors.toCollection(TreeSet::new));
+ }
+
+ @Override
+ public void verifyUserCanRequest(
+ ActivationRequest request
+ ) throws AccessException, IOException {
+
+ validateRequest(request);
+
+ //
+ // Check if the requesting user is allowed to activate this
+ // entitlement.
+ //
+ verifyUserCanActivateEntitlements(
+ request.requestingUser(),
+ ProjectActivationRequest.projectId(request),
+ request.type(),
+ request.entitlements());
+ }
+
+ @Override
+ public void verifyUserCanApprove(
+ UserId approvingUser,
+ MpaActivationRequest request
+ ) throws AccessException, IOException {
+
+ validateRequest(request);
+
+ //
+ // Check if the approving user (!) is allowed to activate this
+ // entitlement.
+ //
+ // NB. The base class already checked that the requesting user
+ // is allowed.
+ //
+ verifyUserCanActivateEntitlements(
+ approvingUser,
+ ProjectActivationRequest.projectId(request),
+ request.type(),
+ request.entitlements());
+ }
+
+ // -------------------------------------------------------------------------
+ // Inner classes.
+ // -------------------------------------------------------------------------
+
+ /**
+ * If a query is provided, the class performs a Resource Manager project
+ * search instead of Policy Analyzer query to list projects. This is faster,
+ * but results in non-personalized results.
+ *
+ * @param scope organization/ID, folder/ID, or project/ID.
+ * @param availableProjectsQuery optional, search query, for example:
+ * - parent:folders/{folder_id}
+ * @param maxActivationDuration maximum duration for an activation
+ */
+ public record Options(
+ String availableProjectsQuery,
+ Duration maxActivationDuration,
+ int minNumberOfReviewersPerActivationRequest,
+ int maxNumberOfReviewersPerActivationRequest
+ ) {
+ static final int MIN_ACTIVATION_TIMEOUT_MINUTES = 5;
+
+ public Options {
+ Preconditions.checkNotNull(maxActivationDuration, "maxActivationDuration");
+
+ Preconditions.checkArgument(!maxActivationDuration.isNegative());
+ Preconditions.checkArgument(
+ maxActivationDuration.toMinutes() >= MIN_ACTIVATION_TIMEOUT_MINUTES,
+ "Activation timeout must be at least 5 minutes");
+ Preconditions.checkArgument(
+ minNumberOfReviewersPerActivationRequest > 0,
+ "The minimum number of reviewers cannot be 0");
+ Preconditions.checkArgument(
+ minNumberOfReviewersPerActivationRequest <= maxNumberOfReviewersPerActivationRequest,
+ "The minimum number of reviewers must not exceed the maximum");
+ }
+
+ public Duration minActivationDuration() {
+ return Duration.ofMinutes(MIN_ACTIVATION_TIMEOUT_MINUTES);
+ }
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/JitConstraints.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/JitConstraints.java
similarity index 97%
rename from sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/JitConstraints.java
rename to sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/JitConstraints.java
index 36587998f..cbf5bc801 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/JitConstraints.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/JitConstraints.java
@@ -19,7 +19,7 @@
// under the License.
//
-package com.google.solutions.jitaccess.core.entitlements;
+package com.google.solutions.jitaccess.core.catalog.project;
import com.google.api.services.cloudasset.v1.model.Expr;
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/PolicyAnalyzer.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/PolicyAnalyzer.java
new file mode 100644
index 000000000..3eb0b726a
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/PolicyAnalyzer.java
@@ -0,0 +1,334 @@
+//
+// 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.catalog.project;
+
+import com.google.api.services.cloudasset.v1.model.Expr;
+import com.google.api.services.cloudasset.v1.model.IamPolicyAnalysis;
+import com.google.common.base.Preconditions;
+import com.google.solutions.jitaccess.core.AccessException;
+import com.google.solutions.jitaccess.core.Annotated;
+import com.google.solutions.jitaccess.core.ProjectId;
+import com.google.solutions.jitaccess.core.UserId;
+import com.google.solutions.jitaccess.core.catalog.ActivationType;
+import com.google.solutions.jitaccess.core.catalog.Entitlement;
+import com.google.solutions.jitaccess.core.RoleBinding;
+import com.google.solutions.jitaccess.core.clients.AssetInventoryClient;
+import jakarta.enterprise.context.ApplicationScoped;
+
+import java.io.IOException;
+import java.util.*;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Helper class for performing Policy Analyzer searches.
+ */
+@ApplicationScoped
+public class PolicyAnalyzer {
+ private final Options options;
+ private final AssetInventoryClient assetInventoryClient;
+
+ public PolicyAnalyzer(
+ AssetInventoryClient assetInventoryClient,
+ Options options
+ ) {
+ Preconditions.checkNotNull(assetInventoryClient, "assetInventoryClient");
+ Preconditions.checkNotNull(options, "options");
+
+ this.assetInventoryClient = assetInventoryClient;
+ this.options = options;
+ }
+
+ private static List findRoleBindings(
+ IamPolicyAnalysis analysisResult,
+ Predicate conditionPredicate,
+ Predicate conditionEvaluationPredicate
+ ) {
+ //
+ // NB. We don't really care which resource a policy is attached to
+ // (indicated by AttachedResourceFullName). Instead, we care about
+ // which resources it applies to.
+ //
+ return Stream.ofNullable(analysisResult.getAnalysisResults())
+ .flatMap(Collection::stream)
+
+ // Narrow down to IAM bindings with a specific IAM condition.
+ .filter(result -> conditionPredicate.test(result.getIamBinding() != null
+ ? result.getIamBinding().getCondition()
+ : null))
+ .flatMap(result -> result
+ .getAccessControlLists()
+ .stream()
+
+ // Narrow down to ACLs with a specific IAM condition evaluation result.
+ .filter(acl -> conditionEvaluationPredicate.test(acl.getConditionEvaluation() != null
+ ? acl.getConditionEvaluation().getEvaluationValue()
+ : null))
+
+ // Collect all (supported) resources covered by these bindings/ACLs.
+ .flatMap(acl -> acl.getResources()
+ .stream()
+ .filter(res -> ProjectId.isProjectFullResourceName(res.getFullResourceName()))
+ .map(res -> new RoleBinding(
+ res.getFullResourceName(),
+ result.getIamBinding().getRole()))))
+ .collect(Collectors.toList());
+ }
+
+ //---------------------------------------------------------------------------
+ // Publics.
+ //---------------------------------------------------------------------------
+
+ /**
+ * Find projects that a user has standing, JIT-, or MPA-eligible access to.
+ */
+ public SortedSet findProjectsWithEntitlements(
+ UserId user
+ ) throws AccessException, IOException {
+
+ Preconditions.checkNotNull(user, "user");
+
+ //
+ // NB. To reliably find projects, we have to let the Asset API consider
+ // inherited role bindings by using the "expand resources" flag. This
+ // flag causes the API to return *all* resources for which an IAM binding
+ // applies.
+ //
+ // The risk here is that the list of resources grows so large that we're hitting
+ // the limits of the API, in which case it starts truncating results. To
+ // mitigate this risk, filter on a permission that:
+ //
+ // - only applies to projects, and has no meaning on descendant resources
+ // - represents the lowest level of access to a project.
+ //
+ var analysisResult = this.assetInventoryClient.findAccessibleResourcesByUser(
+ this.options.scope,
+ user,
+ Optional.of("resourcemanager.projects.get"),
+ Optional.empty(),
+ true);
+
+ //
+ // Consider permanent and eligible bindings.
+ //
+ var roleBindings = findRoleBindings(
+ analysisResult,
+ condition -> condition == null ||
+ JitConstraints.isJitAccessConstraint(condition) ||
+ JitConstraints.isMultiPartyApprovalConstraint(condition),
+ evalResult -> evalResult == null ||
+ "TRUE".equalsIgnoreCase(evalResult) ||
+ "CONDITIONAL".equalsIgnoreCase(evalResult));
+
+ return roleBindings
+ .stream()
+ .map(b -> ProjectId.fromFullResourceName(b.fullResourceName()))
+ .collect(Collectors.toCollection(TreeSet::new));
+ }
+
+ /**
+ * List entitlements for the given user.
+ */
+ public Annotated>> findEntitlements(
+ UserId user,
+ ProjectId projectId,
+ EnumSet statusesToInclude
+ ) throws AccessException, IOException {
+
+ Preconditions.checkNotNull(user, "user");
+ Preconditions.checkNotNull(projectId, "projectId");
+
+ //
+ // Use Asset API to search for resources that the user could
+ // access if they satisfied the eligibility condition.
+ //
+ // NB. The existence of an eligibility condition alone isn't
+ // sufficient - it needs to be on a binding that applies to the
+ // user.
+ //
+ // NB. The Asset API considers group membership if the caller
+ // (i.e., the app's service account) has the 'Groups Reader'
+ // admin role.
+ //
+
+ var analysisResult = this.assetInventoryClient.findAccessibleResourcesByUser(
+ this.options.scope,
+ user,
+ Optional.empty(),
+ Optional.of(projectId.getFullResourceName()),
+ false);
+
+ var warnings = Stream.ofNullable(analysisResult.getNonCriticalErrors())
+ .flatMap(Collection::stream)
+ .map(e -> e.getCause())
+ .collect(Collectors.toSet());
+
+ var allAvailable = new TreeSet>();
+ if (statusesToInclude.contains(Entitlement.Status.AVAILABLE)) {
+
+ //
+ // Find all JIT-eligible role bindings. The bindings are
+ // conditional and have a special condition that serves
+ // as marker.
+ //
+ Set> jitEligible = findRoleBindings(
+ analysisResult,
+ condition -> JitConstraints.isJitAccessConstraint(condition),
+ evalResult -> "CONDITIONAL".equalsIgnoreCase(evalResult))
+ .stream()
+ .map(binding -> new Entitlement(
+ new ProjectRoleBinding(binding),
+ binding.role(),
+ ActivationType.JIT,
+ Entitlement.Status.AVAILABLE))
+ .collect(Collectors.toSet());
+
+ //
+ // Find all MPA-eligible role bindings. The bindings are
+ // conditional and have a special condition that serves
+ // as marker.
+ //
+ Set> mpaEligible = findRoleBindings(
+ analysisResult,
+ condition -> JitConstraints.isMultiPartyApprovalConstraint(condition),
+ evalResult -> "CONDITIONAL".equalsIgnoreCase(evalResult))
+ .stream()
+ .map(binding -> new Entitlement(
+ new ProjectRoleBinding(binding),
+ binding.role(),
+ ActivationType.MPA,
+ Entitlement.Status.AVAILABLE))
+ .collect(Collectors.toSet());
+
+ //
+ // Determine effective set of eligible roles. If a role is both JIT- and
+ // MPA-eligible, only retain the JIT-eligible one.
+ //
+ // Use a list so that JIT-eligible roles go first, followed by MPA-eligible ones.
+ //
+ allAvailable.addAll(jitEligible);
+ allAvailable.addAll(mpaEligible
+ .stream()
+ .filter(r -> !jitEligible.stream().anyMatch(a -> a.id().equals(r.id())))
+ .collect(Collectors.toList()));
+ }
+
+ var allActive = new TreeSet>();
+ if (statusesToInclude.contains(Entitlement.Status.ACTIVE)) {
+ //
+ // Find role bindings which have already been activated.
+ // These bindings have a time condition that we created, and
+ // the condition evaluates to true (indicating it's still
+ // valid).
+ //
+
+ for (var activeBinding : findRoleBindings(
+ analysisResult,
+ condition -> JitConstraints.isActivated(condition),
+ evalResult -> "TRUE".equalsIgnoreCase(evalResult))) {
+ //
+ // Find the corresponding eligible binding to determine
+ // whether this is JIT or MPA-eligible.
+ //
+ var correspondingEligibleBinding = allAvailable
+ .stream()
+ .filter(ent -> ent.id().roleBinding().equals(activeBinding))
+ .findFirst();
+ if (correspondingEligibleBinding.isPresent()) {
+ allActive.add(new Entitlement<>(
+ new ProjectRoleBinding(activeBinding),
+ activeBinding.role(),
+ correspondingEligibleBinding.get().activationType(),
+ Entitlement.Status.ACTIVE));
+ }
+ else {
+ //
+ // Active, but no longer eligible.
+ //
+ allActive.add(new Entitlement<>(
+ new ProjectRoleBinding(activeBinding),
+ activeBinding.role(),
+ ActivationType.NONE,
+ Entitlement.Status.ACTIVE));
+ }
+ }
+ }
+
+ //
+ // Replace roles that have been activated already.
+ //
+ var availableAndActive = allAvailable
+ .stream()
+ .filter(r -> !allActive.stream().anyMatch(a -> a.id().equals(r.id())))
+ .collect(Collectors.toCollection(TreeSet::new));
+ availableAndActive.addAll(allActive);
+
+ return new Annotated<>(availableAndActive, warnings);
+ }
+
+ /**
+ * List users that can approve the activation of an eligible role binding.
+ */
+ public Set findApproversForEntitlement(
+ RoleBinding roleBinding
+ ) throws AccessException, IOException {
+
+ Preconditions.checkNotNull(roleBinding, "roleBinding");
+ assert ProjectId.isProjectFullResourceName(roleBinding.fullResourceName());
+
+ var analysisResult = this.assetInventoryClient.findPermissionedPrincipalsByResource(
+ this.options.scope,
+ roleBinding.fullResourceName(),
+ roleBinding.role());
+
+ return Stream.ofNullable(analysisResult.getAnalysisResults())
+ .flatMap(Collection::stream)
+
+ // Narrow down to IAM bindings with an MPA constraint.
+ .filter(result -> result.getIamBinding() != null &&
+ JitConstraints.isMultiPartyApprovalConstraint(result.getIamBinding().getCondition()))
+
+ // Collect identities (users and group members)
+ .filter(result -> result.getIdentityList() != null)
+ .flatMap(result -> result.getIdentityList().getIdentities().stream()
+ .filter(id -> id.getName().startsWith("user:"))
+ .map(id -> new UserId(id.getName().substring("user:".length()))))
+
+ .collect(Collectors.toCollection(TreeSet::new));
+ }
+
+ // -------------------------------------------------------------------------
+ // Inner classes.
+ // -------------------------------------------------------------------------
+
+ /**
+ * @param scope Scope to use for queries.
+ */
+ public record Options(
+ String scope) {
+
+ public Options {
+ Preconditions.checkNotNull(scope, "scope");
+ }
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectActivationRequest.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectActivationRequest.java
new file mode 100644
index 000000000..19f1ba3e0
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectActivationRequest.java
@@ -0,0 +1,47 @@
+//
+// 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.catalog.project;
+
+import com.google.solutions.jitaccess.core.ProjectId;
+import com.google.solutions.jitaccess.core.catalog.ActivationRequest;
+
+import java.util.stream.Collectors;
+
+class ProjectActivationRequest {
+ private ProjectActivationRequest() {
+ }
+
+ /**
+ * @return common project ID for all requested entitlements.
+ */
+ static ProjectId projectId(ActivationRequest request) {
+ var projects = request.entitlements().stream()
+ .map(e -> e.roleBinding().fullResourceName())
+ .collect(Collectors.toSet());
+
+ if (projects.size() != 1) {
+ throw new IllegalArgumentException("Entitlements must be part of the same project");
+ }
+
+ return ProjectId.fromFullResourceName(projects.stream().findFirst().get());
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectRoleActivator.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectRoleActivator.java
new file mode 100644
index 000000000..173d37e4d
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectRoleActivator.java
@@ -0,0 +1,203 @@
+//
+// 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.catalog.project;
+
+import com.google.api.client.json.webtoken.JsonWebToken;
+import com.google.api.services.cloudresourcemanager.v3.model.Binding;
+import com.google.common.base.Preconditions;
+import com.google.solutions.jitaccess.core.*;
+import com.google.solutions.jitaccess.core.catalog.*;
+import com.google.solutions.jitaccess.core.clients.IamTemporaryAccessConditions;
+import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
+import jakarta.enterprise.context.Dependent;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Activator for project roles.
+ */
+@Dependent
+public class ProjectRoleActivator extends EntitlementActivator {
+ private final ResourceManagerClient resourceManagerClient;
+
+ public ProjectRoleActivator(
+ EntitlementCatalog catalog,
+ ResourceManagerClient resourceManagerClient,
+ JustificationPolicy policy
+ ) {
+ super(catalog, policy);
+
+ Preconditions.checkNotNull(resourceManagerClient, "resourceManagerClient");
+
+ this.resourceManagerClient = resourceManagerClient;
+ }
+
+ private void provisionTemporaryBinding(
+ String bindingDescription,
+ ProjectId projectId,
+ UserId user,
+ Set roles,
+ Instant startTime,
+ Duration duration
+ ) throws AccessException, AlreadyExistsException, IOException {
+
+ //
+ // Add time-bound IAM binding.
+ //
+ // Replace existing bindings for same user and role to avoid
+ // accumulating junk, and to prevent hitting the binding limit.
+ //
+
+ for (var role : roles) {
+ var binding = new Binding()
+ .setMembers(List.of("user:" + user))
+ .setRole(role)
+ .setCondition(new com.google.api.services.cloudresourcemanager.v3.model.Expr()
+ .setTitle(JitConstraints.ACTIVATION_CONDITION_TITLE)
+ .setDescription(bindingDescription)
+ .setExpression(IamTemporaryAccessConditions.createExpression(startTime, duration)));
+
+ //TODO(later): Add bindings in a single request.
+
+ this.resourceManagerClient.addProjectIamBinding(
+ projectId,
+ binding,
+ EnumSet.of(ResourceManagerClient.IamBindingOptions.PURGE_EXISTING_TEMPORARY_BINDINGS),
+ bindingDescription);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Overrides.
+ // -------------------------------------------------------------------------
+
+ @Override
+ protected void provisionAccess(
+ JitActivationRequest request
+ ) throws AccessException, AlreadyExistsException, IOException {
+
+ Preconditions.checkNotNull(request, "request");
+
+ var bindingDescription = String.format(
+ "Self-approved, justification: %s",
+ request.justification());
+
+ provisionTemporaryBinding(
+ bindingDescription,
+ ProjectActivationRequest.projectId(request),
+ request.requestingUser(),
+ request.entitlements()
+ .stream()
+ .map(e -> e.roleBinding().role())
+ .collect(Collectors.toSet()),
+ request.startTime(),
+ request.duration());
+ }
+
+ @Override
+ protected void provisionAccess(
+ UserId approvingUser,
+ MpaActivationRequest request
+ ) throws AccessException, AlreadyExistsException, IOException {
+
+ Preconditions.checkNotNull(request, "request");
+
+ var bindingDescription = String.format(
+ "Approved by %s, justification: %s",
+ approvingUser.email,
+ request.justification());
+
+ //
+ // NB. The start/end time for the binding is derived from the approval token. If multiple
+ // reviewers try to approve the same token, the resulting condition (and binding) will
+ // be the same. This is important so that we can use the FAIL_IF_BINDING_EXISTS flag.
+ //
+
+ provisionTemporaryBinding(
+ bindingDescription,
+ ProjectActivationRequest.projectId(request),
+ request.requestingUser(),
+ request.entitlements()
+ .stream()
+ .map(e -> e.roleBinding().role())
+ .collect(Collectors.toSet()),
+ request.startTime(),
+ request.duration());
+ }
+
+ @Override
+ public JsonWebTokenConverter> createTokenConverter() {
+ return new JsonWebTokenConverter<>() {
+ @Override
+ public JsonWebToken.Payload convert(MpaActivationRequest request) {
+ var roleBindings = request.entitlements()
+ .stream()
+ .map(ent -> ent.roleBinding())
+ .toList();
+
+ if (roleBindings.size() != 1) {
+ throw new IllegalArgumentException("Request must have exactly one entitlement");
+ }
+
+ var roleBinding = roleBindings.get(0);
+
+ return new JsonWebToken.Payload()
+ .setJwtId(request.id().toString())
+ .set("beneficiary", request.requestingUser().email)
+ .set("reviewers", request.reviewers().stream().map(id -> id.email).collect(Collectors.toList()))
+ .set("resource", roleBinding.fullResourceName())
+ .set("role", roleBinding.role())
+ .set("justification", request.justification())
+ .set("start", request.startTime().getEpochSecond())
+ .set("end", request.endTime().getEpochSecond());
+ }
+
+ @Override
+ public MpaActivationRequest convert(JsonWebToken.Payload payload) {
+ var roleBinding = new RoleBinding(
+ payload.get("resource").toString(),
+ payload.get("role").toString());
+
+ var startTime = ((Number)payload.get("start")).longValue();
+ var endTime = ((Number)payload.get("end")).longValue();
+
+ return new MpaRequest<>(
+ new ActivationId(payload.getJwtId()),
+ new UserId(payload.get("beneficiary").toString()),
+ Set.of(new ProjectRoleBinding(roleBinding)),
+ ((List)payload.get("reviewers"))
+ .stream()
+ .map(email -> new UserId(email))
+ .collect(Collectors.toSet()),
+ payload.get("justification").toString(),
+ Instant.ofEpochSecond(startTime),
+ Duration.ofSeconds(endTime - startTime));
+ }
+ };
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectRoleBinding.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectRoleBinding.java
new file mode 100644
index 000000000..38e10a07a
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectRoleBinding.java
@@ -0,0 +1,59 @@
+//
+// 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.catalog.project;
+
+import com.google.common.base.Preconditions;
+import com.google.solutions.jitaccess.core.ProjectId;
+import com.google.solutions.jitaccess.core.catalog.EntitlementId;
+import com.google.solutions.jitaccess.core.RoleBinding;
+
+public class ProjectRoleBinding extends EntitlementId {
+ static final String CATALOG = "iam";
+
+ private final RoleBinding roleBinding;
+
+ public ProjectRoleBinding(RoleBinding roleBinding) {
+ Preconditions.checkNotNull(roleBinding, "roleBinding");
+
+ assert ProjectId.isProjectFullResourceName(roleBinding.fullResourceName());
+
+ this.roleBinding = roleBinding;
+ }
+
+ public RoleBinding roleBinding() {
+ return this.roleBinding;
+ }
+
+ @Override
+ public String catalog() {
+ return CATALOG;
+ }
+
+ @Override
+ public String id() {
+ return this.roleBinding.toString();
+ }
+
+ public ProjectId projectId() {
+ return ProjectId.fromFullResourceName(this.roleBinding.fullResourceName());
+ }
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectRoleCatalog.java b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectRoleCatalog.java
new file mode 100644
index 000000000..c91816989
--- /dev/null
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/catalog/project/ProjectRoleCatalog.java
@@ -0,0 +1,60 @@
+//
+// 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.catalog.project;
+
+import com.google.solutions.jitaccess.core.AccessException;
+import com.google.solutions.jitaccess.core.Annotated;
+import com.google.solutions.jitaccess.core.ProjectId;
+import com.google.solutions.jitaccess.core.UserId;
+import com.google.solutions.jitaccess.core.catalog.Entitlement;
+import com.google.solutions.jitaccess.core.catalog.EntitlementCatalog;
+
+import java.io.IOException;
+import java.util.SortedSet;
+
+/**
+ * Catalog for project-level role bindings.
+ */
+public abstract class ProjectRoleCatalog implements EntitlementCatalog {
+ /**
+ * List projects that the user has any entitlements for.
+ */
+ public abstract SortedSet listProjects(
+ UserId user
+ ) throws AccessException, IOException;
+
+ /**
+ * List available entitlements.
+ */
+ public abstract Annotated>> listEntitlements(
+ UserId user,
+ ProjectId projectId
+ ) throws AccessException, IOException;
+
+ /**
+ * List available reviewers for (MPA-) activating an entitlement.
+ */
+ public abstract SortedSet listReviewers(
+ UserId requestingUser,
+ ProjectRoleBinding entitlement
+ ) throws AccessException, IOException;
+}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/clients/ResourceManagerClient.java b/sources/src/main/java/com/google/solutions/jitaccess/core/clients/ResourceManagerClient.java
index 3e7b774b9..f31a3f7bf 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/clients/ResourceManagerClient.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/clients/ResourceManagerClient.java
@@ -151,7 +151,7 @@ public void addProjectIamBinding(
var nonObsoleteBindings =
policy.getBindings().stream()
.filter(isObsolete.negate())
- .collect(Collectors.toList());
+ .toList();
policy.getBindings().clear();
policy.getBindings().addAll(nonObsoleteBindings);
@@ -251,7 +251,7 @@ public List testIamPermissions(
}
}
- public Set searchProjectIds(String query) throws NotAuthenticatedException, IOException {
+ public SortedSet searchProjectIds(String query) throws NotAuthenticatedException, IOException {
try {
var client = createClient();
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/clients/SmtpClient.java b/sources/src/main/java/com/google/solutions/jitaccess/core/clients/SmtpClient.java
index 2cdf08cca..3f7ceaa43 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/clients/SmtpClient.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/clients/SmtpClient.java
@@ -130,7 +130,7 @@ public void sendMail(
String subject,
String htmlContent,
EnumSet flags
- ) throws MailException, AccessException, IOException {
+ ) throws MailException {
Preconditions.checkNotNull(toRecipients, "toRecipients");
Preconditions.checkNotNull(ccRecipients, "ccRecipients");
Preconditions.checkNotNull(subject, "subject");
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/ProjectRole.java b/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/ProjectRole.java
deleted file mode 100644
index 0b6242ec1..000000000
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/ProjectRole.java
+++ /dev/null
@@ -1,101 +0,0 @@
-//
-// 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.entitlements;
-
-import com.google.common.base.Preconditions;
-import com.google.solutions.jitaccess.core.ProjectId;
-
-/**
- * Represents an eligible role on a project.
- */
-public record ProjectRole(
- RoleBinding roleBinding,
- ProjectRole.Status status
-) implements Comparable {
-
- public ProjectRole {
- Preconditions.checkNotNull(roleBinding);
- Preconditions.checkNotNull(status);
- Preconditions.checkArgument(ProjectId.isProjectFullResourceName(roleBinding.fullResourceName()));
- }
-
- @Override
- public String toString() {
- return String.format("%s (%s)", this.roleBinding, this.status);
- }
-
- /**
- * Return the unqualified project ID.
- */
- public ProjectId getProjectId() {
- return ProjectId.fromFullResourceName(this.roleBinding.fullResourceName());
- }
-
- // -------------------------------------------------------------------------
- // Equality.
- // -------------------------------------------------------------------------
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
- }
-
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
-
- var that = (ProjectRole) o;
- return this.roleBinding.equals(that.roleBinding) && this.status.equals(that.status);
- }
-
- @Override
- public int compareTo(ProjectRole o) {
- return this.roleBinding.compareTo(o.roleBinding);
- }
-
- // -------------------------------------------------------------------------
- // Inner classes.
- // -------------------------------------------------------------------------
-
- public enum Status {
- /**
- * Role binding can be activated using self-approval ("JIT approval")
- */
- ELIGIBLE_FOR_JIT,
-
- /**
- * Role binding can be activated using multi party-approval ("MPA approval")
- */
- ELIGIBLE_FOR_MPA,
-
- /**
- * Eligible role binding has been activated
- */
- ACTIVATED,
-
- /**
- * Approval pending
- */
- ACTIVATION_PENDING
- }
-}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/RoleActivationService.java b/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/RoleActivationService.java
deleted file mode 100644
index ee5953c87..000000000
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/RoleActivationService.java
+++ /dev/null
@@ -1,516 +0,0 @@
-//
-// 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.entitlements;
-
-import com.google.api.client.json.webtoken.JsonWebToken;
-import com.google.api.services.cloudresourcemanager.v3.model.Binding;
-import com.google.common.base.Preconditions;
-import com.google.solutions.jitaccess.core.*;
-import com.google.solutions.jitaccess.core.clients.IamTemporaryAccessConditions;
-import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
-import jakarta.enterprise.context.ApplicationScoped;
-
-import java.io.IOException;
-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;
-import java.util.Set;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-
-/**
- * Service for creating, verifying, and approving activation requests.
- *
- * An activation request is a request from a user to "activate" an eligible role
- * on a project.
- *
- * NB. Activations always occur on the level of a project, even if the IAM binding
- * that made the user eligible has been inherited from a folder.
- */
-@ApplicationScoped
-public class RoleActivationService {
- private final RoleDiscoveryService roleDiscoveryService;
- private final ResourceManagerClient resourceManagerClient;
- private final Options options;
-
- private void checkJustification(String justification) throws AccessDeniedException{
- if (!this.options.justificationPattern.matcher(justification).matches()) {
- throw new AccessDeniedException(
- String.format("Justification does not meet criteria: %s", this.options.justificationHint));
- }
- }
-
- private static boolean canActivateProjectRole(
- ProjectRole projectRole,
- ActivationType activationType
- ) {
- switch (activationType) {
- case JIT: return projectRole.status() == ProjectRole.Status.ELIGIBLE_FOR_JIT;
- case MPA: return projectRole.status() == ProjectRole.Status.ELIGIBLE_FOR_MPA;
- default: return false;
- }
- }
-
- private void checkUserCanActivateProjectRole(
- UserId user,
- RoleBinding roleBinding,
- ActivationType activationType
- ) throws AccessException, IOException {
- //
- // Check if the given role is among the roles that the
- // user is eligible to JIT-/MPA-activate.
- //
- // NB. It doesn't matter whether the user has already
- // activated the role.
- //
- if (this.roleDiscoveryService.listEligibleProjectRoles(
- user,
- ProjectId.fromFullResourceName(roleBinding.fullResourceName()),
- EnumSet.of(
- ProjectRole.Status.ELIGIBLE_FOR_JIT,
- ProjectRole.Status.ELIGIBLE_FOR_MPA))
- .getItems()
- .stream()
- .filter(pr -> pr.roleBinding().equals(roleBinding))
- .filter(pr -> canActivateProjectRole(pr, activationType))
- .findAny()
- .isEmpty()) {
- throw new AccessDeniedException(
- String.format(
- "The user %s is not allowed to activate the role %s",
- user,
- roleBinding.role()));
- }
- }
-
- public RoleActivationService(
- RoleDiscoveryService roleDiscoveryService,
- ResourceManagerClient resourceManagerClient,
- Options configuration
- ) {
- Preconditions.checkNotNull(roleDiscoveryService, "roleDiscoveryService");
- Preconditions.checkNotNull(resourceManagerClient, "resourceManagerAdapter");
- Preconditions.checkNotNull(configuration, "configuration");
-
- this.roleDiscoveryService = roleDiscoveryService;
- this.resourceManagerClient = resourceManagerClient;
- this.options = configuration;
- }
-
- /**
- * Activate a role binding on behalf of the calling user. This is only
- * allowed for bindings with a JIT-constraint.
- */
- public Activation activateProjectRoleForSelf(
- UserId caller,
- RoleBinding roleBinding,
- String justification,
- Duration activationTimeout
- ) throws AccessException, AlreadyExistsException, IOException {
- Preconditions.checkNotNull(caller, "caller");
- Preconditions.checkNotNull(roleBinding, "roleBinding");
- Preconditions.checkNotNull(justification, "justification");
- Preconditions.checkArgument(ProjectId.isProjectFullResourceName(roleBinding.fullResourceName()));
- Preconditions.checkArgument(activationTimeout.toMinutes() >= Options.MIN_ACTIVATION_TIMEOUT_MINUTES,
- "The activation timeout is too short");
- Preconditions.checkArgument(activationTimeout.toMinutes() <= this.options.maxActivationTimeout.toMinutes(),
- "The requested activation timeout exceeds the maximum permitted timeout");
-
- //
- // Check that the justification looks reasonable.
- //
- checkJustification(justification);
-
- //
- // Verify that the user is allowed to (JIT-) activate
- // this role.
- //
- // NB. This check might seem redundant to the checks done during role discovery but is
- // necessary to (a) prevent TOCTOU situations and (b) a situation where a user requests
- // activation without performing discovery first.
- //
- checkUserCanActivateProjectRole(caller, roleBinding, ActivationType.JIT);
-
- //
- // The caller is eligible.
- //
-
- //
- // Add time-bound IAM binding.
- //
- // Replace existing bindings for same user and role to avoid
- // accumulating junk, and to prevent hitting the binding limit.
- //
-
- var activationTime = Instant.now().truncatedTo(ChronoUnit.SECONDS);;
- var expiryTime = activationTime.plus(activationTimeout);
- var bindingDescription = String.format(
- "Self-approved, justification: %s",
- justification);
-
- var binding = new Binding()
- .setMembers(List.of("user:" + caller))
- .setRole(roleBinding.role())
- .setCondition(new com.google.api.services.cloudresourcemanager.v3.model.Expr()
- .setTitle(JitConstraints.ACTIVATION_CONDITION_TITLE)
- .setDescription(bindingDescription)
- .setExpression(IamTemporaryAccessConditions.createExpression(activationTime, expiryTime)));
-
- this.resourceManagerClient.addProjectIamBinding(
- ProjectId.fromFullResourceName(roleBinding.fullResourceName()),
- binding,
- EnumSet.of(ResourceManagerClient.IamBindingOptions.PURGE_EXISTING_TEMPORARY_BINDINGS),
- justification);
-
- return new Activation(
- ActivationId.newId(ActivationType.JIT),
- new ProjectRole(roleBinding, ProjectRole.Status.ACTIVATED),
- activationTime,
- expiryTime);
- }
-
- /**
- * Activate a role binding for a different user (beneficiary). This is only allowed
- * for bindings with an MPA-constraint.
- */
- public Activation activateProjectRoleForPeer(
- UserId caller,
- ActivationRequest request
- ) throws AccessException, AlreadyExistsException, IOException {
- Preconditions.checkNotNull(caller, "caller");
- Preconditions.checkNotNull(request, "request");
-
- if (request.beneficiary.equals(caller)) {
- throw new IllegalArgumentException(
- "MPA activation requires the caller and beneficiary to be the different");
- }
-
- if (!request.reviewers.contains(caller)) {
- throw new AccessDeniedException(
- String.format("The token does not permit approval by %s", caller));
- }
-
- //
- // Verify that both, the calling user and beneficiary are allowed to MPA-activate
- // this role. If they are, then that makes them "peers", and the calling user is
- // qualified to act as a reviewer.
- //
-
- checkUserCanActivateProjectRole(
- caller,
- request.roleBinding,
- ActivationType.MPA);
-
- checkUserCanActivateProjectRole(
- request.beneficiary,
- request.roleBinding,
- ActivationType.MPA);
-
- //
- // Add time-bound IAM binding for the beneficiary.
- //
- // NB. The start/end time for the binding is derived from the approval token. If multiple
- // reviewers try to approve the same token, the resulting condition (and binding) will
- // be the same. This is important so that we can use the FAIL_IF_BINDING_EXISTS flag.
- //
- // Replace existing bindings for same user and role to avoid
- // accumulating junk, and to prevent hitting the binding limit.
- //
-
- var bindingDescription = String.format(
- "Approved by %s, justification: %s",
- caller.email,
- request.justification);
-
- var binding = new Binding()
- .setMembers(List.of("user:" + request.beneficiary.email))
- .setRole(request.roleBinding.role())
- .setCondition(new com.google.api.services.cloudresourcemanager.v3.model.Expr()
- .setTitle(JitConstraints.ACTIVATION_CONDITION_TITLE)
- .setDescription(bindingDescription)
- .setExpression(IamTemporaryAccessConditions.createExpression(
- request.startTime,
- request.endTime)));
-
- this.resourceManagerClient.addProjectIamBinding(
- ProjectId.fromFullResourceName(request.roleBinding.fullResourceName()),
- binding,
- EnumSet.of(
- ResourceManagerClient.IamBindingOptions.PURGE_EXISTING_TEMPORARY_BINDINGS,
- ResourceManagerClient.IamBindingOptions.FAIL_IF_BINDING_EXISTS),
- request.justification);
-
- return new Activation(
- request.id,
- new ProjectRole(request.roleBinding, ProjectRole.Status.ACTIVATED),
- request.startTime,
- request.endTime);
- }
-
- /**
- * Create an activation request that can be passed to reviewers.
- */
- public ActivationRequest createActivationRequestForPeer(
- UserId callerAndBeneficiary,
- Set reviewers,
- RoleBinding roleBinding,
- String justification,
- Duration activationTimeout
- ) throws AccessException, IOException {
- Preconditions.checkNotNull(callerAndBeneficiary, "callerAndBeneficiary");
- Preconditions.checkNotNull(reviewers, "reviewers");
- Preconditions.checkNotNull(roleBinding, "roleBinding");
- Preconditions.checkNotNull(justification, "justification");
-
- Preconditions.checkArgument(ProjectId.isProjectFullResourceName(roleBinding.fullResourceName()));
- Preconditions.checkArgument(reviewers != null && reviewers.size() >= this.options.minNumberOfReviewersPerActivationRequest,
- "At least " + this.options.minNumberOfReviewersPerActivationRequest + " reviewers must be specified");
- Preconditions.checkArgument(reviewers.size() <= this.options.maxNumberOfReviewersPerActivationRequest,
- "The number of reviewers must not exceed " + this.options.maxNumberOfReviewersPerActivationRequest);
- Preconditions.checkArgument(!reviewers.contains(callerAndBeneficiary), "The beneficiary cannot be a reviewer");
- Preconditions.checkArgument(activationTimeout.toMinutes() >= Options.MIN_ACTIVATION_TIMEOUT_MINUTES,
- "The activation timeout is too short");
- Preconditions.checkArgument(activationTimeout.toMinutes() <= this.options.maxActivationTimeout.toMinutes(),
- "The requested activation timeout exceeds the maximum permitted timeout");
-
- //
- // Check that the justification looks reasonable.
- //
- checkJustification(justification);
-
- //
- // Check that the calling user (who is the beneficiary) is allowed to MPA-activate
- // this role.
- //
- // NB. We're not checking if the reviewers have the necessary permissions. It's sufficient
- // to do that on activation.
- //
- checkUserCanActivateProjectRole(callerAndBeneficiary, roleBinding, ActivationType.MPA);
-
- //
- // Issue an activation request.
- //
- var startTime = Instant.now().truncatedTo(ChronoUnit.SECONDS);
- var endTime = startTime.plus(activationTimeout);
-
- return new ActivationRequest(
- ActivationId.newId(ActivationType.MPA),
- callerAndBeneficiary,
- reviewers,
- roleBinding,
- justification,
- startTime,
- endTime);
- }
-
- public Options getOptions() {
- return options;
- }
-
- // -------------------------------------------------------------------------
- // Inner classes.
- // -------------------------------------------------------------------------
-
- public enum ActivationType {
- /** Just-in-time self-approval */
- JIT,
-
- /** Multi-party approval involving a qualified peer */
- MPA
- }
-
- /** Unique ID for an activation */
- public static class ActivationId {
- private static final SecureRandom random = new SecureRandom();
-
- private final String id;
-
- protected ActivationId(String id) {
- Preconditions.checkNotNull(id);
- this.id = id;
- }
-
- public static ActivationId newId(ActivationType type) {
- var id = new byte[12];
- random.nextBytes(id);
-
- return new ActivationId(type.name().toLowerCase() + "-" + Base64.getEncoder().encodeToString(id));
- }
-
- @Override
- public String toString() {
- return this.id;
- }
- }
-
- /** Represents a successful activation of a project role */
- public static class Activation {
- public final ActivationId id;
- public final ProjectRole projectRole;
- public final Instant startTime;
- public final Instant endTime;
-
- private Activation(
- ActivationId id,
- ProjectRole projectRole,
- Instant startTime,
- Instant endTime
- ) {
- Preconditions.checkNotNull(startTime);
- Preconditions.checkNotNull(endTime);
-
- assert startTime.isBefore(endTime);
-
- this.id = id;
- this.projectRole = projectRole;
- this.startTime = startTime;
- this.endTime = endTime;
- }
-
- public static Activation createForTestingOnly(
- ActivationId id,
- ProjectRole projectRole,
- Instant startTime,
- Instant endTime
- ) {
- return new Activation(id, projectRole, startTime, endTime);
- }
- }
-
- /** Represents a pre-validated activation request */
- public static class ActivationRequest {
- public final ActivationId id;
- public final UserId beneficiary;
- public final Set reviewers;
- public final RoleBinding roleBinding;
- public final String justification;
- public final Instant startTime;
- public final Instant endTime;
-
- private ActivationRequest(
- ActivationId id,
- UserId beneficiary,
- Set reviewers,
- RoleBinding roleBinding,
- String justification,
- Instant startTime,
- Instant endTime
- ) {
- Preconditions.checkNotNull(id);
- Preconditions.checkNotNull(beneficiary);
- Preconditions.checkNotNull(reviewers);
- Preconditions.checkNotNull(roleBinding);
- Preconditions.checkNotNull(justification);
- Preconditions.checkNotNull(startTime);
- Preconditions.checkNotNull(endTime);
-
- assert startTime.isBefore(endTime);
-
- this.id = id;
- this.beneficiary = beneficiary;
- this.reviewers = reviewers;
- this.roleBinding = roleBinding;
- this.justification = justification;
- this.startTime = startTime;
- this.endTime = endTime;
- }
-
- public static ActivationRequest createForTestingOnly(
- ActivationId id,
- UserId beneficiary,
- Set reviewers,
- RoleBinding roleBinding,
- String justification,
- Instant startTime,
- Instant endTime
- ) {
- return new ActivationRequest(id, beneficiary, reviewers, roleBinding, justification, startTime, endTime);
- }
-
- protected static ActivationRequest fromJsonWebTokenPayload(JsonWebToken.Payload payload) {
- //noinspection unchecked
- return new RoleActivationService.ActivationRequest(
- new RoleActivationService.ActivationId(payload.getJwtId()),
- new UserId(payload.get("beneficiary").toString()),
- ((List)payload.get("reviewers"))
- .stream()
- .map(email -> new UserId(email))
- .collect(Collectors.toSet()),
- new RoleBinding(
- payload.get("resource").toString(),
- payload.get("role").toString()),
- payload.get("justification").toString(),
- Instant.ofEpochSecond(((Number)payload.get("start")).longValue()),
- Instant.ofEpochSecond(((Number)payload.get("end")).longValue()));
- }
-
- protected JsonWebToken.Payload toJsonWebTokenPayload() {
- return new JsonWebToken.Payload()
- .setJwtId(this.id.toString())
- .set("beneficiary", this.beneficiary.email)
- .set("reviewers", this.reviewers.stream().map(id -> id.email).collect(Collectors.toList()))
- .set("resource", this.roleBinding.fullResourceName())
- .set("role", this.roleBinding.role())
- .set("justification", this.justification)
- .set("start", this.startTime.getEpochSecond())
- .set("end", this.endTime.getEpochSecond());
- }
- }
-
- public static class Options {
- public static final int MIN_ACTIVATION_TIMEOUT_MINUTES = 5;
-
- public final Duration maxActivationTimeout;
- public final String justificationHint;
- public final Pattern justificationPattern;
- public final int minNumberOfReviewersPerActivationRequest;
- public final int maxNumberOfReviewersPerActivationRequest;
-
- public Options(
- String justificationHint,
- Pattern justificationPattern,
- Duration maxActivationTimeout,
- int minNumberOfReviewersPerActivationRequest,
- int maxNumberOfReviewersPerActivationRequest)
- {
- Preconditions.checkArgument(
- maxActivationTimeout.toMinutes() >= MIN_ACTIVATION_TIMEOUT_MINUTES,
- "Activation timeout must be at least 5 minutes");
- Preconditions.checkArgument(
- minNumberOfReviewersPerActivationRequest > 0,
- "The minimum number of reviewers cannot be 0");
- Preconditions.checkArgument(
- minNumberOfReviewersPerActivationRequest <= maxNumberOfReviewersPerActivationRequest,
- "The minimum number of reviewers must not exceed the maximum");
-
- this.maxActivationTimeout = maxActivationTimeout;
- this.justificationHint = justificationHint;
- this.justificationPattern = justificationPattern;
- this.minNumberOfReviewersPerActivationRequest = minNumberOfReviewersPerActivationRequest;
- this.maxNumberOfReviewersPerActivationRequest = maxNumberOfReviewersPerActivationRequest;
- }
- }
-}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/RoleDiscoveryService.java b/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/RoleDiscoveryService.java
deleted file mode 100644
index 4c4727cda..000000000
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/entitlements/RoleDiscoveryService.java
+++ /dev/null
@@ -1,382 +0,0 @@
-//
-// Copyright 2021 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.entitlements;
-
-import com.google.api.services.cloudasset.v1.model.Expr;
-import com.google.api.services.cloudasset.v1.model.IamPolicyAnalysis;
-import com.google.common.base.Preconditions;
-import com.google.solutions.jitaccess.core.*;
-import com.google.solutions.jitaccess.core.clients.AssetInventoryClient;
-import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
-import jakarta.enterprise.context.ApplicationScoped;
-
-import java.io.IOException;
-import java.util.*;
-import java.util.function.Predicate;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-/**
- * Service for discovering eligible roles.
- */
-@ApplicationScoped
-public class RoleDiscoveryService {
- private final AssetInventoryClient assetInventoryClient;
-
- private final ResourceManagerClient resourceManagerClient;
-
- private final Options options;
-
- public RoleDiscoveryService(
- AssetInventoryClient assetInventoryClient,
- ResourceManagerClient resourceManagerClient,
- Options configuration) {
- Preconditions.checkNotNull(assetInventoryClient, "assetInventoryAdapter");
- Preconditions.checkNotNull(resourceManagerClient, "resourceManagerAdapter");
- Preconditions.checkNotNull(configuration, "configuration");
-
- this.assetInventoryClient = assetInventoryClient;
- this.resourceManagerClient = resourceManagerClient;
- this.options = configuration;
- }
-
- private static List findRoleBindings(
- IamPolicyAnalysis analysisResult,
- Predicate conditionPredicate,
- Predicate conditionEvaluationPredicate
- ) {
- //
- // NB. We don't really care which resource a policy is attached to
- // (indicated by AttachedResourceFullName). Instead, we care about
- // which resources it applies to.
- //
- return Stream.ofNullable(analysisResult.getAnalysisResults())
- .flatMap(Collection::stream)
-
- // Narrow down to IAM bindings with a specific IAM condition.
- .filter(result -> conditionPredicate.test(result.getIamBinding() != null
- ? result.getIamBinding().getCondition()
- : null))
- .flatMap(result -> result
- .getAccessControlLists()
- .stream()
-
- // Narrow down to ACLs with a specific IAM condition evaluation result.
- .filter(acl -> conditionEvaluationPredicate.test(acl.getConditionEvaluation() != null
- ? acl.getConditionEvaluation().getEvaluationValue()
- : null))
-
- // Collect all (supported) resources covered by these bindings/ACLs.
- .flatMap(acl -> acl.getResources()
- .stream()
- .filter(res -> ProjectId.isProjectFullResourceName(res.getFullResourceName()))
- .map(res -> new RoleBinding(
- res.getFullResourceName(),
- result.getIamBinding().getRole()))))
- .collect(Collectors.toList());
- }
-
- // ---------------------------------------------------------------------
- // Public methods.
- // ---------------------------------------------------------------------
-
- public Options getOptions() {
- return options;
- }
-
- /**
- * Find projects that a user has standing, JIT-, or MPA-eligible access to.
- */
- public Set listAvailableProjects(
- UserId user
- ) throws AccessException, IOException {
- if(this.options.availableProjectsQuery == null) {
- //
- // NB. To reliably find projects, we have to let the Asset API consider
- // inherited role bindings by using the "expand resources" flag. This
- // flag causes the API to return *all* resources for which an IAM binding
- // applies.
- //
- // The risk here is that the list of resources grows so large that we're hitting
- // the limits of the API, in which case it starts truncating results. To
- // mitigate this risk, filter on a permission that:
- //
- // - only applies to projects, and has no meaning on descendent resources
- // - represents the lowest level of access to a project.
- //
- var analysisResult = this.assetInventoryClient.findAccessibleResourcesByUser(
- this.options.scope,
- user,
- Optional.of("resourcemanager.projects.get"),
- Optional.empty(),
- true);
-
- //
- // Consider permanent and eligible bindings.
- //
- var roleBindings = findRoleBindings(
- analysisResult,
- condition -> condition == null ||
- JitConstraints.isJitAccessConstraint(condition) ||
- JitConstraints.isMultiPartyApprovalConstraint(condition),
- evalResult -> evalResult == null ||
- "TRUE".equalsIgnoreCase(evalResult) ||
- "CONDITIONAL".equalsIgnoreCase(evalResult));
-
- return roleBindings
- .stream()
- .map(b -> ProjectId.fromFullResourceName(b.fullResourceName()))
- .collect(Collectors.toCollection(TreeSet::new));
- }
- else {
- // Used as alternative option if availableProjectsQuery is set and the main approach with Asset API is not working fast enough.
- return resourceManagerClient.searchProjectIds(this.options.availableProjectsQuery);
- }
- }
-
-
-
- /**
- * List eligible role bindings for the given user.
- */
- public AnnotatedResult listEligibleProjectRoles(
- UserId user,
- ProjectId projectId
- ) throws AccessException, IOException {
- return listEligibleProjectRoles(
- user,
- projectId,
- EnumSet.of(
- ProjectRole.Status.ACTIVATED,
- ProjectRole.Status.ELIGIBLE_FOR_JIT,
- ProjectRole.Status.ELIGIBLE_FOR_MPA));
- }
-
- /**
- * List eligible role bindings for the given user.
- */
- public AnnotatedResult listEligibleProjectRoles(
- UserId user,
- ProjectId projectId,
- EnumSet statusesToInclude
- ) throws AccessException, IOException {
- Preconditions.checkNotNull(user, "user");
- Preconditions.checkNotNull(projectId, "projectId");
-
- //
- // Use Asset API to search for resources that the user could
- // access if they satisfied the eligibility condition.
- //
- // NB. The existence of an eligibility condition alone isn't
- // sufficient - it needs to be on a binding that applies to the
- // user.
- //
- // NB. The Asset API considers group membership if the caller
- // (i.e., the App Engine service account) has the 'Groups Reader'
- // admin role.
- //
-
- var analysisResult = this.assetInventoryClient.findAccessibleResourcesByUser(
- this.options.scope,
- user,
- Optional.empty(),
- Optional.of(projectId.getFullResourceName()),
- false);
-
- //
- // Find role bindings which have already been activated.
- // These bindings have a time condition that we created, and
- // the condition evaluates to true (indicating it's still
- // valid).
- //
- Set activatedRoles;
- if (statusesToInclude.contains(ProjectRole.Status.ACTIVATED)) {
- activatedRoles = findRoleBindings(
- analysisResult,
- condition -> JitConstraints.isActivated(condition),
- evalResult -> "TRUE".equalsIgnoreCase(evalResult))
- .stream()
- .map(binding -> new ProjectRole(binding, ProjectRole.Status.ACTIVATED))
- .collect(Collectors.toCollection(TreeSet::new));
- }
- else {
- activatedRoles = Set.of();
- }
-
- //
- // Find all JIT-eligible role bindings. The bindings are
- // conditional and have a special condition that serves
- // as marker.
- //
- Set jitEligibleRoles;
- if (statusesToInclude.contains(ProjectRole.Status.ELIGIBLE_FOR_JIT)) {
- jitEligibleRoles = findRoleBindings(
- analysisResult,
- condition -> JitConstraints.isJitAccessConstraint(condition),
- evalResult -> "CONDITIONAL".equalsIgnoreCase(evalResult))
- .stream()
- .map(binding -> new ProjectRole(binding, ProjectRole.Status.ELIGIBLE_FOR_JIT))
- .collect(Collectors.toCollection(TreeSet::new));
- }
- else {
- jitEligibleRoles = Set.of();
- }
-
- //
- // Find all MPA-eligible role bindings. The bindings are
- // conditional and have a special condition that serves
- // as marker.
- //
- Set mpaEligibleRoles;
- if (statusesToInclude.contains(ProjectRole.Status.ELIGIBLE_FOR_MPA)) {
- mpaEligibleRoles = findRoleBindings(
- analysisResult,
- condition -> JitConstraints.isMultiPartyApprovalConstraint(condition),
- evalResult -> "CONDITIONAL".equalsIgnoreCase(evalResult))
- .stream()
- .map(binding -> new ProjectRole(binding, ProjectRole.Status.ELIGIBLE_FOR_MPA))
- .collect(Collectors.toCollection(TreeSet::new));
- }
- else {
- mpaEligibleRoles = Set.of();
- }
-
- //
- // Determine effective set of eligible roles. If a role is both JIT- and
- // MPA-eligible, only retain the JIT-eligible one.
- //
- // Use a list so that JIT-eligible roles go first, followed by MPA-eligible ones.
- //
- var allEligibleRoles = new ArrayList();
- allEligibleRoles.addAll(jitEligibleRoles);
- allEligibleRoles.addAll(mpaEligibleRoles
- .stream()
- .filter(r -> !jitEligibleRoles.stream().anyMatch(a -> a.roleBinding().equals(r.roleBinding())))
- .collect(Collectors.toList()));
-
- //
- // Replace roles that have been activated already.
- //
- // NB. We can't use !activatedRoles.contains(...)
- // because of the different binding statuses.
- //
- var consolidatedRoles = allEligibleRoles
- .stream()
- .filter(r -> !activatedRoles.stream().anyMatch(a -> a.roleBinding().equals(r.roleBinding())))
- .collect(Collectors.toList());
- consolidatedRoles.addAll(activatedRoles);
-
- return new AnnotatedResult<>(
- consolidatedRoles,
- Stream.ofNullable(analysisResult.getNonCriticalErrors())
- .flatMap(Collection::stream)
- .map(e -> e.getCause())
- .collect(Collectors.toSet()));
- }
-
- /**
- * List users that can approve the activation of an eligible role binding.
- */
- public Set listEligibleUsersForProjectRole(
- UserId callerUserId,
- RoleBinding roleBinding
- ) throws AccessException, IOException {
- Preconditions.checkNotNull(callerUserId, "callerUserId");
- Preconditions.checkNotNull(roleBinding, "roleBinding");
-
- assert ProjectId.isProjectFullResourceName(roleBinding.fullResourceName());
-
- //
- // Check that the (calling) user is really allowed to request approval
- // this role.
- //
- var projectId = ProjectId.fromFullResourceName(roleBinding.fullResourceName());
-
- var eligibleRoles = listEligibleProjectRoles(callerUserId, projectId);
- if (eligibleRoles
- .getItems()
- .stream()
- .filter(pr -> pr.roleBinding().equals(roleBinding))
- .filter(pr -> pr.status() == ProjectRole.Status.ELIGIBLE_FOR_MPA)
- .findAny()
- .isEmpty()) {
- throw new AccessDeniedException(
- String.format("The user %s is not eligible to request approval for this role", callerUserId));
- }
-
- //
- // Find other eligible users.
- //
- var analysisResult = this.assetInventoryClient.findPermissionedPrincipalsByResource(
- this.options.scope,
- roleBinding.fullResourceName(),
- roleBinding.role());
-
- return Stream.ofNullable(analysisResult.getAnalysisResults())
- .flatMap(Collection::stream)
-
- // Narrow down to IAM bindings with an MPA constraint.
- .filter(result -> result.getIamBinding() != null &&
- JitConstraints.isMultiPartyApprovalConstraint(result.getIamBinding().getCondition()))
-
- // Collect identities (users and group members)
- .filter(result -> result.getIdentityList() != null)
- .flatMap(result -> result.getIdentityList().getIdentities().stream()
- .filter(id -> id.getName().startsWith("user:"))
- .map(id -> new UserId(id.getName().substring("user:".length()))))
-
- // Remove the caller.
- .filter(user -> !user.equals(callerUserId))
- .collect(Collectors.toCollection(TreeSet::new));
- }
-
- // -------------------------------------------------------------------------
- // Inner classes.
- // -------------------------------------------------------------------------
-
- public static class Options {
- /**
- * Scope, organization/ID, folder/ID, or project/ID
- */
- public final String scope;
-
- /**
- * In some cases listing all available projects is not working fast enough and times out,
- * so this method is available as alternative.
- * The format is the same as Google Resource Manager API requires for the query parameter, for example:
- * - parent:folders/{folder_id}
- * - parent:organizations/{organization_id}
- * (https://cloud.google.com/resource-manager/reference/rest/v3/projects/search#query-parameters)
- */
- public final String availableProjectsQuery;
-
- /**
- * Search inherited IAM policies
- */
- public Options(String scope, String availableProjectsQuery) {
- this.scope = scope;
- this.availableProjectsQuery = availableProjectsQuery;
- }
-
-
- }
-}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/notifications/MailNotificationService.java b/sources/src/main/java/com/google/solutions/jitaccess/core/notifications/MailNotificationService.java
index 90fcfffcc..c2f7bd3a8 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/notifications/MailNotificationService.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/notifications/MailNotificationService.java
@@ -128,7 +128,7 @@ public void sendNotification(Notification notification) throws NotificationExcep
? EnumSet.of(SmtpClient.Flags.REPLY)
: EnumSet.of(SmtpClient.Flags.NONE));
}
- catch (SmtpClient.MailException | AccessException | IOException e) {
+ catch (SmtpClient.MailException e) {
throw new NotificationException("The notification could not be sent", e);
}
}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/core/notifications/NotificationService.java b/sources/src/main/java/com/google/solutions/jitaccess/core/notifications/NotificationService.java
index 830eb696c..c15501f02 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/core/notifications/NotificationService.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/core/notifications/NotificationService.java
@@ -60,7 +60,7 @@ public boolean canSendNotifications() {
}
@Override
- public void sendNotification(Notification notification) throws NotificationException {
+ public void sendNotification(Notification notification) {
if (this.printToConsole) {
//
// Print it so that we can see the message during development.
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 c76ba9807..f2cb6eac0 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
@@ -68,7 +68,7 @@ public RuntimeConfiguration(Function readSetting) {
this.maxNumberOfReviewersPerActivationRequest = new IntSetting(
List.of("ACTIVATION_REQUEST_MAX_REVIEWERS"),
10);
- this.maxNumberOfJitRolesPerSelfApproval = new IntSetting(
+ this.maxNumberOfEntitlementsPerSelfApproval = new IntSetting(
List.of("ACTIVATION_REQUEST_MAX_ROLES"),
10);
this.availableProjectsQuery = new StringSetting(
@@ -223,9 +223,9 @@ public RuntimeConfiguration(Function readSetting) {
public final IntSetting maxNumberOfReviewersPerActivationRequest;
/**
- * Maximum number of (JIT-) eligible roles that can be activated at once.
+ * Maximum number of (JIT-) entitlements that can be activated at once.
*/
- public final IntSetting maxNumberOfJitRolesPerSelfApproval;
+ public final IntSetting maxNumberOfEntitlementsPerSelfApproval;
/**
* In some cases listing all available projects is not working fast enough and times out,
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 557ce5e96..b5c643717 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
@@ -34,10 +34,11 @@
import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.solutions.jitaccess.core.ApplicationVersion;
import com.google.solutions.jitaccess.core.UserId;
+import com.google.solutions.jitaccess.core.catalog.RegexJustificationPolicy;
+import com.google.solutions.jitaccess.core.catalog.TokenSigner;
+import com.google.solutions.jitaccess.core.catalog.project.IamPolicyCatalog;
+import com.google.solutions.jitaccess.core.catalog.project.PolicyAnalyzer;
import com.google.solutions.jitaccess.core.clients.*;
-import com.google.solutions.jitaccess.core.entitlements.ActivationTokenService;
-import com.google.solutions.jitaccess.core.entitlements.RoleActivationService;
-import com.google.solutions.jitaccess.core.entitlements.RoleDiscoveryService;
import com.google.solutions.jitaccess.core.notifications.MailNotificationService;
import com.google.solutions.jitaccess.core.notifications.NotificationService;
import com.google.solutions.jitaccess.core.notifications.PubSubNotificationService;
@@ -263,26 +264,7 @@ public GoogleCredentials getApplicationCredentials() {
}
@Produces
- public RoleDiscoveryService.Options getRoleDiscoveryServiceOptions() {
- return new RoleDiscoveryService.Options(
- this.configuration.scope.getValue(),
- this.configuration.availableProjectsQuery.isValid() ?
- this.configuration.availableProjectsQuery.getValue() : null
- );
- }
-
- @Produces
- public RoleActivationService.Options getRoleActivationServiceOptions() {
- return new RoleActivationService.Options(
- this.configuration.justificationHint.getValue(),
- Pattern.compile(this.configuration.justificationPattern.getValue()),
- this.configuration.activationTimeout.getValue(),
- this.configuration.minNumberOfReviewersPerActivationRequest.getValue(),
- this.configuration.maxNumberOfReviewersPerActivationRequest.getValue());
- }
-
- @Produces
- public ActivationTokenService.Options getTokenServiceOptions() {
+ public TokenSigner.Options getTokenServiceOptions() {
//
// NB. The clock for activations "starts ticking" when the activation was
// requested. The time allotted for reviewers to approve the request
@@ -292,7 +274,7 @@ public ActivationTokenService.Options getTokenServiceOptions() {
this.configuration.activationRequestTimeout.getValue().getSeconds(),
this.configuration.activationTimeout.getValue().getSeconds()));
- return new ActivationTokenService.Options(
+ return new TokenSigner.Options(
applicationPrincipal,
effectiveRequestTimeout);
}
@@ -358,7 +340,7 @@ else if (this.configuration.isSmtpAuthenticationConfigured() && this.configurati
@Produces
public ApiResource.Options getApiOptions() {
return new ApiResource.Options(
- this.configuration.maxNumberOfJitRolesPerSelfApproval.getValue());
+ this.configuration.maxNumberOfEntitlementsPerSelfApproval.getValue());
}
@Produces
@@ -368,4 +350,39 @@ public HttpTransport.Options getHttpTransportOptions() {
this.configuration.backendReadTimeout.getValue(),
this.configuration.backendWriteTimeout.getValue());
}
+
+ @Produces
+ public RegexJustificationPolicy.Options getRegexJustificationPolicyOptions() {
+ return new RegexJustificationPolicy.Options(
+ this.configuration.justificationHint.getValue(),
+ Pattern.compile(this.configuration.justificationPattern.getValue()));
+ }
+
+ @Produces
+ public PolicyAnalyzer.Options getPolicyAnalyzerOptions() {
+ return new PolicyAnalyzer.Options(
+ this.configuration.scope.getValue());
+ }
+
+ @Produces
+ public IamPolicyCatalog.Options getIamPolicyCatalogOptions() {
+ return new IamPolicyCatalog.Options(
+ this.configuration.availableProjectsQuery.isValid()
+ ? this.configuration.availableProjectsQuery.getValue()
+ : null,
+ this.configuration.activationTimeout.getValue(),
+ this.configuration.minNumberOfReviewersPerActivationRequest.getValue(),
+ this.configuration.maxNumberOfReviewersPerActivationRequest.getValue());
+ }
+//
+// @Produces
+// public EntitlementCatalog getCatalog(
+// PolicyAnalyzer policyAnalyzer,
+// ResourceManagerClient resourceManagerClient
+// ) {
+// return new IamPolicyCatalog(
+// policyAnalyzer,
+// resourceManagerClient,
+// getIamPolicyCatalogOptions());
+// }
}
diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/rest/ApiResource.java b/sources/src/main/java/com/google/solutions/jitaccess/web/rest/ApiResource.java
index aa86c19a9..6f87c73f6 100644
--- a/sources/src/main/java/com/google/solutions/jitaccess/web/rest/ApiResource.java
+++ b/sources/src/main/java/com/google/solutions/jitaccess/web/rest/ApiResource.java
@@ -23,7 +23,10 @@
import com.google.common.base.Preconditions;
import com.google.solutions.jitaccess.core.*;
-import com.google.solutions.jitaccess.core.entitlements.*;
+import com.google.solutions.jitaccess.core.catalog.*;
+import com.google.solutions.jitaccess.core.catalog.project.IamPolicyCatalog;
+import com.google.solutions.jitaccess.core.catalog.project.ProjectRoleActivator;
+import com.google.solutions.jitaccess.core.catalog.project.ProjectRoleBinding;
import com.google.solutions.jitaccess.core.notifications.NotificationService;
import com.google.solutions.jitaccess.web.LogAdapter;
import com.google.solutions.jitaccess.web.LogEvents;
@@ -39,12 +42,14 @@
import jakarta.ws.rs.core.SecurityContext;
import jakarta.ws.rs.core.UriInfo;
+import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
-import java.util.ArrayList;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@@ -58,13 +63,10 @@
public class ApiResource {
@Inject
- RoleDiscoveryService roleDiscoveryService;
+ IamPolicyCatalog iamPolicyCatalog;
@Inject
- RoleActivationService roleActivationService;
-
- @Inject
- ActivationTokenService activationTokenService;
+ ProjectRoleActivator projectRoleActivator;
@Inject
Instance notificationServices;
@@ -72,6 +74,12 @@ public class ApiResource {
@Inject
RuntimeEnvironment runtimeEnvironment;
+ @Inject
+ JustificationPolicy justificationPolicy;
+
+ @Inject
+ TokenSigner tokenSigner;
+
@Inject
LogAdapter logAdapter;
@@ -134,13 +142,13 @@ public PolicyResponse getPolicy(
) {
var iapPrincipal = (UserPrincipal) securityContext.getUserPrincipal();
- var options = this.roleActivationService.getOptions();
+ var options = this.iamPolicyCatalog.options();
return new PolicyResponse(
- options.justificationHint,
+ justificationPolicy.hint(),
iapPrincipal.getId(),
ApplicationVersion.VERSION_STRING,
- (int)options.maxActivationTimeout.toMinutes(),
- Math.min(60, (int)options.maxActivationTimeout.toMinutes()));
+ (int)options.maxActivationDuration().toMinutes(),
+ Math.min(60, (int)options.maxActivationDuration().toMinutes()));
}
/**
@@ -152,15 +160,15 @@ public PolicyResponse getPolicy(
public ProjectsResponse listProjects(
@Context SecurityContext securityContext
) throws AccessException {
- Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService");
+ Preconditions.checkNotNull(this.iamPolicyCatalog, "iamPolicyCatalog");
var iapPrincipal = (UserPrincipal) securityContext.getUserPrincipal();
try {
- var projects = this.roleDiscoveryService.listAvailableProjects(iapPrincipal.getId());
+ var projects = this.iamPolicyCatalog.listProjects(iapPrincipal.getId());
return new ProjectsResponse(projects
- .stream().map(p -> p.id())
+ .stream().map(ProjectId::id)
.collect(Collectors.toSet()));
}
catch (Exception e) {
@@ -184,7 +192,7 @@ public ProjectRolesResponse listRoles(
@PathParam("projectId") String projectIdString,
@Context SecurityContext securityContext
) throws AccessException {
- Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService");
+ Preconditions.checkNotNull(this.iamPolicyCatalog, "iamPolicyCatalog");
Preconditions.checkArgument(
projectIdString != null && !projectIdString.trim().isEmpty(),
@@ -194,13 +202,16 @@ public ProjectRolesResponse listRoles(
var projectId = new ProjectId(projectIdString);
try {
- var bindings = this.roleDiscoveryService.listEligibleProjectRoles(
+ var entitlements = this.iamPolicyCatalog.listEntitlements(
iapPrincipal.getId(),
projectId);
return new ProjectRolesResponse(
- bindings.getItems(),
- bindings.getWarnings());
+ entitlements.items()
+ .stream()
+ .map(ent -> new ProjectRole(ent.id().roleBinding(), ent.activationType(), ent.status()))
+ .collect(Collectors.toList()),
+ entitlements.warnings());
}
catch (Exception e) {
this.logAdapter
@@ -226,7 +237,7 @@ public ProjectRolePeersResponse listPeers(
@QueryParam("role") String role,
@Context SecurityContext securityContext
) throws AccessException {
- Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService");
+ Preconditions.checkNotNull(this.iamPolicyCatalog, "iamPolicyCatalog");
Preconditions.checkArgument(
projectIdString != null && !projectIdString.trim().isEmpty(),
@@ -240,9 +251,9 @@ public ProjectRolePeersResponse listPeers(
var roleBinding = new RoleBinding(projectId, role);
try {
- var peers = this.roleDiscoveryService.listEligibleUsersForProjectRole(
+ var peers = this.iamPolicyCatalog.listReviewers(
iapPrincipal.getId(),
- roleBinding);
+ new ProjectRoleBinding(roleBinding));
assert !peers.contains(iapPrincipal.getId());
@@ -274,7 +285,7 @@ public ActivationStatusResponse selfApproveActivation(
SelfActivationRequest request,
@Context SecurityContext securityContext
) throws AccessDeniedException {
- Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService");
+ Preconditions.checkNotNull(this.iamPolicyCatalog, "iamPolicyCatalog");
Preconditions.checkArgument(
projectIdString != null && !projectIdString.trim().isEmpty(),
@@ -298,86 +309,81 @@ public ActivationStatusResponse selfApproveActivation(
var projectId = new ProjectId(projectIdString);
//
- // NB. The input list of roles might contain duplicates, therefore reduce to a set.
+ // Create a JIT activation request.
//
- var roleBindings = request.roles
- .stream()
- .map(r -> new RoleBinding(projectId.getFullResourceName(), r))
- .collect(Collectors.toSet());
+ var requestedRoleBindingDuration = Duration.ofMinutes(request.activationTimeout);
+ var activationRequest = this.projectRoleActivator.createJitRequest(
+ iapPrincipal.getId(),
+ request.roles
+ .stream()
+ .map(r -> new ProjectRoleBinding(new RoleBinding(projectId.getFullResourceName(), r)))
+ .collect(Collectors.toSet()),
+ request.justification,
+ Instant.now().truncatedTo(ChronoUnit.SECONDS),
+ requestedRoleBindingDuration);
- // Get the requested role binding duration in minutes
- var requestedRoleBindingDuration = Duration.ofMinutes(request.activationTimeout).toMinutes();
+ try {
+ //
+ // Activate the request.
+ //
+ var activation = this.projectRoleActivator.activate(activationRequest);
- var activations = new ArrayList();
- for (var roleBinding : roleBindings) {
- try {
- var activation = this.roleActivationService.activateProjectRoleForSelf(
- iapPrincipal.getId(),
- roleBinding,
- request.justification,
- Duration.ofMinutes(request.activationTimeout));
+ assert activation != null;
- assert activation != null;
- activations.add(activation);
+ //
+ // Notify listeners, if any.
+ //
+ for (var service : this.notificationServices) {
+ service.sendNotification(new ActivationSelfApprovedNotification(projectId, activation));
+ }
- for (var service : this.notificationServices) {
- service.sendNotification(new ActivationSelfApprovedNotification(
- activation,
+ //
+ // Leave an audit log trail.
+ //
+ this.logAdapter
+ .newInfoEntry(
+ LogEvents.API_ACTIVATE_ROLE,
+ String.format(
+ "User %s activated roles %s on '%s' for themselves for %d minutes",
+ iapPrincipal.getId(),
+ activationRequest.entitlements().stream()
+ .map(ent -> String.format("'%s'", ent.roleBinding().role()))
+ .collect(Collectors.joining(", ")),
+ projectId.getFullResourceName(),
+ requestedRoleBindingDuration.toMinutes()))
+ .addLabels(le -> addLabels(le, activationRequest))
+ .write();
+
+ return new ActivationStatusResponse(
+ iapPrincipal.getId(),
+ activation.request(),
+ Entitlement.Status.ACTIVE);
+ }
+ catch (Exception e) {
+ this.logAdapter
+ .newErrorEntry(
+ LogEvents.API_ACTIVATE_ROLE,
+ String.format(
+ "User %s failed to activate roles %s on '%s' for themselves for %d minutes: %s",
iapPrincipal.getId(),
- request.justification));
- }
-
- this.logAdapter
- .newInfoEntry(
- LogEvents.API_ACTIVATE_ROLE,
- String.format(
- "User %s activated role '%s' on '%s' for themselves for %d minutes",
- iapPrincipal.getId(),
- roleBinding.role(),
- roleBinding.fullResourceName(),
- requestedRoleBindingDuration))
- .addLabels(le -> addLabels(le, activation))
- .addLabel("justification", request.justification)
- .write();
+ activationRequest.entitlements().stream()
+ .map(ent -> String.format("'%s'", ent.roleBinding().role()))
+ .collect(Collectors.joining(", ")),
+ projectId.getFullResourceName(),
+ requestedRoleBindingDuration.toMinutes(),
+ Exceptions.getFullMessage(e)))
+ .addLabels(le -> addLabels(le, projectId))
+ .addLabels(le -> addLabels(le, activationRequest))
+ .addLabels(le -> addLabels(le, e))
+ .write();
+
+ if (e instanceof AccessDeniedException) {
+ throw (AccessDeniedException)e.fillInStackTrace();
}
- catch (Exception e) {
- this.logAdapter
- .newErrorEntry(
- LogEvents.API_ACTIVATE_ROLE,
- String.format(
- "User %s failed to activate role '%s' on '%s' for themselves for %d minutes: %s",
- iapPrincipal.getId(),
- roleBinding.role(),
- roleBinding.fullResourceName(),
- requestedRoleBindingDuration,
- Exceptions.getFullMessage(e)))
- .addLabels(le -> addLabels(le, projectId))
- .addLabels(le -> addLabels(le, roleBinding))
- .addLabels(le -> addLabels(le, e))
- .addLabel("justification", request.justification)
- .write();
-
- if (e instanceof AccessDeniedException) {
- throw (AccessDeniedException)e.fillInStackTrace();
- }
- else {
- throw new AccessDeniedException("Activating role failed", e);
- }
+ else {
+ throw new AccessDeniedException("Activating role failed", e);
}
}
-
- assert activations.size() == roleBindings.size();
-
- return new ActivationStatusResponse(
- iapPrincipal.getId(),
- Set.of(),
- true,
- false,
- request.justification,
- activations
- .stream()
- .map(a -> new ActivationStatusResponse.ActivationStatus(a))
- .collect(Collectors.toList()));
}
/**
@@ -393,12 +399,12 @@ public ActivationStatusResponse requestActivation(
@Context SecurityContext securityContext,
@Context UriInfo uriInfo
) throws AccessDeniedException {
- Preconditions.checkNotNull(this.roleDiscoveryService, "roleDiscoveryService");
- assert this.activationTokenService != null;
+ Preconditions.checkNotNull(this.iamPolicyCatalog, "iamPolicyCatalog");
+ assert this.tokenSigner != null;
assert this.notificationServices != null;
- var minReviewers = this.roleActivationService.getOptions().minNumberOfReviewersPerActivationRequest;
- var maxReviewers = this.roleActivationService.getOptions().maxNumberOfReviewersPerActivationRequest;
+ var minReviewers = this.iamPolicyCatalog.options().minNumberOfReviewersPerActivationRequest();
+ var maxReviewers = this.iamPolicyCatalog.options().maxNumberOfReviewersPerActivationRequest();
Preconditions.checkArgument(
projectIdString != null && !projectIdString.trim().isEmpty(),
@@ -434,34 +440,80 @@ public ActivationStatusResponse requestActivation(
var projectId = new ProjectId(projectIdString);
var roleBinding = new RoleBinding(projectId, request.role);
- // Get the requested role binding duration in minutes
- var requestedRoleBindingDuration = Duration.ofMinutes(request.activationTimeout).toMinutes();
+ //
+ // Create an MPA activation request.
+ //
+ var requestedRoleBindingDuration = Duration.ofMinutes(request.activationTimeout);
+ MpaActivationRequest activationRequest;
- try
- {
- //
- // Validate request.
- //
- var activationRequest = this.roleActivationService.createActivationRequestForPeer(
+ try {
+ activationRequest = this.projectRoleActivator.createMpaRequest(
iapPrincipal.getId(),
+ Set.of(new ProjectRoleBinding(roleBinding)),
request.peers.stream().map(email -> new UserId(email)).collect(Collectors.toSet()),
- roleBinding,
request.justification,
- Duration.ofMinutes(request.activationTimeout));
+ Instant.now().truncatedTo(ChronoUnit.SECONDS),
+ requestedRoleBindingDuration);
+ }
+ catch (AccessException | IOException e) {
+ this.logAdapter
+ .newErrorEntry(
+ LogEvents.API_ACTIVATE_ROLE,
+ String.format(
+ "Received invalid activation request from user '%s' for role '%s' on '%s': %s",
+ iapPrincipal.getId(),
+ roleBinding,
+ projectId.getFullResourceName(),
+ Exceptions.getFullMessage(e)))
+ .addLabels(le -> addLabels(le, projectId))
+ .addLabels(le -> addLabels(le, e))
+ .write();
+ if (e instanceof AccessDeniedException) {
+ throw (AccessDeniedException)e.fillInStackTrace();
+ }
+ else {
+ throw new AccessDeniedException("Invalid request", e);
+ }
+ }
+
+ try
+ {
+ //
+ // Create an activation token and pass it to reviewers.
//
- // Create an approval token and pass it to reviewers.
+ // An activation token is a signed activation request that is passed to reviewers.
+ // It contains all information necessary to review (and approve) the activation
+ // request.
//
- var activationToken = this.activationTokenService.createToken(activationRequest);
+ // We must ensure that the information that reviewers see (and base their approval
+ // on) is authentic. Therefore, activation tokens are signed, using the service account
+ // as signing authority.
+ //
+ // Although activation tokens are JWTs, and might look like credentials, they aren't
+ // credentials: They don't grant access to any information, and possession alone is
+ // insufficient to approve an activation request.
+ //
+
+ var activationToken = this.tokenSigner.sign(
+ this.projectRoleActivator.createTokenConverter(),
+ activationRequest);
+ //
+ // Notify reviewers, listeners.
+ //
for (var service : this.notificationServices) {
- var activationRequestUrl = createActivationRequestUrl(uriInfo, activationToken.token);
+ var activationRequestUrl = createActivationRequestUrl(uriInfo, activationToken.token());
service.sendNotification(new RequestActivationNotification(
+ projectId,
activationRequest,
- activationToken.expiryTime,
+ activationToken.expiryTime(),
activationRequestUrl));
}
+ //
+ // Leave an audit log trail.
+ //
this.logAdapter
.newInfoEntry(
LogEvents.API_REQUEST_ROLE,
@@ -470,8 +522,7 @@ public ActivationStatusResponse requestActivation(
iapPrincipal.getId(),
roleBinding.role(),
roleBinding.fullResourceName(),
- requestedRoleBindingDuration
- ))
+ requestedRoleBindingDuration.toMinutes()))
.addLabels(le -> addLabels(le, projectId))
.addLabels(le -> addLabels(le, activationRequest))
.write();
@@ -479,7 +530,7 @@ public ActivationStatusResponse requestActivation(
return new ActivationStatusResponse(
iapPrincipal.getId(),
activationRequest,
- ProjectRole.Status.ACTIVATION_PENDING);
+ Entitlement.Status.ACTIVATION_PENDING);
}
catch (Exception e) {
this.logAdapter
@@ -490,7 +541,7 @@ public ActivationStatusResponse requestActivation(
iapPrincipal.getId(),
roleBinding.role(),
roleBinding.fullResourceName(),
- requestedRoleBindingDuration,
+ requestedRoleBindingDuration.toMinutes(),
Exceptions.getFullMessage(e)))
.addLabels(le -> addLabels(le, projectId))
.addLabels(le -> addLabels(le, roleBinding))
@@ -517,7 +568,7 @@ public ActivationStatusResponse getActivationRequest(
@QueryParam("activation") String obfuscatedActivationToken,
@Context SecurityContext securityContext
) throws AccessException {
- assert this.activationTokenService != null;
+ assert this.tokenSigner != null;
Preconditions.checkArgument(
obfuscatedActivationToken != null && !obfuscatedActivationToken.trim().isEmpty(),
@@ -527,17 +578,19 @@ public ActivationStatusResponse getActivationRequest(
var iapPrincipal = (UserPrincipal) securityContext.getUserPrincipal();
try {
- var activationRequest = this.activationTokenService.verifyToken(activationToken);
+ var activationRequest = this.tokenSigner.verify(
+ this.projectRoleActivator.createTokenConverter(),
+ activationToken);
- if (!activationRequest.beneficiary.equals(iapPrincipal.getId()) &&
- !activationRequest.reviewers.contains(iapPrincipal.getId())) {
+ if (!activationRequest.requestingUser().equals(iapPrincipal.getId()) &&
+ !activationRequest.reviewers().contains(iapPrincipal.getId())) {
throw new AccessDeniedException("The calling user is not authorized to access this approval request");
}
return new ActivationStatusResponse(
iapPrincipal.getId(),
activationRequest,
- ProjectRole.Status.ACTIVATION_PENDING); // TODO(later): Could check if's been activated already.
+ Entitlement.Status.ACTIVATION_PENDING); // TODO(later): Could check if's been activated already.
}
catch (Exception e) {
this.logAdapter
@@ -563,8 +616,8 @@ public ActivationStatusResponse approveActivationRequest(
@Context SecurityContext securityContext,
@Context UriInfo uriInfo
) throws AccessException {
- assert this.activationTokenService != null;
- assert this.roleActivationService != null;
+ assert this.tokenSigner != null;
+ assert this.iamPolicyCatalog != null;
assert this.notificationServices != null;
Preconditions.checkArgument(
@@ -574,9 +627,11 @@ public ActivationStatusResponse approveActivationRequest(
var activationToken = TokenObfuscator.decode(obfuscatedActivationToken);
var iapPrincipal = (UserPrincipal) securityContext.getUserPrincipal();
- RoleActivationService.ActivationRequest activationRequest;
+ MpaActivationRequest activationRequest;
try {
- activationRequest = this.activationTokenService.verifyToken(activationToken);
+ activationRequest = this.tokenSigner.verify(
+ this.projectRoleActivator.createTokenConverter(),
+ activationToken);
}
catch (Exception e) {
this.logAdapter
@@ -589,39 +644,52 @@ public ActivationStatusResponse approveActivationRequest(
throw new AccessDeniedException("Accessing the activation request failed");
}
+ assert activationRequest.entitlements().size() == 1;
+ var roleBinding = activationRequest
+ .entitlements()
+ .stream()
+ .findFirst()
+ .get()
+ .roleBinding();
+
try {
- var activation = this.roleActivationService.activateProjectRoleForPeer(
+ var activation = this.projectRoleActivator.approve(
iapPrincipal.getId(),
activationRequest);
assert activation != null;
+
+ //
+ // Notify listeners.
+ //
for (var service : this.notificationServices) {
service.sendNotification(new ActivationApprovedNotification(
- activationRequest,
+ ProjectId.fromFullResourceName(roleBinding.fullResourceName()),
+ activation,
iapPrincipal.getId(),
createActivationRequestUrl(uriInfo, activationToken)));
}
+ //
+ // Leave an audit trail.
+ //
this.logAdapter
.newInfoEntry(
LogEvents.API_ACTIVATE_ROLE,
String.format(
"User %s approved role '%s' on '%s' for %s",
iapPrincipal.getId(),
- activationRequest.roleBinding.role(),
- activationRequest.roleBinding.fullResourceName(),
- activationRequest.beneficiary))
+ roleBinding.role(),
+ roleBinding.fullResourceName(),
+ activationRequest.requestingUser()))
.addLabels(le -> addLabels(le, activationRequest))
.write();
return new ActivationStatusResponse(
- activationRequest.beneficiary,
- activationRequest.reviewers,
- activationRequest.beneficiary.equals(iapPrincipal.getId()),
- activationRequest.reviewers.contains(iapPrincipal.getId()),
- activationRequest.justification,
- List.of(new ActivationStatusResponse.ActivationStatus(activation)));
+ iapPrincipal.getId(),
+ activationRequest,
+ Entitlement.Status.ACTIVE);
}
catch (Exception e) {
this.logAdapter
@@ -630,9 +698,9 @@ public ActivationStatusResponse approveActivationRequest(
String.format(
"User %s failed to activate role '%s' on '%s' for %s: %s",
iapPrincipal.getId(),
- activationRequest.roleBinding.role(),
- activationRequest.roleBinding.fullResourceName(),
- activationRequest.beneficiary,
+ roleBinding.role(),
+ roleBinding.fullResourceName(),
+ activationRequest.requestingUser(),
Exceptions.getFullMessage(e)))
.addLabels(le -> addLabels(le, activationRequest))
.addLabels(le -> addLabels(le, e))
@@ -651,32 +719,26 @@ public ActivationStatusResponse approveActivationRequest(
// Logging helper methods.
// -------------------------------------------------------------------------
- private static LogAdapter.LogEntry addLabels(
+ private static LogAdapter.LogEntry addLabels(
LogAdapter.LogEntry entry,
- RoleActivationService.Activation activation
+ com.google.solutions.jitaccess.core.catalog.ActivationRequest request
) {
- return entry
- .addLabel("activation_id", activation.id.toString())
- .addLabel("activation_start", activation.startTime.atOffset(ZoneOffset.UTC).toString())
- .addLabel("activation_end", activation.endTime.atOffset(ZoneOffset.UTC).toString())
- .addLabels(e -> addLabels(e, activation.projectRole.roleBinding()));
- }
-
- private static LogAdapter.LogEntry addLabels(
- LogAdapter.LogEntry entry,
- 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
+ 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())
+ .addLabels(e -> addLabels(e, request.entitlements()));
+
+ if (request instanceof MpaActivationRequest mpaRequest) {
+ entry.addLabel("reviewers", mpaRequest
+ .reviewers()
.stream()
.map(u -> u.email)
- .collect(Collectors.joining(", ")))
- .addLabels(e -> addLabels(e, request.roleBinding));
+ .collect(Collectors.joining(", ")));
+ }
+
+ return entry;
}
private static LogAdapter.LogEntry addLabels(
@@ -689,6 +751,15 @@ private static LogAdapter.LogEntry addLabels(
.addLabel("project_id", ProjectId.fromFullResourceName(roleBinding.fullResourceName()).id());
}
+ private static LogAdapter.LogEntry addLabels(
+ LogAdapter.LogEntry entry,
+ Collection extends EntitlementId> entitlements
+ ) {
+ return entry.addLabel(
+ "entitlements",
+ entitlements.stream().map(s -> s.toString()).collect(Collectors.joining(", ")));
+ }
+
private static LogAdapter.LogEntry addLabels(
LogAdapter.LogEntry entry,
Exception exception
@@ -749,13 +820,31 @@ public static class ProjectRolesResponse {
public final List roles;
private ProjectRolesResponse(
- List roleBindings,
+ List roles,
Set warnings
) {
- Preconditions.checkNotNull(roleBindings, "roleBindings");
+ Preconditions.checkNotNull(roles, "roles");
this.warnings = warnings;
- this.roles = roleBindings;
+ this.roles = roles;
+ }
+ }
+
+ public static class ProjectRole {
+ public final RoleBinding roleBinding;
+ public final ActivationType activationType;
+ public final Entitlement.Status status;
+
+ public ProjectRole(
+ RoleBinding roleBinding,
+ ActivationType activationType,
+ Entitlement.Status status) {
+
+ Preconditions.checkNotNull(roleBinding, "roleBinding");
+
+ this.roleBinding = roleBinding;
+ this.activationType = activationType;
+ this.status = status;
}
}
@@ -783,85 +872,66 @@ public static class ActivationRequest {
public static class ActivationStatusResponse {
public final UserId beneficiary;
- public final Set reviewers;
+ public final Collection reviewers;
public final boolean isBeneficiary;
public final boolean isReviewer;
public final String justification;
public final List items;
- private ActivationStatusResponse(
- UserId beneficiary,
- Set reviewers,
- boolean isBeneficiary,
- boolean isReviewer,
- String justification,
- List items
- ) {
- Preconditions.checkNotNull(beneficiary);
- Preconditions.checkNotNull(reviewers);
- Preconditions.checkNotNull(justification);
- Preconditions.checkNotNull(items);
- Preconditions.checkArgument(items.size() > 0);
-
- this.beneficiary = beneficiary;
- this.reviewers = reviewers;
- this.isBeneficiary = isBeneficiary;
- this.isReviewer = isReviewer;
- this.justification = justification;
- this.items = items;
- }
-
private ActivationStatusResponse(
UserId caller,
- RoleActivationService.ActivationRequest request,
- ProjectRole.Status status
+ com.google.solutions.jitaccess.core.catalog.ActivationRequest request,
+ Entitlement.Status status
) {
- this(
- request.beneficiary,
- request.reviewers,
- request.beneficiary.equals(caller),
- request.reviewers.contains(caller),
- request.justification,
- List.of(new ActivationStatus(
- request.id,
- request.roleBinding,
+ Preconditions.checkNotNull(request);
+
+ this.beneficiary = request.requestingUser();
+ this.isBeneficiary = request.requestingUser().equals(caller);
+ this.justification = request.justification();
+ this.items = request
+ .entitlements()
+ .stream()
+ .map(ent -> new ActivationStatusResponse.ActivationStatus(
+ request.id(),
+ ent.roleBinding(),
status,
- request.startTime.getEpochSecond(),
- request.endTime.getEpochSecond())));
+ request.startTime(),
+ request.endTime()))
+ .collect(Collectors.toList());
+
+ if (request instanceof MpaActivationRequest mpaRequest) {
+ this.reviewers = mpaRequest.reviewers();
+ this.isReviewer = mpaRequest.reviewers().contains(caller);
+ }
+ else {
+ this.reviewers = Set.of();
+ this.isReviewer = false;
+ }
}
public static class ActivationStatus {
public final String activationId;
public final String projectId;
public final RoleBinding roleBinding;
- public final ProjectRole.Status status;
+ public final Entitlement.Status status;
public final long startTime;
public final long endTime;
private ActivationStatus(
- RoleActivationService.ActivationId activationId,
+ ActivationId activationId,
RoleBinding roleBinding,
- ProjectRole.Status status,
- long startTime,
- long endTime
+ Entitlement.Status status,
+ Instant startTime,
+ Instant endTime
) {
- assert startTime < endTime;
+ assert endTime.isAfter(startTime);
this.activationId = activationId.toString();
this.projectId = ProjectId.fromFullResourceName(roleBinding.fullResourceName()).id();
this.roleBinding = roleBinding;
this.status = status;
- this.startTime = startTime;
- this.endTime = endTime;
- }
-
- private ActivationStatus(RoleActivationService.Activation activation) {
- this(
- activation.id,
- activation.projectRole.roleBinding(),
- activation.projectRole.status(),
- activation.startTime.getEpochSecond(),
- activation.endTime.getEpochSecond());
+ this.startTime = startTime.getEpochSecond();
+ this.endTime = endTime.getEpochSecond();
}
}
}
@@ -877,26 +947,35 @@ private ActivationStatus(RoleActivationService.Activation activation) {
public class RequestActivationNotification extends NotificationService.Notification
{
protected RequestActivationNotification(
- RoleActivationService.ActivationRequest request,
+ ProjectId projectId,
+ MpaActivationRequest request,
Instant requestExpiryTime,
URL activationRequestUrl) throws MalformedURLException
{
super(
- request.reviewers,
- List.of(request.beneficiary),
+ request.reviewers(),
+ List.of(request.requestingUser()),
String.format(
"%s requests access to project %s",
- request.beneficiary,
- 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);
+ request.requestingUser(),
+ projectId.id()));
+
+ assert request.entitlements().size() == 1;
+
+ this.properties.put("BENEFICIARY", request.requestingUser());
+ this.properties.put("REVIEWERS", request.reviewers());
+ this.properties.put("PROJECT_ID", projectId);
+ this.properties.put("ROLE", request
+ .entitlements()
+ .stream()
+ .findFirst()
+ .get()
+ .roleBinding()
+ .role());
+ this.properties.put("START_TIME", request.startTime());
+ this.properties.put("END_TIME", request.endTime());
this.properties.put("REQUEST_EXPIRY_TIME", requestExpiryTime);
- this.properties.put("JUSTIFICATION", request.justification);
+ this.properties.put("JUSTIFICATION", request.justification());
this.properties.put("BASE_URL", new URL(activationRequestUrl, "/").toString());
this.properties.put("ACTION_URL", activationRequestUrl.toString());
}
@@ -912,26 +991,36 @@ public String getType() {
*/
public class ActivationApprovedNotification extends NotificationService.Notification {
protected ActivationApprovedNotification(
- RoleActivationService.ActivationRequest request,
+ ProjectId projectId,
+ Activation activation,
UserId approver,
URL activationRequestUrl) throws MalformedURLException
{
super(
- List.of(request.beneficiary),
- request.reviewers, // Move reviewers to CC.
+ List.of(activation.request().requestingUser()),
+ ((MpaActivationRequest)activation.request()).reviewers(), // Move reviewers to CC.
String.format(
"%s requests access to project %s",
- request.beneficiary,
- ProjectId.fromFullResourceName(request.roleBinding.fullResourceName()).id()));
+ activation.request().requestingUser(),
+ projectId));
+
+ var request = (MpaActivationRequest)activation.request();
+ assert request.entitlements().size() == 1;
this.properties.put("APPROVER", approver.email);
- 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);
- this.properties.put("JUSTIFICATION", request.justification);
+ this.properties.put("BENEFICIARY", request.requestingUser());
+ this.properties.put("REVIEWERS", request.reviewers());
+ this.properties.put("PROJECT_ID", projectId);
+ this.properties.put("ROLE", activation.request()
+ .entitlements()
+ .stream()
+ .findFirst()
+ .get()
+ .roleBinding()
+ .role());
+ this.properties.put("START_TIME", request.startTime());
+ this.properties.put("END_TIME", request.endTime());
+ this.properties.put("JUSTIFICATION", request.justification());
this.properties.put("BASE_URL", new URL(activationRequestUrl, "/").toString());
}
@@ -951,24 +1040,29 @@ public String getType() {
*/
public class ActivationSelfApprovedNotification extends NotificationService.Notification {
protected ActivationSelfApprovedNotification(
- RoleActivationService.Activation activation,
- UserId beneficiary,
- String justification)
+ ProjectId projectId,
+ Activation activation)
{
super(
- List.of(beneficiary),
+ List.of(activation.request().requestingUser()),
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);
+ "Activated roles %s on '%s'",
+ activation.request().entitlements().stream()
+ .map(ent -> String.format("'%s'", ent.roleBinding().role()))
+ .collect(Collectors.joining(", ")),
+ projectId));
+
+ this.properties.put("BENEFICIARY", activation.request().requestingUser());
+ this.properties.put("PROJECT_ID", projectId);
+ this.properties.put("ROLE", activation.request()
+ .entitlements()
+ .stream()
+ .map(ent -> ent.roleBinding().role())
+ .collect(Collectors.joining(", ")));
+ this.properties.put("START_TIME", activation.request().startTime());
+ this.properties.put("END_TIME", activation.request().endTime());
+ this.properties.put("JUSTIFICATION", activation.request().justification());
}
@Override
@@ -986,17 +1080,11 @@ public String getType() {
// Options.
// -------------------------------------------------------------------------
- public static class Options {
- public final int maxNumberOfJitRolesPerSelfApproval;
-
- public Options(
- int maxNumberOfJitRolesPerSelfApproval
- ) {
+ public record Options(int maxNumberOfJitRolesPerSelfApproval) {
+ public Options {
Preconditions.checkArgument(
maxNumberOfJitRolesPerSelfApproval > 0,
"The maximum number of JIT roles per self-approval must exceed 1");
-
- this.maxNumberOfJitRolesPerSelfApproval = maxNumberOfJitRolesPerSelfApproval;
}
}
}
diff --git a/sources/src/main/resources/META-INF/resources/index.html b/sources/src/main/resources/META-INF/resources/index.html
index 8a24e1d6d..5aee0f9f0 100644
--- a/sources/src/main/resources/META-INF/resources/index.html
+++ b/sources/src/main/resources/META-INF/resources/index.html
@@ -95,6 +95,7 @@
Role |
+ Requirements |
Status |
@@ -513,11 +514,16 @@ Audit and review just-in-time access
this.id = id;
this.statusCaptions = {
- "ACTIVATED": "Active",
- "ACTIVATION_PENDING": "Waiting for approval",
- "ELIGIBLE_FOR_JIT": "Activation required",
- "ELIGIBLE_FOR_MPA": "Peer approval required",
+ "ACTIVE": "Active",
+ "AVAILABLE": "Available",
+ "ACTIVATION_PENDING": "Waiting for approval"
};
+
+ this.activationTypeCaptions = {
+ "JIT": "-",
+ "MPA": "Peer approval required",
+ "NONE": "Unavailable",
+ }
}
/**
@@ -586,10 +592,11 @@ Audit and review just-in-time access
item.roleBinding.role,
[
{ text: item.roleBinding.role },
- { text: this.statusCaptions[item.status] ?? item.status, class: item.status === "ACTIVATED" ? "activated" : null }
+ { text: this.activationTypeCaptions[item.activationType] ?? item.activationType },
+ { text: this.statusCaptions[item.status] ?? item.status, class: item.status === "ACTIVE" ? "activated" : null }
],
true,
- item.status !== "ACTIVATED");
+ item.status !== "ACTIVE");
});
}
@@ -692,7 +699,7 @@ Audit and review just-in-time access
[
{ text: item.projectId },
{ text: item.roleBinding.role },
- { text: this.statusCaptions[item.status] ?? item.status, class: item.status === "ACTIVATED" ? "activated" : null }
+ { text: this.statusCaptions[item.status] ?? item.status, class: item.status === "ACTIVE" ? "activated" : null }
],
false);
});
@@ -790,7 +797,7 @@ Audit and review just-in-time access
throw "Select one or more roles to actviate";
}
- if (selectedRoles.some(i => i.status === "ELIGIBLE_FOR_MPA")) {
+ if (selectedRoles.some(i => i.activationType === "MPA")) {
//
// Multi-party approval: Request and list peers available for this project and role.
//
diff --git a/sources/src/main/resources/META-INF/resources/model.js b/sources/src/main/resources/META-INF/resources/model.js
index 960ad46ee..a50292845 100644
--- a/sources/src/main/resources/META-INF/resources/model.js
+++ b/sources/src/main/resources/META-INF/resources/model.js
@@ -354,7 +354,8 @@ class DebugModel extends Model {
}
else {
await new Promise(r => setTimeout(r, 2000));
- const statuses = ["ACTIVATED", "ELIGIBLE_FOR_JIT", "ELIGIBLE_FOR_MPA"]
+ const activationTypes = ["JIT", "MPA", "NONE"];
+ const statuses = ["ACTIVE", "AVAILABLE"];
return Promise.resolve({
warnings: ["This is a simulated result"],
roles: Array.from({ length: setting }, (e, i) => ({
@@ -362,6 +363,7 @@ class DebugModel extends Model {
id: "//project-1:roles/simulated-role-" + i,
role: "roles/simulated-role-" + i
},
+ activationType: activationTypes[i % activationTypes.length],
status: statuses[i % statuses.length]
}))
});
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/TestProjectId.java b/sources/src/test/java/com/google/solutions/jitaccess/core/TestProjectId.java
index 0ccd6f2d4..afa979dbe 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/TestProjectId.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/TestProjectId.java
@@ -21,7 +21,6 @@
package com.google.solutions.jitaccess.core;
-import com.google.solutions.jitaccess.core.ProjectId;
import org.junit.jupiter.api.Test;
import java.util.List;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestRoleBinding.java b/sources/src/test/java/com/google/solutions/jitaccess/core/TestRoleBinding.java
similarity index 95%
rename from sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestRoleBinding.java
rename to sources/src/test/java/com/google/solutions/jitaccess/core/TestRoleBinding.java
index c85bd6687..f790fa326 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestRoleBinding.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/TestRoleBinding.java
@@ -19,9 +19,9 @@
// under the License.
//
-package com.google.solutions.jitaccess.core.entitlements;
+package com.google.solutions.jitaccess.core;
-import com.google.solutions.jitaccess.core.entitlements.RoleBinding;
+import com.google.solutions.jitaccess.core.RoleBinding;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/TestUserId.java b/sources/src/test/java/com/google/solutions/jitaccess/core/TestUserId.java
index 29269648e..3bad7a729 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/TestUserId.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/TestUserId.java
@@ -21,7 +21,6 @@
package com.google.solutions.jitaccess.core;
-import com.google.solutions.jitaccess.core.UserId;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/SampleEntitlementId.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/SampleEntitlementId.java
new file mode 100644
index 000000000..0f80ebb63
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/SampleEntitlementId.java
@@ -0,0 +1,47 @@
+//
+// 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.catalog;
+
+class SampleEntitlementId extends EntitlementId
+{
+ private final String catalog;
+ private final String id;
+
+ public SampleEntitlementId(String catalog, String id) {
+ this.catalog = catalog;
+ this.id = id;
+ }
+
+ public SampleEntitlementId(String id) {
+ this("sample", id);
+ }
+
+ @Override
+ public String catalog() {
+ return this.catalog;
+ }
+
+ @Override
+ public String id() {
+ return this.id;
+ }
+}
\ No newline at end of file
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestActivationId.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestActivationId.java
new file mode 100644
index 000000000..0cff22371
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestActivationId.java
@@ -0,0 +1,88 @@
+//
+// 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.catalog;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class TestActivationId {
+
+ // -------------------------------------------------------------------------
+ // toString.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void toStringReturnsId() {
+ var id = new ActivationId("jit-123");
+ assertEquals("jit-123", id.toString());
+ }
+
+ @Test
+ public void toStringContainsTypePrefix() {
+ var id = ActivationId.newId(ActivationType.MPA);
+ assertTrue(id.toString().startsWith("mpa-"));
+ }
+
+ // -------------------------------------------------------------------------
+ // Equality.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void whenObjectAreEquivalent_ThenEqualsReturnsTrue() {
+ ActivationId id1 = new ActivationId("jit-1");
+ ActivationId id2 = new ActivationId("jit-1");
+
+ assertTrue(id1.equals(id2));
+ assertEquals(id1.hashCode(), id2.hashCode());
+ }
+
+ @Test
+ public void whenObjectAreSame_ThenEqualsReturnsTrue() {
+ ActivationId id1 = new ActivationId("jit-1");
+
+ assertTrue(id1.equals(id1));
+ }
+
+ @Test
+ public void whenObjectAreNotEquivalent_ThenEqualsReturnsFalse() {
+ ActivationId id1 = new ActivationId("jit-1");
+ ActivationId id2 = new ActivationId("jit-2");
+
+ assertFalse(id1.equals(id2));
+ assertNotEquals(id1.hashCode(), id2.hashCode());
+ }
+
+ @Test
+ public void whenObjectIsNull_ThenEqualsReturnsFalse() {
+ ActivationId id1 = new ActivationId("jit-1");
+
+ assertFalse(id1.equals(null));
+ }
+
+ @Test
+ public void whenObjectIsDifferentType_ThenEqualsReturnsFalse() {
+ ActivationId id1 = new ActivationId("jit-1");
+
+ assertFalse(id1.equals(""));
+ }
+}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestActivationRequest.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestActivationRequest.java
new file mode 100644
index 000000000..791f58a47
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestActivationRequest.java
@@ -0,0 +1,91 @@
+//
+// 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.catalog;
+
+import com.google.solutions.jitaccess.core.UserId;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class TestActivationRequest {
+ private class SampleEntitlementId extends EntitlementId
+ {
+ private final String id;
+
+ public SampleEntitlementId(String id) {
+ this.id = id;
+ }
+
+ @Override
+ public String catalog() {
+ return "sample";
+ }
+
+ @Override
+ public String id() {
+ return this.id;
+ }
+ }
+
+ private class SampleActivationRequest extends ActivationRequest
+ {
+ public SampleActivationRequest(
+ ActivationId id,
+ UserId user,
+ Set entitlements,
+ String justification,
+ Instant startTime,
+ Duration duration) {
+ super(id, user, entitlements, justification, startTime, duration);
+ }
+
+ @Override
+ public ActivationType type() {
+ return ActivationType.JIT;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // toString.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void toStringReturnsSummary() {
+ var request = new SampleActivationRequest(
+ new ActivationId("sample-1"),
+ new UserId("user@example.com"),
+ Set.of(
+ new SampleEntitlementId("1")),
+ "some justification",
+ Instant.ofEpochSecond(0),
+ Duration.ofMinutes(5));
+
+ assertEquals(
+ "[sample-1] entitlements=sample:1, startTime=1970-01-01T00:00:00Z, " +
+ "duration=PT5M, justification=some justification",
+ request.toString());
+ }
+}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestEntitlement.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestEntitlement.java
new file mode 100644
index 000000000..b8feabdda
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestEntitlement.java
@@ -0,0 +1,94 @@
+//
+// 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.catalog;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.TreeSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class TestEntitlement {
+
+ // -------------------------------------------------------------------------
+ // toString.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void toStringReturnsName() {
+ var ent = new Entitlement(
+ new SampleEntitlementId("1"),
+ "Sample entitlement",
+ ActivationType.JIT,
+ Entitlement.Status.AVAILABLE);
+
+ assertEquals("Sample entitlement", ent.toString());
+ }
+
+ // -------------------------------------------------------------------------
+ // compareTo.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void compareToOrdersByStatusThenName() {
+ var availableA = new Entitlement(
+ new SampleEntitlementId("A"),
+ "Entitlement A",
+ ActivationType.JIT,
+ Entitlement.Status.AVAILABLE);
+ var activeA = new Entitlement(
+ new SampleEntitlementId("A"),
+ "Entitlement A",
+ ActivationType.JIT,
+ Entitlement.Status.ACTIVE);
+ var pendingA = new Entitlement(
+ new SampleEntitlementId("A"),
+ "Entitlement A",
+ ActivationType.JIT,
+ Entitlement.Status.ACTIVATION_PENDING);
+
+ var availableB = new Entitlement(
+ new SampleEntitlementId("B"),
+ "Entitlement B",
+ ActivationType.JIT,
+ Entitlement.Status.AVAILABLE);
+
+ var entitlements = List.of(
+ availableB,
+ pendingA,
+ availableA,
+ activeA);
+
+ var sorted = new TreeSet>();
+ sorted.addAll(entitlements);
+
+ Assertions.assertIterableEquals(
+ List.of(
+ availableA,
+ availableB,
+ activeA,
+ pendingA),
+ sorted);
+ }
+}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestEntitlementActivator.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestEntitlementActivator.java
new file mode 100644
index 000000000..a6f55d667
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestEntitlementActivator.java
@@ -0,0 +1,293 @@
+//
+// 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.catalog;
+
+import com.google.solutions.jitaccess.core.AccessDeniedException;
+import com.google.solutions.jitaccess.core.AccessException;
+import com.google.solutions.jitaccess.core.AlreadyExistsException;
+import com.google.solutions.jitaccess.core.UserId;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+public class TestEntitlementActivator {
+ private static final UserId SAMPLE_REQUESTING_USER = new UserId("user@example.com");
+ private static final UserId SAMPLE_APPROVING_USER = new UserId("peer@example.com");
+ private static final UserId SAMPLE_UNKNOWN_USER = new UserId("unknown@example.com");
+
+ private class SampleActivator extends EntitlementActivator {
+ protected SampleActivator(
+ EntitlementCatalog catalog,
+ JustificationPolicy policy
+ ) {
+ super(catalog, policy);
+ }
+
+ @Override
+ protected void provisionAccess(
+ JitActivationRequest request
+ ) throws AccessException, AlreadyExistsException, IOException {
+ }
+
+ @Override
+ protected void provisionAccess(
+ UserId approvingUser,
+ MpaActivationRequest request
+ ) throws AccessException, AlreadyExistsException, IOException {
+ }
+
+ @Override
+ public JsonWebTokenConverter> createTokenConverter() {
+ return null;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // createJitRequest.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void createJitRequestDoesNotCheckAccess() throws Exception {
+ var catalog = Mockito.mock(EntitlementCatalog.class);
+
+ var activator = new SampleActivator(
+ catalog,
+ Mockito.mock(JustificationPolicy.class));
+
+ var entitlements = Set.of(new SampleEntitlementId("cat", "1"));
+ var request = activator.createJitRequest(
+ SAMPLE_REQUESTING_USER,
+ entitlements,
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ assertNotNull(request);
+ assertEquals(SAMPLE_REQUESTING_USER, request.requestingUser());
+ assertIterableEquals(entitlements, request.entitlements());
+
+ verify(catalog, times(0)).verifyUserCanRequest(request);
+ }
+
+ // -------------------------------------------------------------------------
+ // createMpaRequest.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void whenUserNotAllowedToRequest_ThenCreateMpaRequestThrowsException() throws Exception {
+ var catalog = Mockito.mock(EntitlementCatalog.class);
+
+ Mockito.doThrow(new AccessDeniedException("mock"))
+ .when(catalog)
+ .verifyUserCanRequest(any());
+
+ var activator = new SampleActivator(
+ catalog,
+ Mockito.mock(JustificationPolicy.class));
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> activator.createMpaRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new SampleEntitlementId("cat", "1")),
+ Set.of(SAMPLE_APPROVING_USER),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5)));
+ }
+
+ // -------------------------------------------------------------------------
+ // activate (JIT).
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void whenJustificationInvalid_ThenActivateJitRequestThrowsException() throws Exception {
+ var justificationPolicy = Mockito.mock(JustificationPolicy.class);
+
+ Mockito.doThrow(new InvalidJustificationException("mock"))
+ .when(justificationPolicy)
+ .checkJustification(eq(SAMPLE_REQUESTING_USER), anyString());
+
+ var activator = new SampleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ justificationPolicy);
+
+ var request = activator.createJitRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new SampleEntitlementId("cat", "1")),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ assertThrows(
+ InvalidJustificationException.class,
+ () -> activator.activate(request));
+ }
+
+ @Test
+ public void whenUserNotAllowedToRequest_ThenActivateJitRequestThrowsException() throws Exception {
+ var catalog = Mockito.mock(EntitlementCatalog.class);
+
+ Mockito.doThrow(new AccessDeniedException("mock"))
+ .when(catalog)
+ .verifyUserCanRequest(any());
+
+ var activator = new SampleActivator(
+ catalog,
+ Mockito.mock(JustificationPolicy.class));
+
+ var request = activator.createJitRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new SampleEntitlementId("cat", "1")),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> activator.activate(request));
+ }
+
+ // -------------------------------------------------------------------------
+ // approve (MPA).
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void whenApprovingUserSameAsRequestingUser_ThenApproveMpaRequestThrowsException() throws Exception {
+ var activator = new SampleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ Mockito.mock(JustificationPolicy.class));
+
+ var request = activator.createMpaRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new SampleEntitlementId("cat", "1")),
+ Set.of(SAMPLE_APPROVING_USER),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> activator.approve(SAMPLE_REQUESTING_USER, request));
+ }
+
+ @Test
+ public void whenApprovingUserUnknown_ThenApproveMpaRequestThrowsException() throws Exception {
+ var activator = new SampleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ Mockito.mock(JustificationPolicy.class));
+
+ var request = activator.createMpaRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new SampleEntitlementId("cat", "1")),
+ Set.of(SAMPLE_APPROVING_USER),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> activator.approve(SAMPLE_UNKNOWN_USER, request));
+ }
+
+ @Test
+ public void whenJustificationInvalid_ThenApproveMpaRequestThrowsException() throws Exception {
+ var justificationPolicy = Mockito.mock(JustificationPolicy.class);
+
+ Mockito.doThrow(new InvalidJustificationException("mock"))
+ .when(justificationPolicy)
+ .checkJustification(eq(SAMPLE_REQUESTING_USER), anyString());
+
+ var activator = new SampleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ justificationPolicy);
+
+ var request = activator.createMpaRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new SampleEntitlementId("cat", "1")),
+ Set.of(SAMPLE_APPROVING_USER),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ assertThrows(
+ InvalidJustificationException.class,
+ () -> activator.approve(SAMPLE_APPROVING_USER, request));
+ }
+
+ @Test
+ public void whenRequestingUserNotAllowedToRequestAnymore_ThenApproveMpaRequestThrowsException() throws Exception {
+ var catalog = Mockito.mock(EntitlementCatalog.class);
+ var activator = new SampleActivator(
+ catalog,
+ Mockito.mock(JustificationPolicy.class));
+
+ var request = activator.createMpaRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new SampleEntitlementId("cat", "1")),
+ Set.of(SAMPLE_APPROVING_USER),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ Mockito.doThrow(new AccessDeniedException("mock"))
+ .when(catalog)
+ .verifyUserCanRequest(any());
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> activator.approve(SAMPLE_APPROVING_USER, request));
+ }
+
+ @Test
+ public void whenApprovingUserNotAllowedToApprove_ThenApproveMpaRequestThrowsException() throws Exception {
+ var catalog = Mockito.mock(EntitlementCatalog.class);
+ var activator = new SampleActivator(
+ catalog,
+ Mockito.mock(JustificationPolicy.class));
+
+ var request = activator.createMpaRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new SampleEntitlementId("cat", "1")),
+ Set.of(SAMPLE_APPROVING_USER),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ Mockito.doThrow(new AccessDeniedException("mock"))
+ .when(catalog)
+ .verifyUserCanApprove(eq(SAMPLE_APPROVING_USER), any());
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> activator.approve(SAMPLE_APPROVING_USER, request));
+ }
+}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestEntitlementId.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestEntitlementId.java
new file mode 100644
index 000000000..51954e0d5
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestEntitlementId.java
@@ -0,0 +1,113 @@
+//
+// 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.catalog;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.TreeSet;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class TestEntitlementId {
+ // -------------------------------------------------------------------------
+ // hashCode.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void whenIdIsEqual_ThenHashCodeIsEqual() {
+ assertEquals(
+ new SampleEntitlementId("cat", "1").hashCode(),
+ new SampleEntitlementId("dog", "1").hashCode()
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // equals.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void whenObjectAreEquivalent_ThenEqualsReturnsTrue() {
+ SampleEntitlementId id1 = new SampleEntitlementId("cat", "jit-1");
+ SampleEntitlementId id2 = new SampleEntitlementId("cat", "jit-1");
+
+ assertTrue(id1.equals(id2));
+ assertEquals(id1.hashCode(), id2.hashCode());
+ }
+
+ @Test
+ public void whenObjectAreSame_ThenEqualsReturnsTrue() {
+ SampleEntitlementId id1 = new SampleEntitlementId("cat", "jit-1");
+
+ assertTrue(id1.equals(id1));
+ }
+
+ @Test
+ public void whenObjectAreNotEquivalent_ThenEqualsReturnsFalse() {
+ SampleEntitlementId id1 = new SampleEntitlementId("cat", "jit-1");
+ SampleEntitlementId id2 = new SampleEntitlementId("cat", "jit-2");
+
+ assertFalse(id1.equals(id2));
+ assertNotEquals(id1.hashCode(), id2.hashCode());
+ }
+
+ @Test
+ public void whenObjectIsNull_ThenEqualsReturnsFalse() {
+ SampleEntitlementId id1 = new SampleEntitlementId("cat", "jit-1");
+
+ assertFalse(id1.equals(null));
+ }
+
+ @Test
+ public void whenObjectIsDifferentType_ThenEqualsReturnsFalse() {
+ SampleEntitlementId id1 = new SampleEntitlementId("cat", "jit-1");
+
+ assertFalse(id1.equals(""));
+ }
+
+ // -------------------------------------------------------------------------
+ // compareTo.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void compareToOrdersByCatalogThenId() {
+ var ids = List.of(
+ new SampleEntitlementId("b", "2"),
+ new SampleEntitlementId("b", "1"),
+ new SampleEntitlementId("a", "2"),
+ new SampleEntitlementId("b", "3"),
+ new SampleEntitlementId("a", "1"));
+
+ var sorted = new TreeSet();
+ sorted.addAll(ids);
+
+ Assertions.assertIterableEquals(
+ List.of(
+ new SampleEntitlementId("a", "1"),
+ new SampleEntitlementId("a", "2"),
+ new SampleEntitlementId("b", "1"),
+ new SampleEntitlementId("b", "2"),
+ new SampleEntitlementId("b", "3")),
+ sorted);
+ }
+}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestRegexActivationPolicy.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestRegexActivationPolicy.java
new file mode 100644
index 000000000..e8fe85790
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestRegexActivationPolicy.java
@@ -0,0 +1,80 @@
+//
+// 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.catalog;
+
+import com.google.solutions.jitaccess.core.UserId;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class TestRegexActivationPolicy {
+
+ private static final UserId SAMPLE_USER = new UserId("user@example.com");
+
+ // -------------------------------------------------------------------------
+ // checkJustification.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void whenJustificationNullOrEmpty_ThenCheckJustificationThrowsException() {
+ var policy = new RegexJustificationPolicy(new RegexJustificationPolicy.Options(
+ "hint",
+ Pattern.compile(".*")
+ ));
+
+ assertThrows(
+ InvalidJustificationException.class,
+ () -> policy.checkJustification(SAMPLE_USER, null));
+ assertThrows(
+ InvalidJustificationException.class,
+ () -> policy.checkJustification(SAMPLE_USER, ""));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {" ", "a", "b/a", "b/"})
+ public void whenJustificationDoesNotMatchRegex_ThenCheckJustificationThrowsException(
+ String value
+ ) {
+ var policy = new RegexJustificationPolicy(new RegexJustificationPolicy.Options(
+ "hint",
+ Pattern.compile("^b/(\\d+)$")
+ ));
+
+ assertThrows(
+ InvalidJustificationException.class,
+ () -> policy.checkJustification(SAMPLE_USER, value));
+ }
+
+ @Test
+ public void whenJustificationMatchesRegex_ThenCheckJustificationReturns() throws Exception {
+ var policy = new RegexJustificationPolicy(new RegexJustificationPolicy.Options(
+ "hint",
+ Pattern.compile("^b/(\\d+)$")
+ ));
+
+ policy.checkJustification(SAMPLE_USER, "b/1");
+ }
+}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestActivationTokenService.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestTokenSigner.java
similarity index 57%
rename from sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestActivationTokenService.java
rename to sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestTokenSigner.java
index 6eba6d135..2860c8387 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestActivationTokenService.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/TestTokenSigner.java
@@ -1,5 +1,5 @@
//
-// Copyright 2022 Google LLC
+// Copyright 2023 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
@@ -19,57 +19,62 @@
// under the License.
//
-package com.google.solutions.jitaccess.core.entitlements;
+package com.google.solutions.jitaccess.core.catalog;
import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.auth.oauth2.TokenVerifier;
+import com.google.solutions.jitaccess.core.UserId;
import com.google.solutions.jitaccess.core.clients.HttpTransport;
import com.google.solutions.jitaccess.core.clients.IamCredentialsClient;
import com.google.solutions.jitaccess.core.clients.IntegrationTestEnvironment;
-import com.google.solutions.jitaccess.core.ProjectId;
-import com.google.solutions.jitaccess.core.UserId;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.Instant;
-import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
-public class TestActivationTokenService {
+public class TestTokenSigner {
private static final UserId SAMPLE_USER_1 = new UserId("user-1@example.com");
private static final UserId SAMPLE_USER_2 = new UserId("user-2@example.com");
private static final UserId SAMPLE_USER_3 = new UserId("user-3@example.com");
+ private static class PseudoJsonConverter implements JsonWebTokenConverter {
+ @Override
+ public JsonWebToken.Payload convert(JsonWebToken.Payload object) {
+ return object;
+ }
+ }
+
// -------------------------------------------------------------------------
- // createToken.
+ // sign.
// -------------------------------------------------------------------------
@Test
- public void createTokenAddsObligatoryClaims() throws Exception {
+ public void signAddsObligatoryClaims() throws Exception {
var credentialsAdapter = new IamCredentialsClient(
IntegrationTestEnvironment.APPLICATION_CREDENTIALS,
HttpTransport.Options.DEFAULT);
var serviceAccount = IntegrationTestEnvironment.NO_ACCESS_USER;
- var tokenService = new ActivationTokenService(
+
+ var tokenSignerOptions = new TokenSigner.Options(serviceAccount, Duration.ofMinutes(5));
+ var tokenSigner = new TokenSigner(
credentialsAdapter,
- new ActivationTokenService.Options(
- serviceAccount,
- Duration.ofMinutes(5)));
-
- var startTime = Instant.now();
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- SAMPLE_USER_1,
- Set.of(SAMPLE_USER_2, SAMPLE_USER_3),
- new RoleBinding(new ProjectId("project-1"), "roles/role-1"),
- "justification",
- startTime,
- startTime.plusSeconds(60));
-
- var token = tokenService.createToken(request);
- assertNotNull(token.token);
- assertEquals(startTime.plus(tokenService.getOptions().tokenValidity), token.expiryTime);
+ tokenSignerOptions);
+
+ var emptyPayload = new JsonWebToken.Payload();
+
+ var token = tokenSigner.sign(
+ new PseudoJsonConverter(),
+ emptyPayload);
+
+ assertNotNull(token.token());
+ assertTrue(token.issueTime().isBefore(Instant.now().plusSeconds(5)));
+ assertTrue(token.issueTime().isAfter(Instant.now().minusSeconds(5)));
+
+ assertEquals(
+ token.issueTime().plus(tokenSignerOptions.tokenValidity()),
+ token.expiryTime());
var verifiedPayload = TokenVerifier
.newBuilder()
@@ -77,41 +82,39 @@ public void createTokenAddsObligatoryClaims() throws Exception {
.setIssuer(serviceAccount.email)
.setAudience(serviceAccount.email)
.build()
- .verify(token.token)
+ .verify(token.token())
.getPayload();
assertEquals(serviceAccount.email, verifiedPayload.getIssuer());
assertEquals(serviceAccount.email, verifiedPayload.getAudience());
- assertNull(verifiedPayload.getIssuedAtTimeSeconds());
- assertNotNull(verifiedPayload.getExpirationTimeSeconds());
- assertEquals(
- startTime.plus(Duration.ofMinutes(5)).getEpochSecond(),
- verifiedPayload.getExpirationTimeSeconds());
+ assertEquals(token.issueTime().getEpochSecond(), verifiedPayload.getIssuedAtTimeSeconds());
+ assertEquals(token.expiryTime().getEpochSecond(), verifiedPayload.getExpirationTimeSeconds());
}
// -------------------------------------------------------------------------
- // verifyToken.
+ // verify.
// -------------------------------------------------------------------------
@Test
- public void whenJwtMissesAudienceClaim_ThenVerifyTokenThrowsException() throws Exception {
+ public void whenJwtMissesAudienceClaim_ThenVerifyThrowsException() throws Exception {
var credentialsAdapter = new IamCredentialsClient(
IntegrationTestEnvironment.APPLICATION_CREDENTIALS,
HttpTransport.Options.DEFAULT);
var serviceAccount = IntegrationTestEnvironment.NO_ACCESS_USER;
- var tokenService = new ActivationTokenService(
+
+ var tokenSigner = new TokenSigner(
credentialsAdapter,
- new ActivationTokenService.Options(
- serviceAccount,
- Duration.ofMinutes(5)));
+ new TokenSigner.Options(serviceAccount, Duration.ofMinutes(5)));
var payload = new JsonWebToken.Payload()
.setIssuer(serviceAccount.email);
-
var jwt = credentialsAdapter.signJwt(serviceAccount, payload);
- assertThrows(TokenVerifier.VerificationException.class,
- () -> tokenService.verifyToken(jwt));
+ assertThrows(
+ TokenVerifier.VerificationException.class,
+ () -> tokenSigner.verify(
+ new PseudoJsonConverter(),
+ jwt));
}
@Test
@@ -120,19 +123,21 @@ public void whenJwtMissesIssuerClaim_ThenVerifyThrowsException() throws Exceptio
IntegrationTestEnvironment.APPLICATION_CREDENTIALS,
HttpTransport.Options.DEFAULT);
var serviceAccount = IntegrationTestEnvironment.NO_ACCESS_USER;
- var tokenService = new ActivationTokenService(
+
+ var tokenSigner = new TokenSigner(
credentialsAdapter,
- new ActivationTokenService.Options(
- serviceAccount,
- Duration.ofMinutes(5)));
+ new TokenSigner.Options(serviceAccount, Duration.ofMinutes(5)));
var payload = new JsonWebToken.Payload()
.setAudience(serviceAccount.email);
var jwt = credentialsAdapter.signJwt(serviceAccount, payload);
- assertThrows(TokenVerifier.VerificationException.class,
- () -> tokenService.verifyToken(jwt));
+ assertThrows(
+ TokenVerifier.VerificationException.class,
+ () -> tokenSigner.verify(
+ new PseudoJsonConverter(),
+ jwt));
}
@Test
@@ -141,11 +146,10 @@ public void whenJwtSignedByWrongServiceAccount_ThenVerifyThrowsException() throw
IntegrationTestEnvironment.APPLICATION_CREDENTIALS,
HttpTransport.Options.DEFAULT);
var serviceAccount = IntegrationTestEnvironment.TEMPORARY_ACCESS_USER;
- var tokenService = new ActivationTokenService(
+
+ var tokenSigner = new TokenSigner(
credentialsAdapter,
- new ActivationTokenService.Options(
- serviceAccount,
- Duration.ofMinutes(5)));
+ new TokenSigner.Options(serviceAccount, Duration.ofMinutes(5)));
var payload = new JsonWebToken.Payload()
.setAudience(serviceAccount.email)
@@ -153,8 +157,11 @@ public void whenJwtSignedByWrongServiceAccount_ThenVerifyThrowsException() throw
var jwt = credentialsAdapter.signJwt(IntegrationTestEnvironment.NO_ACCESS_USER, payload);
- assertThrows(TokenVerifier.VerificationException.class,
- () -> tokenService.verifyToken(jwt));
+ assertThrows(
+ TokenVerifier.VerificationException.class,
+ () -> tokenSigner.verify(
+ new PseudoJsonConverter(),
+ jwt));
}
@Test
@@ -163,28 +170,21 @@ public void whenJwtValid_ThenVerifySucceeds() throws Exception {
IntegrationTestEnvironment.APPLICATION_CREDENTIALS,
HttpTransport.Options.DEFAULT);
var serviceAccount = IntegrationTestEnvironment.NO_ACCESS_USER;
- var tokenService = new ActivationTokenService(
+
+ var tokenSigner = new TokenSigner(
credentialsAdapter,
- new ActivationTokenService.Options(
- serviceAccount,
- Duration.ofMinutes(5)));
-
- var inputRequest = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- SAMPLE_USER_1,
- Set.of(SAMPLE_USER_2, SAMPLE_USER_3),
- new RoleBinding(new ProjectId("project-1"), "roles/role-1"),
- "justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
-
- var token = tokenService.createToken(inputRequest);
- var outputRequest = tokenService.verifyToken(token.token);
-
- assertEquals(inputRequest.beneficiary, outputRequest.beneficiary);
- assertEquals(inputRequest.reviewers, outputRequest.reviewers);
- assertEquals(inputRequest.justification, outputRequest.justification);
- assertEquals(inputRequest.reviewers, outputRequest.reviewers);
- assertEquals(inputRequest.startTime.getEpochSecond(), outputRequest.startTime.getEpochSecond());
+ new TokenSigner.Options(serviceAccount, Duration.ofMinutes(5)));
+
+ var inputPayload = new JsonWebToken.Payload()
+ .setJwtId("sample-1");
+
+ var token = tokenSigner.sign(
+ new PseudoJsonConverter(),
+ inputPayload);
+ var outputPayload = tokenSigner.verify(
+ new PseudoJsonConverter(),
+ token.token());
+
+ assertEquals(inputPayload.getJwtId(), outputPayload.getJwtId());
}
}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestIamPolicyCatalog.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestIamPolicyCatalog.java
new file mode 100644
index 000000000..33f0542f5
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestIamPolicyCatalog.java
@@ -0,0 +1,634 @@
+//
+// 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.catalog.project;
+
+import com.google.solutions.jitaccess.core.*;
+import com.google.solutions.jitaccess.core.catalog.*;
+import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+public class TestIamPolicyCatalog {
+
+ private static final UserId SAMPLE_REQUESTING_USER = new UserId("user@example.com");
+ private static final UserId SAMPLE_APPROVIING_USER = new UserId("approver@example.com");
+ private static final ProjectId SAMPLE_PROJECT = new ProjectId("project-1");
+ private static final String SAMPLE_ROLE = "roles/resourcemanager.role1";
+
+ //---------------------------------------------------------------------------
+ // validateRequest.
+ //---------------------------------------------------------------------------
+
+ @Test
+ public void whenDurationExceedsMax_ThenValidateRequestThrowsException() throws Exception {
+ var catalog = new IamPolicyCatalog(
+ Mockito.mock(PolicyAnalyzer.class),
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(30),
+ 1,
+ 2
+ ));
+
+ var request = Mockito.mock(ActivationRequest.class);
+ when (request.duration()).thenReturn(catalog.options().maxActivationDuration().plusMinutes(1));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> catalog.validateRequest(request));
+ }
+
+ @Test
+ public void whenDurationBelowMin_ThenValidateRequestThrowsException() throws Exception {
+ var catalog = new IamPolicyCatalog(
+ Mockito.mock(PolicyAnalyzer.class),
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(30),
+ 1,
+ 2
+ ));
+
+ var request = Mockito.mock(ActivationRequest.class);
+ when (request.duration()).thenReturn(catalog.options().minActivationDuration().minusMinutes(1));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> catalog.validateRequest(request));
+ }
+
+ @Test
+ public void whenReviewersMissing_ThenValidateRequestThrowsException() throws Exception {
+ var catalog = new IamPolicyCatalog(
+ Mockito.mock(PolicyAnalyzer.class),
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(30),
+ 1,
+ 2
+ ));
+
+ var request = Mockito.mock(MpaActivationRequest.class);
+ when(request.duration()).thenReturn(catalog.options().minActivationDuration());
+ when(request.reviewers()).thenReturn(null);
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> catalog.validateRequest(request));
+ }
+
+ @Test
+ public void whenNumberOfReviewersExceedsMax_ThenValidateRequestThrowsException() throws Exception {
+ var catalog = new IamPolicyCatalog(
+ Mockito.mock(PolicyAnalyzer.class),
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(30),
+ 1,
+ 2
+ ));
+
+ var request = Mockito.mock(MpaActivationRequest.class);
+ when(request.duration()).thenReturn(catalog.options().minActivationDuration());
+ when(request.reviewers()).thenReturn(Set.of(
+ new UserId("user-1@example.com"),
+ new UserId("user-2@example.com"),
+ new UserId("user-3@example.com")));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> catalog.validateRequest(request));
+ }
+
+ @Test
+ public void whenNumberOfReviewersBelowMin_ThenValidateRequestThrowsException() throws Exception {
+ var catalog = new IamPolicyCatalog(
+ Mockito.mock(PolicyAnalyzer.class),
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(30),
+ 2,
+ 2
+ ));
+
+ var request = Mockito.mock(MpaActivationRequest.class);
+ when(request.duration()).thenReturn(catalog.options().minActivationDuration());
+ when(request.reviewers()).thenReturn(Set.of(
+ new UserId("user-1@example.com")));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> catalog.validateRequest(request));
+ }
+
+ @Test
+ public void whenNumberOfReviewersOk_ThenValidateRequestReturns() throws Exception {
+ var catalog = new IamPolicyCatalog(
+ Mockito.mock(PolicyAnalyzer.class),
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(30),
+ 1,
+ 2
+ ));
+
+ var request = Mockito.mock(MpaActivationRequest.class);
+ when(request.duration()).thenReturn(catalog.options().minActivationDuration());
+ when(request.reviewers()).thenReturn(Set.of(
+ new UserId("user-1@example.com")));
+
+ catalog.validateRequest(request);
+ }
+
+ //---------------------------------------------------------------------------
+ // verifyUserCanActivateEntitlements.
+ //---------------------------------------------------------------------------
+
+ @Test
+ public void whenEntitlementNotFound_ThenVerifyUserCanActivateEntitlementsThrowsException() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(null, Duration.ofMinutes(30), 1, 2));
+
+ when(policyAnalyzer
+ .findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ eq(EnumSet.of(Entitlement.Status.AVAILABLE))))
+ .thenReturn(new Annotated<>(new TreeSet<>(), Set.of()));
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> catalog.verifyUserCanActivateEntitlements(
+ SAMPLE_REQUESTING_USER,
+ SAMPLE_PROJECT,
+ ActivationType.JIT,
+ List.of(new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE)))));
+ }
+
+ @Test
+ public void whenActivationTypeMismatches_ThenVerifyUserCanActivateEntitlementsThrowsException() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(null, Duration.ofMinutes(30), 1, 2));
+
+ var mpaEntitlement = new Entitlement<>(
+ new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE)),
+ "-",
+ ActivationType.MPA,
+ Entitlement.Status.AVAILABLE);
+
+ when(policyAnalyzer
+ .findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ eq(EnumSet.of(Entitlement.Status.AVAILABLE))))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of(mpaEntitlement)),
+ Set.of()));
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> catalog.verifyUserCanActivateEntitlements(
+ SAMPLE_REQUESTING_USER,
+ SAMPLE_PROJECT,
+ ActivationType.JIT,
+ List.of(mpaEntitlement.id())));
+ }
+
+ //---------------------------------------------------------------------------
+ // listReviewers.
+ //---------------------------------------------------------------------------
+
+ @Test
+ public void whenUserNotAllowedToActivateEntitlement_ThenListReviewersThrowsException() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(null, Duration.ofMinutes(30), 1, 2));
+
+ when(policyAnalyzer
+ .findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ eq(EnumSet.of(Entitlement.Status.AVAILABLE))))
+ .thenReturn(new Annotated<>(new TreeSet<>(), Set.of()));
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> catalog.listReviewers(
+ SAMPLE_REQUESTING_USER,
+ new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE))));
+ }
+
+ @Test
+ public void whenUserAllowedToActivateEntitlement_ThenListReviewersExcludesUser() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(null, Duration.ofMinutes(30), 1, 2));
+
+ var mpaEntitlement = new Entitlement<>(
+ new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE)),
+ "-",
+ ActivationType.MPA,
+ Entitlement.Status.AVAILABLE);
+
+ when(policyAnalyzer
+ .findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ eq(EnumSet.of(Entitlement.Status.AVAILABLE))))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of(mpaEntitlement)),
+ Set.of()));
+
+ when(policyAnalyzer
+ .findApproversForEntitlement(
+ eq(mpaEntitlement.id().roleBinding())))
+ .thenReturn(Set.of(SAMPLE_REQUESTING_USER, SAMPLE_APPROVIING_USER));
+
+ var reviewers = catalog.listReviewers(SAMPLE_REQUESTING_USER, mpaEntitlement.id());
+ assertIterableEquals(Set.of(SAMPLE_APPROVIING_USER), reviewers);
+ }
+
+ //---------------------------------------------------------------------------
+ // verifyUserCanRequest.
+ //---------------------------------------------------------------------------
+
+ @Test
+ public void whenUserNotAllowedToActivate_ThenVerifyUserCanRequestThrowsException() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(null, Duration.ofMinutes(30), 1, 2));
+
+ var jitEntitlement = new Entitlement<>(
+ new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE)),
+ "-",
+ ActivationType.JIT,
+ Entitlement.Status.AVAILABLE);
+
+ when(policyAnalyzer
+ .findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ eq(EnumSet.of(Entitlement.Status.AVAILABLE))))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of(jitEntitlement)),
+ Set.of()));
+
+ var request = Mockito.mock(JitActivationRequest.class);
+ when(request.duration()).thenReturn(catalog.options().minActivationDuration());
+ when(request.type()).thenReturn(ActivationType.MPA); // mismatch
+ when(request.requestingUser()).thenReturn(SAMPLE_REQUESTING_USER);
+ when(request.entitlements()).thenReturn(Set.of(jitEntitlement.id()));
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> catalog.verifyUserCanRequest(request));
+ }
+
+ @Test
+ public void whenUserAllowedToActivate_ThenVerifyUserCanRequestReturns() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(null, Duration.ofMinutes(30), 1, 2));
+
+ var jitEntitlement = new Entitlement<>(
+ new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE)),
+ "-",
+ ActivationType.JIT,
+ Entitlement.Status.AVAILABLE);
+
+ when(policyAnalyzer
+ .findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ eq(EnumSet.of(Entitlement.Status.AVAILABLE))))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of(jitEntitlement)),
+ Set.of()));
+
+ var request = Mockito.mock(JitActivationRequest.class);
+ when(request.duration()).thenReturn(catalog.options().minActivationDuration());
+ when(request.type()).thenReturn(ActivationType.JIT);
+ when(request.requestingUser()).thenReturn(SAMPLE_REQUESTING_USER);
+ when(request.entitlements()).thenReturn(Set.of(jitEntitlement.id()));
+
+ catalog.verifyUserCanRequest(request);
+ }
+
+ //---------------------------------------------------------------------------
+ // verifyUserCanApprove.
+ //---------------------------------------------------------------------------
+
+ @Test
+ public void whenUserNotAllowedToActivate_ThenVerifyUserCanApproveThrowsException() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(null, Duration.ofMinutes(30), 1, 2));
+
+ var mpaEntitlement = new Entitlement<>(
+ new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE)),
+ "-",
+ ActivationType.MPA,
+ Entitlement.Status.AVAILABLE);
+
+ when(policyAnalyzer
+ .findEntitlements(
+ eq(SAMPLE_APPROVIING_USER),
+ eq(SAMPLE_PROJECT),
+ eq(EnumSet.of(Entitlement.Status.AVAILABLE))))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of()),
+ Set.of()));
+
+ var request = Mockito.mock(MpaActivationRequest.class);
+ when(request.duration()).thenReturn(catalog.options().minActivationDuration());
+ when(request.type()).thenReturn(ActivationType.MPA);
+ when(request.requestingUser()).thenReturn(SAMPLE_REQUESTING_USER);
+ when(request.reviewers()).thenReturn(Set.of(SAMPLE_APPROVIING_USER));
+ when(request.entitlements()).thenReturn(Set.of(mpaEntitlement.id()));
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> catalog.verifyUserCanApprove(SAMPLE_APPROVIING_USER, request));
+ }
+
+ @Test
+ public void whenUserAllowedToActivate_ThenVerifyUserCanApproveReturns() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(null, Duration.ofMinutes(30), 1, 2));
+
+ var mpaEntitlement = new Entitlement<>(
+ new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE)),
+ "-",
+ ActivationType.MPA,
+ Entitlement.Status.AVAILABLE);
+
+ when(policyAnalyzer
+ .findEntitlements(
+ eq(SAMPLE_APPROVIING_USER),
+ eq(SAMPLE_PROJECT),
+ eq(EnumSet.of(Entitlement.Status.AVAILABLE))))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of(mpaEntitlement)),
+ Set.of()));
+
+ var request = Mockito.mock(MpaActivationRequest.class);
+ when(request.duration()).thenReturn(catalog.options().minActivationDuration());
+ when(request.type()).thenReturn(ActivationType.MPA);
+ when(request.requestingUser()).thenReturn(SAMPLE_REQUESTING_USER);
+ when(request.reviewers()).thenReturn(Set.of(SAMPLE_APPROVIING_USER));
+ when(request.entitlements()).thenReturn(Set.of(mpaEntitlement.id()));
+
+ catalog.verifyUserCanApprove(SAMPLE_APPROVIING_USER, request);
+ }
+
+ //---------------------------------------------------------------------------
+ // listProjects.
+ //---------------------------------------------------------------------------
+
+ @Test
+ public void whenProjectQueryProvided_thenListProjectsPerformsProjectSearch() throws Exception {
+ var resourceManager = Mockito.mock(ResourceManagerClient.class);
+ when(resourceManager.searchProjectIds(eq("query")))
+ .thenReturn(new TreeSet<>(Set.of(
+ new ProjectId("project-2"),
+ new ProjectId("project-3"),
+ new ProjectId("project-1"))));
+
+ var catalog = new IamPolicyCatalog(
+ Mockito.mock(PolicyAnalyzer.class),
+ resourceManager,
+ new IamPolicyCatalog.Options(
+ "query",
+ Duration.ofMinutes(5),
+ 1,
+ 1)
+ );
+
+ var projects = catalog.listProjects(SAMPLE_REQUESTING_USER);
+ assertIterableEquals(
+ List.of( // Sorted
+ new ProjectId("project-1"),
+ new ProjectId("project-2"),
+ new ProjectId("project-3")),
+ projects);
+ }
+
+ @Test
+ public void whenProjectQueryNotProvided_thenListProjectsPerformsPolicySearch() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+ when(policyAnalyzer.findProjectsWithEntitlements(eq(SAMPLE_REQUESTING_USER)))
+ .thenReturn(new TreeSet<>(Set.of(
+ new ProjectId("project-2"),
+ new ProjectId("project-3"),
+ new ProjectId("project-1"))));
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ "",
+ Duration.ofMinutes(5),
+ 1,
+ 1)
+ );
+
+ var projects = catalog.listProjects(SAMPLE_REQUESTING_USER);
+ assertIterableEquals(
+ List.of( // Sorted
+ new ProjectId("project-1"),
+ new ProjectId("project-2"),
+ new ProjectId("project-3")),
+ projects);
+ }
+
+ //---------------------------------------------------------------------------
+ // listEntitlements.
+ //---------------------------------------------------------------------------
+
+ @Test
+ public void listEntitlementsReturnsAvailableAndActiveEntitlements() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+ when(policyAnalyzer.findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ any()))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of()),
+ Set.of()));
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(5),
+ 1,
+ 1)
+ );
+
+ var entitlements = catalog.listEntitlements(SAMPLE_REQUESTING_USER, SAMPLE_PROJECT);
+ assertNotNull(entitlements);
+
+ verify(policyAnalyzer, times(1)).findEntitlements(
+ SAMPLE_REQUESTING_USER,
+ SAMPLE_PROJECT,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+ }
+
+ //---------------------------------------------------------------------------
+ // listReviewers.
+ //---------------------------------------------------------------------------
+
+ @Test
+ public void whenUserNotAllowedToActivateRole_ThenListReviewersThrowsException() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+ when(policyAnalyzer.findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ any()))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of()),
+ Set.of()));
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(5),
+ 1,
+ 1)
+ );
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> catalog.listReviewers(SAMPLE_REQUESTING_USER, new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE))));
+ }
+
+ @Test
+ public void whenUserAllowedToActivateRoleWithoutMpa_ThenListReviewersReturnsList() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+ var role = new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE));
+ when(policyAnalyzer.findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ any()))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of(new Entitlement<>(
+ role,
+ "-",
+ ActivationType.JIT,
+ Entitlement.Status.AVAILABLE))),
+ Set.of()));
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(5),
+ 1,
+ 1)
+ );
+
+ assertThrows(
+ AccessDeniedException.class,
+ () -> catalog.listReviewers(SAMPLE_REQUESTING_USER, role));
+ }
+
+ @Test
+ public void whenUserAllowedToActivateRole_ThenListReviewersReturnsList() throws Exception {
+ var policyAnalyzer = Mockito.mock(PolicyAnalyzer.class);
+ var role = new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE));
+ when(policyAnalyzer.findEntitlements(
+ eq(SAMPLE_REQUESTING_USER),
+ eq(SAMPLE_PROJECT),
+ any()))
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of(new Entitlement<>(
+ role,
+ "-",
+ ActivationType.MPA,
+ Entitlement.Status.AVAILABLE))),
+ Set.of()));
+ when(policyAnalyzer.findApproversForEntitlement(eq(role.roleBinding())))
+ .thenReturn(Set.of(SAMPLE_APPROVIING_USER, SAMPLE_REQUESTING_USER));
+
+ var catalog = new IamPolicyCatalog(
+ policyAnalyzer,
+ Mockito.mock(ResourceManagerClient.class),
+ new IamPolicyCatalog.Options(
+ null,
+ Duration.ofMinutes(5),
+ 1,
+ 1)
+ );
+
+ var reviewers = catalog.listReviewers(SAMPLE_REQUESTING_USER, role);
+ assertIterableEquals(
+ Set.of(SAMPLE_APPROVIING_USER),
+ reviewers);
+ }
+}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestJitConstraints.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestJitConstraints.java
similarity index 96%
rename from sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestJitConstraints.java
rename to sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestJitConstraints.java
index e959ff065..9a8d0dc60 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestJitConstraints.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestJitConstraints.java
@@ -19,9 +19,10 @@
// under the License.
//
-package com.google.solutions.jitaccess.core.entitlements;
+package com.google.solutions.jitaccess.core.catalog.project;
import com.google.api.services.cloudasset.v1.model.Expr;
+import com.google.solutions.jitaccess.core.catalog.project.JitConstraints;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestPolicyAnalyzer.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestPolicyAnalyzer.java
new file mode 100644
index 000000000..537fecff9
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestPolicyAnalyzer.java
@@ -0,0 +1,948 @@
+//
+// 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.catalog.project;
+
+import com.google.api.services.cloudasset.v1.model.*;
+import com.google.solutions.jitaccess.core.ProjectId;
+import com.google.solutions.jitaccess.core.UserId;
+import com.google.solutions.jitaccess.core.catalog.ActivationType;
+import com.google.solutions.jitaccess.core.catalog.Entitlement;
+import com.google.solutions.jitaccess.core.RoleBinding;
+import com.google.solutions.jitaccess.core.clients.AssetInventoryClient;
+import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+public class TestPolicyAnalyzer {
+ private static final UserId SAMPLE_USER = new UserId("user-1", "user-1@example.com");
+ private static final UserId SAMPLE_APPROVING_USER_1 = new UserId("approver-1", "approver-1@example.com");
+ private static final UserId SAMPLE_APPROVING_USER_2 = new UserId("approver-2", "approver-2@example.com");
+ private static final ProjectId SAMPLE_PROJECT_ID_1 = new ProjectId("project-1");
+ private static final ProjectId SAMPLE_PROJECT_ID_2 = new ProjectId("project-2");
+ private static final String SAMPLE_ROLE_1 = "roles/resourcemanager.role1";
+ private static final String SAMPLE_ROLE_2 = "roles/resourcemanager.role2";
+ private static final String JIT_CONDITION = "has({}.jitAccessConstraint)";
+ private static final String MPA_CONDITION = "has({}.multiPartyApprovalConstraint)";
+
+ private static IamPolicyAnalysisResult createIamPolicyAnalysisResult(
+ String resource,
+ String role,
+ UserId user
+ ) {
+ return new IamPolicyAnalysisResult()
+ .setAttachedResourceFullName(resource)
+ .setAccessControlLists(List.of(new GoogleCloudAssetV1AccessControlList()
+ .setResources(List.of(new GoogleCloudAssetV1Resource()
+ .setFullResourceName(resource)))))
+ .setIamBinding(new Binding()
+ .setMembers(List.of("user:" + user))
+ .setRole(role));
+ }
+
+ private static IamPolicyAnalysisResult createConditionalIamPolicyAnalysisResult(
+ String resource,
+ String role,
+ UserId user,
+ String condition,
+ String conditionTitle,
+ String evaluationResult
+ ) {
+ return new IamPolicyAnalysisResult()
+ .setAttachedResourceFullName(resource)
+ .setAccessControlLists(List.of(new GoogleCloudAssetV1AccessControlList()
+ .setResources(List.of(new GoogleCloudAssetV1Resource()
+ .setFullResourceName(resource)))
+ .setConditionEvaluation(new ConditionEvaluation()
+ .setEvaluationValue(evaluationResult))))
+ .setIamBinding(new Binding()
+ .setMembers(List.of("user:" + user))
+ .setRole(role)
+ .setCondition(new Expr()
+ .setTitle(conditionTitle)
+ .setExpression(condition)))
+ .setIdentityList(new GoogleCloudAssetV1IdentityList()
+ .setIdentities(List.of(
+ new GoogleCloudAssetV1Identity().setName("user:" + user.email),
+ new GoogleCloudAssetV1Identity().setName("serviceAccount:ignoreme@x.iam.gserviceaccount.com"),
+ new GoogleCloudAssetV1Identity().setName("group:ignoreme@example.com"))));
+ }
+
+ // ---------------------------------------------------------------------
+ // findProjectsWithEntitlements.
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void whenAnalysisResultEmpty_ThenFindProjectsWithEntitlementsReturnsEmptyList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.of("resourcemanager.projects.get")),
+ eq(Optional.empty()),
+ eq(true)))
+ .thenReturn(new IamPolicyAnalysis());
+
+ var analyzer = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var projectIds = analyzer.findProjectsWithEntitlements(SAMPLE_USER);
+ assertNotNull(projectIds);
+ assertEquals(0, projectIds.size());
+ }
+
+ @Test
+ public void whenAnalysisResultContainsAcsWithUnrecognizedConditions_ThenFindProjectsWithEntitlementsReturnsEmptyList()
+ throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+ var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.of("resourcemanager.projects.get")),
+ eq(Optional.empty()),
+ eq(true)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ "a==b",
+ "unrecognized condition",
+ "TRUE"))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var projectIds = service.findProjectsWithEntitlements(SAMPLE_USER);
+ assertNotNull(projectIds);
+ assertEquals(0, projectIds.size());
+ }
+
+ @Test
+ public void whenAnalysisContainsPermanentBinding_ThenFindProjectsWithEntitlementsReturnsProjectId()
+ throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+ var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.of("resourcemanager.projects.get")),
+ eq(Optional.empty()),
+ eq(true)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ createIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var projectIds = service.findProjectsWithEntitlements(SAMPLE_USER);
+ assertNotNull(projectIds);
+ assertEquals(1, projectIds.size());
+ assertTrue(projectIds.contains(SAMPLE_PROJECT_ID_1));
+ }
+
+ @Test
+ public void whenAnalysisContainsEligibleBindings_ThenFindProjectsWithEntitlementsReturnsProjectIds()
+ throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+ var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.of("resourcemanager.projects.get")),
+ eq(Optional.empty()),
+ eq(true)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "eligible binding",
+ "CONDITIONAL"),
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_2.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ MPA_CONDITION,
+ "eligible binding",
+ "CONDITIONAL"))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var projectIds = service.findProjectsWithEntitlements(SAMPLE_USER);
+ assertNotNull(projectIds);
+ assertEquals(2, projectIds.size());
+ assertTrue(projectIds.contains(SAMPLE_PROJECT_ID_1));
+ assertTrue(projectIds.contains(SAMPLE_PROJECT_ID_2));
+ }
+
+ // ---------------------------------------------------------------------
+ // FindEntitlements.
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void whenAnalysisResultEmpty_ThenFindEntitlementsReturnsEmptyList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis());
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(0, entitlements.items().size());
+ }
+
+ @Test
+ public void whenAnalysisResultContainsEmptyAcl_ThenFindEntitlementsReturnsEmptyList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ new IamPolicyAnalysisResult().setAttachedResourceFullName(SAMPLE_PROJECT_ID_1.getFullResourceName()))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(0, entitlements.items().size());
+ }
+
+ @Test
+ public void whenAnalysisContainsNoEligibleRoles_ThenFindEntitlementsReturnsEmptyList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ createIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(0, entitlements.items().size());
+ }
+
+ @Test
+ public void whenAnalysisContainsJitEligibleBinding_ThenFindEntitlementsReturnsList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "eligible binding",
+ "CONDITIONAL"))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(1, entitlements.items().size());
+
+ var entitlement = entitlements.items().stream().findFirst().get();
+ assertEquals(SAMPLE_PROJECT_ID_1, entitlement.id().projectId());
+ assertEquals(SAMPLE_ROLE_1, entitlement.id().roleBinding().role());
+ assertEquals(SAMPLE_ROLE_1, entitlement.name());
+ assertEquals(ActivationType.JIT, entitlement.activationType());
+ assertEquals(Entitlement.Status.AVAILABLE, entitlement.status());
+ }
+
+ @Test
+ public void whenAnalysisContainsDuplicateJitEligibleBinding_ThenFindEntitlementsReturnsList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "eligible binding #1",
+ "CONDITIONAL"),
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "eligible binding #2",
+ "CONDITIONAL"))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(1, entitlements.items().size());
+
+ var entitlement = entitlements.items().stream().findFirst().get();
+ assertEquals(SAMPLE_PROJECT_ID_1, entitlement.id().projectId());
+ assertEquals(SAMPLE_ROLE_1, entitlement.id().roleBinding().role());
+ assertEquals(SAMPLE_ROLE_1, entitlement.name());
+ assertEquals(ActivationType.JIT, entitlement.activationType());
+ assertEquals(Entitlement.Status.AVAILABLE, entitlement.status());
+ }
+
+ @Test
+ public void whenAnalysisContainsMpaEligibleBinding_ThenFindEntitlementsReturnsList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ MPA_CONDITION,
+ "eligible binding",
+ "CONDITIONAL"))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(1, entitlements.items().size());
+
+ var entitlement = entitlements.items().stream().findFirst().get();
+ assertEquals(SAMPLE_PROJECT_ID_1, entitlement.id().projectId());
+ assertEquals(SAMPLE_ROLE_1, entitlement.id().roleBinding().role());
+ assertEquals(SAMPLE_ROLE_1, entitlement.name());
+ assertEquals(ActivationType.MPA, entitlement.activationType());
+ assertEquals(Entitlement.Status.AVAILABLE, entitlement.status());
+ }
+
+ @Test
+ public void whenAnalysisContainsDuplicateMpaEligibleBinding_ThenFindEntitlementsReturnsList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ MPA_CONDITION,
+ "eligible binding # 1",
+ "CONDITIONAL"),
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ MPA_CONDITION,
+ "eligible binding # 2",
+ "CONDITIONAL"))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(1, entitlements.items().size());
+
+ var entitlement = entitlements.items().stream().findFirst().get();
+ assertEquals(SAMPLE_PROJECT_ID_1, entitlement.id().projectId());
+ assertEquals(SAMPLE_ROLE_1, entitlement.id().roleBinding().role());
+ assertEquals(SAMPLE_ROLE_1, entitlement.name());
+ assertEquals(ActivationType.MPA, entitlement.activationType());
+ assertEquals(Entitlement.Status.AVAILABLE, entitlement.status());
+ }
+
+ @Test
+ public void whenAnalysisContainsMpaEligibleBindingAndJitEligibleBindingForDifferentRoles_ThenFindEntitlementsReturnsList()
+ throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ var jitEligibleBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "JIT-eligible binding",
+ "CONDITIONAL");
+
+ var mpaEligibleBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_2,
+ SAMPLE_USER,
+ MPA_CONDITION,
+ "MPA-eligible binding",
+ "CONDITIONAL");
+
+ when(assetAdapter.findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(jitEligibleBinding, mpaEligibleBinding)));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(2, entitlements.items().size());
+
+ var entitlement = entitlements.items().stream().findFirst().get();
+ assertEquals(SAMPLE_PROJECT_ID_1, entitlement.id().projectId());
+ assertEquals(SAMPLE_ROLE_1, entitlement.id().roleBinding().role());
+ assertEquals(ActivationType.JIT, entitlement.activationType());
+ assertEquals(Entitlement.Status.AVAILABLE, entitlement.status());
+
+ entitlement = entitlements.items().stream().skip(1).findFirst().get();
+ assertEquals(SAMPLE_PROJECT_ID_1, entitlement.id().projectId());
+ assertEquals(SAMPLE_ROLE_2, entitlement.id().roleBinding().role());
+ assertEquals(ActivationType.MPA, entitlement.activationType());
+ assertEquals(Entitlement.Status.AVAILABLE, entitlement.status());
+ }
+
+ @Test
+ public void whenAnalysisContainsMpaEligibleBindingAndJitEligibleBindingForSameRole_ThenFindEntitlementsReturnsList()
+ throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ var jitEligibleBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "JIT-eligible binding",
+ "CONDITIONAL");
+
+ var mpaEligibleBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ MPA_CONDITION,
+ "MPA-eligible binding",
+ "CONDITIONAL");
+
+ when(assetAdapter.findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(jitEligibleBinding, mpaEligibleBinding)));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(1, entitlements.items().size());
+
+ // Only the JIT-eligible binding is retained.
+ var entitlement = entitlements.items().stream().findFirst().get();
+ assertEquals(SAMPLE_PROJECT_ID_1, entitlement.id().projectId());
+ assertEquals(SAMPLE_ROLE_1, entitlement.id().roleBinding().role());
+ assertEquals(ActivationType.JIT, entitlement.activationType());
+ assertEquals(Entitlement.Status.AVAILABLE, entitlement.status());
+ }
+
+ @Test
+ public void whenAnalysisContainsActivatedBinding_ThenFindEntitlementsReturnsMergedList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ var eligibleBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "eligible binding",
+ "CONDITIONAL");
+
+ var activatedBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ "time ...",
+ JitConstraints.ACTIVATION_CONDITION_TITLE,
+ "TRUE");
+
+ var activatedExpiredBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ "time ...",
+ JitConstraints.ACTIVATION_CONDITION_TITLE,
+ "FALSE");
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ eligibleBinding,
+ activatedBinding,
+ activatedExpiredBinding
+ )));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(1, entitlements.items().size());
+
+ var entitlement = entitlements.items().stream().findFirst().get();
+ assertEquals(SAMPLE_PROJECT_ID_1, entitlement.id().projectId());
+ assertEquals(SAMPLE_ROLE_1, entitlement.id().roleBinding().role());
+ assertEquals(ActivationType.JIT, entitlement.activationType());
+ assertEquals(Entitlement.Status.ACTIVE, entitlement.status());
+ }
+
+ @Test
+ public void whenAnalysisContainsEligibleBindingWithExtraCondition_ThenBindingIsIgnored()
+ throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION + " && resource.name=='Foo'",
+ "eligible binding with extra junk",
+ "CONDITIONAL"))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(0, entitlements.items().size());
+ }
+
+ @Test
+ public void whenAnalysisContainsInheritedEligibleBinding_ThenFindEntitlementsReturnsList()
+ throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ var parentFolderAcl = new GoogleCloudAssetV1AccessControlList()
+ .setResources(List.of(new GoogleCloudAssetV1Resource()
+ .setFullResourceName("//cloudresourcemanager.googleapis.com/folders/folder-1")))
+ .setConditionEvaluation(new ConditionEvaluation()
+ .setEvaluationValue("CONDITIONAL"));
+
+ var childFolderAndProjectAcl = new GoogleCloudAssetV1AccessControlList()
+ .setResources(List.of(
+ new GoogleCloudAssetV1Resource()
+ .setFullResourceName("//cloudresourcemanager.googleapis.com/folders/folder-1"),
+ new GoogleCloudAssetV1Resource()
+ .setFullResourceName(SAMPLE_PROJECT_ID_1.getFullResourceName()),
+ new GoogleCloudAssetV1Resource()
+ .setFullResourceName(SAMPLE_PROJECT_ID_2.getFullResourceName())))
+ .setConditionEvaluation(new ConditionEvaluation()
+ .setEvaluationValue("CONDITIONAL"));
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(new IamPolicyAnalysisResult()
+ .setAttachedResourceFullName("//cloudresourcemanager.googleapis.com/folders/folder-1")
+ .setAccessControlLists(List.of(
+ parentFolderAcl,
+ childFolderAndProjectAcl))
+ .setIamBinding(new Binding()
+ .setMembers(List.of("user:" + SAMPLE_USER))
+ .setRole(SAMPLE_ROLE_1)
+ .setCondition(new Expr().setExpression(JIT_CONDITION))))));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.AVAILABLE, Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(2, entitlements.items().size());
+
+ var first = entitlements.items().first();
+ assertEquals(
+ new RoleBinding(SAMPLE_PROJECT_ID_1, SAMPLE_ROLE_1),
+ first.id().roleBinding());
+ assertEquals(
+ ActivationType.JIT,
+ first.activationType());
+
+ var second = entitlements.items().last();
+ assertEquals(
+ new RoleBinding(SAMPLE_PROJECT_ID_2, SAMPLE_ROLE_1),
+ second.id().roleBinding());
+ assertEquals(
+ ActivationType.JIT,
+ second.activationType());
+ }
+
+ @Test
+ public void whenStatusSetToActiveOnly_ThenFindEntitlementsOnlyReturnsActivatedBindings() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ var jitEligibleBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "eligible binding",
+ "CONDITIONAL");
+
+ var mpaEligibleBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_2,
+ SAMPLE_USER,
+ MPA_CONDITION,
+ "MPA-eligible binding",
+ "CONDITIONAL");
+
+ var activatedBinding = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ "time ...",
+ JitConstraints.ACTIVATION_CONDITION_TITLE,
+ "TRUE");
+
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis()
+ .setAnalysisResults(List.of(
+ jitEligibleBinding,
+ mpaEligibleBinding,
+ activatedBinding)));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var entitlements = service.findEntitlements(
+ SAMPLE_USER,
+ SAMPLE_PROJECT_ID_1,
+ EnumSet.of(Entitlement.Status.ACTIVE));
+
+ assertNotNull(entitlements.warnings());
+ assertEquals(0, entitlements.warnings().size());
+
+ assertNotNull(entitlements.items());
+ assertEquals(1, entitlements.items().size());
+
+ var entitlement = entitlements.items().stream().findFirst().get();
+ assertEquals(SAMPLE_PROJECT_ID_1, entitlement.id().projectId());
+ assertEquals(Entitlement.Status.ACTIVE, entitlement.status());
+ }
+
+
+ // ---------------------------------------------------------------------
+ // findApproversForEntitlement.
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void whenAllUsersJitEligible_ThenFindApproversForEntitlementReturnsEmptyList()
+ throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+
+ var mpaBindingResult = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "eligible binding",
+ "CONDITIONAL");
+ when(assetAdapter
+ .findAccessibleResourcesByUser(
+ anyString(),
+ eq(SAMPLE_USER),
+ eq(Optional.empty()),
+ eq(Optional.of(SAMPLE_PROJECT_ID_1.getFullResourceName())),
+ eq(false)))
+ .thenReturn(new IamPolicyAnalysis().setAnalysisResults(List.of(mpaBindingResult)));
+ when(assetAdapter.findPermissionedPrincipalsByResource(anyString(), anyString(), anyString()))
+ .thenReturn(new IamPolicyAnalysis().setAnalysisResults(List.of(mpaBindingResult)));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var approvers = service.findApproversForEntitlement(
+ new RoleBinding(
+ SAMPLE_PROJECT_ID_1,
+ SAMPLE_ROLE_1));
+
+ assertTrue(approvers.isEmpty());
+ }
+
+ @Test
+ public void whenUsersMpaEligible_ThenFindApproversForEntitlementReturnsList() throws Exception {
+ var assetAdapter = Mockito.mock(AssetInventoryClient.class);
+ var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
+
+ var jitBindingResult = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_USER,
+ JIT_CONDITION,
+ "eligible binding",
+ "CONDITIONAL");
+ var mpaBindingResult1 = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_APPROVING_USER_1,
+ MPA_CONDITION,
+ "eligible binding",
+ "CONDITIONAL");
+ var mpaBindingResult2 = createConditionalIamPolicyAnalysisResult(
+ SAMPLE_PROJECT_ID_1.getFullResourceName(),
+ SAMPLE_ROLE_1,
+ SAMPLE_APPROVING_USER_2,
+ MPA_CONDITION,
+ "eligible binding",
+ "CONDITIONAL");
+
+ when(assetAdapter.findPermissionedPrincipalsByResource(anyString(), anyString(), anyString()))
+ .thenReturn(new IamPolicyAnalysis().setAnalysisResults(List.of(
+ jitBindingResult,
+ mpaBindingResult1,
+ mpaBindingResult2)));
+
+ var service = new PolicyAnalyzer(
+ assetAdapter,
+ new PolicyAnalyzer.Options("organizations/0"));
+
+ var approvers = service.findApproversForEntitlement(
+ new RoleBinding(
+ SAMPLE_PROJECT_ID_1,
+ SAMPLE_ROLE_1));
+
+ assertEquals(2, approvers.size());
+ assertIterableEquals(
+ List.of(SAMPLE_APPROVING_USER_1, SAMPLE_APPROVING_USER_2),
+ approvers);
+ }
+}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestProjectRoleActivator.java b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestProjectRoleActivator.java
new file mode 100644
index 000000000..c0ad6d74e
--- /dev/null
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/catalog/project/TestProjectRoleActivator.java
@@ -0,0 +1,160 @@
+//
+// 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.catalog.project;
+
+import com.google.solutions.jitaccess.core.ProjectId;
+import com.google.solutions.jitaccess.core.UserId;
+import com.google.solutions.jitaccess.core.catalog.EntitlementCatalog;
+import com.google.solutions.jitaccess.core.catalog.JustificationPolicy;
+import com.google.solutions.jitaccess.core.RoleBinding;
+import com.google.solutions.jitaccess.core.clients.IamTemporaryAccessConditions;
+import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+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 TestProjectRoleActivator {
+
+ private static final UserId SAMPLE_REQUESTING_USER = new UserId("user@example.com");
+ private static final UserId SAMPLE_APPROVING_USER = new UserId("approver@example.com");
+ private static final ProjectId SAMPLE_PROJECT = new ProjectId("project-1");
+ private static final String SAMPLE_ROLE_1 = "roles/resourcemanager.role1";
+ private static final String SAMPLE_ROLE_2 = "roles/resourcemanager.role2";
+
+ // -------------------------------------------------------------------------
+ // provisionAccess - JIT.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void provisionAccessForJitRequest() throws Exception {
+ var resourceManagerClient = Mockito.mock(ResourceManagerClient.class);
+ var activator = new ProjectRoleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ resourceManagerClient,
+ Mockito.mock(JustificationPolicy.class));
+
+ var request = activator.createJitRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(
+ new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE_1)),
+ new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE_2))),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ var activation = activator.activate(request);
+
+ assertNotNull(activation);
+ assertSame(request, activation.request());
+
+ verify(resourceManagerClient, times(2))
+ .addProjectIamBinding(
+ eq(SAMPLE_PROJECT),
+ argThat(b ->
+ IamTemporaryAccessConditions.isTemporaryAccessCondition(b.getCondition().getExpression()) &&
+ b.getCondition().getTitle().equals(JitConstraints.ACTIVATION_CONDITION_TITLE)),
+ eq(EnumSet.of(ResourceManagerClient.IamBindingOptions.PURGE_EXISTING_TEMPORARY_BINDINGS)),
+ eq("Self-approved, justification: justification"));
+ }
+
+ // -------------------------------------------------------------------------
+ // provisionAccess - MPA.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void provisionAccessForMpaRequest() throws Exception {
+ var resourceManagerClient = Mockito.mock(ResourceManagerClient.class);
+ var activator = new ProjectRoleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ resourceManagerClient,
+ Mockito.mock(JustificationPolicy.class));
+
+ var request = activator.createMpaRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE_1))),
+ Set.of(SAMPLE_APPROVING_USER),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ var activation = activator.approve(
+ SAMPLE_APPROVING_USER,
+ request);
+
+ assertNotNull(activation);
+ assertSame(request, activation.request());
+
+ verify(resourceManagerClient, times(1))
+ .addProjectIamBinding(
+ eq(SAMPLE_PROJECT),
+ argThat(b ->
+ IamTemporaryAccessConditions.isTemporaryAccessCondition(b.getCondition().getExpression()) &&
+ b.getCondition().getTitle().equals(JitConstraints.ACTIVATION_CONDITION_TITLE)),
+ eq(EnumSet.of(ResourceManagerClient.IamBindingOptions.PURGE_EXISTING_TEMPORARY_BINDINGS)),
+ eq("Approved by approver@example.com, justification: justification"));
+ }
+
+ // -------------------------------------------------------------------------
+ // createTokenConverter.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void createTokenConverter() throws Exception {
+ var activator = new ProjectRoleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ Mockito.mock(ResourceManagerClient.class),
+ Mockito.mock(JustificationPolicy.class));
+
+ var inputRequest = activator.createMpaRequest(
+ SAMPLE_REQUESTING_USER,
+ Set.of(new ProjectRoleBinding(new RoleBinding(SAMPLE_PROJECT, SAMPLE_ROLE_1))),
+ Set.of(SAMPLE_APPROVING_USER),
+ "justification",
+ Instant.now(),
+ Duration.ofMinutes(5));
+
+ var payload = activator
+ .createTokenConverter()
+ .convert(inputRequest);
+
+ var outputRequest = activator
+ .createTokenConverter()
+ .convert(payload);
+
+ assertEquals(inputRequest.requestingUser(), outputRequest.requestingUser());
+ assertIterableEquals(inputRequest.reviewers(), outputRequest.reviewers());
+ assertIterableEquals(inputRequest.entitlements(), outputRequest.entitlements());
+ assertEquals(inputRequest.justification(), outputRequest.justification());
+ assertEquals(inputRequest.startTime().getEpochSecond(), outputRequest.startTime().getEpochSecond());
+ assertEquals(inputRequest.endTime().getEpochSecond(), outputRequest.endTime().getEpochSecond());
+ }
+}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestPubSubClient.java b/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestPubSubClient.java
index d0fec3bda..540e507ef 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestPubSubClient.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestPubSubClient.java
@@ -29,7 +29,8 @@
import java.nio.charset.StandardCharsets;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
public class TestPubSubClient {
@Test
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestSecretManagerClient.java b/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestSecretManagerClient.java
index 5fc72ff17..0b64a5259 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestSecretManagerClient.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestSecretManagerClient.java
@@ -37,7 +37,7 @@
import java.io.IOException;
import java.security.GeneralSecurityException;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertThrows;
public class TestSecretManagerClient {
private static final String SECRET_NAME = "testsecret";
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestSmtpClient.java b/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestSmtpClient.java
index 43ba97720..779ab2a57 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestSmtpClient.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/clients/TestSmtpClient.java
@@ -26,7 +26,7 @@
import java.util.Map;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
public class TestSmtpClient {
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestProjectRole.java b/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestProjectRole.java
deleted file mode 100644
index 194faf41c..000000000
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestProjectRole.java
+++ /dev/null
@@ -1,163 +0,0 @@
-//
-// 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.entitlements;
-
-import com.google.solutions.jitaccess.core.ProjectId;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-import java.util.List;
-import java.util.TreeSet;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-public class TestProjectRole {
- private final String SAMPLE_PROJECT_FULLRESOURCENAME =
- "//cloudresourcemanager.googleapis.com/projects/project-1";
-
- // -------------------------------------------------------------------------
- // Constructor.
- // -------------------------------------------------------------------------
-
- @Test
- public void whenResourceIsNotAProject_ThenConstructorThrowsException() {
- assertThrows(
- IllegalArgumentException.class,
- () -> new ProjectRole(
- new RoleBinding("//cloudresourcemanager.googleapis.com/folders/folder-1", "role/sample"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT));
-
- }
-
- // -------------------------------------------------------------------------
- // toString.
- // -------------------------------------------------------------------------
-
- @Test
- public void toStringReturnsId() {
- var role = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/sample"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
- assertEquals(
- "//cloudresourcemanager.googleapis.com/projects/project-1:role/sample (ELIGIBLE_FOR_JIT)",
- role.toString());
- }
-
- // -------------------------------------------------------------------------
- // getProjectId.
- // -------------------------------------------------------------------------
-
- @Test
- public void getProjectIdReturnsUnqualifiedProjectId() {
- var role = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/sample"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
- Assertions.assertEquals(new ProjectId("project-1"), role.getProjectId());
- }
-
- // -------------------------------------------------------------------------
- // equals.
- // -------------------------------------------------------------------------
-
- @Test
- public void whenValueIsEquivalent_ThenEqualsReturnsTrue() {
- var role1 = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/sample"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
- var role2 = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/sample"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
-
- assertTrue(role1.equals(role2));
- assertTrue(role1.equals((Object) role2));
- assertEquals(role1.hashCode(), role2.hashCode());
- assertEquals(role1.toString(), role2.toString());
- }
-
- @Test
- public void whenObjectsAreSame_ThenEqualsReturnsTrue() {
- var role1 = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/sample"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
-
- assertTrue(role1.equals(role1));
- assertTrue(role1.equals((Object) role1));
- assertEquals(role1.hashCode(), role1.hashCode());
- }
-
- @Test
- public void whenRolesDiffer_ThenEqualsReturnsFalse() {
- var role1 = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/one"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
- var role2 = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/two"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
-
- assertFalse(role1.equals(role2));
- assertFalse(role1.equals((Object) role2));
- }
-
- @Test
- public void whenStatusesDiffer_ThenEqualsReturnsFalse() {
- var role1 = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/sample"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
- var role2 = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/sample"),
- ProjectRole.Status.ACTIVATED);
-
- assertFalse(role1.equals(role2));
- assertFalse(role1.equals((Object) role2));
- }
-
- @Test
- public void equalsNullIsFalse() {
- var role = new ProjectRole(
- new RoleBinding(SAMPLE_PROJECT_FULLRESOURCENAME, "role/sample"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
-
- assertFalse(role.equals(null));
- }
-
- @Test
- public void whenInTreeSet_ThenReturnsInExpectedOrder() {
- var role1 = new ProjectRole(
- new RoleBinding("//cloudresourcemanager.googleapis.com/projects/project-1", "role/sample1"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT
- );
- var role2 = new ProjectRole(
- new RoleBinding("//cloudresourcemanager.googleapis.com/projects/project-1", "role/sample2"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT
- );
- var role3 = new ProjectRole(
- new RoleBinding("//cloudresourcemanager.googleapis.com/projects/project-2", "role/sample1"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT
- );
- var roles = List.of(role3,role1,role2);
- var sorted = new TreeSet<>(roles);
- var sortedIter = sorted.iterator();
- assertEquals(sortedIter.next(), role1);
- assertEquals(sortedIter.next(), role2);
- assertEquals(sortedIter.next(), role3);
- }
-}
\ No newline at end of file
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestRoleActivationService.java b/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestRoleActivationService.java
deleted file mode 100644
index 7af75a9db..000000000
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestRoleActivationService.java
+++ /dev/null
@@ -1,777 +0,0 @@
-//
-// 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.entitlements;
-
-import com.google.solutions.jitaccess.core.AccessDeniedException;
-import com.google.solutions.jitaccess.core.AnnotatedResult;
-import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
-import com.google.solutions.jitaccess.core.ProjectId;
-import com.google.solutions.jitaccess.core.UserId;
-import org.junit.jupiter.api.Test;
-import org.mockito.Mockito;
-
-import java.time.Duration;
-import java.time.Instant;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Set;
-import java.util.regex.Pattern;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.*;
-
-public class TestRoleActivationService {
- private static final UserId SAMPLE_USER = new UserId("user-1", "user-1@example.com");
- private static final UserId SAMPLE_USER_2 = new UserId("user-2", "user-2@example.com");
- private static final UserId SAMPLE_USER_3 = new UserId("user-2", "user-3@example.com");
- private static final ProjectId SAMPLE_PROJECT_ID = new ProjectId("project-1");
- private static final String SAMPLE_PROJECT_RESOURCE_1 = "//cloudresourcemanager.googleapis.com/projects/project-1";
- private static final String SAMPLE_ROLE = "roles/resourcemanager.projectIamAdmin";
- private static final Pattern DEFAULT_JUSTIFICATION_PATTERN = Pattern.compile(".*");
- private static final int DEFAULT_MIN_NUMBER_OF_REVIEWERS = 1;
- private static final int DEFAULT_MAX_NUMBER_OF_REVIEWERS = 10;
- private static Duration DEFAULT_ACTIVATION_TIMEOUT = Duration.ofMinutes(10);
-
- // ---------------------------------------------------------------------
- // activateProjectRoleForSelf.
- // ---------------------------------------------------------------------
-
- @Test
- public void whenResourceIsNotAProject_ThenActivateProjectRoleForSelfThrowsException() {
- var resourceAdapter = Mockito.mock(ResourceManagerClient.class);
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
-
- var service = new RoleActivationService(
- discoveryService,
- resourceAdapter,
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(IllegalArgumentException.class,
- () -> service.activateProjectRoleForSelf(
- SAMPLE_USER,
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1 + "/foo/bar",
- SAMPLE_ROLE),
- "justification",
- DEFAULT_ACTIVATION_TIMEOUT));
- }
-
- @Test
- public void whenCallerLacksRoleBinding_ThenActivateProjectRoleForSelfThrowsException() throws Exception {
- var resourceAdapter = Mockito.mock(ResourceManagerClient.class);
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
-
- var caller = SAMPLE_USER;
-
- when(discoveryService.listEligibleProjectRoles(eq(caller), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- "roles/compute.viewer"), // Different role
- ProjectRole.Status.ELIGIBLE_FOR_JIT)),
- Set.of()));
-
- var service = new RoleActivationService(
- discoveryService,
- resourceAdapter,
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(AccessDeniedException.class,
- () -> service.activateProjectRoleForSelf(
- caller,
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- DEFAULT_ACTIVATION_TIMEOUT));
- }
-
- @Test
- public void whenJustificationDoesNotMatch_ThenActivateProjectRoleForSelfThrowsException() {
- var service = new RoleActivationService(
- Mockito.mock(RoleDiscoveryService.class),
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- Pattern.compile("^\\d+$"),
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(AccessDeniedException.class,
- () -> service.activateProjectRoleForSelf(
- SAMPLE_USER,
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "not-numeric",
- DEFAULT_ACTIVATION_TIMEOUT));
- }
-
- @Test
- public void whenActivationTimeoutExceedsMax_ThenActivateProjectRoleForSelfThrowsException() {
- var service = new RoleActivationService(
- Mockito.mock(RoleDiscoveryService.class),
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- Duration.ofMinutes(120),
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(IllegalArgumentException.class,
- () -> service.activateProjectRoleForSelf(
- SAMPLE_USER,
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- Duration.ofMinutes(121)));
- }
-
- @Test
- public void whenCallerIsJitEligible_ThenActivateProjectRoleForSelfAddsBinding() throws Exception {
- var resourceAdapter = Mockito.mock(ResourceManagerClient.class);
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
-
- var caller = SAMPLE_USER;
-
- when(discoveryService.listEligibleProjectRoles(eq(caller), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- ProjectRole.Status.ELIGIBLE_FOR_JIT)),
- Set.of()));
-
- var service = new RoleActivationService(
- discoveryService,
- resourceAdapter,
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- var roleBinding = new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE);
- var activationTimeout = Duration.ofMinutes(5);
- var activation = service.activateProjectRoleForSelf(
- caller,
- roleBinding,
- "justification",
- activationTimeout);
-
- assertTrue(activation.id.toString().startsWith("jit-"));
- assertEquals(activation.projectRole.roleBinding(), roleBinding);
- assertEquals(ProjectRole.Status.ACTIVATED, activation.projectRole.status());
- assertTrue(activation.endTime.isAfter(activation.startTime));
- assertTrue(activation.endTime.isAfter(Instant.now().minusSeconds(60)));
- assertTrue(activation.endTime.isBefore(Instant.now().plus(activationTimeout).plusSeconds(60)));
-
- verify(resourceAdapter)
- .addProjectIamBinding(
- eq(SAMPLE_PROJECT_ID),
- argThat(b -> b.getRole().equals(SAMPLE_ROLE)
- && b.getCondition().getExpression().contains("request.time < timestamp")
- && b.getCondition().getDescription().contains("justification")),
- eq(EnumSet.of(ResourceManagerClient.IamBindingOptions.PURGE_EXISTING_TEMPORARY_BINDINGS)),
- eq("justification"));
- }
-
- // ---------------------------------------------------------------------
- // activateProjectRoleForPeer.
- // ---------------------------------------------------------------------
-
- @Test
- public void whenCallerSameAsBeneficiary_ThenActivateProjectRoleForPeerThrowsException() {
- var service = new RoleActivationService(
- Mockito.mock(RoleDiscoveryService.class),
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- SAMPLE_USER,
- Set.of(SAMPLE_USER),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
-
- assertThrows(IllegalArgumentException.class,
- () -> service.activateProjectRoleForPeer(request.beneficiary, request));
- }
-
- @Test
- public void whenCallerNotListedAsReviewer_ThenActivateProjectRoleForPeerThrowsException() {
- var service = new RoleActivationService(
- Mockito.mock(RoleDiscoveryService.class),
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
-
- assertThrows(AccessDeniedException.class,
- () -> service.activateProjectRoleForPeer(SAMPLE_USER_3, request));
- }
-
- @Test
- public void whenRoleNotMpaEligibleForCaller_ThenActivateProjectRoleForPeerThrowsException() throws Exception {
- var caller = SAMPLE_USER;
- var peer = SAMPLE_USER_2;
- var roleBinding = new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE);
-
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
- when(discoveryService.listEligibleProjectRoles(eq(caller), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ACTIVATED)),
- Set.of()));
-
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- peer,
- Set.of(caller),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
-
- var service = new RoleActivationService(
- discoveryService,
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(AccessDeniedException.class,
- () -> service.activateProjectRoleForPeer(caller, request));
- }
-
- @Test
- public void whenRoleIsJitEligibleForCaller_ThenActivateProjectRoleForPeerThrowsException() throws Exception {
- var caller = SAMPLE_USER;
- var peer = SAMPLE_USER_2;
- var roleBinding = new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE);
-
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
- when(discoveryService.listEligibleProjectRoles(eq(caller), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ELIGIBLE_FOR_JIT)),
- Set.of()));
-
- var service = new RoleActivationService(
- discoveryService,
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- peer,
- Set.of(caller),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
-
- assertThrows(AccessDeniedException.class,
- () -> service.activateProjectRoleForPeer(caller, request));
- }
-
- @Test
- public void whenRoleNotEligibleForPeer_ThenActivateProjectRoleForPeerThrowsException() throws Exception {
- var caller = SAMPLE_USER;
- var peer = SAMPLE_USER_2;
- var roleBinding = new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE);
-
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
- when(discoveryService.listEligibleProjectRoles(eq(caller), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ELIGIBLE_FOR_MPA)),
- Set.of()));
- when(discoveryService.listEligibleProjectRoles(eq(peer), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(),
- Set.of()));
-
- var service = new RoleActivationService(
- discoveryService,
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- peer,
- Set.of(caller),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
-
- assertThrows(AccessDeniedException.class,
- () -> service.activateProjectRoleForPeer(caller, request));
- }
-
- @Test
- public void whenPeerAndCallerEligible_ThenActivateProjectRoleAddsBinding() throws Exception {
- var caller = SAMPLE_USER;
- var peer = SAMPLE_USER_2;
- var roleBinding = new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE);
-
- var resourceAdapter = Mockito.mock(ResourceManagerClient.class);
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
- when(discoveryService.listEligibleProjectRoles(
- eq(caller),
- eq(SAMPLE_PROJECT_ID),
- eq(EnumSet.of(
- ProjectRole.Status.ELIGIBLE_FOR_JIT,
- ProjectRole.Status.ELIGIBLE_FOR_MPA))))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ELIGIBLE_FOR_MPA)),
- Set.of()));
- when(discoveryService.listEligibleProjectRoles(
- eq(peer),
- eq(SAMPLE_PROJECT_ID),
- eq(EnumSet.of(
- ProjectRole.Status.ELIGIBLE_FOR_JIT,
- ProjectRole.Status.ELIGIBLE_FOR_MPA))))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ELIGIBLE_FOR_MPA)),
- Set.of()));
-
- var service = new RoleActivationService(
- discoveryService,
- resourceAdapter,
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- var issuedAt = 1000L;
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- peer,
- Set.of(caller),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- Instant.ofEpochSecond(issuedAt),
- Instant.ofEpochSecond(issuedAt).plusSeconds(60));
-
- var activation = service.activateProjectRoleForPeer(caller, request);
-
- assertNotNull(activation);
- assertEquals(request.id, activation.id);
- assertEquals(ProjectRole.Status.ACTIVATED, activation.projectRole.status());
- assertEquals(roleBinding, activation.projectRole.roleBinding());
- assertEquals(request.startTime, activation.startTime);
- assertEquals(request.endTime, activation.endTime);
-
- verify(resourceAdapter)
- .addProjectIamBinding(
- eq(SAMPLE_PROJECT_ID),
- argThat(b -> b.getRole().equals(SAMPLE_ROLE)
- && b.getCondition().getExpression().contains("request.time < timestamp")
- && b.getCondition().getDescription().contains("justification")),
- eq(EnumSet.of(
- ResourceManagerClient.IamBindingOptions.PURGE_EXISTING_TEMPORARY_BINDINGS,
- ResourceManagerClient.IamBindingOptions.FAIL_IF_BINDING_EXISTS)),
- eq("justification"));
- }
-
- @Test
- public void whenRoleAlreadyActivated_ThenActivateProjectRoleAddsBinding() throws Exception {
- var caller = SAMPLE_USER;
- var peer = SAMPLE_USER_2;
- var roleBinding = new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE);
-
- var resourceAdapter = Mockito.mock(ResourceManagerClient.class);
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
- when(discoveryService.listEligibleProjectRoles(eq(caller), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ELIGIBLE_FOR_MPA)),
- Set.of()));
- when(discoveryService.listEligibleProjectRoles(eq(peer), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ACTIVATED)), // Pretend someone else approved already
- Set.of()));
-
- var service = new RoleActivationService(
- discoveryService,
- resourceAdapter,
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- var issuedAt = 1000L;
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- peer,
- Set.of(caller),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- Instant.ofEpochSecond(issuedAt),
- Instant.ofEpochSecond(issuedAt).plusSeconds(60));
-
- assertThrows(AccessDeniedException.class,
- () -> service.activateProjectRoleForPeer(caller, request));
-
- verify(resourceAdapter, times(0))
- .addProjectIamBinding(
- eq(SAMPLE_PROJECT_ID),
- any(),
- any(),
- any());
- }
-
- // ---------------------------------------------------------------------
- // createActivationRequestForPeer.
- // ---------------------------------------------------------------------
-
- @Test
- public void whenNumberOfReviewersTooLow_ThenCreateActivationRequestForPeerThrowsException() {
- var service = new RoleActivationService(
- Mockito.mock(RoleDiscoveryService.class),
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- 3,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(IllegalArgumentException.class,
- () -> service.createActivationRequestForPeer(
- SAMPLE_USER,
- Set.of(SAMPLE_USER, SAMPLE_USER_2),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- DEFAULT_ACTIVATION_TIMEOUT));
- }
-
- @Test
- public void whenNumberOfReviewersTooHigh_ThenCreateActivationRequestForPeerThrowsException() {
- var service = new RoleActivationService(
- Mockito.mock(RoleDiscoveryService.class),
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- 2));
-
- assertThrows(IllegalArgumentException.class,
- () -> service.createActivationRequestForPeer(
- SAMPLE_USER,
- Set.of(SAMPLE_USER, SAMPLE_USER_2, SAMPLE_USER_3),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- DEFAULT_ACTIVATION_TIMEOUT));
- }
-
- @Test
- public void whenReviewerIncludesBeneficiary_ThenCreateActivationRequestForPeerThrowsException() {
- var service = new RoleActivationService(
- Mockito.mock(RoleDiscoveryService.class),
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(IllegalArgumentException.class,
- () -> service.createActivationRequestForPeer(
- SAMPLE_USER,
- Set.of(SAMPLE_USER),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- DEFAULT_ACTIVATION_TIMEOUT));
- }
-
- @Test
- public void whenJustificationDoesNotMatch_ThenCreateActivationRequestForPeerThrowsException() {
- var service = new RoleActivationService(
- Mockito.mock(RoleDiscoveryService.class),
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- Pattern.compile("^\\d+$"),
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(AccessDeniedException.class,
- () -> service.createActivationRequestForPeer(
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "non-numeric justification",
- DEFAULT_ACTIVATION_TIMEOUT));
- }
-
- @Test
- public void whenActivationTimeoutExceedsMax_ThenCreateActivationRequestForPeerThrowsException() throws Exception {
- var service = new RoleActivationService(
- Mockito.mock(RoleDiscoveryService.class),
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- Duration.ofMinutes(60),
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(IllegalArgumentException.class,
- () -> service.createActivationRequestForPeer(
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- "justification",
- Duration.ofMinutes(61)));
- }
-
- @Test
- public void whenRoleNotMpaEligibleForCaller_ThenCreateActivationRequestForPeerThrowsException() throws Exception {
- var caller = SAMPLE_USER;
- var peer = SAMPLE_USER_2;
- var roleBinding = new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE);
-
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
- when(discoveryService.listEligibleProjectRoles(eq(caller), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ACTIVATED)),
- Set.of()));
-
- var service = new RoleActivationService(
- discoveryService,
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(AccessDeniedException.class,
- () -> service.createActivationRequestForPeer(
- caller,
- Set.of(peer),
- roleBinding,
- "justification",
- DEFAULT_ACTIVATION_TIMEOUT));
- }
-
- @Test
- public void whenRoleIsJitEligibleForCaller_ThenCreateActivationRequestForPeerThrowsException() throws Exception {
- var caller = SAMPLE_USER;
- var peer = SAMPLE_USER_2;
- var roleBinding = new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE);
-
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
- when(discoveryService.listEligibleProjectRoles(eq(caller), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ELIGIBLE_FOR_JIT)),
- Set.of()));
-
- var service = new RoleActivationService(
- discoveryService,
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- assertThrows(AccessDeniedException.class,
- () -> service.createActivationRequestForPeer(
- caller,
- Set.of(peer),
- roleBinding,
- "justification",
- DEFAULT_ACTIVATION_TIMEOUT));
- }
-
- @Test
- public void whenCallerEligible_ThenCreateActivationRequestForPeerSucceeds() throws Exception {
- var caller = SAMPLE_USER;
- var peer = SAMPLE_USER_2;
- var roleBinding = new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE);
-
- var discoveryService = Mockito.mock(RoleDiscoveryService.class);
- when(discoveryService.listEligibleProjectRoles(eq(caller), eq(SAMPLE_PROJECT_ID), any()))
- .thenReturn(new AnnotatedResult(
- List.of(new ProjectRole(
- roleBinding,
- ProjectRole.Status.ELIGIBLE_FOR_MPA)),
- Set.of()));
-
- var service = new RoleActivationService(
- discoveryService,
- Mockito.mock(ResourceManagerClient.class),
- new RoleActivationService.Options(
- "hint",
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_TIMEOUT,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
-
- var request = service.createActivationRequestForPeer(
- caller,
- Set.of(peer),
- roleBinding,
- "justification",
- DEFAULT_ACTIVATION_TIMEOUT);
-
- assertTrue(request.id.toString().startsWith("mpa-"));
- assertEquals("justification", request.justification);
- assertEquals(Set.of(peer), request.reviewers);
- assertEquals(caller, request.beneficiary);
- assertEquals(roleBinding, request.roleBinding);
- }
-
- // ---------------------------------------------------------------------
- // ActivationId.
- // ---------------------------------------------------------------------
-
- @Test
- public void whenTypeIsMpa_ThenNewActivationIdUsesPrefix() {
- var id = RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA);
- assertTrue(id.toString().startsWith("mpa-"));
- }
-
- @Test
- public void whenTypeIsJit_ThenNewActivationIdUsesPrefix() {
- var id = RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.JIT);
- assertTrue(id.toString().startsWith("jit-"));
- }
-}
\ No newline at end of file
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestRoleDiscoveryService.java b/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestRoleDiscoveryService.java
deleted file mode 100644
index 0a509994e..000000000
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/entitlements/TestRoleDiscoveryService.java
+++ /dev/null
@@ -1,1133 +0,0 @@
-//
-// Copyright 2021 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.entitlements;
-
-import com.google.api.services.cloudasset.v1.model.*;
-import com.google.solutions.jitaccess.core.AccessDeniedException;
-import com.google.solutions.jitaccess.core.clients.AssetInventoryClient;
-import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
-import com.google.solutions.jitaccess.core.ProjectId;
-import com.google.solutions.jitaccess.core.UserId;
-import org.junit.jupiter.api.Test;
-import org.mockito.Mockito;
-
-import java.util.*;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.eq;
-import static org.mockito.Mockito.when;
-
-public class TestRoleDiscoveryService {
- private static final UserId SAMPLE_USER = new UserId("user-1", "user-1@example.com");
- private static final UserId SAMPLE_USER_2 = new UserId("user-2", "user-2@example.com");
- private static final UserId SAMPLE_USER_3 = new UserId("user-2", "user-3@example.com");
- private static final ProjectId SAMPLE_PROJECT_ID_1 = new ProjectId("project-1");
- private static final ProjectId SAMPLE_PROJECT_ID_2 = new ProjectId("project-2");
- private static final String SAMPLE_PROJECT_RESOURCE_1 = "//cloudresourcemanager.googleapis.com/projects/project-1";
- private static final String SAMPLE_PROJECT_RESOURCE_2 = "//cloudresourcemanager.googleapis.com/projects/project-2";
- private static final String SAMPLE_ROLE = "roles/resourcemanager.role1";
- private static final String SAMPLE_ROLE_2 = "roles/resourcemanager.role2";
- private static final String JIT_CONDITION = "has({}.jitAccessConstraint)";
- private static final String MPA_CONDITION = "has({}.multiPartyApprovalConstraint)";
-
- private static IamPolicyAnalysisResult createIamPolicyAnalysisResult(
- String resource,
- String role,
- UserId user
- ) {
- return new IamPolicyAnalysisResult()
- .setAttachedResourceFullName(resource)
- .setAccessControlLists(List.of(new GoogleCloudAssetV1AccessControlList()
- .setResources(List.of(new GoogleCloudAssetV1Resource()
- .setFullResourceName(resource)))))
- .setIamBinding(new Binding()
- .setMembers(List.of("user:" + user))
- .setRole(role));
- }
-
- private static IamPolicyAnalysisResult createConditionalIamPolicyAnalysisResult(
- String resource,
- String role,
- UserId user,
- String condition,
- String conditionTitle,
- String evaluationResult
- ) {
- return new IamPolicyAnalysisResult()
- .setAttachedResourceFullName(resource)
- .setAccessControlLists(List.of(new GoogleCloudAssetV1AccessControlList()
- .setResources(List.of(new GoogleCloudAssetV1Resource()
- .setFullResourceName(resource)))
- .setConditionEvaluation(new ConditionEvaluation()
- .setEvaluationValue(evaluationResult))))
- .setIamBinding(new Binding()
- .setMembers(List.of("user:" + user))
- .setRole(role)
- .setCondition(new Expr()
- .setTitle(conditionTitle)
- .setExpression(condition)))
- .setIdentityList(new GoogleCloudAssetV1IdentityList()
- .setIdentities(List.of(
- new GoogleCloudAssetV1Identity().setName("user:" + user.email),
- new GoogleCloudAssetV1Identity().setName("serviceAccount:ignoreme@x.iam.gserviceaccount.com"),
- new GoogleCloudAssetV1Identity().setName("group:ignoreme@example.com"))));
- }
-
- // ---------------------------------------------------------------------
- // listAvailableProjects.
- // ---------------------------------------------------------------------
-
- @Test
- public void whenAnalysisResultEmpty_ThenListAvailableProjectsReturnsEmptyList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.of("resourcemanager.projects.get")),
- eq(Optional.empty()),
- eq(true)))
- .thenReturn(new IamPolicyAnalysis());
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var projectIds = service.listAvailableProjects(SAMPLE_USER);
- assertNotNull(projectIds);
- assertEquals(0, projectIds.size());
- }
-
- @Test
- public void whenAnalysisResultContainsAcsWithUnrecognizedConditions_ThenListAvailableProjectsReturnsEmptyList()
- throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.of("resourcemanager.projects.get")),
- eq(Optional.empty()),
- eq(true)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- "a==b",
- "unrecognized condition",
- "TRUE"))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var projectIds = service.listAvailableProjects(SAMPLE_USER);
- assertNotNull(projectIds);
- assertEquals(0, projectIds.size());
- }
-
- @Test
- public void whenAnalysisContainsPermanentBinding_ThenListAvailableProjectsReturnsProjectId()
- throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.of("resourcemanager.projects.get")),
- eq(Optional.empty()),
- eq(true)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- createIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var projectIds = service.listAvailableProjects(SAMPLE_USER);
- assertNotNull(projectIds);
- assertEquals(1, projectIds.size());
- assertTrue(projectIds.contains(SAMPLE_PROJECT_ID_1));
- }
-
- @Test
- public void whenAnalysisContainsEligibleBindings_ThenListAvailableProjectsReturnsProjectIds()
- throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.of("resourcemanager.projects.get")),
- eq(Optional.empty()),
- eq(true)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "eligible binding",
- "CONDITIONAL"),
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_2,
- SAMPLE_ROLE,
- SAMPLE_USER,
- MPA_CONDITION,
- "eligible binding",
- "CONDITIONAL"))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var projectIds = service.listAvailableProjects(SAMPLE_USER);
- assertNotNull(projectIds);
- assertEquals(2, projectIds.size());
- assertTrue(projectIds.contains(SAMPLE_PROJECT_ID_1));
- assertTrue(projectIds.contains(SAMPLE_PROJECT_ID_2));
- }
-
- // ---------------------------------------------------------------------
- // listEligibleProjectRoles.
- // ---------------------------------------------------------------------
-
- @Test
- public void whenAnalysisResultEmpty_ThenListEligibleProjectRolesReturnsEmptyList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis());
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(0, roles.getItems().size());
- }
-
- @Test
- public void whenAnalysisResultContainsEmptyAcl_ThenListEligibleProjectRolesReturnsEmptyList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- new IamPolicyAnalysisResult().setAttachedResourceFullName(SAMPLE_PROJECT_RESOURCE_1))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(0, roles.getItems().size());
- }
-
- @Test
- public void whenAnalysisContainsNoEligibleRoles_ThenListEligibleProjectRolesReturnsEmptyList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- createIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(0, roles.getItems().size());
- }
-
- @Test
- public void whenAnalysisContainsJitEligibleBinding_ThenListEligibleProjectRolesReturnsList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "eligible binding",
- "CONDITIONAL"))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(1, roles.getItems().size());
-
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(SAMPLE_ROLE, role.roleBinding().role());
- assertEquals(ProjectRole.Status.ELIGIBLE_FOR_JIT, role.status());
- }
-
- @Test
- public void whenAnalysisContainsDuplicateJitEligibleBinding_ThenListEligibleProjectRolesReturnsList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "eligible binding #1",
- "CONDITIONAL"),
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "eligible binding #2",
- "CONDITIONAL"))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(1, roles.getItems().size());
-
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(SAMPLE_ROLE, role.roleBinding().role());
- assertEquals(ProjectRole.Status.ELIGIBLE_FOR_JIT, role.status());
- }
-
- @Test
- public void whenAnalysisContainsMpaEligibleBinding_ThenListEligibleProjectRolesReturnsList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- MPA_CONDITION,
- "eligible binding",
- "CONDITIONAL"))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(1, roles.getItems().size());
-
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(SAMPLE_ROLE, role.roleBinding().role());
- assertEquals(ProjectRole.Status.ELIGIBLE_FOR_MPA, role.status());
- }
-
- @Test
- public void whenAnalysisContainsDuplicateMpaEligibleBinding_ThenListEligibleProjectRolesReturnsList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- MPA_CONDITION,
- "eligible binding # 1",
- "CONDITIONAL"),
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- MPA_CONDITION,
- "eligible binding # 2",
- "CONDITIONAL"))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(1, roles.getItems().size());
-
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(SAMPLE_ROLE, role.roleBinding().role());
- assertEquals(ProjectRole.Status.ELIGIBLE_FOR_MPA, role.status());
- }
-
- @Test
- public void whenAnalysisContainsMpaEligibleBindingAndJitEligibleBindingForDifferentRoles_ThenListEligibleProjectRolesReturnsList()
- throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- var jitEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "JIT-eligible binding",
- "CONDITIONAL");
-
- var mpaEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE_2,
- SAMPLE_USER,
- MPA_CONDITION,
- "MPA-eligible binding",
- "CONDITIONAL");
-
- when(assetAdapter.findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(jitEligibleBinding, mpaEligibleBinding)));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(2, roles.getItems().size());
-
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(SAMPLE_ROLE, role.roleBinding().role());
- assertEquals(ProjectRole.Status.ELIGIBLE_FOR_JIT, role.status());
-
- role = roles.getItems().stream().skip(1).findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(SAMPLE_ROLE_2, role.roleBinding().role());
- assertEquals(ProjectRole.Status.ELIGIBLE_FOR_MPA, role.status());
- }
-
- @Test
- public void whenAnalysisContainsMpaEligibleBindingAndJitEligibleBindingForSameRole_ThenListEligibleProjectRolesReturnsList()
- throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- var jitEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "JIT-eligible binding",
- "CONDITIONAL");
-
- var mpaEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- MPA_CONDITION,
- "MPA-eligible binding",
- "CONDITIONAL");
-
- when(assetAdapter.findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(jitEligibleBinding, mpaEligibleBinding)));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(1, roles.getItems().size());
-
- // Only the JIT-eligible binding is retained.
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(SAMPLE_ROLE, role.roleBinding().role());
- assertEquals(ProjectRole.Status.ELIGIBLE_FOR_JIT, role.status());
- }
-
- @Test
- public void whenAnalysisContainsActivatedBinding_ThenListEligibleProjectRolesReturnsMergedList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- var eligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "eligible binding",
- "CONDITIONAL");
-
- var activatedBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- "time ...",
- JitConstraints.ACTIVATION_CONDITION_TITLE,
- "TRUE");
-
- var activatedExpiredBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- "time ...",
- JitConstraints.ACTIVATION_CONDITION_TITLE,
- "FALSE");
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- eligibleBinding,
- activatedBinding,
- activatedExpiredBinding
- )));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(1, roles.getItems().size());
-
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(SAMPLE_ROLE, role.roleBinding().role());
- assertEquals(ProjectRole.Status.ACTIVATED, role.status());
- }
-
- @Test
- public void whenAnalysisContainsEligibleBindingWithExtraCondition_ThenBindingIsIgnored()
- throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION + " && resource.name=='Foo'",
- "eligible binding with extra junk",
- "CONDITIONAL"))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(0, roles.getItems().size());
- }
-
- @Test
- public void whenAnalysisContainsInheritedEligibleBinding_ThenListEligibleProjectRolesAsyncReturnsList()
- throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- var parentFolderAcl = new GoogleCloudAssetV1AccessControlList()
- .setResources(List.of(new GoogleCloudAssetV1Resource()
- .setFullResourceName("//cloudresourcemanager.googleapis.com/folders/folder-1")))
- .setConditionEvaluation(new ConditionEvaluation()
- .setEvaluationValue("CONDITIONAL"));
-
- var childFolderAndProjectAcl = new GoogleCloudAssetV1AccessControlList()
- .setResources(List.of(
- new GoogleCloudAssetV1Resource()
- .setFullResourceName("//cloudresourcemanager.googleapis.com/folders/folder-1"),
- new GoogleCloudAssetV1Resource()
- .setFullResourceName(SAMPLE_PROJECT_RESOURCE_1),
- new GoogleCloudAssetV1Resource()
- .setFullResourceName(SAMPLE_PROJECT_RESOURCE_2)))
- .setConditionEvaluation(new ConditionEvaluation()
- .setEvaluationValue("CONDITIONAL"));
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(new IamPolicyAnalysisResult()
- .setAttachedResourceFullName("//cloudresourcemanager.googleapis.com/folders/folder-1")
- .setAccessControlLists(List.of(
- parentFolderAcl,
- childFolderAndProjectAcl))
- .setIamBinding(new Binding()
- .setMembers(List.of("user:" + SAMPLE_USER))
- .setRole(SAMPLE_ROLE)
- .setCondition(new Expr().setExpression(JIT_CONDITION))))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(SAMPLE_USER, SAMPLE_PROJECT_ID_1);
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(2, roles.getItems().size());
-
- assertEquals(
- new ProjectRole(
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE),
- ProjectRole.Status.ELIGIBLE_FOR_JIT),
- roles.getItems().get(0));
-
- assertEquals(
- new ProjectRole(
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_2,
- SAMPLE_ROLE),
- ProjectRole.Status.ELIGIBLE_FOR_JIT),
- roles.getItems().get(1));
- }
-
- @Test
- public void whenStatusSetToJitOnly_ThenListEligibleProjectRolesOnlyReturnsJitEligibleBindings() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- var jitEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "eligible binding",
- "CONDITIONAL");
-
- var mpaEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE_2,
- SAMPLE_USER,
- MPA_CONDITION,
- "MPA-eligible binding",
- "CONDITIONAL");
-
- var activatedBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- "time ...",
- JitConstraints.ACTIVATION_CONDITION_TITLE,
- "TRUE");
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- jitEligibleBinding,
- mpaEligibleBinding,
- activatedBinding)));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(
- SAMPLE_USER,
- SAMPLE_PROJECT_ID_1,
- EnumSet.of(ProjectRole.Status.ELIGIBLE_FOR_JIT));
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(1, roles.getItems().size());
-
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(ProjectRole.Status.ELIGIBLE_FOR_JIT, role.status());
- }
-
- @Test
- public void whenStatusSetToMpaOnly_ThenListEligibleProjectRolesOnlyReturnsMpaEligibleBindings() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- var jitEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "eligible binding",
- "CONDITIONAL");
-
- var mpaEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE_2,
- SAMPLE_USER,
- MPA_CONDITION,
- "MPA-eligible binding",
- "CONDITIONAL");
-
- var activatedBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- "time ...",
- JitConstraints.ACTIVATION_CONDITION_TITLE,
- "TRUE");
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- jitEligibleBinding,
- mpaEligibleBinding,
- activatedBinding)));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(
- SAMPLE_USER,
- SAMPLE_PROJECT_ID_1,
- EnumSet.of(ProjectRole.Status.ELIGIBLE_FOR_MPA));
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(1, roles.getItems().size());
-
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(ProjectRole.Status.ELIGIBLE_FOR_MPA, role.status());
- }
-
- @Test
- public void whenStatusSetToActivatedOnly_ThenListEligibleProjectRolesOnlyReturnsActivatedBindings() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- var jitEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- JIT_CONDITION,
- "eligible binding",
- "CONDITIONAL");
-
- var mpaEligibleBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE_2,
- SAMPLE_USER,
- MPA_CONDITION,
- "MPA-eligible binding",
- "CONDITIONAL");
-
- var activatedBinding = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- "time ...",
- JitConstraints.ACTIVATION_CONDITION_TITLE,
- "TRUE");
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis()
- .setAnalysisResults(List.of(
- jitEligibleBinding,
- mpaEligibleBinding,
- activatedBinding)));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var roles = service.listEligibleProjectRoles(
- SAMPLE_USER,
- SAMPLE_PROJECT_ID_1,
- EnumSet.of(ProjectRole.Status.ACTIVATED));
-
- assertNotNull(roles.getWarnings());
- assertEquals(0, roles.getWarnings().size());
-
- assertNotNull(roles.getItems());
- assertEquals(1, roles.getItems().size());
-
- var role = roles.getItems().stream().findFirst().get();
- assertEquals(SAMPLE_PROJECT_ID_1, role.getProjectId());
- assertEquals(ProjectRole.Status.ACTIVATED, role.status());
- }
-
- // ---------------------------------------------------------------------
- // listEligibleUsersForProjectRole.
- // ---------------------------------------------------------------------
-
- @Test
- public void whenRoleIsNotEligible_ThenListEligibleUsersForProjectRoleThrowsException() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(
- new IamPolicyAnalysis()
- .setAnalysisResults(List.of(new IamPolicyAnalysisResult()
- .setAttachedResourceFullName(SAMPLE_PROJECT_RESOURCE_1))));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- assertThrows(
- AccessDeniedException.class,
- () -> service.listEligibleUsersForProjectRole(
- SAMPLE_USER,
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE)));
- }
-
- @Test
- public void whenCallerIsOnlyMpaEligibleUser_ThenListEligibleUsersForProjectRoleReturnsEmptyList()
- throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- var mpaBindingResult = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- MPA_CONDITION,
- "eligible binding",
- "CONDITIONAL");
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis().setAnalysisResults(List.of(mpaBindingResult)));
- when(assetAdapter.findPermissionedPrincipalsByResource(anyString(), anyString(), anyString()))
- .thenReturn(new IamPolicyAnalysis().setAnalysisResults(List.of(mpaBindingResult)));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var approvers = service.listEligibleUsersForProjectRole(
- SAMPLE_USER,
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE));
-
- assertTrue(approvers.isEmpty());
- }
-
- @Test
- public void whenMpaEligibleUsersIncludesOtherUser_ThenListEligibleUsersForProjectRoleReturnsList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- var mpaBindingResult = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER,
- MPA_CONDITION,
- "eligible binding",
- "CONDITIONAL");
- var mpaBindingResultForOtherUser = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER_2,
- MPA_CONDITION,
- "eligible binding",
- "CONDITIONAL");
- var jitBindingResultForOtherUser = createConditionalIamPolicyAnalysisResult(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE,
- SAMPLE_USER_3,
- JIT_CONDITION,
- "eligible binding",
- "CONDITIONAL");
-
- when(assetAdapter
- .findAccessibleResourcesByUser(
- anyString(),
- eq(SAMPLE_USER),
- eq(Optional.empty()),
- eq(Optional.of(SAMPLE_PROJECT_RESOURCE_1)),
- eq(false)))
- .thenReturn(new IamPolicyAnalysis().setAnalysisResults(List.of(mpaBindingResult)));
- when(assetAdapter.findPermissionedPrincipalsByResource(anyString(), anyString(), anyString()))
- .thenReturn(new IamPolicyAnalysis().setAnalysisResults(List.of(
- mpaBindingResult,
- mpaBindingResultForOtherUser,
- jitBindingResultForOtherUser)));
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", null));
-
- var approvers = service.listEligibleUsersForProjectRole(
- SAMPLE_USER,
- new RoleBinding(
- SAMPLE_PROJECT_RESOURCE_1,
- SAMPLE_ROLE));
-
- assertEquals(1, approvers.size());
- assertEquals(SAMPLE_USER_2, approvers.stream().findFirst().get());
- }
-
- @Test
- public void whenResourceManagerEmpty_ThenListAvailableProjectsReturnsEmptyList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
-
- when(resourceManagerAdapter.searchProjectIds(eq("parent:folder/0"))).thenReturn(Set.of());
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", "parent:folder/0"));
-
- var projectIds = service.listAvailableProjects(SAMPLE_USER);
- assertNotNull(projectIds);
- assertEquals(0, projectIds.size());
- }
-
- @Test
- public void whenResourceManagerReturnsList_ThenListAvailableProjectsReturnsTheSameList() throws Exception {
- var assetAdapter = Mockito.mock(AssetInventoryClient.class);
- var resourceManagerAdapter = Mockito.mock(ResourceManagerClient.class);
- var expectedProjectIds = Set.of(new ProjectId("project-1"), new ProjectId("project-2"));
-
- when(resourceManagerAdapter.searchProjectIds(eq("parent:folder/0")))
- .thenReturn(expectedProjectIds);
-
- var service = new RoleDiscoveryService(
- assetAdapter,
- resourceManagerAdapter,
- new RoleDiscoveryService.Options("organizations/0", "parent:folder/0"));
-
- var projectIds = service.listAvailableProjects(SAMPLE_USER);
- assertNotNull(projectIds);
- assertEquals(expectedProjectIds.size(), projectIds.size());
- assertIterableEquals(new HashSet(expectedProjectIds), new HashSet(projectIds));
- }
-}
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/notifications/TestMailNotificationService.java b/sources/src/test/java/com/google/solutions/jitaccess/core/notifications/TestMailNotificationService.java
index cb1b825bc..1d6031ede 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/notifications/TestMailNotificationService.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/notifications/TestMailNotificationService.java
@@ -21,8 +21,8 @@
package com.google.solutions.jitaccess.core.notifications;
-import com.google.solutions.jitaccess.core.clients.SmtpClient;
import com.google.solutions.jitaccess.core.UserId;
+import com.google.solutions.jitaccess.core.clients.SmtpClient;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/core/notifications/TestPubSubNotificationService.java b/sources/src/test/java/com/google/solutions/jitaccess/core/notifications/TestPubSubNotificationService.java
index ab196b070..a9ee3a9c3 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/core/notifications/TestPubSubNotificationService.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/core/notifications/TestPubSubNotificationService.java
@@ -21,9 +21,9 @@
package com.google.solutions.jitaccess.core.notifications;
+import com.google.solutions.jitaccess.core.UserId;
import com.google.solutions.jitaccess.core.clients.PubSubClient;
import com.google.solutions.jitaccess.core.clients.PubSubTopic;
-import com.google.solutions.jitaccess.core.UserId;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/TestLogAdapter.java b/sources/src/test/java/com/google/solutions/jitaccess/web/TestLogAdapter.java
index 2660310da..d8aa7e0ee 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/web/TestLogAdapter.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/web/TestLogAdapter.java
@@ -21,9 +21,8 @@
package com.google.solutions.jitaccess.web;
-import com.google.solutions.jitaccess.web.LogAdapter;
-import com.google.solutions.jitaccess.web.auth.DeviceInfo;
import com.google.solutions.jitaccess.core.UserId;
+import com.google.solutions.jitaccess.web.auth.DeviceInfo;
import com.google.solutions.jitaccess.web.auth.UserPrincipal;
import org.junit.jupiter.api.Test;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/TestXsrfRequestFilter.java b/sources/src/test/java/com/google/solutions/jitaccess/web/TestXsrfRequestFilter.java
index 708befd8e..e822c0a63 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/web/TestXsrfRequestFilter.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/web/TestXsrfRequestFilter.java
@@ -21,11 +21,10 @@
package com.google.solutions.jitaccess.web;
+import jakarta.ws.rs.container.ContainerRequestContext;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
-import jakarta.ws.rs.container.ContainerRequestContext;
-
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/auth/TestIapRequestFilter.java b/sources/src/test/java/com/google/solutions/jitaccess/web/auth/TestIapRequestFilter.java
index b9262c9b9..65d2bd8cf 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/web/auth/TestIapRequestFilter.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/web/auth/TestIapRequestFilter.java
@@ -23,11 +23,10 @@
import com.google.solutions.jitaccess.web.LogAdapter;
import com.google.solutions.jitaccess.web.RuntimeEnvironment;
-import org.junit.jupiter.api.Test;
-import org.mockito.Mockito;
-
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.container.ContainerRequestContext;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/rest/RestDispatcher.java b/sources/src/test/java/com/google/solutions/jitaccess/web/rest/RestDispatcher.java
index 56fa25b3e..15f69f515 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/web/rest/RestDispatcher.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/web/rest/RestDispatcher.java
@@ -22,10 +22,11 @@
package com.google.solutions.jitaccess.web.rest;
import com.google.gson.Gson;
-import com.google.solutions.jitaccess.web.auth.DeviceInfo;
import com.google.solutions.jitaccess.core.UserId;
+import com.google.solutions.jitaccess.web.auth.DeviceInfo;
import com.google.solutions.jitaccess.web.auth.UserPrincipal;
-import com.google.solutions.jitaccess.web.rest.ExceptionMappers;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.SecurityContext;
import org.jboss.resteasy.core.SynchronousDispatcher;
import org.jboss.resteasy.core.SynchronousExecutionContext;
import org.jboss.resteasy.mock.MockDispatcherFactory;
@@ -33,8 +34,6 @@
import org.jboss.resteasy.mock.MockHttpResponse;
import org.jboss.resteasy.spi.Dispatcher;
-import jakarta.ws.rs.core.MediaType;
-import jakarta.ws.rs.core.SecurityContext;
import java.io.UnsupportedEncodingException;
import java.net.URISyntaxException;
import java.security.Principal;
diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/rest/TestApiResource.java b/sources/src/test/java/com/google/solutions/jitaccess/web/rest/TestApiResource.java
index e56d43fc6..6ef969451 100644
--- a/sources/src/test/java/com/google/solutions/jitaccess/web/rest/TestApiResource.java
+++ b/sources/src/test/java/com/google/solutions/jitaccess/web/rest/TestApiResource.java
@@ -22,32 +22,29 @@
package com.google.solutions.jitaccess.web.rest;
import com.google.auth.oauth2.TokenVerifier;
-import com.google.solutions.jitaccess.core.AccessDeniedException;
-import com.google.solutions.jitaccess.core.entitlements.ProjectRole;
-import com.google.solutions.jitaccess.core.entitlements.RoleBinding;
-import com.google.solutions.jitaccess.core.UserId;
+import com.google.solutions.jitaccess.core.*;
+import com.google.solutions.jitaccess.core.catalog.*;
+import com.google.solutions.jitaccess.core.catalog.project.IamPolicyCatalog;
+import com.google.solutions.jitaccess.core.catalog.project.ProjectRoleActivator;
+import com.google.solutions.jitaccess.core.catalog.project.ProjectRoleBinding;
+import com.google.solutions.jitaccess.core.clients.ResourceManagerClient;
+import com.google.solutions.jitaccess.core.notifications.NotificationService;
import com.google.solutions.jitaccess.web.LogAdapter;
import com.google.solutions.jitaccess.web.RuntimeEnvironment;
import com.google.solutions.jitaccess.web.TokenObfuscator;
import jakarta.enterprise.inject.Instance;
-import com.google.solutions.jitaccess.core.ProjectId;
-import com.google.solutions.jitaccess.core.entitlements.ActivationTokenService;
-import com.google.solutions.jitaccess.core.notifications.NotificationService;
-import com.google.solutions.jitaccess.core.entitlements.RoleActivationService;
-import com.google.solutions.jitaccess.core.entitlements.RoleDiscoveryService;
-import com.google.solutions.jitaccess.core.AnnotatedResult;
-
+import jakarta.ws.rs.core.UriBuilder;
+import jakarta.ws.rs.core.UriInfo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
-import jakarta.ws.rs.core.UriBuilder;
-import jakarta.ws.rs.core.UriInfo;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
+import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -67,8 +64,11 @@ public class TestApiResource {
private static final int DEFAULT_MAX_NUMBER_OF_ROLES = 3;
private static final String DEFAULT_HINT = "hint";
private static final Duration DEFAULT_ACTIVATION_DURATION = Duration.ofMinutes(5);
- private static final ActivationTokenService.TokenWithExpiry SAMPLE_TOKEN_WITH_EXPIRY =
- new ActivationTokenService.TokenWithExpiry(SAMPLE_TOKEN, Instant.now().plusSeconds(10));
+ private static final TokenSigner.TokenWithExpiry SAMPLE_TOKEN_WITH_EXPIRY =
+ new TokenSigner.TokenWithExpiry(
+ SAMPLE_TOKEN,
+ Instant.now(),
+ Instant.now().plusSeconds(10));
private ApiResource resource;
private NotificationService notificationService;
@@ -79,9 +79,10 @@ public void before() {
this.resource.options = new ApiResource.Options(DEFAULT_MAX_NUMBER_OF_ROLES);
this.resource.logAdapter = new LogAdapter();
this.resource.runtimeEnvironment = Mockito.mock(RuntimeEnvironment.class);
- this.resource.roleDiscoveryService = Mockito.mock(RoleDiscoveryService.class);
- this.resource.roleActivationService = Mockito.mock(RoleActivationService.class);
- this.resource.activationTokenService = Mockito.mock(ActivationTokenService.class);
+ this.resource.iamPolicyCatalog = Mockito.mock(IamPolicyCatalog.class);
+ this.resource.projectRoleActivator = Mockito.mock(ProjectRoleActivator.class);
+ this.resource.justificationPolicy = Mockito.mock(JustificationPolicy.class);
+ this.resource.tokenSigner = Mockito.mock(TokenSigner.class);
this.notificationService = Mockito.mock(NotificationService.class);
when(this.notificationService.canSendNotifications()).thenReturn(true);
@@ -112,10 +113,11 @@ public void whenPathNotMapped_ThenGetReturnsError() throws Exception {
@Test
public void getPolicyReturnsJustificationHint() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ when(this.resource.justificationPolicy.hint())
+ .thenReturn(DEFAULT_HINT);
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
@@ -132,10 +134,11 @@ public void getPolicyReturnsJustificationHint() throws Exception {
@Test
public void getPolicyReturnsSignedInUser() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ when(this.resource.justificationPolicy.hint())
+ .thenReturn(DEFAULT_HINT);
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
@@ -164,7 +167,7 @@ public void postProjectsReturnsError() throws Exception {
@Test
public void whenProjectDiscoveryThrowsAccessDeniedException_ThenListProjectsReturnsError() throws Exception {
- when(this.resource.roleDiscoveryService.listAvailableProjects(eq(SAMPLE_USER)))
+ when(this.resource.iamPolicyCatalog.listProjects(eq(SAMPLE_USER)))
.thenThrow(new AccessDeniedException("mock"));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
@@ -178,7 +181,7 @@ public void whenProjectDiscoveryThrowsAccessDeniedException_ThenListProjectsRetu
@Test
public void whenProjectDiscoveryThrowsIOException_ThenListProjectsReturnsError() throws Exception {
- when(this.resource.roleDiscoveryService.listAvailableProjects(eq(SAMPLE_USER)))
+ when(this.resource.iamPolicyCatalog.listProjects(eq(SAMPLE_USER)))
.thenThrow(new IOException("mock"));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
@@ -192,8 +195,8 @@ public void whenProjectDiscoveryThrowsIOException_ThenListProjectsReturnsError()
@Test
public void whenProjectDiscoveryReturnsNoProjects_ThenListProjectsReturnsEmptyList() throws Exception {
- when(this.resource.roleDiscoveryService.listAvailableProjects(eq(SAMPLE_USER)))
- .thenReturn(Set.of());
+ when(this.resource.iamPolicyCatalog.listProjects(eq(SAMPLE_USER)))
+ .thenReturn(new TreeSet<>());
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
.get("/api/projects", ApiResource.ProjectsResponse.class);
@@ -207,8 +210,8 @@ public void whenProjectDiscoveryReturnsNoProjects_ThenListProjectsReturnsEmptyLi
@Test
public void whenProjectDiscoveryReturnsProjects_ThenListProjectsReturnsList() throws Exception {
- when(this.resource.roleDiscoveryService.listAvailableProjects(eq(SAMPLE_USER)))
- .thenReturn(Set.of(new ProjectId("project-1"), new ProjectId("project-2")));
+ when(this.resource.iamPolicyCatalog.listProjects(eq(SAMPLE_USER)))
+ .thenReturn(new TreeSet<>(Set.of(new ProjectId("project-1"), new ProjectId("project-2"))));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
.get("/api/projects", ApiResource.ProjectsResponse.class);
@@ -242,7 +245,7 @@ public void getPeersWithoutRoleReturnsError() throws Exception {
@Test
public void whenPeerDiscoveryThrowsAccessDeniedException_ThenListPeersReturnsError() throws Exception {
- when(this.resource.roleDiscoveryService.listEligibleUsersForProjectRole(eq(SAMPLE_USER), any(RoleBinding.class)))
+ when(this.resource.iamPolicyCatalog.listReviewers(eq(SAMPLE_USER), any()))
.thenThrow(new AccessDeniedException("mock"));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
@@ -256,7 +259,7 @@ public void whenPeerDiscoveryThrowsAccessDeniedException_ThenListPeersReturnsErr
@Test
public void whenPeerDiscoveryThrowsIOException_ThenListPeersReturnsError() throws Exception {
- when(this.resource.roleDiscoveryService.listEligibleUsersForProjectRole(eq(SAMPLE_USER), any(RoleBinding.class)))
+ when(this.resource.iamPolicyCatalog.listReviewers(eq(SAMPLE_USER), any()))
.thenThrow(new IOException("mock"));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
@@ -270,11 +273,11 @@ public void whenPeerDiscoveryThrowsIOException_ThenListPeersReturnsError() throw
@Test
public void whenPeerDiscoveryReturnsNoPeers_ThenListPeersReturnsEmptyList() throws Exception {
- when(this.resource.roleDiscoveryService
- .listEligibleUsersForProjectRole(
+ when(this.resource.iamPolicyCatalog
+ .listReviewers(
eq(SAMPLE_USER),
- argThat(r -> r.role().equals("roles/browser"))))
- .thenReturn(Set.of());
+ argThat(r -> r.roleBinding().role().equals("roles/browser"))))
+ .thenReturn(new TreeSet());
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
.get("/api/projects/project-1/peers?role=roles/browser", ApiResource.ProjectRolePeersResponse.class);
@@ -288,11 +291,11 @@ public void whenPeerDiscoveryReturnsNoPeers_ThenListPeersReturnsEmptyList() thro
@Test
public void whenPeerDiscoveryReturnsProjects_ThenListPeersReturnsList() throws Exception {
- when(this.resource.roleDiscoveryService
- .listEligibleUsersForProjectRole(
+ when(this.resource.iamPolicyCatalog
+ .listReviewers(
eq(SAMPLE_USER),
- argThat(r -> r.role().equals("roles/browser"))))
- .thenReturn(Set.of(new UserId("peer-1@example.com"), new UserId("peer-2@example.com")));
+ argThat(r -> r.roleBinding().role().equals("roles/browser"))))
+ .thenReturn(new TreeSet(Set.of(new UserId("peer-1@example.com"), new UserId("peer-2@example.com"))));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
.get("/api/projects/project-1/peers?role=roles/browser", ApiResource.ProjectRolePeersResponse.class);
@@ -318,7 +321,7 @@ public void postRolesReturnsError() throws Exception {
@Test
public void whenProjectIsEmpty_ThenListRolesReturnsError() throws Exception {
- when(this.resource.roleDiscoveryService.listAvailableProjects(eq(SAMPLE_USER)))
+ when(this.resource.iamPolicyCatalog.listProjects(eq(SAMPLE_USER)))
.thenThrow(new AccessDeniedException("mock"));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
@@ -332,9 +335,9 @@ public void whenProjectIsEmpty_ThenListRolesReturnsError() throws Exception {
}
@Test
- public void whenRoleDiscoveryThrowsAccessDeniedException_ThenListRolesReturnsError() throws Exception {
- when(this.resource.roleDiscoveryService
- .listEligibleProjectRoles(
+ public void whenCatalogThrowsAccessDeniedException_ThenListRolesReturnsError() throws Exception {
+ when(this.resource.iamPolicyCatalog
+ .listEntitlements(
eq(SAMPLE_USER),
eq(new ProjectId("project-1"))))
.thenThrow(new AccessDeniedException("mock"));
@@ -349,9 +352,9 @@ public void whenRoleDiscoveryThrowsAccessDeniedException_ThenListRolesReturnsErr
}
@Test
- public void whenRoleDiscoveryThrowsIOException_ThenListRolesReturnsError() throws Exception {
- when(this.resource.roleDiscoveryService
- .listEligibleProjectRoles(
+ public void whenCatalogThrowsIOException_ThenListRolesReturnsError() throws Exception {
+ when(this.resource.iamPolicyCatalog
+ .listEntitlements(
eq(SAMPLE_USER),
eq(new ProjectId("project-1"))))
.thenThrow(new IOException("mock"));
@@ -366,13 +369,13 @@ public void whenRoleDiscoveryThrowsIOException_ThenListRolesReturnsError() throw
}
@Test
- public void whenRoleDiscoveryReturnsNoRoles_ThenListRolesReturnsEmptyList() throws Exception {
- when(this.resource.roleDiscoveryService
- .listEligibleProjectRoles(
+ public void whenCatalogReturnsNoRoles_ThenListRolesReturnsEmptyList() throws Exception {
+ when(this.resource.iamPolicyCatalog
+ .listEntitlements(
eq(SAMPLE_USER),
eq(new ProjectId("project-1"))))
- .thenReturn(new AnnotatedResult<>(
- List.of(),
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of()),
Set.of("warning")));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
@@ -389,21 +392,25 @@ public void whenRoleDiscoveryReturnsNoRoles_ThenListRolesReturnsEmptyList() thro
}
@Test
- public void whenRoleDiscoveryReturnsRoles_ThenListRolesReturnsList() throws Exception {
- var role1 = new ProjectRole(
- new RoleBinding(new ProjectId("project-1").getFullResourceName(), "roles/browser"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
- var role2 = new ProjectRole(
- new RoleBinding(new ProjectId("project-1").getFullResourceName(), "roles/janitor"),
- ProjectRole.Status.ELIGIBLE_FOR_JIT);
-
- when(this.resource.roleDiscoveryService
- .listEligibleProjectRoles(
+ public void whenCatalogReturnsRoles_ThenListRolesReturnsList() throws Exception {
+ var role1 = new Entitlement(
+ new ProjectRoleBinding(new RoleBinding(new ProjectId("project-1").getFullResourceName(), "roles/browser")),
+ "ent-1",
+ ActivationType.JIT,
+ Entitlement.Status.AVAILABLE);
+ var role2 = new Entitlement(
+ new ProjectRoleBinding(new RoleBinding(new ProjectId("project-1").getFullResourceName(), "roles/janitor")),
+ "ent-2",
+ ActivationType.JIT,
+ Entitlement.Status.AVAILABLE);
+
+ when(this.resource.iamPolicyCatalog
+ .listEntitlements(
eq(SAMPLE_USER),
eq(new ProjectId("project-1"))))
- .thenReturn(new AnnotatedResult<>(
- List.of(role1, role2),
- null));
+ .thenReturn(new Annotated<>(
+ new TreeSet<>(Set.of(role1, role2)),
+ Set.of()));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
.get("/api/projects/project-1/roles", ApiResource.ProjectRolesResponse.class);
@@ -413,9 +420,9 @@ public void whenRoleDiscoveryReturnsRoles_ThenListRolesReturnsList() throws Exce
var body = response.getBody();
assertNotNull(body.roles);
assertEquals(2, body.roles.size());
- assertEquals(role1, body.roles.get(0));
- assertEquals(role2, body.roles.get(1));
- assertNull(body.warnings);
+ assertEquals(role1.id().roleBinding(), body.roles.get(0).roleBinding);
+ assertEquals(role2.id().roleBinding(), body.roles.get(1).roleBinding);
+ assertTrue(body.warnings.isEmpty());
}
// -------------------------------------------------------------------------
@@ -508,13 +515,12 @@ public void whenJustificationMissing_ThenSelfApproveActivationReturnsError() thr
}
@Test
- public void whenActivationServiceThrowsException_ThenSelfApproveActivationReturnsError() throws Exception {
- when(this.resource.roleActivationService
- .activateProjectRoleForSelf(
- eq(SAMPLE_USER),
- any(RoleBinding.class),
- anyString(),
- any(Duration.class)))
+ public void whenActivatorThrowsException_ThenSelfApproveActivationReturnsError() throws Exception {
+ when(this.resource.projectRoleActivator
+ .createJitRequest(any(), any(), any(), any(), any()))
+ .thenCallRealMethod();
+ when(this.resource.projectRoleActivator
+ .activate(any()))
.thenThrow(new AccessDeniedException("mock"));
var request = new ApiResource.SelfActivationRequest();
@@ -536,17 +542,12 @@ public void whenActivationServiceThrowsException_ThenSelfApproveActivationReturn
public void whenRolesContainDuplicates_ThenSelfApproveActivationSucceedsAndIgnoresDuplicates() throws Exception {
var roleBinding = new RoleBinding(new ProjectId("project-1"), "roles/browser");
- when(this.resource.roleActivationService
- .activateProjectRoleForSelf(
- eq(SAMPLE_USER),
- eq(roleBinding),
- eq("justification"),
- eq(Duration.ofMinutes(5))))
- .thenReturn(RoleActivationService.Activation.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.JIT),
- new ProjectRole(roleBinding, ProjectRole.Status.ACTIVATED),
- Instant.now(),
- Instant.now().plusSeconds(60)));
+ when(this.resource.projectRoleActivator
+ .createJitRequest(any(), any(), any(), any(), any()))
+ .thenCallRealMethod();
+ when(this.resource.projectRoleActivator
+ .activate(argThat(r -> r.entitlements().size() == 1)))
+ .then(r -> new Activation<>((ActivationRequest) r.getArguments()[0]));
var request = new ApiResource.SelfActivationRequest();
request.roles = List.of("roles/browser", "roles/browser");
@@ -570,7 +571,7 @@ public void whenRolesContainDuplicates_ThenSelfApproveActivationSucceedsAndIgnor
assertEquals(1, body.items.size());
assertEquals("project-1", body.items.get(0).projectId);
assertEquals(roleBinding, body.items.get(0).roleBinding);
- assertEquals(ProjectRole.Status.ACTIVATED, body.items.get(0).status);
+ assertEquals(Entitlement.Status.ACTIVE, body.items.get(0).status);
assertNotNull(body.items.get(0).activationId);
}
@@ -596,10 +597,9 @@ public void whenBodyIsEmpty_ThenRequestActivationReturnsError() throws Exception
@Test
public void whenProjectIsNull_ThenRequestActivationReturnsError() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
@@ -618,10 +618,9 @@ public void whenProjectIsNull_ThenRequestActivationReturnsError() throws Excepti
@Test
public void whenRoleEmpty_ThenRequestActivationReturnsError() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
@@ -644,10 +643,9 @@ public void whenRoleEmpty_ThenRequestActivationReturnsError() throws Exception {
@Test
public void whenPeersEmpty_ThenRequestActivationReturnsError() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
@@ -670,13 +668,12 @@ public void whenPeersEmpty_ThenRequestActivationReturnsError() throws Exception
@Test
public void whenTooFewPeersSelected_ThenRequestActivationReturnsError() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_DURATION,
- 2,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
+ DEFAULT_ACTIVATION_DURATION,
+ 2,
+ DEFAULT_MAX_NUMBER_OF_REVIEWERS));
var request = new ApiResource.ActivationRequest();
request.role = "roles/mock";
@@ -696,13 +693,12 @@ public void whenTooFewPeersSelected_ThenRequestActivationReturnsError() throws E
@Test
public void whenTooManyPeersSelected_ThenRequestActivationReturnsError() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
- DEFAULT_ACTIVATION_DURATION,
- DEFAULT_MIN_NUMBER_OF_REVIEWERS,
- DEFAULT_MAX_NUMBER_OF_REVIEWERS));
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
+ DEFAULT_ACTIVATION_DURATION,
+ DEFAULT_MIN_NUMBER_OF_REVIEWERS,
+ DEFAULT_MAX_NUMBER_OF_REVIEWERS));
var request = new ApiResource.ActivationRequest();
request.role = "roles/mock";
@@ -724,10 +720,9 @@ public void whenTooManyPeersSelected_ThenRequestActivationReturnsError() throws
@Test
public void whenJustificationEmpty_ThenRequestActivationReturnsError() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
@@ -750,10 +745,9 @@ public void whenJustificationEmpty_ThenRequestActivationReturnsError() throws Ex
@Test
public void whenNotificationsNotConfigured_ThenRequestActivationReturnsError() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
@@ -778,22 +772,22 @@ public void whenNotificationsNotConfigured_ThenRequestActivationReturnsError() t
}
@Test
- public void whenActivationServiceThrowsException_ThenRequestActivationReturnsError() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ public void whenActivatorThrowsException_ThenRequestActivationReturnsError() throws Exception {
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
- when(this.resource.roleActivationService
- .createActivationRequestForPeer(
+ when(this.resource.projectRoleActivator
+ .createMpaRequest(
eq(SAMPLE_USER),
- anySet(),
- any(RoleBinding.class),
- anyString(),
- any(Duration.class)))
+ any(),
+ any(),
+ any(),
+ any(),
+ any()))
.thenThrow(new AccessDeniedException("mock"));
var request = new ApiResource.ActivationRequest();
@@ -814,33 +808,20 @@ public void whenActivationServiceThrowsException_ThenRequestActivationReturnsErr
@Test
public void whenRequestValid_ThenRequestActivationSendsNotification() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
- var roleBinding = new RoleBinding(new ProjectId("project-1"), "roles/browser");
+ this.resource.projectRoleActivator = new ProjectRoleActivator(
+ this.resource.iamPolicyCatalog,
+ Mockito.mock(ResourceManagerClient.class),
+ this.resource.justificationPolicy);
- when(this.resource.roleActivationService
- .createActivationRequestForPeer(
- eq(SAMPLE_USER),
- eq(Set.of(SAMPLE_USER_2)),
- argThat(r -> r.role().equals("roles/mock")),
- eq("justification"),
- eq(Duration.ofMinutes(5))))
- .thenReturn(RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.JIT),
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- roleBinding,
- "justification",
- Instant.now(),
- Instant.now().plusSeconds(60)));
- when(this.resource.activationTokenService
- .createToken(any(RoleActivationService.ActivationRequest.class)))
+ when(this.resource.tokenSigner
+ .sign(any(), any()))
.thenReturn(SAMPLE_TOKEN_WITH_EXPIRY);
var request = new ApiResource.ActivationRequest();
@@ -861,37 +842,25 @@ public void whenRequestValid_ThenRequestActivationSendsNotification() throws Exc
@Test
public void whenRequestValid_ThenRequestActivationReturnsSuccessResponse() throws Exception {
- when(this.resource.roleActivationService.getOptions())
- .thenReturn(new RoleActivationService.Options(
- DEFAULT_HINT,
- DEFAULT_JUSTIFICATION_PATTERN,
+ when(this.resource.iamPolicyCatalog.options())
+ .thenReturn(new IamPolicyCatalog.Options(
+ null,
DEFAULT_ACTIVATION_DURATION,
DEFAULT_MIN_NUMBER_OF_REVIEWERS,
DEFAULT_MAX_NUMBER_OF_REVIEWERS));
- var roleBinding = new RoleBinding(new ProjectId("project-1"), "roles/browser");
+ this.resource.projectRoleActivator = new ProjectRoleActivator(
+ this.resource.iamPolicyCatalog,
+ Mockito.mock(ResourceManagerClient.class),
+ this.resource.justificationPolicy);
- when(this.resource.roleActivationService
- .createActivationRequestForPeer(
- eq(SAMPLE_USER),
- eq(Set.of(SAMPLE_USER_2)),
- argThat(r -> r.role().equals("roles/mock")),
- eq("justification"),
- eq(Duration.ofMinutes(5))))
- .thenReturn(RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.JIT),
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- roleBinding,
- "justification",
- Instant.now(),
- Instant.now().plusSeconds(60)));
- when(this.resource.activationTokenService
- .createToken(any(RoleActivationService.ActivationRequest.class)))
+ when(this.resource.tokenSigner
+ .sign(any(), any()))
.thenReturn(SAMPLE_TOKEN_WITH_EXPIRY);
+ var roleBinding = new RoleBinding(new ProjectId("project-1"), "roles/browser");
var request = new ApiResource.ActivationRequest();
- request.role = "roles/mock";
+ request.role = roleBinding.role();
request.peers = List.of(SAMPLE_USER_2.email, SAMPLE_USER_2.email);
request.justification = "justification";
request.activationTimeout = 5;
@@ -903,7 +872,7 @@ public void whenRequestValid_ThenRequestActivationReturnsSuccessResponse() throw
var body = response.getBody();
assertEquals(SAMPLE_USER, body.beneficiary);
- assertEquals(Set.of(SAMPLE_USER_2), body.reviewers);
+ assertIterableEquals(Set.of(SAMPLE_USER_2), body.reviewers);
assertTrue(body.isBeneficiary);
assertFalse(body.isReviewer);
assertEquals("justification", body.justification);
@@ -911,7 +880,7 @@ public void whenRequestValid_ThenRequestActivationReturnsSuccessResponse() throw
assertEquals(1, body.items.size());
assertEquals("project-1", body.items.get(0).projectId);
assertEquals(roleBinding, body.items.get(0).roleBinding);
- assertEquals(ProjectRole.Status.ACTIVATION_PENDING, body.items.get(0).status);
+ assertEquals(Entitlement.Status.ACTIVATION_PENDING, body.items.get(0).status);
assertNotNull(body.items.get(0).activationId);
}
@@ -932,7 +901,7 @@ public void whenTokenMissing_ThenGetActivationRequestReturnsError() throws Excep
@Test
public void whenTokenInvalid_ThenGetActivationRequestReturnsError() throws Exception {
- when(this.resource.activationTokenService.verifyToken(eq(SAMPLE_TOKEN)))
+ when(this.resource.tokenSigner.verify(any(), eq(SAMPLE_TOKEN)))
.thenThrow(new TokenVerifier.VerificationException("mock"));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
@@ -946,18 +915,25 @@ public void whenTokenInvalid_ThenGetActivationRequestReturnsError() throws Excep
assertNotNull(body.getMessage());
}
+
@Test
public void whenCallerNotInvolvedInRequest_ThenGetActivationRequestReturnsError() throws Exception {
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- new RoleBinding(new ProjectId("project-1"), "roles/mock"),
- "a justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
+ var request = new ProjectRoleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ Mockito.mock(ResourceManagerClient.class),
+ Mockito.mock(JustificationPolicy.class))
+ .createMpaRequest(
+ SAMPLE_USER,
+ Set.of(new ProjectRoleBinding(new RoleBinding(new ProjectId("project-1"), "roles/mock"))),
+ Set.of(SAMPLE_USER_2),
+ "a justification",
+ Instant.now(),
+ Duration.ofSeconds(60));
- when(this.resource.activationTokenService.verifyToken(eq(SAMPLE_TOKEN)))
+ when(this.resource.tokenSigner
+ .verify(
+ any(),
+ eq(SAMPLE_TOKEN)))
.thenReturn(request);
var response = new RestDispatcher<>(this.resource, new UserId("other-party@example.com"))
@@ -973,16 +949,22 @@ public void whenCallerNotInvolvedInRequest_ThenGetActivationRequestReturnsError(
@Test
public void whenTokenValid_ThenGetActivationRequestSucceeds() throws Exception {
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- new RoleBinding(new ProjectId("project-1"), "roles/mock"),
- "a justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
+ var request = new ProjectRoleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ Mockito.mock(ResourceManagerClient.class),
+ Mockito.mock(JustificationPolicy.class))
+ .createMpaRequest(
+ SAMPLE_USER,
+ Set.of(new ProjectRoleBinding(new RoleBinding(new ProjectId("project-1"), "roles/mock"))),
+ Set.of(SAMPLE_USER_2),
+ "a justification",
+ Instant.now(),
+ Duration.ofSeconds(60));
- when(this.resource.activationTokenService.verifyToken(eq(SAMPLE_TOKEN)))
+ when(this.resource.tokenSigner
+ .verify(
+ any(),
+ eq(SAMPLE_TOKEN)))
.thenReturn(request);
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
@@ -993,17 +975,17 @@ public void whenTokenValid_ThenGetActivationRequestSucceeds() throws Exception {
assertEquals(200, response.getStatus());
var body = response.getBody();
- assertEquals(request.beneficiary, body.beneficiary);
- assertEquals(Set.of(SAMPLE_USER_2), request.reviewers);
+ assertEquals(request.requestingUser(), body.beneficiary);
+ assertIterableEquals(Set.of(SAMPLE_USER_2), request.reviewers());
assertTrue(body.isBeneficiary);
assertFalse(body.isReviewer);
- assertEquals(request.justification, body.justification);
+ assertEquals(request.justification(), body.justification);
assertEquals(1, body.items.size());
- assertEquals(request.id.toString(), body.items.get(0).activationId);
+ assertEquals(request.id().toString(), body.items.get(0).activationId);
assertEquals("project-1", body.items.get(0).projectId);
assertEquals("ACTIVATION_PENDING", body.items.get(0).status.name());
- assertEquals(request.startTime.getEpochSecond(), body.items.get(0).startTime);
- assertEquals(request.endTime.getEpochSecond(), body.items.get(0).endTime);
+ assertEquals(request.startTime().getEpochSecond(), body.items.get(0).startTime);
+ assertEquals(request.endTime() .getEpochSecond(), body.items.get(0).endTime);
}
// -------------------------------------------------------------------------
@@ -1023,7 +1005,10 @@ public void whenTokenMissing_ThenApproveActivationRequestReturnsError() throws E
@Test
public void whenTokenInvalid_ThenApproveActivationRequestReturnsError() throws Exception {
- when(this.resource.activationTokenService.verifyToken(eq(SAMPLE_TOKEN)))
+ when(this.resource.tokenSigner
+ .verify(
+ any(),
+ eq(SAMPLE_TOKEN)))
.thenThrow(new TokenVerifier.VerificationException("mock"));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
@@ -1038,20 +1023,27 @@ public void whenTokenInvalid_ThenApproveActivationRequestReturnsError() throws E
}
@Test
- public void whenActivationServiceThrowsException_ThenApproveActivationRequestReturnsError() throws Exception {
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- new RoleBinding(new ProjectId("project-1"), "roles/mock"),
- "a justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
+ public void whenActivatorThrowsException_ThenApproveActivationRequestReturnsError() throws Exception {
+ var request = new ProjectRoleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ Mockito.mock(ResourceManagerClient.class),
+ Mockito.mock(JustificationPolicy.class))
+ .createMpaRequest(
+ SAMPLE_USER,
+ Set.of(new ProjectRoleBinding(new RoleBinding(new ProjectId("project-1"), "roles/mock"))),
+ Set.of(SAMPLE_USER_2),
+ "a justification",
+ Instant.now(),
+ Duration.ofSeconds(60));
- when(this.resource.activationTokenService.verifyToken(eq(SAMPLE_TOKEN)))
+ when(this.resource.tokenSigner
+ .verify(
+ any(),
+ eq(SAMPLE_TOKEN)))
.thenReturn(request);
- when(this.resource.roleActivationService
- .activateProjectRoleForPeer(
+
+ when(this.resource.projectRoleActivator
+ .approve(
eq(SAMPLE_USER),
eq(request)))
.thenThrow(new AccessDeniedException("mock"));
@@ -1070,25 +1062,29 @@ public void whenActivationServiceThrowsException_ThenApproveActivationRequestRet
@Test
public void whenTokenValid_ThenApproveActivationSendsNotification() throws Exception {
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- new RoleBinding(new ProjectId("project-1"), "roles/mock"),
- "a justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
- when(this.resource.activationTokenService.verifyToken(eq(SAMPLE_TOKEN)))
+ var request = new ProjectRoleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ Mockito.mock(ResourceManagerClient.class),
+ Mockito.mock(JustificationPolicy.class))
+ .createMpaRequest(
+ SAMPLE_USER,
+ Set.of(new ProjectRoleBinding(new RoleBinding(new ProjectId("project-1"), "roles/mock"))),
+ Set.of(SAMPLE_USER_2),
+ "a justification",
+ Instant.now(),
+ Duration.ofSeconds(60));
+
+ when(this.resource.tokenSigner
+ .verify(
+ any(),
+ eq(SAMPLE_TOKEN)))
.thenReturn(request);
- when(this.resource.roleActivationService
- .activateProjectRoleForPeer(
+
+ when(this.resource.projectRoleActivator
+ .approve(
eq(SAMPLE_USER),
eq(request)))
- .thenReturn(RoleActivationService.Activation.createForTestingOnly(
- request.id,
- new ProjectRole(request.roleBinding, ProjectRole.Status.ACTIVATED),
- request.startTime,
- request.endTime));
+ .thenReturn(new Activation<>(request));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER)
.post(
@@ -1103,25 +1099,29 @@ public void whenTokenValid_ThenApproveActivationSendsNotification() throws Excep
@Test
public void whenTokenValid_ThenApproveActivationRequestSucceeds() throws Exception {
- var request = RoleActivationService.ActivationRequest.createForTestingOnly(
- RoleActivationService.ActivationId.newId(RoleActivationService.ActivationType.MPA),
- SAMPLE_USER,
- Set.of(SAMPLE_USER_2),
- new RoleBinding(new ProjectId("project-1"), "roles/mock"),
- "a justification",
- Instant.now(),
- Instant.now().plusSeconds(60));
- when(this.resource.activationTokenService.verifyToken(eq(SAMPLE_TOKEN)))
+ var request = new ProjectRoleActivator(
+ Mockito.mock(EntitlementCatalog.class),
+ Mockito.mock(ResourceManagerClient.class),
+ Mockito.mock(JustificationPolicy.class))
+ .createMpaRequest(
+ SAMPLE_USER,
+ Set.of(new ProjectRoleBinding(new RoleBinding(new ProjectId("project-1"), "roles/mock"))),
+ Set.of(SAMPLE_USER_2),
+ "a justification",
+ Instant.now(),
+ Duration.ofSeconds(60));
+
+ when(this.resource.tokenSigner
+ .verify(
+ any(),
+ eq(SAMPLE_TOKEN)))
.thenReturn(request);
- when(this.resource.roleActivationService
- .activateProjectRoleForPeer(
+
+ when(this.resource.projectRoleActivator
+ .approve(
eq(SAMPLE_USER_2),
eq(request)))
- .thenReturn(RoleActivationService.Activation.createForTestingOnly(
- request.id,
- new ProjectRole(request.roleBinding, ProjectRole.Status.ACTIVATED),
- request.startTime,
- request.endTime));
+ .thenReturn(new Activation<>(request));
var response = new RestDispatcher<>(this.resource, SAMPLE_USER_2)
.post(
@@ -1131,16 +1131,16 @@ public void whenTokenValid_ThenApproveActivationRequestSucceeds() throws Excepti
assertEquals(200, response.getStatus());
var body = response.getBody();
- assertEquals(request.beneficiary, body.beneficiary);
- assertEquals(Set.of(SAMPLE_USER_2), request.reviewers);
+ assertEquals(request.requestingUser(), body.beneficiary);
+ assertIterableEquals(Set.of(SAMPLE_USER_2), request.reviewers());
assertFalse(body.isBeneficiary);
assertTrue(body.isReviewer);
- assertEquals(request.justification, body.justification);
+ assertEquals(request.justification(), body.justification);
assertEquals(1, body.items.size());
- assertEquals(request.id.toString(), body.items.get(0).activationId);
+ assertEquals(request.id().toString(), body.items.get(0).activationId);
assertEquals("project-1", body.items.get(0).projectId);
- assertEquals("ACTIVATED", body.items.get(0).status.name());
- assertEquals(request.startTime.getEpochSecond(), body.items.get(0).startTime);
- assertEquals(request.endTime.getEpochSecond(), body.items.get(0).endTime);
+ assertEquals("ACTIVE", body.items.get(0).status.name());
+ assertEquals(request.startTime().getEpochSecond(), body.items.get(0).startTime);
+ assertEquals(request.endTime().getEpochSecond(), body.items.get(0).endTime);
}
}
\ No newline at end of file