Skip to content

Commit 5dbe8b9

Browse files
kboxethKyle Boxeth
andauthored
Add SecretsManagerElasticacheUserRotation rotation lambda (#94)
Co-authored-by: Kyle Boxeth <kyboxeth@amazon.com>
1 parent 6dee97d commit 5dbe8b9

File tree

1 file changed

+348
-0
lines changed

1 file changed

+348
-0
lines changed
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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+
import time
9+
10+
logger = logging.getLogger()
11+
logger.setLevel(logging.INFO)
12+
13+
14+
def lambda_handler(event, context):
15+
"""Secrets Manager Elasticache User Handler
16+
17+
This handler rotates ElastiCache user password. Once executed it creates a new version of
18+
a Secret with a generated password and calls ElastiCache modify user API to update user password.
19+
As soon as changes get applied and user state became ‘active’, the new password could be used to
20+
authentication with Cache clusters.
21+
22+
We recommend paying special attention to Lambda function permissions to prevent privilege escalation
23+
and use one Lambda function to rotate a single secret.
24+
25+
Required Lambda function environment variables are the following:
26+
- SECRETS_MANAGER_ENDPOINT: The service endpoint of secrets manager, for example https://secretsmanager.us-east-1.amazonaws.com
27+
- SECRET_ARN: The ARN of secret created in Secrets Manager
28+
- USER_NAME: Username of the ElastiCache user
29+
30+
Args:
31+
event (dict): Lambda dictionary of event parameters. These keys must include the following:
32+
- SecretId: The secret ARN or identifier
33+
- ClientRequestToken: The ClientRequestToken of the secret version
34+
- Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret)
35+
36+
context (LambdaContext): The Lambda runtime information
37+
38+
Raises:
39+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
40+
41+
UserNotFoundFault: If the user associated to the secret does not exist
42+
43+
ValueError: If the secret is not properly configured for rotation
44+
45+
KeyError: If the event parameters do not contain the expected keys
46+
47+
"""
48+
secret_arn = event['SecretId']
49+
token = event['ClientRequestToken']
50+
step = event['Step']
51+
env_secret_arn = os.environ['SECRET_ARN']
52+
if secret_arn != env_secret_arn:
53+
logger.error("Secret %s is not allowed to use this Lambda function for rotation" % secret_arn)
54+
raise ValueError("Secret %s is not allowed to use this Lambda function for rotation" % secret_arn)
55+
56+
# Setup the clients
57+
secrets_manager_service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT'])
58+
59+
# Make sure the version is staged correctly
60+
metadata = secrets_manager_service_client.describe_secret(SecretId=secret_arn)
61+
if not metadata['RotationEnabled']:
62+
logger.error("Secret %s is not enabled for rotation" % secret_arn)
63+
raise ValueError("Secret %s is not enabled for rotation" % secret_arn)
64+
versions = metadata['VersionIdsToStages']
65+
if token not in versions:
66+
logger.error("Secret version %s has no stage for rotation of secret %s." % (token, secret_arn))
67+
raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, secret_arn))
68+
if "AWSCURRENT" in versions[token]:
69+
logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, secret_arn))
70+
return
71+
elif "AWSPENDING" not in versions[token]:
72+
logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, secret_arn))
73+
raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, secret_arn))
74+
75+
if step == "createSecret":
76+
create_secret(secrets_manager_service_client, secret_arn, token)
77+
elif step == "setSecret":
78+
set_secret(secrets_manager_service_client, secret_arn, token)
79+
elif step == "testSecret":
80+
test_secret(secrets_manager_service_client, secret_arn)
81+
elif step == "finishSecret":
82+
finish_secret(secrets_manager_service_client, secret_arn, token)
83+
else:
84+
logger.error("lambda_handler: Invalid step parameter %s for secret %s" % (step, secret_arn))
85+
raise ValueError("Invalid step parameter %s for secret %s" % (step, secret_arn))
86+
87+
88+
def create_secret(secrets_manager_service_client, secret_arn, token):
89+
"""Create the secret
90+
91+
This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a
92+
new secret and put it with the passed in token.
93+
94+
Args:
95+
secrets_manager_service_client (client): The secrets manager service client
96+
97+
secret_arn (string): The secret ARN or other identifier
98+
99+
token (string): The ClientRequestToken associated with the secret version
100+
101+
Raises:
102+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
103+
104+
"""
105+
# Make sure the current secret exists
106+
current_secret = get_secret_dict(secrets_manager_service_client, secret_arn, "AWSCURRENT")
107+
108+
# Verify if the username stored in environment variable is the same with the one stored in current_secret
109+
verify_user_name(current_secret)
110+
111+
user_context = resource_arn_to_context(current_secret["user_arn"])
112+
elasticache_service_client = boto3.client('elasticache', region_name=user_context["region"])
113+
114+
# validates if user exists
115+
elasticache_service_client.describe_users(UserId=user_context["resource"])
116+
117+
# Now try to get the secret version, if that fails, put a new secret
118+
try:
119+
secrets_manager_service_client.get_secret_value(SecretId=secret_arn, VersionId=token, VersionStage="AWSPENDING")
120+
logger.info("createSecret: Successfully retrieved secret for %s." % secret_arn)
121+
except secrets_manager_service_client.exceptions.ResourceNotFoundException:
122+
# Get exclude characters from environment variable
123+
exclude_characters = os.environ['EXCLUDE_CHARACTERS'] if 'EXCLUDE_CHARACTERS' in os.environ else '/@"\'\\'
124+
# Get password length from environment variable
125+
password_length = int(os.environ['PASSWORD_LENGTH']) if 'PASSWORD_LENGTH' in os.environ else 20
126+
# Generate a random password
127+
passwd = secrets_manager_service_client.get_random_password(ExcludeCharacters=exclude_characters, PasswordLength=password_length)
128+
current_secret['password'] = passwd['RandomPassword']
129+
130+
# Put the secret
131+
secrets_manager_service_client.put_secret_value(SecretId=secret_arn, ClientRequestToken=token, SecretString=json.dumps(current_secret),
132+
VersionStages=['AWSPENDING'])
133+
logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (secret_arn, token))
134+
135+
136+
def set_secret(secrets_manager_service_client, secret_arn, token):
137+
"""Set the secret
138+
139+
This method waits for elasticache user to be in a modifiable state ('active'), and set the AWSPENDING and AWSCURRENT secrets in the user.
140+
141+
Args:
142+
secrets_manager_service_client (client): The secrets manager service client
143+
144+
secret_arn (string): The secret ARN or other identifier
145+
146+
token (string): The ClientRequestToken associated with the secret version
147+
148+
Raises:
149+
UserNotFoundFault: If the user associated to the secret does not exist
150+
151+
"""
152+
# Make sure the current secret exists
153+
current_secret = get_secret_dict(secrets_manager_service_client, secret_arn, "AWSCURRENT")
154+
pending_secret = get_secret_dict(secrets_manager_service_client, secret_arn, "AWSPENDING", token)
155+
user_context = resource_arn_to_context(current_secret["user_arn"])
156+
157+
# Verify if the username stored in environment variable is the same with the one stored in pending_secret
158+
verify_user_name(pending_secret)
159+
160+
passwords = [pending_secret["password"]]
161+
# During the first rotation the password might not be present in the current version
162+
if "password" in current_secret:
163+
passwords.append(current_secret["password"])
164+
165+
# creating elasticache client
166+
elasticache_service_client = boto3.client('elasticache', region_name=user_context["region"])
167+
# wait user to be in a modifiable state
168+
user = wait_for_user_be_active("setSecret", elasticache_service_client, user_context["resource"], secret_arn)
169+
# update user passwords
170+
elasticache_service_client.modify_user(UserId=user["UserId"], Passwords=passwords)
171+
logger.info("setSecret: Successfully set password for user %s in elasticache for secret arn %s." % (current_secret["user_arn"], secret_arn))
172+
173+
174+
def test_secret(secrets_manager_service_client, secret_arn):
175+
"""Test the secret
176+
177+
This method waits for the elasticache user to be in `active` state. It means that the password was propagated to all associated instances, if any.
178+
179+
Args:
180+
secrets_manager_service_client (client): The secrets manager service client
181+
182+
secret_arn (string): The secret ARN or other identifier
183+
184+
Raises:
185+
UserNotFoundFault: If the user associated to the secret does not exist
186+
187+
"""
188+
current_secret = get_secret_dict(secrets_manager_service_client, secret_arn, "AWSCURRENT")
189+
user_context = resource_arn_to_context(current_secret["user_arn"])
190+
# creating elasticache client
191+
elasticache_service_client = boto3.client('elasticache', region_name=user_context["region"])
192+
# wait password propagation
193+
wait_for_user_be_active("testSecret", elasticache_service_client, user_context["resource"], secret_arn)
194+
logger.info("testSecret: User %s is active in elasticache after password update for secret arn %s." % (current_secret["user_arn"], secret_arn))
195+
196+
197+
def finish_secret(secrets_manager_service_client, secret_arn, token):
198+
"""Finish the secret
199+
200+
This method finalizes the rotation process by marking the secret version passed in as the AWSCURRENT secret.
201+
202+
Args:
203+
secrets_manager_service_client (client): The secrets manager service client
204+
205+
secret_arn (string): The secret ARN or other identifier
206+
207+
token (string): The ClientRequestToken associated with the secret version
208+
209+
Raises:
210+
ResourceNotFoundException: If the secret with the specified arn does not exist
211+
212+
"""
213+
# First describe the secret to get the current version
214+
metadata = secrets_manager_service_client.describe_secret(SecretId=secret_arn)
215+
current_version = None
216+
for version in metadata["VersionIdsToStages"]:
217+
if "AWSCURRENT" in metadata["VersionIdsToStages"][version]:
218+
if version == token:
219+
# The correct version is already marked as current, return
220+
logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, secret_arn))
221+
return
222+
current_version = version
223+
break
224+
225+
# Finalize by staging the secret version current
226+
secrets_manager_service_client.update_secret_version_stage(SecretId=secret_arn, VersionStage="AWSCURRENT", MoveToVersionId=token,
227+
RemoveFromVersionId=current_version)
228+
logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, secret_arn))
229+
230+
231+
def wait_for_user_be_active(step, elasticache_service_client, user_id, secret_arn):
232+
""" Waits for user to be in 'active' state
233+
234+
This method calls describe_users api in a loop until it reaches the timeout or the user status is 'active'
235+
236+
Args:
237+
step: The current step name
238+
239+
elasticache_service_client: The elasticache service client
240+
241+
user_id: The user id
242+
243+
secret_arn (string): The secret ARN or other identifier
244+
245+
Returns:
246+
User: The user returned by elasticache service client
247+
248+
Raises:
249+
ValueError: If the user does not get active within the defined time
250+
251+
UserNotFoundFault: If the user does not exist
252+
253+
"""
254+
255+
max_waiting_time = int(os.environ['MAX_WAITING_TIME_FOR_ACTIVE_IN_SECONDS']) if 'MAX_WAITING_TIME_FOR_ACTIVE_IN_SECONDS' in os.environ else 600
256+
retry_interval = int(os.environ['WAITING_RETRY_INTERVAL_IN_SECONDS']) if 'WAITING_RETRY_INTERVAL_IN_SECONDS' in os.environ else 10
257+
timeout = time.time() + max_waiting_time
258+
259+
while timeout > time.time():
260+
user = elasticache_service_client.describe_users(UserId=user_id)["Users"][0]
261+
if user["Status"] == "active":
262+
logger.info("%s: user %s active, exiting." % (step, user_id))
263+
return user
264+
logger.info("%s: user %s not active, waiting." % (step, user_id))
265+
time.sleep(retry_interval)
266+
267+
logger.error("%s: user %s associated with secret %s did not reached the active status." % (step, user_id, secret_arn))
268+
raise ValueError("%s: user %s associated with secret %s did not reached the active status." % (step, user_id, secret_arn))
269+
270+
271+
def get_secret_dict(secrets_manager_service_client, secret_arn, stage, token=None):
272+
"""Gets the secret dictionary corresponding for the secret secret_arn, stage, and token
273+
274+
This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string
275+
276+
Args:
277+
secrets_manager_service_client (client): The secrets manager service client
278+
279+
secret_arn (string): The secret ARN or other identifier
280+
281+
token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired
282+
283+
stage (string): The stage identifying the secret version
284+
285+
Returns:
286+
SecretDictionary: Secret dictionary
287+
288+
Raises:
289+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
290+
291+
KeyError: If the secret has no user_arn
292+
293+
"""
294+
# Only do VersionId validation against the stage if a token is passed in
295+
if token is None:
296+
secret = secrets_manager_service_client.get_secret_value(SecretId=secret_arn, VersionStage=stage)
297+
else:
298+
secret = secrets_manager_service_client.get_secret_value(SecretId=secret_arn, VersionId=token, VersionStage=stage)
299+
plaintext = secret['SecretString']
300+
try:
301+
secret_dict = json.loads(plaintext)
302+
except Exception:
303+
# wrapping json parser exceptions to avoid possible password disclosure
304+
logger.error("Invalid secret value json for secret %s." % (secret_arn))
305+
raise ValueError("Invalid secret value json for secret %s." % (secret_arn))
306+
307+
# Validates if there is a user associated to the secret
308+
if "user_arn" not in secret_dict:
309+
logger.error("createSecret: secret %s has no user_arn defined." % (secret_arn))
310+
raise KeyError("createSecret: secret %s has no user_arn defined." % (secret_arn))
311+
312+
return secret_dict
313+
314+
315+
def resource_arn_to_context(arn):
316+
'''Returns a dictionary built based on the user arn
317+
318+
Args:
319+
arn (string): The user ARN
320+
Returns:
321+
dict: A user arn dictionary with fields present in the arn
322+
'''
323+
elements = arn.split(':')
324+
result = {
325+
'arn': elements[0],
326+
'partition': elements[1],
327+
'service': elements[2],
328+
'region': elements[3],
329+
'account': elements[4],
330+
'resource_type': elements[5],
331+
'resource': elements[6]
332+
}
333+
return result
334+
335+
336+
def verify_user_name(secret):
337+
'''Verify whether USER_NAME set in Lambda environment variable matches what's set in the secret
338+
339+
Args:
340+
secret: The secret from Secrets Manager
341+
Raises:
342+
verificationException: username in Lambda environment variable doesn't match the one stored in the secret
343+
'''
344+
env_elasticache_user_name = os.environ['USER_NAME']
345+
secret_user_name = secret["username"]
346+
if env_elasticache_user_name != secret_user_name:
347+
logger.error("User %s is not allowed to use this Lambda function for rotation" % secret_user_name)
348+
raise ValueError("User %s is not allowed to use this Lambda function for rotation" % secret_user_name)

0 commit comments

Comments
 (0)