Skip to content

Commit 3a5dfa3

Browse files
committed
feat: allow Workload Identity Federation for Google connector
A new Google connector option, `useCloudIdentityApi`, has been introduced. If the value it `true`, dex will use cloud identity api to fetch groups. In particular, no user impersonation happens. The linked service account needs to be a assigned to a custom admin role created in Google workspace. This role requires group read rights. assumed that Workload Identity Federation shall be used, and the Moreover, if the JSON propvided for the service account is not of type `service_account` it is assumed it's configued for Workload Identity Federation. In that case, the linked service account needs to include `Service Account Token Creator`. Signed-off-by: Michael Dudzinski <michael.dudzinski@aetherize.com>
1 parent 7c97449 commit 3a5dfa3

2 files changed

Lines changed: 346 additions & 26 deletions

File tree

connector/google/google.go

Lines changed: 155 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
7082
func (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

Comments
 (0)