diff --git a/go.mod b/go.mod index 8f2f6fe..a692388 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.21 require ( cloud.google.com/go/iam v1.2.0 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/go-secure-stdlib/parseutil v0.1.8 golang.org/x/oauth2 v0.22.0 @@ -23,7 +25,6 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/s2a-go v0.1.8 // 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-sockaddr v1.0.5 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect diff --git a/internal/credential/attributes.go b/internal/credential/attributes.go index 71afc88..6a991ad 100644 --- a/internal/credential/attributes.go +++ b/internal/credential/attributes.go @@ -72,3 +72,70 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) TargetServiceAccountId: targetServiceAccountId, }, 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 +} diff --git a/internal/credential/attributes_test.go b/internal/credential/attributes_test.go index 00cd0a0..81d5bb7 100644 --- a/internal/credential/attributes_test.go +++ b/internal/credential/attributes_test.go @@ -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) + }) + } +} diff --git a/internal/credential/config.go b/internal/credential/config.go index 6e7b637..996fefc 100644 --- a/internal/credential/config.go +++ b/internal/credential/config.go @@ -40,6 +40,7 @@ type Config struct { ProjectId string PrivateKey string PrivateKeyId string + Zone string ClientEmail string TargetServiceAccountId string Scopes []string @@ -166,6 +167,22 @@ func (c *Config) credentialsFromADC(ctx context.Context) (*google.Credentials, e 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. // This is used to impersonate the service account to generate an access token. func impersonateServiceAccount( diff --git a/internal/credential/config_test.go b/internal/credential/config_test.go index 0f29bb9..2225854 100644 --- a/internal/credential/config_test.go +++ b/internal/credential/config_test.go @@ -340,3 +340,46 @@ func TestClone(t *testing.T) { require.NotEqual(t, originalConfig.TargetServiceAccountId, clonedConfig.TargetServiceAccountId) 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) + }) + } +} diff --git a/internal/credential/const.go b/internal/credential/const.go index 279f2aa..1342a76 100644 --- a/internal/credential/const.go +++ b/internal/credential/const.go @@ -21,4 +21,10 @@ const ( // ConstTargetServiceAccountID is the unique identifier for the service account that will be impersonated. 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" ) diff --git a/internal/credential/rotate_test.go b/internal/credential/rotate_test.go index a64f528..9526f91 100644 --- a/internal/credential/rotate_test.go +++ b/internal/credential/rotate_test.go @@ -6,8 +6,6 @@ package credential import ( "context" "errors" - "fmt" - "net" "testing" "cloud.google.com/go/iam/admin/apiv1/adminpb" @@ -19,7 +17,6 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/emptypb" ) func TestRotateServiceAccountKey(t *testing.T) { @@ -106,10 +103,10 @@ func TestRotateServiceAccountKey(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gsrv := newGRPCServer() + gsrv := NewGRPCServer() adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer) resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer) - addr, err := gsrv.serve() + addr, err := gsrv.Start() require.NoError(t, err) ctx := context.Background() @@ -245,9 +242,9 @@ func TestValidateIamPermissions(t *testing.T) { testResourceServer.testIamPermissionsResponse = tt.testIamResponse testResourceServer.testIamPermissionsError = tt.testIamPermissionsError - gsrv := newGRPCServer() + gsrv := NewGRPCServer() resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer) - addr, err := gsrv.serve() + addr, err := gsrv.Start() require.NoError(t, err) 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 -} diff --git a/internal/credential/state.go b/internal/credential/state.go index a03fada..bbae593 100644 --- a/internal/credential/state.go +++ b/internal/credential/state.go @@ -28,3 +28,14 @@ func NewPersistedState(opt ...Option) (*PersistedState, error) { s.CredsLastRotatedTime = opts.WithCredsLastRotatedTime 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), + } +} diff --git a/internal/credential/testing.go b/internal/credential/testing.go new file mode 100644 index 0000000..ba9ab6a --- /dev/null +++ b/internal/credential/testing.go @@ -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 +} diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index f7bf1ea..c17cb05 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -9,10 +9,13 @@ import ( compute "cloud.google.com/go/compute/apiv1" 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" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" "github.com/mitchellh/mapstructure" + "google.golang.org/api/iterator" + "google.golang.org/api/option" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -21,6 +24,10 @@ import ( // GCP host service plugin. type HostPlugin struct { 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 ( @@ -28,7 +35,7 @@ var ( ) // 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() if catalog == 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") } - if _, err := getCatalogAttributes(attrs); err != nil { + catalogAttributes, err := getCatalogAttributes(attrs) + if err != nil { 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{ - Persisted: &pb.HostCatalogPersisted{ - Secrets: nil, - }, + Persisted: persistedProto, }, nil } @@ -283,3 +332,32 @@ func validateSet(s *hostsets.HostSet) error { } 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 +} diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index b4875f8..e671271 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -9,12 +9,22 @@ import ( "path/filepath" "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" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" "github.com/hashicorp/go-secure-stdlib/parseutil" "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" ) @@ -295,32 +305,15 @@ func TestListHosts(t *testing.T) { } func TestCreateCatalog(t *testing.T) { - t.Skip("TODO: this needs a secrets file to run - maybe only manually?") 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 { name string req *pb.OnCreateCatalogRequest expected *pb.HostCatalogPersisted + catalogOpts []gcpCatalogPersistedStateOption expectedErr string + expectedRsp *pb.OnCreateCatalogResponse }{ { 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{ 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{ - Secrets: nil, + 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("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 { - tc := tc t.Run(tc.name, func(t *testing.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) if tc.expectedErr != "" { require.Contains(err.Error(), tc.expectedErr) return } - 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 t.Run(tc.name, func(t *testing.T) { require := require.New(t) - actual, err := p.OnCreateCatalog(ctx, tc.req) if tc.expectedErr != "" { require.Contains(err.Error(), tc.expectedErr) diff --git a/plugin/service/host/state.go b/plugin/service/host/state.go new file mode 100644 index 0000000..5b7fae0 --- /dev/null +++ b/plugin/service/host/state.go @@ -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 +} diff --git a/plugin/service/host/state_test.go b/plugin/service/host/state_test.go new file mode 100644 index 0000000..6a8445a --- /dev/null +++ b/plugin/service/host/state_test.go @@ -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") + }) +} diff --git a/plugin/service/host/testing.go b/plugin/service/host/testing.go new file mode 100644 index 0000000..9d3b5eb --- /dev/null +++ b/plugin/service/host/testing.go @@ -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) +}