Skip to content

Commit 3ab5636

Browse files
authored
Merge branch 'master' into feat/postgres14.5
2 parents 80622c3 + e3d16e4 commit 3ab5636

File tree

7 files changed

+223
-8
lines changed

7 files changed

+223
-8
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,36 @@ Support for this can be enabled my making your Cloudwatch Event look like this.
102102
If you supply `USE_IAM_AUTH` with a value of `true`, the `PGPASSWORD` var may be omitted in the CloudWatch event.
103103
If you still provide it, it will be ignored.
104104

105+
#### SecretsManager-based Postgres authentication
106+
107+
If you prefer to not send DB details/credentials in the event parameters, you can store such details in SecretsManager and just provide the SecretId, then the function will fetch your DB details/credentials from the secret value.
108+
109+
NOTE: the execution role for the Lambda function must have access to GetSecretValue for the given secret.
110+
111+
Support for this can be enabled by setting the SECRETS_MANAGER_SECRET_ID, so your Cloudwatch Event looks like this:
112+
113+
```json
114+
115+
{
116+
"SECRETS_MANAGER_SECRET_ID": "my/secret/id",
117+
"S3_BUCKET" : "db-backups",
118+
"ROOT": "hourly-backups"
119+
}
120+
```
121+
122+
If you supply `SECRETS_MANAGER_SECRET_ID`, you can ommit the 'PG*' keys, and they will be fetched from your SecretsManager secret value instead with the following mapping:
123+
124+
| Secret Value | PG-Key |
125+
| ------------- | ------------- |
126+
| username | PGUSER |
127+
| password | PGPASSWORD |
128+
| dbname | PGDATABASE |
129+
| host | PGHOST |
130+
| port | PGPORT |
131+
132+
133+
You can provide overrides in your event to any PG* keys as event parameters will take precedence over secret values.
134+
105135
## Developer
106136

107137
#### Bundling a new `pg_dump` binary

lib/config.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ const path = require("path");
22

33
// default config that is overridden by the Lambda event
44
module.exports = {
5-
S3_REGION: "eu-west-1",
6-
PGDUMP_PATH: path.join(__dirname, "../bin/postgres-14.5"),
7-
// maximum time allowed to connect to postgres before a timeout occurs
8-
PGCONNECT_TIMEOUT: 15,
9-
USE_IAM_AUTH: false,
10-
};
5+
S3_REGION: 'eu-west-1',
6+
PGDUMP_PATH: path.join(__dirname, "../bin/postgres-14.5"),
7+
// maximum time allowed to connect to postgres before a timeout occurs
8+
PGCONNECT_TIMEOUT: 15,
9+
USE_IAM_AUTH: false,
10+
S3_STORAGE_CLASS: 'STANDARD'
11+
}

lib/handler.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const utils = require('./utils')
22
const uploadS3 = require('./upload-s3')
33
const pgdump = require('./pgdump')
44
const decorateWithIamToken = require('./iam')
5+
const decorateWithSecretsManagerCredentials = require('./secrets-manager')
56
const encryption = require('./encryption')
67

78
const DEFAULT_CONFIG = require('./config')
@@ -35,7 +36,18 @@ async function backup(config) {
3536

3637
async function handler(event) {
3738
const baseConfig = { ...DEFAULT_CONFIG, ...event }
38-
const config = event.USE_IAM_AUTH === true ? decorateWithIamToken(baseConfig) : baseConfig
39+
let config
40+
41+
if (event.USE_IAM_AUTH === true) {
42+
config = decorateWithIamToken(baseConfig)
43+
}
44+
else if (event.SECRETS_MANAGER_SECRET_ID) {
45+
config = await decorateWithSecretsManagerCredentials(baseConfig)
46+
}
47+
else {
48+
config = baseConfig
49+
}
50+
3951
try {
4052
return await backup(config)
4153
}

lib/secrets-manager.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/* eslint-disable brace-style */
2+
const AWS = require('aws-sdk')
3+
4+
// configure AWS to log to stdout
5+
AWS.config.update({
6+
logger: process.stdout
7+
})
8+
9+
async function getDbCredentials(config) {
10+
const secretsManager = new AWS.SecretsManager({
11+
region: config.S3_REGION
12+
})
13+
14+
const params = {
15+
SecretId: config.SECRETS_MANAGER_SECRET_ID
16+
}
17+
18+
return new Promise((resolve, reject) => {
19+
secretsManager.getSecretValue(params, (err, data) => {
20+
if (err) {
21+
console.log('Error while getting secret value:', err)
22+
reject(err)
23+
} else {
24+
const credentials = JSON.parse(data.SecretString)
25+
resolve(credentials)
26+
}
27+
})
28+
})
29+
}
30+
31+
async function decorateWithSecretsManagerCredentials(baseConfig) {
32+
try {
33+
const credentials = await getDbCredentials(baseConfig)
34+
35+
const credsFromSecret = {}
36+
37+
if (credentials.username) credsFromSecret.PGUSER = credentials.username
38+
if (credentials.password) credsFromSecret.PGPASSWORD = credentials.password
39+
if (credentials.dbname) credsFromSecret.PGDATABASE = credentials.dbname
40+
if (credentials.host) credsFromSecret.PGHOST = credentials.host
41+
if (credentials.port) credsFromSecret.PGPORT = credentials.port
42+
43+
return {
44+
...credsFromSecret,
45+
...baseConfig
46+
}
47+
} catch (error) {
48+
console.log(error)
49+
return baseConfig
50+
}
51+
}
52+
53+
module.exports = decorateWithSecretsManagerCredentials

lib/upload-s3.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ async function uploadS3(stream, config, key) {
1212
const result = await s3.upload({
1313
Key: key,
1414
Bucket: config.S3_BUCKET,
15-
Body: stream
15+
Body: stream,
16+
StorageClass: config.S3_STORAGE_CLASS
1617
}).promise()
1718

1819
console.log('Uploaded to', result.Location)

test/handler.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,34 @@ describe('Handler', () => {
8989
AWSMOCK.restore('RDS.Signer')
9090
})
9191

92+
it('should be able to authenticate via SecretsManager', async () => {
93+
const { s3Spy, pgSpy } = makeMockHandler()
94+
95+
const secretsManagerMockEvent = { ...mockEvent, SECRETS_MANAGER_SECRET_ID: 'my-secret-id' }
96+
const username = 'myuser'
97+
const password = 'mypassword'
98+
const secretValue = {
99+
SecretString: JSON.stringify({ username, password })
100+
}
101+
102+
AWSMOCK.mock('SecretsManager', 'getSecretValue', (params, callback) => {
103+
expect(params.SecretId).to.eql(secretsManagerMockEvent.SECRETS_MANAGER_SECRET_ID)
104+
callback(null, secretValue)
105+
})
106+
107+
await handler(secretsManagerMockEvent)
108+
// handler should have called pgSpy with correct arguments
109+
expect(pgSpy.calledOnce).to.be.true
110+
expect(s3Spy.calledOnce).to.be.true
111+
expect(s3Spy.firstCall.args).to.have.length(3)
112+
const config = s3Spy.firstCall.args[1]
113+
// production code is synchronous, so this is annoying
114+
expect(config.PGUSER).to.equal(username)
115+
expect(config.PGPASSWORD).to.equal(password)
116+
117+
AWSMOCK.restore('SecretsManager')
118+
})
119+
92120
it('should upload the backup file and an iv file', async () => {
93121
const { s3Spy } = makeMockHandler()
94122

test/secrets-manager.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/* eslint no-underscore-dangle: 0 */
2+
const { expect } = require('chai')
3+
const rewire = require('rewire')
4+
const chai = require('chai')
5+
const chaiAsPromised = require('chai-as-promised')
6+
const AWSMOCK = require('aws-sdk-mock')
7+
const AWS = require('aws-sdk')
8+
9+
chai.should()
10+
chai.use(chaiAsPromised)
11+
12+
AWSMOCK.setSDKInstance(AWS)
13+
14+
const decorateWithSecretsManagerCredentials = rewire('../lib/secrets-manager')
15+
16+
describe('secrets-manager-based auth', () => {
17+
const parsedSecretValue = {
18+
dbname: 'somedatabase',
19+
username: 'someuser',
20+
password: 'somepassword',
21+
host: 'somehost',
22+
port: '2345'
23+
}
24+
const secretValue = {
25+
SecretString: JSON.stringify(parsedSecretValue)
26+
}
27+
28+
const keyMappings = [
29+
{ secretKey: 'username', pgKey: 'PGUSER' },
30+
{ secretKey: 'password', pgKey: 'PGPASSWORD' },
31+
{ secretKey: 'dbname', pgKey: 'PGDATABASE' },
32+
{ secretKey: 'host', pgKey: 'PGHOST' },
33+
{ secretKey: 'port', pgKey: 'PGPORT' }
34+
]
35+
36+
keyMappings.forEach((map) => {
37+
it(`should set ${map.pgKey} from the secret ${map.secretKey}`, async () => {
38+
const mockEvent = { SECRETS_MANAGER_SECRET_ID: 'my-secret-id' }
39+
40+
AWSMOCK.mock('SecretsManager', 'getSecretValue', (params, callback) => {
41+
expect(params.SecretId).to.eql(mockEvent.SECRETS_MANAGER_SECRET_ID)
42+
43+
callback(null, secretValue)
44+
})
45+
46+
const config = await decorateWithSecretsManagerCredentials(mockEvent)
47+
48+
expect(config[map.pgKey]).to.eql(parsedSecretValue[map.secretKey])
49+
})
50+
51+
context(`when the event contains an override for ${map.pgKey}`, () => {
52+
it(`should set ${map.pgKey} from the event params`, async () => {
53+
const mockEvent = {
54+
SECRETS_MANAGER_SECRET_ID: 'my-secret-id',
55+
[map.pgKey]: 'some-value-override'
56+
}
57+
58+
AWSMOCK.mock('SecretsManager', 'getSecretValue', (params, callback) => {
59+
expect(params.SecretId).to.eql(mockEvent.SECRETS_MANAGER_SECRET_ID)
60+
61+
callback(null, secretValue)
62+
})
63+
64+
const config = await decorateWithSecretsManagerCredentials(mockEvent)
65+
66+
expect(config[map.pgKey]).to.eql(mockEvent[map.pgKey])
67+
})
68+
})
69+
})
70+
71+
context('when there is an error getting the secret value', () => {
72+
it('should return the given base config', async () => {
73+
const mockEvent = { SECRETS_MANAGER_SECRET_ID: 'my-secret-id' }
74+
75+
AWSMOCK.mock('SecretsManager', 'getSecretValue', (params, callback) => {
76+
expect(params.SecretId).to.eql(mockEvent.SECRETS_MANAGER_SECRET_ID)
77+
78+
callback('some error', secretValue)
79+
})
80+
81+
const config = await decorateWithSecretsManagerCredentials(mockEvent)
82+
83+
expect(config).to.eql(mockEvent)
84+
})
85+
})
86+
87+
afterEach(() => {
88+
AWSMOCK.restore('SecretsManager')
89+
})
90+
})

0 commit comments

Comments
 (0)