feat: OnCreateCatalog (#8)
* feat: OnCreateCatalog - Update onCreateCatalog to support credential rotation. Service authentication type creds will be rotated since we will have the credentials for the base service account without sufficient permissions. - `onCreateCatalog` will also do a dry run validation to validate the credentials. - The secrets and last rotated time will be returned
This commit is contained in:
1 parent
636432f2fb
commit
6b917b170d
14 files changed
+992
-90
No files matched your search
@@ -5,6 +5,8 @@ go 1.21
|
|||||||
require (
|
require (
|
||||||
cloud.google.com/go/iam v1.2.0
|
cloud.google.com/go/iam v1.2.0
|
||||||
cloud.google.com/go/resourcemanager v1.10.1
|
cloud.google.com/go/resourcemanager v1.10.1
|
||||||
|
github.com/google/go-cmp v0.6.0
|
||||||
|
github.com/googleapis/gax-go/v2 v2.13.0
|
||||||
github.com/hashicorp/boundary/sdk v0.0.47
|
github.com/hashicorp/boundary/sdk v0.0.47
|
||||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8
|
||||||
golang.org/x/oauth2 v0.22.0
|
golang.org/x/oauth2 v0.22.0
|
||||||
@@ -23,7 +25,6 @@ require (
|
|||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/google/s2a-go v0.1.8 // indirect
|
github.com/google/s2a-go v0.1.8 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.3 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.3 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
|
|
||||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||||
github.com/hashicorp/go-sockaddr v1.0.5 // indirect
|
github.com/hashicorp/go-sockaddr v1.0.5 // indirect
|
||||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||||
|
|||||||
@@ -72,3 +72,70 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
|
|||||||
TargetServiceAccountId: targetServiceAccountId,
|
TargetServiceAccountId: targetServiceAccountId,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCredentialsConfig parses values out of a protobuf struct secrets and returns a
|
||||||
|
// Config used for configuring an GCP session. An status error is returned
|
||||||
|
// with an InvalidArgument code if any unrecognized fields are found in the protobuf
|
||||||
|
// struct input.
|
||||||
|
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes) (*Config, error) {
|
||||||
|
// initialize secrets if it is nil
|
||||||
|
// secrets can be nil if the authentication type is Application Default Credential(ADC)
|
||||||
|
if secrets == nil {
|
||||||
|
secrets = &structpb.Struct{
|
||||||
|
Fields: make(map[string]*structpb.Value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize attrs if it is nil
|
||||||
|
// attributes can be nil if the authentication type is Application Default Credential(ADC)
|
||||||
|
// where the credentials are inferred from the environment and no fields are provided
|
||||||
|
// https://cloud.google.com/docs/authentication/application-default-credentials
|
||||||
|
if attrs == nil {
|
||||||
|
attrs = &CredentialAttributes{}
|
||||||
|
}
|
||||||
|
|
||||||
|
unknownFields := values.StructFields(secrets)
|
||||||
|
badFields := make(map[string]string)
|
||||||
|
|
||||||
|
privateKeyId, err := values.GetStringValue(secrets, ConstPrivateKeyId, false)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKeyId)] = err.Error()
|
||||||
|
}
|
||||||
|
delete(unknownFields, ConstPrivateKeyId)
|
||||||
|
|
||||||
|
privateKey, err := values.GetStringValue(secrets, ConstPrivateKey, false)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = err.Error()
|
||||||
|
}
|
||||||
|
delete(unknownFields, ConstPrivateKey)
|
||||||
|
|
||||||
|
delete(unknownFields, ConstCredsLastRotatedTime)
|
||||||
|
for s := range unknownFields {
|
||||||
|
badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
// dynamic credentials requires the target service account id, cl and private key
|
||||||
|
case attrs.TargetServiceAccountId != "" && (privateKey == "" || attrs.ClientEmail == ""):
|
||||||
|
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when target service account id is set"
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when target service account id is set"
|
||||||
|
// static credentials requires the private key and client email
|
||||||
|
case privateKey != "" && attrs.ClientEmail == "":
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when private key is set"
|
||||||
|
case attrs.ClientEmail != "" && privateKey == "":
|
||||||
|
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when client email is set"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(badFields) > 0 {
|
||||||
|
return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
ProjectId: attrs.ProjectId,
|
||||||
|
ClientEmail: attrs.ClientEmail,
|
||||||
|
Zone: attrs.Zone,
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
PrivateKeyId: privateKeyId,
|
||||||
|
TargetServiceAccountId: attrs.TargetServiceAccountId,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -104,3 +104,166 @@ func TestGetCredentialAttributes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetCredentialsConfig(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
secrets map[string]any
|
||||||
|
attrs *CredentialAttributes
|
||||||
|
expected *Config
|
||||||
|
expectedErrContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil secrets",
|
||||||
|
attrs: &CredentialAttributes{
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
},
|
||||||
|
expected: &Config{
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil attributes",
|
||||||
|
attrs: nil,
|
||||||
|
expected: &Config{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown fields in secrets",
|
||||||
|
secrets: map[string]any{
|
||||||
|
"unknown_field": "value",
|
||||||
|
},
|
||||||
|
attrs: &CredentialAttributes{
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
},
|
||||||
|
expectedErrContains: "secrets.unknown_field: unrecognized field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid ignore creds_last_rotated_time",
|
||||||
|
secrets: map[string]any{
|
||||||
|
ConstPrivateKeyId: "test-private-key-id",
|
||||||
|
ConstPrivateKey: "test-private-key",
|
||||||
|
ConstCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||||
|
},
|
||||||
|
attrs: &CredentialAttributes{
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
ClientEmail: "test@test.com",
|
||||||
|
},
|
||||||
|
expected: &Config{
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
ClientEmail: "test@test.com",
|
||||||
|
PrivateKey: "test-private-key",
|
||||||
|
PrivateKeyId: "test-private-key-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing private key with target service account id",
|
||||||
|
secrets: map[string]any{
|
||||||
|
ConstClientEmail: "test@test.com",
|
||||||
|
},
|
||||||
|
attrs: &CredentialAttributes{
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
TargetServiceAccountId: "test-target-service-account",
|
||||||
|
},
|
||||||
|
expectedErrContains: "must not be empty when target service account id is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing client email with target service account id",
|
||||||
|
secrets: map[string]any{
|
||||||
|
ConstPrivateKey: "private-key",
|
||||||
|
},
|
||||||
|
attrs: &CredentialAttributes{
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
TargetServiceAccountId: "test-target-service-account",
|
||||||
|
},
|
||||||
|
expectedErrContains: "must not be empty when target service account id is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing client email with private key",
|
||||||
|
secrets: map[string]any{
|
||||||
|
ConstPrivateKeyId: "test-private-key-id",
|
||||||
|
ConstPrivateKey: "test-private-key",
|
||||||
|
},
|
||||||
|
attrs: &CredentialAttributes{
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
},
|
||||||
|
expectedErrContains: "must not be empty when private key is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing private key with client email",
|
||||||
|
secrets: map[string]any{},
|
||||||
|
attrs: &CredentialAttributes{
|
||||||
|
ClientEmail: "test@test.com",
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
},
|
||||||
|
expectedErrContains: "must not be empty when client email is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid static credentials",
|
||||||
|
secrets: map[string]any{
|
||||||
|
ConstPrivateKeyId: "test-private-key-id",
|
||||||
|
ConstPrivateKey: "test-private-key",
|
||||||
|
},
|
||||||
|
attrs: &CredentialAttributes{
|
||||||
|
ClientEmail: "test@test.com",
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
},
|
||||||
|
expected: &Config{
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
PrivateKey: "test-private-key",
|
||||||
|
PrivateKeyId: "test-private-key-id",
|
||||||
|
ClientEmail: "test@test.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid dynamic credentials",
|
||||||
|
secrets: map[string]any{
|
||||||
|
ConstPrivateKeyId: "test-private-key-id",
|
||||||
|
ConstPrivateKey: "test-private-key",
|
||||||
|
},
|
||||||
|
attrs: &CredentialAttributes{
|
||||||
|
ClientEmail: "test@test.com",
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
TargetServiceAccountId: "test-target-service-account",
|
||||||
|
},
|
||||||
|
expected: &Config{
|
||||||
|
ProjectId: "test-project",
|
||||||
|
Zone: "us-central-1a",
|
||||||
|
PrivateKey: "test-private-key",
|
||||||
|
PrivateKeyId: "test-private-key-id",
|
||||||
|
ClientEmail: "test@test.com",
|
||||||
|
TargetServiceAccountId: "test-target-service-account",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
secrets, err := structpb.NewStruct(tc.secrets)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
actual, err := GetCredentialsConfig(secrets, tc.attrs)
|
||||||
|
if tc.expectedErrContains != "" {
|
||||||
|
require.Error(err)
|
||||||
|
require.Contains(err.Error(), tc.expectedErrContains)
|
||||||
|
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.EqualValues(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ type Config struct {
|
|||||||
ProjectId string
|
ProjectId string
|
||||||
PrivateKey string
|
PrivateKey string
|
||||||
PrivateKeyId string
|
PrivateKeyId string
|
||||||
|
Zone string
|
||||||
ClientEmail string
|
ClientEmail string
|
||||||
TargetServiceAccountId string
|
TargetServiceAccountId string
|
||||||
Scopes []string
|
Scopes []string
|
||||||
@@ -166,6 +167,22 @@ func (c *Config) credentialsFromADC(ctx context.Context) (*google.Credentials, e
|
|||||||
return creds, nil
|
return creds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsRotatable returns a boolean indicating if the credentials are rotatable.
|
||||||
|
func (c *Config) IsRotatable() bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case len(c.TargetServiceAccountId) > 0 && len(c.PrivateKey) > 0 && len(c.ClientEmail) > 0:
|
||||||
|
return true
|
||||||
|
case len(c.PrivateKey) > 0 && len(c.ClientEmail) > 0:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// impersonateServiceAccount impersonates the service account.
|
// impersonateServiceAccount impersonates the service account.
|
||||||
// This is used to impersonate the service account to generate an access token.
|
// This is used to impersonate the service account to generate an access token.
|
||||||
func impersonateServiceAccount(
|
func impersonateServiceAccount(
|
||||||
|
|||||||
@@ -340,3 +340,46 @@ func TestClone(t *testing.T) {
|
|||||||
require.NotEqual(t, originalConfig.TargetServiceAccountId, clonedConfig.TargetServiceAccountId)
|
require.NotEqual(t, originalConfig.TargetServiceAccountId, clonedConfig.TargetServiceAccountId)
|
||||||
require.NotEqual(t, originalConfig.Scopes, clonedConfig.Scopes)
|
require.NotEqual(t, originalConfig.Scopes, clonedConfig.Scopes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsRotatable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Nil Config",
|
||||||
|
config: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Service Account Impersonation Config",
|
||||||
|
config: &Config{
|
||||||
|
PrivateKey: "fake-private-key",
|
||||||
|
ClientEmail: "test-email@example.com",
|
||||||
|
TargetServiceAccountId: "target-service-account@example.com",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Static GCP Config",
|
||||||
|
config: &Config{
|
||||||
|
PrivateKey: "fake-private-key",
|
||||||
|
ClientEmail: "test-email@example.com",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Default Config",
|
||||||
|
config: &Config{},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.config.IsRotatable()
|
||||||
|
require.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,4 +21,10 @@ const (
|
|||||||
|
|
||||||
// ConstTargetServiceAccountID is the unique identifier for the service account that will be impersonated.
|
// ConstTargetServiceAccountID is the unique identifier for the service account that will be impersonated.
|
||||||
ConstTargetServiceAccountID = "target_service_account_id"
|
ConstTargetServiceAccountID = "target_service_account_id"
|
||||||
|
|
||||||
|
// ConstPrivateKeyId is the private key id associated with the service account
|
||||||
|
ConstPrivateKeyId = "private_key_id"
|
||||||
|
|
||||||
|
// ConstPrivateKey is the private key associated with the service account
|
||||||
|
ConstPrivateKey = "private_key"
|
||||||
)
|
)
|
||||||
@@ -6,8 +6,6 @@ package credential
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||||
@@ -19,7 +17,6 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRotateServiceAccountKey(t *testing.T) {
|
func TestRotateServiceAccountKey(t *testing.T) {
|
||||||
@@ -106,10 +103,10 @@ func TestRotateServiceAccountKey(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
gsrv := newGRPCServer()
|
gsrv := NewGRPCServer()
|
||||||
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
|
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
|
||||||
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||||
addr, err := gsrv.serve()
|
addr, err := gsrv.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -245,9 +242,9 @@ func TestValidateIamPermissions(t *testing.T) {
|
|||||||
testResourceServer.testIamPermissionsResponse = tt.testIamResponse
|
testResourceServer.testIamPermissionsResponse = tt.testIamResponse
|
||||||
testResourceServer.testIamPermissionsError = tt.testIamPermissionsError
|
testResourceServer.testIamPermissionsError = tt.testIamPermissionsError
|
||||||
|
|
||||||
gsrv := newGRPCServer()
|
gsrv := NewGRPCServer()
|
||||||
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||||
addr, err := gsrv.serve()
|
addr, err := gsrv.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
testOptions := []googleOption.ClientOption{
|
testOptions := []googleOption.ClientOption{
|
||||||
@@ -267,52 +264,3 @@ func TestValidateIamPermissions(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type testIAMAdminServer struct {
|
|
||||||
adminpb.UnimplementedIAMServer
|
|
||||||
testCreateServiceAccountKeyError error
|
|
||||||
testDeleteServiceAccountKeyError error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *testIAMAdminServer) CreateServiceAccountKey(ctx context.Context, req *adminpb.CreateServiceAccountKeyRequest) (*adminpb.ServiceAccountKey, error) {
|
|
||||||
resp := &adminpb.ServiceAccountKey{
|
|
||||||
Name: "projects/-/serviceAccounts/1234/keys/updated-private-key-id",
|
|
||||||
PrivateKeyData: []byte("updated-private-key"),
|
|
||||||
}
|
|
||||||
return resp, f.testCreateServiceAccountKeyError
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *testIAMAdminServer) DeleteServiceAccountKey(ctx context.Context, req *adminpb.DeleteServiceAccountKeyRequest) (*emptypb.Empty, error) {
|
|
||||||
return &emptypb.Empty{}, f.testDeleteServiceAccountKeyError
|
|
||||||
}
|
|
||||||
|
|
||||||
type testResourceServer struct {
|
|
||||||
resourcemanagerpb.UnimplementedProjectsServer
|
|
||||||
testIamPermissionsResponse *iampb.TestIamPermissionsResponse
|
|
||||||
testIamPermissionsError error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *testResourceServer) TestIamPermissions(context.Context, *iampb.TestIamPermissionsRequest) (*iampb.TestIamPermissionsResponse, error) {
|
|
||||||
return f.testIamPermissionsResponse, f.testIamPermissionsError
|
|
||||||
}
|
|
||||||
|
|
||||||
type grpcServer struct {
|
|
||||||
*grpc.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func newGRPCServer() *grpcServer {
|
|
||||||
return &grpcServer{Server: grpc.NewServer()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *grpcServer) serve() (string, error) {
|
|
||||||
l, err := net.Listen("tcp", "localhost:0")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to listen: %v", err)
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
if err := s.Serve(l); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return l.Addr().String(), nil
|
|
||||||
}
|
|
||||||
@@ -28,3 +28,14 @@ func NewPersistedState(opt ...Option) (*PersistedState, error) {
|
|||||||
s.CredsLastRotatedTime = opts.WithCredsLastRotatedTime
|
s.CredsLastRotatedTime = opts.WithCredsLastRotatedTime
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToMap returns a map of the credentials stored in the persisted state.
|
||||||
|
// ToMap will return a map for long-term credentials with following keys:
|
||||||
|
// private_key_id, private_key & creds_last_rotated_time
|
||||||
|
func (s *PersistedState) ToMap() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
ConstPrivateKey: s.CredentialsConfig.PrivateKey,
|
||||||
|
ConstPrivateKeyId: s.CredentialsConfig.PrivateKeyId,
|
||||||
|
ConstCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||||
|
"cloud.google.com/go/iam/apiv1/iampb"
|
||||||
|
"cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testIAMAdminServer struct {
|
||||||
|
adminpb.UnimplementedIAMServer
|
||||||
|
testCreateServiceAccountKeyError error
|
||||||
|
testDeleteServiceAccountKeyError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestIAMAdminServer(createServiceAccountKeyError error, deleteServiceAccountKeyError error) *testIAMAdminServer {
|
||||||
|
return &testIAMAdminServer{
|
||||||
|
testCreateServiceAccountKeyError: createServiceAccountKeyError,
|
||||||
|
testDeleteServiceAccountKeyError: deleteServiceAccountKeyError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *testIAMAdminServer) CreateServiceAccountKey(ctx context.Context, req *adminpb.CreateServiceAccountKeyRequest) (*adminpb.ServiceAccountKey, error) {
|
||||||
|
resp := &adminpb.ServiceAccountKey{
|
||||||
|
Name: "projects/-/serviceAccounts/1234/keys/updated-private-key-id",
|
||||||
|
PrivateKeyData: []byte("updated-private-key"),
|
||||||
|
}
|
||||||
|
return resp, f.testCreateServiceAccountKeyError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *testIAMAdminServer) DeleteServiceAccountKey(ctx context.Context, req *adminpb.DeleteServiceAccountKeyRequest) (*emptypb.Empty, error) {
|
||||||
|
return &emptypb.Empty{}, f.testDeleteServiceAccountKeyError
|
||||||
|
}
|
||||||
|
|
||||||
|
type testResourceServer struct {
|
||||||
|
resourcemanagerpb.UnimplementedProjectsServer
|
||||||
|
testIamPermissionsResponse *iampb.TestIamPermissionsResponse
|
||||||
|
testIamPermissionsError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestResourceServer(testIamPermissionsResponse *iampb.TestIamPermissionsResponse, testIamPermissionsError error) *testResourceServer {
|
||||||
|
return &testResourceServer{
|
||||||
|
testIamPermissionsResponse: testIamPermissionsResponse,
|
||||||
|
testIamPermissionsError: testIamPermissionsError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *testResourceServer) TestIamPermissions(context.Context, *iampb.TestIamPermissionsRequest) (*iampb.TestIamPermissionsResponse, error) {
|
||||||
|
return f.testIamPermissionsResponse, f.testIamPermissionsError
|
||||||
|
}
|
||||||
|
|
||||||
|
type grpcServer struct {
|
||||||
|
*grpc.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGRPCServer() *grpcServer {
|
||||||
|
return &grpcServer{Server: grpc.NewServer()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *grpcServer) Start() (string, error) {
|
||||||
|
l, err := net.Listen("tcp", "localhost:0")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to listen: %v", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := s.Serve(l); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return l.Addr().String(), nil
|
||||||
|
}
|
||||||
@@ -9,10 +9,13 @@ import (
|
|||||||
|
|
||||||
compute "cloud.google.com/go/compute/apiv1"
|
compute "cloud.google.com/go/compute/apiv1"
|
||||||
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||||
|
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||||
errors "github.com/hashicorp/boundary-plugin-gcp/internal/errors"
|
errors "github.com/hashicorp/boundary-plugin-gcp/internal/errors"
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
|
"google.golang.org/api/iterator"
|
||||||
|
"google.golang.org/api/option"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
@@ -21,6 +24,10 @@ import (
|
|||||||
// GCP host service plugin.
|
// GCP host service plugin.
|
||||||
type HostPlugin struct {
|
type HostPlugin struct {
|
||||||
pb.UnimplementedHostPluginServiceServer
|
pb.UnimplementedHostPluginServiceServer
|
||||||
|
// testGCPClientOpts are passed in to the GCP client to control test behavior
|
||||||
|
testGCPClientOpts []option.ClientOption
|
||||||
|
// testCatalogStateOpts are passed in to the stored state to control test behavior
|
||||||
|
testCatalogStateOpts []gcpCatalogPersistedStateOption
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -28,7 +35,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// OnCreateCatalog is called when a dynamic host catalog is created.
|
// OnCreateCatalog is called when a dynamic host catalog is created.
|
||||||
func (p *HostPlugin) OnCreateCatalog(_ context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
|
func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
|
||||||
catalog := req.GetCatalog()
|
catalog := req.GetCatalog()
|
||||||
if catalog == nil {
|
if catalog == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||||
@@ -39,14 +46,56 @@ func (p *HostPlugin) OnCreateCatalog(_ context.Context, req *pb.OnCreateCatalogR
|
|||||||
return nil, status.Error(codes.InvalidArgument, "attributes are required")
|
return nil, status.Error(codes.InvalidArgument, "attributes are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := getCatalogAttributes(attrs); err != nil {
|
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
credConfig, err := credential.GetCredentialsConfig(catalog.GetSecrets(), catalogAttributes.CredentialAttributes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
credState, err := credential.NewPersistedState([]credential.Option{
|
||||||
|
credential.WithCredentialsConfig(credConfig),
|
||||||
|
}...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "error creating new persisted state: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions := []string{
|
||||||
|
credential.ComputeInstancesListPermission,
|
||||||
|
credential.IAMServiceAccountKeysCreatePermission,
|
||||||
|
credential.IAMServiceAccountKeysDeletePermission,
|
||||||
|
}
|
||||||
|
|
||||||
|
if credState.CredentialsConfig.IsRotatable() && !catalogAttributes.DisableCredentialRotation {
|
||||||
|
if err := credState.CredentialsConfig.RotateServiceAccountKey(ctx, permissions, p.testGCPClientOpts...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogState, err := newGCPCatalogPersistedState(
|
||||||
|
append([]gcpCatalogPersistedStateOption{
|
||||||
|
withCredentials(credState),
|
||||||
|
}, p.testCatalogStateOpts...)...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st := dryRunValidation(ctx, catalogState, p.testGCPClientOpts...); st != nil {
|
||||||
|
return nil, st.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
persistedProto, err := catalogState.toProto()
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "error converting state to proto: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &pb.OnCreateCatalogResponse{
|
return &pb.OnCreateCatalogResponse{
|
||||||
Persisted: &pb.HostCatalogPersisted{
|
Persisted: persistedProto,
|
||||||
Secrets: nil,
|
|
||||||
},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,3 +332,32 @@ func validateSet(s *hostsets.HostSet) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dryRunValidation performs an GCP List Instances call to verify the state's
|
||||||
|
// credentials. If the call fails, the error is returned.
|
||||||
|
func dryRunValidation(
|
||||||
|
ctx context.Context,
|
||||||
|
state *gcpCatalogPersistedState,
|
||||||
|
clientOptions ...option.ClientOption,
|
||||||
|
) *status.Status {
|
||||||
|
if state == nil {
|
||||||
|
return status.New(codes.InvalidArgument, "persisted state is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
instancesClient, err := state.InstancesClient(ctx, clientOptions...)
|
||||||
|
if err != nil {
|
||||||
|
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting instances client: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
it := instancesClient.List(ctx, &computepb.ListInstancesRequest{
|
||||||
|
Project: state.CredentialsConfig.ProjectId,
|
||||||
|
Zone: state.CredentialsConfig.Zone,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = it.Next()
|
||||||
|
if err != nil && err != iterator.Done {
|
||||||
|
return status.New(codes.FailedPrecondition, fmt.Sprintf("gcp list instances failed: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -9,12 +9,22 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"cloud.google.com/go/compute/apiv1/computepb"
|
||||||
|
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||||
|
"cloud.google.com/go/iam/apiv1/iampb"
|
||||||
|
"cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
"github.com/hashicorp/go-secure-stdlib/parseutil"
|
"github.com/hashicorp/go-secure-stdlib/parseutil"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/testing/protocmp"
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -295,32 +305,15 @@ func TestListHosts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateCatalog(t *testing.T) {
|
func TestCreateCatalog(t *testing.T) {
|
||||||
t.Skip("TODO: this needs a secrets file to run - maybe only manually?")
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
p := &HostPlugin{}
|
|
||||||
|
|
||||||
wd, err := os.Getwd()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEmpty(t, wd)
|
|
||||||
project, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "project"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
zone, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "zone"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
hostCatalogAttributes := &hostcatalogs.HostCatalog_Attributes{
|
|
||||||
Attributes: wrapMap(t, map[string]interface{}{
|
|
||||||
cred.ConstProjectId: project,
|
|
||||||
cred.ConstZone: zone,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
req *pb.OnCreateCatalogRequest
|
req *pb.OnCreateCatalogRequest
|
||||||
expected *pb.HostCatalogPersisted
|
expected *pb.HostCatalogPersisted
|
||||||
|
catalogOpts []gcpCatalogPersistedStateOption
|
||||||
expectedErr string
|
expectedErr string
|
||||||
|
expectedRsp *pb.OnCreateCatalogResponse
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "nil catalog",
|
name: "nil catalog",
|
||||||
@@ -343,34 +336,200 @@ func TestCreateCatalog(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedErr: "attributes.project: missing required value \"project\"",
|
expectedErr: "attributes.project_id: missing required value \"project_id\"",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "do not persist secrets, use gcloud application default creds",
|
name: "using static credentials",
|
||||||
req: &pb.OnCreateCatalogRequest{
|
req: &pb.OnCreateCatalogRequest{
|
||||||
Catalog: &hostcatalogs.HostCatalog{
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
Attrs: hostCatalogAttributes,
|
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
cred.ConstProjectId: structpb.NewStringValue("test-project"),
|
||||||
|
cred.ConstZone: structpb.NewStringValue("us-central1-a"),
|
||||||
|
cred.ConstClientEmail: structpb.NewStringValue("test-client-email@email.com"),
|
||||||
|
cred.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Secrets: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||||
|
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: &pb.HostCatalogPersisted{
|
catalogOpts: []gcpCatalogPersistedStateOption{
|
||||||
Secrets: nil,
|
withTestInstancesAPIFunc(newTestMockInstances(ctx,
|
||||||
|
nil,
|
||||||
|
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{
|
||||||
|
Items: []*computepb.Instance{
|
||||||
|
{
|
||||||
|
Name: pointer("boundary-0"),
|
||||||
|
NetworkInterfaces: []*computepb.NetworkInterface{
|
||||||
|
{
|
||||||
|
AccessConfigs: []*computepb.AccessConfig{
|
||||||
|
{
|
||||||
|
NatIP: pointer("102.1.1.1"),
|
||||||
|
ExternalIpv6: pointer("2001:db8::1"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
testMockInstancesWithListInstancesError(nil),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
expectedRsp: &pb.OnCreateCatalogResponse{
|
||||||
|
Persisted: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||||
|
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "using rotated static credentials",
|
||||||
|
req: &pb.OnCreateCatalogRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
cred.ConstProjectId: structpb.NewStringValue("test-project"),
|
||||||
|
cred.ConstZone: structpb.NewStringValue("us-central1-a"),
|
||||||
|
cred.ConstClientEmail: structpb.NewStringValue("test-client-email@email.com"),
|
||||||
|
cred.ConstDisableCredentialRotation: structpb.NewBoolValue(false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Secrets: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||||
|
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
catalogOpts: []gcpCatalogPersistedStateOption{
|
||||||
|
withTestInstancesAPIFunc(newTestMockInstances(ctx,
|
||||||
|
nil,
|
||||||
|
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{
|
||||||
|
Items: []*computepb.Instance{
|
||||||
|
{
|
||||||
|
Name: pointer("boundary-0"),
|
||||||
|
NetworkInterfaces: []*computepb.NetworkInterface{
|
||||||
|
{
|
||||||
|
AccessConfigs: []*computepb.AccessConfig{
|
||||||
|
{
|
||||||
|
NatIP: pointer("102.1.1.1"),
|
||||||
|
ExternalIpv6: pointer("2001:db8::1"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
testMockInstancesWithListInstancesError(nil),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
expectedRsp: &pb.OnCreateCatalogResponse{
|
||||||
|
Persisted: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
cred.ConstPrivateKey: structpb.NewStringValue("updated-private-key"),
|
||||||
|
cred.ConstPrivateKeyId: structpb.NewStringValue("updated-private-key-id"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "using impersonated credentials",
|
||||||
|
req: &pb.OnCreateCatalogRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
cred.ConstProjectId: structpb.NewStringValue("test-project"),
|
||||||
|
cred.ConstZone: structpb.NewStringValue("us-central1-a"),
|
||||||
|
cred.ConstClientEmail: structpb.NewStringValue("test-client-email@email.com"),
|
||||||
|
cred.ConstTargetServiceAccountID: structpb.NewStringValue("test-target-service-account-id"),
|
||||||
|
cred.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Secrets: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||||
|
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
catalogOpts: []gcpCatalogPersistedStateOption{
|
||||||
|
withTestInstancesAPIFunc(newTestMockInstances(ctx,
|
||||||
|
nil,
|
||||||
|
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}),
|
||||||
|
testMockInstancesWithListInstancesError(nil),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
expectedRsp: &pb.OnCreateCatalogResponse{
|
||||||
|
Persisted: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||||
|
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
|
testIAMAdminServer := cred.NewTestIAMAdminServer(nil, nil)
|
||||||
|
testResourceServer := cred.NewTestResourceServer(&iampb.TestIamPermissionsResponse{
|
||||||
|
Permissions: []string{
|
||||||
|
cred.ComputeInstancesListPermission,
|
||||||
|
cred.IAMServiceAccountKeysCreatePermission,
|
||||||
|
cred.IAMServiceAccountKeysDeletePermission,
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
gsrv := cred.NewGRPCServer()
|
||||||
|
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
|
||||||
|
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||||
|
addr, err := gsrv.Start()
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
p := &HostPlugin{
|
||||||
|
testGCPClientOpts: []option.ClientOption{
|
||||||
|
option.WithEndpoint(addr),
|
||||||
|
option.WithoutAuthentication(),
|
||||||
|
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
|
||||||
|
option.WithTokenSource(nil),
|
||||||
|
},
|
||||||
|
testCatalogStateOpts: tc.catalogOpts,
|
||||||
|
}
|
||||||
|
|
||||||
actual, err := p.OnCreateCatalog(ctx, tc.req)
|
actual, err := p.OnCreateCatalog(ctx, tc.req)
|
||||||
if tc.expectedErr != "" {
|
if tc.expectedErr != "" {
|
||||||
require.Contains(err.Error(), tc.expectedErr)
|
require.Contains(err.Error(), tc.expectedErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(err)
|
require.NoError(err)
|
||||||
require.Nil(actual.GetPersisted().Secrets)
|
delete(actual.GetPersisted().GetSecrets().GetFields(), cred.ConstCredsLastRotatedTime)
|
||||||
|
require.Empty(cmp.Diff(tc.expectedRsp, actual, protocmp.Transform()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,7 +602,6 @@ func TestUpdateCatalog(t *testing.T) {
|
|||||||
tc := tc
|
tc := tc
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
actual, err := p.OnCreateCatalog(ctx, tc.req)
|
actual, err := p.OnCreateCatalog(ctx, tc.req)
|
||||||
if tc.expectedErr != "" {
|
if tc.expectedErr != "" {
|
||||||
require.Contains(err.Error(), tc.expectedErr)
|
require.Contains(err.Error(), tc.expectedErr)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package host
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
compute "cloud.google.com/go/compute/apiv1"
|
||||||
|
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||||
|
"github.com/googleapis/gax-go/v2"
|
||||||
|
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||||
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Iterator interface {
|
||||||
|
Next() (*computepb.Instance, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstancesAPI interface {
|
||||||
|
List(ctx context.Context, req *computepb.ListInstancesRequest, opts ...gax.CallOption) *compute.InstanceIterator
|
||||||
|
}
|
||||||
|
|
||||||
|
type gcpCatalogPersistedState struct {
|
||||||
|
// CredentialsConfig is the configuration for the GCP credentials
|
||||||
|
*credential.PersistedState
|
||||||
|
// testInstancesAPIFunc provides a way to provide a factory for a mock Instances client
|
||||||
|
testInstancesAPIFunc instancesAPIFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type instancesAPIFunc func(...*credential.Config) (InstancesAPI, error)
|
||||||
|
|
||||||
|
type gcpCatalogPersistedStateOption func(s *gcpCatalogPersistedState) error
|
||||||
|
|
||||||
|
func withCredentials(x *credential.PersistedState) gcpCatalogPersistedStateOption {
|
||||||
|
return func(s *gcpCatalogPersistedState) error {
|
||||||
|
if s.PersistedState != nil {
|
||||||
|
return errors.New("gcp credentials already set")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.PersistedState = x
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTestInstancesAPIFunc(f instancesAPIFunc) gcpCatalogPersistedStateOption {
|
||||||
|
return func(s *gcpCatalogPersistedState) error {
|
||||||
|
if s.testInstancesAPIFunc != nil {
|
||||||
|
return errors.New("test API function already set")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.testInstancesAPIFunc = f
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGCPCatalogPersistedState(opts ...gcpCatalogPersistedStateOption) (*gcpCatalogPersistedState, error) {
|
||||||
|
s := new(gcpCatalogPersistedState)
|
||||||
|
for _, opt := range opts {
|
||||||
|
if err := opt(s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToProto converts the state to HostCatalogPersisted proto message.
|
||||||
|
func (s *gcpCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) {
|
||||||
|
data, err := structpb.NewStruct(s.ToMap())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
|
||||||
|
}
|
||||||
|
return &pb.HostCatalogPersisted{Secrets: data}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstancesClient returns a configured Instances client based on the session
|
||||||
|
// information stored in the state.
|
||||||
|
func (s *gcpCatalogPersistedState) InstancesClient(
|
||||||
|
ctx context.Context,
|
||||||
|
opts ...option.ClientOption,
|
||||||
|
) (InstancesAPI, error) {
|
||||||
|
if s.testInstancesAPIFunc != nil {
|
||||||
|
return s.testInstancesAPIFunc(s.CredentialsConfig)
|
||||||
|
}
|
||||||
|
creds, err := s.CredentialsConfig.GenerateCredentials(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error generation GCP credentials: %w", err)
|
||||||
|
}
|
||||||
|
if creds == nil {
|
||||||
|
return nil, fmt.Errorf("nil gcp credentials")
|
||||||
|
}
|
||||||
|
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
|
||||||
|
clientOptions = append(clientOptions, opts...)
|
||||||
|
|
||||||
|
client, err := compute.NewInstancesRESTClient(ctx, clientOptions...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error creating instances client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package host
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWithCredentials(t *testing.T) {
|
||||||
|
cred := &credential.PersistedState{}
|
||||||
|
opt := withCredentials(cred)
|
||||||
|
|
||||||
|
state := &gcpCatalogPersistedState{}
|
||||||
|
err := opt(state)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, cred, state.PersistedState)
|
||||||
|
|
||||||
|
// Test setting credentials again should return an error
|
||||||
|
err = opt(state)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "gcp credentials already set", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGCPCatalogPersistedState(t *testing.T) {
|
||||||
|
cred := &credential.PersistedState{}
|
||||||
|
opt := withCredentials(cred)
|
||||||
|
|
||||||
|
state, err := newGCPCatalogPersistedState(opt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, cred, state.PersistedState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToProto(t *testing.T) {
|
||||||
|
lastRotatedTime := time.Now()
|
||||||
|
state := &gcpCatalogPersistedState{PersistedState: &credential.PersistedState{
|
||||||
|
CredentialsConfig: &credential.Config{
|
||||||
|
PrivateKey: "test-private-key",
|
||||||
|
PrivateKeyId: "test-private-key-id",
|
||||||
|
},
|
||||||
|
CredsLastRotatedTime: lastRotatedTime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
proto, err := state.toProto()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedData, err := structpb.NewStruct(map[string]any{
|
||||||
|
"private_key": "test-private-key",
|
||||||
|
"private_key_id": "test-private-key-id",
|
||||||
|
"creds_last_rotated_time": lastRotatedTime.Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedData, proto.Secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstancesClient(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cred := &credential.PersistedState{
|
||||||
|
CredentialsConfig: &credential.Config{},
|
||||||
|
}
|
||||||
|
state := &gcpCatalogPersistedState{PersistedState: cred}
|
||||||
|
|
||||||
|
t.Run("GenerateCredentials error", func(t *testing.T) {
|
||||||
|
_, err := state.InstancesClient(ctx)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "error generation GCP credentials")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NewInstancesRESTClient error", func(t *testing.T) {
|
||||||
|
state.CredentialsConfig = &credential.Config{
|
||||||
|
ProjectId: "test-project-id",
|
||||||
|
PrivateKey: "test-private-key",
|
||||||
|
ClientEmail: "test@example.com",
|
||||||
|
}
|
||||||
|
_, err := state.InstancesClient(ctx, option.WithoutAuthentication())
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "error creating instances client")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package host
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
compute "cloud.google.com/go/compute/apiv1"
|
||||||
|
"cloud.google.com/go/compute/apiv1/computepb"
|
||||||
|
"github.com/googleapis/gax-go/v2"
|
||||||
|
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testHTTPServer struct {
|
||||||
|
server *httptest.Server
|
||||||
|
listInstancesResponse *computepb.InstanceList
|
||||||
|
listInstancesError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testHTTPServer) start() *httptest.Server {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var resp any
|
||||||
|
if strings.Contains(r.URL.Path, "/compute/v1/projects") {
|
||||||
|
if s.listInstancesError != nil {
|
||||||
|
http.Error(w, "error listing instances: "+s.listInstancesError.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp = s.listInstancesResponse
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
http.Error(w, "unknown path: "+r.URL.Path, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to marshal request: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = w.Write(b)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to write response: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
s.server = ts
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointer[T any](input T) *T {
|
||||||
|
ret := input
|
||||||
|
return &ret
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMockInstancesState struct {
|
||||||
|
ListInstancesCalled bool
|
||||||
|
ListInstancesInputParams *computepb.ListInstancesRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testMockInstancesState) Reset() {
|
||||||
|
s.ListInstancesCalled = false
|
||||||
|
s.ListInstancesInputParams = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMockInstances struct {
|
||||||
|
InstancesAPI
|
||||||
|
httpServer *testHTTPServer
|
||||||
|
State *testMockInstancesState
|
||||||
|
client *compute.InstancesClient
|
||||||
|
ListInstancesOutput *computepb.InstanceList
|
||||||
|
ListInstancesError error
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMockInstancesOption func(m *testMockInstances) error
|
||||||
|
|
||||||
|
func testMockInstancesWithListInstancesOutput(o *computepb.InstanceList) testMockInstancesOption {
|
||||||
|
return func(m *testMockInstances) error {
|
||||||
|
m.ListInstancesOutput = o
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMockInstancesWithListInstancesError(e error) testMockInstancesOption {
|
||||||
|
return func(m *testMockInstances) error {
|
||||||
|
m.ListInstancesError = e
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestMockInstances(ctx context.Context, state *testMockInstancesState, opts ...testMockInstancesOption) instancesAPIFunc {
|
||||||
|
return func(cfgs ...*credential.Config) (InstancesAPI, error) {
|
||||||
|
m := &testMockInstances{
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
if err := opt(m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.httpServer = &testHTTPServer{
|
||||||
|
listInstancesResponse: m.ListInstancesOutput,
|
||||||
|
listInstancesError: m.ListInstancesError,
|
||||||
|
}
|
||||||
|
m.httpServer.start()
|
||||||
|
endpoint := m.httpServer.server.URL
|
||||||
|
|
||||||
|
if m.client == nil {
|
||||||
|
client, err := compute.NewInstancesRESTClient(ctx,
|
||||||
|
option.WithoutAuthentication(),
|
||||||
|
option.WithEndpoint(endpoint),
|
||||||
|
option.WithTokenSource(nil),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *testMockInstances) List(ctx context.Context, input *computepb.ListInstancesRequest, opts ...gax.CallOption) *compute.InstanceIterator {
|
||||||
|
if m.State != nil {
|
||||||
|
m.State.ListInstancesCalled = true
|
||||||
|
m.State.ListInstancesInputParams = input
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.client.List(ctx, input)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user