From 27d7eb532b5809ac8846ad8ab8f76bddeca98593 Mon Sep 17 00:00:00 2001 From: Viacheslav Sychov Date: Sun, 23 Apr 2023 20:41:43 +0200 Subject: [PATCH] #2895: Add Support for Multiple Admin Emails to Retrieve Group Lists from Different Google Workspaces Signed-off-by: Viacheslav Sychov --- connector/google/google.go | 93 +++++++++++++++++++++++++-------- connector/google/google_test.go | 28 +++++----- 2 files changed, 85 insertions(+), 36 deletions(-) diff --git a/connector/google/google.go b/connector/google/google.go index f80c3586ab..43256216ec 100644 --- a/connector/google/google.go +++ b/connector/google/google.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "strings" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -22,7 +23,8 @@ import ( ) const ( - issuerURL = "https://accounts.google.com" + issuerURL = "https://accounts.google.com" + wildcardDomainToAdminEmail = "*" ) // Config holds configuration options for Google logins. @@ -46,10 +48,13 @@ type Config struct { // check groups with the admin directory api ServiceAccountFilePath string `json:"serviceAccountFilePath"` + // Deprecated: Use DomainToAdminEmail + AdminEmail string + // Required if ServiceAccountFilePath - // The email of a GSuite super user which the service account will impersonate + // The map workspace domain to email of a GSuite super user which the service account will impersonate // when listing groups - AdminEmail string + DomainToAdminEmail map[string]string // If this field is true, fetch direct group membership and transitive group membership FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership"` @@ -57,6 +62,14 @@ type Config struct { // Open returns a connector which can be used to login users through Google. func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) { + if len(c.AdminEmail) != 0 { + log.Deprecated(logger, `google: use "domainToAdminEmail.*: %s" option instead of "adminEmail: %s".`, c.AdminEmail, c.AdminEmail) + if c.DomainToAdminEmail == nil { + c.DomainToAdminEmail = make(map[string]string) + } + + c.DomainToAdminEmail[wildcardDomainToAdminEmail] = c.AdminEmail + } ctx, cancel := context.WithCancel(context.Background()) provider, err := oidc.NewProvider(ctx, issuerURL) @@ -72,17 +85,26 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e scopes = append(scopes, "profile", "email") } - var adminSrv *admin.Service + adminSrv := make(map[string]*admin.Service) + + // We know impersonation is required when using a service account credential + // TODO: or is it? + if len(c.DomainToAdminEmail) == 0 && c.ServiceAccountFilePath != "" { + cancel() + return nil, fmt.Errorf("directory service requires adminEmail") + } // Fixing a regression caused by default config fallback: https://github.com/dexidp/dex/issues/2699 - if (c.ServiceAccountFilePath != "" && c.AdminEmail != "") || slices.Contains(scopes, "groups") { - srv, err := createDirectoryService(c.ServiceAccountFilePath, c.AdminEmail, logger) - if err != nil { - cancel() - return nil, fmt.Errorf("could not create directory service: %v", err) - } + if (c.ServiceAccountFilePath != "" && len(c.DomainToAdminEmail) != 0) || slices.Contains(scopes, "groups") { + for domain, adminEmail := range c.DomainToAdminEmail { + srv, err := createDirectoryService(c.ServiceAccountFilePath, adminEmail, logger) + if err != nil { + cancel() + return nil, fmt.Errorf("could not create directory service: %v", err) + } - adminSrv = srv + adminSrv[domain] = srv + } } clientID := c.ClientID @@ -103,7 +125,7 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e hostedDomains: c.HostedDomains, groups: c.Groups, serviceAccountFilePath: c.ServiceAccountFilePath, - adminEmail: c.AdminEmail, + domainToAdminEmail: c.DomainToAdminEmail, fetchTransitiveGroupMembership: c.FetchTransitiveGroupMembership, adminSrv: adminSrv, }, nil @@ -123,9 +145,9 @@ type googleConnector struct { hostedDomains []string groups []string serviceAccountFilePath string - adminEmail string + domainToAdminEmail map[string]string fetchTransitiveGroupMembership bool - adminSrv *admin.Service + adminSrv map[string]*admin.Service } func (c *googleConnector) Close() error { @@ -226,7 +248,7 @@ func (c *googleConnector) createIdentity(ctx context.Context, identity connector } var groups []string - if s.Groups && c.adminSrv != nil { + if s.Groups && len(c.adminSrv) != 0 { checkedGroups := make(map[string]struct{}) groups, err = c.getGroups(claims.Email, c.fetchTransitiveGroupMembership, checkedGroups) if err != nil { @@ -258,8 +280,14 @@ func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership var userGroups []string var err error groupsList := &admin.Groups{} + domain := c.extractDomainFromEmail(email) + adminSrv, err := c.findAdminService(domain) + if err != nil { + return nil, err + } + for { - groupsList, err = c.adminSrv.Groups.List(). + groupsList, err = adminSrv.Groups.List(). UserKey(email).PageToken(groupsList.NextPageToken).Do() if err != nil { return nil, fmt.Errorf("could not list groups: %v", err) @@ -295,16 +323,37 @@ func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership return userGroups, nil } +func (c *googleConnector) findAdminService(domain string) (*admin.Service, error) { + adminSrv, ok := c.adminSrv[domain] + if !ok { + adminSrv, ok = c.adminSrv[wildcardDomainToAdminEmail] + c.logger.Debugf("using wildcard (%s) admin email to fetch groups", c.domainToAdminEmail[wildcardDomainToAdminEmail]) + } + + if !ok { + return nil, fmt.Errorf("unable to find super admin email, domainToAdminEmail for domain: %s not set, %s is also empty", domain, wildcardDomainToAdminEmail) + } + + return adminSrv, nil +} + +// extracts the domain name from an email input. If the email is valid, it returns the domain name after the "@" symbol. +// However, in the case of a broken or invalid email, it returns a wildcard symbol. +func (c *googleConnector) extractDomainFromEmail(email string) string { + at := strings.LastIndex(email, "@") + if at >= 0 { + _, domain := email[:at], email[at+1:] + + return domain + } + + return wildcardDomainToAdminEmail +} + // createDirectoryService sets up super user impersonation and creates an admin client for calling // the google admin api. If no serviceAccountFilePath is defined, the application default credential // is used. func createDirectoryService(serviceAccountFilePath, email string, logger log.Logger) (*admin.Service, error) { - // We know impersonation is required when using a service account credential - // TODO: or is it? - if email == "" && serviceAccountFilePath != "" { - return nil, fmt.Errorf("directory service requires adminEmail") - } - var jsonCredentials []byte var err error diff --git a/connector/google/google_test.go b/connector/google/google_test.go index cf5977ab6a..5c21109ef9 100644 --- a/connector/google/google_test.go +++ b/connector/google/google_test.go @@ -110,7 +110,7 @@ func TestOpen(t *testing.T) { ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, - AdminEmail: "foo@bar.com", + DomainToAdminEmail: map[string]string{"*": "foo@bar.com"}, ServiceAccountFilePath: "not_found.json", }, expectedErr: "error reading credentials", @@ -121,18 +121,18 @@ func TestOpen(t *testing.T) { ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, - AdminEmail: "foo@bar.com", + DomainToAdminEmail: map[string]string{"bar.com": "foo@bar.com"}, ServiceAccountFilePath: serviceAccountFilePath, }, expectedErr: "", }, "adc": { config: &Config{ - ClientID: "testClient", - ClientSecret: "testSecret", - RedirectURI: ts.URL + "/callback", - Scopes: []string{"openid", "groups"}, - AdminEmail: "foo@bar.com", + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + DomainToAdminEmail: map[string]string{"*": "foo@bar.com"}, }, adc: serviceAccountFilePath, expectedErr: "", @@ -143,7 +143,7 @@ func TestOpen(t *testing.T) { ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, - AdminEmail: "foo@bar.com", + DomainToAdminEmail: map[string]string{"*": "foo@bar.com"}, ServiceAccountFilePath: serviceAccountFilePath, }, adc: "/dev/null", @@ -176,15 +176,15 @@ func TestGetGroups(t *testing.T) { os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountFilePath) conn, err := newConnector(&Config{ - ClientID: "testClient", - ClientSecret: "testSecret", - RedirectURI: ts.URL + "/callback", - Scopes: []string{"openid", "groups"}, - AdminEmail: "admin@dexidp.com", + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + DomainToAdminEmail: map[string]string{"*": "admin@dexidp.com"}, }) assert.Nil(t, err) - conn.adminSrv, err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL)) + conn.adminSrv[wildcardDomainToAdminEmail], err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL)) assert.Nil(t, err) type testCase struct { userKey string