Skip to content

Commit cb5e603

Browse files
Merge pull request #29 from vimeo/gsm-encoding-fix-unwrapping
Google Secret Manger Enhancements
2 parents a86f423 + 5bfea26 commit cb5e603

File tree

5 files changed

+103
-15
lines changed

5 files changed

+103
-15
lines changed

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
[![GoDoc](https://godoc.org/github.com/vimeo/pentagon?status.svg)](https://godoc.org/github.com/vimeo/pentagon) [![Go Report Card](https://goreportcard.com/badge/github.com/vimeo/pentagon)](https://goreportcard.com/report/github.com/vimeo/pentagon)
33

44
# Pentagon
5-
Pentagon is a small application designed to run as a Kubernetes CronJob to periodically copy secrets stored in [Vault](https://www.vaultproject.io) or Google Secrets Manager into equivalent [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/), keeping them synchronized. Naturally, this should be used with care as "standard" Kubernetes Secrets are simply obfuscated as base64-encoded strings. However, one can and should use more secure methods of securing secrets including Google's [KMS](https://cloud.google.com/kubernetes-engine/docs/how-to/encrypting-secrets) and restricting roles and service accounts appropriately.
5+
Pentagon is a small application designed to run as a Kubernetes CronJob to periodically copy secrets stored in [Vault](https://www.vaultproject.io) or [Google Secret Manager](https://cloud.google.com/security/products/secret-manager) into equivalent [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/), keeping them synchronized. Naturally, this should be used with care as "standard" Kubernetes Secrets are simply obfuscated as base64-encoded strings. However, one can and should use more secure methods of securing secrets including Google's [KMS](https://cloud.google.com/kubernetes-engine/docs/how-to/encrypting-secrets) and restricting roles and service accounts appropriately.
66

7-
## Why not just query Vault?
8-
That's a good question. If you have a highly-available Vault setup that is stable and performant and you're able to modify your applications to query Vault, that's a completely reasonable approach to take. If you don't have such a setup, Pentagon provides a way to cache things securely in Kubernetes secrets which can then be provided to applications without directly introducing a Vault dependency.
7+
## Why not just query Vault or Google Secret Manager?
8+
That's a good question. If you have a highly-available Vault setup that is stable and performant and you're able to modify your applications to query Vault, that's a completely reasonable approach to take. Similarly, if you are able to modify your application to query Google Secret Manager, that's an entirely valid solution. If you don't have such a setup, Pentagon provides a way to cache things securely in Kubernetes secrets which can then be provided to applications without directly introducing a dependency on Vault or Google Secret Manager.
99

1010
## Configuration
1111
Pentagon requires a YAML configuration file, the path to which should be passed as the first and only argument to the application. It is recommended that you store this configuration in a [ConfigMap](https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/) and reference it in the CronJob specification. A sample configuration follows:
@@ -82,6 +82,11 @@ Notice that the `data` object has the `foo` key embedded directly. Alternativel
8282

8383
Notice the extra `data` element nested inside the outer `data`. Vault secrets engines can be mounted at arbitrary paths and it does not appear to be possible to reliably detect which engine was used in the API response directly. In order to properly unwrap the secret data,indicate either `kv` or `kv-v2` as the `vaultEngineType` in the configuration. In the common case of using only one secrets engine, simply define the `defaultEngineType` in the `vault` configuration block and the mapping-level `vaultEngineType` will inherit the default. For compatibility, the unset default value defaults to `kv`. Note that this differs from the current default that Vault itself uses for the key/value secrets engine.
8484

85+
## Special Things about Google Secret Manager
86+
Google Secret Manager's API simply returns arbitrary bytes as the value of a secret, making no assumptions about its encoding. Kubernetes Secrets, on the other hand, can contain multiple key/value pairs. If you would like a single Google Secret Manager Secret to unwrap into multiple key/value pairs in the Kubernetes Secret, add `gsmEncoding: "json"` to the mapping value. Then store a JSON document in Google Secret Manager with JSON that will successfully unmarshal to a `map[string]any`. The key in that map will be used as the key of the Kubernetes Secret. If that value is a string or number, the value will be stored without any quoting. If the value is a JSON object or array it will be stored directly as the string serialization of that structure.
87+
88+
In cases where `gsmEncoding` is not set to json, the key's value will default to the name of the secret (`secretName` in the mapping). If you would like to override this, set `gsmSecretKeyValue` to your preferred key.
89+
8590
## Return Values
8691
The application will return 0 on success (when all keys were copied/updated successfully). A complete list of all possible return values follows:
8792

config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,9 @@ type Mapping struct {
173173
// GSMEncodingType enables the parsing of JSON secrets with more than one key-value pair when set
174174
// to 'json'. For the default behavior, simple values, set to 'string'.
175175
GSMEncodingType string `yaml:"gsmEncodingType"`
176+
177+
// GSMSecretKeyValue allows you to specify the value of the Kubernetes key to
178+
// use for this secret's value in cases where gsmEncodingType is *not* json. If
179+
// this is unset, the key name will default to the value of secretName.
180+
GSMSecretKeyValue string `yaml:"gsmSecretKeyValue"`
176181
}

pentagon/main.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package main
33
import (
44
"context"
55
"fmt"
6-
"io/ioutil"
76
"log"
87
"net/url"
98
"os"
@@ -45,7 +44,7 @@ func main() {
4544
os.Exit(10)
4645
}
4746

48-
configFile, err := ioutil.ReadFile(os.Args[1])
47+
configFile, err := os.ReadFile(os.Args[1])
4948
if err != nil {
5049
log.Printf("error opening configuration file: %s", err)
5150
os.Exit(20)

reflector.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,22 @@ func (r *Reflector) getGSMSecret(ctx context.Context, mapping Mapping) (map[stri
167167
}
168168
casted := make(map[string][]byte, len(unmarshaled))
169169
for k, v := range unmarshaled {
170+
var stringVal string
171+
if err := json.Unmarshal(v, &stringVal); err == nil {
172+
casted[k] = []byte(stringVal)
173+
continue
174+
}
170175
casted[k] = v
171176
}
172177
return casted, nil
173178
}
174179

175-
return map[string][]byte{mapping.SecretName: resp.Payload.Data}, nil
180+
keyName := mapping.GSMSecretKeyValue
181+
if keyName == "" {
182+
keyName = mapping.SecretName
183+
}
184+
185+
return map[string][]byte{keyName: resp.Payload.Data}, nil
176186
}
177187

178188
func (r *Reflector) createK8sSecret(ctx context.Context, mapping Mapping, data map[string][]byte) error {

reflector_test.go

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package pentagon
22

33
import (
44
"context"
5+
"encoding/json"
56
"testing"
67

78
v1 "k8s.io/api/core/v1"
@@ -101,9 +102,10 @@ func TestReflectorGSM(t *testing.T) {
101102

102103
err := r.Reflect(ctx, []Mapping{
103104
{
104-
SourceType: "gsm",
105-
Path: "projects/foo/secrets/bar/versions/latest",
106-
SecretName: "foo",
105+
SourceType: "gsm",
106+
Path: "projects/foo/secrets/bar/versions/latest",
107+
SecretName: "foo",
108+
GSMSecretKeyValue: "foo-key",
107109
},
108110
})
109111
if err != nil {
@@ -126,12 +128,12 @@ func TestReflectorGSM(t *testing.T) {
126128
)
127129
}
128130

129-
if string(secret.Data["foo"]) != "foo_bar_latest" {
131+
if string(secret.Data["foo-key"]) != "foo_bar_latest" {
130132
t.Fatalf("secret value does not equal foo_bar_latest: %s", string(secret.Data["foo"]))
131133
}
132134
}
133135

134-
func TestReflectorGSMJSON(t *testing.T) {
136+
func TestReflectorGSMJSONStruct(t *testing.T) {
135137
ctx := context.Background()
136138
k8sClient := k8sfake.NewSimpleClientset()
137139

@@ -174,12 +176,79 @@ func TestReflectorGSMJSON(t *testing.T) {
174176
)
175177
}
176178

177-
if string(secret.Data["key1"]) != `{"int": 1, "string": "hello"}` {
178-
t.Fatalf("secret value does not equal struct: %s", string(secret.Data["key1"]))
179+
data := map[string]any{}
180+
if err := json.Unmarshal(secret.Data["key1"], &data); err != nil {
181+
t.Fatalf("error unmarshaling secret data for key1: %s", err)
182+
}
183+
if data["int"] != float64(1) {
184+
t.Errorf("secret data for key1 does not contain expected int: %+v", data["int"])
185+
}
186+
if data["string"] != "hello" {
187+
t.Errorf("secret data for key1 does not contain expected string: %v", data["string"])
188+
}
189+
190+
data = map[string]any{}
191+
if err := json.Unmarshal(secret.Data["key2"], &data); err != nil {
192+
t.Fatalf("error unmarshaling secret data for key1: %s", err)
193+
}
194+
195+
if data["float"] != 3.14 {
196+
t.Errorf("secret data for key2 does not contain expected float: %v", data["float"])
197+
}
198+
if data["bool"] != true {
199+
t.Errorf("secret data for key2 does not contain expected bool: %v", data["bool"])
200+
}
201+
}
202+
203+
func TestReflectorGSMJSONUnwrap(t *testing.T) {
204+
ctx := context.Background()
205+
k8sClient := k8sfake.NewSimpleClientset()
206+
207+
gsm := gsm.NewMockGSM(map[string][]byte{
208+
"projects/foo/secrets/bar/versions/latest": []byte(`{"key1": 1, "key2": "val2\nval3"}`),
209+
})
210+
211+
r := NewReflector(
212+
nil,
213+
gsm,
214+
k8sClient, DefaultNamespace,
215+
DefaultLabelValue,
216+
)
217+
218+
err := r.Reflect(ctx, []Mapping{
219+
{
220+
SourceType: "gsm",
221+
Path: "projects/foo/secrets/bar/versions/latest",
222+
GSMEncodingType: GSMEncodingTypeJSON,
223+
SecretName: "foo",
224+
},
225+
})
226+
if err != nil {
227+
t.Fatalf("reflect didn't work: %s", err)
228+
}
229+
230+
// now get the secret out of k8s
231+
secrets := k8sClient.CoreV1().Secrets(DefaultNamespace)
232+
233+
secret, err := secrets.Get(ctx, "foo", metav1.GetOptions{})
234+
if err != nil {
235+
t.Fatalf("secret should be there: %s", err)
236+
}
237+
238+
if secret.Labels[LabelKey] != DefaultLabelValue {
239+
t.Fatalf(
240+
"secret pentagon label should be %s is %s",
241+
DefaultLabelValue,
242+
secret.Labels[LabelKey],
243+
)
244+
}
245+
246+
if string(secret.Data["key1"]) != "1" {
247+
t.Fatalf("secret value does not equal bare int: %s", string(secret.Data["key1"]))
179248
}
180249

181-
if string(secret.Data["key2"]) != `{"float": 3.14, "bool": true}` {
182-
t.Fatalf("secret value does not equal struct: %s", string(secret.Data["key2"]))
250+
if string(secret.Data["key2"]) != "val2\nval3" {
251+
t.Fatalf("secret value does not equal string: %s", string(secret.Data["key2"]))
183252
}
184253
}
185254

0 commit comments

Comments
 (0)