From ee908ddc23c6dec0e0550c8611dcf5953839dc5b Mon Sep 17 00:00:00 2001 From: Andrew Gaffney <109917432+anGaffney@users.noreply.github.com> Date: Mon, 12 May 2025 13:45:18 -0400 Subject: [PATCH] feat(credential/vault): Implement UPD vault credential extractor (#1520) --- .../usernamepassword/usernamepassword_test.go | 2 +- .../internal/usernamepassworddomain/doc.go | 6 + .../usernamepassworddomain.go | 161 +++++++++ .../usernamepassworddomain_test.go | 325 ++++++++++++++++++ 4 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 internal/credential/vault/internal/usernamepassworddomain/doc.go create mode 100644 internal/credential/vault/internal/usernamepassworddomain/usernamepassworddomain.go create mode 100644 internal/credential/vault/internal/usernamepassworddomain/usernamepassworddomain_test.go diff --git a/internal/credential/vault/internal/usernamepassword/usernamepassword_test.go b/internal/credential/vault/internal/usernamepassword/usernamepassword_test.go index 5c8c4807a6..a9ea2bcba9 100644 --- a/internal/credential/vault/internal/usernamepassword/usernamepassword_test.go +++ b/internal/credential/vault/internal/usernamepassword/usernamepassword_test.go @@ -56,7 +56,7 @@ func TestBaseToUsrPass(t *testing.T) { want: usrPass{user: "", pass: ""}, }, { - name: "no-match-username-secret", + name: "no-match-password-secret", given: args{ s: data{ "username": "user", diff --git a/internal/credential/vault/internal/usernamepassworddomain/doc.go b/internal/credential/vault/internal/usernamepassworddomain/doc.go new file mode 100644 index 0000000000..f461042544 --- /dev/null +++ b/internal/credential/vault/internal/usernamepassworddomain/doc.go @@ -0,0 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// Package usernamepassworddomain provides access to the username, password, and domain +// stored in a Vault secret. +package usernamepassworddomain diff --git a/internal/credential/vault/internal/usernamepassworddomain/usernamepassworddomain.go b/internal/credential/vault/internal/usernamepassworddomain/usernamepassworddomain.go new file mode 100644 index 0000000000..29afdb49e3 --- /dev/null +++ b/internal/credential/vault/internal/usernamepassworddomain/usernamepassworddomain.go @@ -0,0 +1,161 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package usernamepassworddomain + +import ( + "strings" + + "github.com/mitchellh/pointerstructure" +) + +type ( + data map[string]any + + // extractFunc attempts to extract the username, password, and domain + // from sd using the provided attribute names, using a known + // Vault data response format. + extractFunc func(sd data, usernameAttr, passwordAttr, domainAttr string) (string, string, string) +) + +// Extract attempts to extract the values of the username and 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, usernameAttr, passwordAttr, domainAttr string) (string, string, string) { + for _, f := range []extractFunc{ + defaultExtract, + kv2Extract, + } { + username, password, domain := f(d, usernameAttr, passwordAttr, domainAttr) + if username != "" && password != "" && domain != "" { + // got valid username and password from secret + return username, password, domain + } + } + + return "", "", "" +} + +// defaultExtract looks for the usernameAttr, passwordAttr, and domainAttr in the data map +func defaultExtract(sd data, usernameAttr, passwordAttr, domainAttr string) (username string, password string, domain string) { + if sd == nil { + // nothing to do return early + return "", "", "" + } + + var u any + switch { + case strings.HasPrefix(usernameAttr, "/"): + var err error + u, err = pointerstructure.Get(sd, usernameAttr) + if err != nil { + return "", "", "" + } + + default: + u = sd[usernameAttr] + } + if u, ok := u.(string); ok { + username = u + } + + 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 + } + + var d any + switch { + case strings.HasPrefix(domainAttr, "/"): + var err error + d, err = pointerstructure.Get(sd, domainAttr) + if err != nil { + return "", "", "" + } + + default: + d = sd[domainAttr] + } + if d, ok := d.(string); ok { + domain = d + } + + return username, password, domain +} + +// kv2Extract looks for the usernameAttr, passwordAttr, and domainAttr 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, usernameAttr, passwordAttr, domainAttr string) (username string, password string, domain 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 u, ok := data[usernameAttr]; ok { + if u, ok := u.(string); ok { + username = u + } + } + if p, ok := data[passwordAttr]; ok { + if p, ok := p.(string); ok { + password = p + } + } + + if d, ok := data[domainAttr]; ok { + if d, ok := d.(string); ok { + domain = d + } + } + + return username, password, domain +} diff --git a/internal/credential/vault/internal/usernamepassworddomain/usernamepassworddomain_test.go b/internal/credential/vault/internal/usernamepassworddomain/usernamepassworddomain_test.go new file mode 100644 index 0000000000..d7ef9bb136 --- /dev/null +++ b/internal/credential/vault/internal/usernamepassworddomain/usernamepassworddomain_test.go @@ -0,0 +1,325 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package usernamepassworddomain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBaseToUsrPassDomain(t *testing.T) { + t.Parallel() + + type args struct { + s data + uAttr string + pAttr string + dAttr string + } + type usrPassDomain struct { + user string + pass string + domain string + } + tests := []struct { + name string + given args + want usrPassDomain + }{ + { + name: "nil-input", + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "no-input", + given: args{}, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "no-secret", + given: args{ + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "no-match-username-secret", + given: args{ + s: data{ + "username-wrong": "user", + "password": "pass", + "domain": "domain", + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "no-match-password-secret", + given: args{ + s: data{ + "username": "user", + "password-wrong": "pass", + "domain": "domain", + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "no-match-domain-secret", + given: args{ + s: data{ + "username": "user", + "password": "pass", + "domain-wrong": "domain", + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "valid-default", + given: args{ + s: data{ + "username": "user", + "password": "pass", + "domain": "domain", + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "user", pass: "pass", domain: "domain"}, + }, + { + name: "no-match-username-secret-kv2", + given: args{ + s: data{ + "metadata": map[string]any{}, + "data": map[string]any{ + "username-wrong": "user", + "password": "pass", + "domain": "domain", + }, + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "no-match-password-secret-kv2", + given: args{ + s: data{ + "metadata": map[string]any{}, + "data": map[string]any{ + "username": "user", + "password-wrong": "pass", + "domain": "domain", + }, + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "no-match-domain-secret-kv2", + given: args{ + s: data{ + "metadata": map[string]any{}, + "data": map[string]any{ + "username": "user", + "password": "pass", + "domain-wrong": "domain", + }, + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "valid-kv2", + given: args{ + s: data{ + "metadata": map[string]any{}, + "data": map[string]any{ + "username": "user", + "password": "pass", + "domain": "domain", + }, + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "user", pass: "pass", domain: "domain"}, + }, + { + name: "no-metadata-kv2", + given: args{ + s: data{ + "data": map[string]any{ + "username": "user", + "password": "pass", + "domain": "domain", + }, + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "invalid-metadata-kv2", + given: args{ + s: data{ + "metadata": "string", + "data": map[string]any{ + "username": "user", + "password": "pass", + "domain": "domain", + }, + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "invalid-field-kv2", + given: args{ + s: data{ + "invalid": map[string]any{}, + "metadata": map[string]any{}, + "data": map[string]any{ + "username": "user", + "password": "pass", + "domain": "domain", + }, + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "", pass: "", domain: ""}, + }, + { + name: "valid-order-default-first", + given: args{ + s: data{ + "username": "default-user", + "password": "default-pass", + "domain": "default-domain", + "metadata": map[string]any{}, + "data": map[string]any{ + "username": "kv2-user", + "password": "kv2-pass", + "domain": "kv2-domain", + }, + }, + uAttr: "username", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "default-user", pass: "default-pass", domain: "default-domain"}, + }, + { + name: "default-user-json-pointer-password", + given: args{ + s: data{ + "username": "default-user", + "domain": "default-domain", + "testing": map[string]any{ + "my-password": "secret", + }, + }, + uAttr: "username", + pAttr: "/testing/my-password", + dAttr: "domain", + }, + want: usrPassDomain{user: "default-user", pass: "secret", domain: "default-domain"}, + }, + { + name: "default-pk-json-pointer-user", + given: args{ + s: data{ + "password": "default-pass", + "domain": "default-domain", + "testing": map[string]any{ + "a-user-name": "me", + }, + }, + uAttr: "/testing/a-user-name", + pAttr: "password", + dAttr: "domain", + }, + want: usrPassDomain{user: "me", pass: "default-pass", domain: "default-domain"}, + }, + { + name: "default-dm-json-pointer-user", + given: args{ + s: data{ + "username": "default-user", + "password": "default-pass", + "domain-site": map[string]any{ + "a-domain": "domain.com", + }, + }, + uAttr: "username", + pAttr: "password", + dAttr: "/domain-site/a-domain", + }, + want: usrPassDomain{user: "default-user", pass: "default-pass", domain: "domain.com"}, + }, + { + name: "all-json-pointer", + given: args{ + s: data{ + "first-path": map[string]any{ + "deeper-path": map[string]any{ + "my-special-user": "you-found-me", + }, + }, + "testing": map[string]any{ + "password": "secret", + }, + "domain-site": map[string]any{ + "a-domain": "domain.com", + }, + }, + uAttr: "/first-path/deeper-path/my-special-user", + pAttr: "/testing/password", + dAttr: "/domain-site/a-domain", + }, + want: usrPassDomain{user: "you-found-me", pass: "secret", domain: "domain.com"}, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + user, pass, domain := Extract(tt.given.s, tt.given.uAttr, tt.given.pAttr, tt.given.dAttr) + assert.Equal(tt.want.user, user) + assert.Equal(tt.want.pass, pass) + assert.Equal(tt.want.domain, domain) + }) + } +}