From 63e9c877bb721d7681198c7e48cf9bf059392b6f Mon Sep 17 00:00:00 2001 From: Steven Soroka Date: Thu, 28 Oct 2021 16:01:46 -0400 Subject: [PATCH] Secrets config (#535) --- .gitignore | 4 + README.md | 6 +- docs/configuration.md | 4 +- docs/secrets.md | 174 ++++++ docs/sources/okta.md | 18 +- internal/kubernetes/kubernetes.go | 10 - internal/kubernetes/kubernetes_test.go | 41 -- internal/registry/_testdata/infra.yaml | 17 +- internal/registry/api.go | 23 +- internal/registry/api_test.go | 542 ++++++++++++++++-- internal/registry/config.go | 300 +++++++++- internal/registry/config_test.go | 35 +- internal/registry/data.go | 13 +- internal/registry/data_test.go | 33 +- internal/registry/registry.go | 57 +- secrets/aws.go | 8 + secrets/awskms.go | 48 +- secrets/awssecretsmanager.go | 37 +- ...stemmanagerparameterstore.go => awsssm.go} | 44 +- secrets/env.go | 92 +++ secrets/file.go | 120 ++++ secrets/kubernetes.go | 32 +- secrets/plain.go | 63 ++ secrets/secrets.go | 15 + secrets/secrets_test.go | 19 +- secrets/vault.go | 46 +- 26 files changed, 1588 insertions(+), 213 deletions(-) create mode 100644 docs/secrets.md delete mode 100644 internal/kubernetes/kubernetes_test.go create mode 100644 secrets/aws.go rename secrets/{awssystemmanagerparameterstore.go => awsssm.go} (60%) create mode 100644 secrets/env.go create mode 100644 secrets/file.go create mode 100644 secrets/plain.go diff --git a/.gitignore b/.gitignore index fe48a0f853..5c22d1a9bd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ docker-desktop.yaml # dir created by some versions of openapi-generator .openapi-generator +secrets/foo/bar:secret +secrets/foo2/bar +secrets/foo2/bar2 +secrets/foo3/bar:secret diff --git a/README.md b/README.md index 9c5a3a170c..a7618cb4a0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ See [Okta](./docs/sources/okta.md) for detailed Okta configuration steps. Cluster name is auto-discovered or can be set statically in Helm with `engine.name`. +Also see [secrets.md](./docs/secrets.md) for details on how secrets work. + ```yaml # example values.yaml --- @@ -51,9 +53,9 @@ config: - kind: okta domain: client-id: - client-secret: + client-secret: : okta: - api-token: + api-token: : groups: - name: Everyone roles: diff --git a/docs/configuration.md b/docs/configuration.md index d1709e2e24..d8b4b621e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -103,9 +103,9 @@ sources: - kind: okta domain: acme.okta.com client-id: 0oapn0qwiQPiMIyR35d6 - client-secret: infra-okta/clientSecret + client-secret: kubernetes:infra-okta/clientSecret okta: - api-token: infra-okta/apiToken + api-token: kubernetes:infra-okta/apiToken groups: - name: administrators diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000000..47dcb09aa7 --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,174 @@ +# Secrets + +Infra supports many secret storage backends, including, but not limited to: + +- Kubernetes +- Vault +- AWS Secrets Manager +- AWS SSM (Systems Manager Parameter Store) +- Environment variables +- Files on the file system +- plaintext secrets (though probably not recommended) + +## Usage + +These can be referenced in the Infra config file using the scheme `:` + +Examples follow. + +### Kubernetes + +```yaml + clientSecret: kubernetes:infra-okta/clientSecret +``` + +This would read the `infra-okta/clientSecret` key from a Kubernetes secret. + +Kubernetes takes configuration, like so: + +```yaml +secrets: + - name: kubernetes # can optionally provide a custom name + kind: kubernetes + namespace: mynamespace +``` + +### Vault + +```yaml + clientSecret: vault:infra-okta-clientSecret +``` + +This would read the `infra-okta-clientSecret` secret from Vault + +Vault takes configuration, like so: + +```yaml +secrets: + - name: vault # can optionally provide a custom name + kind: vault + transitMount: /transit + secretMount: /secret + token: env:VAULT_TOKEN # secret config can even reference other built-in secret types, like env + namespace: mynamespace + address: https://vault +``` + +### AWS Secrets Manager + +```yaml + clientSecret: awssm:infra-okta-clientSecret +``` + +Secrets Manager takes configuration, like so: + +```yaml +secrets: + - name: awssm # can optionally provide a custom name + kind: awssm + endpoint: https://kms.endpoint + region: us-west-2 + accessKeyId: env:AWS_ACCESS_KEY_ID # secret config can even reference other built-in secret types, like env + secretAccessKey: env:AWS_SECRET_ACCESS_KEY +``` + +### AWS SSM (Systems Manager Parameter Store) + +```yaml + clientSecret: awsssm:infra-okta-clientSecret +``` + +SSM takes configuration, like so: + +```yaml +secrets: + - name: awsssm # can optionally provide a custom name + kind: awsssm + keyId: 1234abcd-12ab-34cd-56ef-1234567890ab # optional, if set it's the KMS key that should be used for decryption + endpoint: https://kms.endpoint + region: us-west-2 + accessKeyId: env:AWS_ACCESS_KEY_ID # secret config can even reference other built-in secret types, like env + secretAccessKey: env:AWS_SECRET_ACCESS_KEY +``` + +### Environment variables + +```yaml + clientSecret: env:OKTA_CLIENT_SECRET +``` + +env is built-in and does not need to be declared, but if you do want to declare the configuration for it, you could use this to create a custom env handler, like so: + +```yaml +secrets: + - name: base64env + kind: env + base64: true + base64UrlEncoded: false + base64Raw: false +``` + +which you would then use like: + +```bash +$ export OKTA_CLIENT_SECRET="c3VwZXIgc2VjcmV0IQ==" +``` + +```yaml + clientSecret: base64env:OKTA_CLIENT_SECRET +``` + +### Files on the file system + +It's a common pattern to write secrets to a file on disk and then have an app read them. + +```yaml + clientSecret: file:/var/secrets/okta-client-secret.txt +``` + +file is built-in and does not need to be declared, but if you do want to declare the configuration for it, you could use this to create a custom handler, like so: + +```yaml +secrets: + - name: base64file + kind: file + base64: true + base64UrlEncoded: false + base64Raw: false + path: /var/secrets # optional: assume all files mentioned are in this root directory +``` + +which you would then use like: + +```bash +$ echo "c3VwZXIgc2VjcmV0IQ==" > /var/secrets/okta-client-secret.txt +``` + +```yaml + clientSecret: base64file:okta-client-secret.txt +``` + +### plaintext secrets (though probably not recommended) + +Sometimes it can be handy to support plain text secrets right in the yaml config, especially when the yaml is being generated and the secrets are coming from elsewhere. + +```yaml + clientSecret: plaintext:mySupErSecrEt +``` + +plain is built-in and does not need to be declared, but if you do want to declare the configuration for it, you could use this to create a custom handler, like so: + +```yaml +secrets: + - name: base64text + kind: plain + base64: true + base64UrlEncoded: false + base64Raw: false +``` + +which you would then use like: + +```yaml + clientSecret: base64text:bXlTdXBFclNlY3JFdA== +``` diff --git a/docs/sources/okta.md b/docs/sources/okta.md index 5495d42605..adb9dcf5b6 100644 --- a/docs/sources/okta.md +++ b/docs/sources/okta.md @@ -19,9 +19,9 @@ sources: - kind: okta domain: acme.okta.com client-id: 0oapn0qwiQPiMIyR35d6 - client-secret: infra-okta/clientSecret + client-secret: kubernetes:infra-okta/clientSecret okta: - api-token: infra-okta/apiToken + api-token: kubernetes:infra-okta/apiToken ``` ## Create an Okta App @@ -47,11 +47,15 @@ sources: ![okta_api_token](https://user-images.githubusercontent.com/5853428/124652864-787b5d00-de51-11eb-81d8-e503babfdbca.png) ### Add Okta secrets to the Infra deployment + The Okta client secret and API token are sensitive information which cannot be stored in the Infra configuration file. In order for Infra to access these secret values they must be stored in Kubernetes Secret objects **in the same namespace that the Infra is deployed in**. Create [Kubernetes Secret objects](https://kubernetes.io/docs/tasks/configmap-secret/) to store the Okta client secret and API token (noted in steps 4 and 5 of `Create an Okta App` respectively). You can name these Secrets as you desire, these names will be specified in the Infra configuration. #### Example Secret Creation + +There are [many ways to store secrets](../secrets.md). Here's an example of using Kubernetes for the secret storage. + Store the Okta client secret and API token on the same Kubernetes Secret object in the namespace that Infra is running in. ``` OKTA_CLIENT_SECRET=jfpn0qwiQPiMIfs408fjs048fjpn0qwiQPiMajsdf08j10j2 @@ -59,6 +63,8 @@ OKTA_API_TOKEN=001XJv9xhv899sdfns938haos3h8oahsdaohd2o8hdao82hd kubectl -n infrahq create secret generic infra-okta --from-literal=clientSecret=$OKTA_CLIENT_SECRET --from-literal=apiToken=$OKTA_API_TOKEN ``` +see [secrets.md](../secrets.md) for further details. + ## Add Okta Information to Infra Configuration Edit your [Infra configuration](./configuration.md) (e.g. `infra.yaml`) to include an Okta source: @@ -70,9 +76,9 @@ sources: - kind: okta domain: example.okta.com client-id: 0oapn0qwiQPiMIyR35d6 - client-secret: infra-okta/clientSecret # / + client-secret: kubernetes:infra-okta/clientSecret # : okta: - api-token: infra-okta/apiToken + api-token: kubernetes:infra-okta/apiToken ``` Then apply this config change: @@ -91,9 +97,9 @@ config: - kind: okta domain: example.okta.com client-id: 0oapn0qwiQPiMIyR35d6 - client-secret: infra-okta/clientSecret # / + client-secret: kubernetes:infra-okta/clientSecret # : okta: - api-token: infra-okta/apiToken + api-token: kubernetes:infra-okta/apiToken ``` Then apply this config change: diff --git a/internal/kubernetes/kubernetes.go b/internal/kubernetes/kubernetes.go index 2c2ed7410c..35db89c3f1 100644 --- a/internal/kubernetes/kubernetes.go +++ b/internal/kubernetes/kubernetes.go @@ -691,13 +691,3 @@ func (k *Kubernetes) Endpoint() (string, error) { return fmt.Sprintf("%s:%d", host, port.Port), nil } - -// GetSecret returns a K8s secret object with the specified name from the current namespace if it exists -func (k *Kubernetes) GetSecret(secret string) (string, error) { - b, err := k.SecretReader.GetSecret(secret) - if b == nil { - return "", err - } - - return string(b), err -} diff --git a/internal/kubernetes/kubernetes_test.go b/internal/kubernetes/kubernetes_test.go deleted file mode 100644 index e8f32ad549..0000000000 --- a/internal/kubernetes/kubernetes_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package kubernetes - -import ( - "testing" - - "github.com/infrahq/infra/secrets" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "k8s.io/client-go/kubernetes" - rest "k8s.io/client-go/rest" -) - -func TestInvalidSecretFormats(t *testing.T) { - if testing.Short() { - t.SkipNow() - } - - testConfig := &rest.Config{ - Host: "https://localhost", - } - clientset, err := kubernetes.NewForConfig(testConfig) - require.NoError(t, err) - - testK8s := secrets.NewKubernetesSecretProvider(clientset, "infrahq") - - _, err = testK8s.GetSecret("invalid-secret-format") - assert.NotNil(t, err) - assert.Equal(t, "invalid Kubernetes secret path specified, expected exactly 2 parts but was 1", err.Error()) - - _, err = testK8s.GetSecret("") - assert.NotNil(t, err) - assert.Equal(t, "invalid Kubernetes secret path specified, expected exactly 2 parts but was 1", err.Error()) - - _, err = testK8s.GetSecret("/") - assert.NotNil(t, err) - assert.Equal(t, "resource name may not be empty", err.Error()) - - _, err = testK8s.GetSecret("invalid/number/path") - assert.NotNil(t, err) - assert.Equal(t, "invalid Kubernetes secret path specified, expected exactly 2 parts but was 3", err.Error()) -} diff --git a/internal/registry/_testdata/infra.yaml b/internal/registry/_testdata/infra.yaml index 1e1cb46c33..f11948ab98 100644 --- a/internal/registry/_testdata/infra.yaml +++ b/internal/registry/_testdata/infra.yaml @@ -1,16 +1,21 @@ +secrets: + - name: base64 + kind: plaintext + base64: true + sources: - kind: okta domain: overwrite.example.com - client-id: 0oapn0qwiQPiMIyR35d6 - client-secret: okta-secrets/clientSecret + client-id: base64:MG9hcG4wcXdpUVBpTUl5UjM1ZDY= + client-secret: kubernetes:okta-secrets/clientSecret okta: - api-token: okta-secrets/apiToken + api-token: kubernetes:okta-secrets/apiToken - kind: okta domain: https://test.example.com - client-id: 0oapn0qwiQPiMIyR35d6 - client-secret: okta-secrets/clientSecret + client-id: plaintext:0oapn0qwiQPiMIyR35d6 + client-secret: kubernetes:okta-secrets/clientSecret okta: - api-token: okta-secrets/apiToken + api-token: kubernetes:okta-secrets/apiToken groups: - name: ios-developers diff --git a/internal/registry/api.go b/internal/registry/api.go index 9611b9d597..2afc06d4c5 100644 --- a/internal/registry/api.go +++ b/internal/registry/api.go @@ -14,7 +14,6 @@ import ( "github.com/infrahq/infra/internal" "github.com/infrahq/infra/internal/api" "github.com/infrahq/infra/internal/generate" - "github.com/infrahq/infra/internal/kubernetes" "github.com/infrahq/infra/internal/logging" "gopkg.in/segmentio/analytics-go.v3" "gopkg.in/square/go-jose.v2" @@ -24,10 +23,10 @@ import ( ) type API struct { - db *gorm.DB - k8s *kubernetes.Kubernetes - okta Okta - t *Telemetry + db *gorm.DB + okta Okta + t *Telemetry + registry *Registry } type CustomJWTClaims struct { @@ -43,10 +42,10 @@ var ( func NewAPIMux(reg *Registry) *mux.Router { a := API{ - db: reg.db, - k8s: reg.k8s, - okta: reg.okta, - t: reg.tel, + db: reg.db, + okta: reg.okta, + t: reg.tel, + registry: reg, } r := mux.NewRouter() @@ -417,11 +416,11 @@ func (a *API) GetDestination(w http.ResponseWriter, r *http.Request) { var destination Destination if err := a.db.First(&destination, &Destination{Id: destinationId}).Error; err != nil { - logging.L.Error(err.Error()) - if errors.Is(err, gorm.ErrRecordNotFound) { + logging.L.Debug(err.Error()) sendAPIError(w, http.StatusNotFound, fmt.Sprintf("Could not find destination ID \"%s\"", destinationId)) } else { + logging.L.Error(err.Error()) sendAPIError(w, http.StatusBadRequest, fmt.Sprintf("Could not find destination ID \"%s\"", destinationId)) } @@ -797,7 +796,7 @@ func (a *API) Login(w http.ResponseWriter, r *http.Request) { return } - clientSecret, err := a.k8s.GetSecret(source.ClientSecret) + clientSecret, err := a.registry.GetSecret(source.ClientSecret) if err != nil { logging.L.Error("Could not retrieve okta client secret from kubernetes: " + err.Error()) sendAPIError(w, http.StatusInternalServerError, "invalid okta login information") diff --git a/internal/registry/api_test.go b/internal/registry/api_test.go index 896aa2b78f..75584f5a60 100644 --- a/internal/registry/api_test.go +++ b/internal/registry/api_test.go @@ -15,14 +15,12 @@ import ( "github.com/infrahq/infra/internal" "github.com/infrahq/infra/internal/api" "github.com/infrahq/infra/internal/generate" - "github.com/infrahq/infra/internal/kubernetes" "github.com/infrahq/infra/internal/registry/mocks" "github.com/infrahq/infra/secrets" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "gorm.io/gorm" - rest "k8s.io/client-go/rest" ) type mockSecretReader struct{} @@ -79,6 +77,12 @@ func TestBearerTokenMiddlewareDefault(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -101,6 +105,12 @@ func TestBearerTokenMiddlewareEmptyHeader(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -125,6 +135,12 @@ func TestBearerTokenMiddlewareEmptyHeaderBearer(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -149,6 +165,12 @@ func TestBearerTokenMiddlewareInvalidLength(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -171,6 +193,12 @@ func TestBearerTokenMiddlewareInvalidToken(t *testing.T) { require.NoError(t, err) a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -197,6 +225,12 @@ func TestBearerTokenMiddlewareExpiredToken(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -226,6 +260,12 @@ func TestBearerTokenMiddlewareValidToken(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -256,6 +296,12 @@ func TestBearerTokenMiddlewareInvalidAPIKey(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -282,6 +328,12 @@ func TestBearerTokenMiddlewareValidAPIKey(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -314,6 +366,12 @@ func TestBearerTokenMiddlewareValidAPIKeyRootPermissions(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -346,6 +404,12 @@ func TestBearerTokenMiddlewareValidAPIKeyWrongPermission(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -377,7 +441,15 @@ func TestCreateDestinationNoAPIKey(t *testing.T) { require.NoError(t, err) } - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } req := api.DestinationCreateRequest{ Kubernetes: &api.DestinationKubernetes{ @@ -404,6 +476,12 @@ func TestCreateDestination(t *testing.T) { } a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, db: db, } @@ -440,7 +518,15 @@ func TestLoginHandlerEmptyRequest(t *testing.T) { t.Fatal(err) } - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodPost, "http://test.com/v1/login", nil) w := httptest.NewRecorder() @@ -454,7 +540,15 @@ func TestLoginNilOktaRequest(t *testing.T) { t.Fatal(err) } - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } loginRequest := api.LoginRequest{ Okta: nil, @@ -477,7 +571,15 @@ func TestLoginEmptyOktaRequest(t *testing.T) { t.Fatal(err) } - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } loginRequest := api.LoginRequest{ Okta: &api.LoginRequestOkta{}, @@ -500,7 +602,15 @@ func TestLoginOktaMissingDomainRequest(t *testing.T) { t.Fatal(err) } - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } loginRequest := api.LoginRequest{ Okta: &api.LoginRequestOkta{ @@ -525,7 +635,15 @@ func TestLoginMethodOktaMissingCodeRequest(t *testing.T) { t.Fatal(err) } - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } loginRequest := api.LoginRequest{ Okta: &api.LoginRequestOkta{ @@ -569,18 +687,22 @@ func TestLoginMethodOkta(t *testing.T) { testOkta := new(mocks.Okta) testOkta.On("EmailFromCode", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("test@test.com", nil) - testSecretReader := NewMockSecretReader() - testConfig := &rest.Config{ - Host: "https://localhost", - } - testK8s := &kubernetes.Kubernetes{Config: testConfig, SecretReader: testSecretReader} - telemetry, err := NewTelemetry(db) if err != nil { t.Fatal(err) } - a := &API{db: db, okta: testOkta, k8s: testK8s, t: telemetry} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + okta: testOkta, + t: telemetry, + } loginRequest := api.LoginRequest{ Okta: &api.LoginRequestOkta{ @@ -614,7 +736,15 @@ func TestVersion(t *testing.T) { t.Fatal(err) } - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/version", nil) w := httptest.NewRecorder() @@ -630,7 +760,15 @@ func TestVersion(t *testing.T) { } func TestListRoles(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/roles", nil) @@ -681,7 +819,15 @@ func TestListRoles(t *testing.T) { } func TestListRolesByName(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/roles?name=admin", nil) @@ -715,7 +861,15 @@ func TestListRolesByName(t *testing.T) { } func TestListRolesByKind(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/roles?kind=role", nil) @@ -741,7 +895,15 @@ func TestListRolesByKind(t *testing.T) { } func TestListRolesByMultiple(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/roles?name=admin&kind=role", nil) @@ -759,7 +921,15 @@ func TestListRolesByMultiple(t *testing.T) { func TestListRolesForDestinationReturnsRolesFromConfig(t *testing.T) { // this in memory DB is setup in the config_test.go - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/roles", nil) q := r.URL.Query() @@ -796,7 +966,15 @@ func TestListRolesForDestinationReturnsRolesFromConfig(t *testing.T) { func TestListRolesOnlyFindsForSpecificDestination(t *testing.T) { // this in memory DB is setup in the config_test.go - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/roles", nil) q := r.URL.Query() @@ -832,7 +1010,15 @@ func TestListRolesOnlyFindsForSpecificDestination(t *testing.T) { func TestListRolesForUnknownDestination(t *testing.T) { // this in memory DB is setup in config_test.go - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/roles", nil) q := r.URL.Query() @@ -852,7 +1038,15 @@ func TestListRolesForUnknownDestination(t *testing.T) { } func TestGetRole(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } role := &Role{Name: "mpt-role"} if err := a.db.Create(role).Error; err != nil { @@ -874,7 +1068,15 @@ func TestGetRole(t *testing.T) { } func TestGetRoleEmptyID(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/roles/", nil) vars := map[string]string{ @@ -889,7 +1091,15 @@ func TestGetRoleEmptyID(t *testing.T) { } func TestGetRoleNotFound(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/roles/nonexistent", nil) vars := map[string]string{ @@ -904,7 +1114,15 @@ func TestGetRoleNotFound(t *testing.T) { } func TestListGroups(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/groups", nil) @@ -924,7 +1142,15 @@ func TestListGroups(t *testing.T) { } func TestListGroupsByName(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/groups?name=ios-developers", nil) @@ -943,7 +1169,15 @@ func TestListGroupsByName(t *testing.T) { } func TestGetGroup(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } group := &Group{Name: "mpt-group"} if err := a.db.Create(group).Error; err != nil { @@ -965,7 +1199,15 @@ func TestGetGroup(t *testing.T) { } func TestGetGroupEmptyID(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/groups/", nil) vars := map[string]string{ @@ -980,7 +1222,15 @@ func TestGetGroupEmptyID(t *testing.T) { } func TestGetGroupNotFound(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/groups/nonexistent", nil) vars := map[string]string{ @@ -995,7 +1245,15 @@ func TestGetGroupNotFound(t *testing.T) { } func TestListUsers(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/users", nil) @@ -1016,7 +1274,15 @@ func TestListUsers(t *testing.T) { } func TestListUsersByEmail(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/users?email=woz@example.com", nil) @@ -1035,7 +1301,15 @@ func TestListUsersByEmail(t *testing.T) { } func TestListUsersEmpty(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/users?email=nonexistent@example.com", nil) @@ -1052,7 +1326,15 @@ func TestListUsersEmpty(t *testing.T) { } func TestGetUser(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } user := &User{Email: "mpt-user@infrahq.com"} if err := a.db.Create(user).Error; err != nil { @@ -1074,7 +1356,15 @@ func TestGetUser(t *testing.T) { } func TestGetUserEmptyID(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/users/", nil) vars := map[string]string{ @@ -1089,7 +1379,15 @@ func TestGetUserEmptyID(t *testing.T) { } func TestGetUserNotFound(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/users/nonexistent", nil) vars := map[string]string{ @@ -1104,7 +1402,15 @@ func TestGetUserNotFound(t *testing.T) { } func TestListSources(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/sources", nil) @@ -1121,7 +1427,15 @@ func TestListSources(t *testing.T) { } func TestListSourcesByType(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/sources?kind=okta", nil) @@ -1138,7 +1452,15 @@ func TestListSourcesByType(t *testing.T) { } func TestListSourcesEmpty(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/sources?kind=nonexistent", nil) @@ -1155,7 +1477,15 @@ func TestListSourcesEmpty(t *testing.T) { } func TestGetSource(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } source := &Source{Kind: SourceKindOkta} if err := a.db.Create(source).Error; err != nil { @@ -1177,7 +1507,15 @@ func TestGetSource(t *testing.T) { } func TestGetSourceEmptyID(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/sources/", nil) vars := map[string]string{ @@ -1192,7 +1530,15 @@ func TestGetSourceEmptyID(t *testing.T) { } func TestGetSourceNotFound(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/sources/nonexistent", nil) vars := map[string]string{ @@ -1207,7 +1553,15 @@ func TestGetSourceNotFound(t *testing.T) { } func TestListDestinations(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/destinations", nil) @@ -1228,7 +1582,15 @@ func TestListDestinations(t *testing.T) { } func TestListDestinationsByName(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/destinations?name=cluster-AAA", nil) @@ -1247,7 +1609,15 @@ func TestListDestinationsByName(t *testing.T) { } func TestListDestinationsByType(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/destinations?=kind", nil) @@ -1268,7 +1638,15 @@ func TestListDestinationsByType(t *testing.T) { } func TestListDestinationsEmpty(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/destinations?name=nonexistent", nil) @@ -1285,7 +1663,15 @@ func TestListDestinationsEmpty(t *testing.T) { } func TestGetDestination(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } destination := &Destination{Name: "mpt-destination"} if err := a.db.Create(destination).Error; err != nil { @@ -1307,7 +1693,15 @@ func TestGetDestination(t *testing.T) { } func TestGetDestinationEmptyID(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/destinations/", nil) vars := map[string]string{ @@ -1322,7 +1716,15 @@ func TestGetDestinationEmptyID(t *testing.T) { } func TestGetDestinationNotFound(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } r := httptest.NewRequest(http.MethodGet, "/v1/destinations/nonexistent", nil) vars := map[string]string{ @@ -1337,7 +1739,15 @@ func TestGetDestinationNotFound(t *testing.T) { } func TestCreateAPIKey(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } createAPIKeyRequest := api.InfraAPIKeyCreateRequest{ Name: "test-api-client", @@ -1370,7 +1780,15 @@ func TestCreateAPIKey(t *testing.T) { } func TestDeleteAPIKey(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } k := &APIKey{Name: "test-delete-key", Permissions: string(api.API_KEYS_DELETE)} if err := a.db.Create(k).Error; err != nil { @@ -1394,7 +1812,15 @@ func TestDeleteAPIKey(t *testing.T) { } func TestListAPIKeys(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } k := &APIKey{Name: "test-key", Permissions: string(api.API_KEYS_READ)} if err := a.db.Create(k).Error; err != nil { @@ -1453,7 +1879,15 @@ func containsDestination(destinations []api.Destination, name string) bool { } func TestCredentials(t *testing.T) { - a := &API{db: db} + a := &API{ + registry: &Registry{ + db: db, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, + }, + db: db, + } err := db.FirstOrCreate(&Settings{}).Error require.NoError(t, err) diff --git a/internal/registry/config.go b/internal/registry/config.go index 53ea65e97b..0b23e1bd85 100644 --- a/internal/registry/config.go +++ b/internal/registry/config.go @@ -3,10 +3,12 @@ package registry import ( "errors" "fmt" + "os" "regexp" "strings" "github.com/infrahq/infra/internal/logging" + "github.com/infrahq/infra/secrets" "gopkg.in/yaml.v2" "gorm.io/gorm" ) @@ -57,10 +59,92 @@ type ConfigUserMapping struct { Groups []string `yaml:"groups"` } +type ConfigSecretProvider struct { + Kind string `yaml:"kind"` + Name string `yaml:"name"` // optional + Config interface{} // contains secret-provider-specific config +} + +type simpleConfigSecretProvider struct { + Kind string `yaml:"kind"` + Name string `yaml:"name"` +} + +// ensure ConfigSecretProvider implements yaml.Unmarshaller for the custom config field support +var _ yaml.Unmarshaler = &ConfigSecretProvider{} + +func (sp *ConfigSecretProvider) UnmarshalYAML(unmarshal func(interface{}) error) error { + tmp := &simpleConfigSecretProvider{} + + if err := unmarshal(&tmp); err != nil { + return fmt.Errorf("unmarshalling secret provider: %w", err) + } + + sp.Kind = tmp.Kind + sp.Name = tmp.Name + + switch tmp.Kind { + case "vault": + p := secrets.NewVaultConfig() + if err := unmarshal(&p); err != nil { + return fmt.Errorf("unmarshal yaml: %w", err) + } + + sp.Config = p + case "awsssm": + p := secrets.AWSSSMConfig{} + if err := unmarshal(&p); err != nil { + return fmt.Errorf("unmarshal yaml: %w", err) + } + + sp.Config = p + case "awssecretsmanager": + p := secrets.AWSSecretsManagerConfig{} + if err := unmarshal(&p); err != nil { + return fmt.Errorf("unmarshal yaml: %w", err) + } + + sp.Config = p + case "kubernetes": + p := secrets.KubernetesConfig{} + if err := unmarshal(&p); err != nil { + return fmt.Errorf("unmarshal yaml: %w", err) + } + + sp.Config = p + case "env": + p := secrets.GenericConfig{} + if err := unmarshal(&p); err != nil { + return fmt.Errorf("unmarshal yaml: %w", err) + } + + sp.Config = p + case "file": + p := secrets.FileConfig{} + if err := unmarshal(&p); err != nil { + return fmt.Errorf("unmarshal yaml: %w", err) + } + + sp.Config = p + case "plaintext", "": + p := secrets.GenericConfig{} + if err := unmarshal(&p); err != nil { + return fmt.Errorf("unmarshal yaml: %w", err) + } + + sp.Config = p + default: + return fmt.Errorf("unknown secret provider type %q, expected one of %q", tmp.Kind, secrets.SecretStorageProviderKinds) + } + + return nil +} + type Config struct { - Sources []ConfigSource `yaml:"sources"` - Groups []ConfigGroupMapping `yaml:"groups"` - Users []ConfigUserMapping `yaml:"users"` + Secrets []ConfigSecretProvider `yaml:"secrets"` + Sources []ConfigSource `yaml:"sources"` + Groups []ConfigGroupMapping `yaml:"groups"` + Users []ConfigUserMapping `yaml:"users"` } // this config is loaded at start-up and re-applied when Infra's state changes (ie. a user is added) @@ -263,8 +347,8 @@ func ImportRoleMappings(db *gorm.DB, groups []ConfigGroupMapping, users []Config return nil } -// ImportConfig tries to import all valid fields in a config file and removes old config -func ImportConfig(db *gorm.DB, bs []byte) error { +// importConfig tries to import all valid fields in a config file and removes old config +func (r *Registry) importConfig(bs []byte) error { var config Config if err := yaml.Unmarshal(bs, &config); err != nil { return err @@ -272,7 +356,11 @@ func ImportConfig(db *gorm.DB, bs []byte) error { initialConfig = config - return db.Transaction(func(tx *gorm.DB) error { + if err := r.configureSecrets(config); err != nil { + return fmt.Errorf("secrets config: %w", err) + } + + return r.db.Transaction(func(tx *gorm.DB) error { if err := ImportSources(tx, config.Sources); err != nil { return err } @@ -342,3 +430,203 @@ func importRoles(db *gorm.DB, roles []ConfigRoleKubernetes) (rolesImported []Rol return rolesImported, importedRoleIDs, nil } + +var baseSecretStorageKinds = []string{ + "env", + "file", + "plaintext", + "kubernetes", +} + +func isABaseSecretStorageKind(s string) bool { + for _, item := range baseSecretStorageKinds { + if item == s { + return true + } + } + + return false +} + +func (r *Registry) configureSecrets(config Config) error { + if r.secrets == nil { + r.secrets = map[string]secrets.SecretStorage{} + } + + loadSecretConfig := func(secret ConfigSecretProvider) (err error) { + name := secret.Name + if len(name) == 0 { + name = secret.Kind + } + + if _, found := r.secrets[name]; found { + return fmt.Errorf("duplicate secret configuration for %q, please provide a unique name for this secret configuration", name) + } + + switch secret.Kind { + case "vault": + cfg, ok := secret.Config.(secrets.VaultConfig) + if !ok { + return fmt.Errorf("expected secret config to be VaultConfig, but was %t", secret.Config) + } + + cfg.Token, err = r.GetSecret(cfg.Token) + if err != nil { + return err + } + + vault, err := secrets.NewVaultSecretProviderFromConfig(cfg) + if err != nil { + return fmt.Errorf("creating vault provider: %w", err) + } + + r.secrets[name] = vault + case "awsssm": + cfg, ok := secret.Config.(secrets.AWSSSMConfig) + if !ok { + return fmt.Errorf("expected secret config to be AWSSSMConfig, but was %t", secret.Config) + } + + cfg.AccessKeyID, err = r.GetSecret(cfg.AccessKeyID) + if err != nil { + return err + } + + cfg.SecretAccessKey, err = r.GetSecret(cfg.SecretAccessKey) + if err != nil { + return err + } + + ssm, err := secrets.NewAWSSSMSecretProviderFromConfig(cfg) + if err != nil { + return fmt.Errorf("creating aws ssm: %w", err) + } + + r.secrets[name] = ssm + case "awssecretsmanager": + cfg, ok := secret.Config.(secrets.AWSSecretsManagerConfig) + if !ok { + return fmt.Errorf("expected secret config to be AWSSecretsManagerConfig, but was %t", secret.Config) + } + + cfg.AccessKeyID, err = r.GetSecret(cfg.AccessKeyID) + if err != nil { + return err + } + + cfg.SecretAccessKey, err = r.GetSecret(cfg.SecretAccessKey) + if err != nil { + return err + } + + sm, err := secrets.NewAWSSecretsManagerFromConfig(cfg) + if err != nil { + return fmt.Errorf("creating aws sm: %w", err) + } + + r.secrets[name] = sm + case "kubernetes": + cfg, ok := secret.Config.(secrets.KubernetesConfig) + if !ok { + return fmt.Errorf("expected secret config to be KubernetesConfig, but was %t", secret.Config) + } + + k8s, err := secrets.NewKubernetesSecretProviderFromConfig(cfg) + if err != nil { + return fmt.Errorf("creating k8s secret provider: %w", err) + } + + r.secrets[name] = k8s + case "env": + cfg, ok := secret.Config.(secrets.GenericConfig) + if !ok { + return fmt.Errorf("expected secret config to be GenericConfig, but was %t", secret.Config) + } + + f := secrets.NewEnvSecretProviderFromConfig(cfg) + r.secrets[name] = f + case "file": + cfg, ok := secret.Config.(secrets.FileConfig) + if !ok { + return fmt.Errorf("expected secret config to be FileConfig, but was %t", secret.Config) + } + + f := secrets.NewFileSecretProviderFromConfig(cfg) + r.secrets[name] = f + case "plaintext", "": + cfg, ok := secret.Config.(secrets.GenericConfig) + if !ok { + return fmt.Errorf("expected secret config to be GenericConfig, but was %t", secret.Config) + } + + f := secrets.NewPlainSecretProviderFromConfig(cfg) + r.secrets[name] = f + default: + return fmt.Errorf("unknown secret provider type %q", secret.Kind) + } + + return nil + } + + // check all base types first + for _, secret := range config.Secrets { + if !isABaseSecretStorageKind(secret.Kind) { + continue + } + + if err := loadSecretConfig(secret); err != nil { + return err + } + } + + if err := r.loadDefaultSecretConfig(); err != nil { + return err + } + + // now load non-base types which might depend on them. + for _, secret := range config.Secrets { + if isABaseSecretStorageKind(secret.Kind) { + continue + } + + if err := loadSecretConfig(secret); err != nil { + return err + } + } + + return nil +} + +// loadDefaultSecretConfig loads configuration for types that should be available, +// assuming the user didn't override the configuration for them. +func (r *Registry) loadDefaultSecretConfig() error { + // set up the default supported types + if _, found := r.secrets["env"]; !found { + f := secrets.NewEnvSecretProviderFromConfig(secrets.GenericConfig{}) + r.secrets["env"] = f + } + + if _, found := r.secrets["file"]; !found { + f := secrets.NewFileSecretProviderFromConfig(secrets.FileConfig{}) + r.secrets["file"] = f + } + + if _, found := r.secrets["plaintext"]; !found { + f := secrets.NewPlainSecretProviderFromConfig(secrets.GenericConfig{}) + r.secrets["plaintext"] = f + } + + if _, found := r.secrets["kubernetes"]; !found { + // only setup k8s automatically if KUBERNETES_SERVICE_HOST is defined; ie, we are in the cluster. + if _, ok := os.LookupEnv("KUBERNETES_SERVICE_HOST"); ok { + k8s, err := secrets.NewKubernetesSecretProviderFromConfig(secrets.KubernetesConfig{}) + if err != nil { + return fmt.Errorf("creating k8s secret provider: %w", err) + } + + r.secrets["kubernetes"] = k8s + } + } + + return nil +} diff --git a/internal/registry/config_test.go b/internal/registry/config_test.go index 1a93b7b1b5..fd328b4c51 100644 --- a/internal/registry/config_test.go +++ b/internal/registry/config_test.go @@ -4,6 +4,7 @@ import ( "fmt" "io/ioutil" "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -14,7 +15,7 @@ import ( var db *gorm.DB var ( - fakeOktaSource = Source{Id: "001", Kind: SourceKindOkta, Domain: "test.example.com", ClientSecret: "okta-secrets/client-secret", APIToken: "okta-secrets/api-token"} + fakeOktaSource = Source{Id: "001", Kind: SourceKindOkta, Domain: "test.example.com", ClientSecret: "kubernetes:okta-secrets/client-secret", APIToken: "kubernetes:okta-secrets/api-token"} adminUser = User{Id: "001", Email: "admin@example.com"} standardUser = User{Id: "002", Email: "user@example.com"} iosDevUser = User{Id: "003", Email: "woz@example.com"} @@ -24,10 +25,12 @@ var ( clusterA = Destination{Name: "cluster-AAA"} clusterB = Destination{Name: "cluster-BBB"} clusterC = Destination{Name: "cluster-CCC"} + + registry *Registry ) func setup() error { - confFile, err := ioutil.ReadFile("_testdata/infra.yaml") + confFileData, err := ioutil.ReadFile("_testdata/infra.yaml") if err != nil { return err } @@ -104,7 +107,11 @@ func setup() error { return err } - return ImportConfig(db, confFile) + registry = &Registry{ + db: db, + } + + return registry.importConfig(confFileData) } func TestMain(m *testing.M) { @@ -327,3 +334,25 @@ func containsUserRoleForDestination(db *gorm.DB, user User, destinationId string return false, nil } + +func TestSecretsLoadedOkay(t *testing.T) { + foo, err := registry.secrets["plaintext"].GetSecret("foo") + require.NoError(t, err) + require.Equal(t, "foo", string(foo)) + + var importedOkta Source + err = db.Where(&Source{Kind: SourceKindOkta}).First(&importedOkta).Error + require.NoError(t, err) + + // simple manual secret reader + parts := strings.Split(importedOkta.ClientID, ":") + secretKind := parts[0] + + secretProvider, ok := registry.secrets[secretKind] + require.True(t, ok) + + secret, err := secretProvider.GetSecret(parts[1]) + require.NoError(t, err) + + require.Equal(t, "0oapn0qwiQPiMIyR35d6", string(secret)) +} diff --git a/internal/registry/data.go b/internal/registry/data.go index 171177c567..a0d3af77fe 100644 --- a/internal/registry/data.go +++ b/internal/registry/data.go @@ -16,7 +16,6 @@ import ( "time" "github.com/infrahq/infra/internal/generate" - "github.com/infrahq/infra/internal/kubernetes" "github.com/infrahq/infra/internal/logging" "gopkg.in/square/go-jose.v2" "gorm.io/driver/sqlite" @@ -320,20 +319,20 @@ func (s *Source) DeleteUser(db *gorm.DB, u User) error { } // Validate checks that an Okta source is valid -func (s *Source) Validate(db *gorm.DB, k8s *kubernetes.Kubernetes, okta Okta) error { +func (s *Source) Validate(r *Registry) error { switch s.Kind { case SourceKindOkta: - apiToken, err := k8s.GetSecret(s.APIToken) + apiToken, err := r.GetSecret(s.APIToken) if err != nil { // this logs the expected secret object location, not the actual secret return fmt.Errorf("could not retrieve okta API token from kubernetes secret %v: %w", s.APIToken, err) } - if _, err := k8s.GetSecret(s.ClientSecret); err != nil { + if _, err := r.GetSecret(s.ClientSecret); err != nil { return fmt.Errorf("could not retrieve okta client secret from kubernetes secret %v: %w", s.ClientSecret, err) } - return okta.ValidateOktaConnection(s.Domain, s.ClientID, apiToken) + return r.okta.ValidateOktaConnection(s.Domain, s.ClientID, apiToken) default: return nil } @@ -344,7 +343,7 @@ func (s *Source) SyncUsers(r *Registry) error { switch s.Kind { case SourceKindOkta: - apiToken, err := r.k8s.GetSecret(s.APIToken) + apiToken, err := r.GetSecret(s.APIToken) if err != nil { return fmt.Errorf("sync okta users api token: %w", err) } @@ -386,7 +385,7 @@ func (s *Source) SyncGroups(r *Registry) error { switch s.Kind { case SourceKindOkta: - apiToken, err := r.k8s.GetSecret(s.APIToken) + apiToken, err := r.GetSecret(s.APIToken) if err != nil { return fmt.Errorf("sync okta groups api secret: %w", err) } diff --git a/internal/registry/data_test.go b/internal/registry/data_test.go index 2a541502fe..bb30b8f839 100644 --- a/internal/registry/data_test.go +++ b/internal/registry/data_test.go @@ -3,11 +3,10 @@ package registry import ( "testing" - "github.com/infrahq/infra/internal/kubernetes" "github.com/infrahq/infra/internal/registry/mocks" + "github.com/infrahq/infra/secrets" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - rest "k8s.io/client-go/rest" ) func TestSyncGroupsClearsOnlySource(t *testing.T) { @@ -16,16 +15,12 @@ func TestSyncGroupsClearsOnlySource(t *testing.T) { testOkta := new(mocks.Okta) testOkta.On("Groups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mockGroups, nil) - testSecretReader := NewMockSecretReader() - testConfig := &rest.Config{ - Host: "https://localhost", - } - testK8s := &kubernetes.Kubernetes{Config: testConfig, SecretReader: testSecretReader} - r := &Registry{ - k8s: testK8s, db: db, okta: testOkta, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, } if err := fakeOktaSource.SyncGroups(r); err != nil { @@ -46,16 +41,12 @@ func TestSyncGroupsFromOktaIgnoresUnknownUsers(t *testing.T) { testOkta := new(mocks.Okta) testOkta.On("Groups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mockGroups, nil) - testSecretReader := NewMockSecretReader() - testConfig := &rest.Config{ - Host: "https://localhost", - } - testK8s := &kubernetes.Kubernetes{Config: testConfig, SecretReader: testSecretReader} - r := &Registry{ - k8s: testK8s, db: db, okta: testOkta, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, } if err := fakeOktaSource.SyncGroups(r); err != nil { @@ -77,16 +68,12 @@ func TestSyncGroupsFromOktaRecreatesGroups(t *testing.T) { testOkta := new(mocks.Okta) testOkta.On("Groups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mockGroups, nil) - testSecretReader := NewMockSecretReader() - testConfig := &rest.Config{ - Host: "https://localhost", - } - testK8s := &kubernetes.Kubernetes{Config: testConfig, SecretReader: testSecretReader} - r := &Registry{ - k8s: testK8s, db: db, okta: testOkta, + secrets: map[string]secrets.SecretStorage{ + "kubernetes": NewMockSecretReader(), + }, } if err := fakeOktaSource.SyncGroups(r); err != nil { diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 0ba3af6980..1405ab23b2 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -23,9 +23,9 @@ import ( "github.com/infrahq/infra/internal" "github.com/infrahq/infra/internal/api" "github.com/infrahq/infra/internal/certs" - "github.com/infrahq/infra/internal/kubernetes" "github.com/infrahq/infra/internal/logging" timer "github.com/infrahq/infra/internal/timer" + "github.com/infrahq/infra/secrets" "golang.org/x/crypto/acme/autocert" "gorm.io/gorm" ) @@ -53,9 +53,9 @@ type Registry struct { options Options db *gorm.DB settings Settings - k8s *kubernetes.Kubernetes okta Okta tel *Telemetry + secrets map[string]secrets.SecretStorage } const ( @@ -117,11 +117,6 @@ func Run(options Options) (err error) { scope.SetContext("registryId", r.settings.Id) }) - r.k8s, err = kubernetes.NewKubernetes() - if err != nil { - return fmt.Errorf("k8s: %w", err) - } - if err = r.loadConfigFromFile(); err != nil { return fmt.Errorf("loading config from file: %w", err) } @@ -167,7 +162,7 @@ func (r *Registry) loadConfigFromFile() (err error) { } if len(contents) > 0 { - err = ImportConfig(r.db, contents) + err = r.importConfig(contents) if err != nil { return err } @@ -188,7 +183,7 @@ func (r *Registry) validateSources() { for _, s := range sources { switch s.Kind { case SourceKindOkta: - if err := s.Validate(r.db, r.k8s, r.okta); err != nil { + if err := s.Validate(r); err != nil { logging.S.Errorf("could not validate okta: %w", err) } default: @@ -412,3 +407,47 @@ func (r *Registry) configureSentry() (err error, ok bool) { return nil, false } + +// GetSecret implements the secret definition scheme for Infra. +// eg plaintext:pass123, or kubernetes:infra-okta/apiToken +// it's an abstraction around all secret providers +func (r *Registry) GetSecret(name string) (string, error) { + var kind string + + if !strings.Contains(name, ":") { + // we'll have to guess at what type of secret it is. + // our default guesses are kubernetes, or plain + if strings.Count(name, "/") == 1 { + // guess kubernetes for historical reasons + kind = "kubernetes" + } else { + // guess plain because users sometimes mistake the field for plaintext + kind = "plaintext" + } + + logging.S.Warnf("Secret kind was not specified, expecting secrets in the format :. Assuming its kind is %q", kind) + } else { + parts := strings.SplitN(name, ":", 2) + if len(parts) < 2 { + return "", fmt.Errorf("unexpected secret provider format %q. Expecting :, eg env:API_KEY", name) + } + kind = parts[0] + name = parts[1] + } + + secretProvider, found := r.secrets[kind] + if !found { + return "", fmt.Errorf("secret provider %q not found in configuration for field %q", kind, name) + } + + b, err := secretProvider.GetSecret(name) + if err != nil { + return "", fmt.Errorf("getting secret: %w", err) + } + + if b == nil { + return "", nil + } + + return string(b), nil +} diff --git a/secrets/aws.go b/secrets/aws.go new file mode 100644 index 0000000000..37ed869c07 --- /dev/null +++ b/secrets/aws.go @@ -0,0 +1,8 @@ +package secrets + +type AWSConfig struct { + Endpoint string `yaml:"endpoint"` + Region string `yaml:"region"` + AccessKeyID string `yaml:"accessKeyId"` + SecretAccessKey string `yaml:"secretAccessKey"` +} diff --git a/secrets/awskms.go b/secrets/awskms.go index 78c21bf569..d1bf8fa448 100644 --- a/secrets/awskms.go +++ b/secrets/awskms.go @@ -4,6 +4,8 @@ import ( "fmt" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/kms" "github.com/aws/aws-sdk-go/service/kms/kmsiface" ) @@ -12,19 +14,57 @@ import ( var _ SecretSymmetricKeyProvider = &AWSKMSSecretProvider{} type AWSKMSSecretProvider struct { + AWSKMSConfig + kms kmsiface.KMSAPI } +type AWSKMSConfig struct { + AWSConfig + + EncryptionAlgorithm string `yaml:"encryptionAlgorithm"` + // aws tags? +} + +func NewAWSKMSConfig() AWSKMSConfig { + return AWSKMSConfig{ + EncryptionAlgorithm: "AES_256", + } +} + +func NewAWSKMSSecretProviderFromConfig(cfg AWSKMSConfig) (*AWSKMSSecretProvider, error) { + sess, err := session.NewSession() + if err != nil { + return nil, fmt.Errorf("creating aws session: %w", err) + } + + // for kms service + awscfg := aws.NewConfig(). + WithEndpoint(cfg.Endpoint). + WithCredentials(credentials.NewStaticCredentialsFromCreds( + credentials.Value{ + AccessKeyID: cfg.AccessKeyID, + SecretAccessKey: cfg.SecretAccessKey, + })). + WithRegion(cfg.Region) + + return &AWSKMSSecretProvider{ + AWSKMSConfig: cfg, + kms: kms.New(sess, awscfg), + }, nil +} + func NewAWSKMSSecretProvider(kmssvc kmsiface.KMSAPI) (*AWSKMSSecretProvider, error) { return &AWSKMSSecretProvider{ - kms: kmssvc, + AWSKMSConfig: NewAWSKMSConfig(), + kms: kmssvc, }, nil } func (k *AWSKMSSecretProvider) DecryptDataKey(rootKeyID string, keyData []byte) (*SymmetricKey, error) { req, out := k.kms.DecryptRequest(&kms.DecryptInput{ KeyId: &rootKeyID, - EncryptionAlgorithm: aws.String("AES_256"), + EncryptionAlgorithm: &k.EncryptionAlgorithm, CiphertextBlob: keyData, }) if err := req.Send(); err != nil { @@ -60,7 +100,7 @@ func (k *AWSKMSSecretProvider) GenerateDataKey(name, rootKeyID string) (*Symmetr } dko, err := k.kms.GenerateDataKey(&kms.GenerateDataKeyInput{ - KeySpec: aws.String("AES_256"), + KeySpec: &k.EncryptionAlgorithm, KeyId: aws.String(rootKeyID), }) if err != nil { @@ -71,6 +111,6 @@ func (k *AWSKMSSecretProvider) GenerateDataKey(name, rootKeyID string) (*Symmetr unencrypted: dko.Plaintext, Encrypted: dko.CiphertextBlob, RootKeyID: rootKeyID, - Algorithm: "AES_256", + Algorithm: k.EncryptionAlgorithm, }, nil } diff --git a/secrets/awssecretsmanager.go b/secrets/awssecretsmanager.go index 1745e88a49..49c1f56b28 100644 --- a/secrets/awssecretsmanager.go +++ b/secrets/awssecretsmanager.go @@ -6,18 +6,53 @@ import ( "fmt" "strings" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/secretsmanager" ) var _ SecretStorage = &AWSSecretsManager{} type AWSSecretsManager struct { - UseSecretMaps bool // TODO: support storing to json maps if this is enabled. + AWSSecretsManagerConfig client *secretsmanager.SecretsManager } +type AWSSecretsManagerConfig struct { + AWSConfig + + UseSecretMaps bool `yaml:"useSecretMaps"` // TODO: support storing to json maps if this is enabled. +} + +func NewAWSSecretsManagerFromConfig(cfg AWSSecretsManagerConfig) (*AWSSecretsManager, error) { + sess, err := session.NewSession() + if err != nil { + return nil, fmt.Errorf("creating aws session: %w", err) + } + + awscfg := aws.NewConfig(). + WithCredentials(credentials.NewCredentials(&credentials.StaticProvider{ + Value: credentials.Value{ + AccessKeyID: cfg.AccessKeyID, + SecretAccessKey: cfg.SecretAccessKey, + }, + })). + WithEndpoint(cfg.Endpoint). + WithRegion(cfg.Region) + + awssm := secretsmanager.New(sess, awscfg) + + sm := &AWSSecretsManager{ + AWSSecretsManagerConfig: cfg, + client: awssm, + } + + return sm, nil +} + func NewAWSSecretsManager(client *secretsmanager.SecretsManager) *AWSSecretsManager { return &AWSSecretsManager{ client: client, diff --git a/secrets/awssystemmanagerparameterstore.go b/secrets/awsssm.go similarity index 60% rename from secrets/awssystemmanagerparameterstore.go rename to secrets/awsssm.go index 94ca03abb4..801cdfc448 100644 --- a/secrets/awssystemmanagerparameterstore.go +++ b/secrets/awsssm.go @@ -8,18 +8,48 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ssm" ) -var _ SecretStorage = &AWSSystemManagerParameterStore{} +var _ SecretStorage = &AWSSSM{} -type AWSSystemManagerParameterStore struct { - KeyID string // KMS key to use for decryption +// AWSSSM is the AWS System Manager Parameter Store (aka SSM PS) +type AWSSSM struct { + AWSSSMConfig client *ssm.SSM } -func NewAWSSystemManagerParameterStore(client *ssm.SSM) *AWSSystemManagerParameterStore { - return &AWSSystemManagerParameterStore{ +type AWSSSMConfig struct { + AWSConfig + KeyID string `yaml:"keyId"` // KMS key to use for decryption +} + +func NewAWSSSMSecretProviderFromConfig(cfg AWSSSMConfig) (*AWSSSM, error) { + sess, err := session.NewSession() + if err != nil { + return nil, fmt.Errorf("creating aws session: %w", err) + } + + awscfg := aws.NewConfig(). + WithCredentials(credentials.NewCredentials(&credentials.StaticProvider{ + Value: credentials.Value{ + AccessKeyID: cfg.AccessKeyID, + SecretAccessKey: cfg.SecretAccessKey, + }, + })). + WithEndpoint(cfg.Endpoint). + WithRegion(cfg.Region) + + return &AWSSSM{ + AWSSSMConfig: cfg, + client: ssm.New(sess, awscfg), + }, nil +} + +func NewAWSSSM(client *ssm.SSM) *AWSSSM { + return &AWSSSM{ client: client, } } @@ -32,7 +62,7 @@ var invalidSecretNameChars = regexp.MustCompile(`[^a-zA-Z0-9_.-/]`) // if using kms customer-managed keys, also need: // - kms:GenerateDataKey // - kms:Decrypt -func (s *AWSSystemManagerParameterStore) SetSecret(name string, secret []byte) error { +func (s *AWSSSM) SetSecret(name string, secret []byte) error { name = invalidSecretNameChars.ReplaceAllString(name, "_") secretStr := string(secret) @@ -58,7 +88,7 @@ func (s *AWSSystemManagerParameterStore) SetSecret(name string, secret []byte) e // GetSecret // must have permission secretsmanager:GetSecretValue // kms:Decrypt - required only if you use a customer-managed Amazon Web Services KMS key to encrypt the secret -func (s *AWSSystemManagerParameterStore) GetSecret(name string) (secret []byte, err error) { +func (s *AWSSSM) GetSecret(name string) (secret []byte, err error) { name = invalidSecretNameChars.ReplaceAllString(name, "_") p, err := s.client.GetParameterWithContext(context.TODO(), &ssm.GetParameterInput{ diff --git a/secrets/env.go b/secrets/env.go new file mode 100644 index 0000000000..75a150bddb --- /dev/null +++ b/secrets/env.go @@ -0,0 +1,92 @@ +package secrets + +import ( + "encoding/base64" + "errors" + "fmt" + "os" + "regexp" + "strings" +) + +// implements env storage for secret config + +type EnvSecretProvider struct { + GenericConfig +} + +func NewEnvSecretProviderFromConfig(cfg GenericConfig) *EnvSecretProvider { + return &EnvSecretProvider{ + GenericConfig: cfg, + } +} + +var _ SecretStorage = &EnvSecretProvider{} + +func (fp *EnvSecretProvider) SetSecret(name string, secret []byte) error { + if strings.Contains(name, "$") { + return errors.New("ENV secrets cannot contain $") + } + + name = invalidNameChars.ReplaceAllString(name, "_") + + var b []byte + + if fp.Base64 { + b = make([]byte, fp.encoder().EncodedLen(len(secret))) + fp.encoder().Encode(b, secret) + } else { + b = make([]byte, len(secret)) + copy(b, secret) + } + + if err := os.Setenv(name, string(b)); err != nil { + return fmt.Errorf("setenv: %w", err) + } + + return nil +} + +var invalidNameChars = regexp.MustCompile(`[^\w\d-]`) + +func (fp *EnvSecretProvider) GetSecret(name string) (secret []byte, err error) { + var b []byte + if strings.Contains(name, "$") { + b = []byte(os.ExpandEnv(name)) + } else { + name = invalidNameChars.ReplaceAllString(name, "_") + b = []byte(os.Getenv(name)) + } + + var result []byte + if fp.Base64 { + result = make([]byte, fp.encoder().DecodedLen(len(b))) + + written, err := fp.encoder().Decode(result, b) + if err != nil { + return nil, fmt.Errorf("base64 decoding %q: %w", name, err) + } + + result = result[:written] + + return result, nil + } + + return b, nil +} + +func (fp *EnvSecretProvider) encoder() *base64.Encoding { + if fp.Base64URLEncoded { + if fp.Base64Raw { + return base64.RawURLEncoding + } else { + return base64.URLEncoding + } + } else { // std encoding + if fp.Base64Raw { + return base64.RawStdEncoding + } else { + return base64.StdEncoding + } + } +} diff --git a/secrets/file.go b/secrets/file.go new file mode 100644 index 0000000000..597f70a1c2 --- /dev/null +++ b/secrets/file.go @@ -0,0 +1,120 @@ +package secrets + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "os" + "path" +) + +// implements file storage for secret config + +type GenericConfig struct { + Base64 bool `yaml:"base64"` + Base64URLEncoded bool `yaml:"base64UrlEncoded"` + Base64Raw bool `yaml:"base64Raw"` +} + +type FileConfig struct { + GenericConfig + Path string `yaml:"path"` +} + +type FileSecretProvider struct { + FileConfig +} + +func NewFileSecretProviderFromConfig(cfg FileConfig) *FileSecretProvider { + return &FileSecretProvider{ + FileConfig: cfg, + } +} + +var _ SecretStorage = &FileSecretProvider{} + +func (fp *FileSecretProvider) SetSecret(name string, secret []byte) error { + fullPath := name + + if len(fp.Path) > 0 { + fullPath = path.Join(fp.Path, name) + } + + dir := path.Dir(fullPath) + if len(dir) > 0 { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %q: %w", fp.Path, err) + } + } + + var b []byte + + if fp.Base64 { + b = make([]byte, fp.encoder().EncodedLen(len(secret))) + fp.encoder().Encode(b, secret) + } else { + b = make([]byte, len(secret)) + copy(b, secret) + } + + f, err := os.Create(fullPath) + if err != nil { + return fmt.Errorf("creating file %q: %w", fullPath, err) + } + + if _, err := f.Write(b); err != nil { + return fmt.Errorf("writing file %q: %w", fullPath, err) + } + + return nil +} + +func (fp *FileSecretProvider) GetSecret(name string) (secret []byte, err error) { + fullPath := path.Join(fp.Path, name) + + f, err := os.Open(fullPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, fmt.Errorf("opening file %q: %w", fullPath, err) + } + + b, err := ioutil.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("reading file %q: %w", fullPath, err) + } + + var result []byte + if fp.Base64 { + result = make([]byte, fp.encoder().DecodedLen(len(b))) + + written, err := fp.encoder().Decode(result, b) + if err != nil { + return nil, fmt.Errorf("base64 decoding file %q: %w", fullPath, err) + } + + result = result[:written] + + return result, nil + } + + return b, nil +} + +func (fp *FileSecretProvider) encoder() *base64.Encoding { + if fp.Base64URLEncoded { + if fp.Base64Raw { + return base64.RawURLEncoding + } else { + return base64.URLEncoding + } + } else { // std encoding + if fp.Base64Raw { + return base64.RawStdEncoding + } else { + return base64.StdEncoding + } + } +} diff --git a/secrets/kubernetes.go b/secrets/kubernetes.go index 9a8e4f35f9..af2bac064a 100644 --- a/secrets/kubernetes.go +++ b/secrets/kubernetes.go @@ -12,19 +12,43 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" ) var _ SecretStorage = &KubernetesSecretProvider{} type KubernetesSecretProvider struct { - Namespace string - client *kubernetes.Clientset + KubernetesConfig + client *kubernetes.Clientset +} + +type KubernetesConfig struct { + Namespace string `yaml:"namespace"` +} + +func NewKubernetesSecretProviderFromConfig(cfg KubernetesConfig) (*KubernetesSecretProvider, error) { + k8sConfig, err := rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("getting in-cluster config: %w", err) + } + + clientset, err := kubernetes.NewForConfig(k8sConfig) + if err != nil { + return nil, fmt.Errorf("creating k8s config: %w", err) + } + + return &KubernetesSecretProvider{ + KubernetesConfig: cfg, + client: clientset, + }, nil } func NewKubernetesSecretProvider(client *kubernetes.Clientset, namespace string) *KubernetesSecretProvider { return &KubernetesSecretProvider{ - Namespace: namespace, - client: client, + KubernetesConfig: KubernetesConfig{ + Namespace: namespace, + }, + client: client, } } diff --git a/secrets/plain.go b/secrets/plain.go new file mode 100644 index 0000000000..0b117392b5 --- /dev/null +++ b/secrets/plain.go @@ -0,0 +1,63 @@ +package secrets + +import ( + "encoding/base64" + "errors" + "fmt" +) + +var ErrNotImplemented = errors.New("not implemented") + +// implements plain "storage" for secret config + +type PlainSecretProvider struct { + GenericConfig +} + +func NewPlainSecretProviderFromConfig(cfg GenericConfig) *PlainSecretProvider { + return &PlainSecretProvider{ + GenericConfig: cfg, + } +} + +var _ SecretStorage = &PlainSecretProvider{} + +func (fp *PlainSecretProvider) SetSecret(name string, secret []byte) error { + return ErrNotImplemented // and not really possible to implement... +} + +func (fp *PlainSecretProvider) GetSecret(name string) (secret []byte, err error) { + b := []byte(name) + + var result []byte + if fp.Base64 { + result = make([]byte, fp.encoder().DecodedLen(len(b))) + + written, err := fp.encoder().Decode(result, b) + if err != nil { + return nil, fmt.Errorf("base64 decoding: %w", err) + } + + result = result[:written] + + return result, nil + } + + return b, nil +} + +func (fp *PlainSecretProvider) encoder() *base64.Encoding { + if fp.Base64URLEncoded { + if fp.Base64Raw { + return base64.RawURLEncoding + } else { + return base64.URLEncoding + } + } else { // std encoding + if fp.Base64Raw { + return base64.RawStdEncoding + } else { + return base64.StdEncoding + } + } +} diff --git a/secrets/secrets.go b/secrets/secrets.go index e5378a2427..48072d6332 100644 --- a/secrets/secrets.go +++ b/secrets/secrets.go @@ -17,6 +17,16 @@ type SecretStorage interface { GetSecret(name string) (secret []byte, err error) } +var SecretStorageProviderKinds = []string{ + "vault", + "awsssm", + "awssecretsmanager", + "kubernetes", + "env", + "file", + "plaintext", +} + // SecretSymmetricKeyProvider is implemented by a provider that provides encryption-as-a-service. // Its use is opinionated about the provider in the following ways: // - A root key will be created or referenced and never leaves the provider @@ -34,6 +44,11 @@ type SecretSymmetricKeyProvider interface { DecryptDataKey(rootKeyID string, keyData []byte) (*SymmetricKey, error) } +var SecretSymmetricKeyProviderKinds = []string{ + "vault", + "awskms", +} + type SymmetricKey struct { unencrypted []byte `json:"-"` // the unencrypted data key. Retrieved with DecryptDataKey or set by GenerateDataKey. This field *MUST NOT* be persisted. Encrypted []byte `json:"key"` // the encrypted data key. To be stored by caller. diff --git a/secrets/secrets_test.go b/secrets/secrets_test.go index 906768744f..d040629cde 100644 --- a/secrets/secrets_test.go +++ b/secrets/secrets_test.go @@ -62,7 +62,7 @@ func setup() { }, nil, // cmd []string{ - "SERVICES=secretsmanager,ssm", + "SERVICES=secretsmanager,ssm,events", }, ) containerIDs = append(containerIDs, containerID) @@ -155,7 +155,7 @@ func eachProvider(t *testing.T, eachFunc func(t *testing.T, p interface{})) { // add AWS SSM (Systems Manager Parameter Store) awsssm := ssm.New(sess, localstackCfg) - ssm := NewAWSSystemManagerParameterStore(awsssm) + ssm := NewAWSSSM(awsssm) providers["awsssm"] = ssm @@ -178,6 +178,21 @@ func eachProvider(t *testing.T, eachFunc func(t *testing.T, p interface{})) { providers["kubernetes"] = k8s + // add "file" + providers["file"] = &FileSecretProvider{ + FileConfig: FileConfig{ + GenericConfig: GenericConfig{ + Base64: true, + // Base64Raw: true, + }, + }, + } + + // add "env" + providers["env"] = &EnvSecretProvider{ + GenericConfig: GenericConfig{Base64: true}, + } + for name, provider := range providers { t.Run(name, func(t *testing.T) { eachFunc(t, provider) diff --git a/secrets/vault.go b/secrets/vault.go index 963753182a..7391378038 100644 --- a/secrets/vault.go +++ b/secrets/vault.go @@ -17,39 +17,57 @@ var ( ) type VaultSecretProvider struct { - TransitMount string `yaml:"transit_mount"` // mounting point. defaults to /transit - SecretMount string `yaml:"secret_mount"` // mounting point. defaults to /secret - Token string `yaml:"token"` // vault token... should authenticate as machine to vault instead? + VaultConfig + client *vault.Client +} + +type VaultConfig struct { + TransitMount string `yaml:"transitMount"` // mounting point. defaults to /transit + SecretMount string `yaml:"secretMount"` // mounting point. defaults to /secret + Token string `yaml:"token"` // vault token... should authenticate as machine to vault instead? Namespace string `yaml:"namespace"` + Address string `yaml:"address"` +} - client *vault.Client +func NewVaultConfig() VaultConfig { + return VaultConfig{ + TransitMount: "/transit", + SecretMount: "/secret", + Address: "https://vault", + } } -func NewVaultSecretProvider(address, token, namespace string) (*VaultSecretProvider, error) { +func NewVaultSecretProviderFromConfig(cfg VaultConfig) (*VaultSecretProvider, error) { c, err := vault.NewClient(&vault.Config{ - Address: address, + Address: cfg.Address, }) if err != nil { return nil, err } - c.SetToken(token) + c.SetToken(cfg.Token) - if len(namespace) > 0 { - c.SetNamespace(namespace) + if len(cfg.Namespace) > 0 { + c.SetNamespace(cfg.Namespace) } v := &VaultSecretProvider{ - TransitMount: "/transit", - SecretMount: "/secret", - Token: token, - Namespace: namespace, - client: c, + VaultConfig: cfg, + client: c, } return v, nil } +func NewVaultSecretProvider(address, token, namespace string) (*VaultSecretProvider, error) { + cfg := NewVaultConfig() + cfg.Address = address + cfg.Token = token + cfg.Namespace = namespace + + return NewVaultSecretProviderFromConfig(cfg) +} + func (v *VaultSecretProvider) GetSecret(name string) ([]byte, error) { name = nameEscape(name) path := fmt.Sprintf("%s/data/%s", v.SecretMount, name)