Skip to content

Commit

Permalink
feat: allow Workload Identity Federation for Google connector
Browse files Browse the repository at this point in the history
* A new Google connector option have been introduced, i.e.,
  `serviceAccountToImpersonate`. If this field is non-empty, it is
  assumed that Workload Identity Federation shall be used, and the
  linked service account needs to be configured for domain-wide
  delegation. Moreover, the service account used for Workload Identity
  Federation must include `Service Account Token Creator` for this
  service account.
* Print some warnings if the configuration is not consistent or erroneous.
* Fix fetching groups to rely on `groups` as scope. In the case `groups`
  is specified as a scope, the oauth authentication call will fail as
  Google doesn't support it. Moreover, as fetching groups requires the
  group directory service, it is enough to assume the existence of this
  service as a prerequisite for the fetch. If `groups` is specified as a
  scope, a warning is printed, instead of erroring out, for backwards
  compatibility reasons.
* When specifying `groups` in the configuration, but no group directory
  service will be created, a warning is printed that the groups
  configuration will be ignored.

Signed-off-by: Author Name <[email protected]>
  • Loading branch information
midu-git committed Oct 26, 2023
1 parent 8f3935a commit 113be40
Show file tree
Hide file tree
Showing 2 changed files with 173 additions and 10 deletions.
90 changes: 80 additions & 10 deletions connector/google/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"google.golang.org/api/impersonate"
"net/http"
"os"
"strings"
Expand Down Expand Up @@ -56,10 +57,35 @@ type Config struct {
// when listing groups
DomainToAdminEmail map[string]string

// Optional email of the service account enabled for domain-wide delegation
// If nonempty, it is assumed that Workload Identity Federation is to be used. In that case, the
// specified service account needs to be configured for domain-wide delegation and the service account
// used for Workload Identity Federation must include "Service Account Token Creator" for the specified
// service account.
ServiceAccountToImpersonate string `json:"serviceAccountToImpersonate"`

// If this field is true, fetch direct group membership and transitive group membership
FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership"`
}

func validateConfigAndPrintResult(c *Config, logger log.Logger) error {
if slices.Contains(c.Scopes, "groups") {
logger.Warnf("\"scopes\" contain \"groups\" which is not supported by Google. This will result in an error on request.")
}

if len(c.Groups) > 0 && len(c.DomainToAdminEmail) == 0 {
logger.Warnf("\"groups\" is specified in the configuration, but no Google service account has been configured to be used. \"groups\" will be ignored.")
}

// We know impersonation is required when using a service account credential
// TODO: or is it?
if len(c.DomainToAdminEmail) == 0 && (c.ServiceAccountFilePath != "" || c.ServiceAccountToImpersonate != "") {
return fmt.Errorf("directory service requires the domainToAdminEmail option to be configured")
}

return nil
}

// 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 c.AdminEmail != "" {
Expand Down Expand Up @@ -87,24 +113,36 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e

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 != "" {
if err := validateConfigAndPrintResult(c, logger); err != nil {
cancel()
return nil, fmt.Errorf("directory service requires the domainToAdminEmail option to be configured")
return nil, err
}

// Fixing a regression caused by default config fallback: https://github.com/dexidp/dex/issues/2699
if (c.ServiceAccountFilePath != "" && len(c.DomainToAdminEmail) > 0) || slices.Contains(scopes, "groups") {
// TODO: if scopes contain "group", the oauth request to Google will fail with "Some requested scopes were invalid ... invalid=[groups]"
if ((c.ServiceAccountFilePath != "" || c.ServiceAccountToImpersonate != "") && len(c.DomainToAdminEmail) > 0) || slices.Contains(scopes, "groups") {
logger.Debug("Directory service will be configured.")

for domain, adminEmail := range c.DomainToAdminEmail {
srv, err := createDirectoryService(c.ServiceAccountFilePath, adminEmail, logger)
var srv *admin.Service
var err error

if c.ServiceAccountToImpersonate == "" {
logger.Debugf("Using Service Account Key for domain '%s' impersonating '%s'.", domain, adminEmail)
srv, err = createDirectoryService(c.ServiceAccountFilePath, adminEmail, logger)
} else {
logger.Debugf("Using Workload Identity Federation with SA '%s' for domain '%s' impersonating '%s'.", c.ServiceAccountToImpersonate, domain, adminEmail)
srv, err = createDirectoryServiceForWorkloadIdentityFederation(c.ServiceAccountFilePath, c.ServiceAccountToImpersonate, adminEmail, logger)
}
if err != nil {
cancel()
return nil, fmt.Errorf("could not create directory service: %v", err)
}

adminSrv[domain] = srv
}
} else {
logger.Debug("Directory service will not be configured.")
}

clientID := c.ClientID
Expand All @@ -126,6 +164,7 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e
groups: c.Groups,
serviceAccountFilePath: c.ServiceAccountFilePath,
domainToAdminEmail: c.DomainToAdminEmail,
serviceAccountToImpersonate: c.ServiceAccountToImpersonate,
fetchTransitiveGroupMembership: c.FetchTransitiveGroupMembership,
adminSrv: adminSrv,
}, nil
Expand All @@ -146,6 +185,7 @@ type googleConnector struct {
groups []string
serviceAccountFilePath string
domainToAdminEmail map[string]string
serviceAccountToImpersonate string
fetchTransitiveGroupMembership bool
adminSrv map[string]*admin.Service
}
Expand Down Expand Up @@ -197,7 +237,7 @@ func (c *googleConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
return identity, fmt.Errorf("google: failed to get token: %v", err)
}

return c.createIdentity(r.Context(), identity, s, token)
return c.createIdentity(r.Context(), identity, token)
}

func (c *googleConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
Expand All @@ -210,10 +250,10 @@ func (c *googleConnector) Refresh(ctx context.Context, s connector.Scopes, ident
return identity, fmt.Errorf("google: failed to get token: %v", err)
}

return c.createIdentity(ctx, identity, s, token)
return c.createIdentity(ctx, identity, token)
}

func (c *googleConnector) createIdentity(ctx context.Context, identity connector.Identity, s connector.Scopes, token *oauth2.Token) (connector.Identity, error) {
func (c *googleConnector) createIdentity(ctx context.Context, identity connector.Identity, token *oauth2.Token) (connector.Identity, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return identity, errors.New("google: no id_token in token response")
Expand Down Expand Up @@ -248,7 +288,7 @@ func (c *googleConnector) createIdentity(ctx context.Context, identity connector
}

var groups []string
if s.Groups && len(c.adminSrv) > 0 {
if len(c.adminSrv) > 0 {
checkedGroups := make(map[string]struct{})
groups, err = c.getGroups(claims.Email, c.fetchTransitiveGroupMembership, checkedGroups)
if err != nil {
Expand Down Expand Up @@ -383,3 +423,33 @@ func createDirectoryService(serviceAccountFilePath, email string, logger log.Log

return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx)))
}

func createDirectoryServiceForWorkloadIdentityFederation(serviceAccountFilePath, serviceAccountWithDWD, email string, logger log.Logger) (*admin.Service, error) {
var err error
var ts oauth2.TokenSource

ctx := context.Background()
var config = impersonate.CredentialsConfig{
Subject: email,
Scopes: []string{admin.AdminDirectoryGroupReadonlyScope},
TargetPrincipal: serviceAccountWithDWD,
}

if serviceAccountFilePath == "" {
logger.Debug("Using application default credentials.")
ts, err = impersonate.CredentialsTokenSource(ctx, config)
} else {
logger.Debugf("Using credentials at '%s'.", serviceAccountFilePath)
var jsonCredentials []byte
jsonCredentials, err = os.ReadFile(serviceAccountFilePath)
if err != nil {
return nil, fmt.Errorf("error reading credentials from file: %v", err)
}
ts, err = impersonate.CredentialsTokenSource(ctx, config, option.WithCredentialsJSON(jsonCredentials))
}
if err != nil {
return nil, fmt.Errorf("failed to create : %v", err)
}

return admin.NewService(ctx, option.WithTokenSource(ts))
}
93 changes: 93 additions & 0 deletions connector/google/google_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,28 @@ func tempServiceAccountKey() (string, error) {
return fd.Name(), err
}

func tempWorkloadIdentityFederation() (string, error) {
fd, err := os.CreateTemp("", "workload_identity_federation")
if err != nil {
return "", err
}
defer fd.Close()
err = json.NewEncoder(fd).Encode(map[string]any{
"type": "external_account",
"audience": "//iam.googleapis.com/projects/111111111111/locations/global/workloadIdentityPools/aws-pool/providers/aws-provider",
"subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": map[string]string{
"environment_id": "aws1",
"region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
"regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
},
})
return fd.Name(), err
}

func TestOpen(t *testing.T) {
ts := testSetup()
defer ts.Close()
Expand Down Expand Up @@ -237,6 +259,77 @@ func TestGetGroups(t *testing.T) {
}
}

func TestGetGroupsWithWorkloadIdentityFederation(t *testing.T) {
ts := testSetup()
defer ts.Close()

workloadIdentityFederationFilePath, err := tempWorkloadIdentityFederation()
assert.Nil(t, err)

os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", workloadIdentityFederationFilePath)
conn, err := newConnector(&Config{
ClientID: "testClient",
ClientSecret: "testSecret",
RedirectURI: ts.URL + "/callback",
Scopes: []string{"openid", "groups"},
DomainToAdminEmail: map[string]string{"*": "[email protected]"},
ServiceAccountToImpersonate: "[email protected]",
})
assert.Nil(t, err)

conn.adminSrv[wildcardDomainToAdminEmail], err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL))
assert.Nil(t, err)
type testCase struct {
userKey string
fetchTransitiveGroupMembership bool
shouldErr bool
expectedGroups []string
}

for name, testCase := range map[string]testCase{
"user1_non_transitive_lookup": {
userKey: "[email protected]",
fetchTransitiveGroupMembership: false,
shouldErr: false,
expectedGroups: []string{"[email protected]", "[email protected]"},
},
"user1_transitive_lookup": {
userKey: "[email protected]",
fetchTransitiveGroupMembership: true,
shouldErr: false,
expectedGroups: []string{"[email protected]", "[email protected]", "[email protected]"},
},
"user2_non_transitive_lookup": {
userKey: "[email protected]",
fetchTransitiveGroupMembership: false,
shouldErr: false,
expectedGroups: []string{"[email protected]"},
},
"user2_transitive_lookup": {
userKey: "[email protected]",
fetchTransitiveGroupMembership: true,
shouldErr: false,
expectedGroups: []string{"[email protected]", "[email protected]"},
},
} {
testCase := testCase
callCounter = map[string]int{}
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
lookup := make(map[string]struct{})

groups, err := conn.getGroups(testCase.userKey, testCase.fetchTransitiveGroupMembership, lookup)
if testCase.shouldErr {
assert.NotNil(err)
} else {
assert.Nil(err)
}
assert.ElementsMatch(testCase.expectedGroups, groups)
t.Logf("[%s] Amount of API calls per userKey: %+v\n", t.Name(), callCounter)
})
}
}

func TestDomainToAdminEmailConfig(t *testing.T) {
ts := testSetup()
defer ts.Close()
Expand Down

0 comments on commit 113be40

Please sign in to comment.