Skip to content
This repository was archived by the owner on Jul 26, 2022. It is now read-only.

Commit d61312c

Browse files
PluiesSilas Boyd-Wickizer
authored and
Silas Boyd-Wickizer
committed
feat(vault): Support for Hashicorp Vault (#198)
1 parent a2a9dff commit d61312c

12 files changed

+341
-2
lines changed

README.md

+32-1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ The following table lists the configurable parameters of the `kubernetes-externa
5757
| `env.METRICS_PORT` | Specify the port for the prometheus metrics server | `3001` |
5858
| `env.ROLE_PERMITTED_ANNOTATION` | Specify the annotation key where to lookup the role arn permission boundaries | `iam.amazonaws.com/permitted` |
5959
| `env.POLLER_INTERVAL_MILLISECONDS` | Set POLLER_INTERVAL_MILLISECONDS in Deployment Pod | `10000` |
60+
| `env.VAULT_ADDR` | Endpoint for the Vault backend, if using Vault | `http://127.0.0.1:8200 |
6061
| `envVarsFromSecret.AWS_ACCESS_KEY_ID` | Set AWS_ACCESS_KEY_ID (from a secret) in Deployment Pod | |
6162
| `envVarsFromSecret.AWS_SECRET_ACCESS_KEY` | Set AWS_SECRET_ACCESS_KEY (from a secret) in Deployment Pod | |
6263
| `image.repository` | kubernetes-external-secrets Image name | `godaddy/kubernetes-external-secrets` |
@@ -217,7 +218,7 @@ data:
217218

218219
## Backends
219220

220-
kubernetes-external-secrets supports both AWS Secrets Manager and AWS System Manager.
221+
kubernetes-external-secrets supports AWS Secrets Manager, AWS System Manager, and Hashicorp Vault.
221222

222223
### AWS Secrets Manager
223224

@@ -289,6 +290,36 @@ spec:
289290
property: password
290291
```
291292

293+
### Hashicorp Vault
294+
295+
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).
296+
297+
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:
298+
299+
```yml
300+
apiVersion: 'kubernetes-client.io/v1'
301+
kind: ExternalSecret
302+
metadata:
303+
name: hello-vault-service
304+
spec:
305+
backendType: vault
306+
# Your authentication mount point, e.g. "kubernetes"
307+
vaultMountPoint: my-kubernetes-vault-mount-point
308+
# The vault role that will be used to fetch the secrets
309+
# This role will need to be bound to kubernetes-external-secret's ServiceAccount; see Vault's documentation:
310+
# https://www.vaultproject.io/docs/auth/kubernetes.html
311+
vaultRole: my-vault-role
312+
data:
313+
- name: password
314+
# The full path of the secret to read, as in `vault read secret/data/hello-service/credentials`
315+
key: secret/data/hello-service/credentials
316+
property: password
317+
# Vault values are matched individually. If you have several keys in your Vault secret, you will need to add them all separately
318+
- name: api-key
319+
key: secret/data/hello-service/credentials
320+
property: api-key
321+
```
322+
292323
## Metrics
293324
294325
kubernetes-external-secrets exposes the following metrics over a prometheus endpoint:

charts/kubernetes-external-secrets/templates/deployment.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ spec:
4848
name: {{ $value.secretKeyRef | quote }}
4949
key: {{ $value.key | quote }}
5050
{{- end }}
51+
{{- with .Values.securityContext }}
52+
securityContext:
53+
{{- toYaml . | nindent 8 }}
54+
{{- end }}
5155
{{- with .Values.nodeSelector }}
5256
nodeSelector:
5357
{{- toYaml . | nindent 8 }}

charts/kubernetes-external-secrets/templates/rbac.yaml

+18
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,22 @@ subjects:
4646
- name: {{ template "kubernetes-external-secrets.serviceAccountName" . }}
4747
namespace: {{ .Release.Namespace | quote }}
4848
kind: ServiceAccount
49+
---
50+
apiVersion: rbac.authorization.k8s.io/v1beta1
51+
kind: ClusterRoleBinding
52+
metadata:
53+
name: {{ include "kubernetes-external-secrets.fullname" . }}-auth
54+
labels:
55+
app.kubernetes.io/name: {{ include "kubernetes-external-secrets.name" . }}
56+
helm.sh/chart: {{ include "kubernetes-external-secrets.chart" . }}
57+
app.kubernetes.io/instance: {{ .Release.Name }}
58+
app.kubernetes.io/managed-by: {{ .Release.Service }}
59+
roleRef:
60+
apiGroup: rbac.authorization.k8s.io
61+
kind: ClusterRole
62+
name: system:auth-delegator
63+
subjects:
64+
- name: {{ template "kubernetes-external-secrets.serviceAccountName" . }}
65+
namespace: {{ .Release.Namespace | quote }}
66+
kind: ServiceAccount
4967
{{- end -}}

charts/kubernetes-external-secrets/values.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ env:
88
POLLER_INTERVAL_MILLISECONDS: 10000
99
LOG_LEVEL: info
1010
METRICS_PORT: 3001
11+
VAULT_ADDR: http://127.0.0.1:8200
1112

1213
# Create environment variables from exists k8s secrets
1314
# envVarsFromSecret:

config/environment.js

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ if (environment === 'development') {
1616
require('dotenv').config()
1717
}
1818

19+
const vaultEndpoint = process.env.VAULT_ADDR || 'http://127.0.0.1:8200'
1920
const pollerIntervalMilliseconds = process.env.POLLER_INTERVAL_MILLISECONDS
2021
? Number(process.env.POLLER_INTERVAL_MILLISECONDS) : 10000
2122

@@ -26,6 +27,7 @@ const rolePermittedAnnotation = process.env.ROLE_PERMITTED_ANNOTATION || 'iam.am
2627
const metricsPort = process.env.METRICS_PORT || 3001
2728

2829
module.exports = {
30+
vaultEndpoint,
2931
environment,
3032
pollerIntervalMilliseconds,
3133
metricsPort,

config/index.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict'
22

3+
const vault = require('node-vault')
34
const kube = require('kubernetes-client')
45
const KubeRequest = require('kubernetes-client/backends/request')
56
const pino = require('pino')
@@ -10,6 +11,7 @@ const CustomResourceManager = require('../lib/custom-resource-manager')
1011
const customResourceManifest = require('../custom-resource-manifest.json')
1112
const SecretsManagerBackend = require('../lib/backends/secrets-manager-backend')
1213
const SystemManagerBackend = require('../lib/backends/system-manager-backend')
14+
const VaultBackend = require('../lib/backends/vault-backend')
1315

1416
const kubeconfig = new kube.KubeConfig()
1517
kubeconfig.loadFromDefault()
@@ -38,9 +40,12 @@ const systemManagerBackend = new SystemManagerBackend({
3840
assumeRole: awsConfig.assumeRole,
3941
logger
4042
})
43+
const vaultClient = vault({ apiVersion: 'v1', endpoint: envConfig.vaultEndpoint })
44+
const vaultBackend = new VaultBackend({ client: vaultClient, logger })
4145
const backends = {
4246
secretsManager: secretsManagerBackend,
43-
systemManager: systemManagerBackend
47+
systemManager: systemManagerBackend,
48+
vault: vaultBackend
4449
}
4550

4651
// backwards compatibility
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
apiVersion: 'kubernetes-client.io/v1'
2+
kind: ExternalSecret
3+
metadata:
4+
name: hello-service
5+
spec:
6+
backendType: vault
7+
vaultMountPoint: my-kubernetes-vault-mount-point
8+
vaultRole: my-vault-role
9+
data:
10+
- name: password
11+
key: secret/data/hello-service/password
12+
property: password

external-secrets.yml

+13
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ rules:
3636
resources: ["externalsecrets/status"]
3737
verbs: ["get", "update"]
3838
---
39+
apiVersion: rbac.authorization.k8s.io/v1beta1
40+
kind: ClusterRoleBinding
41+
metadata:
42+
name: kubernetes-external-secrets-cluster-role-binding-auth
43+
roleRef:
44+
apiGroup: rbac.authorization.k8s.io
45+
kind: ClusterRole
46+
name: system:auth-delegator
47+
subjects:
48+
- kind: ServiceAccount
49+
name: kubernetes-external-secrets-service-account
50+
namespace: kubernetes-external-secrets
51+
---
3952
apiVersion: v1
4053
kind: Namespace
4154
metadata:

lib/backends/vault-backend.js

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use strict'
2+
3+
const KVBackend = require('./kv-backend')
4+
5+
/** Vault backend class. */
6+
class VaultBackend extends KVBackend {
7+
/**
8+
* Create Vault backend.
9+
* @param {Object} client - Client for interacting with Vault.
10+
* @param {Object} logger - Logger for logging stuff.
11+
*/
12+
constructor ({ client, logger }) {
13+
super({ logger })
14+
this._client = client
15+
}
16+
17+
/**
18+
* Fetch Kubernetes service account token.
19+
* @returns {string} String representing the token of the service account running this pod.
20+
*/
21+
_fetchServiceAccountToken () {
22+
if (!this._serviceAccountToken) {
23+
const fs = require('fs')
24+
this._serviceAccountToken = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/token', 'utf8')
25+
}
26+
return this._serviceAccountToken
27+
}
28+
29+
/**
30+
* Fetch Kubernetes secret property values.
31+
* @param {Object[]} secretProperties - Kubernetes secret properties.
32+
* @param {string} secretProperties[].key - Secret key in the backend.
33+
* @param {string} secretProperties[].name - Kubernetes Secret property name.
34+
* @param {string} secretProperties[].property - If the backend secret is an
35+
* object, this is the property name of the value to use.
36+
* @returns {Promise} Promise object representing secret property values.
37+
*/
38+
_fetchSecretPropertyValues ({ vaultMountPoint, vaultRole, jwt, externalData }) {
39+
return Promise.all(externalData.map(async secretProperty => {
40+
this._logger.info(`fetching secret property ${secretProperty.key}`)
41+
const value = await this._get({ vaultMountPoint: vaultMountPoint, vaultRole: vaultRole, jwt: jwt, secretKey: secretProperty.key })
42+
43+
return value[secretProperty.property]
44+
}))
45+
}
46+
47+
/**
48+
* Get secret property value from Vault.
49+
* @param {string} secretKey - Key used to store secret property value in Vault.
50+
* @returns {Promise} Promise object representing secret property value.
51+
*/
52+
async _get ({ vaultMountPoint, vaultRole, secretKey }) {
53+
if (!this._client.token) {
54+
const jwt = this._fetchServiceAccountToken()
55+
this._logger.debug(`fetching new token from vault`)
56+
const vault = await this._client.kubernetesLogin({
57+
mount_point: vaultMountPoint,
58+
role: vaultRole,
59+
jwt: jwt
60+
})
61+
this._client.token = vault.auth.client_token
62+
} else {
63+
this._logger.debug(`renewing existing token from vault`)
64+
this._client.tokenRenewSelf()
65+
}
66+
67+
this._logger.debug(`reading secret key ${secretKey} from vault`)
68+
const secretResponse = await this._client.read(secretKey)
69+
70+
return secretResponse.data.data
71+
}
72+
73+
/**
74+
* Fetch Kubernetes secret manifest data.
75+
* @param {ExternalSecretSpec} spec - Kubernetes ExternalSecret spec.
76+
* @returns {Promise} Promise object representing Kubernetes secret manifest data.
77+
*/
78+
async getSecretManifestData ({ spec }) {
79+
const data = {}
80+
const vaultMountPoint = spec.vaultMountPoint
81+
const vaultRole = spec.vaultRole
82+
83+
// Also support spec.properties to be backwards compatible.
84+
const externalData = spec.data || spec.properties
85+
const secretPropertyValues = await this._fetchSecretPropertyValues({
86+
vaultMountPoint,
87+
vaultRole,
88+
externalData
89+
})
90+
externalData.forEach((secret, index) => {
91+
data[secret.name] = (Buffer.from(secretPropertyValues[index], 'utf8')).toString('base64')
92+
})
93+
return data
94+
}
95+
}
96+
97+
module.exports = VaultBackend

lib/backends/vault-backend.test.js

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/* eslint-env mocha */
2+
'use strict'
3+
4+
const { expect } = require('chai')
5+
const sinon = require('sinon')
6+
const pino = require('pino')
7+
8+
const VaultBackend = require('./vault-backend')
9+
const logger = pino({
10+
serializers: {
11+
err: pino.stdSerializers.err
12+
}
13+
})
14+
15+
describe('VaultBackend', () => {
16+
let clientMock
17+
let vaultBackend
18+
19+
beforeEach(() => {
20+
clientMock = sinon.mock()
21+
22+
vaultBackend = new VaultBackend({
23+
client: clientMock,
24+
logger
25+
})
26+
})
27+
28+
describe('_get', () => {
29+
const mountPoint = 'fakeMountPoint'
30+
const role = 'fakeRole'
31+
const secretKey = 'fakeSecretKey'
32+
const jwt = 'this-is-a-jwt-token'
33+
34+
beforeEach(() => {
35+
clientMock.read = sinon.stub().returns({
36+
data: {
37+
data: 'fakeSecretPropertyValue'
38+
}
39+
})
40+
clientMock.tokenRenewSelf = sinon.stub().returns(true)
41+
clientMock.kubernetesLogin = sinon.stub().returns({
42+
auth: {
43+
client_token: '1234'
44+
}
45+
})
46+
47+
vaultBackend._fetchServiceAccountToken = sinon.stub().returns(jwt)
48+
49+
clientMock.token = undefined
50+
})
51+
52+
it('logs in and returns secret property value', async () => {
53+
const secretPropertyValue = await vaultBackend._get({
54+
vaultMountPoint: mountPoint,
55+
vaultRole: role,
56+
secretKey: secretKey
57+
})
58+
59+
// First, we log into Vault...
60+
sinon.assert.calledWith(clientMock.kubernetesLogin, {
61+
mount_point: 'fakeMountPoint',
62+
role: 'fakeRole',
63+
jwt: jwt
64+
})
65+
66+
// ... then we fetch the secret ...
67+
sinon.assert.calledWith(clientMock.read, 'fakeSecretKey')
68+
69+
// ... and expect to get its proper value
70+
expect(secretPropertyValue).equals('fakeSecretPropertyValue')
71+
})
72+
73+
it('returns secret property value after renewing token if a token exists', async () => {
74+
clientMock.token = 'an-existing-token'
75+
76+
const secretPropertyValue = await vaultBackend._get({
77+
vaultMountPoint: mountPoint,
78+
vaultRole: role,
79+
secretKey: secretKey
80+
})
81+
82+
// No logging into Vault...
83+
sinon.assert.notCalled(clientMock.kubernetesLogin)
84+
85+
// ... but renew the token instead ...
86+
sinon.assert.calledOnce(clientMock.tokenRenewSelf)
87+
88+
// ... then we fetch the secret ...
89+
sinon.assert.calledWith(clientMock.read, 'fakeSecretKey')
90+
91+
// ... and expect to get its proper value
92+
expect(secretPropertyValue).equals('fakeSecretPropertyValue')
93+
})
94+
})
95+
})

0 commit comments

Comments
 (0)