diff --git a/fiat-google-groups/src/main/java/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProvider.java b/fiat-google-groups/src/main/java/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProvider.java index 7a5a9d4c0..40e6c0e43 100644 --- a/fiat-google-groups/src/main/java/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProvider.java +++ b/fiat-google-groups/src/main/java/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProvider.java @@ -16,7 +16,6 @@ package com.netflix.spinnaker.fiat.roles.google; -import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; import com.google.api.client.googleapis.batch.BatchRequest; import com.google.api.client.googleapis.batch.json.JsonBatchCallback; import com.google.api.client.googleapis.json.GoogleJsonError; @@ -32,16 +31,22 @@ import com.google.api.services.directory.DirectoryScopes; import com.google.api.services.directory.model.Group; import com.google.api.services.directory.model.Groups; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; import com.netflix.spinnaker.fiat.model.resources.Role; import com.netflix.spinnaker.fiat.permissions.ExternalUser; import com.netflix.spinnaker.fiat.roles.UserRolesProvider; import java.io.FileInputStream; import java.io.IOException; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Deque; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -53,8 +58,6 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.PropertyAccessor; -import org.springframework.beans.PropertyAccessorFactory; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -180,7 +183,7 @@ public List loadRoles(ExternalUser user) { } try { - Groups groups = getGroupsFromEmail(userEmail); + Groups groups = getGroupsFromEmailRecursively(userEmail); if (groups == null || groups.getGroups() == null || groups.getGroups().isEmpty()) { return new ArrayList<>(); } @@ -191,6 +194,54 @@ public List loadRoles(ExternalUser user) { } } + /** + * Retrieves all Google Groups associated with a given email address, including both direct and + * indirect group memberships, if configured to do so. + * + *

This method first fetches the groups the user is directly a member of via {@link + * #getGroupsFromEmail(String)}. If the configuration allows expanding indirect groups (i.e., + * nested groups), it recursively traverses each group's membership to collect nested groups. + * + *

The method avoids cycles and duplicate group processing by maintaining a set of already + * collected group emails. + * + * @param email The email address whose group memberships should be retrieved. + * @return A {@link Groups} object containing all the direct and (optionally) indirect group + * memberships. + * @throws IOException If an error occurs while retrieving group information. + */ + protected Groups getGroupsFromEmailRecursively(String email) throws IOException { + final Groups groups = getGroupsFromEmail(email); + if (groups == null + || groups.getGroups() == null + || groups.getGroups().isEmpty() + || !config.isExpandIndirectGroups()) { + return groups; + } + final Set collectedGroup = new HashSet<>(); + final Deque stack = new ArrayDeque<>(); + for (Group g : groups.getGroups()) { + stack.push(g.getEmail()); + collectedGroup.add(g.getEmail()); + } + while (!stack.isEmpty()) { + String nextEmail = stack.pop(); + Groups subGroups = getGroupsFromEmail(nextEmail); + if (subGroups == null || subGroups.getGroups() == null || subGroups.getGroups().isEmpty()) { + continue; + } + for (Group g : subGroups.getGroups()) { + if (collectedGroup.contains(g.getEmail())) { + continue; + } + stack.push(g.getEmail()); + groups.getGroups().add(g); + collectedGroup.add(g.getEmail()); + } + } + return groups; + } + protected Groups getGroupsFromEmail(String email) throws IOException { final Directory service = getDirectoryService(); final Groups groups = @@ -211,12 +262,15 @@ protected Groups getGroupsFromEmail(String email) throws IOException { return groups; } - private GoogleCredential getGoogleCredential() { + private GoogleCredentials getGoogleCredential() { try { - if (StringUtils.isNotEmpty(config.getCredentialPath())) { - return GoogleCredential.fromStream(new FileInputStream(config.getCredentialPath())); + if (StringUtils.isNotEmpty(config.getCredentialPath()) + && StringUtils.isNotEmpty(config.getAdminUsername())) { + return ServiceAccountCredentials.fromStream(new FileInputStream(config.getCredentialPath())) + .createScoped(SERVICE_ACCOUNT_SCOPES) // add other scopes as needed + .createDelegated(config.getAdminUsername()); } else { - return GoogleCredential.getApplicationDefault(); + return GoogleCredentials.getApplicationDefault(); } } catch (IOException ioe) { throw new RuntimeException(ioe); @@ -226,13 +280,10 @@ private GoogleCredential getGoogleCredential() { private Directory getDirectoryService() { HttpTransport httpTransport = new NetHttpTransport(); GsonFactory jacksonFactory = new GsonFactory(); - GoogleCredential credential = getGoogleCredential(); + GoogleCredentials credentials = getGoogleCredential(); - PropertyAccessor accessor = PropertyAccessorFactory.forDirectFieldAccess(credential); - accessor.setPropertyValue("serviceAccountUser", config.getAdminUsername()); - accessor.setPropertyValue("serviceAccountScopes", SERVICE_ACCOUNT_SCOPES); - - return new Directory.Builder(httpTransport, jacksonFactory, credential) + return new Directory.Builder( + httpTransport, jacksonFactory, new HttpCredentialsAdapter(credentials)) .setApplicationName("Spinnaker-Fiat") .build(); } @@ -272,6 +323,9 @@ public static class Config { /** Google Apps for Work domain, e.g. netflix.com */ private String domain; + /** expand indirect groups for emails */ + private boolean expandIndirectGroups = false; + /** * List of sources to derive role name from group metadata, this setting is additive to allow * backwards compatibility diff --git a/fiat-google-groups/src/test/groovy/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProviderSpec.groovy b/fiat-google-groups/src/test/groovy/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProviderSpec.groovy index 3d83c9ced..d38590e12 100644 --- a/fiat-google-groups/src/test/groovy/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProviderSpec.groovy +++ b/fiat-google-groups/src/test/groovy/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProviderSpec.groovy @@ -4,6 +4,7 @@ import com.netflix.spinnaker.fiat.permissions.ExternalUser import com.google.api.services.directory.model.Group; import com.google.api.services.directory.model.Groups; import spock.lang.Specification +import spock.lang.Unroll class GoogleDirectoryUserRolesProviderSpec extends Specification { GoogleDirectoryUserRolesProvider.Config config = new GoogleDirectoryUserRolesProvider.Config() @@ -21,7 +22,7 @@ class GoogleDirectoryUserRolesProviderSpec extends Specification { GoogleDirectoryUserRolesProvider provider = new GoogleDirectoryUserRolesProvider() { @Override - Groups getGroupsFromEmail(String email) { + Groups getGroupsFromEmailRecursively(String email) { return groups } } @@ -75,9 +76,41 @@ class GoogleDirectoryUserRolesProviderSpec extends Specification { then: result6.name.size() == 0 + } + + @Unroll + def "should recursively collect all nested groups if expandIndirectGroups is #expandIndirectGroups"() { + given: + config.expandIndirectGroups = expandIndirectGroups + def provider = Spy(GoogleDirectoryUserRolesProvider) { + getGroupsFromEmail("root@example.com") >> new Groups(groups: [ + new Group(email: "child1@example.com"), + new Group(email: "child2@example.com") + ]) + getGroupsFromEmail("child1@example.com") >> new Groups(groups: [ + new Group(email: "grandchild1@example.com") + ]) + getGroupsFromEmail("child2@example.com") >> new Groups(groups: [ + new Group(email: "grandchild2@example.com"), + new Group(email: "child1@example.com") + ]) + getGroupsFromEmail("grandchild1@example.com") >> new Groups(groups: []) + getGroupsFromEmail("grandchild2@example.com") >> null + } + provider.setConfig(config) + when: + def result = provider.getGroupsFromEmailRecursively("root@example.com") + then: + result.groups*.email.containsAll(groupsContent) + result.groups.size() == totalEmails + where: + expandIndirectGroups | totalEmails | groupsContent + true | 4 | ["child1@example.com", "child2@example.com", "grandchild1@example.com", "grandchild2@example.com"] + false | 2 | ["child1@example.com", "child2@example.com"] + } private static ExternalUser externalUser(String id) {