From be892acbd99f5494523460d07934d71f6871c145 Mon Sep 17 00:00:00 2001 From: Elim Tsiagbey Date: Sun, 22 Sep 2024 23:21:58 -0400 Subject: [PATCH] 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) } ``` --- go.mod | 2 +- go.sum | 4 + internal/credential/config.go | 192 ++++++++++++++++ internal/credential/config_test.go | 326 ++++++++++++++++++++++++++++ internal/credential/options.go | 48 ++++ internal/credential/options_test.go | 35 +++ internal/credential/state.go | 30 +++ internal/credential/state_test.go | 79 +++++++ 8 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 internal/credential/config.go create mode 100644 internal/credential/config_test.go create mode 100644 internal/credential/options.go create mode 100644 internal/credential/options_test.go create mode 100644 internal/credential/state.go create mode 100644 internal/credential/state_test.go diff --git a/go.mod b/go.mod index cccb92d..b4a6f5b 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 6b9e9fe..4a61a43 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/credential/config.go b/internal/credential/config.go new file mode 100644 index 0000000..81e2d30 --- /dev/null +++ b/internal/credential/config.go @@ -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 +} diff --git a/internal/credential/config_test.go b/internal/credential/config_test.go new file mode 100644 index 0000000..8d03ac8 --- /dev/null +++ b/internal/credential/config_test.go @@ -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) + }) + } +} diff --git a/internal/credential/options.go b/internal/credential/options.go new file mode 100644 index 0000000..0d49b12 --- /dev/null +++ b/internal/credential/options.go @@ -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 + } +} diff --git a/internal/credential/options_test.go b/internal/credential/options_test.go new file mode 100644 index 0000000..7b7a08d --- /dev/null +++ b/internal/credential/options_test.go @@ -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) + }) +} diff --git a/internal/credential/state.go b/internal/credential/state.go new file mode 100644 index 0000000..a03fada --- /dev/null +++ b/internal/credential/state.go @@ -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 +} diff --git a/internal/credential/state_test.go b/internal/credential/state_test.go new file mode 100644 index 0000000..a634da5 --- /dev/null +++ b/internal/credential/state_test.go @@ -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) + } + }) + } +}