-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
7ecc070
commit 5ed1405
Showing
6 changed files
with
298 additions
and
125 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.