1616
1717package com .netflix .spinnaker .fiat .roles .google ;
1818
19- import com .google .api .client .googleapis .auth .oauth2 .GoogleCredential ;
2019import com .google .api .client .googleapis .batch .BatchRequest ;
2120import com .google .api .client .googleapis .batch .json .JsonBatchCallback ;
2221import com .google .api .client .googleapis .json .GoogleJsonError ;
3231import com .google .api .services .directory .DirectoryScopes ;
3332import com .google .api .services .directory .model .Group ;
3433import 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 ;
3537import com .netflix .spinnaker .fiat .model .resources .Role ;
3638import com .netflix .spinnaker .fiat .permissions .ExternalUser ;
3739import com .netflix .spinnaker .fiat .roles .UserRolesProvider ;
3840import java .io .FileInputStream ;
3941import java .io .IOException ;
42+ import java .util .ArrayDeque ;
4043import java .util .ArrayList ;
4144import java .util .Arrays ;
4245import java .util .Collection ;
4346import java .util .Collections ;
47+ import java .util .Deque ;
4448import java .util .HashMap ;
49+ import java .util .HashSet ;
4550import java .util .List ;
4651import java .util .Map ;
4752import java .util .Set ;
5358import lombok .Setter ;
5459import lombok .extern .slf4j .Slf4j ;
5560import org .apache .commons .lang3 .StringUtils ;
56- import org .springframework .beans .PropertyAccessor ;
57- import org .springframework .beans .PropertyAccessorFactory ;
5861import org .springframework .beans .factory .InitializingBean ;
5962import org .springframework .beans .factory .annotation .Autowired ;
6063import 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
0 commit comments