diff --git a/README.md b/README.md index 649a8e6..690e371 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ It supports various backends including: - Kubernetes - Conjur - HCP Vault Secrets +- Bitwarden - Use `vals eval -f refs.yaml` to replace all the `ref`s in the file to actual values and secrets. - Use `vals exec -f env.yaml -- ` to populate envvars and execute the command. @@ -224,6 +225,7 @@ Please see the [relevant unit test cases](https://github.com/helmfile/vals/blob/ - [Kubernetes](#kubernetes) - [Conjur](#conjur) - [HCP Vault Secrets](#hcp-vault-secrets) +- [Bitwarden](#bitwarden) Please see [pkg/providers](https://github.com/helmfile/vals/tree/master/pkg/providers) for the implementations of all the providers. The package names corresponds to the URI schemes. @@ -800,6 +802,27 @@ Example: `ref+hcpvaultsecrets://APPLICATION_NAME/SECRET_NAME[?client_id=HCP_CLIENT_ID&client_secret=HCP_CLIENT_SECRET&organization_id=HCP_ORGANIZATION_ID&organization_name=HCP_ORGANIZATION_NAME&project_id=HCP_PROJECT_ID&project_name=HCP_PROJECT_NAME&version=2]` + +### Bitwarden +This provider retrieves the secrets stored in Bitwarden. It uses the [Bitwarden Vault-Management API](https://bitwarden.com/help/vault-management-api/) that is included in the [Bitwarden CLI](https://github.com/bitwarden/clients) by executing `bw serve`. + +Environment variables: + +- `BW_API_ADDR`: The Bitwarden Vault Management API service address, defaults to http://localhost:8087 + +Parameters: + +Parameters are optional and can be passed as query parameters in the URI, taking precedence over environment variables. + +* `address` defaults to the value of the `BW_API_ADDR` envvar. + +Examples: + +- `ref+bw://4d084b01-87e7-4411-8de9-2476ab9f3f48` gets the password of the item id +- `ref+bw://4d084b01-87e7-4411-8de9-2476ab9f3f48/password` gets the password of the item id +- `ref+bw://4d084b01-87e7-4411-8de9-2476ab9f3f48/{username,password,uri,notes,item}` gets username, password, uri, notes or the whole item of the given item id +- `ref+bw://4d084b01-87e7-4411-8de9-2476ab9f3f48/notes#/key1` gets the *key1* from the yaml stored as note in the item + ## Advanced Usages ### Discriminating config and secrets diff --git a/pkg/providers/bitwarden/bitwarden.go b/pkg/providers/bitwarden/bitwarden.go new file mode 100644 index 0000000..2dc09de --- /dev/null +++ b/pkg/providers/bitwarden/bitwarden.go @@ -0,0 +1,137 @@ +package bitwarden + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "slices" + "strings" + + "gopkg.in/yaml.v2" + + "github.com/helmfile/vals/pkg/api" + "github.com/helmfile/vals/pkg/log" +) + +type bwData struct { + Object string `json:"object"` + Data string `json:"data"` +} + +type bwResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data bwData `json:"data"` +} + +type provider struct { + log *log.Logger + + Address string + SSLVerify bool +} + +func New(l *log.Logger, cfg api.StaticConfig) *provider { + p := &provider{ + log: l, + Address: getAddressConfig(cfg.String("address")), + } + + return p +} + +func (p *provider) GetString(key string) (string, error) { + itemId, keyType, err := extractItemAndType(key) + if err != nil { + return "", err + } + + url := fmt.Sprintf("%s/object/%s/%s", + p.Address, + keyType, + itemId) + + client := &http.Client{} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header = http.Header{ + "Content-Type": {"application/json"}, + } + + res, err := client.Do(req) + if err != nil { + return "", err + } + + defer func() { + _ = res.Body.Close() + }() + + var resp bwResponse + err = json.NewDecoder(res.Body).Decode(&resp) + if err != nil { + return "", fmt.Errorf("bitwarden: get string key %q, cannot decode JSON: %v", key, err) + } + + if !resp.Success { + return "", fmt.Errorf("bitwarden: get string key %q, msg: %s", key, resp.Message) + } + + return resp.Data.Data, nil +} + +func (p *provider) GetStringMap(key string) (map[string]interface{}, error) { + secretMap := map[string]interface{}{} + + secretString, err := p.GetString(key) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal([]byte(secretString), secretMap); err != nil { + return nil, fmt.Errorf("failed to unmarshal secret: %w", err) + } + + return secretMap, nil +} + +func getAddressConfig(cfgAddress string) string { + if cfgAddress != "" { + return cfgAddress + } + + envAddr := os.Getenv("BW_API_ADDR") + if envAddr != "" { + return envAddr + } + + return "http://localhost:8087" +} + +func extractItemAndType(key string) (string, string, error) { + keyType := "password" + + if len(key) == 0 { + return "", "", fmt.Errorf("bitwarden: key cannot be empty") + } + + splits := strings.Split(key, "/") + itemId := splits[0] + + if len(itemId) == 0 { + return "", "", fmt.Errorf("bitwarden: key cannot be empty") + } + + if len(splits) > 1 { + keyType = splits[1] + } + + if !slices.Contains([]string{"username", "password", "uri", "notes", "item"}, keyType) { + return "", "", fmt.Errorf("bitwarden: get string: key %q unknown keytype %q", key, keyType) + } + + return itemId, keyType, nil +} diff --git a/pkg/providers/bitwarden/bitwarden_test.go b/pkg/providers/bitwarden/bitwarden_test.go new file mode 100644 index 0000000..f3e5bbd --- /dev/null +++ b/pkg/providers/bitwarden/bitwarden_test.go @@ -0,0 +1,93 @@ +package bitwarden + +import ( + "os" + "testing" +) + +func TestGetAddressConfig(t *testing.T) { + // Test case 1: cfgAddress is not empty + cfgAddress := "http://example.com" + expected := "http://example.com" + result := getAddressConfig(cfgAddress) + if result != expected { + t.Errorf("Expected %s, but got %s", expected, result) + } + + // Test case 2: cfgAddress is empty, but envAddr is not empty + os.Setenv("BW_API_ADDR", "http://env.example.com") + expected = "http://env.example.com" + result = getAddressConfig("") + os.Unsetenv("BW_API_ADDR") + if result != expected { + t.Errorf("Expected %s, but got %s", expected, result) + } + + // Test case 3: cfgAddress and envAddr are empty + os.Setenv("BW_API_ADDR", "") + expected = "http://localhost:8087" + result = getAddressConfig("") + os.Unsetenv("BW_API_ADDR") + if result != expected { + t.Errorf("Expected %s, but got %s", expected, result) + } +} + +func TestExtractItemAndType(t *testing.T) { + testCases := []struct { + key string + expectedItemId string + expectedKeyType string + expectedErrorMsg string + }{ + { + key: "item012", + expectedItemId: "item012", + expectedKeyType: "password", + expectedErrorMsg: "", + }, + { + key: "item123/password", + expectedItemId: "item123", + expectedKeyType: "password", + expectedErrorMsg: "", + }, + { + key: "item456/username", + expectedItemId: "item456", + expectedKeyType: "username", + expectedErrorMsg: "", + }, + { + key: "item789/invalid", + expectedItemId: "", + expectedKeyType: "", + expectedErrorMsg: "bitwarden: get string: key \"item789/invalid\" unknown keytype \"invalid\"", + }, + { + key: "", + expectedItemId: "", + expectedKeyType: "", + expectedErrorMsg: "bitwarden: key cannot be empty", + }, + { + key: "/password", + expectedItemId: "", + expectedKeyType: "", + expectedErrorMsg: "bitwarden: key cannot be empty", + }, + } + + for _, tc := range testCases { + itemId, keyType, err := extractItemAndType(tc.key) + if err != nil && err.Error() != tc.expectedErrorMsg { + t.Errorf("Expected error message %q, but got %q", tc.expectedErrorMsg, err.Error()) + } + if itemId != tc.expectedItemId { + t.Errorf("Expected itemId %q, but got %q", tc.expectedItemId, itemId) + } + if keyType != tc.expectedKeyType { + t.Errorf("Expected keyType %q, but got %q", tc.expectedKeyType, keyType) + } + } +} diff --git a/vals.go b/vals.go index 23963e8..5c58720 100644 --- a/vals.go +++ b/vals.go @@ -23,6 +23,7 @@ import ( "github.com/helmfile/vals/pkg/providers/awskms" "github.com/helmfile/vals/pkg/providers/awssecrets" "github.com/helmfile/vals/pkg/providers/azurekeyvault" + "github.com/helmfile/vals/pkg/providers/bitwarden" "github.com/helmfile/vals/pkg/providers/conjur" "github.com/helmfile/vals/pkg/providers/doppler" "github.com/helmfile/vals/pkg/providers/echo" @@ -95,6 +96,7 @@ const ( ProviderK8s = "k8s" ProviderConjur = "conjur" ProviderHCPVaultSecrets = "hcpvaultsecrets" + ProviderBitwarden = "bw" ) var ( @@ -262,6 +264,9 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) { case ProviderHCPVaultSecrets: p := hcpvaultsecrets.New(r.logger, conf) return p, nil + case ProviderBitwarden: + p := bitwarden.New(r.logger, conf) + return p, nil } return nil, fmt.Errorf("no provider registered for scheme %q", scheme) }