diff --git a/connector/google/google.go b/connector/google/google.go index 1b515b8e..72cc6a18 100644 --- a/connector/google/google.go +++ b/connector/google/google.go @@ -71,7 +71,7 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e scopes = append(scopes, "profile", "email") } - srv, err := createDirectoryService(c.ServiceAccountFilePath, c.AdminEmail) + srv, err := createDirectoryService(c.ServiceAccountFilePath, c.AdminEmail, logger) if err != nil { cancel() return nil, fmt.Errorf("could not create directory service: %v", err) @@ -279,37 +279,37 @@ func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership return uniqueGroups(userGroups), nil } -// createDirectoryService loads a google service account credentials file, -// sets up super user impersonation and creates an admin client for calling -// the google admin api -func createDirectoryService(serviceAccountFilePath string, email string) (*admin.Service, error) { - if serviceAccountFilePath == "" && email == "" { - return nil, nil - } - if serviceAccountFilePath == "" || email == "" { - return nil, fmt.Errorf("directory service requires both serviceAccountFilePath and adminEmail") - } - jsonCredentials, err := os.ReadFile(serviceAccountFilePath) - if err != nil { - return nil, fmt.Errorf("error reading credentials from file: %v", err) +// 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) { + if email == "" { + return nil, fmt.Errorf("directory service requires adminEmail") } - config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope) - if err != nil { - return nil, fmt.Errorf("unable to parse client secret file to config: %v", err) - } - - // Impersonate an admin. This is mandatory for the admin APIs. - config.Subject = email + var jsonCredentials []byte + var err error ctx := context.Background() - client := config.Client(ctx) - - srv, err := admin.NewService(ctx, option.WithHTTPClient(client)) - if err != nil { - return nil, fmt.Errorf("unable to create directory service %v", err) + if serviceAccountFilePath == "" { + logger.Warn("the application default credential is used since the service account file path is not used") + credential, err := google.FindDefaultCredentials(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch application default credentials: %w", err) + } + jsonCredentials = credential.JSON + } else { + jsonCredentials, err = os.ReadFile(serviceAccountFilePath) + if err != nil { + return nil, fmt.Errorf("error reading credentials from file: %v", err) + } } - return srv, nil + config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope) + if err != nil { + return nil, fmt.Errorf("unable to parse credentials to config: %v", err) + } + config.Subject = email + return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx))) } // uniqueGroups returns the unique groups of a slice diff --git a/connector/google/google_test.go b/connector/google/google_test.go new file mode 100644 index 00000000..5cecbec9 --- /dev/null +++ b/connector/google/google_test.go @@ -0,0 +1,145 @@ +package google + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func testSetup(t *testing.T) *httptest.Server { + mux := http.NewServeMux() + // TODO: mock calls + // mux.HandleFunc("/admin/directory/v1/groups", func(w http.ResponseWriter, r *http.Request) { + // w.Header().Add("Content-Type", "application/json") + // json.NewEncoder(w).Encode(&admin.Groups{ + // Groups: []*admin.Group{}, + // }) + // }) + return httptest.NewServer(mux) +} + +func newConnector(config *Config, serverURL string) (*googleConnector, error) { + log := logrus.New() + conn, err := config.Open("id", log) + if err != nil { + return nil, err + } + + googleConn, ok := conn.(*googleConnector) + if !ok { + return nil, fmt.Errorf("failed to convert to googleConnector") + } + return googleConn, nil +} + +func tempServiceAccountKey() (string, error) { + fd, err := os.CreateTemp("", "google_service_account_key") + if err != nil { + return "", err + } + defer fd.Close() + err = json.NewEncoder(fd).Encode(map[string]string{ + "type": "service_account", + "project_id": "sample-project", + "private_key_id": "sample-key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\nsample-key\n-----END PRIVATE KEY-----\n", + "client_id": "sample-client-id", + "client_x509_cert_url": "localhost", + }) + return fd.Name(), err +} + +func TestOpen(t *testing.T) { + ts := testSetup(t) + defer ts.Close() + + type testCase struct { + config *Config + expectedErr string + + // string to set in GOOGLE_APPLICATION_CREDENTIALS. As local development environments can + // already contain ADC, test cases will be built uppon this setting this env variable + adc string + } + + serviceAccountFilePath, err := tempServiceAccountKey() + assert.Nil(t, err) + + for name, reference := range map[string]testCase{ + "missing_admin_email": { + config: &Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + }, + expectedErr: "requires adminEmail", + }, + "service_account_key_not_found": { + config: &Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + AdminEmail: "foo@bar.com", + ServiceAccountFilePath: "not_found.json", + }, + expectedErr: "error reading credentials", + }, + "service_account_key_valid": { + config: &Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + AdminEmail: "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", + }, + adc: serviceAccountFilePath, + expectedErr: "", + }, + "adc_priority": { + config: &Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + AdminEmail: "foo@bar.com", + ServiceAccountFilePath: serviceAccountFilePath, + }, + adc: "/dev/null", + expectedErr: "", + }, + } { + reference := reference + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", reference.adc) + conn, err := newConnector(reference.config, ts.URL) + + if reference.expectedErr == "" { + assert.Nil(err) + assert.NotNil(conn) + } else { + assert.ErrorContains(err, reference.expectedErr) + } + }) + } +}