Implement client for KV Secrets API

Signed-off-by: Hasan Turken <turkenh@gmail.com>
This commit is contained in:
Hasan Turken 2022-02-22 23:36:21 +03:00
parent ac03ae3946
commit 796c2ec38e
No known key found for this signature in database
GPG Key ID: D7AA042F8F8B488E
2 changed files with 243 additions and 58 deletions

View File

@ -16,12 +16,205 @@
package vault
import "github.com/hashicorp/vault/api"
import (
"path/filepath"
"reflect"
// Client is a minimal Vault client that supports required methods for Vault
// Secret Store.
type Client interface {
Read(path string) (*api.Secret, error)
Write(path string, data map[string]interface{}) (*api.Secret, error)
Delete(path string) (*api.Secret, error)
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/hashicorp/vault/api"
)
const (
errRead = "cannot read vault secret"
errWrite = "cannot write vault secret"
errNotFound = "secret not found"
errFmtUnexpectedValue = "expecting a string as a value for key %q, but it is %q"
)
// KVVersion represent API version of the Vault KV engine
// https://www.vaultproject.io/docs/secrets/kv
type KVVersion string
const (
// KVVersionV1 indicates that "Kubernetes Auth" will be used to
// authenticate to Vault.
// https://www.vaultproject.io/docs/auth/kubernetes
KVVersionV1 KVVersion = "v1"
// KVVersionV2 indicates that "Token Auth" will be used to
// authenticate to Vault.
// https://www.vaultproject.io/docs/auth/token
KVVersionV2 KVVersion = "v2"
)
// KVSecret is a KV Engine secret
type KVSecret struct {
metadata map[string]string
data map[string][]byte
}
type KVOption func(*KV)
func WithVersion(v KVVersion) KVOption {
return func(kv *KV) {
kv.version = v
}
}
type KV struct {
client *api.Logical
mountPath string
version KVVersion
}
func NewKV(logical *api.Logical, mountPath string, opts ...KVOption) *KV {
kv := &KV{
client: logical,
mountPath: mountPath,
version: KVVersionV2,
}
for _, o := range opts {
o(kv)
}
return kv
}
func (k *KV) Get(path string, secret *KVSecret) error {
s, err := k.client.Read(k.pathForData(path))
if err != nil {
return errors.Wrap(err, errRead)
}
if s == nil {
return errors.New(errNotFound)
}
return k.parseAsKVSecret(s, secret)
}
func (k *KV) Apply(path string, secret *KVSecret) error {
_, err := k.client.Write(k.pathForData(path), k.secretDataFor(secret))
return errors.Wrap(err, errWrite)
}
func (k *KV) Delete(path string) error {
if k.version == KVVersionV1 {
_, err := k.client.Delete(filepath.Join(k.mountPath, path))
return errors.Wrap(err, errDelete)
}
// Note(turkenh): With KV v2, we need to delete metadata and all versions:
// https://www.vaultproject.io/api-docs/secret/kv/kv-v2#delete-metadata-and-all-versions
_, err := k.client.Delete(filepath.Join(k.mountPath, "metadata", path))
return errors.Wrap(err, errDelete)
}
func isNotFound(err error) bool {
return err.Error() == errNotFound
}
func (k *KV) pathForData(path string) string {
if k.version == KVVersionV1 {
return filepath.Join(k.mountPath, path)
}
return filepath.Join(k.mountPath, "data", path)
}
func (k *KV) parseAsKVSecret(s *api.Secret, kv *KVSecret) error {
var err error
if k.version == KVVersionV1 {
kv.data, err = valuesAsByteArray(s.Data)
return err
}
// kv version is v2
// Note(turkenh): kv v2 secrets contains another "data" and a "metadata"
// block inside the top level generic "data" field.
// https://www.vaultproject.io/api/secret/kv/kv-v2#sample-response-1
if sData, ok := s.Data["data"].(map[string]interface{}); ok && sData != nil {
if kv.data, err = valuesAsByteArray(sData); err != nil {
return err
}
}
if sMeta, ok := s.Data["metadata"].(map[string]interface{}); ok && sMeta != nil {
if kv.metadata, err = valuesAsString(sMeta); err != nil {
return err
}
}
return nil
}
func (k *KV) secretDataFor(kv *KVSecret) map[string]interface{} {
if k.version == KVVersionV1 {
// There is no metadata for a v1 kv secret
return valuesAsInterface(kv.data)
}
// kv version is v2
// Note(turkenh): kv v2 secrets contains another "data" and a "metadata"
// block inside the top level generic "data" field.
// https://www.vaultproject.io/api/secret/kv/kv-v2#sample-response-1
out := make(map[string]interface{}, 2)
out["data"] = byteArrayValuesAsString(kv.data)
out["metadata"] = kv.metadata
return out
}
func valuesAsByteArray(in map[string]interface{}) (map[string][]byte, error) {
if len(in) == 0 {
return nil, nil
}
out := make(map[string][]byte, len(in))
for key, val := range in {
sVal, ok := val.(string)
if !ok {
return nil, errors.Errorf(errFmtUnexpectedValue, key, reflect.TypeOf(val))
}
out[key] = []byte(sVal)
}
return out, nil
}
func valuesAsString(in map[string]interface{}) (map[string]string, error) {
if len(in) == 0 {
return nil, nil
}
out := make(map[string]string, len(in))
for key, val := range in {
sVal, ok := val.(string)
if !ok {
return nil, errors.Errorf(errFmtUnexpectedValue, key, reflect.TypeOf(val))
}
out[key] = sVal
}
return out, nil
}
func byteArrayValuesAsString(in map[string][]byte) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for key, val := range in {
out[key] = string(val)
}
return out
}
func valuesAsInterface(in map[string][]byte) map[string]interface{} {
if len(in) == 0 {
return nil
}
out := make(map[string]interface{}, len(in))
for key, val := range in {
out[key] = val
}
return out
}

View File

@ -21,12 +21,14 @@ import (
"path/filepath"
"github.com/hashicorp/vault/api"
"github.com/crossplane/crossplane-runtime/pkg/resource"
"sigs.k8s.io/controller-runtime/pkg/client"
v1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/connection/store"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/resource"
)
// Error strings.
@ -35,17 +37,22 @@ const (
errNewClient = "cannot create new client"
errExtractToken = "cannot extract token"
errRead = "cannot read secret"
errReadToAppend = "cannot read secret to append keys"
errWrite = "cannot write secret"
errDelete = "cannot delete secret"
errGet = "cannot get secret"
errApply = "cannot apply secret"
errDelete = "cannot delete secret"
)
// KVClient is a Vault KV Secrets engine client that supports both v1 and v2.
type KVClient interface {
Get(path string, secret *KVSecret) error
Apply(path string, secret *KVSecret) error
Delete(path string) error
}
// SecretStore is a Vault Secret Store.
type SecretStore struct {
client Client
client KVClient
pathPrefix string
defaultParentPath string
}
@ -54,12 +61,6 @@ func NewSecretStore(ctx context.Context, kube client.Client, cfg v1.SecretStoreC
if cfg.Vault == nil {
return nil, errors.New(errNoConfig)
}
if cfg.Vault.Auth.Method != v1.VaultAuthToken {
return nil, errors.Errorf("%q auth not supported yet, please use Token auth", cfg.Vault.Auth.Method)
}
if cfg.Vault.Auth.Token == nil {
return nil, errors.New("token auth configured but no token provided")
}
vCfg := api.DefaultConfig()
vCfg.Address = cfg.Vault.Server
@ -68,34 +69,35 @@ func NewSecretStore(ctx context.Context, kube client.Client, cfg v1.SecretStoreC
return nil, errors.Wrap(err, errNewClient)
}
t, err := resource.CommonCredentialExtractor(ctx, cfg.Vault.Auth.Token.Source, kube, cfg.Vault.Auth.Token.CommonCredentialSelectors)
if err != nil {
return nil, errors.Wrap(err, errExtractToken)
switch cfg.Vault.Auth.Method {
case v1.VaultAuthToken:
if cfg.Vault.Auth.Token == nil {
return nil, errors.New("token auth configured but no token provided")
}
t, err := resource.CommonCredentialExtractor(ctx, cfg.Vault.Auth.Token.Source, kube, cfg.Vault.Auth.Token.CommonCredentialSelectors)
if err != nil {
return nil, errors.Wrap(err, errExtractToken)
}
c.SetToken(string(t))
case v1.VaultAuthKubernetes:
return nil, errors.Errorf("%q is not supported as an auth method yet", v1.VaultAuthKubernetes)
default:
return nil, errors.Errorf("%q is not supported as an auth method", cfg.Vault.Auth.Method)
}
c.SetToken(string(t))
return &SecretStore{
client: c.Logical(),
pathPrefix: cfg.Vault.MountPath,
client: NewKV(c.Logical(), cfg.Vault.MountPath, WithVersion(KVVersion(cfg.Vault.Version))),
defaultParentPath: cfg.DefaultScope,
}, nil
}
// ReadKeyValues reads and returns key value pairs for a given Vault Secret.
func (ss *SecretStore) ReadKeyValues(_ context.Context, i store.Secret) (store.KeyValues, error) {
// TODO(turkenh): Handle not found
s, err := ss.client.Read(ss.pathForSecretInstance(i))
if err != nil {
return nil, errors.Wrap(err, errRead)
s := &KVSecret{}
if err := ss.client.Get(ss.pathForSecretInstance(i), s); resource.Ignore(isNotFound, err) != nil {
return nil, errors.Wrap(err, errGet)
}
// TODO(turkenh): debug log s.Warnings ?
kv := make(map[string][]byte, len(s.Data))
for k, v := range s.Data {
kv[k] = []byte(v.(string))
}
return kv, nil
return s.data, nil
}
// WriteKeyValues writes key value pairs to a given Vault Secret.
@ -104,29 +106,20 @@ func (ss *SecretStore) WriteKeyValues(_ context.Context, i store.Secret, kv stor
// Nothing to write
return nil
}
s, err := ss.client.Read(ss.pathForSecretInstance(i))
if err != nil {
return errors.Wrap(err, errReadToAppend)
existing := &KVSecret{}
if err := ss.client.Get(ss.pathForSecretInstance(i), existing); resource.Ignore(isNotFound, err) != nil {
return errors.Wrap(err, errGet)
}
var existing map[string]interface{}
if s != nil {
existing = s.Data
data := make(map[string][]byte, len(kv)+len(existing.data))
for k, v := range existing.data {
data[k] = v
}
data := make(map[string]interface{}, len(kv)+len(existing))
for k, v := range kv {
// Note(turkenh): value here is or type []byte, it is stored as base64
// encoded in Vault if we don't cast it to string. This could be a
// configuration option if needed.
data[k] = string(v)
}
for k, v := range existing {
data[k] = v
}
_, err = ss.client.Write(ss.pathForSecretInstance(i), data)
// TODO(turkenh): debug log s.Warnings ?
return errors.Wrap(err, errWrite)
return errors.Wrap(ss.client.Apply(ss.pathForSecretInstance(i), &KVSecret{data: data}), errApply)
}
// DeleteKeyValues delete key value pairs from a given Vault Secret.
@ -134,13 +127,12 @@ func (ss *SecretStore) WriteKeyValues(_ context.Context, i store.Secret, kv stor
// If kv specified, those would be deleted and secret instance will be deleted
// only if there is no data left.
func (ss *SecretStore) DeleteKeyValues(_ context.Context, i store.Secret, kv store.KeyValues) error {
_, err := ss.client.Delete(ss.pathForSecretInstance(i))
return errors.Wrap(err, errDelete)
return errors.Wrap(ss.client.Delete(ss.pathForSecretInstance(i)), errDelete)
}
func (ss *SecretStore) pathForSecretInstance(i store.Secret) string {
if i.Scope != "" {
return filepath.Clean(filepath.Join(ss.pathPrefix, i.Scope, i.Name))
return filepath.Clean(filepath.Join(i.Scope, i.Name))
}
return filepath.Clean(filepath.Join(ss.pathPrefix, ss.defaultParentPath, i.Name))
return filepath.Clean(filepath.Join(ss.defaultParentPath, i.Name))
}