diff --git a/README.md b/README.md index 34c2b297..49d6582b 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The following table lists the configurable parameters of the `kubernetes-externa | `env.METRICS_PORT` | Specify the port for the prometheus metrics server | `3001` | | `env.ROLE_PERMITTED_ANNOTATION` | Specify the annotation key where to lookup the role arn permission boundaries | `iam.amazonaws.com/permitted` | | `env.POLLER_INTERVAL_MILLISECONDS` | Set POLLER_INTERVAL_MILLISECONDS in Deployment Pod | `10000` | +| `env.VAULT_ADDR` | Endpoint for the Vault backend, if using Vault | `http://127.0.0.1:8200 | | `envVarsFromSecret.AWS_ACCESS_KEY_ID` | Set AWS_ACCESS_KEY_ID (from a secret) in Deployment Pod | | | `envVarsFromSecret.AWS_SECRET_ACCESS_KEY` | Set AWS_SECRET_ACCESS_KEY (from a secret) in Deployment Pod | | | `image.repository` | kubernetes-external-secrets Image name | `godaddy/kubernetes-external-secrets` | @@ -217,7 +218,7 @@ data: ## Backends -kubernetes-external-secrets supports both AWS Secrets Manager and AWS System Manager. +kubernetes-external-secrets supports AWS Secrets Manager, AWS System Manager, and Hashicorp Vault. ### AWS Secrets Manager @@ -289,6 +290,36 @@ spec: property: password ``` +### Hashicorp Vault + +kubernetes-external-secrets supports fetching secrets from [Hashicorp Vault](https://www.vaultproject.io/), using the [Kubernetes authentication method](https://www.vaultproject.io/docs/auth/kubernetes.html). + +You will need to set the `VAULT_ADDR` environment variables so that kubernetes-external-secrets knows which endpoint to connect to, then create `ExternalSecret` definitions as follows: + +```yml +apiVersion: 'kubernetes-client.io/v1' +kind: ExternalSecret +metadata: + name: hello-vault-service +spec: + backendType: vault + # Your authentication mount point, e.g. "kubernetes" + vaultMountPoint: my-kubernetes-vault-mount-point + # The vault role that will be used to fetch the secrets + # This role will need to be bound to kubernetes-external-secret's ServiceAccount; see Vault's documentation: + # https://www.vaultproject.io/docs/auth/kubernetes.html + vaultRole: my-vault-role + data: + - name: password + # The full path of the secret to read, as in `vault read secret/data/hello-service/credentials` + key: secret/data/hello-service/credentials + property: password + # Vault values are matched individually. If you have several keys in your Vault secret, you will need to add them all separately + - name: api-key + key: secret/data/hello-service/credentials + property: api-key +``` + ## Metrics kubernetes-external-secrets exposes the following metrics over a prometheus endpoint: diff --git a/charts/kubernetes-external-secrets/templates/deployment.yaml b/charts/kubernetes-external-secrets/templates/deployment.yaml index 72ea4425..8ac3afee 100644 --- a/charts/kubernetes-external-secrets/templates/deployment.yaml +++ b/charts/kubernetes-external-secrets/templates/deployment.yaml @@ -48,6 +48,10 @@ spec: name: {{ $value.secretKeyRef | quote }} key: {{ $value.key | quote }} {{- end }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/charts/kubernetes-external-secrets/templates/rbac.yaml b/charts/kubernetes-external-secrets/templates/rbac.yaml index b979b7a1..4c76702e 100644 --- a/charts/kubernetes-external-secrets/templates/rbac.yaml +++ b/charts/kubernetes-external-secrets/templates/rbac.yaml @@ -46,4 +46,22 @@ subjects: - name: {{ template "kubernetes-external-secrets.serviceAccountName" . }} namespace: {{ .Release.Namespace | quote }} kind: ServiceAccount +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: {{ include "kubernetes-external-secrets.fullname" . }}-auth + labels: + app.kubernetes.io/name: {{ include "kubernetes-external-secrets.name" . }} + helm.sh/chart: {{ include "kubernetes-external-secrets.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: +- name: {{ template "kubernetes-external-secrets.serviceAccountName" . }} + namespace: {{ .Release.Namespace | quote }} + kind: ServiceAccount {{- end -}} diff --git a/charts/kubernetes-external-secrets/values.yaml b/charts/kubernetes-external-secrets/values.yaml index 2584b660..f48cdfd4 100644 --- a/charts/kubernetes-external-secrets/values.yaml +++ b/charts/kubernetes-external-secrets/values.yaml @@ -8,6 +8,7 @@ env: POLLER_INTERVAL_MILLISECONDS: 10000 LOG_LEVEL: info METRICS_PORT: 3001 + VAULT_ADDR: http://127.0.0.1:8200 # Create environment variables from exists k8s secrets # envVarsFromSecret: diff --git a/config/environment.js b/config/environment.js index 94a221fd..b2cf2dd8 100644 --- a/config/environment.js +++ b/config/environment.js @@ -16,6 +16,7 @@ if (environment === 'development') { require('dotenv').config() } +const vaultEndpoint = process.env.VAULT_ADDR || 'http://127.0.0.1:8200' const pollerIntervalMilliseconds = process.env.POLLER_INTERVAL_MILLISECONDS ? Number(process.env.POLLER_INTERVAL_MILLISECONDS) : 10000 @@ -26,6 +27,7 @@ const rolePermittedAnnotation = process.env.ROLE_PERMITTED_ANNOTATION || 'iam.am const metricsPort = process.env.METRICS_PORT || 3001 module.exports = { + vaultEndpoint, environment, pollerIntervalMilliseconds, metricsPort, diff --git a/config/index.js b/config/index.js index 04bfab25..61ba4356 100644 --- a/config/index.js +++ b/config/index.js @@ -1,5 +1,6 @@ 'use strict' +const vault = require('node-vault') const kube = require('kubernetes-client') const KubeRequest = require('kubernetes-client/backends/request') const pino = require('pino') @@ -10,6 +11,7 @@ const CustomResourceManager = require('../lib/custom-resource-manager') const customResourceManifest = require('../custom-resource-manifest.json') const SecretsManagerBackend = require('../lib/backends/secrets-manager-backend') const SystemManagerBackend = require('../lib/backends/system-manager-backend') +const VaultBackend = require('../lib/backends/vault-backend') const kubeconfig = new kube.KubeConfig() kubeconfig.loadFromDefault() @@ -38,9 +40,12 @@ const systemManagerBackend = new SystemManagerBackend({ assumeRole: awsConfig.assumeRole, logger }) +const vaultClient = vault({ apiVersion: 'v1', endpoint: envConfig.vaultEndpoint }) +const vaultBackend = new VaultBackend({ client: vaultClient, logger }) const backends = { secretsManager: secretsManagerBackend, - systemManager: systemManagerBackend + systemManager: systemManagerBackend, + vault: vaultBackend } // backwards compatibility diff --git a/examples/hello-service-external-secret-vault.yml b/examples/hello-service-external-secret-vault.yml new file mode 100644 index 00000000..17830d29 --- /dev/null +++ b/examples/hello-service-external-secret-vault.yml @@ -0,0 +1,12 @@ +apiVersion: 'kubernetes-client.io/v1' +kind: ExternalSecret +metadata: + name: hello-service +spec: + backendType: vault + vaultMountPoint: my-kubernetes-vault-mount-point + vaultRole: my-vault-role + data: + - name: password + key: secret/data/hello-service/password + property: password diff --git a/external-secrets.yml b/external-secrets.yml index cf6065bb..274caf13 100644 --- a/external-secrets.yml +++ b/external-secrets.yml @@ -36,6 +36,19 @@ rules: resources: ["externalsecrets/status"] verbs: ["get", "update"] --- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: kubernetes-external-secrets-cluster-role-binding-auth +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: +- kind: ServiceAccount + name: kubernetes-external-secrets-service-account + namespace: kubernetes-external-secrets +--- apiVersion: v1 kind: Namespace metadata: diff --git a/lib/backends/vault-backend.js b/lib/backends/vault-backend.js new file mode 100644 index 00000000..987d664f --- /dev/null +++ b/lib/backends/vault-backend.js @@ -0,0 +1,97 @@ +'use strict' + +const KVBackend = require('./kv-backend') + +/** Vault backend class. */ +class VaultBackend extends KVBackend { + /** + * Create Vault backend. + * @param {Object} client - Client for interacting with Vault. + * @param {Object} logger - Logger for logging stuff. + */ + constructor ({ client, logger }) { + super({ logger }) + this._client = client + } + + /** + * Fetch Kubernetes service account token. + * @returns {string} String representing the token of the service account running this pod. + */ + _fetchServiceAccountToken () { + if (!this._serviceAccountToken) { + const fs = require('fs') + this._serviceAccountToken = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/token', 'utf8') + } + return this._serviceAccountToken + } + + /** + * Fetch Kubernetes secret property values. + * @param {Object[]} secretProperties - Kubernetes secret properties. + * @param {string} secretProperties[].key - Secret key in the backend. + * @param {string} secretProperties[].name - Kubernetes Secret property name. + * @param {string} secretProperties[].property - If the backend secret is an + * object, this is the property name of the value to use. + * @returns {Promise} Promise object representing secret property values. + */ + _fetchSecretPropertyValues ({ vaultMountPoint, vaultRole, jwt, externalData }) { + return Promise.all(externalData.map(async secretProperty => { + this._logger.info(`fetching secret property ${secretProperty.key}`) + const value = await this._get({ vaultMountPoint: vaultMountPoint, vaultRole: vaultRole, jwt: jwt, secretKey: secretProperty.key }) + + return value[secretProperty.property] + })) + } + + /** + * Get secret property value from Vault. + * @param {string} secretKey - Key used to store secret property value in Vault. + * @returns {Promise} Promise object representing secret property value. + */ + async _get ({ vaultMountPoint, vaultRole, secretKey }) { + if (!this._client.token) { + const jwt = this._fetchServiceAccountToken() + this._logger.debug(`fetching new token from vault`) + const vault = await this._client.kubernetesLogin({ + mount_point: vaultMountPoint, + role: vaultRole, + jwt: jwt + }) + this._client.token = vault.auth.client_token + } else { + this._logger.debug(`renewing existing token from vault`) + this._client.tokenRenewSelf() + } + + this._logger.debug(`reading secret key ${secretKey} from vault`) + const secretResponse = await this._client.read(secretKey) + + return secretResponse.data.data + } + + /** + * Fetch Kubernetes secret manifest data. + * @param {ExternalSecretSpec} spec - Kubernetes ExternalSecret spec. + * @returns {Promise} Promise object representing Kubernetes secret manifest data. + */ + async getSecretManifestData ({ spec }) { + const data = {} + const vaultMountPoint = spec.vaultMountPoint + const vaultRole = spec.vaultRole + + // Also support spec.properties to be backwards compatible. + const externalData = spec.data || spec.properties + const secretPropertyValues = await this._fetchSecretPropertyValues({ + vaultMountPoint, + vaultRole, + externalData + }) + externalData.forEach((secret, index) => { + data[secret.name] = (Buffer.from(secretPropertyValues[index], 'utf8')).toString('base64') + }) + return data + } +} + +module.exports = VaultBackend diff --git a/lib/backends/vault-backend.test.js b/lib/backends/vault-backend.test.js new file mode 100644 index 00000000..8c82e11a --- /dev/null +++ b/lib/backends/vault-backend.test.js @@ -0,0 +1,95 @@ +/* eslint-env mocha */ +'use strict' + +const { expect } = require('chai') +const sinon = require('sinon') +const pino = require('pino') + +const VaultBackend = require('./vault-backend') +const logger = pino({ + serializers: { + err: pino.stdSerializers.err + } +}) + +describe('VaultBackend', () => { + let clientMock + let vaultBackend + + beforeEach(() => { + clientMock = sinon.mock() + + vaultBackend = new VaultBackend({ + client: clientMock, + logger + }) + }) + + describe('_get', () => { + const mountPoint = 'fakeMountPoint' + const role = 'fakeRole' + const secretKey = 'fakeSecretKey' + const jwt = 'this-is-a-jwt-token' + + beforeEach(() => { + clientMock.read = sinon.stub().returns({ + data: { + data: 'fakeSecretPropertyValue' + } + }) + clientMock.tokenRenewSelf = sinon.stub().returns(true) + clientMock.kubernetesLogin = sinon.stub().returns({ + auth: { + client_token: '1234' + } + }) + + vaultBackend._fetchServiceAccountToken = sinon.stub().returns(jwt) + + clientMock.token = undefined + }) + + it('logs in and returns secret property value', async () => { + const secretPropertyValue = await vaultBackend._get({ + vaultMountPoint: mountPoint, + vaultRole: role, + secretKey: secretKey + }) + + // First, we log into Vault... + sinon.assert.calledWith(clientMock.kubernetesLogin, { + mount_point: 'fakeMountPoint', + role: 'fakeRole', + jwt: jwt + }) + + // ... then we fetch the secret ... + sinon.assert.calledWith(clientMock.read, 'fakeSecretKey') + + // ... and expect to get its proper value + expect(secretPropertyValue).equals('fakeSecretPropertyValue') + }) + + it('returns secret property value after renewing token if a token exists', async () => { + clientMock.token = 'an-existing-token' + + const secretPropertyValue = await vaultBackend._get({ + vaultMountPoint: mountPoint, + vaultRole: role, + secretKey: secretKey + }) + + // No logging into Vault... + sinon.assert.notCalled(clientMock.kubernetesLogin) + + // ... but renew the token instead ... + sinon.assert.calledOnce(clientMock.tokenRenewSelf) + + // ... then we fetch the secret ... + sinon.assert.calledWith(clientMock.read, 'fakeSecretKey') + + // ... and expect to get its proper value + expect(secretPropertyValue).equals('fakeSecretPropertyValue') + }) + }) +}) diff --git a/package-lock.json b/package-lock.json index e60c0606..adc8a7b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4994,6 +4994,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" }, + "mustache": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-2.3.2.tgz", + "integrity": "sha512-KpMNwdQsYz3O/SBS1qJ/o3sqUJ5wSb8gb0pul8CO0S56b9Y2ALm8zCfsjPXsqGFfoNBkDwZuZIAjhsZI03gYVQ==" + }, "mute-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", @@ -5094,6 +5099,33 @@ } } }, + "node-vault": { + "version": "0.9.11", + "resolved": "https://registry.npmjs.org/node-vault/-/node-vault-0.9.11.tgz", + "integrity": "sha512-v4xfXtuJuVzQrf8yYh+8/z7yf+UA4O4W0YaOF4rl4TBjK03ryKZnSNnTpfxYTL22pzc9bg7SsLwXi3pWKnACwg==", + "requires": { + "debug": "3.1.0", + "mustache": "^2.2.1", + "request": "2.88.0", + "request-promise-native": "1.0.7", + "tv4": "^1.2.7" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, "nodemon": { "version": "1.18.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.18.10.tgz", @@ -6221,6 +6253,24 @@ } } }, + "request-promise-core": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz", + "integrity": "sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag==", + "requires": { + "lodash": "^4.17.11" + } + }, + "request-promise-native": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.7.tgz", + "integrity": "sha512-rIMnbBdgNViL37nZ1b3L/VfPOpSi0TqVDQPAvO6U14lMzOLrt5nilxCQqtDKhZeDiW0/hkCXGoQjhgJd/tCh6w==", + "requires": { + "request-promise-core": "1.1.2", + "stealthy-require": "^1.1.1", + "tough-cookie": "^2.3.3" + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7002,6 +7052,11 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" }, + "stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -7360,6 +7415,11 @@ "safe-buffer": "^5.0.1" } }, + "tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha1-0CDIRvrdUMhVq7JeuuzGj8EPeWM=" + }, "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/package.json b/package.json index e48621dc..e74b15fe 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "lodash.clonedeep": "^4.5.0", "lodash.merge": "^4.6.2", "make-promises-safe": "^5.0.0", + "node-vault": "^0.9.8", "pino": "^5.12.0", "prom-client": "^11.5.3" },