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

Commit 74d4459

Browse files
authored
feat(aws-ssm): Add support to get parameters by path (#603)
* Adding support to scrape full paths instead of each individual key from SSM * Multiple changes related with improvements Updating Readme Improving code with users suggestions Lint fixes Adding tests for ssm path feature * Fixing additional lint issues with tests
1 parent 3be641f commit 74d4459

File tree

8 files changed

+238
-33
lines changed

8 files changed

+238
-33
lines changed

README.md

+27
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,33 @@ spec:
391391
property: password
392392
```
393393

394+
# AWS SSM Parameter Store
395+
396+
You can scrape values from SSM Parameter Store individually or by providing a path to fetch all keys inside.
397+
398+
Additionally you can also scrape all sub paths (child paths) if you need to. The default is not to scrape child paths
399+
400+
```yml
401+
apiVersion: kubernetes-client.io/v1
402+
kind: ExternalSecret
403+
metadata:
404+
name: hello-service
405+
spec:
406+
backendType: secretsManager
407+
# optional: specify role to assume when retrieving the data
408+
roleArn: arn:aws:iam::123456789012:role/test-role
409+
# optional: specify region
410+
region: us-east-1
411+
data:
412+
- key: /foo/name
413+
name: fooName
414+
- path: /extra-people/
415+
recursive: false
416+
```
417+
418+
419+
420+
394421
### Hashicorp Vault
395422

396423
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).

charts/kubernetes-external-secrets/crds/kubernetes-client.io_externalsecrets_crd.yaml

+30-19
Original file line numberDiff line numberDiff line change
@@ -72,25 +72,36 @@ spec:
7272
type: array
7373
items:
7474
type: object
75-
properties:
76-
key:
77-
description: Secret key in backend
78-
type: string
79-
name:
80-
description: Name set for this key in the generated secret
81-
type: string
82-
property:
83-
description: Property to extract if secret in backend is a JSON object
84-
isBinary:
85-
description: >-
86-
Whether the backend secret shall be treated as binary data
87-
represented by a base64-encoded string. You must set this to true
88-
for any base64-encoded binary data in the backend - to ensure it
89-
is not encoded in base64 again. Default is false.
90-
type: boolean
91-
required:
92-
- name
93-
- key
75+
anyOf:
76+
- properties:
77+
key:
78+
description: Secret key in backend
79+
type: string
80+
name:
81+
description: Name set for this key in the generated secret
82+
type: string
83+
property:
84+
description: Property to extract if secret in backend is a JSON object
85+
isBinary:
86+
description: >-
87+
Whether the backend secret shall be treated as binary data
88+
represented by a base64-encoded string. You must set this to true
89+
for any base64-encoded binary data in the backend - to ensure it
90+
is not encoded in base64 again. Default is false.
91+
type: boolean
92+
required:
93+
- key
94+
- name
95+
- properties:
96+
path:
97+
description: >-
98+
Path from SSM to scrape secrets
99+
This will fetch all secrets and use the key from the secret as variable name
100+
recursive:
101+
description: Allow to recurse thru all child keys on a given path
102+
type: boolean
103+
required:
104+
- path
94105
roleArn:
95106
type: string
96107
oneOf:

config/aws-config.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ if (stsEndpoint) {
4343

4444
if (localstack) {
4545
secretsManagerConfig = {
46-
endpoint: process.env.LOCALSTACK_SM_URL || 'http://localhost:4584',
46+
endpoint: process.env.LOCALSTACK_SM_URL || 'http://localhost:4566',
4747
region: process.env.AWS_REGION || 'us-west-2'
4848
}
4949
systemManagerConfig = {
50-
endpoint: process.env.LOCALSTACK_SSM_URL || 'http://localhost:4583',
50+
endpoint: process.env.LOCALSTACK_SSM_URL || 'http://localhost:4566',
5151
region: process.env.AWS_REGION || 'us-west-2'
5252
}
5353
stsConfig = {
54-
endpoint: process.env.LOCALSTACK_STS_URL || 'http://localhost:4592',
54+
endpoint: process.env.LOCALSTACK_STS_URL || 'http://localhost:4566',
5555
region: process.env.AWS_REGION || 'us-west-2'
5656
}
5757
}

e2e/tests/ssm.test.js

+48
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,54 @@ describe('ssm', async () => {
5353
expect(secret.body.data.name).to.equal('Zm9v')
5454
})
5555

56+
it('should pull existing secrets from ssm path and create a secret from it', async () => {
57+
const name1 = await putParameter({
58+
Name: `/e2e/${uuid}-names/name1`,
59+
Type: 'String',
60+
Value: 'foo'
61+
}).catch(err => {
62+
expect(err).to.equal(null)
63+
})
64+
65+
const name2 = await putParameter({
66+
Name: `/e2e/${uuid}-names/name2`,
67+
Type: 'String',
68+
Value: 'bar'
69+
}).catch(err => {
70+
expect(err).to.equal(null)
71+
})
72+
73+
const result = await kubeClient
74+
.apis[customResourceManifest.spec.group]
75+
.v1.namespaces('default')[customResourceManifest.spec.names.plural]
76+
.post({
77+
body: {
78+
apiVersion: 'kubernetes-client.io/v1',
79+
kind: 'ExternalSecret',
80+
metadata: {
81+
name: `e2e-ssm-${uuid}-names`
82+
},
83+
spec: {
84+
backendType: 'systemManager',
85+
data: [
86+
{
87+
path: `/e2e/${uuid}-names`
88+
}
89+
]
90+
}
91+
}
92+
})
93+
94+
expect(name1).to.not.equal(undefined)
95+
expect(name2).to.not.equal(undefined)
96+
expect(result).to.not.equal(undefined)
97+
expect(result.statusCode).to.equal(201)
98+
99+
const secret = await waitForSecret('default', `e2e-ssm-${uuid}-names`)
100+
expect(secret.body.data.name1).to.equal('Zm9v') // Expect base64 foo
101+
expect(secret.body.data.name2).to.equal('YmFy') // Expect base64 bar
102+
})
103+
56104
it('should pull existing secret from ssm in a different region', async () => {
57105
const ssmEU = awsConfig.systemManagerFactory({
58106
region: 'eu-west-1'

examples/ssm-example.yaml

+8-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ spec:
77
# optional: specify role to assume when retrieving the data
88
roleArn: arn:aws:iam::123456789012:role/test-role
99
# optional: specify region
10-
region: eu-west-1
10+
region: us-west-2
1111
data:
12-
- key: /foo/name1
12+
# Can either be key+name or all keys from a given path or even both
13+
# Order below is important. Values are fetched from SSM in the same order you put them here (top to bottom)
14+
# This means that if a given key is found duplicate, the last value found has precedence
15+
- key: /foo/name
1316
name: variable-name
17+
- path: /bar/
18+
# optional: choose whether to scrape all child paths or not. Default is false
19+
recursive: false

lib/backends/kv-backend.js

+44-6
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class KVBackend extends AbstractBackend {
1818
* @param {Object[]} data - Kubernetes secret properties.
1919
* @param {string} data[].key - Secret key in the backend.
2020
* @param {string} data[].name - Kubernetes Secret property name.
21+
* @param {string} data[].path - Kubernetes Secret path to fetch keys from.
2122
* @param {string} data[].property - If the backend secret is an
2223
* object, this is the property name of the value to use.
2324
* @param {string} data[].isBinary - If the backend secret shall be treated
@@ -27,8 +28,24 @@ class KVBackend extends AbstractBackend {
2728
*/
2829
_fetchDataValues ({ data, specOptions }) {
2930
return Promise.all(data.map(async dataItem => {
30-
const { name, property = null, key, ...keyOptions } = dataItem
31-
const plainOrObjValue = await this._get({ key, keyOptions, specOptions })
31+
const { name, property = null, key, path, ...keyOptions } = dataItem
32+
33+
let response = {}
34+
let plainOrObjValue
35+
36+
// Supporting fetching by key or by path
37+
// If 'path' is not defined, we can assume 'key' will exist due to CRD validation
38+
let singleParameterKey = true
39+
if (path) { singleParameterKey = false }
40+
41+
if (singleParameterKey) {
42+
// Single secret
43+
plainOrObjValue = await this._get({ key, keyOptions, specOptions })
44+
} else {
45+
// All secrets inside the specified path
46+
plainOrObjValue = await this._getByPath({ path, keyOptions, specOptions })
47+
}
48+
3249
const shouldParseValue = 'property' in dataItem
3350
const isBinary = 'isBinary' in dataItem && dataItem.isBinary === true
3451

@@ -39,8 +56,8 @@ class KVBackend extends AbstractBackend {
3956
parsedValue = JSON.parse(value)
4057
} catch (err) {
4158
this._logger.warn(`Failed to JSON.parse value for '${key}',` +
42-
' please verify that your secret value is correctly formatted as JSON.' +
43-
` To use plain text secret remove the 'property: ${property}'`)
59+
' please verify that your secret value is correctly formatted as JSON.' +
60+
` To use plain text secret remove the 'property: ${property}'`)
4461
return
4562
}
4663

@@ -61,7 +78,17 @@ class KVBackend extends AbstractBackend {
6178
}
6279
}
6380

64-
return { [name]: value }
81+
if (singleParameterKey) {
82+
// Not path, return as is
83+
response = { [name]: value }
84+
} else {
85+
// Returning dict with path keys and values
86+
for (const records in value) {
87+
response[records] = value[records]
88+
}
89+
}
90+
91+
return response
6592
}))
6693
}
6794

@@ -79,7 +106,7 @@ class KVBackend extends AbstractBackend {
79106
return JSON.parse(value)
80107
} catch (err) {
81108
this._logger.warn(`Failed to JSON.parse value for '${key}',` +
82-
' please verify that your secret value is correctly formatted as JSON.')
109+
' please verify that your secret value is correctly formatted as JSON.')
83110
}
84111
}))
85112
}
@@ -95,6 +122,17 @@ class KVBackend extends AbstractBackend {
95122
throw new Error('_get not implemented')
96123
}
97124

125+
/**
126+
* Get a secret property value from Key Value backend.
127+
* @param {string} path - Path from where to fetch secrets on the backend.
128+
* @param {string} keyOptions - Options for this specific key, eg version etc.
129+
* @param {string} specOptions - Options for this external secret, eg role
130+
* @returns {Promise} Promise object representing secret property values.
131+
*/
132+
_getByPath ({ path, keyOptions, specOptions }) {
133+
throw new Error('_getByPath not implemented')
134+
}
135+
98136
/**
99137
* Convert secret value to buffer
100138
* @param {(string|Buffer|object)} plainValue - plain secret value

lib/backends/system-manager-backend.js

+77-2
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@ class SystemManagerBackend extends KVBackend {
2424
* @returns {Promise} Promise object representing secret property value.
2525
*/
2626
async _get ({ key, specOptions: { roleArn, region } }) {
27-
this._logger.info(`fetching secret property ${key} with role: ${roleArn || 'pods role'} in region ${region}`)
28-
2927
let client = this._client
3028
let factoryArgs = null
3129
if (roleArn) {
30+
this._logger.info(`fetching secret property ${key} with role: ${roleArn} in region ${region}`)
3231
const credentials = this._assumeRole({
3332
RoleArn: roleArn,
3433
RoleSessionName: 'k8s-external-secrets'
@@ -37,6 +36,8 @@ class SystemManagerBackend extends KVBackend {
3736
...factoryArgs,
3837
credentials
3938
}
39+
} else {
40+
this._logger.info(`fetching secret property ${key} with pod role in region ${region}`)
4041
}
4142
if (region) {
4243
factoryArgs = {
@@ -63,6 +64,80 @@ class SystemManagerBackend extends KVBackend {
6364
throw err
6465
}
6566
}
67+
68+
/**
69+
* Get secret property value from System Manager.
70+
* @param {string} path - Key used to store secret property value in System Manager.
71+
* @param {object} specOptions - Options for this external secret, eg role
72+
* @param {string} specOptions.roleArn - IAM role arn to assume
73+
* @returns {Promise} Promise object representing secret property value.
74+
*/
75+
async _getByPath ({ path, keyOptions, specOptions: { roleArn, region } }) {
76+
let client = this._client
77+
let factoryArgs = null
78+
const recursive = keyOptions.recursive || false
79+
80+
this._logger.info(`fetching all secrets ${recursive ? '(recursively)' : ''} inside path ${path} with role ${roleArn !== ' from pod'} in region ${region}`)
81+
82+
if (roleArn) {
83+
const credentials = this._assumeRole({
84+
RoleArn: roleArn,
85+
RoleSessionName: 'k8s-external-secrets'
86+
})
87+
factoryArgs = {
88+
...factoryArgs,
89+
credentials
90+
}
91+
}
92+
if (region) {
93+
factoryArgs = {
94+
...factoryArgs,
95+
region
96+
}
97+
}
98+
if (factoryArgs) {
99+
client = this._clientFactory(factoryArgs)
100+
}
101+
try {
102+
const getAllParameters = async () => {
103+
const EMPTY = Symbol('empty')
104+
this._logger.info(`fetching parameters for path ${path}`)
105+
const res = []
106+
for await (const lf of (async function * () {
107+
let NextToken = EMPTY
108+
while (NextToken || NextToken === EMPTY) {
109+
const parameters = await client.getParametersByPath({
110+
Path: path,
111+
WithDecryption: true,
112+
Recursive: recursive,
113+
NextToken: NextToken !== EMPTY ? NextToken : undefined
114+
}).promise()
115+
yield * parameters.Parameters
116+
NextToken = parameters.NextToken
117+
}
118+
})()) {
119+
res.push(lf)
120+
}
121+
return res
122+
}
123+
124+
const parameters = {}
125+
const ssmData = await getAllParameters()
126+
for (const ssmRecord in ssmData) {
127+
const paramName = require('path').basename(ssmData[String(ssmRecord)].Name)
128+
const paramValue = ssmData[ssmRecord].Value
129+
parameters[paramName] = paramValue
130+
}
131+
132+
return parameters
133+
} catch (err) {
134+
if (err.code === 'ParameterNotFound' && (!err.message || err.message === 'null')) {
135+
err.message = `ParameterNotFound: ${path} could not be found.`
136+
}
137+
138+
throw err
139+
}
140+
}
66141
}
67142

68143
module.exports = SystemManagerBackend

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"coverage": "nyc ./node_modules/mocha/bin/_mocha --recursive lib",
88
"lint": "eslint --fix --ignore-pattern /coverage/ ./",
99
"local": "LOCALSTACK=1 AWS_ACCESS_KEY_ID=foobar AWS_SECRET_ACCESS_KEY=foobar nodemon",
10-
"localstack": "docker run -it -p 4583:4583 -p 4584:4584 -p 4592:4592 -p 9999:8080 -e SERVICES=ssm,secretsmanager,sts -e DEBUG=1 --rm localstack/localstack:0.10.5",
10+
"localstack": "docker run -it -p 4566:4566 -p 9999:8080 -e SERVICES=ssm,secretsmanager,sts -e DEBUG=1 --rm localstack/localstack:latest",
1111
"release": "standard-version --tag-prefix='' && ./release.sh",
1212
"start": "./bin/daemon.js",
1313
"nodemon": "nodemon ./bin/daemon.js",

0 commit comments

Comments
 (0)