|
| 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