|
| 1 | +################################################################################ |
| 2 | +# This program is used to rotate a AWS secret manager secret, and update the |
| 3 | +# corresponding FSxN file system with the new secret. It depends on the |
| 4 | +# secret having the following tags associted with the secret: |
| 5 | +# fsxId: The ID of the FSx file system to update. |
| 6 | +# region: The region in which the FSx file system is located. |
| 7 | +################################################################################ |
| 8 | + |
| 9 | +import boto3 |
| 10 | +import logging |
| 11 | + |
| 12 | +charactersToExcludeInPassword = '/"\'\\' |
| 13 | + |
| 14 | +################################################################################ |
| 15 | +# THis function is used to get the value of a tag from a list of tags. |
| 16 | +################################################################################ |
| 17 | +def getTagValue(tags, key): |
| 18 | + for tag in tags: |
| 19 | + if tag['Key'] == key: |
| 20 | + return tag['Value'] |
| 21 | + return None |
| 22 | + |
| 23 | +################################################################################ |
| 24 | +# This function is used to create a new version of a Secret Manager secret |
| 25 | +# asscoiated with the supplied token. It will first check to see if a secret |
| 26 | +# already exists, and if not, it will create a new secret with version stage |
| 27 | +# set to AWSPENDING. |
| 28 | +################################################################################ |
| 29 | +def create_secret(secretsClient, arn, token): |
| 30 | + global logger |
| 31 | + # |
| 32 | + # Make sure the current secret exists |
| 33 | + secretsClient.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT") |
| 34 | + # |
| 35 | + # Now try to get the secret version, if that fails, put a new secret |
| 36 | + try: |
| 37 | + secretsClient.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING") |
| 38 | + logger.info(f"create_secret: Secret already exist secret for ARN {arn} with VersionId {token}.") |
| 39 | + except secretsClient.exceptions.ResourceNotFoundException: |
| 40 | + # |
| 41 | + # Generate a random password. |
| 42 | + passwd = secretsClient.get_random_password(ExcludeCharacters=charactersToExcludeInPassword, PasswordLength=8, IncludeSpace=False) |
| 43 | + # |
| 44 | + # Put the secret. |
| 45 | + secretsClient.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=passwd['RandomPassword'], VersionStages=['AWSPENDING']) |
| 46 | + logger.info(f"create_secret: Successfully put secret for ARN {arn} with ClientRequestToken {token} and VersionStage = 'AWSPENDING'.") |
| 47 | + |
| 48 | +################################################################################ |
| 49 | +# This functino is used to set the password of an FSxN file system based |
| 50 | +# on a secret stored in the Secrets Manager pointed to by the supplied secret |
| 51 | +# ARN with VersionId equal to token and VersionStage equal to AWSPENDING. |
| 52 | +################################################################################ |
| 53 | +def set_secret(secretsClient, arn, token): |
| 54 | + global logger |
| 55 | + |
| 56 | + try: |
| 57 | + secretValueResponse = secretsClient.get_secret_value(SecretId=arn, VersionStage="AWSPENDING", VersionId=token) |
| 58 | + except ClientError as e: |
| 59 | + logger.error(f"Unable to retrieve secret for {arn} in VersionStage = 'AWSPENDING'. Error={e}") |
| 60 | + # |
| 61 | + # Pass the exception on so the Secret Manager will know that the rotate failed. |
| 62 | + raise Exception(e) |
| 63 | + |
| 64 | + password = secretValueResponse['SecretString'] |
| 65 | + # |
| 66 | + # Get the FSx file system ID and region from the secret's tags. |
| 67 | + secretMetadata = secretsClient.describe_secret(SecretId=arn) |
| 68 | + tags = secretMetadata['Tags'] |
| 69 | + fsxId = getTagValue(tags, 'fsxId') |
| 70 | + fsxRegion = getTagValue(tags, 'region') |
| 71 | + if fsxId is None or fsxRegion is None: |
| 72 | + message=f"Unable to retrieve the FSx file system ID ('fsxId') or region ('region') tags from secret {arn}." |
| 73 | + logger.error(message) |
| 74 | + raise Exception(message) # Signal to the Secrets Manager that the rotation failed. |
| 75 | + # |
| 76 | + # Update the FSx file system with the new password. |
| 77 | + fsxClient = boto3.client(service_name='fsx', region_name=fsxRegion) |
| 78 | + fsxClient.update_file_system(OntapConfiguration={"FsxAdminPassword": password}, FileSystemId=fsxId) |
| 79 | + logger.info(f"Successfully set the FSxN ({fsxId}) password to secret stored in {arn} with a VersionStage = 'AWSPENDING'.") |
| 80 | + |
| 81 | +################################################################################ |
| 82 | +# Usually this function would be used to test that the service has been updated |
| 83 | +# to use the new password. However, since the FSx file system is not accessible |
| 84 | +# from this Lambda function, unless it is running within the FSxN's VPC, there |
| 85 | +# is no way to test that the password has been set correctly. |
| 86 | +################################################################################ |
| 87 | +def test_secret(secretsClient, arn, token): |
| 88 | + global logger |
| 89 | + return |
| 90 | + |
| 91 | +################################################################################ |
| 92 | +# This function is used to finalize the secret rotation process. it does this |
| 93 | +# by marking the secret version passed in as the AWSCURRENT secret. |
| 94 | +################################################################################ |
| 95 | +def finish_secret(secretsClient, arn, token): |
| 96 | + global logger |
| 97 | + # |
| 98 | + # First get the current version. |
| 99 | + metadata = secretsClient.describe_secret(SecretId=arn) |
| 100 | + current_version = None |
| 101 | + for version in metadata["VersionIdsToStages"]: |
| 102 | + if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: |
| 103 | + if version == token: |
| 104 | + # |
| 105 | + # The new version is already marked as current. |
| 106 | + logger.info(f"finishSecret: Version {version} already marked as AWSCURRENT for {arn}") |
| 107 | + return |
| 108 | + current_version = version |
| 109 | + break |
| 110 | + # |
| 111 | + # Finalize by staging the secret version current |
| 112 | + secretsClient.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version) |
| 113 | + logger.info(f"finishSecret: Successfully set AWSCURRENT stage to version {token} for secret {arn}.") |
| 114 | + |
| 115 | + |
| 116 | +################################################################################ |
| 117 | +# This is the main entry point for the Lambda function. It expects the following |
| 118 | +# parameters: |
| 119 | +# event['SecretId']: The ARN of the secret to rotate. |
| 120 | +# event['ClientRequestToken']: The ClientRequestToken associated with the secret version. |
| 121 | +# event['Step']: The rotation step (createSecret, setSecret, testSecret, or finishSecret). |
| 122 | +# |
| 123 | +################################################################################ |
| 124 | +def lambda_handler(event, context): |
| 125 | + global logger |
| 126 | + |
| 127 | + arn = event['SecretId'] |
| 128 | + token = event['ClientRequestToken'] |
| 129 | + step = event['Step'] |
| 130 | + |
| 131 | + logger = logging.getLogger() |
| 132 | + logger.setLevel(logging.INFO) |
| 133 | + # |
| 134 | + # Set the logging level higher for these noisy modules to mute thier messages. |
| 135 | + logging.getLogger("boto3").setLevel(logging.WARNING) |
| 136 | + logging.getLogger("botocore").setLevel(logging.WARNING) |
| 137 | + |
| 138 | + logger.info(f'arn={arn}, token={token}, step={step}.') |
| 139 | + # |
| 140 | + # Create a client to the secrets manager service. |
| 141 | + secretsClient = boto3.client('secretsmanager') |
| 142 | + # |
| 143 | + # Make sure the version is staged correctly. |
| 144 | + metadata = secretsClient.describe_secret(SecretId=arn) |
| 145 | + if not metadata['RotationEnabled']: |
| 146 | + message = f"Secret {arn} is not enabled for rotation." |
| 147 | + logger.error(message) |
| 148 | + raise Exception(message) |
| 149 | + # |
| 150 | + # If rotation is enabled, then a version is created with a version-id |
| 151 | + # equal to the token. |
| 152 | + versions = metadata['VersionIdsToStages'] |
| 153 | + if token not in versions: |
| 154 | + message = f"Secret version {token} has no stage for rotation of secret {arn}." |
| 155 | + logger.error(message) |
| 156 | + raise Exception(message) |
| 157 | + # |
| 158 | + # Now check that the version hasn't already been promoted to AWSCURRENT and if not |
| 159 | + # that a AWSPENDING staging exist. |
| 160 | + if "AWSCURRENT" in versions[token]: |
| 161 | + logger.info(f"Secret version {token} already set as AWSCURRENT for secret {arn}.") |
| 162 | + return |
| 163 | + elif "AWSPENDING" not in versions[token]: |
| 164 | + message = f"Secret version {token} not set as AWSPENDING for rotation of secret {arn}." |
| 165 | + logger.error(message) |
| 166 | + raise Exception(message) |
| 167 | + # |
| 168 | + # At this point we are ready to process the request. |
| 169 | + if step == "createSecret": |
| 170 | + create_secret(secretsClient, arn, token) |
| 171 | + |
| 172 | + elif step == "setSecret": |
| 173 | + set_secret(secretsClient, arn, token) |
| 174 | + |
| 175 | + elif step == "testSecret": |
| 176 | + test_secret(secretsClient, arn, token) |
| 177 | + |
| 178 | + elif step == "finishSecret": |
| 179 | + finish_secret(secretsClient, arn, token) |
| 180 | + |
| 181 | + else: |
| 182 | + raise ValueError(f"Invalid step parameter '{step}'.") |
0 commit comments