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:
Elim Tsiagbey authored and GitHub committed 2024-10-21 10:47:06 -04:00
1 parent 636432f2fb
commit 6b917b170d
14 files changed
+992 -90

No files matched your search

+2 -1
View File
@@ -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
+67
View File
@@ -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
}
+163
View File
@@ -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)
})
}
}
+17
View File
@@ -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(
+43
View File
@@ -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)
})
}
}
+6
View File
@@ -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"
) )
+4 -56
View File
@@ -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
}
+11
View File
@@ -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),
}
}
+79
View File
@@ -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
}
+83 -5
View File
@@ -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
}
+186 -28
View File
@@ -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)
+105
View File
@@ -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
}
+88
View File
@@ -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")
})
}
+138
View File
@@ -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)
}