From 8ce9168a86088ffb90471773a863c69f7cc3c5b7 Mon Sep 17 00:00:00 2001 From: Tom Proctor Date: Mon, 8 Feb 2021 14:48:16 +0000 Subject: [PATCH 1/5] Support all secret types --- go.mod | 2 +- internal/config/config.go | 25 +-- internal/config/config_test.go | 79 +++++++- .../testdata/example-parameters-string.txt | 2 +- internal/provider/provider.go | 148 +++++++------- main.go | 12 +- test/bats/configs/nginx-inline-volume.yaml | 2 +- test/bats/configs/nginx-pki.yaml | 19 ++ .../vault-foo-secretproviderclass.yaml | 15 +- ...foo-sync-multiple-secretproviderclass.yaml | 20 +- .../vault-foo-sync-secretproviderclass.yaml | 19 +- .../vault-pki-secretproviderclass.yaml | 18 ++ test/bats/configs/vault-policy-readonly.hcl | 7 - test/bats/configs/vault-policy.hcl | 7 + test/bats/provider.bats | 186 ++++++++++-------- 15 files changed, 338 insertions(+), 223 deletions(-) create mode 100644 test/bats/configs/nginx-pki.yaml create mode 100644 test/bats/configs/vault-pki-secretproviderclass.yaml delete mode 100644 test/bats/configs/vault-policy-readonly.hcl create mode 100644 test/bats/configs/vault-policy.hcl diff --git a/go.mod b/go.mod index 466e830..33a021b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.12 require ( github.com/hashicorp/go-hclog v0.8.0 github.com/hashicorp/vault/api v1.0.4 - github.com/mitchellh/mapstructure v1.4.1 + github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/pkg/errors v0.9.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.6.1 diff --git a/internal/config/config.go b/internal/config/config.go index 8fa4383..90732b5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,9 +55,11 @@ func (c TLSConfig) CertificatesConfigured() bool { } type Secret struct { - ObjectName string `yaml:"objectName"` - ObjectPath string `yaml:"objectPath"` - ObjectVersion string `yaml:"objectVersion"` + ObjectName string `yaml:"objectName,omitempty"` + SecretPath string `yaml:"secretPath,omitempty"` + SecretKey string `yaml:"secretKey,omitempty"` + Method string `yaml:"method,omitempty"` + SecretArgs map[string]interface{} `yaml:"secretArgs,omitempty"` } func Parse(parametersStr, targetPath, permissionStr string) (Config, error) { @@ -110,25 +112,10 @@ func parseParameters(parametersStr string) (Parameters, error) { } secretsYaml := params["objects"] - // TODO: There is an unnecessary map under objects, instead of just directly storing an array there. - // Deserialisation can be simplified a fair bit if we remove it. - secretsMap := map[string][]string{} - err = yaml.Unmarshal([]byte(secretsYaml), &secretsMap) + err = yaml.Unmarshal([]byte(secretsYaml), ¶meters.Secrets) if err != nil { return Parameters{}, err } - secrets, ok := secretsMap["array"] - if !ok { - return Parameters{}, errors.New("no secrets to read configured") - } - for _, s := range secrets { - var secret Secret - err = yaml.Unmarshal([]byte(s), &secret) - if err != nil { - return Parameters{}, err - } - parameters.Secrets = append(parameters.Secrets, secret) - } // Set default values. if parameters.VaultAddress == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6af637b..776ca42 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,18 +3,85 @@ package config import ( "encoding/json" "io/ioutil" + "path/filepath" "testing" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" "gotest.tools/assert" ) const ( - objects = "array:\n - |\n objectPath: \"v1/secret/foo1\"\n objectName: \"bar1\"\n objectVersion: \"\"" + objects = "-\n secretPath: \"v1/secret/foo1\"\n objectName: \"bar1\"" + certsSPCYaml = `apiVersion: secrets-store.csi.x-k8s.io/v1alpha1 +kind: SecretProviderClass +metadata: + name: vault-foo +spec: + provider: vault + parameters: + objects: | + - objectName: "test-certs" + secretPath: "pki/issue/example-dot-com" + secretArgs: + common_name: "test.example.com" + ip_sans: "127.0.0.1" + exclude_cn_from_sans: true + method: "PUT" + - objectName: "internal-certs" + secretPath: "pki/issue/example-dot-com" + secretArgs: + common_name: "internal.example.com" + method: "PUT" +` ) +func TestParseParametersFromYaml(t *testing.T) { + // Test starts with a minimal simulation of the processing the driver does + // with each SecretProviderClass yaml. + var secretProviderClass struct { + Spec struct { + Parameters map[string]string `yaml:"parameters"` + } `yaml:"spec"` + } + err := yaml.Unmarshal([]byte(certsSPCYaml), &secretProviderClass) + require.NoError(t, err) + paramsBytes, err := json.Marshal(secretProviderClass.Spec.Parameters) + + // This is now the form the provider receives the data in. + params, err := parseParameters(string(paramsBytes)) + require.NoError(t, err) + + assert.DeepEqual(t, Parameters{ + VaultAddress: defaultVaultAddress, + KubernetesServiceAccountPath: defaultKubernetesServiceAccountPath, + VaultKubernetesMountPath: defaultVaultKubernetesMountPath, + Secrets: []Secret{ + { + ObjectName: "test-certs", + SecretPath: "pki/issue/example-dot-com", + SecretArgs: map[string]interface{}{ + "common_name": "test.example.com", + "ip_sans": "127.0.0.1", + "exclude_cn_from_sans": true, + }, + Method: "PUT", + }, + { + ObjectName: "internal-certs", + SecretPath: "pki/issue/example-dot-com", + SecretArgs: map[string]interface{}{ + "common_name": "internal.example.com", + }, + Method: "PUT", + }, + }, + }, params) +} + func TestParseParameters(t *testing.T) { - parametersStr, err := ioutil.ReadFile("testdata/example-parameters-string.txt") + // This file's contents are copied directly from a driver mount request. + parametersStr, err := ioutil.ReadFile(filepath.Join("testdata", "example-parameters-string.txt")) require.NoError(t, err) actual, err := parseParameters(string(parametersStr)) require.NoError(t, err) @@ -25,8 +92,8 @@ func TestParseParameters(t *testing.T) { VaultSkipTLSVerify: true, }, Secrets: []Secret{ - {"bar1", "v1/secret/foo1", ""}, - {"bar2", "v1/secret/foo2", ""}, + {"bar1", "v1/secret/foo1", "", "GET", nil}, + {"bar2", "v1/secret/foo2", "", "", nil}, }, VaultKubernetesMountPath: defaultVaultKubernetesMountPath, KubernetesServiceAccountPath: defaultKubernetesServiceAccountPath, @@ -64,7 +131,7 @@ func TestParseConfig(t *testing.T) { expected.VaultRoleName = roleName expected.TLSConfig.VaultSkipTLSVerify = true expected.Secrets = []Secret{ - {"bar1", "v1/secret/foo1", ""}, + {"bar1", "v1/secret/foo1", "", "", nil}, } return expected }(), @@ -92,7 +159,7 @@ func TestParseConfig(t *testing.T) { expected.KubernetesServiceAccountPath = "my-account-path" expected.TLSConfig.VaultSkipTLSVerify = true expected.Secrets = []Secret{ - {"bar1", "v1/secret/foo1", ""}, + {"bar1", "v1/secret/foo1", "", "", nil}, } return expected }(), diff --git a/internal/config/testdata/example-parameters-string.txt b/internal/config/testdata/example-parameters-string.txt index 805daf5..21bbc55 100644 --- a/internal/config/testdata/example-parameters-string.txt +++ b/internal/config/testdata/example-parameters-string.txt @@ -1 +1 @@ -{"csi.storage.k8s.io/pod.name":"nginx-secrets-store-inline","csi.storage.k8s.io/pod.namespace":"test","csi.storage.k8s.io/pod.uid":"9aeb260f-d64a-426c-9872-95b6bab37e00","csi.storage.k8s.io/serviceAccount.name":"default","objects":"array:\n - |\n objectPath: \"v1/secret/foo1\"\n objectName: \"bar1\"\n objectVersion: \"\"\n - |\n objectPath: \"v1/secret/foo2\"\n objectName: \"bar2\"\n objectVersion: \"\"\n","roleName":"example-role","vaultAddress":"http://vault:8200","vaultSkipTLSVerify":"true"} \ No newline at end of file +{"csi.storage.k8s.io/pod.name":"nginx-secrets-store-inline","csi.storage.k8s.io/pod.namespace":"test","csi.storage.k8s.io/pod.uid":"9aeb260f-d64a-426c-9872-95b6bab37e00","csi.storage.k8s.io/serviceAccount.name":"default","objects":"- secretPath: \"v1/secret/foo1\"\n objectName: \"bar1\"\n method: \"GET\"\n- secretPath: \"v1/secret/foo2\"\n objectName: \"bar2\"","roleName":"example-role","vaultAddress":"http://vault:8200","vaultSkipTLSVerify":"true"} \ No newline at end of file diff --git a/internal/provider/provider.go b/internal/provider/provider.go index c84432f..800a5d7 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -3,19 +3,18 @@ package provider import ( "bytes" "context" + "encoding/json" "fmt" "io/ioutil" "net/url" "os" "path/filepath" - "regexp" "strings" "github.com/hashicorp/go-hclog" vaultclient "github.com/hashicorp/secrets-store-csi-driver-provider-vault/internal/client" "github.com/hashicorp/secrets-store-csi-driver-provider-vault/internal/config" "github.com/hashicorp/vault/api" - "github.com/mitchellh/mapstructure" ) func readJWTToken(path string) (string, error) { @@ -31,58 +30,21 @@ func readJWTToken(path string) (string, error) { // and communicates with the Vault API. type provider struct { logger hclog.Logger + cache map[cacheKey]*api.Secret } func NewProvider(logger hclog.Logger) *provider { p := &provider{ logger: logger, + cache: make(map[cacheKey]*api.Secret), } return p } -func (p *provider) getMountInfo(ctx context.Context, client *api.Client, mountName string) (string, string, error) { - p.logger.Debug("vault: checking mount info", "mountName", mountName) - req := client.NewRequest("GET", "/v1/sys/mounts") - secret, err := vaultclient.Do(ctx, client, req) - if err != nil { - return "", "", fmt.Errorf("failed to read mounts: %w", err) - } - if secret == nil || secret.Data == nil { - return "", "", fmt.Errorf("empty response from %q, warnings: %v", req.URL.Path, secret.Warnings) - } - - mounts := map[string]*api.MountOutput{} - err = mapstructure.Decode(secret.Data, &mounts) - if err != nil { - return "", "", err - } - - mount, ok := mounts[mountName+"/"] - if !ok { - return "", "", fmt.Errorf("did not found mount %q in v1/sys/mounts", mountName) - } - - return mount.Type, mount.Options["version"], nil -} - -func generateSecretEndpoint(secretMountType string, secretMountVersion string, secretPrefix string, secretSuffix string) (string, error) { - addr := "" - errMessage := fmt.Errorf("Only mount types KV/1 and KV/2 are supported") - switch secretMountType { - case "kv": - switch secretMountVersion { - case "1": - addr = "/v1/" + secretPrefix + "/" + secretSuffix - case "2": - addr = "/v1/" + secretPrefix + "/data/" + secretSuffix - default: - return "", errMessage - } - default: - return "", errMessage - } - return addr, nil +type cacheKey struct { + secretPath string + method string } func (p *provider) login(ctx context.Context, client *api.Client, vaultKubernetesMountPath, roleName, jwt string) (string, error) { @@ -109,54 +71,92 @@ func (p *provider) login(ctx context.Context, client *api.Client, vaultKubernete return secret.Auth.ClientToken, nil } -func (p *provider) getSecret(ctx context.Context, client *api.Client, secret config.Secret) (content string, err error) { - p.logger.Debug("vault: getting secrets from vault...") - - s := regexp.MustCompile("/+").Split(secret.ObjectPath, 3) - if len(s) < 3 { - return "", fmt.Errorf("unable to parse secret path %q", secret.ObjectPath) +func ensureV1Prefix(s string) string { + switch { + case strings.HasPrefix(s, "/v1/"): + return s + case strings.HasPrefix(s, "v1/"): + return "/" + s + case strings.HasPrefix(s, "/"): + return "/v1" + s + default: + return "/v1/" + s } - secretPrefix := s[1] - secretSuffix := s[2] +} - secretMountType, secretMountVersion, err := p.getMountInfo(ctx, client, secretPrefix) - if err != nil { - return "", err +func generateRequest(client *api.Client, secret config.Secret) (*api.Request, error) { + secretPath := ensureV1Prefix(secret.SecretPath) + queryIndex := strings.Index(secretPath, "?") + var queryParams map[string][]string + if queryIndex != -1 { + var err error + queryParams, err = url.ParseQuery(secretPath[queryIndex+1:]) + if err != nil { + return nil, fmt.Errorf("failed to parse query parameters from secretPath %q for objectName %q: %w", secretPath, secret.ObjectName, err) + } + secretPath = secretPath[:queryIndex] + } + method := "GET" + if secret.Method != "" { + method = secret.Method } - endpoint, err := generateSecretEndpoint(secretMountType, secretMountVersion, secretPrefix, secretSuffix) - if err != nil { - return "", err + req := client.NewRequest(method, secretPath) + if queryParams != nil { + req.Params = queryParams + } + if secret.SecretArgs != nil { + req.SetJSONBody(secret.SecretArgs) } - p.logger.Debug("vault: Requesting valid secret mounted", "endpoint", endpoint) + return req, nil +} + +func (p *provider) getSecret(ctx context.Context, client *api.Client, secretConfig config.Secret) (string, error) { + var secret *api.Secret + var cached bool + key := cacheKey{secretPath: secretConfig.SecretPath, method: secretConfig.Method} + if secret, cached = p.cache[key]; !cached { + req, err := generateRequest(client, secretConfig) + p.logger.Debug("Requesting secret", "secretConfig", secretConfig, "method", req.Method, "path", req.URL.Path, "params", req.Params) - req := client.NewRequest("GET", endpoint) - if secret.ObjectVersion != "" { - req.Params = url.Values{ - "version": []string{secret.ObjectVersion}, + secret, err = vaultclient.Do(ctx, client, req) + if err != nil { + return "", fmt.Errorf("couldn't read secret %q: %w", secretConfig.ObjectName, err) } + if secret == nil || secret.Data == nil { + return "", fmt.Errorf("empty response from %q, warnings: %v", req.URL.Path, secret.Warnings) + } + + p.cache[key] = secret + } else { + p.logger.Debug("Secret fetched from cache", "secretConfig", secretConfig) } - resp, err := vaultclient.Do(ctx, client, req) - if err != nil { - return "", fmt.Errorf("couldn't read secret %q: %w", secret.ObjectName, err) - } - if resp == nil || resp.Data == nil { - return "", fmt.Errorf("empty response from %q, warnings: %v", req.URL.Path, resp.Warnings) + + // If no secretKey specified, we return the whole response as a JSON object. + if secretConfig.SecretKey == "" { + bytes, err := json.Marshal(secret) + if err != nil { + return "", err + } + + return string(bytes), nil } + // Automatically parse through to embedded .data.data map if it's present + // and the correct type (e.g. for kv v2). var data map[string]interface{} - d, ok := resp.Data["data"] + d, ok := secret.Data["data"] if ok { data, ok = d.(map[string]interface{}) } if !ok { - data = resp.Data + data = secret.Data } - content, ok = data[secret.ObjectName].(string) + content, ok := data[secretConfig.SecretKey].(string) if !ok { - return "", fmt.Errorf("failed to get secret content %q as string", data[secret.ObjectName]) + return "", fmt.Errorf("failed to get secret content %q as string", secretConfig.SecretKey) } return content, nil @@ -187,7 +187,7 @@ func (p *provider) MountSecretsStoreObjectContent(ctx context.Context, cfg confi if err != nil { return nil, err } - versions[fmt.Sprintf("%s:%s:%s", secret.ObjectName, secret.ObjectPath, secret.ObjectVersion)] = secret.ObjectVersion + versions[fmt.Sprintf("%s:%s:%s", secret.ObjectName, secret.SecretPath, secret.Method)] = "0" err = writeSecret(p.logger, cfg.TargetPath, secret.ObjectName, content, cfg.FilePermission) if err != nil { return nil, err diff --git a/main.go b/main.go index cb01a2b..b4a3ba8 100644 --- a/main.go +++ b/main.go @@ -1,17 +1,20 @@ package main import ( + "context" "fmt" "net" "os" "os/signal" "syscall" + "time" "github.com/hashicorp/go-hclog" providerserver "github.com/hashicorp/secrets-store-csi-driver-provider-vault/internal/server" "github.com/hashicorp/secrets-store-csi-driver-provider-vault/internal/version" "github.com/spf13/pflag" "google.golang.org/grpc" + "google.golang.org/grpc/status" pb "sigs.k8s.io/secrets-store-csi-driver/provider/v1alpha1" ) @@ -50,7 +53,14 @@ func realMain(logger hclog.Logger) error { } logger.Info("Creating new gRPC server") - server := grpc.NewServer() + server := grpc.NewServer( + grpc.UnaryInterceptor(func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + startTime := time.Now() + resp, err := handler(ctx, req) + logger.Info("Finished unary gRPC call", "grpc.method", info.FullMethod, "grpc.time", time.Since(startTime), "grpc.code", status.Code(err), "err", err) + return resp, err + }), + ) c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGTERM, syscall.SIGINT) diff --git a/test/bats/configs/nginx-inline-volume.yaml b/test/bats/configs/nginx-inline-volume.yaml index 568d592..43c4e0a 100644 --- a/test/bats/configs/nginx-inline-volume.yaml +++ b/test/bats/configs/nginx-inline-volume.yaml @@ -1,7 +1,7 @@ kind: Pod apiVersion: v1 metadata: - name: nginx-secrets-store-inline + name: nginx-inline spec: containers: - image: nginx diff --git a/test/bats/configs/nginx-pki.yaml b/test/bats/configs/nginx-pki.yaml new file mode 100644 index 0000000..e3c0e00 --- /dev/null +++ b/test/bats/configs/nginx-pki.yaml @@ -0,0 +1,19 @@ +kind: Pod +apiVersion: v1 +metadata: + name: nginx-pki +spec: + containers: + - image: nginx + name: nginx + volumeMounts: + - name: secrets-store-inline + mountPath: "/mnt/secrets-store" + readOnly: true + volumes: + - name: secrets-store-inline + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: "vault-pki" diff --git a/test/bats/configs/vault-foo-secretproviderclass.yaml b/test/bats/configs/vault-foo-secretproviderclass.yaml index 611881f..4a6550a 100644 --- a/test/bats/configs/vault-foo-secretproviderclass.yaml +++ b/test/bats/configs/vault-foo-secretproviderclass.yaml @@ -10,12 +10,9 @@ spec: vaultAddress: http://vault:8200 vaultSkipTLSVerify: "true" objects: | - array: - - | - objectPath: "v1/secret/foo1" - objectName: "bar1" - objectVersion: "" - - | - objectPath: "v1/secret/foo2" - objectName: "bar2" - objectVersion: "" + - objectName: "secret-1" + secretPath: "secret/data/foo1" + secretKey: "bar1" + - objectName: "secret-2" + secretPath: "secret/data/foo2" + secretKey: "bar2" diff --git a/test/bats/configs/vault-foo-sync-multiple-secretproviderclass.yaml b/test/bats/configs/vault-foo-sync-multiple-secretproviderclass.yaml index 257d9b4..cb9f5e5 100644 --- a/test/bats/configs/vault-foo-sync-multiple-secretproviderclass.yaml +++ b/test/bats/configs/vault-foo-sync-multiple-secretproviderclass.yaml @@ -9,18 +9,16 @@ spec: - secretName: foosecret-1 type: Opaque data: - - objectName: bar1 + - objectName: secret-1 key: username parameters: roleName: "example-role" vaultAddress: http://vault:8200 vaultSkipTLSVerify: "true" objects: | - array: - - | - objectPath: "v1/secret/foo-sync1" - objectName: "bar1" - objectVersion: "" + - objectName: "secret-1" + secretPath: "/secret/data/foo-sync1" + secretKey: "bar1" --- apiVersion: secrets-store.csi.x-k8s.io/v1alpha1 kind: SecretProviderClass @@ -32,16 +30,14 @@ spec: - secretName: foosecret-2 type: Opaque data: - - objectName: bar2 + - objectName: secret-2 key: pwd parameters: roleName: "example-role" vaultAddress: http://vault:8200 vaultSkipTLSVerify: "true" objects: | - array: - - | - objectPath: "v1/secret/foo-sync2" - objectName: "bar2" - objectVersion: "" + - objectName: "secret-2" + secretPath: "secret/data/foo-sync2" + secretKey: "bar2" diff --git a/test/bats/configs/vault-foo-sync-secretproviderclass.yaml b/test/bats/configs/vault-foo-sync-secretproviderclass.yaml index 4882395..d182978 100644 --- a/test/bats/configs/vault-foo-sync-secretproviderclass.yaml +++ b/test/bats/configs/vault-foo-sync-secretproviderclass.yaml @@ -11,21 +11,18 @@ spec: labels: environment: "test" data: - - objectName: bar1 + - objectName: secret-1 key: pwd - - objectName: bar2 + - objectName: secret-2 key: username parameters: roleName: "example-role" vaultAddress: http://vault:8200 vaultSkipTLSVerify: "true" objects: | - array: - - | - objectPath: "v1/secret/foo-sync1" - objectName: "bar1" - objectVersion: "" - - | - objectPath: "v1/secret/foo-sync2" - objectName: "bar2" - objectVersion: "" + - objectName: "secret-1" + secretPath: "/v1/secret/data/foo-sync1" + secretKey: "bar1" + - objectName: "secret-2" + secretPath: "v1/secret/data/foo-sync2" + secretKey: "bar2" diff --git a/test/bats/configs/vault-pki-secretproviderclass.yaml b/test/bats/configs/vault-pki-secretproviderclass.yaml new file mode 100644 index 0000000..8638a80 --- /dev/null +++ b/test/bats/configs/vault-pki-secretproviderclass.yaml @@ -0,0 +1,18 @@ +apiVersion: secrets-store.csi.x-k8s.io/v1alpha1 +kind: SecretProviderClass +metadata: + name: vault-pki +spec: + provider: vault + parameters: + roleName: "example-role" + vaultAddress: http://vault:8200 + vaultSkipTLSVerify: "true" + # N.B. No secretKey means the whole JSON response will be written. + objects: | + - objectName: "certs" + secretPath: "pki/issue/example-dot-com" + secretArgs: + common_name: "test.example.com" + ttl: "24h" + method: "PUT" diff --git a/test/bats/configs/vault-policy-readonly.hcl b/test/bats/configs/vault-policy-readonly.hcl deleted file mode 100644 index 4651053..0000000 --- a/test/bats/configs/vault-policy-readonly.hcl +++ /dev/null @@ -1,7 +0,0 @@ -path "sys/mounts" { - capabilities = ["read"] -} - -path "secret/*" { - capabilities = ["read"] -} diff --git a/test/bats/configs/vault-policy.hcl b/test/bats/configs/vault-policy.hcl new file mode 100644 index 0000000..7c68f59 --- /dev/null +++ b/test/bats/configs/vault-policy.hcl @@ -0,0 +1,7 @@ +path "secret/*" { + capabilities = ["read"] +} + +path "pki/issue/example-dot-com" { + capabilities = ["update"] +} diff --git a/test/bats/provider.bats b/test/bats/provider.bats index 9ecebf9..d2b8089 100644 --- a/test/bats/provider.bats +++ b/test/bats/provider.bats @@ -5,7 +5,7 @@ load _helpers export SETUP_TEARDOWN_OUTFILE=/dev/stdout SUPPRESS_SETUP_TEARDOWN_LOGS=true # Comment this line out to show setup/teardown logs for failed tests. if [[ -n $SUPPRESS_SETUP_TEARDOWN_LOGS ]]; then - export SETUP_TEARDOWN_OUTFILE=/dev/null + export SETUP_TEARDOWN_OUTFILE=/dev/null fi #SKIP_TEARDOWN=true @@ -14,19 +14,30 @@ CONFIGS=test/bats/configs setup(){ { # Braces used to redirect all setup logs. # Configure Vault. + # Setup kubernetes auth engine. kubectl --namespace=csi exec vault-0 -- vault auth enable kubernetes kubectl --namespace=csi exec vault-0 -- sh -c 'vault write auth/kubernetes/config \ token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \ kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443" \ kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt' - cat $CONFIGS/vault-policy-readonly.hcl | kubectl --namespace=csi exec -i vault-0 -- vault policy write example-readonly - + cat $CONFIGS/vault-policy.hcl | kubectl --namespace=csi exec -i vault-0 -- vault policy write example-policy - kubectl --namespace=csi exec vault-0 -- vault write auth/kubernetes/role/example-role \ bound_service_account_names=secrets-store-csi-driver-provider-vault \ bound_service_account_namespaces=csi \ - policies=default,example-readonly \ + policies=default,example-policy \ ttl=20m - # Create secrets in Vault. + # Setup pki secrets engine. + kubectl --namespace=csi exec vault-0 -- vault secrets enable pki + kubectl --namespace=csi exec vault-0 -- vault write -field=certificate pki/root/generate/internal \ + common_name="example.com" + kubectl --namespace=csi exec vault-0 -- vault write pki/config/urls \ + issuing_certificates="http://127.0.0.1:8200/v1/pki/ca" + kubectl --namespace=csi exec vault-0 -- vault write pki/roles/example-dot-com \ + allowed_domains="example.com" \ + allow_subdomains=true + + # Create kv secrets in Vault. kubectl --namespace=csi exec vault-0 -- vault kv put secret/foo1 bar1=hello1 kubectl --namespace=csi exec vault-0 -- vault kv put secret/foo2 bar2=hello2 kubectl --namespace=csi exec vault-0 -- vault kv put secret/foo-sync1 bar1=hello-sync1 @@ -37,19 +48,21 @@ setup(){ kubectl --namespace=test apply -f $CONFIGS/vault-foo-secretproviderclass.yaml kubectl --namespace=test apply -f $CONFIGS/vault-foo-sync-secretproviderclass.yaml kubectl --namespace=test apply -f $CONFIGS/vault-foo-sync-multiple-secretproviderclass.yaml + kubectl --namespace=test apply -f $CONFIGS/vault-pki-secretproviderclass.yaml } > $SETUP_TEARDOWN_OUTFILE } teardown(){ if [[ -n $SKIP_TEARDOWN ]]; then - echo "Skipping teardown" - return + echo "Skipping teardown" + return fi { # Braces used to redirect all teardown logs. # Teardown Vault configuration. kubectl --namespace=csi exec vault-0 -- vault auth disable kubernetes - kubectl --namespace=csi exec vault-0 -- vault policy delete example-readonly + kubectl --namespace=csi exec vault-0 -- vault secrets disable pki + kubectl --namespace=csi exec vault-0 -- vault policy delete example-policy kubectl --namespace=csi exec vault-0 -- vault kv delete secret/foo1 kubectl --namespace=csi exec vault-0 -- vault kv delete secret/foo2 kubectl --namespace=csi exec vault-0 -- vault kv delete secret/foo-sync1 @@ -63,92 +76,103 @@ teardown(){ @test "1 Inline secrets-store-csi volume" { kubectl --namespace=test apply -f $CONFIGS/nginx-inline-volume.yaml - kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod/nginx-secrets-store-inline + kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod/nginx-inline - result=$(kubectl --namespace=test exec nginx-secrets-store-inline -- cat /mnt/secrets-store/bar1) + result=$(kubectl --namespace=test exec nginx-inline -- cat /mnt/secrets-store/secret-1) [[ "$result" == "hello1" ]] - result=$(kubectl --namespace=test exec nginx-secrets-store-inline -- cat /mnt/secrets-store/bar2) + result=$(kubectl --namespace=test exec nginx-inline -- cat /mnt/secrets-store/secret-2) [[ "$result" == "hello2" ]] } @test "2 Sync with kubernetes secrets" { - # Deploy some pods that should cause k8s secrets to be created. - kubectl --namespace=test apply -f $CONFIGS/nginx-env-var.yaml - kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod -l app=nginx - - POD=$(kubectl --namespace=test get pod -l app=nginx -o jsonpath="{.items[0].metadata.name}") - result=$(kubectl --namespace=test exec $POD -- cat /mnt/secrets-store/bar1) - [[ "$result" == "hello-sync1" ]] - - result=$(kubectl --namespace=test exec $POD -- cat /mnt/secrets-store/bar2) - [[ "$result" == "hello-sync2" ]] - - run kubectl get secret --namespace=test foosecret - [ "$status" -eq 0 ] - - result=$(kubectl --namespace=test get secret foosecret -o jsonpath="{.data.pwd}" | base64 -d) - [[ "$result" == "hello-sync1" ]] - - result=$(kubectl --namespace=test exec $POD -- printenv | grep SECRET_USERNAME | awk -F"=" '{ print $2 }' | tr -d '\r\n') - [[ "$result" == "hello-sync2" ]] - - result=$(kubectl --namespace=test get secret foosecret -o jsonpath="{.metadata.labels.environment}") - [[ "${result//$'\r'}" == "test" ]] - - result=$(kubectl --namespace=test get secret foosecret -o jsonpath="{.metadata.labels.secrets-store\.csi\.k8s\.io/managed}") - [[ "${result//$'\r'}" == "true" ]] - - # There isn't really an event we can wait for to ensure this has happened. - for i in {0..60}; do - result="$(kubectl --namespace=test get secret foosecret -o json | jq '.metadata.ownerReferences | length')" - if [[ "$result" -eq 2 ]]; then - break - fi - sleep 1 - done - [[ "$result" -eq 2 ]] - - # Wait for secret deletion in a background process. - kubectl --namespace=test wait --for=delete --timeout=60s secret foosecret & - WAIT_PID=$! - - # Trigger deletion implicitly by deleting only owners. - kubectl --namespace=test delete -f $CONFIGS/nginx-env-var.yaml - echo "Waiting for foosecret to get deleted" - wait $WAIT_PID - - # Ensure it actually got deleted. - run kubectl --namespace=test get secret foosecret - [ "$status" -eq 1 ] + # Deploy some pods that should cause k8s secrets to be created. + kubectl --namespace=test apply -f $CONFIGS/nginx-env-var.yaml + kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod -l app=nginx + + POD=$(kubectl --namespace=test get pod -l app=nginx -o jsonpath="{.items[0].metadata.name}") + result=$(kubectl --namespace=test exec $POD -- cat /mnt/secrets-store/secret-1) + [[ "$result" == "hello-sync1" ]] + + result=$(kubectl --namespace=test exec $POD -- cat /mnt/secrets-store/secret-2) + [[ "$result" == "hello-sync2" ]] + + run kubectl get secret --namespace=test foosecret + [ "$status" -eq 0 ] + + result=$(kubectl --namespace=test get secret foosecret -o jsonpath="{.data.pwd}" | base64 -d) + [[ "$result" == "hello-sync1" ]] + + result=$(kubectl --namespace=test exec $POD -- printenv | grep SECRET_USERNAME | awk -F"=" '{ print $2 }' | tr -d '\r\n') + [[ "$result" == "hello-sync2" ]] + + result=$(kubectl --namespace=test get secret foosecret -o jsonpath="{.metadata.labels.environment}") + [[ "${result//$'\r'}" == "test" ]] + + result=$(kubectl --namespace=test get secret foosecret -o jsonpath="{.metadata.labels.secrets-store\.csi\.k8s\.io/managed}") + [[ "${result//$'\r'}" == "true" ]] + + # There isn't really an event we can wait for to ensure this has happened. + for i in {0..60}; do + result="$(kubectl --namespace=test get secret foosecret -o json | jq '.metadata.ownerReferences | length')" + if [[ "$result" -eq 2 ]]; then + break + fi + sleep 1 + done + [[ "$result" -eq 2 ]] + + # Wait for secret deletion in a background process. + kubectl --namespace=test wait --for=delete --timeout=60s secret foosecret & + WAIT_PID=$! + + # Trigger deletion implicitly by deleting only owners. + kubectl --namespace=test delete -f $CONFIGS/nginx-env-var.yaml + echo "Waiting for foosecret to get deleted" + wait $WAIT_PID + + # Ensure it actually got deleted. + run kubectl --namespace=test get secret foosecret + [ "$status" -eq 1 ] } @test "3 SecretProviderClass in different namespace not usable" { - kubectl create namespace negative-test-ns - kubectl --namespace=negative-test-ns apply -f $CONFIGS/nginx-env-var.yaml - kubectl --namespace=negative-test-ns wait --for=condition=PodScheduled --timeout=60s pod -l app=nginx - POD=$(kubectl get pod -l app=nginx -n negative-test-ns -o jsonpath="{.items[0].metadata.name}") + kubectl create namespace negative-test-ns + kubectl --namespace=negative-test-ns apply -f $CONFIGS/nginx-env-var.yaml + kubectl --namespace=negative-test-ns wait --for=condition=PodScheduled --timeout=60s pod -l app=nginx + POD=$(kubectl get pod -l app=nginx -n negative-test-ns -o jsonpath="{.items[0].metadata.name}") - wait_for_success "kubectl describe pod $POD -n negative-test-ns | grep 'FailedMount.*failed to get secretproviderclass negative-test-ns/vault-foo-sync.*not found'" + wait_for_success "kubectl describe pod $POD -n negative-test-ns | grep 'FailedMount.*failed to get secretproviderclass negative-test-ns/vault-foo-sync.*not found'" } @test "4 Pod with multiple SecretProviderClasses" { - POD=nginx-multiple-volumes - kubectl --namespace=test apply -f $CONFIGS/nginx-multiple-volumes.yaml - kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod $POD - - result=$(kubectl --namespace=test exec $POD -- cat /mnt/secrets-store-1/bar1) - [[ "$result" == "hello-sync1" ]] - result=$(kubectl --namespace=test exec $POD -- cat /mnt/secrets-store-2/bar2) - [[ "$result" == "hello-sync2" ]] - - result=$(kubectl --namespace=test get secret foosecret-1 -o jsonpath="{.data.username}" | base64 -d) - [[ "$result" == "hello-sync1" ]] - result=$(kubectl --namespace=test get secret foosecret-2 -o jsonpath="{.data.pwd}" | base64 -d) - [[ "$result" == "hello-sync2" ]] - - result=$(kubectl --namespace=test exec $POD -- printenv | grep SECRET_1_USERNAME | awk -F"=" '{ print $2 }' | tr -d '\r\n') - [[ "$result" == "hello-sync1" ]] - result=$(kubectl --namespace=test exec $POD -- printenv | grep SECRET_2_PWD | awk -F"=" '{ print $2 }' | tr -d '\r\n') - [[ "$result" == "hello-sync2" ]] + POD=nginx-multiple-volumes + kubectl --namespace=test apply -f $CONFIGS/nginx-multiple-volumes.yaml + kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod $POD + + result=$(kubectl --namespace=test exec $POD -- cat /mnt/secrets-store-1/secret-1) + [[ "$result" == "hello-sync1" ]] + result=$(kubectl --namespace=test exec $POD -- cat /mnt/secrets-store-2/secret-2) + [[ "$result" == "hello-sync2" ]] + + result=$(kubectl --namespace=test get secret foosecret-1 -o jsonpath="{.data.username}" | base64 -d) + [[ "$result" == "hello-sync1" ]] + result=$(kubectl --namespace=test get secret foosecret-2 -o jsonpath="{.data.pwd}" | base64 -d) + [[ "$result" == "hello-sync2" ]] + + result=$(kubectl --namespace=test exec $POD -- printenv | grep SECRET_1_USERNAME | awk -F"=" '{ print $2 }' | tr -d '\r\n') + [[ "$result" == "hello-sync1" ]] + result=$(kubectl --namespace=test exec $POD -- printenv | grep SECRET_2_PWD | awk -F"=" '{ print $2 }' | tr -d '\r\n') + [[ "$result" == "hello-sync2" ]] +} + +@test "5 SecretProviderClass with query parameters and PUT method" { + kubectl --namespace=test apply -f $CONFIGS/nginx-pki.yaml + kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod/nginx-pki + + result=$(kubectl --namespace=test exec nginx-pki -- cat /mnt/secrets-store/certs) + [[ "$result" != "" ]] + # Ensure we have some valid x509 certificates. + echo "$result" | jq -r '.data.certificate' | openssl x509 -noout + echo "$result" | jq -r '.data.issuing_ca' | openssl x509 -noout } From 726ed0d1f7505924af3be6aebe492afe0a0b9d93 Mon Sep 17 00:00:00 2001 From: Tom Proctor Date: Tue, 9 Feb 2021 11:47:02 +0000 Subject: [PATCH 2/5] Add dynamic credentials test --- Makefile | 4 +- test/bats/configs/nginx-dynamic-creds.yaml | 19 +++++++++ test/bats/configs/nginx-env-var.yaml | 2 +- test/bats/configs/nginx-inline-volume.yaml | 2 +- test/bats/configs/nginx-multiple-volumes.yaml | 2 +- test/bats/configs/nginx-pki.yaml | 2 +- test/bats/configs/postgres-client.yaml | 16 +++++++ .../configs/postgres-creation-statements.sql | 2 + test/bats/configs/postgres.yaml | 28 +++++++++++++ ...ult-dynamic-creds-secretproviderclass.yaml | 19 +++++++++ test/bats/configs/vault-policy.hcl | 4 ++ test/bats/provider.bats | 42 ++++++++++++++++++- 12 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 test/bats/configs/nginx-dynamic-creds.yaml create mode 100644 test/bats/configs/postgres-client.yaml create mode 100644 test/bats/configs/postgres-creation-statements.sql create mode 100644 test/bats/configs/postgres.yaml create mode 100644 test/bats/configs/vault-dynamic-creds-secretproviderclass.yaml diff --git a/Makefile b/Makefile index 9597764..318b550 100644 --- a/Makefile +++ b/Makefile @@ -69,12 +69,12 @@ e2e-setup: e2e-container --namespace=csi \ --set linux.image.pullPolicy="IfNotPresent" \ --set grpcSupportedProviders="azure;gcp;vault" - helm install vault https://github.com/hashicorp/vault-helm/archive/v0.9.0.tar.gz \ + helm install vault https://github.com/hashicorp/vault-helm/archive/v0.9.1.tar.gz \ --wait --timeout=5m \ --namespace=csi \ --set injector.enabled=false \ --set server.dev.enabled=true \ - --set 'server.extraEnvironmentVars.VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200' + --set server.image.repository=hashicorp.jfrog.io/docker/vault kubectl apply --namespace=csi -f test/bats/configs/secrets-store-csi-driver-provider-vault.yaml kubectl wait --namespace=csi --for=condition=Ready --timeout=5m pod -l app.kubernetes.io/name=vault kubectl wait --namespace=csi --for=condition=Ready --timeout=5m pod -l app=secrets-store-csi-driver-provider-vault diff --git a/test/bats/configs/nginx-dynamic-creds.yaml b/test/bats/configs/nginx-dynamic-creds.yaml new file mode 100644 index 0000000..2fc895e --- /dev/null +++ b/test/bats/configs/nginx-dynamic-creds.yaml @@ -0,0 +1,19 @@ +kind: Pod +apiVersion: v1 +metadata: + name: nginx-dynamic-creds +spec: + containers: + - image: hashicorp.jfrog.io/docker/nginx + name: nginx + volumeMounts: + - name: secrets-store-inline + mountPath: "/mnt/secrets-store" + readOnly: true + volumes: + - name: secrets-store-inline + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: "vault-dynamic-creds" diff --git a/test/bats/configs/nginx-env-var.yaml b/test/bats/configs/nginx-env-var.yaml index b138daf..37e5f06 100644 --- a/test/bats/configs/nginx-env-var.yaml +++ b/test/bats/configs/nginx-env-var.yaml @@ -16,7 +16,7 @@ spec: spec: terminationGracePeriodSeconds: 0 containers: - - image: nginx + - image: hashicorp.jfrog.io/docker/nginx name: nginx env: - name: SECRET_USERNAME diff --git a/test/bats/configs/nginx-inline-volume.yaml b/test/bats/configs/nginx-inline-volume.yaml index 43c4e0a..955241d 100644 --- a/test/bats/configs/nginx-inline-volume.yaml +++ b/test/bats/configs/nginx-inline-volume.yaml @@ -4,7 +4,7 @@ metadata: name: nginx-inline spec: containers: - - image: nginx + - image: hashicorp.jfrog.io/docker/nginx name: nginx volumeMounts: - name: secrets-store-inline diff --git a/test/bats/configs/nginx-multiple-volumes.yaml b/test/bats/configs/nginx-multiple-volumes.yaml index c2e09ae..04c1dcc 100644 --- a/test/bats/configs/nginx-multiple-volumes.yaml +++ b/test/bats/configs/nginx-multiple-volumes.yaml @@ -5,7 +5,7 @@ metadata: spec: terminationGracePeriodSeconds: 0 containers: - - image: nginx + - image: hashicorp.jfrog.io/docker/nginx name: nginx volumeMounts: - name: secrets-store-1 diff --git a/test/bats/configs/nginx-pki.yaml b/test/bats/configs/nginx-pki.yaml index e3c0e00..d1167aa 100644 --- a/test/bats/configs/nginx-pki.yaml +++ b/test/bats/configs/nginx-pki.yaml @@ -4,7 +4,7 @@ metadata: name: nginx-pki spec: containers: - - image: nginx + - image: hashicorp.jfrog.io/docker/nginx name: nginx volumeMounts: - name: secrets-store-inline diff --git a/test/bats/configs/postgres-client.yaml b/test/bats/configs/postgres-client.yaml new file mode 100644 index 0000000..647f3b0 --- /dev/null +++ b/test/bats/configs/postgres-client.yaml @@ -0,0 +1,16 @@ +kind: Pod +apiVersion: v1 +metadata: + name: postgres-client +spec: + containers: + - image: hashicorp.jfrog.io/docker/postgres:13-alpine + name: postgres + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: db + envFrom: + - secretRef: + name: postgres-root diff --git a/test/bats/configs/postgres-creation-statements.sql b/test/bats/configs/postgres-creation-statements.sql new file mode 100644 index 0000000..454f7e0 --- /dev/null +++ b/test/bats/configs/postgres-creation-statements.sql @@ -0,0 +1,2 @@ +CREATE ROLE "{{name}}" WITH LOGIN ENCRYPTED PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO "{{name}}"; \ No newline at end of file diff --git a/test/bats/configs/postgres.yaml b/test/bats/configs/postgres.yaml new file mode 100644 index 0000000..e1acd05 --- /dev/null +++ b/test/bats/configs/postgres.yaml @@ -0,0 +1,28 @@ +kind: Pod +apiVersion: v1 +metadata: + name: postgres +spec: + containers: + - image: hashicorp.jfrog.io/docker/postgres:13-alpine + name: postgres + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: db + envFrom: + - secretRef: + name: postgres-root + readinessProbe: + exec: + command: + - pg_isready + - -ddb + - -h127.0.0.1 + - -p5432 + initialDelaySeconds: 1 + periodSeconds: 1 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 1 diff --git a/test/bats/configs/vault-dynamic-creds-secretproviderclass.yaml b/test/bats/configs/vault-dynamic-creds-secretproviderclass.yaml new file mode 100644 index 0000000..6b04d24 --- /dev/null +++ b/test/bats/configs/vault-dynamic-creds-secretproviderclass.yaml @@ -0,0 +1,19 @@ +apiVersion: secrets-store.csi.x-k8s.io/v1alpha1 +kind: SecretProviderClass +metadata: + name: vault-dynamic-creds +spec: + provider: vault + parameters: + roleName: "example-role" + vaultAddress: http://vault:8200 + vaultSkipTLSVerify: "true" + # Referring to the same dynamic creds twice in one secret provider class should + # result in only one read to Vault, to ensure the username and password match. + objects: | + - objectName: "dbUsername" + secretPath: "database/creds/test-role" + secretKey: "username" + - objectName: "dbPassword" + secretPath: "database/creds/test-role" + secretKey: "password" diff --git a/test/bats/configs/vault-policy.hcl b/test/bats/configs/vault-policy.hcl index 7c68f59..f54711a 100644 --- a/test/bats/configs/vault-policy.hcl +++ b/test/bats/configs/vault-policy.hcl @@ -2,6 +2,10 @@ path "secret/*" { capabilities = ["read"] } +path "database/creds/test-role" { + capabilities = ["read"] +} + path "pki/issue/example-dot-com" { capabilities = ["update"] } diff --git a/test/bats/provider.bats b/test/bats/provider.bats index d2b8089..0305773 100644 --- a/test/bats/provider.bats +++ b/test/bats/provider.bats @@ -45,6 +45,7 @@ setup(){ # Create shared k8s resources. kubectl create namespace test + kubectl --namespace=test apply -f $CONFIGS/vault-dynamic-creds-secretproviderclass.yaml kubectl --namespace=test apply -f $CONFIGS/vault-foo-secretproviderclass.yaml kubectl --namespace=test apply -f $CONFIGS/vault-foo-sync-secretproviderclass.yaml kubectl --namespace=test apply -f $CONFIGS/vault-foo-sync-multiple-secretproviderclass.yaml @@ -62,6 +63,7 @@ teardown(){ # Teardown Vault configuration. kubectl --namespace=csi exec vault-0 -- vault auth disable kubernetes kubectl --namespace=csi exec vault-0 -- vault secrets disable pki + kubectl --namespace=csi exec vault-0 -- vault secrets disable database kubectl --namespace=csi exec vault-0 -- vault policy delete example-policy kubectl --namespace=csi exec vault-0 -- vault kv delete secret/foo1 kubectl --namespace=csi exec vault-0 -- vault kv delete secret/foo2 @@ -168,11 +170,49 @@ teardown(){ @test "5 SecretProviderClass with query parameters and PUT method" { kubectl --namespace=test apply -f $CONFIGS/nginx-pki.yaml - kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod/nginx-pki + kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod nginx-pki result=$(kubectl --namespace=test exec nginx-pki -- cat /mnt/secrets-store/certs) [[ "$result" != "" ]] + # Ensure we have some valid x509 certificates. echo "$result" | jq -r '.data.certificate' | openssl x509 -noout echo "$result" | jq -r '.data.issuing_ca' | openssl x509 -noout + echo "$result" | jq -r '.data.certificate' | openssl x509 -noout -text | grep "test.example.com" +} + +@test "6 Dynamic secrets engine, endpoint is called only once per SecretProviderClass" { + # Setup postgres + POSTGRES_PASSWORD=$(openssl rand -base64 30) + kubectl --namespace=test create secret generic postgres-root \ + --from-literal=POSTGRES_USER="root" \ + --from-literal=POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" + kubectl --namespace=test apply -f $CONFIGS/postgres.yaml + kubectl wait --namespace=test --for=condition=Ready --timeout=5m pod postgres + POSTGRES_POD_IP=$(kubectl --namespace=test get pod postgres -o json | jq -r '.status.podIP') + + # Configure vault to manage postgres + kubectl --namespace=csi exec vault-0 -- vault secrets enable database + kubectl --namespace=csi exec vault-0 -- vault write database/config/postgres \ + plugin_name="postgresql-database-plugin" \ + allowed_roles="*" \ + connection_url="postgres://{{username}}:{{password}}@${POSTGRES_POD_IP}:5432/db?sslmode=disable" \ + username="root" \ + password="${POSTGRES_PASSWORD}" + cat $CONFIGS/postgres-creation-statements.sql | kubectl --namespace=csi exec -i vault-0 -- vault write database/roles/test-role \ + db_name="postgres" \ + default_ttl="1h" max_ttl="24h" \ + creation_statements=- + + # Now deploy a pod that will generate some dynamic credentials. + kubectl --namespace=test apply -f $CONFIGS/nginx-dynamic-creds.yaml + kubectl --namespace=test wait --for=condition=Ready --timeout=60s pod nginx-dynamic-creds + + # Read the creds out of the pod and verify they work for a query. + DYNAMIC_USERNAME=$(kubectl --namespace=test exec nginx-dynamic-creds -- cat /mnt/secrets-store/dbUsername) + DYNAMIC_PASSWORD=$(kubectl --namespace=test exec nginx-dynamic-creds -- cat /mnt/secrets-store/dbPassword) + result=$(kubectl --namespace=test exec postgres -- psql postgres://${DYNAMIC_USERNAME}:${DYNAMIC_PASSWORD}@127.0.0.1:5432/db --command="SELECT usename FROM pg_catalog.pg_user" --csv | sed -n '3 p') + + [[ "$result" != "" ]] + [[ "$result" == "${DYNAMIC_USERNAME}" ]] } From 7f604f72a97abe0638f2e6543226cab318c608fe Mon Sep 17 00:00:00 2001 From: Tom Proctor Date: Tue, 9 Feb 2021 13:48:36 +0000 Subject: [PATCH 3/5] Add provider unit tests --- internal/provider/provider.go | 38 ++++--- internal/provider/provider_test.go | 168 +++++++++++++++++++++++++++-- 2 files changed, 179 insertions(+), 27 deletions(-) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 800a5d7..4a019a4 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -112,6 +112,26 @@ func generateRequest(client *api.Client, secret config.Secret) (*api.Request, er return req, nil } +func keyFromData(rootData map[string]interface{}, secretKey string) (string, error) { + // Automatically parse through to embedded .data.data map if it's present + // and the correct type (e.g. for kv v2). + var data map[string]interface{} + d, ok := rootData["data"] + if ok { + data, ok = d.(map[string]interface{}) + } + if !ok { + data = rootData + } + + content, ok := data[secretKey].(string) + if !ok { + return "", fmt.Errorf("failed to get secret content %q as string", secretKey) + } + + return content, nil +} + func (p *provider) getSecret(ctx context.Context, client *api.Client, secretConfig config.Secret) (string, error) { var secret *api.Secret var cached bool @@ -143,23 +163,7 @@ func (p *provider) getSecret(ctx context.Context, client *api.Client, secretConf return string(bytes), nil } - // Automatically parse through to embedded .data.data map if it's present - // and the correct type (e.g. for kv v2). - var data map[string]interface{} - d, ok := secret.Data["data"] - if ok { - data, ok = d.(map[string]interface{}) - } - if !ok { - data = secret.Data - } - - content, ok := data[secretConfig.SecretKey].(string) - if !ok { - return "", fmt.Errorf("failed to get secret content %q as string", secretConfig.SecretKey) - } - - return content, nil + return keyFromData(secret.Data, secretConfig.SecretKey) } // MountSecretsStoreObjectContent mounts content of the vault object to target path diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index f7a98d0..47653aa 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/hashicorp/go-hclog" + "github.com/hashicorp/secrets-store-csi-driver-provider-vault/internal/config" + "github.com/hashicorp/vault/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -84,25 +86,171 @@ func TestWriteSecret(t *testing.T) { }, } { root, err := ioutil.TempDir(os.TempDir(), "") - require.NoError(t, err) + require.NoError(t, err, tc.name) defer func() { - err := os.RemoveAll(root) - if err != nil { - t.Log("Error cleaning up", err) - } + require.NoError(t, os.RemoveAll(root), tc.name) }() err = writeSecret(l, root, tc.file, "", tc.permission) if tc.invalid { - require.Error(t, err) - assert.Contains(t, err.Error(), "must not contain any .. segments") + require.Error(t, err, tc.name) + assert.Contains(t, err.Error(), "must not contain any .. segments", tc.name) continue } - require.NoError(t, err) + require.NoError(t, err, tc.name) rootedPath := filepath.Join(root, tc.file) info, err := os.Stat(rootedPath) - require.NoError(t, err) - assert.Equal(t, tc.permission, info.Mode()) + require.NoError(t, err, tc.name) + assert.Equal(t, tc.permission, info.Mode(), tc.name) + } +} + +func TestEnsureV1Prefix(t *testing.T) { + for _, tc := range []struct { + name string + input string + expected string + }{ + {"no prefix", "secret/foo", "/v1/secret/foo"}, + {"leading slash", "/secret/foo", "/v1/secret/foo"}, + {"leading v1", "v1/secret/foo", "/v1/secret/foo"}, + {"leading /v1/", "/v1/secret/foo", "/v1/secret/foo"}, + // These will mostly be invalid paths, but testing reasonable behaviour. + {"empty string", "", "/v1/"}, + {"just /v1/", "/v1/", "/v1/"}, + {"leading 1", "1/secret/foo", "/v1/1/secret/foo"}, + {"2* /v1/", "/v1/v1/", "/v1/v1/"}, + {"v2", "/v2/secret/foo", "/v1/v2/secret/foo"}, + } { + assert.Equal(t, tc.expected, ensureV1Prefix(tc.input), tc.name) + } +} + +func TestGenerateRequest(t *testing.T) { + type expected struct { + method string + path string + params string + body string + } + client, err := api.NewClient(nil) + require.NoError(t, err) + for _, tc := range []struct { + name string + secret config.Secret + expected expected + }{ + { + name: "base case", + secret: config.Secret{ + SecretPath: "secret/foo", + }, + expected: expected{"GET", "/v1/secret/foo", "", ""}, + }, + { + name: "zero-length query string", + secret: config.Secret{ + SecretPath: "secret/foo?", + }, + expected: expected{"GET", "/v1/secret/foo", "", ""}, + }, + { + name: "query string", + secret: config.Secret{ + SecretPath: "secret/foo?bar=true&baz=maybe&zap=0", + }, + expected: expected{"GET", "/v1/secret/foo", "bar=true&baz=maybe&zap=0", ""}, + }, + { + name: "method specified", + secret: config.Secret{ + SecretPath: "secret/foo", + Method: "PUT", + }, + expected: expected{"PUT", "/v1/secret/foo", "", ""}, + }, + { + name: "body specified", + secret: config.Secret{ + SecretPath: "secret/foo", + Method: "POST", + SecretArgs: map[string]interface{}{ + "bar": true, + "baz": 10, + "zap": "a string", + }, + }, + expected: expected{"POST", "/v1/secret/foo", "", `{"bar":true,"baz":10,"zap":"a string"}`}, + }, + } { + req, err := generateRequest(client, tc.secret) + require.NoError(t, err, tc.name) + assert.Equal(t, req.Method, tc.expected.method, tc.name) + assert.Equal(t, req.URL.Path, tc.expected.path, tc.name) + assert.Equal(t, req.Params.Encode(), tc.expected.params, tc.name) + assert.Equal(t, tc.expected.body, string(req.BodyBytes), tc.name) + } +} + +func TestKeyFromData(t *testing.T) { + data := map[string]interface{}{ + "foo": "bar", + "baz": "zap", + } + dataWithDataString := map[string]interface{}{ + "foo": "bar", + "baz": "zap", + "data": "hello", + } + dataWithDataField := map[string]interface{}{ + "data": map[string]interface{}{ + "foo": "bar", + "baz": "zap", + }, + } + dataWithNonStringValue := map[string]interface{}{ + "foo": 10, + "baz": "zap", + } + for _, tc := range []struct { + name string + key string + data map[string]interface{} + expected string + errExpected bool + }{ + { + name: "base case", + key: "foo", + data: data, + expected: "bar", + }, + { + name: "string data", + key: "data", + data: dataWithDataString, + expected: "hello", + }, + { + name: "kv v2 embedded data field", + key: "foo", + data: dataWithDataField, + expected: "bar", + }, + { + name: "kv v2 embedded data field", + key: "foo", + data: dataWithNonStringValue, + errExpected: true, + }, + } { + content, err := keyFromData(tc.data, tc.key) + if tc.errExpected { + require.Error(t, err, tc.name) + } else { + require.NoError(t, err, tc.name) + assert.Equal(t, tc.expected, content) + } } } From accb276fb4b98f4572f9c78a49a4a223b8969280 Mon Sep 17 00:00:00 2001 From: Tom Proctor Date: Tue, 9 Feb 2021 13:49:49 +0000 Subject: [PATCH 4/5] Add secretKey coverage to config unit tests --- internal/config/config_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 776ca42..7110ff7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,6 +23,7 @@ spec: objects: | - objectName: "test-certs" secretPath: "pki/issue/example-dot-com" + secretKey: "certificate" secretArgs: common_name: "test.example.com" ip_sans: "127.0.0.1" @@ -60,6 +61,7 @@ func TestParseParametersFromYaml(t *testing.T) { { ObjectName: "test-certs", SecretPath: "pki/issue/example-dot-com", + SecretKey: "certificate", SecretArgs: map[string]interface{}{ "common_name": "test.example.com", "ip_sans": "127.0.0.1", From 39b4cc5208cba7c018856234ddd4f2adff0599a2 Mon Sep 17 00:00:00 2001 From: Tom Proctor Date: Tue, 9 Feb 2021 15:34:18 +0000 Subject: [PATCH 5/5] Prettify testdata --- .../config/testdata/example-parameters-string.txt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/config/testdata/example-parameters-string.txt b/internal/config/testdata/example-parameters-string.txt index 21bbc55..aee7a1d 100644 --- a/internal/config/testdata/example-parameters-string.txt +++ b/internal/config/testdata/example-parameters-string.txt @@ -1 +1,10 @@ -{"csi.storage.k8s.io/pod.name":"nginx-secrets-store-inline","csi.storage.k8s.io/pod.namespace":"test","csi.storage.k8s.io/pod.uid":"9aeb260f-d64a-426c-9872-95b6bab37e00","csi.storage.k8s.io/serviceAccount.name":"default","objects":"- secretPath: \"v1/secret/foo1\"\n objectName: \"bar1\"\n method: \"GET\"\n- secretPath: \"v1/secret/foo2\"\n objectName: \"bar2\"","roleName":"example-role","vaultAddress":"http://vault:8200","vaultSkipTLSVerify":"true"} \ No newline at end of file +{ + "csi.storage.k8s.io/pod.name":"nginx-secrets-store-inline", + "csi.storage.k8s.io/pod.namespace":"test", + "csi.storage.k8s.io/pod.uid":"9aeb260f-d64a-426c-9872-95b6bab37e00", + "csi.storage.k8s.io/serviceAccount.name":"default", + "objects":"- secretPath: \"v1/secret/foo1\"\n objectName: \"bar1\"\n method: \"GET\"\n- secretPath: \"v1/secret/foo2\"\n objectName: \"bar2\"", + "roleName":"example-role", + "vaultAddress":"http://vault:8200", + "vaultSkipTLSVerify":"true" +} \ No newline at end of file