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:
1 parent
eec01f7d69
commit
be892acbd9
8 files changed
+715
-1
No files matched your search
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user