feat: GCP authentication (#5)

* feat: GCP authentication

Add authentication support for:
- authentication with service account key
- authentication with impersonating service account key
- authentication with application default credentials which are read from the environment

A HTTP client with a token set will be returned and used for authentication with all clients. for example
```
gcpClient, err := gcpConfig.GetClient(ctx)
if err != nil {
	log.Fatalf("Failed to get client: %v", err)
}

client, err := computeCloud.NewInstancesRESTClient(ctx, option.WithHTTPClient(gcpClient))
if err != nil {
	log.Fatalf("Failed to create client: %v", err)
}
```
This commit is contained in:
Elim Tsiagbey authored and GitHub committed 2024-09-22 23:21:58 -04:00
1 parent eec01f7d69
commit be892acbd9
8 files changed
+715 -1

No files matched your search

+1 -1
View File
@@ -5,6 +5,7 @@ go 1.21
require (
github.com/hashicorp/boundary/sdk v0.0.47
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8
golang.org/x/oauth2 v0.21.0
google.golang.org/api v0.188.0
)
@@ -27,7 +28,6 @@ require (
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect
)
+4
View File
@@ -183,6 +183,8 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
@@ -239,6 +241,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+192
View File
@@ -0,0 +1,192 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/impersonate"
googleOption "google.golang.org/api/option"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// impersonateServiceAccount is a function variable that creates a TokenSource
var impersonateServiceAccountFn = func(
ctx context.Context,
config impersonate.CredentialsConfig,
opts ...googleOption.ClientOption,
) (oauth2.TokenSource, error) {
return impersonate.CredentialsTokenSource(ctx, config, opts...)
}
// findDefaultCredentialsFn is a function variable that finds the default credentials
var findDefaultCredentialsFn = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
return google.FindDefaultCredentials(ctx, scopes...)
}
// serviceAccountTokenLifetime is the lifetime of the generated GCP authentication token.
// The token is valid for 1 hour.
const serviceAccountTokenLifetime = time.Hour
// Config is the configuration for the GCP credential.
type Config struct {
ProjectId string
PrivateKey string
PrivateKeyId string
ClientEmail string
TargetServiceAccountId string
Scopes []string
Client *http.Client
}
// credentials represents a simplified version of the GCP credentials file format.
type credentials struct {
ClientEmail string `json:"client_email"`
Type string `json:"type"`
ProjectId string `json:"project_id"`
PrivateKey string `json:"private_key"`
PrivateKeyId string `json:"private_key_id"`
}
// toCredentials converts the config to credentials.
func (c *Config) toCredentials() *credentials {
return &credentials{
Type: "service_account",
ProjectId: c.ProjectId,
PrivateKey: c.PrivateKey,
PrivateKeyId: c.PrivateKeyId,
ClientEmail: c.ClientEmail,
}
}
// GetClient returns the client for the configuration.
// The client is a *http.Client which is created with GCP credentials.
// The returned client is not valid beyond the lifetime of the context.
//
// If the client is not set, it will generate the client.
func (c *Config) GetClient(ctx context.Context) (*http.Client, error) {
if c.Client == nil {
creds, err := c.generateCredentials(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "error getting credentials: %v", err)
}
c.Client = oauth2.NewClient(ctx, creds.TokenSource)
}
return c.Client, nil
}
// generateCredentials generates GCP credentials based on the provided configuration.
// It supports Service Account Key, Service Account Impersonation, and ADC.
func (c *Config) generateCredentials(ctx context.Context) (*google.Credentials, error) {
var creds *google.Credentials
var err error
switch {
case c.PrivateKey != "" && c.ClientEmail != "" && c.TargetServiceAccountId != "":
creds, err = c.credentialsFromImpersonation(ctx)
case c.PrivateKey != "" && c.ClientEmail != "":
creds, err = c.credentialsFromServiceAccountKey(ctx)
default:
creds, err = c.credentialsFromADC(ctx)
}
if err != nil {
return nil, err
}
return creds, nil
}
// credentialsFromServiceAccountKey generates the credentials from the service account key.
func (c *Config) credentialsFromServiceAccountKey(ctx context.Context) (*google.Credentials, error) {
if c.PrivateKey == "" {
return nil, status.Error(codes.InvalidArgument, "private_key is required")
}
if c.ClientEmail == "" {
return nil, status.Error(codes.InvalidArgument, "client_email is required")
}
credBytes, err := json.Marshal(c.toCredentials())
if err != nil {
return nil, status.Error(codes.Internal, "error marshaling credentials")
}
creds, err := google.CredentialsFromJSON(ctx, credBytes, c.Scopes...)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to parse credentials: %v", err)
}
return creds, nil
}
// credentialsFromImpersonation generates the credentials from the impersonation.
func (c *Config) credentialsFromImpersonation(ctx context.Context) (*google.Credentials, error) {
if c.PrivateKey == "" {
return nil, status.Error(codes.InvalidArgument, "private_key is required")
}
if c.ClientEmail == "" {
return nil, status.Error(codes.InvalidArgument, "client_email is required")
}
if c.TargetServiceAccountId == "" {
return nil, status.Error(codes.InvalidArgument, "target_service_account_id is required")
}
credBytes, err := json.Marshal(c.toCredentials())
if err != nil {
return nil, status.Error(codes.Internal, "error marshaling credentials")
}
creds, err := impersonateServiceAccount(ctx, c.ProjectId, credBytes, c.TargetServiceAccountId, c.Scopes)
if err != nil {
return nil, status.Errorf(codes.Internal, "error impersonating service account: %v", err)
}
return creds, nil
}
// credentialsFromADC generates the credentials from the Application Default Credentials (ADC).
func (c *Config) credentialsFromADC(ctx context.Context) (*google.Credentials, error) {
creds, err := findDefaultCredentialsFn(ctx, c.Scopes...)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to find default credentials: %v", err)
}
return creds, nil
}
// impersonateServiceAccount impersonates the service account.
// This is used to impersonate the service account to generate an access token.
func impersonateServiceAccount(
ctx context.Context,
projectId string,
baseCredentialsJSON []byte,
targetServiceAccountId string,
scopes []string,
) (*google.Credentials, error) {
if baseCredentialsJSON == nil {
return nil, status.Error(codes.InvalidArgument, "base_service_account is required")
}
if targetServiceAccountId == "" {
return nil, status.Error(codes.InvalidArgument, "target_service_account_id is required")
}
if len(scopes) == 0 {
return nil, status.Error(codes.InvalidArgument, "scope is required")
}
ts, err := impersonateServiceAccountFn(ctx, impersonate.CredentialsConfig{
TargetPrincipal: targetServiceAccountId,
Scopes: scopes,
Lifetime: serviceAccountTokenLifetime,
}, googleOption.WithCredentialsJSON(baseCredentialsJSON))
if err != nil {
return nil, fmt.Errorf("failed to create impersonated token source: %v", err)
}
return &google.Credentials{
ProjectID: projectId,
TokenSource: ts,
}, nil
}
+326
View File
@@ -0,0 +1,326 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/impersonate"
googleOption "google.golang.org/api/option"
)
func TestGenerateCredentials(t *testing.T) {
ctx := context.Background()
testImpersonateServiceFn := func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error) {
return oauth2.StaticTokenSource(&oauth2.Token{}), nil
}
impersonateServiceAccountFn = testImpersonateServiceFn
const fakePrivateKey = `-----BEGIN PRIVATE KEY-----
MIIBVgIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEA1y+Vmxg1hK6j0ef6
-----END PRIVATE KEY-----`
tests := []struct {
name string
config *Config
wantErr bool
expectedErr string
}{
{
name: "Service Account Key Authentication",
config: &Config{
PrivateKey: fakePrivateKey,
ClientEmail: "test_email@example.com",
ProjectId: "test_project",
PrivateKeyId: "test_key_id",
},
wantErr: false,
},
{
name: "Service Account Impersonation",
config: &Config{
PrivateKey: fakePrivateKey,
ClientEmail: "test_email@example.com",
ProjectId: "test_project",
PrivateKeyId: "test_key_id",
TargetServiceAccountId: "target_service_account@example.com",
Scopes: []string{"test_scope"},
},
wantErr: false,
},
{
name: "Application Default Credentials",
config: &Config{},
wantErr: true, // Since ADC might not be available in test environment
expectedErr: "failed to find default credentials",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
creds, err := tt.config.generateCredentials(ctx)
if tt.wantErr {
require.ErrorContains(t, err, tt.expectedErr)
require.Nil(t, creds)
return
}
require.NoError(t, err)
require.NotNil(t, creds)
require.Equal(t, tt.config.ProjectId, creds.ProjectID)
})
}
}
func TestGetClient(t *testing.T) {
ctx := context.Background()
config := &Config{
PrivateKey: "test_private_key",
ClientEmail: "test_email@example.com",
ProjectId: "test_project",
PrivateKeyId: "test_key_id",
}
client, err := config.GetClient(ctx)
require.NoError(t, err)
require.NotNil(t, client)
// Call GetClient again to ensure the cached client is returned
cachedClient, err := config.GetClient(ctx)
require.NoError(t, err)
require.Equal(t, client, cachedClient)
}
func TestCredentialsFromServiceAccountKey(t *testing.T) {
ctx := context.Background()
// Define a fake but correctly formatted PEM-encoded private key.
const fakePrivateKey = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC
-----END PRIVATE KEY-----`
// Test cases
tests := []struct {
name string
config *Config
wantErr bool
expectedErr string
}{
{
name: "Successful Credential Generation",
config: &Config{
PrivateKey: fakePrivateKey,
ClientEmail: "test-email@example.com",
ProjectId: "test-project-id",
PrivateKeyId: "test-private-key-id",
},
wantErr: false,
},
{
name: "Missing Private Key",
config: &Config{
ClientEmail: "test-email@example.com",
ProjectId: "test-project-id",
PrivateKeyId: "test-private-key-id",
},
wantErr: true,
expectedErr: "private_key is required",
},
{
name: "Missing Client Email",
config: &Config{
PrivateKey: fakePrivateKey,
ProjectId: "test-project-id",
PrivateKeyId: "test-private-key-id",
},
wantErr: true,
expectedErr: "client_email is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
creds, err := tt.config.credentialsFromServiceAccountKey(ctx)
if tt.wantErr {
require.ErrorContains(t, err, tt.expectedErr)
require.Nil(t, creds)
return
}
require.NoError(t, err)
require.NotNil(t, creds)
})
}
}
func TestCredentialsFromADC(t *testing.T) {
ctx := context.Background()
mockTokenSource := oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: "mock_access_token",
})
tests := []struct {
name string
mockFindDefaultCred func(ctx context.Context, scopes ...string) (*google.Credentials, error)
wantErr bool
expectedErr string
}{
{
name: "Successful ADC Retrieval",
mockFindDefaultCred: func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
return &google.Credentials{
ProjectID: "test-project-id",
TokenSource: mockTokenSource,
}, nil
},
wantErr: false,
},
{
name: "ADC Not Available",
mockFindDefaultCred: func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
return nil, errors.New("ADC not available")
},
wantErr: true,
expectedErr: "failed to find default credentials",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Override findDefaultCredentials with the mock implementation.
findDefaultCredentialsFn = tt.mockFindDefaultCred
config := &Config{}
creds, err := config.credentialsFromADC(ctx)
if tt.wantErr {
require.ErrorContains(t, err, tt.expectedErr)
require.Nil(t, creds)
return
}
require.NoError(t, err)
require.NotNil(t, creds)
require.Equal(t, "test-project-id", creds.ProjectID, "Expected the project ID to be 'test-project-id'")
require.Equal(t, mockTokenSource, creds.TokenSource, "Expected the mock token source")
})
}
}
func TestCredentialsFromImpersonation(t *testing.T) {
ctx := context.Background()
// Define a fake but correctly formatted PEM-encoded private key.
const fakePrivateKey = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC
-----END PRIVATE KEY-----`
testImpersonateServiceFn := func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error) {
return oauth2.StaticTokenSource(&oauth2.Token{}), nil
}
impersonateServiceAccountFn = testImpersonateServiceFn
tests := []struct {
name string
config *Config
mockImpersonateServiceAccountFn func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error)
wantErr bool
expectedErr string
}{
{
name: "Successful Impersonation",
config: &Config{
PrivateKey: fakePrivateKey,
ClientEmail: "test-email@example.com",
ProjectId: "test-project-id",
PrivateKeyId: "test-private-key-id",
TargetServiceAccountId: "target-service-account@example.com",
Scopes: []string{"test-scope"},
},
mockImpersonateServiceAccountFn: func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error) {
return oauth2.StaticTokenSource(&oauth2.Token{}), nil
},
wantErr: false,
},
{
name: "Error during Impersonation",
config: &Config{
PrivateKey: fakePrivateKey,
ClientEmail: "test-email@example.com",
ProjectId: "test-project-id",
PrivateKeyId: "test-private-key-id",
TargetServiceAccountId: "target-service-account@example.com",
Scopes: []string{"test-scope"},
},
mockImpersonateServiceAccountFn: func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error) {
return nil, errors.New("mock impersonation error")
},
wantErr: true,
expectedErr: "failed to create impersonated token source",
},
{
name: "Missing Private Key",
config: &Config{
ClientEmail: "test-email@example.com",
ProjectId: "test-project-id",
PrivateKeyId: "test-private-key-id",
TargetServiceAccountId: "target-service-account@example.com",
},
wantErr: true,
expectedErr: "private_key is required",
},
{
name: "Missing Client Email",
config: &Config{
PrivateKey: fakePrivateKey,
ProjectId: "test-project-id",
PrivateKeyId: "test-private-key-id",
TargetServiceAccountId: "target-service-account@example.com",
},
mockImpersonateServiceAccountFn: nil,
wantErr: true,
expectedErr: "client_email is required",
},
{
name: "Missing Target Service Account ID",
config: &Config{
PrivateKey: fakePrivateKey,
ClientEmail: "test-email@example.com",
ProjectId: "test-project-id",
PrivateKeyId: "test-private-key-id",
},
mockImpersonateServiceAccountFn: nil,
wantErr: true,
expectedErr: "target_service_account_id is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.mockImpersonateServiceAccountFn != nil {
impersonateServiceAccountFn = tt.mockImpersonateServiceAccountFn
}
creds, err := tt.config.credentialsFromImpersonation(ctx)
if tt.wantErr {
require.ErrorContains(t, err, tt.expectedErr)
require.Nil(t, creds)
return
}
require.NoError(t, err)
require.NotNil(t, creds)
require.Equal(t, tt.config.ProjectId, creds.ProjectID)
token, err := creds.TokenSource.Token()
require.NoError(t, err)
require.NotNil(t, token)
})
}
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package credential
import "time"
// options = how options are represented
type Options struct {
WithCredentialsConfig *Config
WithCredsLastRotatedTime time.Time
}
// getOpts - iterate the inbound Options and return a struct
func getOpts(opts ...Option) (*Options, error) {
defaultOptions := getDefaultOptions()
for _, opt := range opts {
if err := opt(defaultOptions); err != nil {
return nil, err
}
}
return defaultOptions, nil
}
// Option - how Options are passed as arguments
type Option func(*Options) error
func getDefaultOptions() *Options {
return &Options{
WithCredentialsConfig: &Config{},
}
}
// WithCredentialsConfig - set the credentials config
func WithCredentialsConfig(c *Config) Option {
return func(o *Options) error {
o.WithCredentialsConfig = c
return nil
}
}
// WithCredsLastRotatedTime - set the last rotated time
func WithCredsLastRotatedTime(t time.Time) Option {
return func(o *Options) error {
o.WithCredsLastRotatedTime = t
return nil
}
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func Test_getOpts(t *testing.T) {
t.Parallel()
t.Run("WithCredentialsConfig", func(t *testing.T) {
c := &Config{
ProjectId: "test",
ClientEmail: "test",
}
opts, err := getOpts(WithCredentialsConfig(c))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithCredentialsConfig = c
require.Equal(t, opts, testOpts)
})
t.Run("WithCredsLastRotatedTime", func(t *testing.T) {
tm := time.Now()
opts, err := getOpts(WithCredsLastRotatedTime(tm))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithCredsLastRotatedTime = tm
require.Equal(t, opts, testOpts)
})
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"time"
)
type CredentialType int
// PersistedState is the persisted state for the GCP credential.
type PersistedState struct {
// CredentialsConfig is the credential configuration for the GCP credential.
CredentialsConfig *Config
// CredsLastRotatedTime is the last rotation of service account key for the GCP credential.
CredsLastRotatedTime time.Time
}
// NewPersistedState - create a new PersistedState
func NewPersistedState(opt ...Option) (*PersistedState, error) {
s := new(PersistedState)
opts, err := getOpts(opt...)
if err != nil {
return nil, err
}
s.CredentialsConfig = opts.WithCredentialsConfig
s.CredsLastRotatedTime = opts.WithCredsLastRotatedTime
return s, nil
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestNewGCPCredentialPersistedState(t *testing.T) {
staticTime := time.Now()
cases := []struct {
name string
opts []Option
expected *PersistedState
expectedErr string
}{
{
name: "error",
opts: []Option{
func(s *Options) error {
return errors.New("option error")
},
},
expectedErr: "option error",
},
{
name: "with credentials config",
opts: []Option{
WithCredentialsConfig(
&Config{
ProjectId: "test-project",
ClientEmail: "test-email",
},
),
},
expected: &PersistedState{
CredentialsConfig: &Config{
ProjectId: "test-project",
ClientEmail: "test-email",
},
},
},
{
name: "rotation time",
opts: []Option{
WithCredsLastRotatedTime(staticTime),
},
expected: &PersistedState{
CredsLastRotatedTime: staticTime,
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := NewPersistedState(tc.opts...)
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
return
}
require.NoError(err)
require.Equal(tc.expected.CredsLastRotatedTime, actual.CredsLastRotatedTime)
if tc.expected.CredentialsConfig != nil {
require.NotNil(actual.CredentialsConfig)
require.Equal(tc.expected.CredentialsConfig.ProjectId, actual.CredentialsConfig.ProjectId)
require.Equal(tc.expected.CredentialsConfig.ClientEmail, actual.CredentialsConfig.ClientEmail)
}
})
}
}