Implement client for KV Secrets API
Signed-off-by: Hasan Turken <turkenh@gmail.com>
This commit is contained in:
parent
ac03ae3946
commit
796c2ec38e
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue