components-contrib/secretstores/hashicorp/vault/vault.go

544 lines
15 KiB
Go

/*
Copyright 2021 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package vault
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
jsoniter "github.com/json-iterator/go"
"golang.org/x/net/http2"
"github.com/dapr/components-contrib/metadata"
"github.com/dapr/components-contrib/secretstores"
"github.com/dapr/kit/logger"
kitmd "github.com/dapr/kit/metadata"
)
const (
defaultVaultAddress string = "https://127.0.0.1:8200"
defaultVaultEnginePath string = "secret"
componentVaultAddress string = "vaultAddr"
componentCaCert string = "caCert"
componentCaPath string = "caPath"
componentCaPem string = "caPem"
componentSkipVerify string = "skipVerify"
componentTLSServerName string = "tlsServerName"
componentVaultToken string = "vaultToken"
componentVaultTokenMountPath string = "vaultTokenMountPath"
componentVaultKVPrefix string = "vaultKVPrefix"
componentVaultKVUsePrefix string = "vaultKVUsePrefix"
defaultVaultKVPrefix string = "dapr"
vaultHTTPHeader string = "X-Vault-Token"
vaultHTTPRequestHeader string = "X-Vault-Request"
vaultEnginePath string = "enginePath"
vaultValueType string = "vaultValueType"
versionID string = "version_id"
DataStr string = "data"
)
type valueType string
const (
valueTypeMap valueType = "map"
valueTypeText valueType = "text"
)
var _ secretstores.SecretStore = (*vaultSecretStore)(nil)
func (v valueType) isMapType() bool {
return v == valueTypeMap
}
var ErrNotFound = errors.New("secret key or version not exist")
// vaultSecretStore is a secret store implementation for HashiCorp Vault.
type vaultSecretStore struct {
client *http.Client
vaultAddress string
vaultToken string
vaultTokenMountPath string
vaultKVPrefix string
vaultEnginePath string
vaultValueType valueType
json jsoniter.API
logger logger.Logger
}
type VaultMetadata struct {
CaCert string
CaPath string
CaPem string
SkipVerify string
TLSServerName string
VaultAddr string
VaultKVPrefix string
VaultKVUsePrefix bool
VaultToken string
VaultTokenMountPath string
EnginePath string
VaultValueType string
}
// tlsConfig is TLS configuration to interact with HashiCorp Vault.
type tlsConfig struct {
vaultCAPem string
vaultCACert string
vaultCAPath string
vaultSkipVerify bool
vaultServerName string
}
// vaultKVResponse is the response data from Vault KV.
type vaultKVResponse struct {
Data struct {
Data map[string]string `json:"data"`
} `json:"data"`
}
// vaultListKVResponse is the response data from Vault KV.
type vaultListKVResponse struct {
Data struct {
Keys []string `json:"keys"`
} `json:"data"`
}
// NewHashiCorpVaultSecretStore returns a new HashiCorp Vault secret store.
func NewHashiCorpVaultSecretStore(logger logger.Logger) secretstores.SecretStore {
return &vaultSecretStore{
client: &http.Client{},
logger: logger,
json: jsoniter.ConfigFastest,
}
}
// Init creates a HashiCorp Vault client.
func (v *vaultSecretStore) Init(_ context.Context, meta secretstores.Metadata) error {
m := VaultMetadata{
VaultKVUsePrefix: true,
}
err := kitmd.DecodeMetadata(meta.Properties, &m)
if err != nil {
return err
}
// Get Vault address
address := m.VaultAddr
if address == "" {
address = defaultVaultAddress
}
v.vaultAddress = address
v.vaultEnginePath = defaultVaultEnginePath
if m.EnginePath != "" {
v.vaultEnginePath = m.EnginePath
}
v.vaultValueType = valueTypeMap
if m.VaultValueType != "" {
switch valueType(m.VaultValueType) {
case valueTypeMap:
case valueTypeText:
v.vaultValueType = valueTypeText
default:
return fmt.Errorf("vault init error, invalid value type %s, accepted values are map or text", m.VaultValueType)
}
}
v.vaultToken = m.VaultToken
v.vaultTokenMountPath = m.VaultTokenMountPath
initErr := v.initVaultToken()
if initErr != nil {
return initErr
}
vaultKVPrefix := m.VaultKVPrefix
if !m.VaultKVUsePrefix {
vaultKVPrefix = ""
} else if vaultKVPrefix == "" {
vaultKVPrefix = defaultVaultKVPrefix
}
v.vaultKVPrefix = vaultKVPrefix
// Generate TLS config
tlsConf := metadataToTLSConfig(&m)
client, err := v.createHTTPClient(tlsConf)
if err != nil {
return fmt.Errorf("couldn't create client using config: %w", err)
}
v.client = client
return nil
}
func metadataToTLSConfig(meta *VaultMetadata) *tlsConfig {
tlsConf := tlsConfig{}
// Configure TLS settings
skipVerify := meta.SkipVerify
tlsConf.vaultSkipVerify = false
if skipVerify == "true" {
tlsConf.vaultSkipVerify = true
}
tlsConf.vaultCACert = meta.CaCert
tlsConf.vaultCAPem = meta.CaPem
tlsConf.vaultCAPath = meta.CaPath
tlsConf.vaultServerName = meta.TLSServerName
return &tlsConf
}
// GetSecret retrieves a secret using a key and returns a map of decrypted string/string values.
func (v *vaultSecretStore) getSecret(ctx context.Context, secret, version string) (*vaultKVResponse, error) {
// Create get secret url
var vaultSecretPathAddr string
if v.vaultKVPrefix == "" {
vaultSecretPathAddr = v.vaultAddress + "/v1/" + v.vaultEnginePath + "/data/" + secret + "?version=" + version
} else {
vaultSecretPathAddr = v.vaultAddress + "/v1/" + v.vaultEnginePath + "/data/" + v.vaultKVPrefix + "/" + secret + "?version=" + version
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, vaultSecretPathAddr, nil)
if err != nil {
return nil, fmt.Errorf("couldn't generate request: %w", err)
}
// Set vault token.
httpReq.Header.Set(vaultHTTPHeader, v.vaultToken)
// Set X-Vault-Request header
httpReq.Header.Set(vaultHTTPRequestHeader, "true")
httpresp, err := v.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("couldn't get secret: %w", err)
}
defer httpresp.Body.Close()
if httpresp.StatusCode != http.StatusOK {
var b bytes.Buffer
io.Copy(&b, httpresp.Body)
v.logger.Debugf("getSecret %s couldn't get successful response: %#v, %s", secret, httpresp, b.String())
if httpresp.StatusCode == http.StatusNotFound {
// handle not found error
return nil, fmt.Errorf("getSecret %s failed %w", secret, ErrNotFound)
}
return nil, fmt.Errorf("couldn't get successful response, status code %d, body %s",
httpresp.StatusCode, b.String())
}
var d vaultKVResponse
if v.vaultValueType.isMapType() {
// parse the secret value to map[string]string
if err := json.NewDecoder(httpresp.Body).Decode(&d); err != nil {
return nil, fmt.Errorf("couldn't decode response body: %s", err)
}
} else {
// treat the secret as string
b, err := io.ReadAll(httpresp.Body)
if err != nil {
return nil, fmt.Errorf("couldn't read response: %s", err)
}
res := v.json.Get(b, DataStr, DataStr).ToString()
d.Data.Data = map[string]string{
secret: res,
}
}
return &d, nil
}
// GetSecret retrieves a secret using a key and returns a map of decrypted string/string values.
func (v *vaultSecretStore) GetSecret(ctx context.Context, req secretstores.GetSecretRequest) (secretstores.GetSecretResponse, error) {
// version 0 represent for latest version
version := "0"
if value, ok := req.Metadata[versionID]; ok {
version = value
}
d, err := v.getSecret(ctx, req.Name, version)
if err != nil {
return secretstores.GetSecretResponse{Data: nil}, err
}
resp := secretstores.GetSecretResponse{
Data: d.Data.Data,
}
return resp, nil
}
// BulkGetSecret retrieves all secrets in the store and returns a map of decrypted string/string values.
func (v *vaultSecretStore) BulkGetSecret(ctx context.Context, req secretstores.BulkGetSecretRequest) (secretstores.BulkGetSecretResponse, error) {
version := "0"
if value, ok := req.Metadata[versionID]; ok {
version = value
}
resp := secretstores.BulkGetSecretResponse{
Data: map[string]map[string]string{},
}
keys, err := v.listKeysUnderPath(ctx, "")
if err != nil {
return secretstores.BulkGetSecretResponse{}, err
}
for _, key := range keys {
keyValues := map[string]string{}
secrets, err := v.getSecret(ctx, key, version)
if err != nil {
if errors.Is(err, ErrNotFound) {
// version not exist skip
continue
}
return secretstores.BulkGetSecretResponse{Data: nil}, err
}
for k, v := range secrets.Data.Data {
keyValues[k] = v
}
resp.Data[key] = keyValues
}
return resp, nil
}
// listKeysUnderPath get all the keys recursively under a given path.(returned keys including path as prefix)
// path should not has `/` prefix.
func (v *vaultSecretStore) listKeysUnderPath(ctx context.Context, path string) ([]string, error) {
var vaultSecretsPathAddr string
// Create list secrets url
if v.vaultKVPrefix == "" {
vaultSecretsPathAddr = fmt.Sprintf("%s/v1/%s/metadata/%s", v.vaultAddress, v.vaultEnginePath, path)
} else {
vaultSecretsPathAddr = fmt.Sprintf("%s/v1/%s/metadata/%s/%s", v.vaultAddress, v.vaultEnginePath, v.vaultKVPrefix, path)
}
httpReq, err := http.NewRequestWithContext(ctx, "LIST", vaultSecretsPathAddr, nil)
if err != nil {
return nil, fmt.Errorf("couldn't generate request: %s", err)
}
// Set vault token.
httpReq.Header.Set(vaultHTTPHeader, v.vaultToken)
// Set X-Vault-Request header
httpReq.Header.Set(vaultHTTPRequestHeader, "true")
httpresp, err := v.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("couldn't get secret: %s", err)
}
defer httpresp.Body.Close()
if httpresp.StatusCode != http.StatusOK {
var b bytes.Buffer
io.Copy(&b, httpresp.Body)
v.logger.Debugf("list keys couldn't get successful response: %#v, %s", httpresp, b.String())
return nil, fmt.Errorf("list keys couldn't get successful response, status code: %d, status: %s, response %s",
httpresp.StatusCode, httpresp.Status, b.String())
}
var d vaultListKVResponse
if err := json.NewDecoder(httpresp.Body).Decode(&d); err != nil {
return nil, fmt.Errorf("couldn't decode response body: %s", err)
}
res := make([]string, 0, len(d.Data.Keys))
for _, key := range d.Data.Keys {
if v.isSecretPath(key) {
res = append(res, path+key)
} else {
subKeys, err := v.listKeysUnderPath(ctx, path+key)
if err != nil {
return nil, err
}
res = append(res, subKeys...)
}
}
return res, nil
}
// isSecretPath checks if the key is a valid secret path or it is part of the secret path.
func (v *vaultSecretStore) isSecretPath(key string) bool {
return !strings.HasSuffix(key, "/")
}
// initVaultToken reads the vault token from the file if token is defined by mount path.
func (v *vaultSecretStore) initVaultToken() error {
// Test that at least one of them are set if not return error
if v.vaultToken == "" && v.vaultTokenMountPath == "" {
return fmt.Errorf("token mount path and token not set")
}
// Test that both are not set. If so return error
if v.vaultToken != "" && v.vaultTokenMountPath != "" {
return fmt.Errorf("token mount path and token both set")
}
if v.vaultToken != "" {
return nil
}
data, err := os.ReadFile(v.vaultTokenMountPath)
if err != nil {
return fmt.Errorf("couldn't read vault token from mount path %s err: %s", v.vaultTokenMountPath, err)
}
v.vaultToken = string(bytes.TrimSpace(data))
return nil
}
func (v *vaultSecretStore) createHTTPClient(config *tlsConfig) (*http.Client, error) {
tlsClientConfig := &tls.Config{MinVersion: tls.VersionTLS12}
if config != nil && config.vaultSkipVerify {
v.logger.Infof("hashicorp vault: you are using 'skipVerify' to skip server config verify which is unsafe!")
}
tlsClientConfig.InsecureSkipVerify = config.vaultSkipVerify
if !config.vaultSkipVerify {
rootCAPools, err := v.getRootCAsPools(config.vaultCAPem, config.vaultCAPath, config.vaultCACert)
if err != nil {
return nil, err
}
tlsClientConfig.RootCAs = rootCAPools
if config.vaultServerName != "" {
tlsClientConfig.ServerName = config.vaultServerName
}
}
// Setup http transport
transport := &http.Transport{
TLSClientConfig: tlsClientConfig,
}
// Configure http2 client
err := http2.ConfigureTransport(transport)
if err != nil {
return nil, errors.New("failed to configure http2")
}
return &http.Client{
Transport: transport,
}, nil
}
// getRootCAsPools returns root CAs when you give it CA Pem file, CA path, and CA Certificate. Default is system certificates.
func (v *vaultSecretStore) getRootCAsPools(vaultCAPem string, vaultCAPath string, vaultCACert string) (*x509.CertPool, error) {
if vaultCAPem != "" {
certPool := x509.NewCertPool()
cert := []byte(vaultCAPem)
if ok := certPool.AppendCertsFromPEM(cert); !ok {
return nil, fmt.Errorf("couldn't read PEM")
}
return certPool, nil
}
if vaultCAPath != "" {
certPool := x509.NewCertPool()
if err := readCertificateFolder(certPool, vaultCAPath); err != nil {
return nil, err
}
return certPool, nil
}
if vaultCACert != "" {
certPool := x509.NewCertPool()
if err := readCertificateFile(certPool, vaultCACert); err != nil {
return nil, err
}
return certPool, nil
}
certPool, err := x509.SystemCertPool()
if err != nil {
return nil, fmt.Errorf("couldn't read system certs: %s", err)
}
return certPool, nil
}
// readCertificateFile reads the certificate at given path.
func readCertificateFile(certPool *x509.CertPool, path string) error {
// Read certificate file
pemFile, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("couldn't read CA file from disk: %s", err)
}
if ok := certPool.AppendCertsFromPEM(pemFile); !ok {
return fmt.Errorf("couldn't read PEM")
}
return nil
}
// readCertificateFolder scans a folder for certificates.
func readCertificateFolder(certPool *x509.CertPool, path string) error {
err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if info.IsDir() {
return nil
}
return readCertificateFile(certPool, p)
})
if err != nil {
return fmt.Errorf("couldn't read certificates at %s: %s", path, err)
}
return nil
}
// Features returns the features available in this secret store.
func (v *vaultSecretStore) Features() []secretstores.Feature {
if v.vaultValueType == valueTypeText {
return []secretstores.Feature{}
}
return []secretstores.Feature{secretstores.FeatureMultipleKeyValuesPerSecret}
}
func (v *vaultSecretStore) GetComponentMetadata() (metadataInfo metadata.MetadataMap) {
metadataStruct := VaultMetadata{}
metadata.GetMetadataInfoFromStructType(reflect.TypeOf(metadataStruct), &metadataInfo, metadata.SecretStoreType)
return
}