@@ -17,6 +17,7 @@ import (
1717 "golang.org/x/oauth2"
1818 "golang.org/x/oauth2/google"
1919 admin "google.golang.org/api/admin/directory/v1"
20+ "google.golang.org/api/cloudidentity/v1"
2021 "google.golang.org/api/impersonate"
2122 "google.golang.org/api/option"
2223
@@ -53,11 +54,14 @@ type Config struct {
5354 // Deprecated: Use DomainToAdminEmail
5455 AdminEmail string
5556
56- // Required if ServiceAccountFilePath
57+ // If ServiceAccountFilePath is set, this value is ignored if UseCloudIdentityAPI is set. Otherwise, it's required.
5758 // The map workspace domain to email of a GSuite super user which the service account will impersonate
5859 // when listing groups
5960 DomainToAdminEmail map [string ]string
6061
62+ // If set, Cloud Identity API is used to fetch groups for a user. Defaults to false.
63+ UseCloudIdentityAPI bool `json:"useCloudIdentityAPI"`
64+
6165 // If this field is true, fetch direct group membership and transitive group membership
6266 FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership"`
6367
@@ -66,6 +70,14 @@ type Config struct {
6670 PromptType * string `json:"promptType"`
6771}
6872
73+ func validateConfigForCloudIdentity (c * Config , logger * slog.Logger ) error {
74+ if len (c .DomainToAdminEmail ) > 0 || len (c .AdminEmail ) > 0 {
75+ logger .Warn ("For cloud identity calls \" DomainToAdminEmail\" and \" AdminEmail\" are ignored. It's safe to remove both configuration options." )
76+ }
77+
78+ return nil
79+ }
80+
6981// Open returns a connector which can be used to login users through Google.
7082func (c * Config ) Open (id string , logger * slog.Logger ) (conn connector.Connector , err error ) {
7183 logger = logger .With (slog .Group ("connector" , "type" , "google" , "id" , id ))
@@ -94,22 +106,38 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector,
94106
95107 adminSrv := make (map [string ]* admin.Service )
96108
97- // We know impersonation is required when using a service account credential
98- // TODO: or is it?
99- if len (c .DomainToAdminEmail ) == 0 && c .ServiceAccountFilePath != "" {
100- cancel ()
101- return nil , fmt .Errorf ("directory service requires the domainToAdminEmail option to be configured" )
102- }
109+ var groupsMembershipsService * cloudidentity.GroupsMembershipsService
103110
104- if (len (c .DomainToAdminEmail ) > 0 ) || slices .Contains (scopes , "groups" ) {
105- for domain , adminEmail := range c .DomainToAdminEmail {
106- srv , err := createDirectoryService (c .ServiceAccountFilePath , adminEmail , logger )
107- if err != nil {
108- cancel ()
109- return nil , fmt .Errorf ("could not create directory service: %v" , err )
110- }
111+ if c .UseCloudIdentityAPI {
112+ err = validateConfigForCloudIdentity (c , logger )
113+ if err != nil {
114+ cancel ()
115+ return nil , err
116+ }
117+
118+ groupsMembershipsService , err = createGroupsMembershipsService (c .ServiceAccountFilePath , logger )
119+ if err != nil {
120+ cancel ()
121+ return nil , fmt .Errorf ("could not create groups memebership service: %v" , err )
122+ }
123+ } else {
124+ // We know impersonation is required when using a service account credential
125+ // TODO: or is it?
126+ if len (c .DomainToAdminEmail ) == 0 && c .ServiceAccountFilePath != "" {
127+ cancel ()
128+ return nil , fmt .Errorf ("directory service requires the domainToAdminEmail option to be configured" )
129+ }
130+
131+ if (len (c .DomainToAdminEmail ) > 0 ) || slices .Contains (scopes , "groups" ) {
132+ for domain , adminEmail := range c .DomainToAdminEmail {
133+ srv , err := createDirectoryService (c .ServiceAccountFilePath , adminEmail , logger )
134+ if err != nil {
135+ cancel ()
136+ return nil , fmt .Errorf ("could not create directory service: %v" , err )
137+ }
111138
112- adminSrv [domain ] = srv
139+ adminSrv [domain ] = srv
140+ }
113141 }
114142 }
115143
@@ -137,8 +165,10 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector,
137165 groups : c .Groups ,
138166 serviceAccountFilePath : c .ServiceAccountFilePath ,
139167 domainToAdminEmail : c .DomainToAdminEmail ,
168+ useCloudIdentityAPI : c .UseCloudIdentityAPI ,
140169 fetchTransitiveGroupMembership : c .FetchTransitiveGroupMembership ,
141170 adminSrv : adminSrv ,
171+ groupsMembershipsService : groupsMembershipsService ,
142172 promptType : promptType ,
143173 }, nil
144174}
@@ -158,8 +188,10 @@ type googleConnector struct {
158188 groups []string
159189 serviceAccountFilePath string
160190 domainToAdminEmail map [string ]string
191+ useCloudIdentityAPI bool
161192 fetchTransitiveGroupMembership bool
162193 adminSrv map [string ]* admin.Service
194+ groupsMembershipsService * cloudidentity.GroupsMembershipsService
163195 promptType string
164196}
165197
@@ -262,14 +294,25 @@ func (c *googleConnector) createIdentity(ctx context.Context, identity connector
262294 }
263295
264296 var groups []string
265- if s .Groups && len ( c . adminSrv ) > 0 {
297+ if s .Groups {
266298 checkedGroups := make (map [string ]struct {})
267- groups , err = c .getGroups (claims .Email , c .fetchTransitiveGroupMembership , checkedGroups )
268- if err != nil {
269- return identity , fmt .Errorf ("google: could not retrieve groups: %v" , err )
299+ if c .useCloudIdentityAPI {
300+ if c .groupsMembershipsService != nil {
301+ groups , err = c .getGroupsFromCloudIdentityAPI (claims .Email , c .fetchTransitiveGroupMembership , checkedGroups )
302+ if err != nil {
303+ return identity , fmt .Errorf ("google: could not retrieve groups from Cloud Identity API: %v" , err )
304+ }
305+ }
306+ } else {
307+ if len (c .adminSrv ) > 0 {
308+ groups , err = c .getGroupsFromAdminAPI (claims .Email , c .fetchTransitiveGroupMembership , checkedGroups )
309+ if err != nil {
310+ return identity , fmt .Errorf ("google: could not retrieve groups form Admin API: %v" , err )
311+ }
312+ }
270313 }
271314
272- if len (c .groups ) > 0 {
315+ if len (c .groups ) > 0 && len ( groups ) > 0 {
273316 groups = pkg_groups .Filter (groups , c .groups )
274317 if len (groups ) == 0 {
275318 return identity , fmt .Errorf ("google: user %q is not in any of the required groups" , claims .Username )
@@ -288,9 +331,9 @@ func (c *googleConnector) createIdentity(ctx context.Context, identity connector
288331 return identity , nil
289332}
290333
291- // getGroups creates a connection to the admin directory service and lists
334+ // getGroupsFromAdminAPI creates a connection to the admin directory service and lists
292335// all groups the user is a member of
293- func (c * googleConnector ) getGroups (email string , fetchTransitiveGroupMembership bool , checkedGroups map [string ]struct {}) ([]string , error ) {
336+ func (c * googleConnector ) getGroupsFromAdminAPI (email string , fetchTransitiveGroupMembership bool , checkedGroups map [string ]struct {}) ([]string , error ) {
294337 var userGroups []string
295338 var err error
296339 groupsList := & admin.Groups {}
@@ -321,7 +364,54 @@ func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership
321364 }
322365
323366 // getGroups takes a user's email/alias as well as a group's email/alias
324- transitiveGroups , err := c .getGroups (group .Email , fetchTransitiveGroupMembership , checkedGroups )
367+ transitiveGroups , err := c .getGroupsFromAdminAPI (group .Email , fetchTransitiveGroupMembership , checkedGroups )
368+ if err != nil {
369+ return nil , fmt .Errorf ("could not list transitive groups: %v" , err )
370+ }
371+
372+ userGroups = append (userGroups , transitiveGroups ... )
373+ }
374+
375+ if groupsList .NextPageToken == "" {
376+ break
377+ }
378+ }
379+
380+ return userGroups , nil
381+ }
382+
383+ // getGroupsFromCloudIdentityAPI creates a connection to the cloud identity service and lists
384+ // all groups the user is a member of
385+ func (c * googleConnector ) getGroupsFromCloudIdentityAPI (email string , fetchTransitiveGroupMembership bool , checkedGroups map [string ]struct {}) ([]string , error ) {
386+ var userGroups []string
387+ var err error
388+ groupsList := & cloudidentity.SearchDirectGroupsResponse {}
389+ groupsMembershipService := c .groupsMembershipsService
390+
391+ for {
392+ query := fmt .Sprintf ("member_key_id=='%s'" , email )
393+ groupsList , err = groupsMembershipService .SearchDirectGroups ("groups/-" ).
394+ Query (query ).PageToken (groupsList .NextPageToken ).Do ()
395+ if err != nil {
396+ return nil , fmt .Errorf ("could not list groups: %v" , err )
397+ }
398+
399+ for _ , membership := range groupsList .Memberships {
400+ groupEmail := membership .GroupKey .Id
401+ if _ , exists := checkedGroups [groupEmail ]; exists {
402+ continue
403+ }
404+
405+ checkedGroups [groupEmail ] = struct {}{}
406+ // TODO (joelspeed): Make desired group key configurable
407+ userGroups = append (userGroups , groupEmail )
408+
409+ if ! fetchTransitiveGroupMembership {
410+ continue
411+ }
412+
413+ // getGroups takes a user's email/alias as well as a group's email/alias
414+ transitiveGroups , err := c .getGroupsFromCloudIdentityAPI (groupEmail , fetchTransitiveGroupMembership , checkedGroups )
325415 if err != nil {
326416 return nil , fmt .Errorf ("could not list transitive groups: %v" , err )
327417 }
@@ -455,3 +545,45 @@ func createDirectoryService(serviceAccountFilePath, email string, logger *slog.L
455545
456546 return admin .NewService (ctx , option .WithHTTPClient (config .Client (ctx )))
457547}
548+
549+ func createGroupsMembershipsService (serviceAccountFilePath string , logger * slog.Logger ) (service * cloudidentity.GroupsMembershipsService , err error ) {
550+ var jsonCredentials []byte
551+
552+ ctx := context .Background ()
553+ if serviceAccountFilePath == "" {
554+ logger .Warn ("the application default credential is used since the service account file path is not used" )
555+ credential , err := google .FindDefaultCredentials (ctx )
556+ if err != nil {
557+ return nil , fmt .Errorf ("failed to fetch application default credentials: %w" , err )
558+ }
559+
560+ if credential .JSON == nil {
561+ return nil , fmt .Errorf ("application default credentials returned empty JSON" )
562+ }
563+
564+ jsonCredentials = credential .JSON
565+ } else {
566+ logger .Debug ("Using credentials at" , "sa_path" , serviceAccountFilePath )
567+ jsonCredentials , err = os .ReadFile (serviceAccountFilePath )
568+ if err != nil {
569+ return nil , fmt .Errorf ("error reading credentials from file: %v" , err )
570+ }
571+ }
572+
573+ var cloudIdentityService * cloudidentity.Service
574+
575+ config , err := google .JWTConfigFromJSON (jsonCredentials , cloudidentity .CloudIdentityGroupsReadonlyScope )
576+
577+ if err == nil {
578+ cloudIdentityService , err = cloudidentity .NewService (ctx , option .WithHTTPClient (config .Client (ctx )))
579+ } else {
580+ // most probably type != service_account
581+ cloudIdentityService , err = cloudidentity .NewService (ctx , option .WithCredentialsJSON (jsonCredentials ), option .WithScopes (cloudidentity .CloudIdentityGroupsReadonlyScope ))
582+ }
583+
584+ if err != nil {
585+ return nil , fmt .Errorf ("error creating cloud identity service: %v" , err )
586+ }
587+
588+ return cloudidentity .NewGroupsMembershipsService (cloudIdentityService ), nil
589+ }
0 commit comments