From 6f76a88dcd6d3727146917e7eb0897b0b641d09f Mon Sep 17 00:00:00 2001 From: Eric Millbrandt Date: Tue, 31 Jan 2023 10:01:32 -0500 Subject: [PATCH] Add secret manager replication support (#827) Add secret manager replication support Signed-off-by: Eric Millbrandt eric.millbrandt@numerated.com SUMMARY Add support for regional secret replication. The component now supports: Creating a secret with a regional replica Adding a region replica to a secret Removing a region replica from a secret ISSUE TYPE Feature Pull Request COMPONENT NAME aws_secret ADDITIONAL INFORMATION https://aws.amazon.com/about-aws/whats-new/2021/03/aws-secrets-manager-provides-support-to-replicate-secrets-in-aws-secrets-manager-to-multiple-aws-regions/ https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/secretsmanager.html Reviewed-by: Eric Millbrandt Reviewed-by: Markus Bergholz Reviewed-by: Mark Chappell Reviewed-by: Alina Buzachis Reviewed-by: Mark Woolley --- secretsmanager_secret.py | 112 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/secretsmanager_secret.py b/secretsmanager_secret.py index d46267b278d..337b28669bc 100644 --- a/secretsmanager_secret.py +++ b/secretsmanager_secret.py @@ -41,6 +41,24 @@ - Specifies a user-provided description of the secret. type: str default: '' + replica: + description: + - Specifies a list of regions and kms_key_ids (optional) to replicate the secret to + type: list + elements: dict + version_added: 5.3.0 + suboptions: + region: + description: + - Region to replicate secret to. + type: str + required: true + kms_key_id: + description: + - Specifies the ARN or alias of the AWS KMS customer master key (CMK) in the + destination region to be used (alias/aws/secretsmanager is assumed if not specified) + type: str + required: false kms_key_id: description: - Specifies the ARN or alias of the AWS KMS customer master key (CMK) to be @@ -196,10 +214,13 @@ class Secret(object): """An object representation of the Secret described by the self.module args""" - def __init__(self, name, secret_type, secret, resource_policy=None, description="", kms_key_id=None, - tags=None, lambda_arn=None, rotation_interval=None): + def __init__( + self, name, secret_type, secret, resource_policy=None, description="", kms_key_id=None, + tags=None, lambda_arn=None, rotation_interval=None, replica_regions=None, + ): self.name = name self.description = description + self.replica_regions = replica_regions self.kms_key_id = kms_key_id if secret_type == "binary": self.secret_type = "SecretBinary" @@ -223,6 +244,15 @@ def create_args(self): args["Description"] = self.description if self.kms_key_id: args["KmsKeyId"] = self.kms_key_id + if self.replica_regions: + add_replica_regions = [] + for replica in self.replica_regions: + if replica["kms_key_id"]: + add_replica_regions.append({'Region': replica["region"], + 'KmsKeyId': replica["kms_key_id"]}) + else: + add_replica_regions.append({'Region': replica["region"]}) + args["AddReplicaRegions"] = add_replica_regions if self.tags: args["Tags"] = ansible_dict_to_boto3_tag_list(self.tags) args[self.secret_type] = self.secret @@ -320,6 +350,35 @@ def put_resource_policy(self, secret): self.module.fail_json_aws(e, msg="Failed to update secret resource policy") return response + def remove_replication(self, name, regions): + if self.module.check_mode: + self.module.exit_json(changed=True) + try: + replica_regions = [] + response = self.client.remove_regions_from_replication( + SecretId=name, + RemoveReplicaRegions=regions) + except (BotoCoreError, ClientError) as e: + self.module.fail_json_aws(e, msg="Failed to replicate secret") + return response + + def replicate_secret(self, name, regions): + if self.module.check_mode: + self.module.exit_json(changed=True) + try: + replica_regions = [] + for replica in regions: + if replica["kms_key_id"]: + replica_regions.append({'Region': replica["region"], 'KmsKeyId': replica["kms_key_id"]}) + else: + replica_regions.append({'Region': replica["region"]}) + response = self.client.replicate_secret_to_regions( + SecretId=name, + AddReplicaRegions=replica_regions) + except (BotoCoreError, ClientError) as e: + self.module.fail_json_aws(e, msg="Failed to replicate secret") + return response + def restore_secret(self, name): if self.module.check_mode: self.module.exit_json(changed=True) @@ -424,12 +483,49 @@ def rotation_match(desired_secret, current_secret): return True +def compare_regions(desired_secret, current_secret): + """Compare secrets replication configuration + + Args: + desired_secret: camel dict representation of the desired secret state. + current_secret: secret reference as returned by the secretsmanager api. + + Returns: bool + """ + regions_to_set_replication = [] + regions_to_remove_replication = [] + + if desired_secret.replica_regions is None: + return regions_to_set_replication, regions_to_remove_replication + + if desired_secret.replica_regions: + regions_to_set_replication = desired_secret.replica_regions + + for current_secret_region in current_secret.get("ReplicationStatus", []): + if regions_to_set_replication: + for desired_secret_region in regions_to_set_replication: + if current_secret_region["Region"] == desired_secret_region["region"]: + regions_to_set_replication.remove(desired_secret_region) + else: + regions_to_remove_replication.append(current_secret_region["Region"]) + else: + regions_to_remove_replication.append(current_secret_region["Region"]) + + return regions_to_set_replication, regions_to_remove_replication + + def main(): + replica_args = dict( + region=dict(type='str', required=True), + kms_key_id=dict(type='str', required=False), + ) + module = AnsibleAWSModule( argument_spec={ 'name': dict(required=True), 'state': dict(choices=['present', 'absent'], default='present'), 'description': dict(default=""), + 'replica': dict(type='list', elements='dict', options=replica_args), 'kms_key_id': dict(), 'secret_type': dict(choices=['binary', 'string'], default="string"), 'secret': dict(default="", no_log=True), @@ -454,6 +550,7 @@ def main(): module.params.get('secret_type'), module.params.get('secret') or module.params.get('json_secret'), description=module.params.get('description'), + replica_regions=module.params.get('replica'), kms_key_id=module.params.get('kms_key_id'), resource_policy=module.params.get('resource_policy'), tags=module.params.get('tags'), @@ -492,6 +589,7 @@ def main(): if not rotation_match(secret, current_secret): result = secrets_mgr.update_rotation(secret) changed = True + current_resource_policy_response = secrets_mgr.get_resource_policy(secret.name) current_resource_policy = current_resource_policy_response.get("ResourcePolicy") if compare_policies(secret.resource_policy, current_resource_policy): @@ -500,6 +598,7 @@ def main(): else: result = secrets_mgr.put_resource_policy(secret) changed = True + if module.params.get('tags') is not None: current_tags = boto3_tag_list_to_ansible_dict(current_secret.get('Tags', [])) tags_to_add, tags_to_remove = compare_aws_tags(current_tags, secret.tags, purge_tags) @@ -509,6 +608,15 @@ def main(): if tags_to_remove: secrets_mgr.untag_secret(secret.name, tags_to_remove) changed = True + + regions_to_set_replication, regions_to_remove_replication = compare_regions(secret, current_secret) + if regions_to_set_replication: + secrets_mgr.replicate_secret(secret.name, regions_to_set_replication) + changed = True + if regions_to_remove_replication: + secrets_mgr.remove_replication(secret.name, regions_to_remove_replication) + changed = True + result = camel_dict_to_snake_dict(secrets_mgr.get_secret(secret.name)) if result.get('tags', None) is not None: result['tags_dict'] = boto3_tag_list_to_ansible_dict(result.get('tags', []))