├── .github └── PULL_REQUEST_TEMPLATE.md ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SecretsManagerActiveDirectoryAndKeytabRotationSingleUser ├── lambda_function.py └── utils.py ├── SecretsManagerActiveDirectoryRotationSingleUser └── lambda_function.py ├── SecretsManagerElasticacheUserRotation └── lambda_function.py ├── SecretsManagerInfluxDBRotationMultiUser └── lambda_function.py ├── SecretsManagerInfluxDBRotationSingleUser └── lambda_function.py ├── SecretsManagerMongoDBRotationMultiUser └── lambda_function.py ├── SecretsManagerMongoDBRotationSingleUser └── lambda_function.py ├── SecretsManagerRDSDb2RotationMultiUser └── lambda_function.py ├── SecretsManagerRDSDb2RotationSingleUser └── lambda_function.py ├── SecretsManagerRDSMariaDBRotationMultiUser └── lambda_function.py ├── SecretsManagerRDSMariaDBRotationSingleUser └── lambda_function.py ├── SecretsManagerRDSMySQLRotationMultiUser └── lambda_function.py ├── SecretsManagerRDSMySQLRotationSingleUser └── lambda_function.py ├── SecretsManagerRDSOracleRotationMultiUser └── lambda_function.py ├── SecretsManagerRDSOracleRotationSingleUser └── lambda_function.py ├── SecretsManagerRDSPostgreSQLRotationMultiUser └── lambda_function.py ├── SecretsManagerRDSPostgreSQLRotationSingleUser └── lambda_function.py ├── SecretsManagerRDSSQLServerRotationMultiUser └── lambda_function.py ├── SecretsManagerRDSSQLServerRotationSingleUser └── lambda_function.py ├── SecretsManagerRedshiftRotationMultiUser └── lambda_function.py ├── SecretsManagerRedshiftRotationSingleUser └── lambda_function.py └── SecretsManagerRotationTemplate └── lambda_function.py /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | README.md @ecraw-amzn 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/issues), or [recently closed](https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## AWS Secrets Manager Rotation Lambda Functions 2 | 3 | Secrets Manager provides rotation function templates for several types of credentials. To use the templates, see https://docs.aws.amazon.com/secretsmanager/latest/userguide/reference_available-rotation-templates.html. 4 | 5 | ## License Summary 6 | 7 | This sample code is made available under a modified MIT license. See the LICENSE file. 8 | -------------------------------------------------------------------------------- /SecretsManagerActiveDirectoryAndKeytabRotationSingleUser/utils.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import os 3 | import logging 4 | import subprocess 5 | from subprocess import Popen 6 | import tempfile 7 | import time 8 | import uuid 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.INFO) 12 | 13 | 14 | class KeytabManager: 15 | def __init__(self): 16 | self.temp_files = [] 17 | 18 | def __enter__(self): 19 | return self 20 | 21 | def __exit__(self, type, value, traceback): 22 | self._cleanup_temp_files() 23 | 24 | def generate_random_keytab_file_path(self): 25 | """ 26 | Generates a random file path in the /tmp directory and adds the 27 | file path to a local collection of all generated file paths 28 | Returns: 29 | Randomly generated file path 30 | """ 31 | filepath = os.path.join("/tmp", uuid.uuid4().hex) 32 | self._track_temp_file(filepath) 33 | return filepath 34 | 35 | def _track_temp_file(self, filepath: str): 36 | """ 37 | Adds a file path to a local data structure. 38 | Args: 39 | filepath (string): File path to track 40 | """ 41 | self.temp_files.append(filepath) 42 | 43 | def _cleanup_temp_files(self): 44 | for file in self.temp_files: 45 | try: 46 | os.remove(file) 47 | except OSError: 48 | pass 49 | 50 | def split_keytab(self, master_keytab_data: bytes, principals: list, user_principal: str) -> str: 51 | """ 52 | Splits a keytab by filtering out any unspecified principals. 53 | If no principals are specified, no filtering is done. 54 | Args: 55 | master_keytab_data (bytes): Binary format of the keytab containing all principals for the user 56 | principals (list): Principals to preserve from the original keytab 57 | user_principal (string): User principal that the keytab authenticates 58 | Returns: 59 | keytab_data (bytes): A binary encoded keytab 60 | Raises: 61 | Exception: If keytab fails to split 62 | """ 63 | with tempfile.NamedTemporaryFile(dir="/tmp", delete=True) as temp_keytab_file: 64 | temp_keytab_file.write(master_keytab_data) 65 | temp_keytab_file.flush() 66 | try: 67 | # Generate a new keytab containing keys for all principals specified. 68 | # If no principals are specified, a keytab containing all principals' 69 | # keys will be generated. 70 | new_keytab_data = self._create_new_keytab_from_principals(temp_keytab_file.name, 71 | principals, 72 | user_principal) 73 | base64_encoded_keytab = KeytabManager.binary_to_base64_string(new_keytab_data) 74 | return base64_encoded_keytab 75 | except Exception as e: 76 | raise Exception(f"Failed to split keytab: {e}") 77 | 78 | def generate_new_keytab_file(self, username: str, password: str, user_principal: str, domain_name: str) -> bytes: 79 | """ 80 | Generates a new keytab for a given user. The keytab contains all principals belonging to the user. 81 | Args: 82 | username (string): AD user name 83 | password (string): Password of the AD user 84 | user_principal (string): User principal of the AD user and the domain it belongs to 85 | domain_name (string): Domain or directory name 86 | Returns: 87 | keytab_data (string): A binary encoded keytab 88 | Raises: 89 | Exception: If keytab fails to create or validate 90 | """ 91 | try: 92 | output_filepath = self.generate_random_keytab_file_path() 93 | generate_keytab_command = [ 94 | "./msktutil", 95 | "update", 96 | "--use-service-account", 97 | "--account-name", 98 | username, 99 | "--old-account-password", 100 | password, 101 | "--keytab", 102 | output_filepath, 103 | "--dont-change-password", 104 | "--realm", 105 | domain_name.upper(), 106 | "-N" 107 | ] 108 | KeytabManager._run_command(command=generate_keytab_command) 109 | keytab_data = KeytabManager._read_file_as_bytes(output_filepath) 110 | KeytabManager._validate_keytab(keytab_data, user_principal) 111 | return keytab_data 112 | except Exception as e: 113 | raise Exception(f"Keytab failed to create or validate: {e}") 114 | 115 | @staticmethod 116 | def validate_base64_encoded_keytab(base64_encoded_keytab: str, user_principal: str): 117 | """ 118 | Validates a base64 encoded keytab 119 | Args: 120 | base64_encoded_keytab (string): Base64 encoded keytab data 121 | user_principal (string): User principal that the keytab authenticates 122 | Raises: 123 | ValueError: If base64 encoded keytab fails to validate 124 | """ 125 | try: 126 | # Decode keytab back to binary form 127 | binary_keytab_data = KeytabManager.base64_string_to_binary(base64_encoded_keytab) 128 | 129 | # Validate binary keytab data 130 | KeytabManager._validate_keytab(binary_keytab_data, user_principal) 131 | except ValueError as e: 132 | raise ValueError(f"Failed to validate base64 encoded keytab: {e}") 133 | 134 | @staticmethod 135 | def _validate_keytab(binary_keytab_data: bytes, user_principal: str): 136 | """ 137 | Validates a keytab against a user principal using kinit 138 | Args: 139 | binary_keytab_data (bytes): Binary data of keytab to validate 140 | user_principal (string): User principal to validate against 141 | Raises: 142 | ValueError: If keytab validation fails 143 | """ 144 | try: 145 | # Write binary keytab data to file (kinit is used to validate the keytab, and kinit only handles files) 146 | with tempfile.NamedTemporaryFile(dir="/tmp", delete=True) as temp_keytab_file: 147 | temp_keytab_file.write(binary_keytab_data) 148 | temp_keytab_file.flush() 149 | 150 | # Validate using kinit 151 | with tempfile.NamedTemporaryFile(dir="/tmp", delete=True) as cache: 152 | kinit_command = [ 153 | "./kinit", 154 | "-c", 155 | cache.name, 156 | user_principal, 157 | "-k", 158 | "-t", 159 | temp_keytab_file.name 160 | ] 161 | KeytabManager._run_command(kinit_command) 162 | except ValueError as e: 163 | raise ValueError(f"Keytab validation failed: {e}") 164 | 165 | def _create_new_keytab_from_principals(self, 166 | original_keytab_filepath: str, 167 | principals: list, 168 | user_principal: str) -> bytes: 169 | """ 170 | Create a new keytab containing a subset of principals from an 171 | existing keytab 172 | Args: 173 | original_keytab_filepath (string): Path to an existing keytab 174 | principals (list): Principals to include in the new keytab 175 | user_principal (string): User principal associated with the keytabs 176 | Raises: 177 | Exception: If new keytab fails to create or validate 178 | """ 179 | try: 180 | # If no principals are specified, a keytab with all principals will 181 | # be created by default 182 | if not principals: 183 | logger.warning(f"No principals specified. Creating a new keytab for all SPNs under UPN {user_principal}") 184 | keytab_data = KeytabManager._read_file_as_bytes(original_keytab_filepath) 185 | return keytab_data 186 | 187 | # 188 | # If principals are specified, a new keytab is generated by starting with a comprehensive 189 | # keytab and deleting the principals that were not specified. 190 | # 191 | 192 | # In keytab file, principals are ordered and given an order number. 193 | # The order number is known as a "slot". This call will retrieve a 194 | # map of each principal and its given slot number. 195 | principal_slots = KeytabManager._get_principal_slots(original_keytab_filepath) 196 | 197 | # Get slot numbers of principals to delete 198 | slot_numbers_to_delete = KeytabManager._get_slot_numbers_to_delete(principals, principal_slots) 199 | 200 | # Generate staging file path for new keytab 201 | new_keytab_filepath = self.generate_random_keytab_file_path() 202 | 203 | # Generate list of commands to send to ktutil 204 | delent_commands = KeytabManager._get_delent_commands_from_slots(slot_numbers_to_delete) 205 | read_kt_command = f"read_kt {original_keytab_filepath}" 206 | write_kt_command = f"write_kt {new_keytab_filepath}" 207 | quit_command = "quit" 208 | ktutil_commands = [ 209 | read_kt_command, 210 | *delent_commands, 211 | write_kt_command, 212 | quit_command 213 | ] 214 | 215 | # Create keytab from subset of principals 216 | ktutil_interactive = InteractiveCommand("./ktutil", ktutil_commands) 217 | ktutil_interactive.send_commands() 218 | 219 | # Validate the new keytab using kinit 220 | new_keytab_data = KeytabManager._read_file_as_bytes(new_keytab_filepath) 221 | KeytabManager._validate_keytab(new_keytab_data, user_principal) 222 | 223 | return new_keytab_data 224 | except Exception as e: 225 | raise Exception(f"Failed to create or validate new keytab from principals: {e}") 226 | 227 | def get_principals_from_base64_keytab(self, base64_encoded_keytab: str) -> set: 228 | binary_encoded_keytab = KeytabManager.base64_string_to_binary(base64_encoded_keytab) 229 | return self._get_principals_from_binary_keytab(binary_encoded_keytab) 230 | 231 | def _get_principals_from_binary_keytab(self, binary_keytab_data: bytes) -> set: 232 | with tempfile.NamedTemporaryFile(dir="/tmp", delete=True) as temp_keytab_file: 233 | temp_keytab_file.write(binary_keytab_data) 234 | temp_keytab_file.flush() 235 | principal_slots = self._get_principal_slots(temp_keytab_file.name) 236 | return set(principal_slots.values()) 237 | 238 | @staticmethod 239 | def _get_slot_numbers_to_delete(principals_to_keep: list, principal_slots: dict): 240 | """ 241 | Identify the slot numbers to delete based on a list of principals to keep. 242 | Args: 243 | principals_to_keep (list): List of principals to keep 244 | principal_slots (dict): Dict of principals mapped to their slot numbers 245 | Returns: 246 | to_delete (list): A list of slot numbers to delete 247 | """ 248 | to_delete = [] 249 | for slot_number, principal in principal_slots.items(): 250 | if principal not in principals_to_keep: 251 | to_delete.append(slot_number) 252 | return to_delete 253 | 254 | @staticmethod 255 | def _get_principal_slots(keytab_filepath: str) -> dict: 256 | """ 257 | Get slot numbers (index) of each keytab principal in a keytab 258 | Args: 259 | keytab_filepath (string): Keytab to get slot numbers for 260 | Returns: 261 | slots (dict): A dictionary of a keytab's principals mapped to their 262 | respective slot number 263 | """ 264 | try: 265 | # Print out principals in keytab, then split the output into lines 266 | klist_command = ['./klist', '-k', keytab_filepath] 267 | klist_output = KeytabManager._run_command(command=klist_command) 268 | lines = klist_output.splitlines() 269 | 270 | # Principal listings begin on the 4th line 271 | principal_lines = lines[3:] 272 | 273 | # Each line is space-delimited and the last entry is the principal. 274 | # Save every principal and preserve their order 275 | principals = [line.split(" ")[-1] for line in principal_lines] 276 | 277 | # Assign slot numbers to each principal, starting with 1 278 | slots = zip(range(1, len(principals) + 1), principals) 279 | slots = {slot[0]: slot[1] for slot in slots} 280 | 281 | # Return each principal with its assigned slot number 282 | return slots 283 | except ValueError as e: 284 | raise ValueError(f"Failed to get principal slots: {e}") 285 | 286 | @staticmethod 287 | def _get_delent_commands_from_slots(slots_to_delete: list) -> list: 288 | """ 289 | Convert list of slots to delete into a list of commands to pipe 290 | into ktutil. Slots deletion is put in reverse order. 291 | Args: 292 | slots_to_delete (list): Keytab principal slots to delete from a keytab 293 | Returns: 294 | A list of "delent" commands for ktutil 295 | """ 296 | # Principals must be deleted in reverse order because ktutil resets the slot numbers 297 | # when a principal is deleted (e.g. if we delete principal in slot 1, indexing resets 298 | # such that principal in slot 2 moves to slot 1, etc. Every slot number is decreased by 1). 299 | slots_to_delete = sorted(slots_to_delete) 300 | slots_to_delete.reverse() 301 | 302 | return [f"delent {slot}" for slot in slots_to_delete] 303 | 304 | @staticmethod 305 | def _run_command(command: list, timeout_in_seconds: int = 15) -> str: 306 | """ 307 | Runs a command line command using subprocess. 308 | Args: 309 | command (list): Command to execute from context of a command line 310 | timeout_in_seconds (int): Timeout for command execution 311 | Returns: 312 | output (string): Standard out from command execution 313 | Raises: 314 | ValueError: If the command execution throws an error 315 | """ 316 | proc = subprocess.Popen(command, 317 | stdin=subprocess.PIPE, 318 | stdout=subprocess.PIPE, 319 | stderr=subprocess.PIPE, 320 | encoding="utf-8", 321 | shell=False) 322 | output, error = proc.communicate(timeout=timeout_in_seconds) 323 | if error or proc.returncode != 0: 324 | raise ValueError( 325 | "Subprocess command failed: %d %s %s" % (proc.returncode, error, output) 326 | ) 327 | return output 328 | 329 | @staticmethod 330 | def _read_file_as_bytes(filepath: str) -> bytes: 331 | """ 332 | Reads a files contents as bytes 333 | Args: 334 | filepath (string): File path to read 335 | Returns: 336 | Bytes in file 337 | """ 338 | try: 339 | with open(filepath, 'rb') as new_keytab: 340 | return new_keytab.read() 341 | except Exception as e: 342 | logger.info(f"Could not read bytes from file {filepath}: {e}") 343 | return bytes() 344 | 345 | @staticmethod 346 | def get_user_principal(user: str, domain_name: str): 347 | """ 348 | Converts an AD user name and a domain name to a user principal string 349 | Args: 350 | user (string): AD user 351 | domain_name (string): AD domain name or directory name 352 | Returns: 353 | AD user principal 354 | """ 355 | return f"{user}@{domain_name.upper()}" 356 | 357 | @staticmethod 358 | def binary_to_base64_string(binary_data: bytes) -> str: 359 | return binascii.b2a_base64(binary_data, newline=False).decode("utf8") 360 | 361 | @staticmethod 362 | def base64_string_to_binary(base64_str: str) -> bytes: 363 | return binascii.a2b_base64(base64_str) 364 | 365 | 366 | class InteractiveCommand: 367 | def __init__(self, executable: str, commands: list): 368 | self.executable = executable 369 | self.commands = commands 370 | 371 | def send_commands(self, expects_response=False): 372 | """ 373 | Sends multiple commands to an interactive executable 374 | 375 | Returns: 376 | nothing 377 | """ 378 | # Start the interactive command in a process 379 | process = self._start_process() 380 | 381 | # Send the interactive commands 382 | for command in self.commands: 383 | self._write(process, command) 384 | time.sleep(.2) # Some delay is required for subsequent commands to be properly registered 385 | 386 | if expects_response: 387 | self._read(process) 388 | 389 | # Terminate the process 390 | self._terminate(process) 391 | 392 | def _start_process(self): 393 | """ 394 | Starts a subprocess 395 | 396 | Returns: 397 | nothing 398 | """ 399 | return subprocess.Popen(self.executable, 400 | stdin=subprocess.PIPE, 401 | stdout=subprocess.PIPE, 402 | stderr=subprocess.PIPE) 403 | 404 | def _read(self, process: Popen): 405 | """ 406 | Reads output from the open process 407 | 408 | Args: 409 | process: current open process 410 | 411 | Returns: 412 | nothing 413 | """ 414 | return process.stdout.readline().decode("utf-8").strip() 415 | 416 | def _write(self, process: Popen, command: str): 417 | """ 418 | Sends commands to the open process 419 | 420 | Args: 421 | process: current open process 422 | command: command to send 423 | 424 | Returns: 425 | nothing 426 | """ 427 | process.stdin.write(f"{command.strip()}\n".encode("utf-8")) 428 | process.stdin.flush() 429 | 430 | def _terminate(self, process: Popen): 431 | """ 432 | Safely terminates the current open process 433 | Args: 434 | process: current open process 435 | 436 | Returns: 437 | nothing 438 | """ 439 | process.stdin.close() 440 | process.terminate() 441 | process.wait(timeout=0.2) 442 | -------------------------------------------------------------------------------- /SecretsManagerActiveDirectoryRotationSingleUser/lambda_function.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import json 5 | import logging 6 | import os 7 | import subprocess 8 | import tempfile 9 | from typing import Final 10 | import boto3 11 | 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.INFO) 14 | 15 | """ 16 | This Lambda Function rotates the password for an Directory Services user account 17 | and rotates the corresponding secret stored in Secrets Manager. Specifically, 18 | this function updates the password for an existing user rather than creating 19 | a new user. This means that there is a shot period of time when the password in 20 | Directory Services does not match the secret in Secrets Manager. Consumers of 21 | the secret should be aware of this and implement a retry after a short wait if 22 | authentication fails. You can read more about this here: 23 | https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets 24 | -lambda-function-customizing.html 25 | 26 | The Secrets Manager secret should include three key/value pairs stored as JSON. 27 | For example, the default secret looks like this: 28 | { 29 | "DirectoryId": "d-1234567890", 30 | "Username": "WebServiceAccount", 31 | "Password": "SuperSecretPassword123!" 32 | } 33 | You can override the keys using environment variables for use-cases other than 34 | Seamless Domain Join. 35 | For example, Systems Manager Seamless Domain Join uses 36 | 'awsSeamlessDomainDirectoryId', 37 | 'awsSeamlessDomainUsername', and 'awsSeamlessDomainPassword' as key names 38 | within the secret. 39 | 40 | Important Notes: 41 | #1 Kerberos needs DNS, please change DHCP options set to use domain name. 42 | #2 This Lambda must be connected to the same VPC as your Directory Services 43 | directory. 44 | #3 For Directory Services please add corresponding route to internet 45 | gateway for AWS CLI. 46 | #4 The pre-initialized secret must match AD credentials. 47 | 48 | """ 49 | # If DICT_KEY_USERNAME, DICT_KEY_USERNAME are set, this 50 | # password rotation can be used for other users. 51 | DICT_KEY_DIRECTORY = os.environ.get( 52 | "DICT_KEY_DIRECTORY") or "awsSeamlessDomainDirectoryId" 53 | DICT_KEY_USERNAME = os.environ.get( 54 | "DICT_KEY_USERNAME") or "awsSeamlessDomainUsername" 55 | DICT_KEY_PASSWORD = os.environ.get( 56 | "DICT_KEY_PASSWORD") or "awsSeamlessDomainPassword" 57 | 58 | KINIT_CURRENT_CREDS_SUCCESSFUL: Final = "KINIT_USING_CURRENT_CREDS_SUCCESSFUL" 59 | KINIT_PENDING_CREDS_SUCCESSFUL: Final = "KINIT_USING_PENDING_CREDS_SUCCESSFUL" 60 | EXCLUDE_CHARACTERS: Final = "/@\"'\\" 61 | 62 | 63 | def lambda_handler(event, context): 64 | """ 65 | Rotates a password for a Directory Services user account. This is the 66 | main lambda entry point. 67 | Args: 68 | event (dict): Lambda dictionary of event parameters. These keys must 69 | include the following: 70 | - SecretId: The secret ARN or identifier 71 | - ClientRequestToken: The ClientRequestToken of the secret version 72 | - Step: The rotation step (one of createSecret, setSecret, 73 | testSecret, or finishSecret) 74 | context (LambdaContext): The Lambda runtime information 75 | Raises: 76 | ResourceNotFoundException: If the secret with the specified arn and 77 | stage does not exist 78 | ValueError: If the secret is not properly configured for rotation 79 | KeyError: If the event parameters do not contain the expected keys 80 | Exceptions from ds.describe_directories : 81 | DirectoryService.Client.exceptions.EntityDoesNotExistException 82 | DirectoryService.Client.exceptions.InvalidParameterException 83 | DirectoryService.Client.exceptions.InvalidNextTokenException 84 | DirectoryService.Client.exceptions.ClientException 85 | DirectoryService.Client.exceptions.ServiceException 86 | """ 87 | arn = event["SecretId"] 88 | token = event["ClientRequestToken"] 89 | step = event["Step"] 90 | 91 | # To use only the packaged kerberos libraries. 92 | os.environ["LD_LIBRARY_PATH"] = "./:$LD_LIBRARY_PATH" 93 | 94 | # Setup the clients 95 | secrets_manager_client = boto3.client( 96 | "secretsmanager", endpoint_url=os.environ["SECRETS_MANAGER_ENDPOINT"] 97 | ) 98 | directory_services_client = boto3.client("ds") 99 | 100 | # Make sure the version is staged correctly 101 | metadata = secrets_manager_client.describe_secret(SecretId=arn) 102 | if "RotationEnabled" in metadata and not metadata["RotationEnabled"]: 103 | logger.error("Secret %s is not enabled for rotation" % arn) 104 | raise ValueError("Secret %s is not enabled for rotation" % arn) 105 | 106 | current_dict = get_secret_dict(secrets_manager_client, arn, "AWSCURRENT") 107 | directory_name_list = [current_dict[DICT_KEY_DIRECTORY]] 108 | directory_info = directory_services_client.describe_directories( 109 | DirectoryIds=directory_name_list, Limit=1 110 | ) 111 | directory_description = directory_info["DirectoryDescriptions"][0] 112 | directory_name = directory_description["Name"] 113 | 114 | versions = metadata["VersionIdsToStages"] 115 | if token not in versions: 116 | logger.error( 117 | "Secret version %s has no stage for rotation of secret %s." % (token, arn) 118 | ) 119 | raise ValueError( 120 | "Secret version %s has no stage for rotation of secret %s." % (token, arn) 121 | ) 122 | if "AWSCURRENT" in versions[token]: 123 | logger.info( 124 | "Secret version %s already set as AWSCURRENT for secret %s." % (token, arn) 125 | ) 126 | return 127 | elif "AWSPENDING" not in versions[token]: 128 | logger.error( 129 | "Secret version %s not set as AWSPENDING for rotation of secret %s." 130 | % (token, arn) 131 | ) 132 | raise ValueError( 133 | "Secret version %s not set as AWSPENDING for rotation of secret %s." 134 | % (token, arn) 135 | ) 136 | 137 | # Call the appropriate step 138 | if step == "createSecret": 139 | create_secret(secrets_manager_client, arn, token, directory_name, current_dict) 140 | elif step == "setSecret": 141 | # Get the pending secret and update password in Directory Services 142 | pending_dict = get_secret_dict(secrets_manager_client, arn, "AWSPENDING", token) 143 | if current_dict[DICT_KEY_USERNAME] != pending_dict[DICT_KEY_USERNAME]: 144 | logger.error( 145 | "Username %s in current dict does not match username %s in " 146 | "pending dict" 147 | % (current_dict[DICT_KEY_USERNAME], pending_dict[DICT_KEY_USERNAME]) 148 | ) 149 | raise ValueError( 150 | "Username %s in current dict does not match username %s in " 151 | "pending dict" 152 | % (current_dict[DICT_KEY_USERNAME], pending_dict[DICT_KEY_USERNAME]) 153 | ) 154 | pending_directory_name_list = [pending_dict[DICT_KEY_DIRECTORY]] 155 | if pending_directory_name_list != directory_name_list: 156 | logger.error( 157 | "Current directory name list %s does not match pending " 158 | "directory name list %s" 159 | % (directory_name_list, pending_directory_name_list) 160 | ) 161 | raise ValueError( 162 | "Current directory name list %s does not match pending " 163 | "directory name list %s" 164 | % (directory_name_list, pending_directory_name_list) 165 | ) 166 | set_secret( 167 | directory_services_client, 168 | directory_name, 169 | current_dict, 170 | pending_dict, 171 | ) 172 | elif step == "testSecret": 173 | pending_dict = get_secret_dict(secrets_manager_client, arn, "AWSPENDING", token) 174 | test_secret(directory_name, pending_dict) 175 | elif step == "finishSecret": 176 | finish_secret(secrets_manager_client, arn, token) 177 | else: 178 | logger.error( 179 | "lambda_handler: Invalid step parameter %s for secret %s" % (step, arn) 180 | ) 181 | raise ValueError("Invalid step parameter %s for secret %s" % (step, arn)) 182 | 183 | 184 | def create_secret(secrets_manager_client, arn, token, directory_name, current_dict): 185 | """ 186 | Creates a new secret and labels it AWSPENDING. This is the first step in 187 | the rotation. 188 | It only creates the pending secret in Secrets Manager. It does NOT update 189 | Directory Services. That 190 | will happen in the next step, setSecret. This method first checks for the 191 | existence of a pending 192 | secret for the passed in token. If one does not exist, it will generate a 193 | new secret. 194 | Args: 195 | secrets_manager_client (client): The secrets manager service client 196 | arn (string): The secret ARN or other identifier 197 | token (string): The ClientRequestToken associated with the secret 198 | directory_name (string): Directory name used for kinit 199 | current_dict (dictionary): Used for kinit operations 200 | Raises: 201 | ValueError: Raise exception if kinit fails with given credentials 202 | """ 203 | 204 | # Exception if kinit fails 205 | execute_kinit_command(current_dict, None, directory_name) 206 | 207 | # Now try to get the secret version, if that fails, put a new secret 208 | try: 209 | get_secret_dict(secrets_manager_client, arn, "AWSPENDING", token) 210 | logger.info("createSecret: Successfully retrieved secret for %s." % arn) 211 | except secrets_manager_client.exceptions.ResourceNotFoundException: 212 | exclude_characters = os.environ.get("EXCLUDE_CHARACTERS", EXCLUDE_CHARACTERS) 213 | # Generate a random password 214 | passwd = secrets_manager_client.get_random_password( 215 | ExcludeCharacters=exclude_characters 216 | ) 217 | current_dict[DICT_KEY_PASSWORD] = passwd["RandomPassword"] 218 | 219 | # Put the secret 220 | secrets_manager_client.put_secret_value( 221 | SecretId=arn, 222 | ClientRequestToken=token, 223 | SecretString=json.dumps(current_dict), 224 | VersionStages=["AWSPENDING"], 225 | ) 226 | logger.info( 227 | "createSecret: Successfully put secret for ARN %s and version %s." 228 | % (arn, token) 229 | ) 230 | 231 | 232 | def set_secret(directory_services_client, directory_name, current_dict, pending_dict): 233 | """ 234 | Set the secret in Directory Services. This is the second step, 235 | where Directory Services 236 | is actually updated. This method does not update the Secret Manager 237 | label. Therefore, the 238 | AWSCURRENT secret does not match the password in Directory Services as 239 | the end of this 240 | step. We are technically in a broken state at the end of this step. It 241 | will be fixed in the 242 | finishSecret step when the Secrets Manager value is updated. 243 | Args: 244 | directory_services_client (client): The directory services client 245 | directory_name (string): Directory name used for kinit 246 | current_dict (dictionary): Used for kinit operations 247 | pending_dict (dictionary): Used to reset Directory Services password 248 | Raises: 249 | ResourceNotFoundException: If the secret with the specified arn and 250 | stage does not exist 251 | ValueError: If the secret is not valid JSON or unable to set password 252 | in Directory Services 253 | KeyError: If the secret json does not contain the expected keys 254 | ValueError: Raise exception if kinit fails with given credentials 255 | """ 256 | 257 | # Make sure current or pending credentials work 258 | status = execute_kinit_command(current_dict, pending_dict, directory_name) 259 | # Cover the case where this step has already succeeded and 260 | # AWSCURRENT is no longer the current password, try to log in 261 | # with the AWSPENDING password and if that is successful, immediately 262 | # return. 263 | if status == KINIT_PENDING_CREDS_SUCCESSFUL: 264 | return 265 | 266 | try: 267 | directory_services_client.reset_user_password( 268 | DirectoryId=pending_dict[DICT_KEY_DIRECTORY], 269 | UserName=pending_dict[DICT_KEY_USERNAME], 270 | NewPassword=pending_dict[DICT_KEY_PASSWORD], 271 | ) 272 | except Exception as directory_service_exception: 273 | logger.error( 274 | "setSecret: Unable to reset the users password in Directory " 275 | "Services for directory %s and user %s" 276 | % (pending_dict[DICT_KEY_DIRECTORY], pending_dict[DICT_KEY_USERNAME]) 277 | ) 278 | raise ValueError( 279 | "Unable to reset the users password in Directory Services" 280 | ) from directory_service_exception 281 | 282 | 283 | def test_secret(directory_name, pending_dict): 284 | """ 285 | Args: 286 | directory_name (string) : Directory name 287 | pending_dict (dictionary): Used to test pending credentials 288 | Raises: 289 | ValueError: Raise exception if kinit fails with given credentials 290 | """ 291 | execute_kinit_command(None, pending_dict, directory_name) 292 | 293 | 294 | def finish_secret(secrets_manager_client, arn, token): 295 | """ 296 | Finish the rotation by marking the pending secret as current. This is the 297 | final step. 298 | This method finishes the secret rotation by staging the secret staged 299 | AWSPENDING with the AWSCURRENT stage. 300 | secrets_manager_client (client): The secrets manager service client 301 | arn (string): The secret ARN or other identifier 302 | token (string): The ClientRequestToken associated with the secret version 303 | """ 304 | 305 | # First describe the secret to get the current version 306 | metadata = secrets_manager_client.describe_secret(SecretId=arn) 307 | current_version = None 308 | for version in metadata["VersionIdsToStages"]: 309 | if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: 310 | if version == token: 311 | # The correct version is already marked as current, return 312 | logger.info( 313 | "finishSecret: Version %s already marked as AWSCURRENT " 314 | "for %s" % (version, arn) 315 | ) 316 | return 317 | current_version = version 318 | break 319 | 320 | # Finalize by staging the secret version current 321 | secrets_manager_client.update_secret_version_stage( 322 | SecretId=arn, 323 | VersionStage="AWSCURRENT", 324 | MoveToVersionId=token, 325 | RemoveFromVersionId=current_version, 326 | ) 327 | logger.info( 328 | "finishSecret: Successfully set AWSCURRENT stage to version %s for " 329 | "secret %s." % (token, arn) 330 | ) 331 | 332 | 333 | def get_secret_dict(secrets_manager_client, arn, stage, token=None): 334 | """ 335 | Gets the secret dictionary corresponding for the secret arn, stage, 336 | and token 337 | This helper function gets credentials for the arn and stage passed in and 338 | returns the dictionary 339 | by parsing the JSON string. You can change the default dictionary keys 340 | using env vars above. 341 | Args: 342 | secrets_manager_client (client): The secrets manager service client 343 | arn (string): The secret ARN or other identifier 344 | token (string): The ClientRequestToken associated with the secret 345 | version, or None if no validation is desired 346 | stage (string): The stage identifying the secret version 347 | Returns: 348 | SecretDictionary: Secret dictionary 349 | Raises: 350 | ResourceNotFoundException: If the secret with the specified arn and 351 | stage does not exist 352 | ValueError: If the secret is not valid JSON 353 | """ 354 | required_fields = [DICT_KEY_DIRECTORY, DICT_KEY_USERNAME, DICT_KEY_PASSWORD] 355 | # Only do VersionId validation against the stage if a token is passed in 356 | if token: 357 | secret = secrets_manager_client.get_secret_value( 358 | SecretId=arn, VersionId=token, VersionStage=stage 359 | ) 360 | else: 361 | secret = secrets_manager_client.get_secret_value( 362 | SecretId=arn, VersionStage=stage 363 | ) 364 | plaintext = secret["SecretString"] 365 | secret_dict = json.loads(plaintext) 366 | 367 | for field in required_fields: 368 | if field not in secret_dict: 369 | raise KeyError("%s key is missing from secret JSON" % field) 370 | 371 | # Parse and return the secret JSON string 372 | return secret_dict 373 | 374 | 375 | def execute_kinit_command(current_dict, pending_dict, directory_name): 376 | """ 377 | Executes the kinit command to verify user credentials. 378 | Args: 379 | current_dict (dictionary): Dictionary containing current credentials 380 | pending_dict (dictionary): Dictionary containing pending credentials 381 | directory_name (string): Directory name used for kinit command 382 | Returns: 383 | kinit_creds_successful or raises exception 384 | Raises: 385 | ValueError: Raise exception if kinit fails with given credentials 386 | """ 387 | 388 | if pending_dict is not None: 389 | # First try to log in with the AWSPENDING password and if that is 390 | # successful, immediately return. 391 | with tempfile.NamedTemporaryFile(dir="/tmp", delete=True) as cache: 392 | username, password = check_inputs(pending_dict) 393 | try: 394 | proc = subprocess.Popen( 395 | [ 396 | "./kinit", 397 | "-c", 398 | cache.name, 399 | "%s@%s" % (username, directory_name.upper()), 400 | ], 401 | stdin=subprocess.PIPE, 402 | stdout=subprocess.PIPE, 403 | encoding="utf-8", 404 | shell=False, 405 | ) 406 | output, error = proc.communicate(input="%s\n" % password, timeout=15) 407 | if error is not None or proc.returncode != 0: 408 | raise ValueError( 409 | "kinit failed %d %s %s" % (proc.returncode, error, output) 410 | ) 411 | return KINIT_PENDING_CREDS_SUCCESSFUL 412 | except: 413 | # If Pending secret does not authenticate, we can proceed to 414 | # current secret. 415 | logger.info( 416 | "execute_kinit_command: Proceed to current secret since " 417 | "pending secret " 418 | "does not authenticate" 419 | ) 420 | 421 | if current_dict is None: 422 | logger.error("execute_kinit_command: Unexpected value for current_dict") 423 | raise ValueError("execute_kinit_command: Unexpected value for current_dict") 424 | 425 | with tempfile.NamedTemporaryFile(dir="/tmp", delete=True) as cache: 426 | try: 427 | username, password = check_inputs(current_dict) 428 | proc = subprocess.Popen( 429 | [ 430 | "./kinit", 431 | "-c", 432 | cache.name, 433 | "%s@%s" % (username, directory_name.upper()), 434 | ], 435 | stdin=subprocess.PIPE, 436 | stdout=subprocess.PIPE, 437 | encoding="utf-8", 438 | shell=False, 439 | ) 440 | output, error = proc.communicate(input="%s\n" % password, timeout=15) 441 | if error is not None or proc.returncode != 0: 442 | raise ValueError( 443 | "kinit failed %d %s %s" % (proc.returncode, error, output) 444 | ) 445 | return KINIT_CURRENT_CREDS_SUCCESSFUL 446 | except Exception: 447 | logger.error("execute_kinit_command: kinit failed") 448 | raise ValueError("execute_kinit_command: kinit failed") from Exception 449 | 450 | 451 | def check_inputs(dict_arg): 452 | """ 453 | Check username and password for invalid characters 454 | Args: 455 | dict_arg (dictionary): Dictionary containing current credentials 456 | Returns: 457 | username(string): Username from Directory Service 458 | password(string): Password of username from Directory Service 459 | Raises: 460 | Value Error: If username or password has characters from exclude list. 461 | """ 462 | username = dict_arg[DICT_KEY_USERNAME] 463 | password = dict_arg[DICT_KEY_PASSWORD] 464 | 465 | exclude_characters = os.environ.get("EXCLUDE_CHARACTERS", EXCLUDE_CHARACTERS) 466 | 467 | username_check_list = [char in username for char in exclude_characters] 468 | if True in username_check_list: 469 | raise ValueError("check_inputs: Invalid character in username") 470 | 471 | password_check_list = [char in password for char in exclude_characters] 472 | if True in password_check_list: 473 | raise ValueError("check_inputs: Invalid character in password") 474 | 475 | return username, password 476 | 477 | -------------------------------------------------------------------------------- /SecretsManagerElasticacheUserRotation/lambda_function.py: -------------------------------------------------------------------------------- 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) 349 | -------------------------------------------------------------------------------- /SecretsManagerInfluxDBRotationSingleUser/lambda_function.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024 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 influxdb_client 9 | from contextlib import contextmanager 10 | 11 | logger = logging.getLogger() 12 | logger.setLevel(logging.INFO) 13 | 14 | def lambda_handler(event, context): 15 | """Secrets Manager InfluxDB User Rotation Multi User Handler 16 | 17 | This handler uses the single user rotation strategy to rotate an InfluxDB users password. This rotation 18 | strategy authenticates the current user in the InfluxDB instance and creates a new password for the user. 19 | 20 | InfluxDB users do not hold a specific set of permissions, but rather own tokens. Tokens cannot be owned 21 | by multiple users. If a user gets deleted so do the tokens that belong to that user. Tokens are the 22 | recommended way for managing access control with Timestream for InfluxDB. Users should be used to create 23 | tokens, and the single user Lambda rotation function should be used to manage password rotation for users. 24 | 25 | The Secret SecretString is expected to be a JSON string with the following format: 26 | { 27 | 'engine': , 28 | 'username': , 29 | 'password': , 30 | 'dbIdentifier': , 31 | } 32 | 33 | Args: 34 | event (dict): Lambda dictionary of event parameters. These keys must include the following: 35 | - SecretId: The secret ARN or identifier 36 | - ClientRequestToken: The ClientRequestToken of the secret version 37 | - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) 38 | 39 | context (LambdaContext): The Lambda runtime information 40 | 41 | Raises: 42 | ResourceNotFoundException: If the secret with the specified ARN and stage does not exist 43 | ValueError: If the secret is not properly configured for rotation 44 | KeyError: If SECRETS_MANAGER_ENDPOINT not set in the environment variables 45 | 46 | """ 47 | arn = event["SecretId"] 48 | version_token = event["ClientRequestToken"] 49 | step = event["Step"] 50 | 51 | boto_session = boto3.Session() 52 | secrets_client = boto_session.client("secretsmanager", endpoint_url=os.environ["SECRETS_MANAGER_ENDPOINT"]) 53 | influxdb_client = boto_session.client("timestream-influxdb") 54 | 55 | # Make sure the version is staged correctly 56 | metadata = secrets_client.describe_secret(SecretId=arn) 57 | if "RotationEnabled" in metadata and not metadata["RotationEnabled"]: 58 | logger.error("Secret %s is not enabled for rotation." % arn) 59 | raise ValueError("Secret %s is not enabled for rotation." % arn) 60 | versions = metadata["VersionIdsToStages"] 61 | if version_token not in versions: 62 | logger.error("Secret version %s has no stage for rotation of secret %s." % (version_token, arn)) 63 | raise ValueError("Secret version %s has no stage for rotation of secret %s." % (version_token, arn)) 64 | if "AWSCURRENT" in versions[version_token]: 65 | logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (version_token, arn)) 66 | return 67 | elif "AWSPENDING" not in versions[version_token]: 68 | logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (version_token, arn)) 69 | raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (version_token, arn)) 70 | 71 | if step == "createSecret": 72 | create_secret(secrets_client, arn, version_token) 73 | 74 | elif step == "setSecret": 75 | set_secret(secrets_client, influxdb_client, arn, version_token) 76 | 77 | elif step == "testSecret": 78 | test_secret(secrets_client, influxdb_client, arn, version_token) 79 | 80 | elif step == "finishSecret": 81 | finish_secret(secrets_client, arn, version_token) 82 | 83 | else: 84 | logger.error("lambda_handler: Invalid step parameter %s for secret %s." % (step, arn)) 85 | raise ValueError("Invalid step parameter %s for secret %s." % (step, arn)) 86 | 87 | def create_secret(secrets_client, arn, version_token): 88 | """Create the secret 89 | 90 | This method first checks for the existence of a secret for the passed in user. If one does not exist, it will generate a 91 | password and place a new secret in the pending stage. 92 | 93 | Args: 94 | secrets_client (client): The Secrets Manager service client 95 | arn (string): The secret ARN or other identifier 96 | version_token (string): The ClientRequestToken associated with the secret version 97 | 98 | """ 99 | 100 | # Make sure the current secret exists 101 | current_secret_dict = get_secret_dict(secrets_client, arn, "AWSCURRENT") 102 | 103 | # Now try to get the secret, if that fails, put a new secret 104 | try: 105 | get_secret_dict(secrets_client, arn, "AWSPENDING", version_token) 106 | logger.info("create_secret: Successfully retrieved secret for %s." % arn) 107 | except secrets_client.exceptions.ResourceNotFoundException: 108 | current_secret_dict["password"] = secrets_client.get_random_password()["RandomPassword"] 109 | secrets_client.put_secret_value(SecretId=arn, ClientRequestToken=version_token, SecretString=json.dumps(current_secret_dict), VersionStages=["AWSPENDING"]) 110 | 111 | logger.info("create_secret: Successfully generated new password and staged for ARN %s and version %s." % (arn, version_token)) 112 | 113 | 114 | def set_secret(secrets_client, influxdb_client, arn, version_token): 115 | """Set the secret 116 | 117 | 118 | This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it 119 | tries to login with the AWSCURRENT and AWSPREVIOUS secrets. If either one succeeds, it sets the AWSPENDING password 120 | as the user password in the database. Else, it throws a ValueError. 121 | 122 | Args: 123 | secrets_client (client): The Secrets Manager service client 124 | influxdb_client (client): The InfluxDB client 125 | arn (string): The secret ARN or other identifier 126 | version_token (string): The ClientRequestToken associated with the secret version 127 | 128 | """ 129 | 130 | try: 131 | previous_secret_dict = get_secret_dict(secrets_client, arn, "AWSPREVIOUS") 132 | except (secrets_client.exceptions.ResourceNotFoundException, KeyError): 133 | previous_secret_dict = None 134 | 135 | # Make sure the current secret exists 136 | current_secret_dict = get_secret_dict(secrets_client, arn, "AWSCURRENT") 137 | pending_secret_dict = get_secret_dict(secrets_client, arn, "AWSPENDING", version_token) 138 | endpoint_url = get_db_info(current_secret_dict["dbIdentifier"], influxdb_client) 139 | 140 | # Make sure the DB instance from current and pending match 141 | if current_secret_dict["dbIdentifier"] != pending_secret_dict["dbIdentifier"]: 142 | logger.error("setSecret: Attempting to modify user for a DB %s other than current DB %s." % (pending_secret_dict["dbIdentifier"], current_secret_dict["dbIdentifier"])) 143 | raise ValueError("Attempting to modify user for DB %s other than current DB %s." % (pending_secret_dict["dbIdentifier"], current_secret_dict["dbIdentifier"])) 144 | 145 | # Make sure the username in current and pending secrets match 146 | if current_secret_dict["username"] != pending_secret_dict["username"]: 147 | logger.error("setSecret: Attempting to modify user %s other than current user %s." % (pending_secret_dict["username"], current_secret_dict["username"])) 148 | raise ValueError("Attempting to modify user %s other than current user %s." % (pending_secret_dict["username"], current_secret_dict["username"])) 149 | 150 | # First try to login with the pending secret, if it succeeds, return 151 | try: 152 | with get_connection(endpoint_url, pending_secret_dict, arn, "setSecret", True) as pending_conn: 153 | pending_conn.organizations_api().find_organizations() 154 | logger.info("Successfully authenticated the pending user secret.") 155 | return 156 | except Exception: 157 | pass 158 | 159 | password_update_success = False 160 | # Attempt connection and password update with the current secret 161 | try: 162 | with get_connection(endpoint_url, current_secret_dict, arn, "setSecret", True) as conn: 163 | conn.users_api().update_password(user=conn.users_api().me().id, password=pending_secret_dict["password"]) 164 | password_update_success = True 165 | logger.info("Successfully authenticated the current secret for updating password.") 166 | except Exception: 167 | pass 168 | 169 | # If the current secret fails to authenticate then we can attempt a connection with the previous secret 170 | if not password_update_success and previous_secret_dict is not None: 171 | try: 172 | with get_connection(endpoint_url, previous_secret_dict, arn, "setSecret", True) as conn: 173 | conn.users_api().update_password(user=conn.users_api().me().id, password=pending_secret_dict["password"]) 174 | logger.info("Successfully authenticated the previous secret for updating password.") 175 | password_update_success = True 176 | except Exception: 177 | pass 178 | 179 | if not password_update_success: 180 | logger.error("setSecret: Failed to update password for secret ARN %s." % arn) 181 | raise ValueError("Unable to log into database with previous, current, or pending secret of secret ARN %s." % arn) 182 | 183 | logger.info("set_secret: Successfully updated the password for ARN %s and version %s." % (arn, version_token)) 184 | 185 | 186 | def test_secret(secrets_client, influxdb_client, arn, version_token): 187 | """Test the user against the InfluxDB instance 188 | 189 | This method attempts a connection with the Timestream for InfluxDB instance with the secrets staged 190 | in AWSPENDING and ensures the pending and current secrets have matching dbIdentifier and username values. 191 | 192 | Args: 193 | secrets_client (client): The Secrets Manager service client 194 | influxdb_client (client): The InfluxDB client 195 | arn (string): The secret ARN or other identifier 196 | version_token (string): The ClientRequestToken associated with the secret version 197 | 198 | Raises: ValueError: If the pending user fails to authenticate. 199 | 200 | """ 201 | 202 | pending_secret_dict = get_secret_dict(secrets_client, arn, "AWSPENDING", version_token) 203 | 204 | # Verify pending authentication can successfully authenticate 205 | with get_connection(get_db_info(pending_secret_dict["dbIdentifier"], influxdb_client), pending_secret_dict, arn, "testSecret") as pending_user_client: 206 | pending_user_client.organizations_api().find_organizations() 207 | 208 | logger.info("test_secret: Successfully tested authentication rotation.") 209 | 210 | 211 | def finish_secret(secrets_client, arn, version_token): 212 | """Finish the secret 213 | 214 | This method finalizes the rotation process by marking the secret version passed in as the AWSCURRENT secret. 215 | 216 | Args: 217 | secrets_client (client): The Secrets Manager service client 218 | arn (string): The secret ARN or other identifier 219 | version_token (string): The ClientRequestToken associated with the secret version 220 | 221 | Raises: 222 | ValueError: If the current secret and pending secret do not have matching dbIdentifier values. 223 | 224 | """ 225 | 226 | # First describe the secret to get the current version 227 | metadata = secrets_client.describe_secret(SecretId=arn) 228 | current_version = None 229 | for version in metadata["VersionIdsToStages"]: 230 | if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: 231 | if version == version_token: 232 | # The correct version is already marked as current, return 233 | logger.info("finish_secret: Version %s already marked as AWSCURRENT for %s." % (version, arn)) 234 | return 235 | current_version = version 236 | break 237 | 238 | # Finalize by staging the secret version current 239 | secrets_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=version_token, RemoveFromVersionId=current_version,) 240 | 241 | logger.info("finish_secret: Successfully set AWSCURRENT stage to version %s for secret %s." % (version_token, arn)) 242 | 243 | 244 | def get_secret_dict(secrets_client, arn, stage, version_token=None): 245 | """Gets the secret dictionary corresponding for the secret arn, stage, and version_token 246 | 247 | This helper function gets credentials for the ARN and stage passed in and returns the dictionary by parsing the 248 | JSON string 249 | 250 | Args: 251 | secrets_client (client): The Secrets Manager service client 252 | arn (string): The secret ARN or other identifier 253 | stage (string): The stage identifying the secret version 254 | versionId (string): The ClientRequestToken associated with the secret version, or None if no validation is desired 255 | 256 | Returns: 257 | SecretDictionary: Secret dictionary 258 | 259 | Raises: 260 | ResourceNotFoundException: If the secret with the specified ARN and stage does not exist 261 | ValueError: If the secret is not valid JSON 262 | KeyError: If required keys missing in secret or engine is not 'timestream-influxdb' 263 | 264 | """ 265 | 266 | # Only do VersionId validation against the stage if a version_token is passed in 267 | if version_token: 268 | secret = secrets_client.get_secret_value(SecretId=arn, VersionId=version_token, VersionStage=stage) 269 | else: 270 | secret = secrets_client.get_secret_value(SecretId=arn, VersionStage=stage) 271 | plaintext = secret["SecretString"] 272 | try: 273 | secret_dict = json.loads(plaintext) 274 | except Exception: 275 | # wrap JSON parser exceptions to avoid possible token disclosure 276 | logger.error("Invalid secret value JSON for secret %s." % arn) 277 | raise ValueError("Invalid secret value JSON for secret %s." % arn) 278 | 279 | # Run semantic validations for secrets 280 | required_fields = ["engine", "username", "password", "dbIdentifier"] 281 | 282 | for field in required_fields: 283 | if field not in secret_dict: 284 | raise KeyError("%s key is missing from secret JSON." % field) 285 | 286 | if secret_dict["engine"] != "timestream-influxdb": 287 | raise KeyError("Database engine must be set to 'timestream-influxdb' in order to use this Lambda rotation function.") 288 | 289 | return secret_dict 290 | 291 | 292 | def get_db_info(db_instance_identifier, influxdb_client): 293 | """Get InfluxDB information 294 | 295 | This helper function returns the url for the InfluxDB instance, 296 | that matches the identifier which is provided in the user secret. 297 | 298 | Args: 299 | db_instance_identifier (string): The InfluxDB instance identifier 300 | influxdb_client (client): The InfluxDB client 301 | 302 | Returns: 303 | endpoint (string): The endpoint for the DB instance 304 | 305 | Raises: 306 | ValueError: Failed to retrieve DB information 307 | KeyError: DB info returned does not contain expected key 308 | 309 | """ 310 | 311 | describe_response = influxdb_client.get_db_instance(identifier=db_instance_identifier) 312 | 313 | if describe_response is None or describe_response["endpoint"] is None: 314 | raise KeyError("Invalid endpoint info for InfluxDB instance.") 315 | 316 | return describe_response["endpoint"] 317 | 318 | 319 | 320 | @contextmanager 321 | def get_connection(endpoint_url, secret_dict, arn, step, ignore_error=False): 322 | """Get connection to InfluxDB 323 | 324 | This helper function returns a connection to the provided InfluxDB instance. 325 | 326 | Args: 327 | endpoint_url (string): Url for the InfluxDB instance 328 | secret_dict (dictionary): Dictionary with username/password to authenticate connection 329 | arn (string): ARN for secret to log in event of failure to make connection 330 | step (string): Step in which the Lambda function is making the connection 331 | ignore_error (boolean): Flag for if to ignore errors 332 | 333 | Raises: 334 | ValueError: If the connection or health check fails 335 | 336 | """ 337 | conn = None 338 | try: 339 | conn = influxdb_client.InfluxDBClient(url="https://" + endpoint_url + ":8086", username=secret_dict["username"], password=secret_dict["password"], debug=False, verify_ssl=True) 340 | 341 | # Verify InfluxDB connection 342 | health = conn.ping() 343 | if not health and not ignore_error: 344 | logger.error("%s: Connection failure." % step) 345 | 346 | yield conn 347 | except Exception as err: 348 | if not ignore_error: 349 | logger.error("%s: Connection failure with secret ARN %s %s." % (step, arn, err)) 350 | raise ValueError("%s: Failed to set new authorization with secret ARN %s %s." % (step, arn, err)) from err 351 | finally: 352 | if conn is not None: 353 | conn.close() 354 | 355 | 356 | 357 | -------------------------------------------------------------------------------- /SecretsManagerMongoDBRotationSingleUser/lambda_function.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import re 5 | import boto3 6 | import json 7 | import logging 8 | import os 9 | from pymongo import MongoClient, errors 10 | 11 | logger = logging.getLogger() 12 | logger.setLevel(logging.INFO) 13 | 14 | 15 | def lambda_handler(event, context): 16 | """Secrets Manager MongoDB Handler 17 | 18 | This handler uses the single-user rotation scheme to rotate a MongoDB user credential. This rotation scheme 19 | logs into the database as the user and rotates the user's own password, immediately invalidating the user's 20 | previous password. 21 | 22 | The Secret SecretString is expected to be a JSON string with the following format: 23 | { 24 | 'engine': , 25 | 'host': , 26 | 'username': , 27 | 'password': , 28 | 'dbname': , 29 | 'port': 30 | 'ssl': 31 | } 32 | 33 | Args: 34 | event (dict): Lambda dictionary of event parameters. These keys must include the following: 35 | - SecretId: The secret ARN or identifier 36 | - ClientRequestToken: The ClientRequestToken of the secret version 37 | - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) 38 | 39 | context (LambdaContext): The Lambda runtime information 40 | 41 | Raises: 42 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 43 | 44 | ValueError: If the secret is not properly configured for rotation 45 | 46 | KeyError: If the secret json does not contain the expected keys 47 | 48 | """ 49 | arn = event['SecretId'] 50 | token = event['ClientRequestToken'] 51 | step = event['Step'] 52 | 53 | # Setup the client 54 | service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT']) 55 | 56 | # Make sure the version is staged correctly 57 | metadata = service_client.describe_secret(SecretId=arn) 58 | if "RotationEnabled" in metadata and not metadata['RotationEnabled']: 59 | logger.error("Secret %s is not enabled for rotation" % arn) 60 | raise ValueError("Secret %s is not enabled for rotation" % arn) 61 | versions = metadata['VersionIdsToStages'] 62 | if token not in versions: 63 | logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 64 | raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 65 | if "AWSCURRENT" in versions[token]: 66 | logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)) 67 | return 68 | elif "AWSPENDING" not in versions[token]: 69 | logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 70 | raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 71 | 72 | # Call the appropriate step 73 | if step == "createSecret": 74 | create_secret(service_client, arn, token) 75 | 76 | elif step == "setSecret": 77 | set_secret(service_client, arn, token) 78 | 79 | elif step == "testSecret": 80 | test_secret(service_client, arn, token) 81 | 82 | elif step == "finishSecret": 83 | finish_secret(service_client, arn, token) 84 | 85 | else: 86 | logger.error("lambda_handler: Invalid step parameter %s for secret %s" % (step, arn)) 87 | raise ValueError("Invalid step parameter %s for secret %s" % (step, arn)) 88 | 89 | 90 | def create_secret(service_client, arn, token): 91 | """Generate a new secret 92 | 93 | This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a 94 | new secret and put it with the passed in token. 95 | 96 | Args: 97 | service_client (client): The secrets manager service client 98 | 99 | arn (string): The secret ARN or other identifier 100 | 101 | token (string): The ClientRequestToken associated with the secret version 102 | 103 | Raises: 104 | ValueError: If the current secret is not valid JSON 105 | 106 | KeyError: If the secret json does not contain the expected keys 107 | 108 | """ 109 | # Make sure the current secret exists 110 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 111 | 112 | # Now try to get the secret version, if that fails, put a new secret 113 | try: 114 | get_secret_dict(service_client, arn, "AWSPENDING", token) 115 | logger.info("createSecret: Successfully retrieved secret for %s." % arn) 116 | except service_client.exceptions.ResourceNotFoundException: 117 | # Generate a random password 118 | current_dict['password'] = get_random_password(service_client) 119 | 120 | # Put the secret 121 | service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=json.dumps(current_dict), VersionStages=['AWSPENDING']) 122 | logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token)) 123 | 124 | 125 | def set_secret(service_client, arn, token): 126 | """Set the pending secret in the database 127 | 128 | This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it 129 | tries to login with the AWSCURRENT and AWSPREVIOUS secrets. If either one succeeds, it sets the AWSPENDING password 130 | as the user password in the database. Else, it throws a ValueError. 131 | 132 | Args: 133 | service_client (client): The secrets manager service client 134 | 135 | arn (string): The secret ARN or other identifier 136 | 137 | token (string): The ClientRequestToken associated with the secret version 138 | 139 | Raises: 140 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 141 | 142 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 143 | 144 | KeyError: If the secret json does not contain the expected keys 145 | 146 | """ 147 | try: 148 | previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS") 149 | except (service_client.exceptions.ResourceNotFoundException, KeyError): 150 | previous_dict = None 151 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 152 | pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) 153 | 154 | # First try to login with the pending secret, if it succeeds, return 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 | # Make sure the user from current and pending match 162 | if current_dict['username'] != pending_dict['username']: 163 | logger.error("setSecret: Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 164 | raise ValueError("Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 165 | 166 | # Make sure the host from current and pending match 167 | if current_dict['host'] != pending_dict['host']: 168 | logger.error("setSecret: Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 169 | raise ValueError("Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 170 | 171 | # Now try the current password 172 | conn = get_connection(current_dict) 173 | 174 | # If both current and pending do not work, try previous 175 | if not conn and previous_dict: 176 | # Update previous_dict to leverage current SSL settings 177 | previous_dict.pop('ssl', None) 178 | if 'ssl' in current_dict: 179 | previous_dict['ssl'] = current_dict['ssl'] 180 | 181 | conn = get_connection(previous_dict) 182 | 183 | # Make sure the user/host from previous and pending match 184 | if previous_dict['username'] != pending_dict['username']: 185 | logger.error("setSecret: Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 186 | raise ValueError("Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 187 | if previous_dict['host'] != pending_dict['host']: 188 | logger.error("setSecret: Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 189 | raise ValueError("Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 190 | 191 | # If we still don't have a connection, raise a ValueError 192 | if not conn: 193 | logger.error("setSecret: Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 194 | raise ValueError("Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 195 | 196 | # Now set the password to the pending password 197 | try: 198 | conn.command("updateUser", pending_dict['username'], pwd=pending_dict['password']) 199 | logger.info("setSecret: Successfully set password for user %s in MongoDB for secret arn %s." % (pending_dict['username'], arn)) 200 | except errors.PyMongoError: 201 | logger.error("setSecret: Error encountered when attempting to set password in database for user %s", pending_dict['username']) 202 | raise ValueError("Error encountered when attempting to set password in database for user %s", pending_dict['username']) 203 | finally: 204 | conn.logout() 205 | 206 | 207 | def test_secret(service_client, arn, token): 208 | """Test the pending secret against the database 209 | 210 | This method tries to log into the database with the secrets staged with AWSPENDING and runs 211 | a permissions check to ensure the user has the corrrect permissions. 212 | 213 | Args: 214 | service_client (client): The secrets manager service client 215 | 216 | arn (string): The secret ARN or other identifier 217 | 218 | token (string): The ClientRequestToken associated with the secret version 219 | 220 | Raises: 221 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 222 | 223 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 224 | 225 | KeyError: If the secret json does not contain the expected keys 226 | 227 | """ 228 | # Try to login with the pending secret, if it succeeds, return 229 | pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) 230 | conn = get_connection(pending_dict) 231 | if conn: 232 | # This is where the lambda will validate the user's permissions. Uncomment/modify the below lines to 233 | # tailor these validations to your needs 234 | try: 235 | conn.command('usersInfo', pending_dict['username']) 236 | finally: 237 | conn.logout() 238 | 239 | logger.info("testSecret: Successfully signed into MongoDB with AWSPENDING secret in %s." % arn) 240 | return 241 | else: 242 | logger.error("testSecret: Unable to log into database with pending secret of secret ARN %s" % arn) 243 | raise ValueError("Unable to log into database with pending secret of secret ARN %s" % arn) 244 | 245 | 246 | def finish_secret(service_client, arn, token): 247 | """Finish the rotation by marking the pending secret as current 248 | 249 | This method finishes the secret rotation by staging the secret staged AWSPENDING with the AWSCURRENT stage. 250 | 251 | Args: 252 | service_client (client): The secrets manager service client 253 | 254 | arn (string): The secret ARN or other identifier 255 | 256 | token (string): The ClientRequestToken associated with the secret version 257 | 258 | """ 259 | # First describe the secret to get the current version 260 | metadata = service_client.describe_secret(SecretId=arn) 261 | current_version = None 262 | for version in metadata["VersionIdsToStages"]: 263 | if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: 264 | if version == token: 265 | # The correct version is already marked as current, return 266 | logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn)) 267 | return 268 | current_version = version 269 | break 270 | 271 | # Finalize by staging the secret version current 272 | service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version) 273 | logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, arn)) 274 | 275 | 276 | def get_connection(secret_dict): 277 | """Gets a connection to MongoDB from a secret dictionary 278 | 279 | This helper function uses connectivity information from the secret dictionary to initiate 280 | connection attempt(s) to the database. Will attempt a fallback, non-SSL connection when 281 | initial connection fails using SSL and fall_back is True. 282 | 283 | Args: 284 | secret_dict (dict): The Secret Dictionary 285 | 286 | Returns: 287 | Connection: The pymongo.database.Database object if successful. None otherwise 288 | 289 | Raises: 290 | KeyError: If the secret json does not contain the expected keys 291 | 292 | """ 293 | # Parse and validate the secret JSON string 294 | port = int(secret_dict['port']) if 'port' in secret_dict else 27017 295 | dbname = secret_dict['dbname'] if 'dbname' in secret_dict else "admin" 296 | 297 | # Get SSL connectivity configuration 298 | use_ssl, fall_back = get_ssl_config(secret_dict) 299 | 300 | # if an 'ssl' key is not found or does not contain a valid value, attempt an SSL connection and fall back to non-SSL on failure 301 | conn = connect_and_authenticate(secret_dict, port, dbname, use_ssl) 302 | if conn or not fall_back: 303 | return conn 304 | else: 305 | return connect_and_authenticate(secret_dict, port, dbname, False) 306 | 307 | 308 | def get_ssl_config(secret_dict): 309 | """Gets the desired SSL and fall back behavior using a secret dictionary 310 | 311 | This helper function uses the existance and value the 'ssl' key in a secret dictionary 312 | to determine desired SSL connectivity configuration. Its behavior is as follows: 313 | - 'ssl' key DNE or invalid type/value: return True, True 314 | - 'ssl' key is bool: return secret_dict['ssl'], False 315 | - 'ssl' key equals "true" ignoring case: return True, False 316 | - 'ssl' key equals "false" ignoring case: return False, False 317 | 318 | Args: 319 | secret_dict (dict): The Secret Dictionary 320 | 321 | Returns: 322 | Tuple(use_ssl, fall_back): SSL configuration 323 | - use_ssl (bool): Flag indicating if an SSL connection should be attempted 324 | - fall_back (bool): Flag indicating if non-SSL connection should be attempted if SSL connection fails 325 | 326 | """ 327 | # Default to True for SSL and fall_back mode if 'ssl' key DNE 328 | if 'ssl' not in secret_dict: 329 | return True, True 330 | 331 | # Handle type bool 332 | if isinstance(secret_dict['ssl'], bool): 333 | return secret_dict['ssl'], False 334 | 335 | # Handle type string 336 | if isinstance(secret_dict['ssl'], str): 337 | ssl = secret_dict['ssl'].lower() 338 | if ssl == "true": 339 | return True, False 340 | elif ssl == "false": 341 | return False, False 342 | else: 343 | # Invalid string value, default to True for both SSL and fall_back mode 344 | return True, True 345 | 346 | # Invalid type, default to True for both SSL and fall_back mode 347 | return True, True 348 | 349 | 350 | def connect_and_authenticate(secret_dict, port, dbname, use_ssl): 351 | """Attempt to connect and authenticate to a MongoDB instance 352 | 353 | This helper function tries to connect to the database using connectivity info passed in. 354 | If successful, it returns the connection, else None 355 | 356 | Args: 357 | - secret_dict (dict): The Secret Dictionary 358 | - port (int): The databse port to connect to 359 | - dbname (str): Name of the database 360 | - use_ssl (bool): Flag indicating whether connection should use SSL/TLS 361 | 362 | Returns: 363 | Connection: The pymongo.database.Database object if successful. None otherwise 364 | 365 | Raises: 366 | KeyError: If the secret json does not contain the expected keys 367 | 368 | """ 369 | # Try to obtain a connection to the db 370 | try: 371 | # Hostname verfification and server certificate validation enabled by default when ssl=True 372 | client = MongoClient(host=secret_dict['host'], port=port, connectTimeoutMS=5000, serverSelectionTimeoutMS=5000, ssl=use_ssl) 373 | db = client[dbname] 374 | db.authenticate(secret_dict['username'], secret_dict['password']) 375 | logger.info("Successfully established %s connection as user '%s' with host: '%s'" % ("SSL/TLS" if use_ssl else "non SSL/TLS", secret_dict['username'], secret_dict['host'])) 376 | return db 377 | except errors.PyMongoError as e: 378 | if 'SSL handshake failed' in e.args[0]: 379 | logger.error("Unable to establish SSL/TLS handshake, check that SSL/TLS is enabled on the host: %s" % secret_dict['host']) 380 | elif re.search("hostname '.+' doesn't match", e.args[0]): 381 | logger.error("Hostname verification failed when estlablishing SSL/TLS Handshake with host: %s" % secret_dict['host']) 382 | return None 383 | 384 | 385 | def get_secret_dict(service_client, arn, stage, token=None): 386 | """Gets the secret dictionary corresponding for the secret arn, stage, and token 387 | 388 | This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string 389 | 390 | Args: 391 | service_client (client): The secrets manager service client 392 | 393 | arn (string): The secret ARN or other identifier 394 | 395 | token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired 396 | 397 | stage (string): The stage identifying the secret version 398 | 399 | Returns: 400 | SecretDictionary: Secret dictionary 401 | 402 | Raises: 403 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 404 | 405 | ValueError: If the secret is not valid JSON 406 | 407 | """ 408 | required_fields = ['host', 'username', 'password'] 409 | 410 | # Only do VersionId validation against the stage if a token is passed in 411 | if token: 412 | secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage) 413 | else: 414 | secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage) 415 | plaintext = secret['SecretString'] 416 | secret_dict = json.loads(plaintext) 417 | 418 | # Run validations against the secret 419 | if 'engine' not in secret_dict or secret_dict['engine'] != 'mongo': 420 | raise KeyError("Database engine must be set to 'mongo' in order to use this rotation lambda") 421 | for field in required_fields: 422 | if field not in secret_dict: 423 | raise KeyError("%s key is missing from secret JSON" % field) 424 | 425 | # Parse and return the secret JSON string 426 | return secret_dict 427 | 428 | 429 | def get_environment_bool(variable_name, default_value): 430 | """Loads the environment variable and converts it to the boolean. 431 | 432 | Args: 433 | variable_name (string): Name of environment variable 434 | 435 | default_value (bool): The result will fallback to the default_value when the environment variable with the given name doesn't exist. 436 | 437 | Returns: 438 | bool: True when the content of environment variable contains either 'true', '1', 'y' or 'yes' 439 | """ 440 | variable = os.environ.get(variable_name, str(default_value)) 441 | return variable.lower() in ['true', '1', 'y', 'yes'] 442 | 443 | 444 | def get_random_password(service_client): 445 | """ Generates a random new password. Generator loads parameters that affects the content of the resulting password from the environment 446 | variables. When environment variable is missing sensible defaults are chosen. 447 | 448 | Supported environment variables: 449 | - EXCLUDE_CHARACTERS 450 | - PASSWORD_LENGTH 451 | - EXCLUDE_NUMBERS 452 | - EXCLUDE_PUNCTUATION 453 | - EXCLUDE_UPPERCASE 454 | - EXCLUDE_LOWERCASE 455 | - REQUIRE_EACH_INCLUDED_TYPE 456 | 457 | Args: 458 | service_client (client): The secrets manager service client 459 | 460 | Returns: 461 | string: The randomly generated password. 462 | """ 463 | passwd = service_client.get_random_password( 464 | ExcludeCharacters=os.environ.get('EXCLUDE_CHARACTERS', '/@"\'\\'), 465 | PasswordLength=int(os.environ.get('PASSWORD_LENGTH', 32)), 466 | ExcludeNumbers=get_environment_bool('EXCLUDE_NUMBERS', False), 467 | ExcludePunctuation=get_environment_bool('EXCLUDE_PUNCTUATION', False), 468 | ExcludeUppercase=get_environment_bool('EXCLUDE_UPPERCASE', False), 469 | ExcludeLowercase=get_environment_bool('EXCLUDE_LOWERCASE', False), 470 | RequireEachIncludedType=get_environment_bool('REQUIRE_EACH_INCLUDED_TYPE', True) 471 | ) 472 | return passwd['RandomPassword'] 473 | -------------------------------------------------------------------------------- /SecretsManagerRDSMariaDBRotationSingleUser/lambda_function.py: -------------------------------------------------------------------------------- 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 pymysql 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.INFO) 12 | 13 | 14 | def lambda_handler(event, context): 15 | """Secrets Manager RDS MariaDB Handler 16 | 17 | This handler uses the single-user rotation scheme to rotate an RDS MariaDB user credential. This rotation scheme 18 | logs into the database as the user and rotates the user's own password, immediately invalidating the user's 19 | previous password. 20 | 21 | The Secret SecretString is expected to be a JSON string with the following format: 22 | { 23 | 'engine': , 24 | 'host': , 25 | 'username': , 26 | 'password': , 27 | 'dbname': , 28 | 'port': 29 | } 30 | 31 | Args: 32 | event (dict): Lambda dictionary of event parameters. These keys must include the following: 33 | - SecretId: The secret ARN or identifier 34 | - ClientRequestToken: The ClientRequestToken of the secret version 35 | - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) 36 | 37 | context (LambdaContext): The Lambda runtime information 38 | 39 | Raises: 40 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 41 | 42 | ValueError: If the secret is not properly configured for rotation 43 | 44 | KeyError: If the secret json does not contain the expected keys 45 | 46 | """ 47 | arn = event['SecretId'] 48 | token = event['ClientRequestToken'] 49 | step = event['Step'] 50 | 51 | # Setup the client 52 | service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT']) 53 | 54 | # Make sure the version is staged correctly 55 | metadata = service_client.describe_secret(SecretId=arn) 56 | if "RotationEnabled" in metadata and not metadata['RotationEnabled']: 57 | logger.error("Secret %s is not enabled for rotation" % arn) 58 | raise ValueError("Secret %s is not enabled for rotation" % arn) 59 | versions = metadata['VersionIdsToStages'] 60 | if token not in versions: 61 | logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 62 | raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 63 | if "AWSCURRENT" in versions[token]: 64 | logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)) 65 | return 66 | elif "AWSPENDING" not in versions[token]: 67 | logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 68 | raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 69 | 70 | # Call the appropriate step 71 | if step == "createSecret": 72 | create_secret(service_client, arn, token) 73 | 74 | elif step == "setSecret": 75 | set_secret(service_client, arn, token) 76 | 77 | elif step == "testSecret": 78 | test_secret(service_client, arn, token) 79 | 80 | elif step == "finishSecret": 81 | finish_secret(service_client, arn, token) 82 | 83 | else: 84 | logger.error("lambda_handler: Invalid step parameter %s for secret %s" % (step, arn)) 85 | raise ValueError("Invalid step parameter %s for secret %s" % (step, arn)) 86 | 87 | 88 | def create_secret(service_client, arn, token): 89 | """Generate a new 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 | service_client (client): The secrets manager service client 96 | 97 | arn (string): The secret ARN or other identifier 98 | 99 | token (string): The ClientRequestToken associated with the secret version 100 | 101 | Raises: 102 | ValueError: If the current secret is not valid JSON 103 | 104 | KeyError: If the secret json does not contain the expected keys 105 | 106 | """ 107 | # Make sure the current secret exists 108 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 109 | 110 | # Now try to get the secret version, if that fails, put a new secret 111 | try: 112 | get_secret_dict(service_client, arn, "AWSPENDING", token) 113 | logger.info("createSecret: Successfully retrieved secret for %s." % arn) 114 | except service_client.exceptions.ResourceNotFoundException: 115 | # Generate a random password 116 | current_dict['password'] = get_random_password(service_client) 117 | 118 | # Put the secret 119 | service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=json.dumps(current_dict), VersionStages=['AWSPENDING']) 120 | logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token)) 121 | 122 | 123 | def set_secret(service_client, arn, token): 124 | """Set the pending secret in the database 125 | 126 | This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it 127 | tries to login with the AWSCURRENT and AWSPREVIOUS secrets. If either one succeeds, it sets the AWSPENDING password 128 | as the user password in the database. Else, it throws a ValueError. 129 | 130 | Args: 131 | service_client (client): The secrets manager service client 132 | 133 | arn (string): The secret ARN or other identifier 134 | 135 | token (string): The ClientRequestToken associated with the secret version 136 | 137 | Raises: 138 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 139 | 140 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 141 | 142 | KeyError: If the secret json does not contain the expected keys 143 | 144 | """ 145 | try: 146 | previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS") 147 | except (service_client.exceptions.ResourceNotFoundException, KeyError): 148 | previous_dict = None 149 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 150 | pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) 151 | 152 | # First try to login with the pending secret, if it succeeds, return 153 | conn = get_connection(pending_dict) 154 | if conn: 155 | conn.close() 156 | logger.info("setSecret: AWSPENDING secret is already set as password in MariaDB DB for secret arn %s." % arn) 157 | return 158 | 159 | # Make sure the user from current and pending match 160 | if current_dict['username'] != pending_dict['username']: 161 | logger.error("setSecret: Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 162 | raise ValueError("Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 163 | 164 | # Make sure the host from current and pending match 165 | if current_dict['host'] != pending_dict['host']: 166 | logger.error("setSecret: Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 167 | raise ValueError("Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 168 | 169 | # Now try the current password 170 | conn = get_connection(current_dict) 171 | 172 | # If both current and pending do not work, try previous 173 | if not conn and previous_dict: 174 | # Update previous_dict to leverage current SSL settings 175 | previous_dict.pop('ssl', None) 176 | if 'ssl' in current_dict: 177 | previous_dict['ssl'] = current_dict['ssl'] 178 | 179 | conn = get_connection(previous_dict) 180 | 181 | # Make sure the user and host from previous and pending match 182 | if previous_dict['username'] != pending_dict['username']: 183 | logger.error("setSecret: Attempting to modify user %s other than last valid user %s" % (pending_dict['username'], previous_dict['username'])) 184 | raise ValueError("Attempting to modify user %s other than last valid user %s" % (pending_dict['username'], previous_dict['username'])) 185 | if previous_dict['host'] != pending_dict['host']: 186 | logger.error("setSecret: Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 187 | raise ValueError("Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 188 | 189 | # If we still don't have a connection, raise a ValueError 190 | if not conn: 191 | logger.error("setSecret: Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 192 | raise ValueError("Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 193 | 194 | # Now set the password to the pending password 195 | try: 196 | with conn.cursor() as cur: 197 | cur.execute("SET PASSWORD = PASSWORD(%s)", pending_dict['password']) 198 | conn.commit() 199 | logger.info("setSecret: Successfully set password for user %s in MariaDB DB for secret arn %s." % (pending_dict['username'], arn)) 200 | finally: 201 | conn.close() 202 | 203 | 204 | def test_secret(service_client, arn, token): 205 | """Test the pending secret against the database 206 | 207 | This method tries to log into the database with the secrets staged with AWSPENDING and runs 208 | a permissions check to ensure the user has the corrrect permissions. 209 | 210 | Args: 211 | service_client (client): The secrets manager service client 212 | 213 | arn (string): The secret ARN or other identifier 214 | 215 | token (string): The ClientRequestToken associated with the secret version 216 | 217 | Raises: 218 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 219 | 220 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 221 | 222 | KeyError: If the secret json does not contain the expected keys 223 | 224 | """ 225 | # Try to login with the pending secret, if it succeeds, return 226 | conn = get_connection(get_secret_dict(service_client, arn, "AWSPENDING", token)) 227 | if conn: 228 | # This is where the lambda will validate the user's permissions. Uncomment/modify the below lines to 229 | # tailor these validations to your needs 230 | try: 231 | with conn.cursor() as cur: 232 | cur.execute("SELECT NOW()") 233 | conn.commit() 234 | finally: 235 | conn.close() 236 | 237 | logger.info("testSecret: Successfully signed into MariaDB DB with AWSPENDING secret in %s." % arn) 238 | return 239 | else: 240 | logger.error("testSecret: Unable to log into database with pending secret of secret ARN %s" % arn) 241 | raise ValueError("Unable to log into database with pending secret of secret ARN %s" % arn) 242 | 243 | 244 | def finish_secret(service_client, arn, token): 245 | """Finish the rotation by marking the pending secret as current 246 | 247 | This method finishes the secret rotation by staging the secret staged AWSPENDING with the AWSCURRENT stage. 248 | 249 | Args: 250 | service_client (client): The secrets manager service client 251 | 252 | arn (string): The secret ARN or other identifier 253 | 254 | token (string): The ClientRequestToken associated with the secret version 255 | 256 | """ 257 | # First describe the secret to get the current version 258 | metadata = service_client.describe_secret(SecretId=arn) 259 | current_version = None 260 | for version in metadata["VersionIdsToStages"]: 261 | if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: 262 | if version == token: 263 | # The correct version is already marked as current, return 264 | logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn)) 265 | return 266 | current_version = version 267 | break 268 | 269 | # Finalize by staging the secret version current 270 | service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version) 271 | logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, arn)) 272 | 273 | 274 | def get_connection(secret_dict): 275 | """Gets a connection to MariaDB DB from a secret dictionary 276 | 277 | This helper function uses connectivity information from the secret dictionary to initiate 278 | connection attempt(s) to the database. Will attempt a fallback, non-SSL connection when 279 | initial connection fails using SSL and fall_back is True. 280 | 281 | Args: 282 | secret_dict (dict): The Secret Dictionary 283 | 284 | Returns: 285 | Connection: The pymysql.connections.Connection object if successful. None otherwise 286 | 287 | Raises: 288 | KeyError: If the secret json does not contain the expected keys 289 | 290 | """ 291 | # Parse and validate the secret JSON string 292 | port = int(secret_dict['port']) if 'port' in secret_dict else 3306 293 | dbname = secret_dict['dbname'] if 'dbname' in secret_dict else None 294 | 295 | # Get SSL connectivity configuration 296 | use_ssl, fall_back = get_ssl_config(secret_dict) 297 | 298 | # if an 'ssl' key is not found or does not contain a valid value, attempt an SSL connection and fall back to non-SSL on failure 299 | conn = connect_and_authenticate(secret_dict, port, dbname, use_ssl) 300 | if conn or not fall_back: 301 | return conn 302 | else: 303 | return connect_and_authenticate(secret_dict, port, dbname, False) 304 | 305 | 306 | def get_ssl_config(secret_dict): 307 | """Gets the desired SSL and fall back behavior using a secret dictionary 308 | 309 | This helper function uses the existance and value the 'ssl' key in a secret dictionary 310 | to determine desired SSL connectivity configuration. Its behavior is as follows: 311 | - 'ssl' key DNE or invalid type/value: return True, True 312 | - 'ssl' key is bool: return secret_dict['ssl'], False 313 | - 'ssl' key equals "true" ignoring case: return True, False 314 | - 'ssl' key equals "false" ignoring case: return False, False 315 | 316 | Args: 317 | secret_dict (dict): The Secret Dictionary 318 | 319 | Returns: 320 | Tuple(use_ssl, fall_back): SSL configuration 321 | - use_ssl (bool): Flag indicating if an SSL connection should be attempted 322 | - fall_back (bool): Flag indicating if non-SSL connection should be attempted if SSL connection fails 323 | 324 | """ 325 | # Default to True for SSL and fall_back mode if 'ssl' key DNE 326 | if 'ssl' not in secret_dict: 327 | return True, True 328 | 329 | # Handle type bool 330 | if isinstance(secret_dict['ssl'], bool): 331 | return secret_dict['ssl'], False 332 | 333 | # Handle type string 334 | if isinstance(secret_dict['ssl'], str): 335 | ssl = secret_dict['ssl'].lower() 336 | if ssl == "true": 337 | return True, False 338 | elif ssl == "false": 339 | return False, False 340 | else: 341 | # Invalid string value, default to True for both SSL and fall_back mode 342 | return True, True 343 | 344 | # Invalid type, default to True for both SSL and fall_back mode 345 | return True, True 346 | 347 | 348 | def connect_and_authenticate(secret_dict, port, dbname, use_ssl): 349 | """Attempt to connect and authenticate to a MariaDB instance 350 | 351 | This helper function tries to connect to the database using connectivity info passed in. 352 | If successful, it returns the connection, else None 353 | 354 | Args: 355 | - secret_dict (dict): The Secret Dictionary 356 | - port (int): The databse port to connect to 357 | - dbname (str): Name of the database 358 | - use_ssl (bool): Flag indicating whether connection should use SSL/TLS 359 | 360 | Returns: 361 | Connection: The pymongo.database.Database object if successful. None otherwise 362 | 363 | Raises: 364 | KeyError: If the secret json does not contain the expected keys 365 | 366 | """ 367 | ssl = {'ca': '/etc/pki/tls/cert.pem'} if use_ssl else None 368 | 369 | # Try to obtain a connection to the db 370 | try: 371 | # Checks hostname and verifies server certificate implictly when 'ca' key is in 'ssl' dictionary 372 | conn = pymysql.connect(host=secret_dict['host'], user=secret_dict['username'], password=secret_dict['password'], port=port, database=dbname, connect_timeout=5, ssl=ssl) 373 | logger.info("Successfully established %s connection as user '%s' with host: '%s'" % ("SSL/TLS" if use_ssl else "non SSL/TLS", secret_dict['username'], secret_dict['host'])) 374 | return conn 375 | except pymysql.OperationalError as e: 376 | if 'certificate verify failed: IP address mismatch' in e.args[1]: 377 | logger.error("Hostname verification failed when estlablishing SSL/TLS Handshake with host: %s" % secret_dict['host']) 378 | return None 379 | 380 | 381 | def get_secret_dict(service_client, arn, stage, token=None): 382 | """Gets the secret dictionary corresponding for the secret arn, stage, and token 383 | 384 | This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string 385 | 386 | Args: 387 | service_client (client): The secrets manager service client 388 | 389 | arn (string): The secret ARN or other identifier 390 | 391 | token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired 392 | 393 | stage (string): The stage identifying the secret version 394 | 395 | Returns: 396 | SecretDictionary: Secret dictionary 397 | 398 | Raises: 399 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 400 | 401 | ValueError: If the secret is not valid JSON 402 | 403 | """ 404 | required_fields = ['host', 'username', 'password'] 405 | 406 | # Only do VersionId validation against the stage if a token is passed in 407 | if token: 408 | secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage) 409 | else: 410 | secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage) 411 | plaintext = secret['SecretString'] 412 | secret_dict = json.loads(plaintext) 413 | 414 | # Run validations against the secret 415 | if 'engine' not in secret_dict or secret_dict['engine'] != 'mariadb': 416 | raise KeyError("Database engine must be set to 'mariadb' in order to use this rotation lambda") 417 | for field in required_fields: 418 | if field not in secret_dict: 419 | raise KeyError("%s key is missing from secret JSON" % field) 420 | 421 | # Parse and return the secret JSON string 422 | return secret_dict 423 | 424 | 425 | def get_environment_bool(variable_name, default_value): 426 | """Loads the environment variable and converts it to the boolean. 427 | 428 | Args: 429 | variable_name (string): Name of environment variable 430 | 431 | default_value (bool): The result will fallback to the default_value when the environment variable with the given name doesn't exist. 432 | 433 | Returns: 434 | bool: True when the content of environment variable contains either 'true', '1', 'y' or 'yes' 435 | """ 436 | variable = os.environ.get(variable_name, str(default_value)) 437 | return variable.lower() in ['true', '1', 'y', 'yes'] 438 | 439 | 440 | def get_random_password(service_client): 441 | """ Generates a random new password. Generator loads parameters that affects the content of the resulting password from the environment 442 | variables. When environment variable is missing sensible defaults are chosen. 443 | 444 | Supported environment variables: 445 | - EXCLUDE_CHARACTERS 446 | - PASSWORD_LENGTH 447 | - EXCLUDE_NUMBERS 448 | - EXCLUDE_PUNCTUATION 449 | - EXCLUDE_UPPERCASE 450 | - EXCLUDE_LOWERCASE 451 | - REQUIRE_EACH_INCLUDED_TYPE 452 | 453 | Args: 454 | service_client (client): The secrets manager service client 455 | 456 | Returns: 457 | string: The randomly generated password. 458 | """ 459 | passwd = service_client.get_random_password( 460 | ExcludeCharacters=os.environ.get('EXCLUDE_CHARACTERS', '/@"\'\\'), 461 | PasswordLength=int(os.environ.get('PASSWORD_LENGTH', 32)), 462 | ExcludeNumbers=get_environment_bool('EXCLUDE_NUMBERS', False), 463 | ExcludePunctuation=get_environment_bool('EXCLUDE_PUNCTUATION', False), 464 | ExcludeUppercase=get_environment_bool('EXCLUDE_UPPERCASE', False), 465 | ExcludeLowercase=get_environment_bool('EXCLUDE_LOWERCASE', False), 466 | RequireEachIncludedType=get_environment_bool('REQUIRE_EACH_INCLUDED_TYPE', True) 467 | ) 468 | return passwd['RandomPassword'] 469 | -------------------------------------------------------------------------------- /SecretsManagerRDSMySQLRotationSingleUser/lambda_function.py: -------------------------------------------------------------------------------- 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 pymysql 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.INFO) 12 | 13 | 14 | def lambda_handler(event, context): 15 | """Secrets Manager RDS MySQL Handler 16 | 17 | This handler uses the single-user rotation scheme to rotate an RDS MySQL user credential. This rotation scheme 18 | logs into the database as the user and rotates the user's own password, immediately invalidating the user's 19 | previous password. 20 | 21 | The Secret SecretString is expected to be a JSON string with the following format: 22 | { 23 | 'engine': , 24 | 'host': , 25 | 'username': , 26 | 'password': , 27 | 'dbname': , 28 | 'port': 29 | } 30 | 31 | Args: 32 | event (dict): Lambda dictionary of event parameters. These keys must include the following: 33 | - SecretId: The secret ARN or identifier 34 | - ClientRequestToken: The ClientRequestToken of the secret version 35 | - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) 36 | 37 | context (LambdaContext): The Lambda runtime information 38 | 39 | Raises: 40 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 41 | 42 | ValueError: If the secret is not properly configured for rotation 43 | 44 | KeyError: If the secret json does not contain the expected keys 45 | 46 | """ 47 | arn = event['SecretId'] 48 | token = event['ClientRequestToken'] 49 | step = event['Step'] 50 | 51 | # Setup the client 52 | service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT']) 53 | 54 | # Make sure the version is staged correctly 55 | metadata = service_client.describe_secret(SecretId=arn) 56 | if "RotationEnabled" in metadata and not metadata['RotationEnabled']: 57 | logger.error("Secret %s is not enabled for rotation" % arn) 58 | raise ValueError("Secret %s is not enabled for rotation" % arn) 59 | versions = metadata['VersionIdsToStages'] 60 | if token not in versions: 61 | logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 62 | raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 63 | if "AWSCURRENT" in versions[token]: 64 | logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)) 65 | return 66 | elif "AWSPENDING" not in versions[token]: 67 | logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 68 | raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 69 | 70 | # Call the appropriate step 71 | if step == "createSecret": 72 | create_secret(service_client, arn, token) 73 | 74 | elif step == "setSecret": 75 | set_secret(service_client, arn, token) 76 | 77 | elif step == "testSecret": 78 | test_secret(service_client, arn, token) 79 | 80 | elif step == "finishSecret": 81 | finish_secret(service_client, arn, token) 82 | 83 | else: 84 | logger.error("lambda_handler: Invalid step parameter %s for secret %s" % (step, arn)) 85 | raise ValueError("Invalid step parameter %s for secret %s" % (step, arn)) 86 | 87 | 88 | def create_secret(service_client, arn, token): 89 | """Generate a new 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 | service_client (client): The secrets manager service client 96 | 97 | arn (string): The secret ARN or other identifier 98 | 99 | token (string): The ClientRequestToken associated with the secret version 100 | 101 | Raises: 102 | ValueError: If the current secret is not valid JSON 103 | 104 | KeyError: If the secret json does not contain the expected keys 105 | 106 | """ 107 | # Make sure the current secret exists 108 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 109 | 110 | # Now try to get the secret version, if that fails, put a new secret 111 | try: 112 | get_secret_dict(service_client, arn, "AWSPENDING", token) 113 | logger.info("createSecret: Successfully retrieved secret for %s." % arn) 114 | except service_client.exceptions.ResourceNotFoundException: 115 | # Generate a random password 116 | current_dict['password'] = get_random_password(service_client) 117 | 118 | # Put the secret 119 | service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=json.dumps(current_dict), VersionStages=['AWSPENDING']) 120 | logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token)) 121 | 122 | 123 | def set_secret(service_client, arn, token): 124 | """Set the pending secret in the database 125 | 126 | This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it 127 | tries to login with the AWSCURRENT and AWSPREVIOUS secrets. If either one succeeds, it sets the AWSPENDING password 128 | as the user password in the database. Else, it throws a ValueError. 129 | 130 | Args: 131 | service_client (client): The secrets manager service client 132 | 133 | arn (string): The secret ARN or other identifier 134 | 135 | token (string): The ClientRequestToken associated with the secret version 136 | 137 | Raises: 138 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 139 | 140 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 141 | 142 | KeyError: If the secret json does not contain the expected keys 143 | 144 | """ 145 | try: 146 | previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS") 147 | except (service_client.exceptions.ResourceNotFoundException, KeyError): 148 | previous_dict = None 149 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 150 | pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) 151 | 152 | # First try to login with the pending secret, if it succeeds, return 153 | conn = get_connection(pending_dict) 154 | if conn: 155 | conn.close() 156 | logger.info("setSecret: AWSPENDING secret is already set as password in MySQL DB for secret arn %s." % arn) 157 | return 158 | 159 | # Make sure the user from current and pending match 160 | if current_dict['username'] != pending_dict['username']: 161 | logger.error("setSecret: Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 162 | raise ValueError("Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 163 | 164 | # Make sure the host from current and pending match 165 | if current_dict['host'] != pending_dict['host']: 166 | logger.error("setSecret: Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 167 | raise ValueError("Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 168 | 169 | # Now try the current password 170 | conn = get_connection(current_dict) 171 | 172 | # If both current and pending do not work, try previous 173 | if not conn and previous_dict: 174 | # Update previous_dict to leverage current SSL settings 175 | previous_dict.pop('ssl', None) 176 | if 'ssl' in current_dict: 177 | previous_dict['ssl'] = current_dict['ssl'] 178 | 179 | conn = get_connection(previous_dict) 180 | 181 | # Make sure the user/host from previous and pending match 182 | if previous_dict['username'] != pending_dict['username']: 183 | logger.error("setSecret: Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 184 | raise ValueError("Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 185 | if previous_dict['host'] != pending_dict['host']: 186 | logger.error("setSecret: Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 187 | raise ValueError("Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 188 | 189 | # If we still don't have a connection, raise a ValueError 190 | if not conn: 191 | logger.error("setSecret: Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 192 | raise ValueError("Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 193 | 194 | # Now set the password to the pending password 195 | try: 196 | with conn.cursor() as cur: 197 | cur.execute("SELECT VERSION()") 198 | ver = cur.fetchone() 199 | password_option = get_password_option(ver[0]) 200 | cur.execute("SET PASSWORD = " + password_option, pending_dict['password']) 201 | conn.commit() 202 | logger.info("setSecret: Successfully set password for user %s in MySQL DB for secret arn %s." % (pending_dict['username'], arn)) 203 | finally: 204 | conn.close() 205 | 206 | 207 | def test_secret(service_client, arn, token): 208 | """Test the pending secret against the database 209 | 210 | This method tries to log into the database with the secrets staged with AWSPENDING and runs 211 | a permissions check to ensure the user has the corrrect permissions. 212 | 213 | Args: 214 | service_client (client): The secrets manager service client 215 | 216 | arn (string): The secret ARN or other identifier 217 | 218 | token (string): The ClientRequestToken associated with the secret version 219 | 220 | Raises: 221 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 222 | 223 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 224 | 225 | KeyError: If the secret json does not contain the expected keys 226 | 227 | """ 228 | # Try to login with the pending secret, if it succeeds, return 229 | conn = get_connection(get_secret_dict(service_client, arn, "AWSPENDING", token)) 230 | if conn: 231 | # This is where the lambda will validate the user's permissions. Uncomment/modify the below lines to 232 | # tailor these validations to your needs 233 | try: 234 | with conn.cursor() as cur: 235 | cur.execute("SELECT NOW()") 236 | conn.commit() 237 | finally: 238 | conn.close() 239 | 240 | logger.info("testSecret: Successfully signed into MySQL DB with AWSPENDING secret in %s." % arn) 241 | return 242 | else: 243 | logger.error("testSecret: Unable to log into database with pending secret of secret ARN %s" % arn) 244 | raise ValueError("Unable to log into database with pending secret of secret ARN %s" % arn) 245 | 246 | 247 | def finish_secret(service_client, arn, token): 248 | """Finish the rotation by marking the pending secret as current 249 | 250 | This method finishes the secret rotation by staging the secret staged AWSPENDING with the AWSCURRENT stage. 251 | 252 | Args: 253 | service_client (client): The secrets manager service client 254 | 255 | arn (string): The secret ARN or other identifier 256 | 257 | token (string): The ClientRequestToken associated with the secret version 258 | 259 | """ 260 | # First describe the secret to get the current version 261 | metadata = service_client.describe_secret(SecretId=arn) 262 | current_version = None 263 | for version in metadata["VersionIdsToStages"]: 264 | if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: 265 | if version == token: 266 | # The correct version is already marked as current, return 267 | logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn)) 268 | return 269 | current_version = version 270 | break 271 | 272 | # Finalize by staging the secret version current 273 | service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version) 274 | logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, arn)) 275 | 276 | 277 | def get_connection(secret_dict): 278 | """Gets a connection to MySQL DB from a secret dictionary 279 | 280 | This helper function uses connectivity information from the secret dictionary to initiate 281 | connection attempt(s) to the database. Will attempt a fallback, non-SSL connection when 282 | initial connection fails using SSL and fall_back is True. 283 | 284 | Args: 285 | secret_dict (dict): The Secret Dictionary 286 | 287 | Returns: 288 | Connection: The pymysql.connections.Connection object if successful. None otherwise 289 | 290 | Raises: 291 | KeyError: If the secret json does not contain the expected keys 292 | 293 | """ 294 | # Parse and validate the secret JSON string 295 | port = int(secret_dict['port']) if 'port' in secret_dict else 3306 296 | dbname = secret_dict['dbname'] if 'dbname' in secret_dict else None 297 | 298 | # Get SSL connectivity configuration 299 | use_ssl, fall_back = get_ssl_config(secret_dict) 300 | 301 | # if an 'ssl' key is not found or does not contain a valid value, attempt an SSL connection and fall back to non-SSL on failure 302 | conn = connect_and_authenticate(secret_dict, port, dbname, use_ssl) 303 | if conn or not fall_back: 304 | return conn 305 | else: 306 | return connect_and_authenticate(secret_dict, port, dbname, False) 307 | 308 | 309 | def get_ssl_config(secret_dict): 310 | """Gets the desired SSL and fall back behavior using a secret dictionary 311 | 312 | This helper function uses the existance and value the 'ssl' key in a secret dictionary 313 | to determine desired SSL connectivity configuration. Its behavior is as follows: 314 | - 'ssl' key DNE or invalid type/value: return True, True 315 | - 'ssl' key is bool: return secret_dict['ssl'], False 316 | - 'ssl' key equals "true" ignoring case: return True, False 317 | - 'ssl' key equals "false" ignoring case: return False, False 318 | 319 | Args: 320 | secret_dict (dict): The Secret Dictionary 321 | 322 | Returns: 323 | Tuple(use_ssl, fall_back): SSL configuration 324 | - use_ssl (bool): Flag indicating if an SSL connection should be attempted 325 | - fall_back (bool): Flag indicating if non-SSL connection should be attempted if SSL connection fails 326 | 327 | """ 328 | # Default to True for SSL and fall_back mode if 'ssl' key DNE 329 | if 'ssl' not in secret_dict: 330 | return True, True 331 | 332 | # Handle type bool 333 | if isinstance(secret_dict['ssl'], bool): 334 | return secret_dict['ssl'], False 335 | 336 | # Handle type string 337 | if isinstance(secret_dict['ssl'], str): 338 | ssl = secret_dict['ssl'].lower() 339 | if ssl == "true": 340 | return True, False 341 | elif ssl == "false": 342 | return False, False 343 | else: 344 | # Invalid string value, default to True for both SSL and fall_back mode 345 | return True, True 346 | 347 | # Invalid type, default to True for both SSL and fall_back mode 348 | return True, True 349 | 350 | 351 | def connect_and_authenticate(secret_dict, port, dbname, use_ssl): 352 | """Attempt to connect and authenticate to a MySQL instance 353 | 354 | This helper function tries to connect to the database using connectivity info passed in. 355 | If successful, it returns the connection, else None 356 | 357 | Args: 358 | - secret_dict (dict): The Secret Dictionary 359 | - port (int): The databse port to connect to 360 | - dbname (str): Name of the database 361 | - use_ssl (bool): Flag indicating whether connection should use SSL/TLS 362 | 363 | Returns: 364 | Connection: The pymongo.database.Database object if successful. None otherwise 365 | 366 | Raises: 367 | KeyError: If the secret json does not contain the expected keys 368 | 369 | """ 370 | ssl = {'ca': '/etc/pki/tls/cert.pem', } if use_ssl else None 371 | 372 | # Try to obtain a connection to the db 373 | try: 374 | # Checks hostname and verifies server certificate implictly when 'ca' key is in 'ssl' dictionary 375 | conn = pymysql.connect(host=secret_dict['host'], user=secret_dict['username'], password=secret_dict['password'], port=port, database=dbname, connect_timeout=5, ssl=ssl) 376 | logger.info("Successfully established %s connection as user '%s' with host: '%s'" % ("SSL/TLS" if use_ssl else "non SSL/TLS", secret_dict['username'], secret_dict['host'])) 377 | return conn 378 | except pymysql.OperationalError as e: 379 | if 'certificate verify failed: IP address mismatch' in e.args[1]: 380 | logger.error("Hostname verification failed when estlablishing SSL/TLS Handshake with host: %s" % secret_dict['host']) 381 | return None 382 | 383 | 384 | def get_secret_dict(service_client, arn, stage, token=None): 385 | """Gets the secret dictionary corresponding for the secret arn, stage, and token 386 | 387 | This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string 388 | 389 | Args: 390 | service_client (client): The secrets manager service client 391 | 392 | arn (string): The secret ARN or other identifier 393 | 394 | token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired 395 | 396 | stage (string): The stage identifying the secret version 397 | 398 | Returns: 399 | SecretDictionary: Secret dictionary 400 | 401 | Raises: 402 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 403 | 404 | ValueError: If the secret is not valid JSON 405 | 406 | """ 407 | required_fields = ['host', 'username', 'password'] 408 | 409 | # Only do VersionId validation against the stage if a token is passed in 410 | if token: 411 | secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage) 412 | else: 413 | secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage) 414 | plaintext = secret['SecretString'] 415 | secret_dict = json.loads(plaintext) 416 | 417 | # Run validations against the secret 418 | supported_engines = ["mysql", "aurora-mysql"] 419 | if 'engine' not in secret_dict or secret_dict['engine'] not in supported_engines: 420 | raise KeyError("Database engine must be set to 'mysql' in order to use this rotation lambda") 421 | for field in required_fields: 422 | if field not in secret_dict: 423 | raise KeyError("%s key is missing from secret JSON" % field) 424 | 425 | # Parse and return the secret JSON string 426 | return secret_dict 427 | 428 | 429 | def get_password_option(version): 430 | """Gets the password option template string to use for the SET PASSWORD sql query 431 | 432 | This helper function takes in the mysql version and returns the appropriate password option template string that can 433 | be used in the SET PASSWORD query for that mysql version. 434 | 435 | Args: 436 | version (string): The mysql database version 437 | 438 | Returns: 439 | PasswordOption: The password option string 440 | 441 | """ 442 | if version.startswith("8"): 443 | return "%s" 444 | else: 445 | return "PASSWORD(%s)" 446 | 447 | 448 | def get_environment_bool(variable_name, default_value): 449 | """Loads the environment variable and converts it to the boolean. 450 | 451 | Args: 452 | variable_name (string): Name of environment variable 453 | 454 | default_value (bool): The result will fallback to the default_value when the environment variable with the given name doesn't exist. 455 | 456 | Returns: 457 | bool: True when the content of environment variable contains either 'true', '1', 'y' or 'yes' 458 | """ 459 | variable = os.environ.get(variable_name, str(default_value)) 460 | return variable.lower() in ['true', '1', 'y', 'yes'] 461 | 462 | 463 | def get_random_password(service_client): 464 | """ Generates a random new password. Generator loads parameters that affects the content of the resulting password from the environment 465 | variables. When environment variable is missing sensible defaults are chosen. 466 | 467 | Supported environment variables: 468 | - EXCLUDE_CHARACTERS 469 | - PASSWORD_LENGTH 470 | - EXCLUDE_NUMBERS 471 | - EXCLUDE_PUNCTUATION 472 | - EXCLUDE_UPPERCASE 473 | - EXCLUDE_LOWERCASE 474 | - REQUIRE_EACH_INCLUDED_TYPE 475 | 476 | Args: 477 | service_client (client): The secrets manager service client 478 | 479 | Returns: 480 | string: The randomly generated password. 481 | """ 482 | passwd = service_client.get_random_password( 483 | ExcludeCharacters=os.environ.get('EXCLUDE_CHARACTERS', '/@"\'\\'), 484 | PasswordLength=int(os.environ.get('PASSWORD_LENGTH', 32)), 485 | ExcludeNumbers=get_environment_bool('EXCLUDE_NUMBERS', False), 486 | ExcludePunctuation=get_environment_bool('EXCLUDE_PUNCTUATION', False), 487 | ExcludeUppercase=get_environment_bool('EXCLUDE_UPPERCASE', False), 488 | ExcludeLowercase=get_environment_bool('EXCLUDE_LOWERCASE', False), 489 | RequireEachIncludedType=get_environment_bool('REQUIRE_EACH_INCLUDED_TYPE', True) 490 | ) 491 | return passwd['RandomPassword'] 492 | -------------------------------------------------------------------------------- /SecretsManagerRDSOracleRotationSingleUser/lambda_function.py: -------------------------------------------------------------------------------- 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 oracledb 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.INFO) 12 | 13 | 14 | def lambda_handler(event, context): 15 | """Secrets Manager RDS Oracle Handler 16 | 17 | This handler uses the single-user rotation scheme to rotate an RDS Oracle user credential. This rotation scheme 18 | logs into the database as the user and rotates the user's own password, immediately invalidating the user's 19 | previous password. 20 | 21 | The Secret SecretString is expected to be a JSON string with the following format: 22 | { 23 | 'engine': , 24 | 'host': , 25 | 'username': , 26 | 'password': , 27 | 'dbname': , 28 | 'port': 29 | } 30 | 31 | Args: 32 | event (dict): Lambda dictionary of event parameters. These keys must include the following: 33 | - SecretId: The secret ARN or identifier 34 | - ClientRequestToken: The ClientRequestToken of the secret version 35 | - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) 36 | 37 | context (LambdaContext): The Lambda runtime information 38 | 39 | Raises: 40 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 41 | 42 | ValueError: If the secret is not properly configured for rotation 43 | 44 | KeyError: If the secret json does not contain the expected keys 45 | 46 | """ 47 | 48 | arn = event['SecretId'] 49 | token = event['ClientRequestToken'] 50 | step = event['Step'] 51 | 52 | # Setup the client 53 | service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT']) 54 | 55 | # Make sure the version is staged correctly 56 | metadata = service_client.describe_secret(SecretId=arn) 57 | if "RotationEnabled" in metadata and not metadata['RotationEnabled']: 58 | logger.error("Secret %s is not enabled for rotation" % arn) 59 | raise ValueError("Secret %s is not enabled for rotation" % arn) 60 | versions = metadata['VersionIdsToStages'] 61 | if token not in versions: 62 | logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 63 | raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 64 | if "AWSCURRENT" in versions[token]: 65 | logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)) 66 | return 67 | elif "AWSPENDING" not in versions[token]: 68 | logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 69 | raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 70 | 71 | # Call the appropriate step 72 | if step == "createSecret": 73 | create_secret(service_client, arn, token) 74 | 75 | elif step == "setSecret": 76 | set_secret(service_client, arn, token) 77 | 78 | elif step == "testSecret": 79 | test_secret(service_client, arn, token) 80 | 81 | elif step == "finishSecret": 82 | finish_secret(service_client, arn, token) 83 | 84 | else: 85 | logger.error("lambda_handler: Invalid step parameter %s for secret %s" % (step, arn)) 86 | raise ValueError("Invalid step parameter %s for secret %s" % (step, arn)) 87 | 88 | 89 | def create_secret(service_client, arn, token): 90 | """Generate a new secret 91 | 92 | This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a 93 | new secret and put it with the passed in token. 94 | 95 | Args: 96 | service_client (client): The secrets manager service client 97 | 98 | arn (string): The secret ARN or other identifier 99 | 100 | token (string): The ClientRequestToken associated with the secret version 101 | 102 | Raises: 103 | ValueError: If the current secret is not valid JSON 104 | 105 | KeyError: If the secret json does not contain the expected keys 106 | 107 | """ 108 | # Make sure the current secret exists 109 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 110 | 111 | # Now try to get the secret version, if that fails, put a new secret 112 | try: 113 | get_secret_dict(service_client, arn, "AWSPENDING", token) 114 | logger.info("createSecret: Successfully retrieved secret for %s." % arn) 115 | except service_client.exceptions.ResourceNotFoundException: 116 | # Generate a random password 117 | current_dict['password'] = get_random_password(service_client) 118 | 119 | # Put the secret 120 | service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=json.dumps(current_dict), VersionStages=['AWSPENDING']) 121 | logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token)) 122 | 123 | 124 | def set_secret(service_client, arn, token): 125 | """Set the pending secret in the database 126 | 127 | This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it 128 | tries to login with the AWSCURRENT and AWSPREVIOUS secrets. If either one succeeds, it sets the AWSPENDING password 129 | as the user password in the database. Else, it throws a ValueError. 130 | 131 | Args: 132 | service_client (client): The secrets manager service client 133 | 134 | arn (string): The secret ARN or other identifier 135 | 136 | token (string): The ClientRequestToken associated with the secret version 137 | 138 | Raises: 139 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 140 | 141 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 142 | 143 | KeyError: If the secret json does not contain the expected keys 144 | 145 | """ 146 | try: 147 | previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS") 148 | except (service_client.exceptions.ResourceNotFoundException, KeyError): 149 | previous_dict = None 150 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 151 | pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) 152 | 153 | # First try to login with the pending secret, if it succeeds, return 154 | conn = get_connection(pending_dict) 155 | if conn: 156 | conn.close() 157 | logger.info("setSecret: AWSPENDING secret is already set as password in Oracle DB for secret arn %s." % arn) 158 | return 159 | 160 | # Make sure the user from current and pending match 161 | if current_dict['username'] != pending_dict['username']: 162 | logger.error("setSecret: Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 163 | raise ValueError("Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 164 | 165 | # Make sure the host from current and pending match 166 | if current_dict['host'] != pending_dict['host']: 167 | logger.error("setSecret: Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 168 | raise ValueError("Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 169 | 170 | # Now try the current password 171 | conn = get_connection(current_dict) 172 | if not conn and previous_dict: 173 | # If both current and pending do not work, try previous 174 | conn = get_connection(previous_dict) 175 | 176 | # Make sure the user/host from previous and pending match 177 | if previous_dict['username'] != pending_dict['username']: 178 | logger.error("setSecret: Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 179 | raise ValueError("Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 180 | if previous_dict['host'] != pending_dict['host']: 181 | logger.error("setSecret: Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 182 | raise ValueError("Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 183 | 184 | # If we still don't have a connection, raise a ValueError 185 | if not conn: 186 | logger.error("setSecret: Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 187 | raise ValueError("Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 188 | 189 | cur = conn.cursor() 190 | 191 | # Escape username via DBMS ENQUOTE_NAME 192 | cur.execute("SELECT sys.DBMS_ASSERT.enquote_name(:username) FROM DUAL", username=pending_dict['username']) 193 | escaped_username = cur.fetchone()[0] 194 | 195 | # Passwords cannot have double quotes in Oracle, remove any double quotes to allow the password to be properly escaped 196 | pending_password = pending_dict['password'].replace("\"", "") 197 | 198 | # Now set the password to the pending password 199 | sql = "ALTER USER %s IDENTIFIED BY \"%s\"" % (escaped_username, pending_dict['password']) 200 | cur.execute(sql) 201 | conn.commit() 202 | logger.info("setSecret: Successfully set password for user %s in Oracle DB for secret arn %s." % (pending_dict['username'], arn)) 203 | 204 | 205 | def test_secret(service_client, arn, token): 206 | """Test the pending secret against the database 207 | 208 | This method tries to log into the database with the secrets staged with AWSPENDING and runs 209 | a permissions check to ensure the user has the corrrect permissions. 210 | 211 | Args: 212 | service_client (client): The secrets manager service client 213 | 214 | arn (string): The secret ARN or other identifier 215 | 216 | token (string): The ClientRequestToken associated with the secret version 217 | 218 | Raises: 219 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 220 | 221 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 222 | 223 | KeyError: If the secret json does not contain the expected keys 224 | 225 | """ 226 | # Try to login with the pending secret, if it succeeds, return 227 | conn = get_connection(get_secret_dict(service_client, arn, "AWSPENDING", token)) 228 | if conn: 229 | # This is where the lambda will validate the user's permissions. Uncomment/modify the below lines to 230 | # tailor these validations to your needs 231 | cur = conn.cursor() 232 | cur.execute("SELECT SYSDATE FROM DUAL") 233 | conn.commit() 234 | 235 | logger.info("testSecret: Successfully signed into Oracle DB with AWSPENDING secret in %s." % arn) 236 | return 237 | else: 238 | logger.error("testSecret: Unable to log into database with pending secret of secret ARN %s" % arn) 239 | raise ValueError("Unable to log into database with pending secret of secret ARN %s" % arn) 240 | 241 | 242 | def finish_secret(service_client, arn, token): 243 | """Finish the rotation by marking the pending secret as current 244 | 245 | This method finishes the secret rotation by staging the secret staged AWSPENDING with the AWSCURRENT stage. 246 | 247 | Args: 248 | service_client (client): The secrets manager service client 249 | 250 | arn (string): The secret ARN or other identifier 251 | 252 | token (string): The ClientRequestToken associated with the secret version 253 | 254 | """ 255 | # First describe the secret to get the current version 256 | metadata = service_client.describe_secret(SecretId=arn) 257 | current_version = None 258 | for version in metadata["VersionIdsToStages"]: 259 | if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: 260 | if version == token: 261 | # The correct version is already marked as current, return 262 | logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn)) 263 | return 264 | current_version = version 265 | break 266 | 267 | # Finalize by staging the secret version current 268 | service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version) 269 | logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, arn)) 270 | 271 | 272 | def get_connection(secret_dict): 273 | """Gets a connection to Oracle DB from a secret dictionary 274 | 275 | This helper function tries to connect to the database grabbing connection info 276 | from the secret dictionary. If successful, it returns the connection, else None 277 | 278 | Args: 279 | secret_dict (dict): The Secret Dictionary 280 | 281 | Returns: 282 | Connection: The oracledb.Connection object if successful. None otherwise 283 | 284 | Raises: 285 | KeyError: If the secret json does not contain the expected keys 286 | 287 | """ 288 | # Parse and validate the secret JSON string 289 | port = str(secret_dict['port']) if 'port' in secret_dict else '1521' 290 | use_ssl, fall_back = get_ssl_config(secret_dict) 291 | conn = connect_and_authenticate(secret_dict, port, use_ssl) 292 | 293 | if conn or not fall_back: 294 | return conn 295 | else: 296 | return connect_and_authenticate(secret_dict, port, False) 297 | 298 | 299 | def connect_and_authenticate(secret_dict, port, use_ssl): 300 | """Connects to Oracle DB and authenticates 301 | 302 | This helper function tries to connect to the database using the supplied 303 | connection parameters and authenticates. If successful, it returns the 304 | connection, else None 305 | 306 | Args: 307 | secret_dict (dict): The Secret Dictionary 308 | 309 | port (str): The database connection port 310 | 311 | use_ssl (bool): Flag indicating whether to use SSL/TLS encryption 312 | 313 | Returns: 314 | Connection: The oracledb.Connection object if successful. None otherwise 315 | 316 | Raises: 317 | KeyError: If the secret json does not contain the expected keys 318 | 319 | """ 320 | try: 321 | if use_ssl: 322 | oracle_dsn = '''(description= (address=(protocol=tcps) 323 | (port=''' + port + ''')(host=''' + secret_dict['host'] + '''))(connect_data=(SID=''' + secret_dict['dbname'] + ''')))''' 324 | conn = oracledb.connect(user=secret_dict['username'], 325 | password=secret_dict['password'], 326 | dsn=oracle_dsn) 327 | elif not use_ssl: 328 | conn = oracledb.connect(user=secret_dict['username'], 329 | password=secret_dict['password'], 330 | dsn=secret_dict['host'] + ':' + port + '/' + secret_dict['dbname']) 331 | logger.info("Successfully established connection as user '%s' with host: '%s'" % (secret_dict['username'], secret_dict['host'])) 332 | 333 | return conn 334 | except (oracledb.DatabaseError, oracledb.OperationalError): 335 | return None 336 | 337 | 338 | def get_ssl_config(secret_dict): 339 | """Gets the desired SSL and fall back behavior using a secret dictionary 340 | 341 | This helper function uses the existance and value the 'ssl' key in a secret dictionary 342 | to determine desired SSL connectivity configuration. Its behavior is as follows: 343 | - 'ssl' key DNE or invalid type/value: return True, True 344 | - 'ssl' key is bool: return secret_dict['ssl'], False 345 | - 'ssl' key equals "true" ignoring case: return True, False 346 | - 'ssl' key equals "false" ignoring case: return False, False 347 | 348 | Args: 349 | secret_dict (dict): The Secret Dictionary 350 | 351 | Returns: 352 | Tuple(use_ssl, fall_back): SSL configuration 353 | - use_ssl (bool): Flag indicating if an SSL connection should be attempted 354 | - fall_back (bool): Flag indicating if non-SSL connection should be attempted if SSL connection fails 355 | 356 | """ 357 | # Default to True for SSL and fall_back mode if 'ssl' key DNE 358 | if 'ssl' not in secret_dict: 359 | return True, True 360 | 361 | # Handle type bool 362 | if isinstance(secret_dict['ssl'], bool): 363 | return secret_dict['ssl'], False 364 | 365 | # Handle type string 366 | if isinstance(secret_dict['ssl'], str): 367 | ssl = secret_dict['ssl'].lower() 368 | if ssl == "true": 369 | return True, False 370 | elif ssl == "false": 371 | return False, False 372 | else: 373 | # Invalid string value, default to True for both SSL and fall_back mode 374 | return True, True 375 | 376 | # Invalid type, default to True for both SSL and fall_back mode 377 | return True, True 378 | 379 | 380 | def get_secret_dict(service_client, arn, stage, token=None): 381 | """Gets the secret dictionary corresponding for the secret arn, stage, and token 382 | 383 | This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string 384 | 385 | Args: 386 | service_client (client): The secrets manager service client 387 | 388 | arn (string): The secret ARN or other identifier 389 | 390 | token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired 391 | 392 | stage (string): The stage identifying the secret version 393 | 394 | Returns: 395 | SecretDictionary: Secret dictionary 396 | 397 | Raises: 398 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 399 | 400 | ValueError: If the secret is not valid JSON 401 | 402 | """ 403 | required_fields = ['host', 'username', 'password', 'dbname'] 404 | 405 | # Only do VersionId validation against the stage if a token is passed in 406 | if token: 407 | secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage) 408 | else: 409 | secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage) 410 | plaintext = secret['SecretString'] 411 | secret_dict = json.loads(plaintext) 412 | 413 | # Run validations against the secret 414 | if 'engine' not in secret_dict or secret_dict['engine'] != 'oracle': 415 | raise KeyError("Database engine must be set to 'oracle' in order to use this rotation lambda") 416 | for field in required_fields: 417 | if field not in secret_dict: 418 | raise KeyError("%s key is missing from secret JSON" % field) 419 | 420 | # Parse and return the secret JSON string 421 | return secret_dict 422 | 423 | 424 | def get_environment_bool(variable_name, default_value): 425 | """Loads the environment variable and converts it to the boolean. 426 | 427 | Args: 428 | variable_name (string): Name of environment variable 429 | 430 | default_value (bool): The result will fallback to the default_value when the environment variable with the given name doesn't exist. 431 | 432 | Returns: 433 | bool: True when the content of environment variable contains either 'true', '1', 'y' or 'yes' 434 | """ 435 | variable = os.environ.get(variable_name, str(default_value)) 436 | return variable.lower() in ['true', '1', 'y', 'yes'] 437 | 438 | 439 | def get_random_password(service_client): 440 | """ Generates a random new password. Generator loads parameters that affects the content of the resulting password from the environment 441 | variables. When environment variable is missing sensible defaults are chosen. 442 | 443 | Supported environment variables: 444 | - EXCLUDE_CHARACTERS 445 | - PASSWORD_LENGTH 446 | - EXCLUDE_NUMBERS 447 | - EXCLUDE_PUNCTUATION 448 | - EXCLUDE_UPPERCASE 449 | - EXCLUDE_LOWERCASE 450 | - REQUIRE_EACH_INCLUDED_TYPE 451 | 452 | Args: 453 | service_client (client): The secrets manager service client 454 | 455 | Returns: 456 | string: The randomly generated password. 457 | """ 458 | passwd = service_client.get_random_password( 459 | ExcludeCharacters=os.environ.get('EXCLUDE_CHARACTERS', '/@"\'\\'), 460 | PasswordLength=int(os.environ.get('PASSWORD_LENGTH', 30)), 461 | ExcludeNumbers=get_environment_bool('EXCLUDE_NUMBERS', False), 462 | ExcludePunctuation=get_environment_bool('EXCLUDE_PUNCTUATION', False), 463 | ExcludeUppercase=get_environment_bool('EXCLUDE_UPPERCASE', False), 464 | ExcludeLowercase=get_environment_bool('EXCLUDE_LOWERCASE', False), 465 | RequireEachIncludedType=get_environment_bool('REQUIRE_EACH_INCLUDED_TYPE', True) 466 | ) 467 | return passwd['RandomPassword'] 468 | -------------------------------------------------------------------------------- /SecretsManagerRDSPostgreSQLRotationSingleUser/lambda_function.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import re 5 | import boto3 6 | import json 7 | import logging 8 | import os 9 | import pg 10 | import pgdb 11 | 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.INFO) 14 | 15 | 16 | def lambda_handler(event, context): 17 | """Secrets Manager RDS PostgreSQL Handler 18 | 19 | This handler uses the single-user rotation scheme to rotate an RDS PostgreSQL user credential. This rotation 20 | scheme logs into the database as the user and rotates the user's own password, immediately invalidating the 21 | user's previous password. 22 | 23 | The Secret SecretString is expected to be a JSON string with the following format: 24 | { 25 | 'engine': , 26 | 'host': , 27 | 'username': , 28 | 'password': , 29 | 'dbname': , 30 | 'port': 31 | } 32 | 33 | Args: 34 | event (dict): Lambda dictionary of event parameters. These keys must include the following: 35 | - SecretId: The secret ARN or identifier 36 | - ClientRequestToken: The ClientRequestToken of the secret version 37 | - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) 38 | 39 | context (LambdaContext): The Lambda runtime information 40 | 41 | Raises: 42 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 43 | 44 | ValueError: If the secret is not properly configured for rotation 45 | 46 | KeyError: If the secret json does not contain the expected keys 47 | 48 | """ 49 | arn = event['SecretId'] 50 | token = event['ClientRequestToken'] 51 | step = event['Step'] 52 | 53 | # Setup the client 54 | service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT']) 55 | 56 | # Make sure the version is staged correctly 57 | metadata = service_client.describe_secret(SecretId=arn) 58 | if "RotationEnabled" in metadata and not metadata['RotationEnabled']: 59 | logger.error("Secret %s is not enabled for rotation" % arn) 60 | raise ValueError("Secret %s is not enabled for rotation" % arn) 61 | versions = metadata['VersionIdsToStages'] 62 | if token not in versions: 63 | logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 64 | raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 65 | if "AWSCURRENT" in versions[token]: 66 | logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)) 67 | return 68 | elif "AWSPENDING" not in versions[token]: 69 | logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 70 | raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 71 | 72 | # Call the appropriate step 73 | if step == "createSecret": 74 | create_secret(service_client, arn, token) 75 | 76 | elif step == "setSecret": 77 | set_secret(service_client, arn, token) 78 | 79 | elif step == "testSecret": 80 | test_secret(service_client, arn, token) 81 | 82 | elif step == "finishSecret": 83 | finish_secret(service_client, arn, token) 84 | 85 | else: 86 | logger.error("lambda_handler: Invalid step parameter %s for secret %s" % (step, arn)) 87 | raise ValueError("Invalid step parameter %s for secret %s" % (step, arn)) 88 | 89 | 90 | def create_secret(service_client, arn, token): 91 | """Generate a new secret 92 | 93 | This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a 94 | new secret and put it with the passed in token. 95 | 96 | Args: 97 | service_client (client): The secrets manager service client 98 | 99 | arn (string): The secret ARN or other identifier 100 | 101 | token (string): The ClientRequestToken associated with the secret version 102 | 103 | Raises: 104 | ValueError: If the current secret is not valid JSON 105 | 106 | KeyError: If the secret json does not contain the expected keys 107 | 108 | """ 109 | # Make sure the current secret exists 110 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 111 | 112 | # Now try to get the secret version, if that fails, put a new secret 113 | try: 114 | get_secret_dict(service_client, arn, "AWSPENDING", token) 115 | logger.info("createSecret: Successfully retrieved secret for %s." % arn) 116 | except service_client.exceptions.ResourceNotFoundException: 117 | # Generate a random password 118 | current_dict['password'] = get_random_password(service_client) 119 | # Put the secret 120 | service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=json.dumps(current_dict), VersionStages=['AWSPENDING']) 121 | logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token)) 122 | 123 | 124 | def set_secret(service_client, arn, token): 125 | """Set the pending secret in the database 126 | 127 | This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it 128 | tries to login with the AWSCURRENT and AWSPREVIOUS secrets. If either one succeeds, it sets the AWSPENDING password 129 | as the user password in the database. Else, it throws a ValueError. 130 | 131 | Args: 132 | service_client (client): The secrets manager service client 133 | 134 | arn (string): The secret ARN or other identifier 135 | 136 | token (string): The ClientRequestToken associated with the secret version 137 | 138 | Raises: 139 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 140 | 141 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 142 | 143 | KeyError: If the secret json does not contain the expected keys 144 | 145 | """ 146 | try: 147 | previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS") 148 | except (service_client.exceptions.ResourceNotFoundException, KeyError): 149 | previous_dict = None 150 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 151 | pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) 152 | 153 | # First try to login with the pending secret, if it succeeds, return 154 | conn = get_connection(pending_dict) 155 | if conn: 156 | conn.close() 157 | logger.info("setSecret: AWSPENDING secret is already set as password in PostgreSQL DB for secret arn %s." % arn) 158 | return 159 | 160 | # Make sure the user from current and pending match 161 | if current_dict['username'] != pending_dict['username']: 162 | logger.error("setSecret: Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 163 | raise ValueError("Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 164 | 165 | # Make sure the host from current and pending match 166 | if current_dict['host'] != pending_dict['host']: 167 | logger.error("setSecret: Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 168 | raise ValueError("Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 169 | 170 | # Now try the current password 171 | conn = get_connection(current_dict) 172 | 173 | # If both current and pending do not work, try previous 174 | if not conn and previous_dict: 175 | # Update previous_dict to leverage current SSL settings 176 | previous_dict.pop('ssl', None) 177 | if 'ssl' in current_dict: 178 | previous_dict['ssl'] = current_dict['ssl'] 179 | 180 | conn = get_connection(previous_dict) 181 | 182 | # Make sure the user/host from previous and pending match 183 | if previous_dict['username'] != pending_dict['username']: 184 | logger.error("setSecret: Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 185 | raise ValueError("Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 186 | if previous_dict['host'] != pending_dict['host']: 187 | logger.error("setSecret: Attempting to modify user for host %s other than previous valid host %s" % (pending_dict['host'], previous_dict['host'])) 188 | raise ValueError("Attempting to modify user for host %s other than current previous valid %s" % (pending_dict['host'], previous_dict['host'])) 189 | 190 | # If we still don't have a connection, raise a ValueError 191 | if not conn: 192 | logger.error("setSecret: Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 193 | raise ValueError("Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 194 | 195 | # Now set the password to the pending password 196 | try: 197 | with conn.cursor() as cur: 198 | # Get escaped username via quote_ident 199 | cur.execute("SELECT quote_ident(%s)", (pending_dict['username'],)) 200 | escaped_username = cur.fetchone()[0] 201 | 202 | alter_role = "ALTER USER %s" % escaped_username 203 | cur.execute(alter_role + " WITH PASSWORD %s", (pending_dict['password'],)) 204 | conn.commit() 205 | logger.info("setSecret: Successfully set password for user %s in PostgreSQL DB for secret arn %s." % (pending_dict['username'], arn)) 206 | finally: 207 | conn.close() 208 | 209 | 210 | def test_secret(service_client, arn, token): 211 | """Test the pending secret against the database 212 | 213 | This method tries to log into the database with the secrets staged with AWSPENDING and runs 214 | a permissions check to ensure the user has the corrrect permissions. 215 | 216 | Args: 217 | service_client (client): The secrets manager service client 218 | 219 | arn (string): The secret ARN or other identifier 220 | 221 | token (string): The ClientRequestToken associated with the secret version 222 | 223 | Raises: 224 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 225 | 226 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 227 | 228 | KeyError: If the secret json does not contain the expected keys 229 | 230 | """ 231 | # Try to login with the pending secret, if it succeeds, return 232 | conn = get_connection(get_secret_dict(service_client, arn, "AWSPENDING", token)) 233 | if conn: 234 | # This is where the lambda will validate the user's permissions. Uncomment/modify the below lines to 235 | # tailor these validations to your needs 236 | try: 237 | with conn.cursor() as cur: 238 | cur.execute("SELECT NOW()") 239 | conn.commit() 240 | finally: 241 | conn.close() 242 | 243 | logger.info("testSecret: Successfully signed into PostgreSQL DB with AWSPENDING secret in %s." % arn) 244 | return 245 | else: 246 | logger.error("testSecret: Unable to log into database with pending secret of secret ARN %s" % arn) 247 | raise ValueError("Unable to log into database with pending secret of secret ARN %s" % arn) 248 | 249 | 250 | def finish_secret(service_client, arn, token): 251 | """Finish the rotation by marking the pending secret as current 252 | 253 | This method finishes the secret rotation by staging the secret staged AWSPENDING with the AWSCURRENT stage. 254 | 255 | Args: 256 | service_client (client): The secrets manager service client 257 | 258 | arn (string): The secret ARN or other identifier 259 | 260 | token (string): The ClientRequestToken associated with the secret version 261 | 262 | """ 263 | # First describe the secret to get the current version 264 | metadata = service_client.describe_secret(SecretId=arn) 265 | current_version = None 266 | for version in metadata["VersionIdsToStages"]: 267 | if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: 268 | if version == token: 269 | # The correct version is already marked as current, return 270 | logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn)) 271 | return 272 | current_version = version 273 | break 274 | 275 | # Finalize by staging the secret version current 276 | service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version) 277 | logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, arn)) 278 | 279 | 280 | def get_connection(secret_dict): 281 | """Gets a connection to PostgreSQL DB from a secret dictionary 282 | 283 | This helper function uses connectivity information from the secret dictionary to initiate 284 | connection attempt(s) to the database. Will attempt a fallback, non-SSL connection when 285 | initial connection fails using SSL and fall_back is True. 286 | 287 | Args: 288 | secret_dict (dict): The Secret Dictionary 289 | 290 | Returns: 291 | Connection: The pgdb.Connection object if successful. None otherwise 292 | 293 | Raises: 294 | KeyError: If the secret json does not contain the expected keys 295 | 296 | """ 297 | # Parse and validate the secret JSON string 298 | port = int(secret_dict['port']) if 'port' in secret_dict else 5432 299 | dbname = secret_dict['dbname'] if 'dbname' in secret_dict else "postgres" 300 | 301 | # Get SSL connectivity configuration 302 | use_ssl, fall_back = get_ssl_config(secret_dict) 303 | 304 | # if an 'ssl' key is not found or does not contain a valid value, attempt an SSL connection and fall back to non-SSL on failure 305 | conn = connect_and_authenticate(secret_dict, port, dbname, use_ssl) 306 | if conn or not fall_back: 307 | return conn 308 | else: 309 | return connect_and_authenticate(secret_dict, port, dbname, False) 310 | 311 | 312 | def get_ssl_config(secret_dict): 313 | """Gets the desired SSL and fall back behavior using a secret dictionary 314 | 315 | This helper function uses the existance and value the 'ssl' key in a secret dictionary 316 | to determine desired SSL connectivity configuration. Its behavior is as follows: 317 | - 'ssl' key DNE or invalid type/value: return True, True 318 | - 'ssl' key is bool: return secret_dict['ssl'], False 319 | - 'ssl' key equals "true" ignoring case: return True, False 320 | - 'ssl' key equals "false" ignoring case: return False, False 321 | 322 | Args: 323 | secret_dict (dict): The Secret Dictionary 324 | 325 | Returns: 326 | Tuple(use_ssl, fall_back): SSL configuration 327 | - use_ssl (bool): Flag indicating if an SSL connection should be attempted 328 | - fall_back (bool): Flag indicating if non-SSL connection should be attempted if SSL connection fails 329 | 330 | """ 331 | # Default to True for SSL and fall_back mode if 'ssl' key DNE 332 | if 'ssl' not in secret_dict: 333 | return True, True 334 | 335 | # Handle type bool 336 | if isinstance(secret_dict['ssl'], bool): 337 | return secret_dict['ssl'], False 338 | 339 | # Handle type string 340 | if isinstance(secret_dict['ssl'], str): 341 | ssl = secret_dict['ssl'].lower() 342 | if ssl == "true": 343 | return True, False 344 | elif ssl == "false": 345 | return False, False 346 | else: 347 | # Invalid string value, default to True for both SSL and fall_back mode 348 | return True, True 349 | 350 | # Invalid type, default to True for both SSL and fall_back mode 351 | return True, True 352 | 353 | 354 | def connect_and_authenticate(secret_dict, port, dbname, use_ssl): 355 | """Attempt to connect and authenticate to a PostgreSQL instance 356 | 357 | This helper function tries to connect to the database using connectivity info passed in. 358 | If successful, it returns the connection, else None 359 | 360 | Args: 361 | - secret_dict (dict): The Secret Dictionary 362 | - port (int): The databse port to connect to 363 | - dbname (str): Name of the database 364 | - use_ssl (bool): Flag indicating whether connection should use SSL/TLS 365 | 366 | Returns: 367 | Connection: The pymongo.database.Database object if successful. None otherwise 368 | 369 | Raises: 370 | KeyError: If the secret json does not contain the expected keys 371 | 372 | """ 373 | # Try to obtain a connection to the db 374 | try: 375 | if use_ssl: 376 | # Setting sslmode='verify-full' will verify the server's certificate and check the server's host name 377 | conn = pgdb.connect(host=secret_dict['host'], user=secret_dict['username'], password=secret_dict['password'], database=dbname, port=port, 378 | connect_timeout=5, sslrootcert='/etc/pki/tls/cert.pem', sslmode='verify-full') 379 | else: 380 | conn = pgdb.connect(host=secret_dict['host'], user=secret_dict['username'], password=secret_dict['password'], database=dbname, port=port, 381 | connect_timeout=5, sslmode='disable') 382 | logger.info("Successfully established %s connection as user '%s' with host: '%s'" % ("SSL/TLS" if use_ssl else "non SSL/TLS", secret_dict['username'], secret_dict['host'])) 383 | return conn 384 | except pg.InternalError as e: 385 | if "server does not support SSL, but SSL was required" in e.args[0]: 386 | logger.error("Unable to establish SSL/TLS handshake, SSL/TLS is not enabled on the host: %s" % secret_dict['host']) 387 | elif re.search('server common name ".+" does not match host name ".+"', e.args[0]): 388 | logger.error("Hostname verification failed when estlablishing SSL/TLS Handshake with host: %s" % secret_dict['host']) 389 | elif re.search('no pg_hba.conf entry for host ".+", SSL off', e.args[0]): 390 | logger.error("Unable to establish SSL/TLS handshake, SSL/TLS is enforced on the host: %s" % secret_dict['host']) 391 | return None 392 | 393 | 394 | def get_secret_dict(service_client, arn, stage, token=None): 395 | """Gets the secret dictionary corresponding for the secret arn, stage, and token 396 | 397 | This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string 398 | 399 | Args: 400 | service_client (client): The secrets manager service client 401 | 402 | arn (string): The secret ARN or other identifier 403 | 404 | token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired 405 | 406 | stage (string): The stage identifying the secret version 407 | 408 | Returns: 409 | SecretDictionary: Secret dictionary 410 | 411 | Raises: 412 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 413 | 414 | ValueError: If the secret is not valid JSON 415 | 416 | """ 417 | required_fields = ['host', 'username', 'password'] 418 | 419 | # Only do VersionId validation against the stage if a token is passed in 420 | if token: 421 | secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage) 422 | else: 423 | secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage) 424 | plaintext = secret['SecretString'] 425 | secret_dict = json.loads(plaintext) 426 | 427 | # Run validations against the secret 428 | supported_engines = ["postgres", "aurora-postgresql"] 429 | if 'engine' not in secret_dict or secret_dict['engine'] not in supported_engines: 430 | raise KeyError("Database engine must be set to 'postgres' in order to use this rotation lambda") 431 | for field in required_fields: 432 | if field not in secret_dict: 433 | raise KeyError("%s key is missing from secret JSON" % field) 434 | 435 | # Parse and return the secret JSON string 436 | return secret_dict 437 | 438 | 439 | def get_environment_bool(variable_name, default_value): 440 | """Loads the environment variable and converts it to the boolean. 441 | 442 | Args: 443 | variable_name (string): Name of environment variable 444 | 445 | default_value (bool): The result will fallback to the default_value when the environment variable with the given name doesn't exist. 446 | 447 | Returns: 448 | bool: True when the content of environment variable contains either 'true', '1', 'y' or 'yes' 449 | """ 450 | variable = os.environ.get(variable_name, str(default_value)) 451 | return variable.lower() in ['true', '1', 'y', 'yes'] 452 | 453 | 454 | def get_random_password(service_client): 455 | """ Generates a random new password. Generator loads parameters that affects the content of the resulting password from the environment 456 | variables. When environment variable is missing sensible defaults are chosen. 457 | 458 | Supported environment variables: 459 | - EXCLUDE_CHARACTERS 460 | - PASSWORD_LENGTH 461 | - EXCLUDE_NUMBERS 462 | - EXCLUDE_PUNCTUATION 463 | - EXCLUDE_UPPERCASE 464 | - EXCLUDE_LOWERCASE 465 | - REQUIRE_EACH_INCLUDED_TYPE 466 | 467 | Args: 468 | service_client (client): The secrets manager service client 469 | 470 | Returns: 471 | string: The randomly generated password. 472 | """ 473 | passwd = service_client.get_random_password( 474 | ExcludeCharacters=os.environ.get('EXCLUDE_CHARACTERS', ':/@"\'\\'), 475 | PasswordLength=int(os.environ.get('PASSWORD_LENGTH', 32)), 476 | ExcludeNumbers=get_environment_bool('EXCLUDE_NUMBERS', False), 477 | ExcludePunctuation=get_environment_bool('EXCLUDE_PUNCTUATION', False), 478 | ExcludeUppercase=get_environment_bool('EXCLUDE_UPPERCASE', False), 479 | ExcludeLowercase=get_environment_bool('EXCLUDE_LOWERCASE', False), 480 | RequireEachIncludedType=get_environment_bool('REQUIRE_EACH_INCLUDED_TYPE', True) 481 | ) 482 | return passwd['RandomPassword'] 483 | -------------------------------------------------------------------------------- /SecretsManagerRedshiftRotationSingleUser/lambda_function.py: -------------------------------------------------------------------------------- 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 pg 9 | import pgdb 10 | 11 | logger = logging.getLogger() 12 | logger.setLevel(logging.INFO) 13 | 14 | 15 | def lambda_handler(event, context): 16 | """Secrets Manager Redshift Handler 17 | 18 | This handler uses the single-user rotation scheme to rotate a Redshift user credential. This rotation 19 | scheme logs into the database as the user and rotates the user's own password, immediately invalidating the 20 | user's previous password. 21 | 22 | The Secret SecretString is expected to be a JSON string with the following format: 23 | { 24 | 'engine': , 25 | 'host': , 26 | 'username': , 27 | 'password': , 28 | 'dbname': , 29 | 'port': 30 | } 31 | 32 | Args: 33 | event (dict): Lambda dictionary of event parameters. These keys must include the following: 34 | - SecretId: The secret ARN or identifier 35 | - ClientRequestToken: The ClientRequestToken of the secret version 36 | - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) 37 | 38 | context (LambdaContext): The Lambda runtime information 39 | 40 | Raises: 41 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 42 | 43 | ValueError: If the secret is not properly configured for rotation 44 | 45 | KeyError: If the secret json does not contain the expected keys 46 | 47 | """ 48 | arn = event['SecretId'] 49 | token = event['ClientRequestToken'] 50 | step = event['Step'] 51 | 52 | # Setup the client 53 | service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT']) 54 | 55 | # Make sure the version is staged correctly 56 | metadata = service_client.describe_secret(SecretId=arn) 57 | if "RotationEnabled" in metadata and not metadata['RotationEnabled']: 58 | logger.error("Secret %s is not enabled for rotation" % arn) 59 | raise ValueError("Secret %s is not enabled for rotation" % arn) 60 | versions = metadata['VersionIdsToStages'] 61 | if token not in versions: 62 | logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 63 | raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 64 | if "AWSCURRENT" in versions[token]: 65 | logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)) 66 | return 67 | elif "AWSPENDING" not in versions[token]: 68 | logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 69 | raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 70 | 71 | # Call the appropriate step 72 | if step == "createSecret": 73 | create_secret(service_client, arn, token) 74 | 75 | elif step == "setSecret": 76 | set_secret(service_client, arn, token) 77 | 78 | elif step == "testSecret": 79 | test_secret(service_client, arn, token) 80 | 81 | elif step == "finishSecret": 82 | finish_secret(service_client, arn, token) 83 | 84 | else: 85 | logger.error("lambda_handler: Invalid step parameter %s for secret %s" % (step, arn)) 86 | raise ValueError("Invalid step parameter %s for secret %s" % (step, arn)) 87 | 88 | 89 | def create_secret(service_client, arn, token): 90 | """Generate a new secret 91 | 92 | This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a 93 | new secret and put it with the passed in token. 94 | 95 | Args: 96 | service_client (client): The secrets manager service client 97 | 98 | arn (string): The secret ARN or other identifier 99 | 100 | token (string): The ClientRequestToken associated with the secret version 101 | 102 | Raises: 103 | ValueError: If the current secret is not valid JSON 104 | 105 | KeyError: If the secret json does not contain the expected keys 106 | 107 | """ 108 | # Make sure the current secret exists 109 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 110 | 111 | # Now try to get the secret version, if that fails, put a new secret 112 | try: 113 | get_secret_dict(service_client, arn, "AWSPENDING", token) 114 | logger.info("createSecret: Successfully retrieved secret for %s." % arn) 115 | except service_client.exceptions.ResourceNotFoundException: 116 | # Generate a random password 117 | current_dict['password'] = get_random_password(service_client) 118 | 119 | # Put the secret 120 | service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=json.dumps(current_dict), VersionStages=['AWSPENDING']) 121 | logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token)) 122 | 123 | 124 | def set_secret(service_client, arn, token): 125 | """Set the pending secret in the database 126 | 127 | This method tries to login to the database with the AWSPENDING secret and returns on success. If that fails, it 128 | tries to login with the AWSCURRENT and AWSPREVIOUS secrets. If either one succeeds, it sets the AWSPENDING password 129 | as the user password in the database. Else, it throws a ValueError. 130 | 131 | Args: 132 | service_client (client): The secrets manager service client 133 | 134 | arn (string): The secret ARN or other identifier 135 | 136 | token (string): The ClientRequestToken associated with the secret version 137 | 138 | Raises: 139 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 140 | 141 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 142 | 143 | KeyError: If the secret json does not contain the expected keys 144 | 145 | """ 146 | try: 147 | previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS") 148 | except (service_client.exceptions.ResourceNotFoundException, KeyError): 149 | previous_dict = None 150 | current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") 151 | pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) 152 | 153 | # First try to login with the pending secret, if it succeeds, return 154 | conn = get_connection(pending_dict) 155 | if conn: 156 | conn.close() 157 | logger.info("setSecret: AWSPENDING secret is already set as password in Redshift DB for secret arn %s." % arn) 158 | return 159 | 160 | # Make sure the user from current and pending match 161 | if current_dict['username'] != pending_dict['username']: 162 | logger.error("setSecret: Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 163 | raise ValueError("Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) 164 | 165 | # Make sure the host from current and pending match 166 | if current_dict['host'] != pending_dict['host']: 167 | logger.error("setSecret: Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 168 | raise ValueError("Attempting to modify user for host %s other than current host %s" % (pending_dict['host'], current_dict['host'])) 169 | 170 | # Now try the current password 171 | conn = get_connection(current_dict) 172 | if not conn and previous_dict: 173 | # If both current and pending do not work, try previous 174 | conn = get_connection(previous_dict) 175 | 176 | # Make sure the user/host from current and pending match 177 | if previous_dict['username'] != pending_dict['username']: 178 | logger.error("setSecret: Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 179 | raise ValueError("Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) 180 | if previous_dict['host'] != pending_dict['host']: 181 | logger.error("setSecret: Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 182 | raise ValueError("Attempting to modify user for host %s other than previous host %s" % (pending_dict['host'], previous_dict['host'])) 183 | 184 | # If we still don't have a connection, raise a ValueError 185 | if not conn: 186 | logger.error("setSecret: Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 187 | raise ValueError("Unable to log into database with previous, current, or pending secret of secret arn %s" % arn) 188 | 189 | # Now set the password to the pending password 190 | try: 191 | with conn.cursor() as cur: 192 | # Get escaped username via quote_ident 193 | cur.execute("SELECT quote_ident(%s)", (pending_dict['username'],)) 194 | escaped_username = cur.fetchone()[0] 195 | 196 | alter_role = "ALTER USER %s" % escaped_username 197 | cur.execute(alter_role + " WITH PASSWORD %s", (pending_dict['password'],)) 198 | conn.commit() 199 | logger.info("setSecret: Successfully set password for user %s in Redshift DB for secret arn %s." % (pending_dict['username'], arn)) 200 | finally: 201 | conn.close() 202 | 203 | 204 | def test_secret(service_client, arn, token): 205 | """Test the pending secret against the database 206 | 207 | This method tries to log into the database with the secrets staged with AWSPENDING and runs 208 | a permissions check to ensure the user has the correct permissions. 209 | 210 | Args: 211 | service_client (client): The secrets manager service client 212 | 213 | arn (string): The secret ARN or other identifier 214 | 215 | token (string): The ClientRequestToken associated with the secret version 216 | 217 | Raises: 218 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 219 | 220 | ValueError: If the secret is not valid JSON or valid credentials are found to login to the database 221 | 222 | KeyError: If the secret json does not contain the expected keys 223 | 224 | """ 225 | # Try to login with the pending secret, if it succeeds, return 226 | conn = get_connection(get_secret_dict(service_client, arn, "AWSPENDING", token)) 227 | if conn: 228 | # This is where the lambda will validate the user's permissions. Uncomment/modify the below lines to 229 | # tailor these validations to your needs 230 | try: 231 | with conn.cursor() as cur: 232 | cur.execute("SELECT NOW()") 233 | conn.commit() 234 | finally: 235 | conn.close() 236 | 237 | logger.info("testSecret: Successfully signed into Redshift DB with AWSPENDING secret in %s." % arn) 238 | return 239 | else: 240 | logger.error("testSecret: Unable to log into database with pending secret of secret ARN %s" % arn) 241 | raise ValueError("Unable to log into database with pending secret of secret ARN %s" % arn) 242 | 243 | 244 | def finish_secret(service_client, arn, token): 245 | """Finish the rotation by marking the pending secret as current 246 | 247 | This method finishes the secret rotation by staging the secret staged AWSPENDING with the AWSCURRENT stage. 248 | 249 | Args: 250 | service_client (client): The secrets manager service client 251 | 252 | arn (string): The secret ARN or other identifier 253 | 254 | token (string): The ClientRequestToken associated with the secret version 255 | 256 | """ 257 | # First describe the secret to get the current version 258 | metadata = service_client.describe_secret(SecretId=arn) 259 | current_version = None 260 | for version in metadata["VersionIdsToStages"]: 261 | if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: 262 | if version == token: 263 | # The correct version is already marked as current, return 264 | logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn)) 265 | return 266 | current_version = version 267 | break 268 | 269 | # Finalize by staging the secret version current 270 | service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version) 271 | logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, arn)) 272 | 273 | 274 | def get_connection(secret_dict): 275 | """Gets a connection to Redshift DB from a secret dictionary 276 | 277 | This helper function tries to connect to the database grabbing connection info 278 | from the secret dictionary. If successful, it returns the connection, else None 279 | 280 | Args: 281 | secret_dict (dict): The Secret Dictionary 282 | 283 | Returns: 284 | Connection: The pgdb.Connection object if successful. None otherwise 285 | 286 | Raises: 287 | KeyError: If the secret json does not contain the expected keys 288 | 289 | """ 290 | # Parse and validate the secret JSON string 291 | port = int(secret_dict['port']) if 'port' in secret_dict else 5439 292 | dbname = secret_dict['dbname'] if 'dbname' in secret_dict else "dev" 293 | 294 | # Try to obtain a connection to the db 295 | try: 296 | conn = pgdb.connect(host=secret_dict['host'], user=secret_dict['username'], password=secret_dict['password'], database=dbname, port=port, connect_timeout=5) 297 | logger.info("Successfully established connection as user '%s' with host: '%s'" % (secret_dict['username'], secret_dict['host'])) 298 | return conn 299 | except pg.InternalError: 300 | return None 301 | 302 | 303 | def get_secret_dict(service_client, arn, stage, token=None): 304 | """Gets the secret dictionary corresponding for the secret arn, stage, and token 305 | 306 | This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string 307 | 308 | Args: 309 | service_client (client): The secrets manager service client 310 | 311 | arn (string): The secret ARN or other identifier 312 | 313 | token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired 314 | 315 | stage (string): The stage identifying the secret version 316 | 317 | Returns: 318 | SecretDictionary: Secret dictionary 319 | 320 | Raises: 321 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 322 | 323 | ValueError: If the secret is not valid JSON 324 | 325 | """ 326 | required_fields = ['host', 'username', 'password'] 327 | 328 | # Only do VersionId validation against the stage if a token is passed in 329 | if token: 330 | secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage) 331 | else: 332 | secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage) 333 | plaintext = secret['SecretString'] 334 | secret_dict = json.loads(plaintext) 335 | 336 | # Run validations against the secret 337 | if 'engine' not in secret_dict or secret_dict['engine'] != 'redshift': 338 | raise KeyError("Database engine must be set to 'redshift' in order to use this rotation lambda") 339 | for field in required_fields: 340 | if field not in secret_dict: 341 | raise KeyError("%s key is missing from secret JSON" % field) 342 | 343 | # Parse and return the secret JSON string 344 | return secret_dict 345 | 346 | 347 | def get_environment_bool(variable_name, default_value): 348 | """Loads the environment variable and converts it to the boolean. 349 | 350 | Args: 351 | variable_name (string): Name of environment variable 352 | 353 | default_value (bool): The result will fallback to the default_value when the environment variable with the given name doesn't exist. 354 | 355 | Returns: 356 | bool: True when the content of environment variable contains either 'true', '1', 'y' or 'yes' 357 | """ 358 | variable = os.environ.get(variable_name, str(default_value)) 359 | return variable.lower() in ['true', '1', 'y', 'yes'] 360 | 361 | 362 | def get_random_password(service_client): 363 | """ Generates a random new password. Generator loads parameters that affects the content of the resulting password from the environment 364 | variables. When environment variable is missing sensible defaults are chosen. 365 | 366 | Supported environment variables: 367 | - EXCLUDE_CHARACTERS 368 | - PASSWORD_LENGTH 369 | - EXCLUDE_PUNCTUATION 370 | - REQUIRE_EACH_INCLUDED_TYPE 371 | 372 | Redshift requires password to have at least one lower, one upper case and one number character. Because of that 373 | following options are omitted: 374 | - EXCLUDE_UPPERCASE 375 | - EXCLUDE_LOWERCASE 376 | - EXCLUDE_NUMBERS 377 | 378 | Args: 379 | service_client (client): The secrets manager service client 380 | 381 | Returns: 382 | string: The randomly generated password. 383 | """ 384 | passwd = service_client.get_random_password( 385 | ExcludeCharacters=os.environ.get('EXCLUDE_CHARACTERS', '/@"\'\\:'), 386 | PasswordLength=int(os.environ.get('PASSWORD_LENGTH', 32)), 387 | ExcludePunctuation=get_environment_bool('EXCLUDE_PUNCTUATION', False), 388 | RequireEachIncludedType=get_environment_bool('REQUIRE_EACH_INCLUDED_TYPE', True) 389 | ) 390 | return passwd['RandomPassword'] 391 | -------------------------------------------------------------------------------- /SecretsManagerRotationTemplate/lambda_function.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import boto3 5 | import logging 6 | import os 7 | 8 | logger = logging.getLogger() 9 | logger.setLevel(logging.INFO) 10 | 11 | 12 | def lambda_handler(event, context): 13 | """Secrets Manager Rotation Template 14 | 15 | This is a template for creating an AWS Secrets Manager rotation lambda 16 | 17 | Args: 18 | event (dict): Lambda dictionary of event parameters. These keys must include the following: 19 | - SecretId: The secret ARN or identifier 20 | - ClientRequestToken: The ClientRequestToken of the secret version 21 | - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) 22 | 23 | context (LambdaContext): The Lambda runtime information 24 | 25 | Raises: 26 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 27 | 28 | ValueError: If the secret is not properly configured for rotation 29 | 30 | KeyError: If the event parameters do not contain the expected keys 31 | 32 | """ 33 | arn = event['SecretId'] 34 | token = event['ClientRequestToken'] 35 | step = event['Step'] 36 | 37 | # Setup the client 38 | service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT']) 39 | 40 | # Make sure the version is staged correctly 41 | metadata = service_client.describe_secret(SecretId=arn) 42 | if not metadata['RotationEnabled']: 43 | logger.error("Secret %s is not enabled for rotation" % arn) 44 | raise ValueError("Secret %s is not enabled for rotation" % arn) 45 | versions = metadata['VersionIdsToStages'] 46 | if token not in versions: 47 | logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 48 | raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn)) 49 | if "AWSCURRENT" in versions[token]: 50 | logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)) 51 | return 52 | elif "AWSPENDING" not in versions[token]: 53 | logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 54 | raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) 55 | 56 | if step == "createSecret": 57 | create_secret(service_client, arn, token) 58 | 59 | elif step == "setSecret": 60 | set_secret(service_client, arn, token) 61 | 62 | elif step == "testSecret": 63 | test_secret(service_client, arn, token) 64 | 65 | elif step == "finishSecret": 66 | finish_secret(service_client, arn, token) 67 | 68 | else: 69 | raise ValueError("Invalid step parameter") 70 | 71 | 72 | def create_secret(service_client, arn, token): 73 | """Create the secret 74 | 75 | This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a 76 | new secret and put it with the passed in token. 77 | 78 | Args: 79 | service_client (client): The secrets manager service client 80 | 81 | arn (string): The secret ARN or other identifier 82 | 83 | token (string): The ClientRequestToken associated with the secret version 84 | 85 | Raises: 86 | ResourceNotFoundException: If the secret with the specified arn and stage does not exist 87 | 88 | """ 89 | # Make sure the current secret exists 90 | service_client.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT") 91 | 92 | # Now try to get the secret version, if that fails, put a new secret 93 | try: 94 | service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING") 95 | logger.info("createSecret: Successfully retrieved secret for %s." % arn) 96 | except service_client.exceptions.ResourceNotFoundException: 97 | # Get exclude characters from environment variable 98 | exclude_characters = os.environ['EXCLUDE_CHARACTERS'] if 'EXCLUDE_CHARACTERS' in os.environ else '/@"\'\\' 99 | # Generate a random password 100 | passwd = service_client.get_random_password(ExcludeCharacters=exclude_characters) 101 | 102 | # Put the secret 103 | service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=passwd['RandomPassword'], VersionStages=['AWSPENDING']) 104 | logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token)) 105 | 106 | 107 | def set_secret(service_client, arn, token): 108 | """Set the secret 109 | 110 | This method should set the AWSPENDING secret in the service that the secret belongs to. For example, if the secret is a database 111 | credential, this method should take the value of the AWSPENDING secret and set the user's password to this value in the database. 112 | 113 | Args: 114 | service_client (client): The secrets manager service client 115 | 116 | arn (string): The secret ARN or other identifier 117 | 118 | token (string): The ClientRequestToken associated with the secret version 119 | 120 | """ 121 | # This is where the secret should be set in the service 122 | raise NotImplementedError 123 | 124 | 125 | def test_secret(service_client, arn, token): 126 | """Test the secret 127 | 128 | This method should validate that the AWSPENDING secret works in the service that the secret belongs to. For example, if the secret 129 | is a database credential, this method should validate that the user can login with the password in AWSPENDING and that the user has 130 | all of the expected permissions against the database. 131 | 132 | Args: 133 | service_client (client): The secrets manager service client 134 | 135 | arn (string): The secret ARN or other identifier 136 | 137 | token (string): The ClientRequestToken associated with the secret version 138 | 139 | """ 140 | # This is where the secret should be tested against the service 141 | raise NotImplementedError 142 | 143 | 144 | def finish_secret(service_client, arn, token): 145 | """Finish the secret 146 | 147 | This method finalizes the rotation process by marking the secret version passed in as the AWSCURRENT secret. 148 | 149 | Args: 150 | service_client (client): The secrets manager service client 151 | 152 | arn (string): The secret ARN or other identifier 153 | 154 | token (string): The ClientRequestToken associated with the secret version 155 | 156 | Raises: 157 | ResourceNotFoundException: If the secret with the specified arn does not exist 158 | 159 | """ 160 | # First describe the secret to get the current version 161 | metadata = service_client.describe_secret(SecretId=arn) 162 | current_version = None 163 | for version in metadata["VersionIdsToStages"]: 164 | if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: 165 | if version == token: 166 | # The correct version is already marked as current, return 167 | logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn)) 168 | return 169 | current_version = version 170 | break 171 | 172 | # Finalize by staging the secret version current 173 | service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version) 174 | logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, arn)) 175 | --------------------------------------------------------------------------------