Skip to content

Commit

Permalink
add testing on secret mutation
Browse files Browse the repository at this point in the history
  • Loading branch information
pablo-ruth committed Oct 25, 2019
1 parent 7ecc070 commit 5ed1405
Show file tree
Hide file tree
Showing 6 changed files with 298 additions and 125 deletions.
61 changes: 61 additions & 0 deletions api/admission.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package api

import (
"encoding/json"
"net/http"

"k8s.io/api/admission/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// sendAdmissionReviewError create an admission review with an
// error set as response message and write it to http.ResponseWriter
func (s *Server) sendAdmissionReviewError(w http.ResponseWriter, err error) {

// Create empty AdmissionReview with an error
// set as response message
ar := v1beta1.AdmissionReview{
Response: &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
},
}

// Marshal admission review with response
arResp, err := json.Marshal(ar)
if err != nil {
s.Logger.Errorf("failed to marshal response: %s", err)
http.Error(w, http.StatusText(500), 500)
return
}

// Set http code to server error
w.WriteHeader(500)

// Send admission review back to kubernetes
_, err = w.Write(arResp)
if err != nil {
s.Logger.Errorf("failed to write response: %s", err)
return
}
}

// sendAdmissionReview masharl and write to http.ResponseWriter an admission review
func (s *Server) sendAdmissionReview(w http.ResponseWriter, ar v1beta1.AdmissionReview) {

// Marshal Admission Rreview
resp, err := json.Marshal(ar)
if err != nil {
s.Logger.Errorf("failed to marshal response: %s", err)
http.Error(w, http.StatusText(500), 500)
return
}

// Send admission review back to kubernetes
_, err = w.Write(resp)
if err != nil {
s.Logger.Errorf("failed to write admission review response: %s", err)
return
}
}
89 changes: 89 additions & 0 deletions api/mutate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package api

import (
"bytes"
"encoding/base64"
"fmt"
"regexp"
"strings"
"text/template"

"github.com/Masterminds/sprig/v3"
corev1 "k8s.io/api/core/v1"
)

// mutateSecretData iterates over all secret keys and replace values if necessary
// by secret values stored in Vault
func (s *Server) mutateSecretData(secret corev1.Secret) ([]patchOperation, error) {

// Patchs list
patch := []patchOperation{}

// Check each data key for secret to mutate
for k8sSecretKey, k8sSecretValue := range secret.Data {

// Ignore if no "vault:" prefix on secret value
if !strings.HasPrefix(string(k8sSecretValue), "vault:") {
s.Logger.Debugf("value of key '%s' doesn't start by 'vault:', ignoring", k8sSecretKey)
continue
}

// Extract Vault secret path and key
re := regexp.MustCompile(`^vault:(.*)#(.*)$`)
sub := re.FindStringSubmatch(string(k8sSecretValue))
if len(sub) != 3 {
return []patchOperation{}, fmt.Errorf("vault placeholder '%s' doesn't match regex '^vault:(.*)#(.*)$'", string(k8sSecretValue))
}
vaultRawSecretPath := sub[1]
vaultSecretKey := sub[2]

// Check that required fields are not empty
for key, val := range map[string]string{"name": secret.Name, "namespace": secret.Namespace} {
if val == "" {
return []patchOperation{}, fmt.Errorf("secret field %s cannot be empty", key)
}
}

// Template vault secret path
pathTemplate, err := template.New("path").Funcs(sprig.TxtFuncMap()).Parse(s.VaultPattern)
if err != nil {
return []patchOperation{}, fmt.Errorf("failed to parse template vault path pattern: %s", err)
}

var vaultSecretPath bytes.Buffer
err = pathTemplate.Execute(&vaultSecretPath, struct {
Name string
Namespace string
Secret string
}{
Name: secret.Name, // Kubernetes secret name
Namespace: secret.Namespace, // Kubernetes secret namespace
Secret: vaultRawSecretPath, // Kubernetes secret parsed value
})
if err != nil {
return []patchOperation{}, fmt.Errorf("failed to execute template function on vault path pattern: %s", err)
}

// Read secret from Vault
vaultSecretValue, err := s.Vault.Read(vaultSecretPath.String(), vaultSecretKey)
if err != nil {
return []patchOperation{}, fmt.Errorf("failed to read secret '%s' in vault: %s", vaultSecretPath.String(), err)
}

// Create patch to mutate secret value with vault value
patch = append(
patch,
patchOperation{
Op: "replace",
Path: fmt.Sprintf("/data/%s", k8sSecretKey),
Value: base64.StdEncoding.EncodeToString([]byte(vaultSecretValue)),
},
)

s.Logger.Infof(
"kubernetes secret '%s' key '%s' in namespace '%s', replaced by vault secret '%s' key '%s'",
secret.Name, k8sSecretKey, secret.Namespace, vaultSecretPath.String(), vaultSecretKey)
}

return patch, nil
}
135 changes: 135 additions & 0 deletions api/mutate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package api

import (
"encoding/json"
"errors"
"testing"

"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
)

func TestServer_mutateSecretData(t *testing.T) {

var mutateTests = []struct {
vaultClient VaultClient
vaultPattern string
secret string
patch []patchOperation
errorString string
}{
{
// Test secret that doesn't need to be mutated
fakeVaultClient{},
"secret/data/{{.Secret}}",
`{"metadata":{"name":"test-secret","namespace":"test-namespace","creationTimestamp":null,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"YmFy\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"test-secret\",\"namespace\":\"test-namespace\"},\"type\":\"Opaque\"}\n"}},"data":{"foo":"YmFy"},"type":"Opaque"}`,
[]patchOperation{},
"",
},
{
// Test secret with invalid path pattern
fakeVaultClient{},
"secret/data/{{.Secret}}",
`{"metadata":{"name":"test-secret","namespace":"test-namespace","creationTimestamp":null,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"dmF1bHQ6YmFy\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"test-secret\",\"namespace\":\"test-namespace\"},\"type\":\"Opaque\"}\n"}},"data":{"foo":"dmF1bHQ6YmFy"},"type":"Opaque"}`,
[]patchOperation{},
"vault placeholder 'vault:bar' doesn't match regex '^vault:(.*)#(.*)$'",
},
{
// Test secret with empty name
fakeVaultClient{},
"secret/data/{{.Secret}}",
`{"metadata":{"name":"","namespace":"test-namespace","creationTimestamp":null,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"dmF1bHQ6Zm9vI2Jhcg==\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"\",\"namespace\":\"test-namespace\"},\"type\":\"Opaque\"}\n"}},"data":{"foo":"dmF1bHQ6Zm9vI2Jhcg=="},"type":"Opaque"}`,
[]patchOperation{},
"secret field name cannot be empty",
},
{
// Test secret with empty namespace
fakeVaultClient{},
"secret/data/{{.Secret}}",
`{"metadata":{"name":"test-secret","namespace":"","creationTimestamp":null,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"dmF1bHQ6Zm9vI2Jhcg==\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"test-secret\",\"namespace\":\"\"},\"type\":\"Opaque\"}\n"}},"data":{"foo":"dmF1bHQ6Zm9vI2Jhcg=="},"type":"Opaque"}`,
[]patchOperation{},
"secret field namespace cannot be empty",
},
{
// Test invalid vault pattern
fakeVaultClient{},
"secret/data/{{.Secret}}/{{.InvalidKey}}",
`{"metadata":{"name":"test-secret","namespace":"test-namespace","creationTimestamp":null,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"dmF1bHQ6Zm9vI2Jhcg==\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"test-secret\",\"namespace\":\"test-namespace\"},\"type\":\"Opaque\"}\n"}},"data":{"foo":"dmF1bHQ6Zm9vI2Jhcg=="},"type":"Opaque"}`,
[]patchOperation{},
"failed to execute template function on vault path pattern: template: path:1:26: executing \"path\" at <.InvalidKey>: can't evaluate field InvalidKey in type struct { Name string; Namespace string; Secret string }",
},
{
// Test secret that doesn't exists in vault
fakeVaultClient{"error"},
"secret/data/{{.Secret}}",
`{"metadata":{"name":"test-secret","namespace":"test-namespace","creationTimestamp":null,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"dmF1bHQ6Zm9vI2Jhcg==\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"test-secret\",\"namespace\":\"test-namespace\"},\"type\":\"Opaque\"}\n"}},"data":{"foo":"dmF1bHQ6Zm9vI2Jhcg=="},"type":"Opaque"}`,
[]patchOperation{},
"failed to read secret 'secret/data/foo' in vault: failed to read key in vault",
},
{
// Test valid secret defined in vault
fakeVaultClient{"bar"},
"secret/data/{{.Secret}}",
`{"metadata":{"name":"test-secret","namespace":"test-namespace","creationTimestamp":null,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"dmF1bHQ6Zm9vI2Jhcg==\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"test-secret\",\"namespace\":\"test-namespace\"},\"type\":\"Opaque\"}\n"}},"data":{"foo":"dmF1bHQ6Zm9vI2Jhcg=="},"type":"Opaque"}`,
[]patchOperation{patchOperation{Op: "replace", Path: "/data/foo", Value: "YmFy"}},
"",
},
{
// Test valid secret defined in vault + one simple secret
fakeVaultClient{"bar"},
"secret/data/{{.Secret}}",
`{"metadata":{"name":"test-secret","namespace":"test-namespace","creationTimestamp":null,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"dmF1bHQ6Zm9vI2Jhcg==\",\"simple\":\"test\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"test-secret\",\"namespace\":\"test-namespace\"},\"type\":\"Opaque\"}\n"}},"data":{"foo":"dmF1bHQ6Zm9vI2Jhcg==","simple":"test"},"type":"Opaque"}`,
[]patchOperation{patchOperation{Op: "replace", Path: "/data/foo", Value: "YmFy"}},
"",
},
{
// Test multi valid secrets defined in vault + one simple secret
fakeVaultClient{"bar"},
"secret/data/{{.Secret}}",
`{"metadata":{"name":"test-secret","namespace":"test-namespace","creationTimestamp":null,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"dmF1bHQ6Zm9vI2Jhcg==\",\"simple\":\"test\",\"foo2\":\"dmF1bHQ6Zm9vI2JhcjI=\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"test-secret\",\"namespace\":\"test-namespace\"},\"type\":\"Opaque\"}\n"}},"data":{"foo":"dmF1bHQ6Zm9vI2Jhcg==","simple":"test","foo2":"dmF1bHQ6Zm9vI2JhcjI="},"type":"Opaque"}`,
[]patchOperation{patchOperation{Op: "replace", Path: "/data/foo", Value: "YmFy"}, patchOperation{Op: "replace", Path: "/data/foo2", Value: "YmFy"}},
"",
},
}

for _, test := range mutateTests {

s := Server{
Listen: ":8443",
Cert: "",
Key: "",
Vault: test.vaultClient,
VaultPattern: test.vaultPattern,
Logger: logrus.New(),
}

// Parse secret object
var secret corev1.Secret
err := json.Unmarshal([]byte(test.secret), &secret)
if err != nil {
t.Fatal(err)
}

patch, err := s.mutateSecretData(secret)
if test.errorString == "" {
require.Nil(t, err)
} else {
require.EqualError(t, err, test.errorString)
}

require.Equal(t, patch, test.patch)
}
}

type fakeVaultClient struct {
Value string
}

func (f fakeVaultClient) Read(path, key string) (string, error) {
if f.Value == "error" {
return "", errors.New("failed to read key in vault")
}

return f.Value, nil
}
Loading

0 comments on commit 5ed1405

Please sign in to comment.