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 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