Skip to content

Commit

Permalink
feat: Bitwarden provider (#276) (#277)
Browse files Browse the repository at this point in the history
  • Loading branch information
smerschjohann authored Mar 22, 2024
1 parent d91b335 commit 8d67d10
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 0 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <COMMAND>` to populate envvars and execute the command.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions pkg/providers/bitwarden/bitwarden.go
Original file line number Diff line number Diff line change
@@ -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
}
93 changes: 93 additions & 0 deletions pkg/providers/bitwarden/bitwarden_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
5 changes: 5 additions & 0 deletions vals.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -95,6 +96,7 @@ const (
ProviderK8s = "k8s"
ProviderConjur = "conjur"
ProviderHCPVaultSecrets = "hcpvaultsecrets"
ProviderBitwarden = "bw"
)

var (
Expand Down Expand Up @@ -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)
}
Expand Down

0 comments on commit 8d67d10

Please sign in to comment.