Skip to content

Commit d43685f

Browse files
authored
Merge pull request #1 from aws-samples/develop
Initial commit of rotation function templates
2 parents ad8df73 + 5a380ae commit d43685f

File tree

15 files changed

+5239
-0
lines changed

15 files changed

+5239
-0
lines changed
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: MIT-0
3+
4+
import boto3
5+
import json
6+
import logging
7+
import os
8+
from pymongo import MongoClient, errors
9+
10+
logger = logging.getLogger()
11+
logger.setLevel(logging.INFO)
12+
13+
14+
def lambda_handler(event, context):
15+
"""Secrets Manager MongoDB Handler
16+
17+
This handler uses the master-user rotation scheme to rotate an MongoDB user credential. During the first rotation, this
18+
scheme logs into the database as the master user, creates a new user (appending _clone to the username), and grants the
19+
new user all of the permissions from the user being rotated. Once the secret is in this state, every subsequent rotation
20+
simply creates a new secret with the AWSPREVIOUS user credentials, adds any missing permissions that are in the current
21+
secret, changes that user's password, and then marks the latest secret as AWSCURRENT.
22+
23+
The Secret SecretString is expected to be a JSON string with the following format:
24+
{
25+
'engine': <required: must be set to 'mongo'>,
26+
'host': <required: instance host name>,
27+
'username': <required: username>,
28+
'password': <required: password>,
29+
'dbname': <optional: database name>,
30+
'port': <optional: if not specified, default port 27017 will be used>,
31+
'masterarn': <required: the arn of the master secret which will be used to create users/change passwords>
32+
}
33+
34+
Args:
35+
event (dict): Lambda dictionary of event parameters. These keys must include the following:
36+
- SecretId: The secret ARN or identifier
37+
- ClientRequestToken: The ClientRequestToken of the secret version
38+
- Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret)
39+
40+
context (LambdaContext): The Lambda runtime information
41+
42+
Raises:
43+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
44+
45+
ValueError: If the secret is not properly configured for rotation
46+
47+
KeyError: If the secret json does not contain the expected keys
48+
49+
"""
50+
arn = event['SecretId']
51+
token = event['ClientRequestToken']
52+
step = event['Step']
53+
54+
# Setup the client
55+
service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT'])
56+
57+
# Make sure the version is staged correctly
58+
metadata = service_client.describe_secret(SecretId=arn)
59+
if "RotationEnabled" in metadata and not metadata['RotationEnabled']:
60+
logger.error("Secret %s is not enabled for rotation" % arn)
61+
raise ValueError("Secret %s is not enabled for rotation" % arn)
62+
versions = metadata['VersionIdsToStages']
63+
if token not in versions:
64+
logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn))
65+
raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn))
66+
if "AWSCURRENT" in versions[token]:
67+
logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn))
68+
return
69+
elif "AWSPENDING" not in versions[token]:
70+
logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn))
71+
raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn))
72+
73+
# Call the appropriate step
74+
if step == "createSecret":
75+
create_secret(service_client, arn, token)
76+
77+
elif step == "setSecret":
78+
set_secret(service_client, arn, token)
79+
80+
elif step == "testSecret":
81+
test_secret(service_client, arn, token)
82+
83+
elif step == "finishSecret":
84+
finish_secret(service_client, arn, token)
85+
86+
else:
87+
logger.error("lambda_handler: Invalid step parameter %s for secret %s" % (step, arn))
88+
raise ValueError("Invalid step parameter %s for secret %s" % (step, arn))
89+
90+
91+
def create_secret(service_client, arn, token):
92+
"""Generate a new secret
93+
94+
This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a
95+
new secret and save it using the passed in token.
96+
97+
Args:
98+
service_client (client): The secrets manager service client
99+
100+
arn (string): The secret ARN or other identifier
101+
102+
token (string): The ClientRequestToken associated with the secret version
103+
104+
Raises:
105+
ValueError: If the current secret is not valid JSON
106+
107+
KeyError: If the secret json does not contain the expected keys
108+
109+
"""
110+
# Make sure the current secret exists
111+
current_dict = get_secret_dict(service_client, arn, "AWSCURRENT")
112+
113+
# Now try to get the secret version, if that fails, put a new secret
114+
try:
115+
get_secret_dict(service_client, arn, "AWSPENDING", token)
116+
logger.info("createSecret: Successfully retrieved secret for %s." % arn)
117+
except service_client.exceptions.ResourceNotFoundException:
118+
# Get the alternate username swapping between the original user and the user with _clone appended to it
119+
current_dict['username'] = get_alt_username(current_dict['username'])
120+
121+
# Generate a random password
122+
passwd = service_client.get_random_password(ExcludeCharacters='/@"\'\\')
123+
current_dict['password'] = passwd['RandomPassword']
124+
125+
# Put the secret
126+
service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=json.dumps(current_dict), VersionStages=['AWSPENDING'])
127+
logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token))
128+
129+
130+
def set_secret(service_client, arn, token):
131+
"""Set the pending secret in the database
132+
133+
This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it
134+
tries to login with the master credentials from the masterarn in the current secret. If this succeeds, it adds all
135+
grants for AWSCURRENT user to the AWSPENDING user, creating the user and/or setting the password in the process.
136+
Else, it throws a ValueError.
137+
138+
Args:
139+
service_client (client): The secrets manager service client
140+
141+
arn (string): The secret ARN or other identifier
142+
143+
token (string): The ClientRequestToken associated with the secret version
144+
145+
Raises:
146+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
147+
148+
ValueError: If the secret is not valid JSON or master credentials could not be used to login to DB
149+
150+
KeyError: If the secret json does not contain the expected keys
151+
152+
"""
153+
# First try to login with the pending secret, if it succeeds, return
154+
pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token)
155+
conn = get_connection(pending_dict)
156+
if conn:
157+
conn.logout()
158+
logger.info("setSecret: AWSPENDING secret is already set as password in MongoDB for secret arn %s." % arn)
159+
return
160+
161+
# Before we do anything with the secret, make sure the AWSCURRENT secret is valid by logging in to the db
162+
# This ensures that the credential we are rotating is valid to protect against a confused deputy attack
163+
current_dict = get_secret_dict(service_client, arn, "AWSCURRENT")
164+
conn = get_connection(current_dict)
165+
if not conn:
166+
logger.error("setSecret: Unable to log into database using current credentials for secret %s" % arn)
167+
raise ValueError("Unable to log into database using current credentials for secret %s" % arn)
168+
conn.logout()
169+
170+
# Now get the master arn from the current secret
171+
master_arn = current_dict['masterarn']
172+
master_dict = get_secret_dict(service_client, master_arn, "AWSCURRENT")
173+
if current_dict['host'] != master_dict['host']:
174+
logger.warn("setSecret: Master database host %s is not the same host as current %s" % (master_dict['host'], current_dict['host']))
175+
176+
# Now log into the database with the master credentials
177+
conn = get_connection(master_dict)
178+
if not conn:
179+
logger.error("setSecret: Unable to log into database using credentials in master secret %s" % master_arn)
180+
raise ValueError("Unable to log into database using credentials in master secret %s" % master_arn)
181+
182+
# Now set the password to the pending password
183+
try:
184+
user_info = conn.command('usersInfo', pending_dict['username'])
185+
if user_info['users']:
186+
conn.command("updateUser", pending_dict['username'], pwd=pending_dict['password'])
187+
else:
188+
user_info = conn.command('usersInfo', current_dict['username'])
189+
conn.command("createUser", pending_dict['username'], pwd=pending_dict['password'], roles=user_info['users'][0]['roles'])
190+
logger.info("setSecret: Successfully set password for %s in MongoDB for secret arn %s." % (pending_dict['username'], arn))
191+
finally:
192+
conn.logout()
193+
194+
195+
def test_secret(service_client, arn, token):
196+
"""Test the pending secret against the database
197+
198+
This method tries to log into the database with the secrets staged with AWSPENDING and runs
199+
a permissions check to ensure the user has the correct permissions.
200+
201+
Args:
202+
service_client (client): The secrets manager service client
203+
204+
arn (string): The secret ARN or other identifier
205+
206+
token (string): The ClientRequestToken associated with the secret version
207+
208+
Raises:
209+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
210+
211+
ValueError: If the secret is not valid JSON or pending credentials could not be used to login to the database
212+
213+
KeyError: If the secret json does not contain the expected keys
214+
215+
"""
216+
# Try to login with the pending secret, if it succeeds, return
217+
pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token)
218+
conn = get_connection(pending_dict)
219+
if conn:
220+
# This is where the lambda will validate the user's permissions. Modify the below lines to
221+
# tailor these validations to your needs
222+
try:
223+
conn.command('usersInfo', pending_dict['username'])
224+
finally:
225+
conn.logout()
226+
227+
logger.info("testSecret: Successfully signed into MongoDB with AWSPENDING secret in %s." % arn)
228+
return
229+
else:
230+
logger.error("testSecret: Unable to log into database with pending secret of secret ARN %s" % arn)
231+
raise ValueError("Unable to log into database with pending secret of secret ARN %s" % arn)
232+
233+
234+
def finish_secret(service_client, arn, token):
235+
"""Finish the rotation by marking the pending secret as current
236+
237+
This method moves the secret from the AWSPENDING stage to the AWSCURRENT stage.
238+
239+
Args:
240+
service_client (client): The secrets manager service client
241+
242+
arn (string): The secret ARN or other identifier
243+
244+
token (string): The ClientRequestToken associated with the secret version
245+
246+
Raises:
247+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
248+
249+
"""
250+
# First describe the secret to get the current version
251+
metadata = service_client.describe_secret(SecretId=arn)
252+
current_version = None
253+
for version in metadata["VersionIdsToStages"]:
254+
if "AWSCURRENT" in metadata["VersionIdsToStages"][version]:
255+
if version == token:
256+
# The correct version is already marked as current, return
257+
logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn))
258+
return
259+
current_version = version
260+
break
261+
262+
# Finalize by staging the secret version current
263+
service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version)
264+
logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (version, arn))
265+
266+
267+
def get_connection(secret_dict):
268+
"""Gets a connection to MongoDB from a secret dictionary
269+
270+
This helper function tries to connect to the database grabbing connection info
271+
from the secret dictionary. If successful, it returns the connection, else None
272+
273+
Args:
274+
secret_dict (dict): The Secret Dictionary
275+
276+
Returns:
277+
Connection: The pymongo.database.Database object if successful. None otherwise
278+
279+
Raises:
280+
KeyError: If the secret json does not contain the expected keys
281+
282+
"""
283+
port = int(secret_dict['port']) if 'port' in secret_dict else 27017
284+
dbname = secret_dict['dbname'] if 'dbname' in secret_dict else "admin"
285+
286+
# Try to obtain a connection to the db
287+
try:
288+
client = MongoClient(host=secret_dict['host'], port=port, connectTimeoutMS=5000, serverSelectionTimeoutMS=5000)
289+
db = client[dbname]
290+
db.authenticate(secret_dict['username'], secret_dict['password'])
291+
return db
292+
except errors.PyMongoError:
293+
return None
294+
295+
296+
def get_secret_dict(service_client, arn, stage, token=None):
297+
"""Gets the secret dictionary corresponding for the secret arn, stage, and token
298+
299+
This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string
300+
301+
Args:
302+
service_client (client): The secrets manager service client
303+
304+
arn (string): The secret ARN or other identifier
305+
306+
token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired
307+
308+
stage (string): The stage identifying the secret version
309+
310+
Returns:
311+
SecretDictionary: Secret dictionary
312+
313+
Raises:
314+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
315+
316+
ValueError: If the secret is not valid JSON
317+
318+
"""
319+
required_fields = ['host', 'username', 'password']
320+
321+
# Only do VersionId validation against the stage if a token is passed in
322+
if token:
323+
secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage)
324+
else:
325+
secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage)
326+
plaintext = secret['SecretString']
327+
secret_dict = json.loads(plaintext)
328+
329+
# Run validations against the secret
330+
if 'engine' not in secret_dict or secret_dict['engine'] != 'mongo':
331+
raise KeyError("Database engine must be set to 'mongo' in order to use this rotation lambda")
332+
for field in required_fields:
333+
if field not in secret_dict:
334+
raise KeyError("%s key is missing from secret JSON" % field)
335+
336+
# Parse and return the secret JSON string
337+
return secret_dict
338+
339+
340+
def get_alt_username(current_username):
341+
"""Gets the alternate username for the current_username passed in
342+
343+
This helper function gets the username for the alternate user based on the passed in current username.
344+
345+
Args:
346+
current_username (client): The current username
347+
348+
Returns:
349+
AlternateUsername: Alternate username
350+
351+
Raises:
352+
ValueError: If the new username length would exceed the maximum allowed
353+
354+
"""
355+
clone_suffix = "_clone"
356+
if current_username.endswith(clone_suffix):
357+
return current_username[:(len(clone_suffix) * -1)]
358+
else:
359+
return current_username + clone_suffix

0 commit comments

Comments
 (0)