feat: OnUpdate Host Catalog (#9)

* feat: OnUpdate Host Catalog

Add support for onUpdate host catalog
This commit is contained in:
Elim Tsiagbey authored and GitHub committed 2024-10-28 10:48:26 -04:00
1 parent 6b917b170d
commit 08fd6e5b01
11 files changed
+989 -52

No files matched your search

+20
View File
@@ -55,6 +55,26 @@ type credentials struct {
PrivateKeyId string `json:"private_key_id"`
}
// NewConfig creates a new GCP credential configuration
// based on the provided options.
// If the options are invalid, it will return an error.
func NewConfig(opt ...Option) (*Config, error) {
opts, err := getOpts(opt...)
if err != nil {
return nil, err
}
c := &Config{
ProjectId: opts.WithProjectId,
PrivateKey: opts.WithPrivateKey,
PrivateKeyId: opts.WithPrivateKeyId,
ClientEmail: opts.WithClientEmail,
TargetServiceAccountId: opts.WithTargetServiceAccountId,
Zone: opts.WithZone,
Scopes: opts.WithScopes,
}
return c, nil
}
// toCredentials converts the config to credentials.
func (c *Config) toCredentials() *credentials {
return &credentials{
+62
View File
@@ -383,3 +383,65 @@ func TestIsRotatable(t *testing.T) {
})
}
}
func TestNewConfig(t *testing.T) {
tests := []struct {
name string
options []Option
expected *Config
expectError bool
}{
{
name: "Valid Options",
options: []Option{
WithProjectId("test-project-id"),
WithPrivateKey("test-private-key"),
WithPrivateKeyId("test-private-key-id"),
WithClientEmail("test-email@example.com"),
WithTargetServiceAccountId("target-service-account@example.com"),
WithZone("test-zone"),
WithScopes([]string{"scope1", "scope2"}),
},
expected: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
PrivateKeyId: "test-private-key-id",
ClientEmail: "test-email@example.com",
TargetServiceAccountId: "target-service-account@example.com",
Zone: "test-zone",
Scopes: []string{"scope1", "scope2"},
},
expectError: false,
},
{
name: "No Options",
options: []Option{},
expected: &Config{},
expectError: false,
},
{
name: "Invalid Option",
options: []Option{
func(opts *Options) error {
return errors.New("invalid option")
},
},
expected: nil,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := NewConfig(tt.options...)
if tt.expectError {
require.Error(t, err)
require.Nil(t, config)
return
}
require.NoError(t, err)
require.NotNil(t, config)
require.Equal(t, tt.expected, config)
})
}
}
+75 -3
View File
@@ -9,8 +9,15 @@ import (
// options = how options are represented
type Options struct {
WithCredentialsConfig *Config
WithCredsLastRotatedTime time.Time
WithCredentialsConfig *Config
WithCredsLastRotatedTime time.Time
WithClientEmail string
WithProjectId string
WithTargetServiceAccountId string
WithZone string
WithPrivateKeyId string
WithPrivateKey string
WithScopes []string
}
// getOpts - iterate the inbound Options and return a struct
@@ -41,10 +48,75 @@ func WithCredentialsConfig(c *Config) Option {
}
}
// WithCredsLastRotatedTime - set the last rotated time
// WithCredsLastRotatedTime - The last time the service account key was
// rotated
func WithCredsLastRotatedTime(t time.Time) Option {
return func(o *Options) error {
o.WithCredsLastRotatedTime = t
return nil
}
}
// WithClientEmail - The email address associated with the service account.
// The email address used to uniquely identify the service account
func WithClientEmail(email string) Option {
return func(o *Options) error {
o.WithClientEmail = email
return nil
}
}
// WithProjectId - The project ID associated with the service account
func WithProjectId(id string) Option {
return func(o *Options) error {
o.WithProjectId = id
return nil
}
}
// WithTargetServiceAccountId - The account that will be impersonated.
// This account has permission to perform actions that the base service
// account does not have.
func WithTargetServiceAccountId(id string) Option {
return func(o *Options) error {
o.WithTargetServiceAccountId = id
return nil
}
}
// WithZone - The zone where the GCP resources are located
func WithZone(zone string) Option {
return func(o *Options) error {
o.WithZone = zone
return nil
}
}
// WithPrivateKeyId - The private key ID
// of the GCP service account
func WithPrivateKeyId(id string) Option {
return func(o *Options) error {
o.WithPrivateKeyId = id
return nil
}
}
// WithPrivateKey - set the GCP service account
// private key which is used to authentication
func WithPrivateKey(key string) Option {
return func(o *Options) error {
o.WithPrivateKey = key
return nil
}
}
// WithScopes - set the GCP scope that defines the level
// of access that the requested access token will have.
// This option is required when authenticating with
// Service Account Impersonation.
func WithScopes(scopes []string) Option {
return func(o *Options) error {
o.WithScopes = scopes
return nil
}
}
+89 -1
View File
@@ -25,11 +25,99 @@ func Test_getOpts(t *testing.T) {
require.Equal(t, opts, testOpts)
})
t.Run("WithCredsLastRotatedTime", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
require.Empty(t, opts.WithCredsLastRotatedTime)
tm := time.Now()
opts, err := getOpts(WithCredsLastRotatedTime(tm))
opts, err = getOpts(WithCredsLastRotatedTime(tm))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithCredsLastRotatedTime = tm
require.Equal(t, opts, testOpts)
})
t.Run("WithClientEmail", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
require.Empty(t, opts.WithClientEmail)
email := "test"
opts, err = getOpts(WithClientEmail(email))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithClientEmail = email
require.Equal(t, opts, testOpts)
})
t.Run("WithProjectId", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
require.Empty(t, opts.WithProjectId)
projectID := "test"
opts, err = getOpts(WithProjectId(projectID))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithProjectId = projectID
require.Equal(t, opts, testOpts)
})
t.Run("WithTargetServiceAccountId", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
require.Empty(t, opts.WithTargetServiceAccountId)
serviceAccountID := "test"
opts, err = getOpts(WithTargetServiceAccountId(serviceAccountID))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithTargetServiceAccountId = serviceAccountID
require.Equal(t, opts, testOpts)
})
t.Run("WithZone", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
require.Empty(t, opts.WithZone)
zone := "test"
opts, err = getOpts(WithZone(zone))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithZone = zone
require.Equal(t, opts, testOpts)
})
t.Run("WithPrivateKey", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
require.Empty(t, opts.WithPrivateKey)
privateKey := "test"
opts, err = getOpts(WithPrivateKey(privateKey))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithPrivateKey = privateKey
require.Equal(t, opts, testOpts)
})
t.Run("WithPrivateKeyId", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
require.Empty(t, opts.WithPrivateKeyId)
privateKeyID := "test"
opts, err = getOpts(WithPrivateKeyId(privateKeyID))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithPrivateKeyId = privateKeyID
require.Equal(t, opts, testOpts)
})
t.Run("WithScopes", func(t *testing.T) {
opts, err := getOpts()
require.NoError(t, err)
require.Nil(t, opts.WithScopes)
scopes := []string{"test"}
opts, err = getOpts(WithScopes(scopes))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.WithScopes = scopes
require.Equal(t, opts, testOpts)
})
}
+37
View File
@@ -112,6 +112,43 @@ func (c *Config) RotateServiceAccountKey(ctx context.Context, permissions []stri
return nil
}
func (c *Config) DeletePrivateKey(ctx context.Context, opts ...option.ClientOption) error {
if c.PrivateKey == "" {
return status.Error(codes.InvalidArgument, "cannot delete credentials when private key is not set")
}
if c.PrivateKeyId == "" {
return status.Error(codes.InvalidArgument, "cannot delete credentials when private key ID is not set")
}
if c.ClientEmail == "" {
return status.Error(codes.InvalidArgument, "cannot delete credentials when client email is not set")
}
creds, err := c.GenerateCredentials(ctx)
if err != nil {
return status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
}
if creds.TokenSource == nil {
return status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
}
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
clientOptions = append(clientOptions, opts...)
iamClient, err := admin.NewIamClient(ctx, clientOptions...)
if err != nil {
return status.Errorf(codes.Internal, "error creating IAM client: %v", err)
}
err = iamClient.DeleteServiceAccountKey(ctx, &adminpb.DeleteServiceAccountKeyRequest{
Name: fmt.Sprintf("projects/-/serviceAccounts/%s/keys/%s", c.ClientEmail, c.PrivateKeyId),
})
if err != nil {
return status.Errorf(codes.Internal, "error deleting service account key: %v", err)
}
return nil
}
// ValidateIamPermissions tests the IAM permissions for the credentials.
// It returns the granted permissions if successful.
func (c *Config) ValidateIamPermissions(ctx context.Context, permissions []string, opts ...option.ClientOption) ([]string, error) {
+97
View File
@@ -264,3 +264,100 @@ func TestValidateIamPermissions(t *testing.T) {
})
}
}
func TestDeletePrivateKey(t *testing.T) {
testIAMAdminServer := &testIAMAdminServer{}
tests := []struct {
name string
config *Config
setup func()
expectedError error
}{
{
name: "PrivateKey is empty",
config: &Config{
PrivateKey: "",
},
expectedError: status.Error(codes.InvalidArgument, "cannot delete credentials when private key is not set"),
},
{
name: "PrivateKeyId is empty",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "",
},
expectedError: status.Error(codes.InvalidArgument, "cannot delete credentials when private key ID is not set"),
},
{
name: "ClientEmail is empty",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "some-private-key-id",
ClientEmail: "",
},
expectedError: status.Error(codes.InvalidArgument, "cannot delete credentials when client email is not set"),
},
{
name: "GenerateCredentials returns error",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "some-private-key-id",
ClientEmail: "client@example.com",
TargetServiceAccountId: "test-service-account-id",
},
expectedError: status.Errorf(codes.Unauthenticated, "error generating credentials"),
},
{
name: "DeleteServiceAccountKey returns error",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "some-private-key-id",
ClientEmail: "client@example.com",
},
setup: func() {
testIAMAdminServer.testDeleteServiceAccountKeyError = errors.New("delete key error")
},
expectedError: status.Errorf(codes.Internal, "error deleting service account key"),
},
{
name: "Successful deletion",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "some-private-key-id",
ClientEmail: "client@example.com",
},
setup: func() {
testIAMAdminServer.testDeleteServiceAccountKeyError = nil
},
expectedError: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gsrv := NewGRPCServer()
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
addr, err := gsrv.Start()
require.NoError(t, err)
ctx := context.Background()
if tt.setup != nil {
tt.setup()
}
testOptions := []googleOption.ClientOption{
googleOption.WithEndpoint(addr),
googleOption.WithoutAuthentication(),
googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
googleOption.WithTokenSource(nil),
}
err = tt.config.DeletePrivateKey(ctx, testOptions...)
if tt.expectedError != nil {
require.ErrorContains(t, err, tt.expectedError.Error())
return
}
require.NoError(t, err)
})
}
}
+121
View File
@@ -4,7 +4,15 @@
package credential
import (
"context"
"fmt"
"time"
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
"google.golang.org/api/option"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
type CredentialType int
@@ -29,13 +37,126 @@ func NewPersistedState(opt ...Option) (*PersistedState, error) {
return s, nil
}
// ReplaceCreds replaces the private key in the state with a new key.
// If the existing key was rotated at any point in time, it is
// deleted first, otherwise it's left alone. This method returns a
// status error with PluginError details.
func (s *PersistedState) ReplaceCreds(ctx context.Context, newCreds *Config, opts ...option.ClientOption) error {
if newCreds == nil {
return status.Errorf(codes.InvalidArgument, "missing new credentials")
}
if s.CredentialsConfig == nil {
return status.Errorf(codes.InvalidArgument, "missing existing credentials")
}
// Delete old/existing credentials.
// This is done with the same credentials to ensure that it has the proper permissions to do it.
if !s.CredsLastRotatedTime.IsZero() && s.CredentialsConfig.IsRotatable() {
if err := s.DeleteCreds(ctx, opts...); err != nil {
return err
}
}
// Set the new attributes and clear the rotated time.
s.CredentialsConfig = newCreds
s.CredsLastRotatedTime = time.Time{}
return nil
}
// DeleteCreds deletes the credentials in the state. The access key
// ID, secret access key, and rotation time fields are zeroed out in
// the state just to ensure that they cannot be re-used after. This
// method returns a status error with PluginError details.
func (s *PersistedState) DeleteCreds(ctx context.Context, opts ...option.ClientOption) error {
if s.CredentialsConfig == nil {
return status.Errorf(codes.InvalidArgument, "missing credentials config")
}
if !s.CredentialsConfig.IsRotatable() {
return status.Errorf(codes.InvalidArgument, "cannot delete credentials for non-rotatable credentials")
}
err := s.CredentialsConfig.DeletePrivateKey(ctx, opts...)
if err != nil {
return err
}
s.CredentialsConfig = nil
s.CredsLastRotatedTime = time.Time{}
return 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 {
if !s.CredentialsConfig.IsRotatable() {
return map[string]any{}
}
return map[string]any{
ConstPrivateKey: s.CredentialsConfig.PrivateKey,
ConstPrivateKeyId: s.CredentialsConfig.PrivateKeyId,
ConstCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
}
}
// PersistedStateFromProto parses values out of a protobuf struct input
// and returns a PersistedState used for GCP authentication.
func PersistedStateFromProto(secrets *structpb.Struct, attrs *CredentialAttributes, opts ...Option) (*PersistedState, error) {
if secrets == nil {
secrets = &structpb.Struct{
Fields: map[string]*structpb.Value{},
}
}
if attrs == nil {
return nil, fmt.Errorf("missing credential attributes")
}
privateKeyId, err := values.GetStringValue(secrets, ConstPrivateKeyId, false)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
}
privateKey, err := values.GetStringValue(secrets, ConstPrivateKey, false)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
}
credsLastRotatedTime, err := values.GetTimeValue(secrets, ConstCredsLastRotatedTime)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
}
s, err := NewPersistedState(opts...)
if err != nil {
return nil, err
}
cfgOpts := []Option{}
if privateKeyId != "" && privateKey != "" {
cfgOpts = append(cfgOpts,
WithPrivateKey(privateKey),
WithPrivateKeyId(privateKeyId))
}
if attrs.Zone != "" {
cfgOpts = append(cfgOpts, WithZone(attrs.Zone))
}
if attrs.ProjectId != "" {
cfgOpts = append(cfgOpts, WithProjectId(attrs.ProjectId))
}
if attrs.ClientEmail != "" {
cfgOpts = append(cfgOpts, WithClientEmail(attrs.ClientEmail))
}
if attrs.TargetServiceAccountId != "" {
cfgOpts = append(cfgOpts, WithTargetServiceAccountId(attrs.TargetServiceAccountId))
}
credentialsConfig, err := NewConfig(cfgOpts...)
if err != nil {
return nil, err
}
s.CredentialsConfig = credentialsConfig
s.CredsLastRotatedTime = credsLastRotatedTime
return s, nil
}
+107
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
func TestNewGCPCredentialPersistedState(t *testing.T) {
@@ -77,3 +78,109 @@ func TestNewGCPCredentialPersistedState(t *testing.T) {
})
}
}
func TestPersistedStateFromProto(t *testing.T) {
staticTime := time.Now()
cases := []struct {
name string
secrets *structpb.Struct
attrs *CredentialAttributes
opts []Option
expected *PersistedState
expectedErr string
}{
{
name: "missing credential attributes",
expectedErr: "missing credential attributes",
},
{
name: "missing credential attributes",
secrets: &structpb.Struct{},
attrs: nil,
expectedErr: "missing credential attributes",
},
{
name: "with static credentials",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
ConstPrivateKey: structpb.NewStringValue("test-private-key"),
ConstCredsLastRotatedTime: structpb.NewStringValue(staticTime.Format(time.RFC3339Nano)),
},
},
attrs: &CredentialAttributes{
Zone: "us-central1-a",
ProjectId: "test-project",
ClientEmail: "test-email",
},
expected: &PersistedState{
CredentialsConfig: &Config{
PrivateKeyId: "test-private-key-id",
PrivateKey: "test-private-key",
Zone: "us-central1-a",
ProjectId: "test-project",
ClientEmail: "test-email",
},
CredsLastRotatedTime: func() time.Time {
t, err := time.Parse(time.RFC3339Nano, staticTime.Format(time.RFC3339Nano))
if err != nil {
panic(err)
}
return t
}(),
},
},
{
name: "with service account impersonation credentials",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
ConstPrivateKey: structpb.NewStringValue("test-private-key"),
ConstCredsLastRotatedTime: structpb.NewStringValue((time.Time{}).Format(time.RFC3339Nano)),
},
},
attrs: &CredentialAttributes{
Zone: "us-central1-a",
ProjectId: "test-project",
ClientEmail: "test-email",
TargetServiceAccountId: "test-service-account-id",
},
expected: &PersistedState{
CredentialsConfig: &Config{
PrivateKeyId: "test-private-key-id",
PrivateKey: "test-private-key",
Zone: "us-central1-a",
ProjectId: "test-project",
ClientEmail: "test-email",
TargetServiceAccountId: "test-service-account-id",
},
CredsLastRotatedTime: time.Time{},
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := PersistedStateFromProto(tc.secrets, tc.attrs, tc.opts...)
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
return
}
require.NoError(err)
require.Equal(tc.expected.CredsLastRotatedTime, actual.CredsLastRotatedTime)
if tc.expected.CredentialsConfig != nil {
require.NotNil(actual.CredentialsConfig)
require.Equal(tc.expected.CredentialsConfig.PrivateKeyId, actual.CredentialsConfig.PrivateKeyId)
require.Equal(tc.expected.CredentialsConfig.PrivateKey, actual.CredentialsConfig.PrivateKey)
require.Equal(tc.expected.CredentialsConfig.Zone, actual.CredentialsConfig.Zone)
require.Equal(tc.expected.CredentialsConfig.ProjectId, actual.CredentialsConfig.ProjectId)
require.Equal(tc.expected.CredentialsConfig.ClientEmail, actual.CredentialsConfig.ClientEmail)
require.Equal(tc.expected.CredentialsConfig.TargetServiceAccountId, actual.CredentialsConfig.TargetServiceAccountId)
}
})
}
}