Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add secret manager replication support #827

Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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