Skip to content

Commit

Permalink
Add secret manager replication support (#827)
Browse files Browse the repository at this point in the history
Add secret manager replication support

Signed-off-by: Eric Millbrandt [email protected]
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 <[email protected]>
Reviewed-by: Markus Bergholz <[email protected]>
Reviewed-by: Mark Chappell <None>
Reviewed-by: Alina Buzachis <None>
Reviewed-by: Mark Woolley <[email protected]>
  • Loading branch information
emillbrandt-ngt authored Jan 31, 2023
1 parent 2356d3d commit c7c6800
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
minor_changes:
- secretsmanager_secret - added support for region replication using the ``replica`` parameter (https://github.com/ansible-collections/community.aws/pull/827).
112 changes: 110 additions & 2 deletions plugins/modules/secretsmanager_secret.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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'),
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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', []))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@
- include_tasks: 'basic.yml'
# Permissions missing
#- include_tasks: 'rotation.yml'
# Multi-Region CI not supported (yet)
#- include_tasks: 'replication.yml'
116 changes: 116 additions & 0 deletions tests/integration/targets/secretsmanager_secret/tasks/replication.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
- block:
# ============================================================
# Creation/Deletion testing
# ============================================================
- name: add secret to AWS Secrets Manager
aws_secret:
name: "{{ secret_name }}"
state: present
secret_type: 'string'
secret: "{{ super_secret_string }}"
replica:
- region: 'us-east-2'
- region: 'us-west-2'
kms_key_id: 'alias/aws/secretsmanager'
register: result

- name: assert correct keys are returned
assert:
that:
- result.changed
- result.arn is not none
- result.name is not none
- result.secret.replication_status[0]["region"] == 'us-east-2'
- result.secret.replication_status[1]["region"] == 'us-west-2'
- result.secret.replication_status[1]["kms_key_id"] == 'alias/aws/secretsmanager'
- result.tags is not none
- result.version_ids_to_stages is not none

- name: no changes to secret
aws_secret:
name: "{{ secret_name }}"
state: present
secret: "{{ super_secret_string }}"
replica:
- region: 'us-east-2'
- region: 'us-west-2'
kms_key_id: 'alias/aws/secretsmanager'
register: result

- name: assert correct keys are returned
assert:
that:
- not result.changed
- result.arn is not none

- name: remove region replica
aws_secret:
name: "{{ secret_name }}"
description: 'this is a change to remove replication'
secret: "{{ super_secret_string }}"
state: present
replica: []
register: result

- name: assert that replica was removed
assert:
that:
- not result.failed
- '"replication_status" not in result.secret'

- name: add region replica to an existing secret
aws_secret:
name: "{{ secret_name }}"
description: 'this is a change add replication'
secret: "{{ super_secret_string }}"
state: present
replica:
- region: 'us-east-2'
- region: 'us-west-2'
kms_key_id: 'alias/aws/secretsmanager'
register: result

- name: assert that replica was created
assert:
that:
- not result.failed
- result.secret.replication_status[0]["region"] == 'us-east-2'
- result.secret.replication_status[1]["region"] == 'us-west-2'
- result.secret.replication_status[1]["kms_key_id"] == 'alias/aws/secretsmanager'

- name: change replica regions
aws_secret:
name: "{{ secret_name }}"
state: present
secret: "{{ super_secret_string }}"
replica:
- region: 'us-east-2'
- region: 'eu-central-1'
kms_key_id: 'alias/aws/secretsmanager'
register: result

- name: assert that replica regions changed
assert:
that:
- not result.failed
- result.secret.replication_status[0]["region"] == 'us-east-2'
- result.secret.replication_status[1]["region"] == 'eu-central-1'
- result.secret.replication_status[1]["kms_key_id"] == 'alias/aws/secretsmanager'

always:
- name: remove region replica
aws_secret:
name: "{{ secret_name }}"
description: 'this is a change to remove replication'
state: present
secret: "{{ super_secret_string }}"
register: result
ignore_errors: yes

- name: remove secret
aws_secret:
name: "{{ secret_name }}"
state: absent
recovery_window: 0
ignore_errors: yes

0 comments on commit c7c6800

Please sign in to comment.