From 4a8009fec4ba357ea7630e070526e8c64e17a99e Mon Sep 17 00:00:00 2001 From: Justin Nguyen Date: Wed, 15 Oct 2025 14:31:25 -0500 Subject: [PATCH] feat(credential/vault): Implement repository for vault password credential (#6140) --- globals/credentials.go | 1 + internal/credential/credential.go | 7 + .../credential/vault/internal/password/doc.go | 6 + .../vault/internal/password/password.go | 118 +++++++++ .../vault/internal/password/password_test.go | 176 +++++++++++++ .../credential/vault/mapping_overriders.go | 61 +++++ .../vault/mapping_overriders_test.go | 20 ++ internal/credential/vault/private_library.go | 42 ++++ .../credential/vault/private_library_test.go | 238 ++++++++++++++++++ .../vault/repository_credential_library.go | 8 + .../repository_credential_library_test.go | 202 ++++++++++++++- internal/credential/vault/store/vault.pb.go | 125 ++++++--- .../credential/vault/store/v1/vault.proto | 16 ++ 13 files changed, 990 insertions(+), 30 deletions(-) create mode 100644 internal/credential/vault/internal/password/doc.go create mode 100644 internal/credential/vault/internal/password/password.go create mode 100644 internal/credential/vault/internal/password/password_test.go diff --git a/globals/credentials.go b/globals/credentials.go index ee1f2bbd4..988af79ae 100644 --- a/globals/credentials.go +++ b/globals/credentials.go @@ -11,6 +11,7 @@ const ( UnspecifiedCredentialType CredentialType = "unspecified" UsernamePasswordCredentialType CredentialType = "username_password" UsernamePasswordDomainCredentialType CredentialType = "username_password_domain" + PasswordCredentialType CredentialType = "password" SshPrivateKeyCredentialType CredentialType = "ssh_private_key" SshCertificateCredentialType CredentialType = "ssh_certificate" JsonCredentialType CredentialType = "json" diff --git a/internal/credential/credential.go b/internal/credential/credential.go index 03bf3d5c5..f28ee9b92 100644 --- a/internal/credential/credential.go +++ b/internal/credential/credential.go @@ -143,6 +143,13 @@ type UsernamePasswordDomain interface { Domain() string } +// PasswordCredential is a credential containing a username and a password. +// Does not follow naming convention to avoid conflict with existing Password type. +type PasswordCredential interface { + Credential + Password() Password +} + // SshPrivateKey is a credential containing a username an SSH private key and // an optional private key passphrase. type SshPrivateKey interface { diff --git a/internal/credential/vault/internal/password/doc.go b/internal/credential/vault/internal/password/doc.go new file mode 100644 index 000000000..78e6efc18 --- /dev/null +++ b/internal/credential/vault/internal/password/doc.go @@ -0,0 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// Package password provides access to the password +// stored in a Vault secret. +package password diff --git a/internal/credential/vault/internal/password/password.go b/internal/credential/vault/internal/password/password.go new file mode 100644 index 000000000..f0f13bca8 --- /dev/null +++ b/internal/credential/vault/internal/password/password.go @@ -0,0 +1,118 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package password + +import ( + "strings" + + "github.com/mitchellh/pointerstructure" +) + +type ( + data map[string]any + + // extractFunc attempts to extract the password + // from sd using the provided attribute names, using a known + // Vault data response format. + extractFunc func(sd data, passwordAttr string) string +) + +// Extract attempts to extract the values of the password +// stored within the provided data using the given attribute names. +// +// Extract does not return partial results, i.e. if one of the attributes +// were extracted but not the other ("") will be returned. +func Extract(d data, passwordAttr string) string { + for _, f := range []extractFunc{ + defaultExtract, + kv2Extract, + } { + password := f(d, passwordAttr) + if password != "" { + // got valid password from secret + return password + } + } + + return "" +} + +// defaultExtract looks for the passwordAttr in the data map +func defaultExtract(sd data, passwordAttr string) (password string) { + if sd == nil { + // nothing to do return early + return "" + } + + var p any + switch { + case strings.HasPrefix(passwordAttr, "/"): + var err error + p, err = pointerstructure.Get(sd, passwordAttr) + if err != nil { + return "" + } + + default: + p = sd[passwordAttr] + } + + if p, ok := p.(string); ok { + password = p + } + + return password +} + +// kv2Extract looks for the the passwordAttr in the embedded +// 'data' field within the data map. +// +// Additionally it validates the data is in the expected KV-v2 format: +// +// { +// "data": {}, +// "metadata: {} +// } +// +// If the format does not match, it returns (""). See: +// https://www.vaultproject.io/api/secret/kv/kv-v2#sample-response-1 +func kv2Extract(sd data, passwordAttr string) (password string) { + if sd == nil { + // nothing to do return early + return "" + } + + var data, metadata map[string]any + for k, v := range sd { + switch k { + case "data": + var ok bool + if data, ok = v.(map[string]any); !ok { + // data field should be of type map[string]interface{} in KV-v2 + return "" + } + case "metadata": + var ok bool + if metadata, ok = v.(map[string]any); !ok { + // metadata field should be of type map[string]interface{} in KV-v2 + return "" + } + default: + // secretData contains a non valid KV-v2 top level field + return "" + } + } + if data == nil || metadata == nil { + // missing required KV-v2 field + return "" + } + + if p, ok := data[passwordAttr]; ok { + if p, ok := p.(string); ok { + password = p + } + } + + return password +} diff --git a/internal/credential/vault/internal/password/password_test.go b/internal/credential/vault/internal/password/password_test.go new file mode 100644 index 000000000..6f05cc653 --- /dev/null +++ b/internal/credential/vault/internal/password/password_test.go @@ -0,0 +1,176 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package password + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBaseToPass(t *testing.T) { + t.Parallel() + + type args struct { + s data + pAttr string + } + type pass struct { + pass string + } + tests := []struct { + name string + given args + want pass + }{ + { + name: "nil-input", + want: pass{pass: ""}, + }, + { + name: "no-input", + given: args{}, + want: pass{pass: ""}, + }, + { + name: "no-secret", + given: args{ + pAttr: "password", + }, + want: pass{pass: ""}, + }, + { + name: "no-match-password-secret", + given: args{ + s: data{ + "password-wrong": "pass", + }, + pAttr: "password", + }, + want: pass{pass: ""}, + }, + { + name: "valid-default", + given: args{ + s: data{ + "password": "pass", + }, + pAttr: "password", + }, + want: pass{pass: "pass"}, + }, + { + name: "no-match-password-secret-kv2", + given: args{ + s: data{ + "metadata": map[string]any{}, + "data": map[string]any{ + "password-wrong": "pass", + }, + }, + pAttr: "password", + }, + want: pass{pass: ""}, + }, + { + name: "valid-kv2", + given: args{ + s: data{ + "metadata": map[string]any{}, + "data": map[string]any{ + "password": "pass", + }, + }, + pAttr: "password", + }, + want: pass{pass: "pass"}, + }, + { + name: "no-metadata-kv2", + given: args{ + s: data{ + "data": map[string]any{ + "password": "pass", + }, + }, + pAttr: "password", + }, + want: pass{pass: ""}, + }, + { + name: "invalid-metadata-kv2", + given: args{ + s: data{ + "metadata": "string", + "data": map[string]any{ + "password": "pass", + }, + }, + pAttr: "password", + }, + want: pass{pass: ""}, + }, + { + name: "invalid-field-kv2", + given: args{ + s: data{ + "invalid": map[string]any{}, + "metadata": map[string]any{}, + "data": map[string]any{ + "password": "pass", + }, + }, + pAttr: "password", + }, + want: pass{pass: ""}, + }, + { + name: "valid-order-default-first", + given: args{ + s: data{ + "password": "default-pass", + "metadata": map[string]any{}, + "data": map[string]any{ + "password": "kv2-pass", + }, + }, + pAttr: "password", + }, + want: pass{pass: "default-pass"}, + }, + { + name: "json-pointer-password", + given: args{ + s: data{ + "testing": map[string]any{ + "my-password": "secret", + }, + }, + pAttr: "/testing/my-password", + }, + want: pass{pass: "secret"}, + }, + { + name: "deep-json-pointer", + given: args{ + s: data{ + "first-path": map[string]any{ + "deeper-path": map[string]any{ + "my-password": "deeper-secret", + }, + }, + }, + pAttr: "/first-path/deeper-path/my-password", + }, + want: pass{pass: "deeper-secret"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + pass := Extract(tt.given.s, tt.given.pAttr) + assert.Equal(tt.want.pass, pass) + }) + } +} diff --git a/internal/credential/vault/mapping_overriders.go b/internal/credential/vault/mapping_overriders.go index eef7559e9..e7f5e9416 100644 --- a/internal/credential/vault/mapping_overriders.go +++ b/internal/credential/vault/mapping_overriders.go @@ -21,6 +21,8 @@ func validMappingOverride(m MappingOverride, ct globals.CredentialType) bool { return ct == globals.UsernamePasswordCredentialType case *UsernamePasswordDomainOverride: return ct == globals.UsernamePasswordDomainCredentialType + case *PasswordOverride: + return ct == globals.PasswordCredentialType case *SshPrivateKeyOverride: return ct == globals.SshPrivateKeyCredentialType default: @@ -171,6 +173,65 @@ func (o *UsernamePasswordDomainOverride) SetTableName(n string) { o.tableName = n } +// A PasswordOverride contains optional values for overriding the +// default mappings used to map a Vault secret to a Password credential +// type for the credential library that owns it. +type PasswordOverride struct { + *store.PasswordOverride + tableName string `gorm:"-"` +} + +var _ MappingOverride = (*PasswordOverride)(nil) + +// NewPasswordOverride creates a new in memory PasswordOverride. +// WithOverrideAttribute and WithOverridePasswordAttribute are the +// only valid options. All other options are ignored. +func NewPasswordOverride(opt ...Option) *PasswordOverride { + opts := getOpts(opt...) + o := &PasswordOverride{ + PasswordOverride: &store.PasswordOverride{ + PasswordAttribute: sanitize.String(opts.withOverridePasswordAttribute), + }, + } + return o +} + +func allocPasswordOverride() *PasswordOverride { + return &PasswordOverride{ + PasswordOverride: &store.PasswordOverride{}, + } +} + +func (o *PasswordOverride) clone() MappingOverride { + cp := proto.Clone(o.PasswordOverride) + return &PasswordOverride{ + PasswordOverride: cp.(*store.PasswordOverride), + } +} + +func (o *PasswordOverride) setLibraryId(i string) { + o.LibraryId = i +} + +func (o *PasswordOverride) sanitize() { + if sentinel.Is(o.PasswordAttribute) { + o.PasswordAttribute = "" + } +} + +// TableName returns the table name. +func (o *PasswordOverride) TableName() string { + if o.tableName != "" { + return o.tableName + } + return "credential_vault_library_password_mapping_override" +} + +// SetTableName sets the table name. +func (o *PasswordOverride) SetTableName(n string) { + o.tableName = n +} + // A SshPrivateKeyOverride contains optional values for overriding the // default mappings used to map a Vault secret to a SshPrivateKey credential // type for the credential library that owns it. diff --git a/internal/credential/vault/mapping_overriders_test.go b/internal/credential/vault/mapping_overriders_test.go index 4199602fa..94668d149 100644 --- a/internal/credential/vault/mapping_overriders_test.go +++ b/internal/credential/vault/mapping_overriders_test.go @@ -45,6 +45,11 @@ func TestValidMappingOverrides(t *testing.T) { ct: globals.UsernamePasswordDomainCredentialType, want: true, }, + { + m: nil, + ct: globals.PasswordCredentialType, + want: true, + }, { m: unknownMapper(1), ct: globals.UnspecifiedCredentialType, @@ -60,6 +65,11 @@ func TestValidMappingOverrides(t *testing.T) { ct: globals.UsernamePasswordDomainCredentialType, want: false, }, + { + m: unknownMapper(1), + ct: globals.PasswordCredentialType, + want: false, + }, { m: allocUsernamePasswordOverride(), ct: globals.UnspecifiedCredentialType, @@ -80,6 +90,16 @@ func TestValidMappingOverrides(t *testing.T) { ct: globals.UsernamePasswordDomainCredentialType, want: true, }, + { + m: allocPasswordOverride(), + ct: globals.UnspecifiedCredentialType, + want: false, + }, + { + m: allocPasswordOverride(), + ct: globals.PasswordCredentialType, + want: true, + }, { m: allocSshPrivateKeyOverride(), ct: globals.UnspecifiedCredentialType, diff --git a/internal/credential/vault/private_library.go b/internal/credential/vault/private_library.go index 5a00b1a48..7843c87b2 100644 --- a/internal/credential/vault/private_library.go +++ b/internal/credential/vault/private_library.go @@ -23,6 +23,7 @@ import ( ldapv3 "github.com/go-ldap/ldap/v3" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/credential" + "github.com/hashicorp/boundary/internal/credential/vault/internal/password" "github.com/hashicorp/boundary/internal/credential/vault/internal/sshprivatekey" "github.com/hashicorp/boundary/internal/credential/vault/internal/usernamepassword" "github.com/hashicorp/boundary/internal/credential/vault/internal/usernamepassworddomain" @@ -66,6 +67,8 @@ func convert(ctx context.Context, bc *baseCred) (dynamicCred, error) { return baseToSshPriKey(ctx, bc) case globals.UsernamePasswordDomainCredentialType: return baseToUsrPassDomain(ctx, bc) + case globals.PasswordCredentialType: + return baseToPass(ctx, bc) } return bc, nil } @@ -166,6 +169,45 @@ func baseToUsrPassDomain(ctx context.Context, bc *baseCred) (*usrPassDomainCred, }, nil } +var _ credential.PasswordCredential = (*passCred)(nil) + +type passCred struct { + *baseCred + password credential.Password +} + +func (c *passCred) Password() credential.Password { return c.password } + +func baseToPass(ctx context.Context, bc *baseCred) (*passCred, error) { + switch { + case bc == nil: + return nil, errors.E(ctx, errors.WithCode(errors.InvalidParameter), errors.WithMsg("nil baseCred")) + case bc.lib == nil: + return nil, errors.E(ctx, errors.WithCode(errors.InvalidParameter), errors.WithMsg("nil baseCred.lib")) + case bc.Library().CredentialType() != globals.PasswordCredentialType: + return nil, errors.E(ctx, errors.WithCode(errors.InvalidParameter), errors.WithMsg("invalid credential type")) + } + + lib, ok := bc.lib.(*genericIssuingCredentialLibrary) + if !ok { + return nil, errors.E(ctx, errors.WithCode(errors.InvalidParameter), errors.WithMsg("baseCred.lib is not of type genericIssuingCredentialLibrary")) + } + + pAttr := lib.PasswordAttribute + if pAttr == "" { + pAttr = "password" + } + password := password.Extract(bc.secretData, pAttr) + if password == "" { + return nil, errors.E(ctx, errors.WithCode(errors.VaultInvalidCredentialMapping)) + } + + return &passCred{ + baseCred: bc, + password: credential.Password(password), + }, nil +} + var _ credential.SshPrivateKey = (*sshPrivateKeyCred)(nil) type sshPrivateKeyCred struct { diff --git a/internal/credential/vault/private_library_test.go b/internal/credential/vault/private_library_test.go index a57d4e2ab..266e47219 100644 --- a/internal/credential/vault/private_library_test.go +++ b/internal/credential/vault/private_library_test.go @@ -344,6 +344,37 @@ func TestRepository_getPrivateLibraries(t *testing.T) { req := credential.Request{SourceId: lib.GetPublicId(), Purpose: credential.BrokeredPurpose} requests = append(requests, req) } + { + opts := []Option{ + WithCredentialType(globals.PasswordCredentialType), + } + libIn, err := NewCredentialLibrary(origStore.GetPublicId(), "/vault/path", opts...) + assert.NoError(err) + require.NotNil(libIn) + lib, err := repo.CreateCredentialLibrary(ctx, prj.GetPublicId(), libIn) + assert.NoError(err) + require.NotNil(lib) + libs[lib.GetPublicId()] = lib + req := credential.Request{SourceId: lib.GetPublicId(), Purpose: credential.BrokeredPurpose} + requests = append(requests, req) + } + { + opts := []Option{ + WithCredentialType(globals.PasswordCredentialType), + WithMappingOverride(NewPasswordOverride( + WithOverridePasswordAttribute("test-password"), + )), + } + libIn, err := NewCredentialLibrary(origStore.GetPublicId(), "/vault/path", opts...) + assert.NoError(err) + require.NotNil(libIn) + lib, err := repo.CreateCredentialLibrary(ctx, prj.GetPublicId(), libIn) + assert.NoError(err) + require.NotNil(lib) + libs[lib.GetPublicId()] = lib + req := credential.Request{SourceId: lib.GetPublicId(), Purpose: credential.BrokeredPurpose} + requests = append(requests, req) + } { opts := []Option{ WithCredentialType(globals.SshPrivateKeyCredentialType), @@ -460,6 +491,8 @@ func TestRepository_getPrivateLibraries(t *testing.T) { assert.Equal(w.UsernameAttribute, got.UsernameAttribute) assert.Equal(w.PasswordAttribute, got.PasswordAttribute) assert.Equal(w.DomainAttribute, got.DomainAttribute) + case *PasswordOverride: + assert.Equal(w.PasswordAttribute, got.PasswordAttribute) case *SshPrivateKeyOverride: assert.Equal(w.UsernameAttribute, got.UsernameAttribute) assert.Equal(w.PrivateKeyAttribute, got.PrivateKeyAttribute) @@ -1424,6 +1457,211 @@ func TestBaseToUsrPassDomain(t *testing.T) { } } +func TestBaseToPass(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + given *baseCred + want *passCred + wantErr errors.Code + }{ + { + name: "nil-input", + wantErr: errors.InvalidParameter, + }, + { + name: "nil-library", + given: &baseCred{}, + wantErr: errors.InvalidParameter, + }, + { + name: "library-not-password-type", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.UnspecifiedCredentialType), + }, + }, + wantErr: errors.InvalidParameter, + }, + { + name: "invalid-no-password-attribute", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + }, + secretData: map[string]any{}, + }, + wantErr: errors.VaultInvalidCredentialMapping, + }, + { + name: "valid-default-attributes", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + }, + secretData: map[string]any{ + "password": "my-password", + }, + }, + want: &passCred{ + password: credential.Password("my-password"), + }, + }, + { + name: "valid-override-attributes", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + PasswordAttribute: "test-password", + }, + secretData: map[string]any{ + "password": "default-password", + "test-password": "override-password", + }, + }, + want: &passCred{ + password: credential.Password("override-password"), + }, + }, + { + name: "invalid-password-override", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + PasswordAttribute: "missing-password", + }, + secretData: map[string]any{ + "password": "default-password", + "test-password": "override-password", + }, + }, + wantErr: errors.VaultInvalidCredentialMapping, + }, + { + name: "invalid-kv2-no-metadata-field", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + }, + secretData: map[string]any{ + "data": map[string]any{ + "password": "my-password", + }, + }, + }, + wantErr: errors.VaultInvalidCredentialMapping, + }, + { + name: "invalid-kv2-no-data-field", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + }, + secretData: map[string]any{ + "metadata": map[string]any{}, + }, + }, + wantErr: errors.VaultInvalidCredentialMapping, + }, + { + name: "invalid-kv2-invalid-metadata-type", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + }, + secretData: map[string]any{ + "metadata": "hello", + "data": map[string]any{ + "password": "my-password", + }, + }, + }, + wantErr: errors.VaultInvalidCredentialMapping, + }, + { + name: "invalid-kv2-invalid-metadata-type", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + }, + secretData: map[string]any{ + "metadata": map[string]any{}, + "data": "hello", + }, + }, + wantErr: errors.VaultInvalidCredentialMapping, + }, + { + name: "invalid-kv2-additional-field", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + }, + secretData: map[string]any{ + "bad-field": "hello", + "metadata": map[string]any{}, + "data": map[string]any{ + "password": "my-password", + }, + }, + }, + wantErr: errors.VaultInvalidCredentialMapping, + }, + { + name: "valid-kv2-default-password-attribute", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + }, + secretData: map[string]any{ + "metadata": map[string]any{}, + "data": map[string]any{ + "password": "my-password", + }, + }, + }, + want: &passCred{ + password: credential.Password("my-password"), + }, + }, + { + name: "valid-kv2-override-password-attribute", + given: &baseCred{ + lib: &genericIssuingCredentialLibrary{ + CredType: string(globals.PasswordCredentialType), + PasswordAttribute: "test-password", + }, + secretData: map[string]any{ + "metadata": map[string]any{}, + "data": map[string]any{ + "password": "default-password", + "test-password": "override-password", + }, + }, + }, + want: &passCred{ + password: credential.Password("override-password"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := baseToPass(context.Background(), tt.given) + if tt.wantErr != 0 { + assert.Truef(errors.Match(errors.T(tt.wantErr), err), "want err: %q got: %q", tt.wantErr, err) + assert.Nil(got) + return + } + require.NoError(err) + want := tt.want + want.baseCred = tt.given + assert.Equal(want, got) + }) + } +} + func TestBaseToSshPriKey(t *testing.T) { t.Parallel() diff --git a/internal/credential/vault/repository_credential_library.go b/internal/credential/vault/repository_credential_library.go index 707d64ccf..d3ac19341 100644 --- a/internal/credential/vault/repository_credential_library.go +++ b/internal/credential/vault/repository_credential_library.go @@ -406,6 +406,14 @@ func (pl *listLookupLibrary) toCredentialLibrary() *CredentialLibrary { upd.sanitize() cl.MappingOverride = upd } + case string(globals.PasswordCredentialType): + if pl.PasswordAttribute != "" { + p := allocPasswordOverride() + p.LibraryId = pl.PublicId + p.PasswordAttribute = pl.PasswordAttribute + p.sanitize() + cl.MappingOverride = p + } case string(globals.SshPrivateKeyCredentialType): if pl.UsernameAttribute != "" || pl.PrivateKeyAttribute != "" || pl.PrivateKeyPassphraseAttribute != "" { pk := allocSshPrivateKeyOverride() diff --git a/internal/credential/vault/repository_credential_library_test.go b/internal/credential/vault/repository_credential_library_test.go index fccb90133..c9b8d60f4 100644 --- a/internal/credential/vault/repository_credential_library_test.go +++ b/internal/credential/vault/repository_credential_library_test.go @@ -293,6 +293,76 @@ func TestRepository_CreateCredentialLibrary(t *testing.T) { }, }, }, + { + name: "valid-password-credential-type", + in: &CredentialLibrary{ + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + want: &CredentialLibrary{ + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + }, + { + name: "unknown-password-mapping-override-type", + in: &CredentialLibrary{ + MappingOverride: unknownMapper(1), + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + wantErr: errors.VaultInvalidMappingOverride, + }, + { + name: "invalid-password-mapping-override-type", + in: &CredentialLibrary{ + MappingOverride: NewUsernamePasswordOverride(WithOverrideUsernameAttribute("test")), + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + wantErr: errors.VaultInvalidMappingOverride, + }, + { + name: "valid-password-credential-type-with-password-override", + in: &CredentialLibrary{ + MappingOverride: NewPasswordOverride( + WithOverridePasswordAttribute("ptest"), + ), + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + want: &CredentialLibrary{ + MappingOverride: NewPasswordOverride( + WithOverridePasswordAttribute("ptest"), + ), + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + }, { name: "valid-ssh-private-key-credential-type", in: &CredentialLibrary{ @@ -339,7 +409,7 @@ func TestRepository_CreateCredentialLibrary(t *testing.T) { wantErr: errors.VaultInvalidMappingOverride, }, { - name: "valid-ssh-prviate-key-credential-type-with-username-override", + name: "valid-ssh-private-key-credential-type-with-username-override", in: &CredentialLibrary{ MappingOverride: NewSshPrivateKeyOverride( WithOverrideUsernameAttribute("utest"), @@ -486,6 +556,15 @@ func TestRepository_CreateCredentialLibrary(t *testing.T) { override := allocUsernamePasswordOverride() assert.NoError(rw.LookupWhere(ctx, &override, "library_id = ?", []any{got.GetPublicId()})) + case *PasswordOverride: + g, ok := got.MappingOverride.(*PasswordOverride) + require.True(ok) + assert.Equal(w.PasswordAttribute, g.PasswordAttribute) + + // verify it was persisted in the database + override := allocPasswordOverride() + assert.NoError(rw.LookupWhere(ctx, &override, "library_id = ?", []any{got.GetPublicId()})) + case *SshPrivateKeyOverride: g, ok := got.MappingOverride.(*SshPrivateKeyOverride) require.True(ok) @@ -1273,6 +1352,94 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { masks: []string{"MappingOverride"}, wantErr: errors.VaultInvalidMappingOverride, }, + { + name: "password-attribute-change-password-attribute", + orig: &CredentialLibrary{ + MappingOverride: NewPasswordOverride( + WithOverridePasswordAttribute("orig-password"), + ), + CredentialLibrary: &store.CredentialLibrary{ + HttpMethod: "GET", + VaultPath: "/some/path", + Name: "test-password-repo", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + chgFn: changeMappingOverride( + NewPasswordOverride( + WithOverridePasswordAttribute("changed-password"), + ), + ), + masks: []string{"MappingOverride"}, + want: &CredentialLibrary{ + MappingOverride: NewPasswordOverride( + WithOverridePasswordAttribute("changed-password"), + ), + CredentialLibrary: &store.CredentialLibrary{ + HttpMethod: "GET", + VaultPath: "/some/path", + Name: "test-password-repo", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + wantCount: 1, + }, + { + name: "no-mapping-override-change-password-attributes", + orig: &CredentialLibrary{ + CredentialLibrary: &store.CredentialLibrary{ + HttpMethod: "GET", + VaultPath: "/some/path", + Name: "test-password-repo", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + chgFn: changeMappingOverride( + NewPasswordOverride( + WithOverridePasswordAttribute("changed-password"), + ), + ), + masks: []string{"MappingOverride"}, + want: &CredentialLibrary{ + MappingOverride: NewPasswordOverride( + WithOverridePasswordAttribute("changed-password"), + ), + CredentialLibrary: &store.CredentialLibrary{ + HttpMethod: "GET", + VaultPath: "/some/path", + Name: "test-password-repo", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + wantCount: 1, + }, + { + name: "password-attributes-delete-mapping-override", + orig: &CredentialLibrary{ + MappingOverride: NewPasswordOverride( + WithOverridePasswordAttribute("orig-password"), + WithOverridePrivateKeyAttribute("orig-private-key"), + WithOverridePrivateKeyPassphraseAttribute("orig-passphrase"), + ), + CredentialLibrary: &store.CredentialLibrary{ + HttpMethod: "GET", + VaultPath: "/some/path", + Name: "test-password-repo", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + chgFn: changeMappingOverride(nil), + masks: []string{"MappingOverride"}, + want: &CredentialLibrary{ + CredentialLibrary: &store.CredentialLibrary{ + HttpMethod: "GET", + VaultPath: "/some/path", + Name: "test-password-repo", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + wantCount: 1, + }, { name: "ssh-private-key-attributes-change-username-attribute", orig: &CredentialLibrary{ @@ -1535,6 +1702,10 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { require.True(ok) assert.Equal(w.UsernameAttribute, g.UsernameAttribute) assert.Equal(w.PasswordAttribute, g.PasswordAttribute) + case *PasswordOverride: + g, ok := got.MappingOverride.(*PasswordOverride) + require.True(ok) + assert.Equal(w.PasswordAttribute, g.PasswordAttribute) case *SshPrivateKeyOverride: g, ok := got.MappingOverride.(*SshPrivateKeyOverride) require.True(ok) @@ -1800,6 +1971,31 @@ func TestRepository_LookupCredentialLibrary(t *testing.T) { }, }, }, + { + name: "valid-password-credential-type", + in: &CredentialLibrary{ + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + }, + { + name: "valid-password-credential-type-with-password-override", + in: &CredentialLibrary{ + MappingOverride: NewPasswordOverride( + WithOverridePasswordAttribute("ptest"), + ), + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(globals.PasswordCredentialType), + }, + }, + }, { name: "valid-ssh-private-key-credential-type", in: &CredentialLibrary{ @@ -1901,6 +2097,10 @@ func TestRepository_LookupCredentialLibrary(t *testing.T) { require.True(ok) assert.Equal(w.UsernameAttribute, g.UsernameAttribute) assert.Equal(w.PasswordAttribute, g.PasswordAttribute) + case *PasswordOverride: + g, ok := got.MappingOverride.(*PasswordOverride) + require.True(ok) + assert.Equal(w.PasswordAttribute, g.PasswordAttribute) case *SshPrivateKeyOverride: g, ok := got.MappingOverride.(*SshPrivateKeyOverride) require.True(ok) diff --git a/internal/credential/vault/store/vault.pb.go b/internal/credential/vault/store/vault.pb.go index 637fa4fa0..4323f55d0 100644 --- a/internal/credential/vault/store/vault.pb.go +++ b/internal/credential/vault/store/vault.pb.go @@ -1174,6 +1174,68 @@ func (x *UsernamePasswordDomainOverride) GetDomainAttribute() string { return "" } +type PasswordOverride struct { + state protoimpl.MessageState `protogen:"open.v1"` + // library_id of the owning vault credential library. + // @inject_tag: `gorm:"primary_key"` + LibraryId string `protobuf:"bytes,1,opt,name=library_id,json=libraryId,proto3" json:"library_id,omitempty" gorm:"primary_key"` + // password_attribute is the name of the attribute in the Data field of a + // Vault api.Secret that maps to a password. + // If set, it overrides any default attribute names the system uses to + // find a password attribute. + // + // See https://github.com/hashicorp/vault/blob/5e505ec039177e8212cbbab74ccb644c46e62e63/api/secret.go#L25 + // + // @inject_tag: `gorm:"default:null"` + PasswordAttribute string `protobuf:"bytes,2,opt,name=password_attribute,json=passwordAttribute,proto3" json:"password_attribute,omitempty" gorm:"default:null"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PasswordOverride) Reset() { + *x = PasswordOverride{} + mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PasswordOverride) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PasswordOverride) ProtoMessage() {} + +func (x *PasswordOverride) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PasswordOverride.ProtoReflect.Descriptor instead. +func (*PasswordOverride) Descriptor() ([]byte, []int) { + return file_controller_storage_credential_vault_store_v1_vault_proto_rawDescGZIP(), []int{8} +} + +func (x *PasswordOverride) GetLibraryId() string { + if x != nil { + return x.LibraryId + } + return "" +} + +func (x *PasswordOverride) GetPasswordAttribute() string { + if x != nil { + return x.PasswordAttribute + } + return "" +} + type SshPrivateKeyOverride struct { state protoimpl.MessageState `protogen:"open.v1"` // library_id of the owning vault credential library. @@ -1213,7 +1275,7 @@ type SshPrivateKeyOverride struct { func (x *SshPrivateKeyOverride) Reset() { *x = SshPrivateKeyOverride{} - mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[8] + mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1225,7 +1287,7 @@ func (x *SshPrivateKeyOverride) String() string { func (*SshPrivateKeyOverride) ProtoMessage() {} func (x *SshPrivateKeyOverride) ProtoReflect() protoreflect.Message { - mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[8] + mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1238,7 +1300,7 @@ func (x *SshPrivateKeyOverride) ProtoReflect() protoreflect.Message { // Deprecated: Use SshPrivateKeyOverride.ProtoReflect.Descriptor instead. func (*SshPrivateKeyOverride) Descriptor() ([]byte, []int) { - return file_controller_storage_credential_vault_store_v1_vault_proto_rawDescGZIP(), []int{8} + return file_controller_storage_credential_vault_store_v1_vault_proto_rawDescGZIP(), []int{9} } func (x *SshPrivateKeyOverride) GetLibraryId() string { @@ -1306,7 +1368,7 @@ type LdapCredentialLibrary struct { func (x *LdapCredentialLibrary) Reset() { *x = LdapCredentialLibrary{} - mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[9] + mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1318,7 +1380,7 @@ func (x *LdapCredentialLibrary) String() string { func (*LdapCredentialLibrary) ProtoMessage() {} func (x *LdapCredentialLibrary) ProtoReflect() protoreflect.Message { - mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[9] + mi := &file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1331,7 +1393,7 @@ func (x *LdapCredentialLibrary) ProtoReflect() protoreflect.Message { // Deprecated: Use LdapCredentialLibrary.ProtoReflect.Descriptor instead. func (*LdapCredentialLibrary) Descriptor() ([]byte, []int) { - return file_controller_storage_credential_vault_store_v1_vault_proto_rawDescGZIP(), []int{9} + return file_controller_storage_credential_vault_store_v1_vault_proto_rawDescGZIP(), []int{10} } func (x *LdapCredentialLibrary) GetPublicId() string { @@ -1546,7 +1608,11 @@ const file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc = "" "library_id\x18\x01 \x01(\tR\tlibraryId\x12-\n" + "\x12username_attribute\x18\x02 \x01(\tR\x11usernameAttribute\x12-\n" + "\x12password_attribute\x18\x03 \x01(\tR\x11passwordAttribute\x12)\n" + - "\x10domain_attribute\x18\x04 \x01(\tR\x0fdomainAttribute\"\xe2\x01\n" + + "\x10domain_attribute\x18\x04 \x01(\tR\x0fdomainAttribute\"`\n" + + "\x10PasswordOverride\x12\x1d\n" + + "\n" + + "library_id\x18\x01 \x01(\tR\tlibraryId\x12-\n" + + "\x12password_attribute\x18\x02 \x01(\tR\x11passwordAttribute\"\xe2\x01\n" + "\x15SshPrivateKeyOverride\x12\x1d\n" + "\n" + "library_id\x18\x01 \x01(\tR\tlibraryId\x12-\n" + @@ -1582,7 +1648,7 @@ func file_controller_storage_credential_vault_store_v1_vault_proto_rawDescGZIP() return file_controller_storage_credential_vault_store_v1_vault_proto_rawDescData } -var file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_controller_storage_credential_vault_store_v1_vault_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_controller_storage_credential_vault_store_v1_vault_proto_goTypes = []any{ (*CredentialStore)(nil), // 0: controller.storage.credential.vault.store.v1.CredentialStore (*Token)(nil), // 1: controller.storage.credential.vault.store.v1.Token @@ -1592,28 +1658,29 @@ var file_controller_storage_credential_vault_store_v1_vault_proto_goTypes = []an (*Credential)(nil), // 5: controller.storage.credential.vault.store.v1.Credential (*UsernamePasswordOverride)(nil), // 6: controller.storage.credential.vault.store.v1.UsernamePasswordOverride (*UsernamePasswordDomainOverride)(nil), // 7: controller.storage.credential.vault.store.v1.UsernamePasswordDomainOverride - (*SshPrivateKeyOverride)(nil), // 8: controller.storage.credential.vault.store.v1.SshPrivateKeyOverride - (*LdapCredentialLibrary)(nil), // 9: controller.storage.credential.vault.store.v1.LdapCredentialLibrary - (*timestamp.Timestamp)(nil), // 10: controller.storage.timestamp.v1.Timestamp + (*PasswordOverride)(nil), // 8: controller.storage.credential.vault.store.v1.PasswordOverride + (*SshPrivateKeyOverride)(nil), // 9: controller.storage.credential.vault.store.v1.SshPrivateKeyOverride + (*LdapCredentialLibrary)(nil), // 10: controller.storage.credential.vault.store.v1.LdapCredentialLibrary + (*timestamp.Timestamp)(nil), // 11: controller.storage.timestamp.v1.Timestamp } var file_controller_storage_credential_vault_store_v1_vault_proto_depIdxs = []int32{ - 10, // 0: controller.storage.credential.vault.store.v1.CredentialStore.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 1: controller.storage.credential.vault.store.v1.CredentialStore.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 2: controller.storage.credential.vault.store.v1.CredentialStore.delete_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 3: controller.storage.credential.vault.store.v1.Token.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 4: controller.storage.credential.vault.store.v1.Token.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 5: controller.storage.credential.vault.store.v1.Token.last_renewal_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 6: controller.storage.credential.vault.store.v1.Token.expiration_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 7: controller.storage.credential.vault.store.v1.CredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 8: controller.storage.credential.vault.store.v1.CredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 9: controller.storage.credential.vault.store.v1.SSHCertificateCredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 10: controller.storage.credential.vault.store.v1.SSHCertificateCredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 11: controller.storage.credential.vault.store.v1.Credential.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 12: controller.storage.credential.vault.store.v1.Credential.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 13: controller.storage.credential.vault.store.v1.Credential.last_renewal_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 14: controller.storage.credential.vault.store.v1.Credential.expiration_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 15: controller.storage.credential.vault.store.v1.LdapCredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 10, // 16: controller.storage.credential.vault.store.v1.LdapCredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 0: controller.storage.credential.vault.store.v1.CredentialStore.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 1: controller.storage.credential.vault.store.v1.CredentialStore.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 2: controller.storage.credential.vault.store.v1.CredentialStore.delete_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 3: controller.storage.credential.vault.store.v1.Token.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 4: controller.storage.credential.vault.store.v1.Token.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 5: controller.storage.credential.vault.store.v1.Token.last_renewal_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 6: controller.storage.credential.vault.store.v1.Token.expiration_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 7: controller.storage.credential.vault.store.v1.CredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 8: controller.storage.credential.vault.store.v1.CredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 9: controller.storage.credential.vault.store.v1.SSHCertificateCredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 10: controller.storage.credential.vault.store.v1.SSHCertificateCredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 11: controller.storage.credential.vault.store.v1.Credential.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 12: controller.storage.credential.vault.store.v1.Credential.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 13: controller.storage.credential.vault.store.v1.Credential.last_renewal_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 14: controller.storage.credential.vault.store.v1.Credential.expiration_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 15: controller.storage.credential.vault.store.v1.LdapCredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 11, // 16: controller.storage.credential.vault.store.v1.LdapCredentialLibrary.update_time:type_name -> controller.storage.timestamp.v1.Timestamp 17, // [17:17] is the sub-list for method output_type 17, // [17:17] is the sub-list for method input_type 17, // [17:17] is the sub-list for extension type_name @@ -1632,7 +1699,7 @@ func file_controller_storage_credential_vault_store_v1_vault_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc), len(file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc)), NumEnums: 0, - NumMessages: 10, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/proto/controller/storage/credential/vault/store/v1/vault.proto b/internal/proto/controller/storage/credential/vault/store/v1/vault.proto index 25a3b02d9..cdd03fb4c 100644 --- a/internal/proto/controller/storage/credential/vault/store/v1/vault.proto +++ b/internal/proto/controller/storage/credential/vault/store/v1/vault.proto @@ -499,6 +499,22 @@ message UsernamePasswordDomainOverride { string domain_attribute = 4; } +message PasswordOverride { + // library_id of the owning vault credential library. + // @inject_tag: `gorm:"primary_key"` + string library_id = 1; + + // password_attribute is the name of the attribute in the Data field of a + // Vault api.Secret that maps to a password. + // If set, it overrides any default attribute names the system uses to + // find a password attribute. + // + // See https://github.com/hashicorp/vault/blob/5e505ec039177e8212cbbab74ccb644c46e62e63/api/secret.go#L25 + // + // @inject_tag: `gorm:"default:null"` + string password_attribute = 2; +} + message SshPrivateKeyOverride { // library_id of the owning vault credential library. // @inject_tag: `gorm:"primary_key"`