Skip to content
This repository was archived by the owner on Dec 20, 2025. It is now read-only.

Commit 2540491

Browse files
feat(fiat/google-groups): expand indirect google groups for emails (#1213)
1 parent 1c7fb38 commit 2540491

File tree

2 files changed

+102
-15
lines changed

2 files changed

+102
-15
lines changed

fiat-google-groups/src/main/java/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProvider.java

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
package com.netflix.spinnaker.fiat.roles.google;
1818

19-
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
2019
import com.google.api.client.googleapis.batch.BatchRequest;
2120
import com.google.api.client.googleapis.batch.json.JsonBatchCallback;
2221
import com.google.api.client.googleapis.json.GoogleJsonError;
@@ -32,16 +31,22 @@
3231
import com.google.api.services.directory.DirectoryScopes;
3332
import com.google.api.services.directory.model.Group;
3433
import com.google.api.services.directory.model.Groups;
34+
import com.google.auth.http.HttpCredentialsAdapter;
35+
import com.google.auth.oauth2.GoogleCredentials;
36+
import com.google.auth.oauth2.ServiceAccountCredentials;
3537
import com.netflix.spinnaker.fiat.model.resources.Role;
3638
import com.netflix.spinnaker.fiat.permissions.ExternalUser;
3739
import com.netflix.spinnaker.fiat.roles.UserRolesProvider;
3840
import java.io.FileInputStream;
3941
import java.io.IOException;
42+
import java.util.ArrayDeque;
4043
import java.util.ArrayList;
4144
import java.util.Arrays;
4245
import java.util.Collection;
4346
import java.util.Collections;
47+
import java.util.Deque;
4448
import java.util.HashMap;
49+
import java.util.HashSet;
4550
import java.util.List;
4651
import java.util.Map;
4752
import java.util.Set;
@@ -53,8 +58,6 @@
5358
import lombok.Setter;
5459
import lombok.extern.slf4j.Slf4j;
5560
import org.apache.commons.lang3.StringUtils;
56-
import org.springframework.beans.PropertyAccessor;
57-
import org.springframework.beans.PropertyAccessorFactory;
5861
import org.springframework.beans.factory.InitializingBean;
5962
import org.springframework.beans.factory.annotation.Autowired;
6063
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -180,7 +183,7 @@ public List<Role> loadRoles(ExternalUser user) {
180183
}
181184

182185
try {
183-
Groups groups = getGroupsFromEmail(userEmail);
186+
Groups groups = getGroupsFromEmailRecursively(userEmail);
184187
if (groups == null || groups.getGroups() == null || groups.getGroups().isEmpty()) {
185188
return new ArrayList<>();
186189
}
@@ -191,6 +194,54 @@ public List<Role> loadRoles(ExternalUser user) {
191194
}
192195
}
193196

197+
/**
198+
* Retrieves all Google Groups associated with a given email address, including both direct and
199+
* indirect group memberships, if configured to do so.
200+
*
201+
* <p>This method first fetches the groups the user is directly a member of via {@link
202+
* #getGroupsFromEmail(String)}. If the configuration allows expanding indirect groups (i.e.,
203+
* nested groups), it recursively traverses each group's membership to collect nested groups.
204+
*
205+
* <p>The method avoids cycles and duplicate group processing by maintaining a set of already
206+
* collected group emails.
207+
*
208+
* @param email The email address whose group memberships should be retrieved.
209+
* @return A {@link Groups} object containing all the direct and (optionally) indirect group
210+
* memberships.
211+
* @throws IOException If an error occurs while retrieving group information.
212+
*/
213+
protected Groups getGroupsFromEmailRecursively(String email) throws IOException {
214+
final Groups groups = getGroupsFromEmail(email);
215+
if (groups == null
216+
|| groups.getGroups() == null
217+
|| groups.getGroups().isEmpty()
218+
|| !config.isExpandIndirectGroups()) {
219+
return groups;
220+
}
221+
final Set<String> collectedGroup = new HashSet<>();
222+
final Deque<String> stack = new ArrayDeque<>();
223+
for (Group g : groups.getGroups()) {
224+
stack.push(g.getEmail());
225+
collectedGroup.add(g.getEmail());
226+
}
227+
while (!stack.isEmpty()) {
228+
String nextEmail = stack.pop();
229+
Groups subGroups = getGroupsFromEmail(nextEmail);
230+
if (subGroups == null || subGroups.getGroups() == null || subGroups.getGroups().isEmpty()) {
231+
continue;
232+
}
233+
for (Group g : subGroups.getGroups()) {
234+
if (collectedGroup.contains(g.getEmail())) {
235+
continue;
236+
}
237+
stack.push(g.getEmail());
238+
groups.getGroups().add(g);
239+
collectedGroup.add(g.getEmail());
240+
}
241+
}
242+
return groups;
243+
}
244+
194245
protected Groups getGroupsFromEmail(String email) throws IOException {
195246
final Directory service = getDirectoryService();
196247
final Groups groups =
@@ -211,12 +262,15 @@ protected Groups getGroupsFromEmail(String email) throws IOException {
211262
return groups;
212263
}
213264

214-
private GoogleCredential getGoogleCredential() {
265+
private GoogleCredentials getGoogleCredential() {
215266
try {
216-
if (StringUtils.isNotEmpty(config.getCredentialPath())) {
217-
return GoogleCredential.fromStream(new FileInputStream(config.getCredentialPath()));
267+
if (StringUtils.isNotEmpty(config.getCredentialPath())
268+
&& StringUtils.isNotEmpty(config.getAdminUsername())) {
269+
return ServiceAccountCredentials.fromStream(new FileInputStream(config.getCredentialPath()))
270+
.createScoped(SERVICE_ACCOUNT_SCOPES) // add other scopes as needed
271+
.createDelegated(config.getAdminUsername());
218272
} else {
219-
return GoogleCredential.getApplicationDefault();
273+
return GoogleCredentials.getApplicationDefault();
220274
}
221275
} catch (IOException ioe) {
222276
throw new RuntimeException(ioe);
@@ -226,13 +280,10 @@ private GoogleCredential getGoogleCredential() {
226280
private Directory getDirectoryService() {
227281
HttpTransport httpTransport = new NetHttpTransport();
228282
GsonFactory jacksonFactory = new GsonFactory();
229-
GoogleCredential credential = getGoogleCredential();
283+
GoogleCredentials credentials = getGoogleCredential();
230284

231-
PropertyAccessor accessor = PropertyAccessorFactory.forDirectFieldAccess(credential);
232-
accessor.setPropertyValue("serviceAccountUser", config.getAdminUsername());
233-
accessor.setPropertyValue("serviceAccountScopes", SERVICE_ACCOUNT_SCOPES);
234-
235-
return new Directory.Builder(httpTransport, jacksonFactory, credential)
285+
return new Directory.Builder(
286+
httpTransport, jacksonFactory, new HttpCredentialsAdapter(credentials))
236287
.setApplicationName("Spinnaker-Fiat")
237288
.build();
238289
}
@@ -272,6 +323,9 @@ public static class Config {
272323
/** Google Apps for Work domain, e.g. netflix.com */
273324
private String domain;
274325

326+
/** expand indirect groups for emails */
327+
private boolean expandIndirectGroups = false;
328+
275329
/**
276330
* List of sources to derive role name from group metadata, this setting is additive to allow
277331
* backwards compatibility

fiat-google-groups/src/test/groovy/com/netflix/spinnaker/fiat/roles/google/GoogleDirectoryUserRolesProviderSpec.groovy

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.netflix.spinnaker.fiat.permissions.ExternalUser
44
import com.google.api.services.directory.model.Group;
55
import com.google.api.services.directory.model.Groups;
66
import spock.lang.Specification
7+
import spock.lang.Unroll
78

89
class GoogleDirectoryUserRolesProviderSpec extends Specification {
910
GoogleDirectoryUserRolesProvider.Config config = new GoogleDirectoryUserRolesProvider.Config()
@@ -21,7 +22,7 @@ class GoogleDirectoryUserRolesProviderSpec extends Specification {
2122

2223
GoogleDirectoryUserRolesProvider provider = new GoogleDirectoryUserRolesProvider() {
2324
@Override
24-
Groups getGroupsFromEmail(String email) {
25+
Groups getGroupsFromEmailRecursively(String email) {
2526
return groups
2627
}
2728
}
@@ -75,9 +76,41 @@ class GoogleDirectoryUserRolesProviderSpec extends Specification {
7576

7677
then:
7778
result6.name.size() == 0
79+
}
80+
81+
@Unroll
82+
def "should recursively collect all nested groups if expandIndirectGroups is #expandIndirectGroups"() {
83+
given:
84+
config.expandIndirectGroups = expandIndirectGroups
85+
def provider = Spy(GoogleDirectoryUserRolesProvider) {
86+
getGroupsFromEmail("root@example.com") >> new Groups(groups: [
87+
new Group(email: "child1@example.com"),
88+
new Group(email: "child2@example.com")
89+
])
90+
getGroupsFromEmail("child1@example.com") >> new Groups(groups: [
91+
new Group(email: "grandchild1@example.com")
92+
])
93+
getGroupsFromEmail("child2@example.com") >> new Groups(groups: [
94+
new Group(email: "grandchild2@example.com"),
95+
new Group(email: "child1@example.com")
96+
])
97+
getGroupsFromEmail("grandchild1@example.com") >> new Groups(groups: [])
98+
getGroupsFromEmail("grandchild2@example.com") >> null
99+
}
100+
provider.setConfig(config)
78101

102+
when:
103+
def result = provider.getGroupsFromEmailRecursively("root@example.com")
79104

105+
then:
106+
result.groups*.email.containsAll(groupsContent)
107+
result.groups.size() == totalEmails
80108

109+
where:
110+
expandIndirectGroups | totalEmails | groupsContent
111+
true | 4 | ["child1@example.com", "child2@example.com", "grandchild1@example.com", "grandchild2@example.com"]
112+
false | 2 | ["child1@example.com", "child2@example.com"]
113+
81114
}
82115

83116
private static ExternalUser externalUser(String id) {

0 commit comments

Comments
 (0)