diff --git a/plugins/modules/kms_key.py b/plugins/modules/kms_key.py new file mode 100644 index 00000000000..53d691888b6 --- /dev/null +++ b/plugins/modules/kms_key.py @@ -0,0 +1,1216 @@ +#!/usr/bin/python +# -*- coding: utf-8 -* +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: kms_key +version_added: 1.0.0 +short_description: Perform various KMS key management tasks +description: + - Manage role/user access to a KMS key. + - Not designed for encrypting/decrypting. + - Prior to release 5.0.0 this module was called C(community.aws.aws_kms). + The usage did not change. +options: + alias: + description: + - An alias for a key. + - For safety, even though KMS does not require keys to have an alias, this module expects all + new keys to be given an alias to make them easier to manage. Existing keys without an alias + may be referred to by I(key_id). Use M(community.aws.kms_key_info) to find key ids. + - Note that passing a I(key_id) and I(alias) will only cause a new alias to be added, an alias will never be renamed. + - The C(alias/) prefix is optional. + - Required if I(key_id) is not given. + required: false + aliases: + - key_alias + type: str + key_id: + description: + - Key ID or ARN of the key. + - One of I(alias) or I(key_id) are required. + required: false + aliases: + - key_arn + type: str + enable_key_rotation: + description: + - Whether the key should be automatically rotated every year. + required: false + type: bool + policy_mode: + description: + - (deprecated) Grant or deny access. + - Used for modifying the Key Policy rather than modifying a grant and only + works on the default policy created through the AWS Console. + - This option has been deprecated, and will be removed in a release after 2021-12-01. Use I(policy) instead. + default: grant + choices: [ grant, deny ] + aliases: + - mode + type: str + policy_role_name: + description: + - (deprecated) Role to allow/deny access. + - One of I(policy_role_name) or I(policy_role_arn) are required. + - Used for modifying the Key Policy rather than modifying a grant and only + works on the default policy created through the AWS Console. + - This option has been deprecated, and will be removed in a release after 2021-12-01. Use I(policy) instead. + required: false + aliases: + - role_name + type: str + policy_role_arn: + description: + - (deprecated) ARN of role to allow/deny access. + - One of I(policy_role_name) or I(policy_role_arn) are required. + - Used for modifying the Key Policy rather than modifying a grant and only + works on the default policy created through the AWS Console. + - This option has been deprecated, and will be removed in a release after 2021-12-01. Use I(policy) instead. + type: str + required: false + aliases: + - role_arn + policy_grant_types: + description: + - (deprecated) List of grants to give to user/role. Likely "role,role grant" or "role,role grant,admin". + - Required when I(policy_mode=grant). + - Used for modifying the Key Policy rather than modifying a grant and only + works on the default policy created through the AWS Console. + - This option has been deprecated, and will be removed in a release after 2021-12-01. Use I(policy) instead. + required: false + aliases: + - grant_types + type: list + elements: str + policy_clean_invalid_entries: + description: + - (deprecated) If adding/removing a role and invalid grantees are found, remove them. These entries will cause an update to fail in all known cases. + - Only cleans if changes are being made. + - Used for modifying the Key Policy rather than modifying a grant and only + works on the default policy created through the AWS Console. + - This option has been deprecated, and will be removed in a release after 2021-12-01. Use I(policy) instead. + type: bool + default: true + aliases: + - clean_invalid_entries + state: + description: + - Whether a key should be present or absent. + - Note that making an existing key C(absent) only schedules a key for deletion. + - Passing a key that is scheduled for deletion with I(state=present) will cancel key deletion. + required: False + choices: + - present + - absent + default: present + type: str + enabled: + description: Whether or not a key is enabled. + default: True + type: bool + description: + description: + - A description of the CMK. + - Use a description that helps you decide whether the CMK is appropriate for a task. + type: str + pending_window: + description: + - The number of days between requesting deletion of the CMK and when it will actually be deleted. + - Only used when I(state=absent) and the CMK has not yet been deleted. + - Valid values are between 7 and 30 (inclusive). + - 'See also: U(https://docs.aws.amazon.com/kms/latest/APIReference/API_ScheduleKeyDeletion.html#KMS-ScheduleKeyDeletion-request-PendingWindowInDays)' + type: int + aliases: ['deletion_delay'] + version_added: 1.4.0 + purge_grants: + description: + - Whether the I(grants) argument should cause grants not in the list to be removed. + default: False + type: bool + grants: + description: + - A list of grants to apply to the key. Each item must contain I(grantee_principal). + Each item can optionally contain I(retiring_principal), I(operations), I(constraints), + I(name). + - I(grantee_principal) and I(retiring_principal) must be ARNs + - 'For full documentation of suboptions see the boto3 documentation:' + - 'U(https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/kms.html#KMS.Client.create_grant)' + type: list + elements: dict + suboptions: + grantee_principal: + description: The full ARN of the principal being granted permissions. + required: true + type: str + retiring_principal: + description: The full ARN of the principal permitted to revoke/retire the grant. + type: str + operations: + type: list + elements: str + description: + - A list of operations that the grantee may perform using the CMK. + choices: ['Decrypt', 'Encrypt', 'GenerateDataKey', 'GenerateDataKeyWithoutPlaintext', 'ReEncryptFrom', 'ReEncryptTo', + 'CreateGrant', 'RetireGrant', 'DescribeKey', 'Verify', 'Sign'] + constraints: + description: + - Constraints is a dict containing C(encryption_context_subset) or C(encryption_context_equals), + either or both being a dict specifying an encryption context match. + See U(https://docs.aws.amazon.com/kms/latest/APIReference/API_GrantConstraints.html) or + U(https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/kms.html#KMS.Client.create_grant) + type: dict + policy: + description: + - policy to apply to the KMS key. + - See U(https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html) + type: json + key_spec: + aliases: + - customer_master_key_spec + description: + - Specifies the type of KMS key to create. + - The specification is not changeable once the key is created. + type: str + default: SYMMETRIC_DEFAULT + choices: ['SYMMETRIC_DEFAULT', 'RSA_2048', 'RSA_3072', 'RSA_4096', 'ECC_NIST_P256', 'ECC_NIST_P384', 'ECC_NIST_P521', 'ECC_SECG_P256K1'] + version_added: 2.1.0 + key_usage: + description: + - Determines the cryptographic operations for which you can use the KMS key. + - The usage is not changeable once the key is created. + type: str + default: ENCRYPT_DECRYPT + choices: ['ENCRYPT_DECRYPT', 'SIGN_VERIFY'] + version_added: 2.1.0 +author: + - Ted Timmons (@tedder) + - Will Thames (@willthames) + - Mark Chappell (@tremble) +extends_documentation_fragment: + - amazon.aws.aws + - amazon.aws.ec2 + - amazon.aws.tags.deprecated_purge + +notes: + - There are known inconsistencies in the amount of time required for updates of KMS keys to be fully reflected on AWS. + This can cause issues when running duplicate tasks in succession or using the M(community.aws.kms_key_info) module to fetch key metadata + shortly after modifying keys. + For this reason, it is recommended to use the return data from this module (M(community.aws.kms_key)) to fetch a key's metadata. +''' + +EXAMPLES = r''' +# Managing the KMS IAM Policy via policy_mode and policy_grant_types is fragile +# and has been deprecated in favour of the policy option. +- name: grant user-style access to production secrets + community.aws.kms_key: + args: + alias: "alias/my_production_secrets" + policy_mode: grant + policy_role_name: "prod-appServerRole-1R5AQG2BSEL6L" + policy_grant_types: "role,role grant" +- name: remove access to production secrets from role + community.aws.kms_key: + args: + alias: "alias/my_production_secrets" + policy_mode: deny + policy_role_name: "prod-appServerRole-1R5AQG2BSEL6L" + +# Create a new KMS key +- community.aws.kms_key: + alias: mykey + tags: + Name: myKey + Purpose: protect_stuff + +# Update previous key with more tags +- community.aws.kms_key: + alias: mykey + tags: + Name: myKey + Purpose: protect_stuff + Owner: security_team + +# Update a known key with grants allowing an instance with the billing-prod IAM profile +# to decrypt data encrypted with the environment: production, application: billing +# encryption context +- community.aws.kms_key: + key_id: abcd1234-abcd-1234-5678-ef1234567890 + grants: + - name: billing_prod + grantee_principal: arn:aws:iam::1234567890123:role/billing_prod + constraints: + encryption_context_equals: + environment: production + application: billing + operations: + - Decrypt + - RetireGrant + +- name: Update IAM policy on an existing KMS key + community.aws.kms_key: + alias: my-kms-key + policy: '{"Version": "2012-10-17", "Id": "my-kms-key-permissions", "Statement": [ { } ]}' + state: present + +- name: Example using lookup for policy json + community.aws.kms_key: + alias: my-kms-key + policy: "{{ lookup('template', 'kms_iam_policy_template.json.j2') }}" + state: present +''' + +RETURN = r''' +key_id: + description: ID of key. + type: str + returned: always + sample: abcd1234-abcd-1234-5678-ef1234567890 +key_arn: + description: ARN of key. + type: str + returned: always + sample: arn:aws:kms:ap-southeast-2:123456789012:key/abcd1234-abcd-1234-5678-ef1234567890 +key_state: + description: + - The state of the key. + - Will be one of C('Creating'), C('Enabled'), C('Disabled'), C('PendingDeletion'), C('PendingImport'), + C('PendingReplicaDeletion'), C('Unavailable'), or C('Updating'). + type: str + returned: always + sample: PendingDeletion +key_usage: + description: The cryptographic operations for which you can use the key. + type: str + returned: always + sample: ENCRYPT_DECRYPT +origin: + description: The source of the key's key material. When this value is C(AWS_KMS), + AWS KMS created the key material. When this value is C(EXTERNAL), the + key material was imported or the CMK lacks key material. + type: str + returned: always + sample: AWS_KMS +aws_account_id: + description: The AWS Account ID that the key belongs to. + type: str + returned: always + sample: 1234567890123 +creation_date: + description: Date and time of creation of the key. + type: str + returned: always + sample: "2017-04-18T15:12:08.551000+10:00" +deletion_date: + description: Date and time after which KMS deletes this KMS key. + type: str + returned: when key_state is PendingDeletion + sample: "2017-04-18T15:12:08.551000+10:00" + version_added: 3.3.0 +description: + description: Description of the key. + type: str + returned: always + sample: "My Key for Protecting important stuff" +enabled: + description: Whether the key is enabled. True if I(key_state) is C(Enabled). + type: bool + returned: always + sample: false +enable_key_rotation: + description: Whether the automatic annual key rotation is enabled. Returns None if key rotation status can't be determined. + type: bool + returned: always + sample: false +aliases: + description: List of aliases associated with the key. + type: list + returned: always + sample: + - aws/acm + - aws/ebs +policies: + description: List of policy documents for the key. Empty when access is denied even if there are policies. + type: list + returned: always + elements: str + sample: + Version: "2012-10-17" + Id: "auto-ebs-2" + Statement: + - Sid: "Allow access through EBS for all principals in the account that are authorized to use EBS" + Effect: "Allow" + Principal: + AWS: "*" + Action: + - "kms:Encrypt" + - "kms:Decrypt" + - "kms:ReEncrypt*" + - "kms:GenerateDataKey*" + - "kms:CreateGrant" + - "kms:DescribeKey" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: "111111111111" + kms:ViaService: "ec2.ap-southeast-2.amazonaws.com" + - Sid: "Allow direct access to key metadata to the account" + Effect: "Allow" + Principal: + AWS: "arn:aws:iam::111111111111:root" + Action: + - "kms:Describe*" + - "kms:Get*" + - "kms:List*" + - "kms:RevokeGrant" + Resource: "*" +key_policies: + description: List of policy documents for the key. Empty when access is denied even if there are policies. + type: list + returned: always + elements: dict + sample: + Version: "2012-10-17" + Id: "auto-ebs-2" + Statement: + - Sid: "Allow access through EBS for all principals in the account that are authorized to use EBS" + Effect: "Allow" + Principal: + AWS: "*" + Action: + - "kms:Encrypt" + - "kms:Decrypt" + - "kms:ReEncrypt*" + - "kms:GenerateDataKey*" + - "kms:CreateGrant" + - "kms:DescribeKey" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: "111111111111" + kms:ViaService: "ec2.ap-southeast-2.amazonaws.com" + - Sid: "Allow direct access to key metadata to the account" + Effect: "Allow" + Principal: + AWS: "arn:aws:iam::111111111111:root" + Action: + - "kms:Describe*" + - "kms:Get*" + - "kms:List*" + - "kms:RevokeGrant" + Resource: "*" + version_added: 3.3.0 +tags: + description: Dictionary of tags applied to the key. Empty when access is denied even if there are tags. + type: dict + returned: always + sample: + Name: myKey + Purpose: protecting_stuff +grants: + description: List of grants associated with a key. + type: list + elements: dict + returned: always + contains: + constraints: + description: Constraints on the encryption context that the grant allows. + See U(https://docs.aws.amazon.com/kms/latest/APIReference/API_GrantConstraints.html) for further details + type: dict + returned: always + sample: + encryption_context_equals: + "aws:lambda:_function_arn": "arn:aws:lambda:ap-southeast-2:012345678912:function:xyz" + creation_date: + description: Date of creation of the grant. + type: str + returned: always + sample: "2017-04-18T15:12:08+10:00" + grant_id: + description: The unique ID for the grant. + type: str + returned: always + sample: abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234 + grantee_principal: + description: The principal that receives the grant's permissions. + type: str + returned: always + sample: arn:aws:sts::0123456789012:assumed-role/lambda_xyz/xyz + issuing_account: + description: The AWS account under which the grant was issued. + type: str + returned: always + sample: arn:aws:iam::01234567890:root + key_id: + description: The key ARN to which the grant applies. + type: str + returned: always + sample: arn:aws:kms:ap-southeast-2:123456789012:key/abcd1234-abcd-1234-5678-ef1234567890 + name: + description: The friendly name that identifies the grant. + type: str + returned: always + sample: xyz + operations: + description: The list of operations permitted by the grant. + type: list + returned: always + sample: + - Decrypt + - RetireGrant + retiring_principal: + description: The principal that can retire the grant. + type: str + returned: always + sample: arn:aws:sts::0123456789012:assumed-role/lambda_xyz/xyz +changes_needed: + description: Grant types that would be changed/were changed. + type: dict + returned: always + sample: { "role": "add", "role grant": "add" } +had_invalid_entries: + description: Whether there are invalid (non-ARN) entries in the KMS entry. These don't count as a change, but will be removed if any changes are being made. + type: bool + returned: always +''' + +# these mappings are used to go from simple labels to the actual 'Sid' values returned +# by get_policy. They seem to be magic values. +statement_label = { + 'role': 'Allow use of the key', + 'role grant': 'Allow attachment of persistent resources', + 'admin': 'Allow access for Key Administrators' +} + +import json +import re + +try: + import botocore +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.six import string_types + +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ansible_dict_to_boto3_tag_list +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto3_tag_list_to_ansible_dict +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import compare_aws_tags +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import compare_policies + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_iam_roles_with_backoff(connection): + paginator = connection.get_paginator('list_roles') + return paginator.paginate().build_full_result() + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_keys_with_backoff(connection): + paginator = connection.get_paginator('list_keys') + return paginator.paginate().build_full_result() + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_aliases_with_backoff(connection): + paginator = connection.get_paginator('list_aliases') + return paginator.paginate().build_full_result() + + +def get_kms_aliases_lookup(connection): + _aliases = dict() + for alias in get_kms_aliases_with_backoff(connection)['Aliases']: + # Not all aliases are actually associated with a key + if 'TargetKeyId' in alias: + # strip off leading 'alias/' and add it to key's aliases + if alias['TargetKeyId'] in _aliases: + _aliases[alias['TargetKeyId']].append(alias['AliasName'][6:]) + else: + _aliases[alias['TargetKeyId']] = [alias['AliasName'][6:]] + return _aliases + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_tags_with_backoff(connection, key_id, **kwargs): + return connection.list_resource_tags(KeyId=key_id, **kwargs) + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_grants_with_backoff(connection, key_id): + params = dict(KeyId=key_id) + paginator = connection.get_paginator('list_grants') + return paginator.paginate(**params).build_full_result() + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_metadata_with_backoff(connection, key_id): + return connection.describe_key(KeyId=key_id) + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def list_key_policies_with_backoff(connection, key_id): + paginator = connection.get_paginator('list_key_policies') + return paginator.paginate(KeyId=key_id).build_full_result() + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_key_policy_with_backoff(connection, key_id, policy_name): + return connection.get_key_policy(KeyId=key_id, PolicyName=policy_name) + + +def get_kms_tags(connection, module, key_id): + # Handle pagination here as list_resource_tags does not have + # a paginator + kwargs = {} + tags = [] + more = True + while more: + try: + tag_response = get_kms_tags_with_backoff(connection, key_id, **kwargs) + tags.extend(tag_response['Tags']) + except is_boto3_error_code('AccessDeniedException'): + tag_response = {} + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="Failed to obtain key tags") + if tag_response.get('NextMarker'): + kwargs['Marker'] = tag_response['NextMarker'] + else: + more = False + return tags + + +def get_kms_policies(connection, module, key_id): + try: + policies = list_key_policies_with_backoff(connection, key_id)['PolicyNames'] + return [get_key_policy_with_backoff(connection, key_id, policy)['Policy'] for + policy in policies] + except is_boto3_error_code('AccessDeniedException'): + return [] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="Failed to obtain key policies") + + +def camel_to_snake_grant(grant): + ''' camel_to_snake_grant snakifies everything except the encryption context ''' + constraints = grant.get('Constraints', {}) + result = camel_dict_to_snake_dict(grant) + if 'EncryptionContextEquals' in constraints: + result['constraints']['encryption_context_equals'] = constraints['EncryptionContextEquals'] + if 'EncryptionContextSubset' in constraints: + result['constraints']['encryption_context_subset'] = constraints['EncryptionContextSubset'] + return result + + +def get_key_details(connection, module, key_id): + try: + result = get_kms_metadata_with_backoff(connection, key_id)['KeyMetadata'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to obtain key metadata") + result['KeyArn'] = result.pop('Arn') + + try: + aliases = get_kms_aliases_lookup(connection) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to obtain aliases") + + try: + current_rotation_status = connection.get_key_rotation_status(KeyId=key_id) + result['enable_key_rotation'] = current_rotation_status.get('KeyRotationEnabled') + except is_boto3_error_code(['AccessDeniedException', 'UnsupportedOperationException']) as e: + result['enable_key_rotation'] = None + result['aliases'] = aliases.get(result['KeyId'], []) + + result = camel_dict_to_snake_dict(result) + + # grants and tags get snakified differently + try: + result['grants'] = [camel_to_snake_grant(grant) for grant in + get_kms_grants_with_backoff(connection, key_id)['Grants']] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to obtain key grants") + tags = get_kms_tags(connection, module, key_id) + result['tags'] = boto3_tag_list_to_ansible_dict(tags, 'TagKey', 'TagValue') + result['policies'] = get_kms_policies(connection, module, key_id) + result['key_policies'] = [json.loads(policy) for policy in result['policies']] + return result + + +def get_kms_facts(connection, module): + try: + keys = get_kms_keys_with_backoff(connection)['Keys'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to obtain keys") + + return [get_key_details(connection, module, key['KeyId']) for key in keys] + + +def convert_grant_params(grant, key): + grant_params = dict(KeyId=key['key_arn'], + GranteePrincipal=grant['grantee_principal']) + if grant.get('operations'): + grant_params['Operations'] = grant['operations'] + if grant.get('retiring_principal'): + grant_params['RetiringPrincipal'] = grant['retiring_principal'] + if grant.get('name'): + grant_params['Name'] = grant['name'] + if grant.get('constraints'): + grant_params['Constraints'] = dict() + if grant['constraints'].get('encryption_context_subset'): + grant_params['Constraints']['EncryptionContextSubset'] = grant['constraints']['encryption_context_subset'] + if grant['constraints'].get('encryption_context_equals'): + grant_params['Constraints']['EncryptionContextEquals'] = grant['constraints']['encryption_context_equals'] + return grant_params + + +def different_grant(existing_grant, desired_grant): + if existing_grant.get('grantee_principal') != desired_grant.get('grantee_principal'): + return True + if existing_grant.get('retiring_principal') != desired_grant.get('retiring_principal'): + return True + if set(existing_grant.get('operations', [])) != set(desired_grant.get('operations')): + return True + if existing_grant.get('constraints') != desired_grant.get('constraints'): + return True + return False + + +def compare_grants(existing_grants, desired_grants, purge_grants=False): + existing_dict = dict((eg['name'], eg) for eg in existing_grants) + desired_dict = dict((dg['name'], dg) for dg in desired_grants) + to_add_keys = set(desired_dict.keys()) - set(existing_dict.keys()) + if purge_grants: + to_remove_keys = set(existing_dict.keys()) - set(desired_dict.keys()) + else: + to_remove_keys = set() + to_change_candidates = set(existing_dict.keys()) & set(desired_dict.keys()) + for candidate in to_change_candidates: + if different_grant(existing_dict[candidate], desired_dict[candidate]): + to_add_keys.add(candidate) + to_remove_keys.add(candidate) + + to_add = [] + to_remove = [] + for key in to_add_keys: + grant = desired_dict[key] + to_add.append(grant) + for key in to_remove_keys: + grant = existing_dict[key] + to_remove.append(grant) + return to_add, to_remove + + +def start_key_deletion(connection, module, key_metadata): + if key_metadata['KeyState'] == 'PendingDeletion': + return False + + if module.check_mode: + return True + + deletion_params = {'KeyId': key_metadata['Arn']} + if module.params.get('pending_window'): + deletion_params['PendingWindowInDays'] = module.params.get('pending_window') + + try: + connection.schedule_key_deletion(**deletion_params) + return True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to schedule key for deletion") + + +def cancel_key_deletion(connection, module, key): + key_id = key['key_arn'] + if key['key_state'] != 'PendingDeletion': + return False + + if module.check_mode: + return True + + try: + connection.cancel_key_deletion(KeyId=key_id) + # key is disabled after deletion cancellation + # set this so that ensure_enabled_disabled works correctly + key['key_state'] = 'Disabled' + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to cancel key deletion") + + return True + + +def ensure_enabled_disabled(connection, module, key, enabled): + desired_state = 'Enabled' + if not enabled: + desired_state = 'Disabled' + + if key['key_state'] == desired_state: + return False + + key_id = key['key_arn'] + if not module.check_mode: + if enabled: + try: + connection.enable_key(KeyId=key_id) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to enable key") + else: + try: + connection.disable_key(KeyId=key_id) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to disable key") + + return True + + +def update_alias(connection, module, key, alias): + alias = canonicalize_alias_name(alias) + + if alias is None: + return False + + key_id = key['key_arn'] + aliases = get_kms_aliases_with_backoff(connection)['Aliases'] + # We will only add new aliases, not rename existing ones + if alias in [_alias['AliasName'] for _alias in aliases]: + return False + + if not module.check_mode: + try: + connection.create_alias(TargetKeyId=key_id, AliasName=alias) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed create key alias") + + return True + + +def update_description(connection, module, key, description): + if description is None: + return False + if key['description'] == description: + return False + + key_id = key['key_arn'] + if not module.check_mode: + try: + connection.update_key_description(KeyId=key_id, Description=description) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to update key description") + + return True + + +def update_tags(connection, module, key, desired_tags, purge_tags): + if desired_tags is None: + return False + + # purge_tags needs to be explicitly set, so an empty tags list means remove + # all tags + + to_add, to_remove = compare_aws_tags(key['tags'], desired_tags, purge_tags) + if not (bool(to_add) or bool(to_remove)): + return False + + key_id = key['key_arn'] + if not module.check_mode: + if to_remove: + try: + connection.untag_resource(KeyId=key_id, TagKeys=to_remove) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Unable to remove tag") + if to_add: + try: + tags = ansible_dict_to_boto3_tag_list(module.params['tags'], tag_name_key_name='TagKey', tag_value_key_name='TagValue') + connection.tag_resource(KeyId=key_id, Tags=tags) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Unable to add tag to key") + + return True + + +def update_policy(connection, module, key, policy): + if policy is None: + return False + try: + new_policy = json.loads(policy) + except ValueError as e: + module.fail_json_aws(e, msg="Unable to parse new policy as JSON") + + key_id = key['key_arn'] + try: + keyret = connection.get_key_policy(KeyId=key_id, PolicyName='default') + original_policy = json.loads(keyret['Policy']) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError): + # If we can't fetch the current policy assume we're making a change + # Could occur if we have PutKeyPolicy without GetKeyPolicy + original_policy = {} + + if not compare_policies(original_policy, new_policy): + return False + + if not module.check_mode: + try: + connection.put_key_policy(KeyId=key_id, PolicyName='default', Policy=policy) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Unable to update key policy") + + return True + + +def update_key_rotation(connection, module, key, enable_key_rotation): + if enable_key_rotation is None: + return False + key_id = key['key_arn'] + + try: + current_rotation_status = connection.get_key_rotation_status(KeyId=key_id) + if current_rotation_status.get('KeyRotationEnabled') == enable_key_rotation: + return False + except is_boto3_error_code('AccessDeniedException'): + pass + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="Unable to get current key rotation status") + + if not module.check_mode: + try: + if enable_key_rotation: + connection.enable_key_rotation(KeyId=key_id) + else: + connection.disable_key_rotation(KeyId=key_id) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to enable/disable key rotation") + + return True + + +def update_grants(connection, module, key, desired_grants, purge_grants): + existing_grants = key['grants'] + + to_add, to_remove = compare_grants(existing_grants, desired_grants, purge_grants) + if not (bool(to_add) or bool(to_remove)): + return False + + key_id = key['key_arn'] + if not module.check_mode: + for grant in to_remove: + try: + connection.retire_grant(KeyId=key_id, GrantId=grant['grant_id']) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Unable to retire grant") + for grant in to_add: + grant_params = convert_grant_params(grant, key) + try: + connection.create_grant(**grant_params) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Unable to create grant") + + return True + + +def update_key(connection, module, key): + changed = False + + changed |= cancel_key_deletion(connection, module, key) + changed |= ensure_enabled_disabled(connection, module, key, module.params['enabled']) + changed |= update_alias(connection, module, key, module.params['alias']) + changed |= update_description(connection, module, key, module.params['description']) + changed |= update_tags(connection, module, key, module.params['tags'], module.params.get('purge_tags')) + changed |= update_policy(connection, module, key, module.params.get('policy')) + changed |= update_grants(connection, module, key, module.params.get('grants'), module.params.get('purge_grants')) + changed |= update_key_rotation(connection, module, key, module.params.get('enable_key_rotation')) + + # make results consistent with kms_facts before returning + result = get_key_details(connection, module, key['key_arn']) + result['changed'] = changed + return result + + +def create_key(connection, module): + key_usage = module.params.get('key_usage') + key_spec = module.params.get('key_spec') + tags_list = ansible_dict_to_boto3_tag_list( + module.params['tags'] or {}, + # KMS doesn't use "Key" and "Value" as other APIs do. + tag_name_key_name='TagKey', tag_value_key_name='TagValue' + ) + params = dict(BypassPolicyLockoutSafetyCheck=False, + Tags=tags_list, + KeyUsage=key_usage, + CustomerMasterKeySpec=key_spec, + Origin='AWS_KMS') + + if module.check_mode: + return {'changed': True} + + if module.params.get('description'): + params['Description'] = module.params['description'] + if module.params.get('policy'): + params['Policy'] = module.params['policy'] + + try: + result = connection.create_key(**params)['KeyMetadata'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to create initial key") + + key = get_key_details(connection, module, result['KeyId']) + update_alias(connection, module, key, module.params['alias']) + update_key_rotation(connection, module, key, module.params.get('enable_key_rotation')) + + ensure_enabled_disabled(connection, module, key, module.params.get('enabled')) + update_grants(connection, module, key, module.params.get('grants'), False) + + # make results consistent with kms_facts + result = get_key_details(connection, module, key['key_id']) + result['changed'] = True + return result + + +def delete_key(connection, module, key_metadata): + changed = False + + changed |= start_key_deletion(connection, module, key_metadata) + + result = get_key_details(connection, module, key_metadata['Arn']) + result['changed'] = changed + return result + + +def get_arn_from_role_name(iam, rolename): + ret = iam.get_role(RoleName=rolename) + if ret.get('Role') and ret['Role'].get('Arn'): + return ret['Role']['Arn'] + raise Exception('could not find arn for name {0}.'.format(rolename)) + + +def _clean_statement_principals(statement, clean_invalid_entries): + + # create Principal and 'AWS' so we can safely use them later. + if not isinstance(statement.get('Principal'), dict): + statement['Principal'] = dict() + + # If we have a single AWS Principal, ensure we still have a list (to manipulate) + if 'AWS' in statement['Principal'] and isinstance(statement['Principal']['AWS'], string_types): + statement['Principal']['AWS'] = [statement['Principal']['AWS']] + if not isinstance(statement['Principal'].get('AWS'), list): + statement['Principal']['AWS'] = list() + + valid_princ = re.compile('^arn:aws:(iam|sts)::') + + invalid_entries = [item for item in statement['Principal']['AWS'] if not valid_princ.match(item)] + valid_entries = [item for item in statement['Principal']['AWS'] if valid_princ.match(item)] + + if bool(invalid_entries) and clean_invalid_entries: + statement['Principal']['AWS'] = valid_entries + return True + + return False + + +def _do_statement_grant(statement, role_arn, grant_types, mode, grant_type): + + if mode == 'grant': + if grant_type in grant_types: + if role_arn not in statement['Principal']['AWS']: # needs to be added. + statement['Principal']['AWS'].append(role_arn) + return 'add' + elif role_arn in statement['Principal']['AWS']: # not one the places the role should be + statement['Principal']['AWS'].remove(role_arn) + return 'remove' + return None + + if mode == 'deny' and role_arn in statement['Principal']['AWS']: + # we don't selectively deny. that's a grant with a + # smaller list. so deny=remove all of this arn. + statement['Principal']['AWS'].remove(role_arn) + return 'remove' + return None + + +def do_policy_grant(module, kms, keyarn, role_arn, grant_types, mode='grant', dry_run=True, clean_invalid_entries=True): + ret = {} + policy = json.loads(get_key_policy_with_backoff(kms, keyarn, 'default')['Policy']) + + changes_needed = {} + assert_policy_shape(module, policy) + had_invalid_entries = False + for statement in policy['Statement']: + # We already tested that these are the only types in the statements + for grant_type in statement_label: + # Are we on this grant type's statement? + if statement['Sid'] != statement_label[grant_type]: + continue + + had_invalid_entries |= _clean_statement_principals(statement, clean_invalid_entries) + change = _do_statement_grant(statement, role_arn, grant_types, mode, grant_type) + if change: + changes_needed[grant_type] = change + + ret['changes_needed'] = changes_needed + ret['had_invalid_entries'] = had_invalid_entries + ret['new_policy'] = policy + ret['changed'] = bool(changes_needed) + + if dry_run or not ret['changed']: + return ret + + try: + policy_json_string = json.dumps(policy) + kms.put_key_policy(KeyId=keyarn, PolicyName='default', Policy=policy_json_string) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not update key_policy', new_policy=policy_json_string) + + return ret + + +def assert_policy_shape(module, policy): + '''Since the policy seems a little, uh, fragile, make sure we know approximately what we're looking at.''' + errors = [] + if policy['Version'] != "2012-10-17": + errors.append('Unknown version/date ({0}) of policy. Things are probably different than we assumed they were.'.format(policy['Version'])) + + found_statement_type = {} + for statement in policy['Statement']: + for label, sidlabel in statement_label.items(): + if statement['Sid'] == sidlabel: + found_statement_type[label] = True + + for statementtype in statement_label: + if not found_statement_type.get(statementtype): + errors.append('Policy is missing {0}.'.format(statementtype)) + + if errors: + module.fail_json(msg='Problems asserting policy shape. Cowardly refusing to modify it', errors=errors, policy=policy) + + +def canonicalize_alias_name(alias): + if alias is None: + return None + if alias.startswith('alias/'): + return alias + return 'alias/' + alias + + +def fetch_key_metadata(connection, module, key_id, alias): + # Note - fetching a key's metadata is very inconsistent shortly after any sort of update to a key has occurred. + # Combinations of manual waiters, checking expecting key values to actual key value, and static sleeps + # have all been exhausted, but none of those available options have solved the problem. + # Integration tests will wait for 10 seconds to combat this issue. + # See https://github.com/ansible-collections/community.aws/pull/1052. + + alias = canonicalize_alias_name(module.params.get('alias')) + + try: + # Fetch by key_id where possible + if key_id: + return get_kms_metadata_with_backoff(connection, key_id)['KeyMetadata'] + # Or try alias as a backup + return get_kms_metadata_with_backoff(connection, alias)['KeyMetadata'] + + except connection.exceptions.NotFoundException: + return None + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, 'Failed to fetch key metadata.') + + +def update_policy_grants(connection, module, key_metadata, mode): + iam = module.client('iam') + key_id = key_metadata['Arn'] + + if module.params.get('policy_role_name') and not module.params.get('policy_role_arn'): + module.params['policy_role_arn'] = get_arn_from_role_name(iam, module.params['policy_role_name']) + if not module.params.get('policy_role_arn'): + module.fail_json(msg='policy_role_arn or policy_role_name is required to {0}'.format(module.params['policy_mode'])) + + # check the grant types for 'grant' only. + if mode == 'grant': + for grant_type in module.params['policy_grant_types']: + if grant_type not in statement_label: + module.fail_json(msg='{0} is an unknown grant type.'.format(grant_type)) + + return do_policy_grant(module, connection, + key_id, + module.params['policy_role_arn'], + module.params['policy_grant_types'], + mode=mode, + dry_run=module.check_mode, + clean_invalid_entries=module.params['policy_clean_invalid_entries']) + + +def main(): + argument_spec = dict( + alias=dict(aliases=['key_alias']), + policy_mode=dict(aliases=['mode'], choices=['grant', 'deny'], default='grant'), + policy_role_name=dict(aliases=['role_name']), + policy_role_arn=dict(aliases=['role_arn']), + policy_grant_types=dict(aliases=['grant_types'], type='list', elements='str'), + policy_clean_invalid_entries=dict(aliases=['clean_invalid_entries'], type='bool', default=True), + pending_window=dict(aliases=['deletion_delay'], type='int'), + key_id=dict(aliases=['key_arn']), + description=dict(), + enabled=dict(type='bool', default=True), + tags=dict(type='dict', aliases=['resource_tags']), + purge_tags=dict(type='bool'), + grants=dict(type='list', default=[], elements='dict'), + policy=dict(type='json'), + purge_grants=dict(type='bool', default=False), + state=dict(default='present', choices=['present', 'absent']), + enable_key_rotation=(dict(type='bool')), + key_spec=dict(type='str', default='SYMMETRIC_DEFAULT', aliases=['customer_master_key_spec'], + choices=['SYMMETRIC_DEFAULT', 'RSA_2048', 'RSA_3072', 'RSA_4096', 'ECC_NIST_P256', 'ECC_NIST_P384', 'ECC_NIST_P521', 'ECC_SECG_P256K1']), + key_usage=dict(type='str', default='ENCRYPT_DECRYPT', choices=['ENCRYPT_DECRYPT', 'SIGN_VERIFY']), + ) + + module = AnsibleAWSModule( + supports_check_mode=True, + argument_spec=argument_spec, + required_one_of=[['alias', 'key_id']], + ) + + mode = module.params['policy_mode'] + + kms = module.client('kms') + + if module.params.get('purge_tags') is None: + module.deprecate( + 'The purge_tags parameter currently defaults to False.' + ' For consistency across the collection, this default value' + ' will change to True in release 5.0.0.', + version='5.0.0', collection_name='community.aws') + module.params['purge_tags'] = False + + module.deprecate("The 'policies' return key is deprecated and will be replaced by 'key_policies'. Both values are returned for now.", + date='2024-05-01', collection_name='community.aws') + + key_metadata = fetch_key_metadata(kms, module, module.params.get('key_id'), module.params.get('alias')) + # We can't create keys with a specific ID, if we can't access the key we'll have to fail + if module.params.get('state') == 'present' and module.params.get('key_id') and not key_metadata: + module.fail_json(msg="Could not find key with id {0} to update".format(module.params.get('key_id'))) + + if module.params.get('policy_grant_types') or mode == 'deny': + module.deprecate('Managing the KMS IAM Policy via policy_mode and policy_grant_types is fragile' + ' and has been deprecated in favour of the policy option.', date='2021-12-01', collection_name='community.aws') + result = update_policy_grants(kms, module, key_metadata, mode) + module.exit_json(**result) + + if module.params.get('state') == 'absent': + if key_metadata is None: + module.exit_json(changed=False) + result = delete_key(kms, module, key_metadata) + module.exit_json(**result) + + if key_metadata: + key_details = get_key_details(kms, module, key_metadata['Arn']) + result = update_key(kms, module, key_details) + module.exit_json(**result) + + result = create_key(kms, module) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/kms_key_info.py b/plugins/modules/kms_key_info.py new file mode 100644 index 00000000000..b9ecf80fcc3 --- /dev/null +++ b/plugins/modules/kms_key_info.py @@ -0,0 +1,515 @@ +#!/usr/bin/python +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: kms_key_info +version_added: 1.0.0 +short_description: Gather information about AWS KMS keys +description: + - Gather information about AWS KMS keys including tags and grants. + - Prior to release 5.0.0 this module was called C(community.aws.aws_kms_info). + The usage did not change. +author: + - "Will Thames (@willthames)" +options: + alias: + description: + - Alias for key. + - Mutually exclusive with I(key_id) and I(filters). + required: false + aliases: + - key_alias + type: str + version_added: 1.4.0 + key_id: + description: + - Key ID or ARN of the key. + - Mutually exclusive with I(alias) and I(filters). + required: false + aliases: + - key_arn + type: str + version_added: 1.4.0 + filters: + description: + - A dict of filters to apply. Each dict item consists of a filter key and a filter value. + The filters aren't natively supported by boto3, but are supported to provide similar + functionality to other modules. Standard tag filters (C(tag-key), C(tag-value) and + C(tag:tagName)) are available, as are C(key-id) and C(alias) + - Mutually exclusive with I(alias) and I(key_id). + type: dict + pending_deletion: + description: Whether to get full details (tags, grants etc.) of keys pending deletion. + default: False + type: bool +extends_documentation_fragment: + - amazon.aws.aws + - amazon.aws.ec2 +''' + +EXAMPLES = r''' +# Note: These examples do not set authentication details, see the AWS Guide for details. + +# Gather information about all KMS keys +- community.aws.kms_key_info: + +# Gather information about all keys with a Name tag +- community.aws.kms_key_info: + filters: + tag-key: Name + +# Gather information about all keys with a specific name +- community.aws.kms_key_info: + filters: + "tag:Name": Example +''' + +RETURN = r''' +kms_keys: + description: List of keys. + type: complex + returned: always + contains: + key_id: + description: ID of key. + type: str + returned: always + sample: abcd1234-abcd-1234-5678-ef1234567890 + key_arn: + description: ARN of key. + type: str + returned: always + sample: arn:aws:kms:ap-southeast-2:123456789012:key/abcd1234-abcd-1234-5678-ef1234567890 + key_state: + description: + - The state of the key. + - Will be one of C('Creating'), C('Enabled'), C('Disabled'), C('PendingDeletion'), C('PendingImport'), + C('PendingReplicaDeletion'), C('Unavailable'), or C('Updating'). + type: str + returned: always + sample: PendingDeletion + key_usage: + description: The cryptographic operations for which you can use the key. + type: str + returned: always + sample: ENCRYPT_DECRYPT + origin: + description: The source of the key's key material. When this value is C(AWS_KMS), + AWS KMS created the key material. When this value is C(EXTERNAL), the + key material was imported or the CMK lacks key material. + type: str + returned: always + sample: AWS_KMS + aws_account_id: + description: The AWS Account ID that the key belongs to. + type: str + returned: always + sample: 1234567890123 + creation_date: + description: Date and time of creation of the key. + type: str + returned: always + sample: "2017-04-18T15:12:08.551000+10:00" + deletion_date: + description: Date and time after which KMS deletes this KMS key. + type: str + returned: when key_state is PendingDeletion + sample: "2017-04-18T15:12:08.551000+10:00" + version_added: 3.3.0 + description: + description: Description of the key. + type: str + returned: always + sample: "My Key for Protecting important stuff" + enabled: + description: Whether the key is enabled. True if I(key_state) is C(Enabled). + type: bool + returned: always + sample: false + enable_key_rotation: + description: Whether the automatic annual key rotation is enabled. Returns None if key rotation status can't be determined. + type: bool + returned: always + sample: false + aliases: + description: list of aliases associated with the key. + type: list + returned: always + sample: + - aws/acm + - aws/ebs + tags: + description: Dictionary of tags applied to the key. Empty when access is denied even if there are tags. + type: dict + returned: always + sample: + Name: myKey + Purpose: protecting_stuff + policies: + description: List of policy documents for the key. Empty when access is denied even if there are policies. + type: list + returned: always + elements: str + sample: + Version: "2012-10-17" + Id: "auto-ebs-2" + Statement: + - Sid: "Allow access through EBS for all principals in the account that are authorized to use EBS" + Effect: "Allow" + Principal: + AWS: "*" + Action: + - "kms:Encrypt" + - "kms:Decrypt" + - "kms:ReEncrypt*" + - "kms:GenerateDataKey*" + - "kms:CreateGrant" + - "kms:DescribeKey" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: "111111111111" + kms:ViaService: "ec2.ap-southeast-2.amazonaws.com" + - Sid: "Allow direct access to key metadata to the account" + Effect: "Allow" + Principal: + AWS: "arn:aws:iam::111111111111:root" + Action: + - "kms:Describe*" + - "kms:Get*" + - "kms:List*" + - "kms:RevokeGrant" + Resource: "*" + key_policies: + description: List of policy documents for the key. Empty when access is denied even if there are policies. + type: list + returned: always + elements: dict + sample: + Version: "2012-10-17" + Id: "auto-ebs-2" + Statement: + - Sid: "Allow access through EBS for all principals in the account that are authorized to use EBS" + Effect: "Allow" + Principal: + AWS: "*" + Action: + - "kms:Encrypt" + - "kms:Decrypt" + - "kms:ReEncrypt*" + - "kms:GenerateDataKey*" + - "kms:CreateGrant" + - "kms:DescribeKey" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: "111111111111" + kms:ViaService: "ec2.ap-southeast-2.amazonaws.com" + - Sid: "Allow direct access to key metadata to the account" + Effect: "Allow" + Principal: + AWS: "arn:aws:iam::111111111111:root" + Action: + - "kms:Describe*" + - "kms:Get*" + - "kms:List*" + - "kms:RevokeGrant" + Resource: "*" + version_added: 3.3.0 + grants: + description: List of grants associated with a key. + type: list + elements: dict + returned: always + contains: + constraints: + description: Constraints on the encryption context that the grant allows. + See U(https://docs.aws.amazon.com/kms/latest/APIReference/API_GrantConstraints.html) for further details + type: dict + returned: always + sample: + encryption_context_equals: + "aws:lambda:_function_arn": "arn:aws:lambda:ap-southeast-2:012345678912:function:xyz" + creation_date: + description: Date of creation of the grant. + type: str + returned: always + sample: "2017-04-18T15:12:08+10:00" + grant_id: + description: The unique ID for the grant. + type: str + returned: always + sample: abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234 + grantee_principal: + description: The principal that receives the grant's permissions. + type: str + returned: always + sample: arn:aws:sts::0123456789012:assumed-role/lambda_xyz/xyz + issuing_account: + description: The AWS account under which the grant was issued. + type: str + returned: always + sample: arn:aws:iam::01234567890:root + key_id: + description: The key ARN to which the grant applies. + type: str + returned: always + sample: arn:aws:kms:ap-southeast-2:123456789012:key/abcd1234-abcd-1234-5678-ef1234567890 + name: + description: The friendly name that identifies the grant. + type: str + returned: always + sample: xyz + operations: + description: The list of operations permitted by the grant. + type: list + returned: always + sample: + - Decrypt + - RetireGrant + retiring_principal: + description: The principal that can retire the grant. + type: str + returned: always + sample: arn:aws:sts::0123456789012:assumed-role/lambda_xyz/xyz +''' + +import json + +try: + import botocore +except ImportError: + pass # Handled by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto3_tag_list_to_ansible_dict + +# Caching lookup for aliases +_aliases = dict() + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_keys_with_backoff(connection): + paginator = connection.get_paginator('list_keys') + return paginator.paginate().build_full_result() + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_aliases_with_backoff(connection): + paginator = connection.get_paginator('list_aliases') + return paginator.paginate().build_full_result() + + +def get_kms_aliases_lookup(connection): + if not _aliases: + for alias in get_kms_aliases_with_backoff(connection)['Aliases']: + # Not all aliases are actually associated with a key + if 'TargetKeyId' in alias: + # strip off leading 'alias/' and add it to key's aliases + if alias['TargetKeyId'] in _aliases: + _aliases[alias['TargetKeyId']].append(alias['AliasName'][6:]) + else: + _aliases[alias['TargetKeyId']] = [alias['AliasName'][6:]] + return _aliases + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_tags_with_backoff(connection, key_id, **kwargs): + return connection.list_resource_tags(KeyId=key_id, **kwargs) + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_grants_with_backoff(connection, key_id, **kwargs): + params = dict(KeyId=key_id) + if kwargs.get('tokens'): + params['GrantTokens'] = kwargs['tokens'] + paginator = connection.get_paginator('list_grants') + return paginator.paginate(**params).build_full_result() + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_kms_metadata_with_backoff(connection, key_id): + return connection.describe_key(KeyId=key_id) + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def list_key_policies_with_backoff(connection, key_id): + paginator = connection.get_paginator('list_key_policies') + return paginator.paginate(KeyId=key_id).build_full_result() + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_key_policy_with_backoff(connection, key_id, policy_name): + return connection.get_key_policy(KeyId=key_id, PolicyName=policy_name) + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def get_enable_key_rotation_with_backoff(connection, key_id): + try: + current_rotation_status = connection.get_key_rotation_status(KeyId=key_id) + except is_boto3_error_code(['AccessDeniedException', 'UnsupportedOperationException']) as e: + return None + + return current_rotation_status.get('KeyRotationEnabled') + + +def canonicalize_alias_name(alias): + if alias is None: + return None + if alias.startswith('alias/'): + return alias + return 'alias/' + alias + + +def get_kms_tags(connection, module, key_id): + # Handle pagination here as list_resource_tags does not have + # a paginator + kwargs = {} + tags = [] + more = True + while more: + try: + tag_response = get_kms_tags_with_backoff(connection, key_id, **kwargs) + tags.extend(tag_response['Tags']) + except is_boto3_error_code('AccessDeniedException'): + tag_response = {} + except botocore.exceptions.ClientError as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="Failed to obtain key tags") + if tag_response.get('NextMarker'): + kwargs['Marker'] = tag_response['NextMarker'] + else: + more = False + return tags + + +def get_kms_policies(connection, module, key_id): + try: + policies = list_key_policies_with_backoff(connection, key_id)['PolicyNames'] + return [get_key_policy_with_backoff(connection, key_id, policy)['Policy'] for + policy in policies] + except is_boto3_error_code('AccessDeniedException'): + return [] + except botocore.exceptions.ClientError as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="Failed to obtain key policies") + + +def key_matches_filter(key, filtr): + if filtr[0] == 'key-id': + return filtr[1] == key['key_id'] + if filtr[0] == 'tag-key': + return filtr[1] in key['tags'] + if filtr[0] == 'tag-value': + return filtr[1] in key['tags'].values() + if filtr[0] == 'alias': + return filtr[1] in key['aliases'] + if filtr[0].startswith('tag:'): + tag_key = filtr[0][4:] + if tag_key not in key['tags']: + return False + return key['tags'].get(tag_key) == filtr[1] + + +def key_matches_filters(key, filters): + if not filters: + return True + else: + return all(key_matches_filter(key, filtr) for filtr in filters.items()) + + +def get_key_details(connection, module, key_id, tokens=None): + if not tokens: + tokens = [] + try: + result = get_kms_metadata_with_backoff(connection, key_id)['KeyMetadata'] + # Make sure we have the canonical ARN, we might have been passed an alias + key_id = result['Arn'] + except is_boto3_error_code('NotFoundException'): + return None + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="Failed to obtain key metadata") + result['KeyArn'] = result.pop('Arn') + + try: + aliases = get_kms_aliases_lookup(connection) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to obtain aliases") + # We can only get aliases for our own account, so we don't need the full ARN + result['aliases'] = aliases.get(result['KeyId'], []) + result['enable_key_rotation'] = get_enable_key_rotation_with_backoff(connection, key_id) + + if module.params.get('pending_deletion'): + return camel_dict_to_snake_dict(result) + + try: + result['grants'] = get_kms_grants_with_backoff(connection, key_id, tokens=tokens)['Grants'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to obtain key grants") + tags = get_kms_tags(connection, module, key_id) + + result = camel_dict_to_snake_dict(result) + result['tags'] = boto3_tag_list_to_ansible_dict(tags, 'TagKey', 'TagValue') + result['policies'] = get_kms_policies(connection, module, key_id) + result['key_policies'] = [json.loads(policy) for policy in result['policies']] + return result + + +def get_kms_info(connection, module): + if module.params.get('key_id'): + key_id = module.params.get('key_id') + details = get_key_details(connection, module, key_id) + if details: + return [details] + return [] + elif module.params.get('alias'): + alias = canonicalize_alias_name(module.params.get('alias')) + details = get_key_details(connection, module, alias) + if details: + return [details] + return [] + else: + try: + keys = get_kms_keys_with_backoff(connection)['Keys'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to obtain keys") + return [get_key_details(connection, module, key['KeyId']) for key in keys] + + +def main(): + argument_spec = dict( + alias=dict(aliases=['key_alias']), + key_id=dict(aliases=['key_arn']), + filters=dict(type='dict'), + pending_deletion=dict(type='bool', default=False), + ) + + module = AnsibleAWSModule(argument_spec=argument_spec, + mutually_exclusive=[['alias', 'filters', 'key_id']], + supports_check_mode=True) + + try: + connection = module.client('kms') + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Failed to connect to AWS') + + module.deprecate("The 'policies' return key is deprecated and will be replaced by 'key_policies'. Both values are returned for now.", + date='2024-05-01', collection_name='community.aws') + + all_keys = get_kms_info(connection, module) + filtered_keys = [key for key in all_keys if key_matches_filters(key, module.params['filters'])] + ret_params = dict(kms_keys=filtered_keys) + + module.exit_json(**ret_params) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/kms_key/aliases b/tests/integration/targets/kms_key/aliases new file mode 100644 index 00000000000..967fd7fe094 --- /dev/null +++ b/tests/integration/targets/kms_key/aliases @@ -0,0 +1,8 @@ +# Various race conditions - likely needs waiters +# https://github.com/ansible-collections/community.aws/issues/433 +# No KMS supported waiters, and manual waiting for updates didn't fix the issue either. +# Issue likely from AWS side - added waits on updates in integration tests to workaround this. + +cloud/aws + +kms_key_info diff --git a/tests/integration/targets/kms_key/inventory b/tests/integration/targets/kms_key/inventory new file mode 100644 index 00000000000..14abc6eb267 --- /dev/null +++ b/tests/integration/targets/kms_key/inventory @@ -0,0 +1,10 @@ +# inventory names shortened down to fit resource name length limits +[tests] +states +grants +modify +tagging + +[all:vars] +ansible_connection=local +ansible_python_interpreter="{{ ansible_playbook_python }}" diff --git a/tests/integration/targets/kms_key/main.yml b/tests/integration/targets/kms_key/main.yml new file mode 100644 index 00000000000..3cbf14f7bf7 --- /dev/null +++ b/tests/integration/targets/kms_key/main.yml @@ -0,0 +1,10 @@ +--- +# Beware: most of our tests here are run in parallel. +# To add new tests you'll need to add a new host to the inventory and a matching +# '{{ inventory_hostname }}'.yml file in roles/aws_kms/tasks/ + +- hosts: all + gather_facts: no + strategy: free + roles: + - aws_kms diff --git a/tests/integration/targets/kms_key/meta/main.yml b/tests/integration/targets/kms_key/meta/main.yml new file mode 100644 index 00000000000..32cf5dda7ed --- /dev/null +++ b/tests/integration/targets/kms_key/meta/main.yml @@ -0,0 +1 @@ +dependencies: [] diff --git a/tests/integration/targets/kms_key/roles/aws_kms/defaults/main.yml b/tests/integration/targets/kms_key/roles/aws_kms/defaults/main.yml new file mode 100644 index 00000000000..3e5e6c895ee --- /dev/null +++ b/tests/integration/targets/kms_key/roles/aws_kms/defaults/main.yml @@ -0,0 +1,2 @@ +--- +kms_key_alias: "ansible-test-{{ inventory_hostname | replace('_','-') }}{{ tiny_prefix }}" diff --git a/tests/integration/targets/kms_key/roles/aws_kms/tasks/main.yml b/tests/integration/targets/kms_key/roles/aws_kms/tasks/main.yml new file mode 100644 index 00000000000..81f3e009899 --- /dev/null +++ b/tests/integration/targets/kms_key/roles/aws_kms/tasks/main.yml @@ -0,0 +1,13 @@ +--- +- name: 'aws_kms integration tests' + collections: + - amazon.aws + - community.aws + module_defaults: + group/aws: + aws_access_key: '{{ aws_access_key }}' + aws_secret_key: '{{ aws_secret_key }}' + security_token: '{{ security_token | default(omit) }}' + region: '{{ aws_region }}' + block: + - include: './test_{{ inventory_hostname }}.yml' diff --git a/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_grants.yml b/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_grants.yml new file mode 100644 index 00000000000..d86309e41d9 --- /dev/null +++ b/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_grants.yml @@ -0,0 +1,368 @@ +- block: + # ============================================================ + # PREPARATION + # + # Get some information about who we are before starting our tests + # we'll need this as soon as we start working on the policies + - name: get ARN of calling user + aws_caller_info: + register: aws_caller_info + + # IAM Roles completes before the Role is fully instantiated, create it here + # to ensure it exists when we need it for updating the policies + - name: create an IAM role that can do nothing + iam_role: + name: '{{ kms_key_alias }}' + state: present + assume_role_policy_document: '{"Version": "2012-10-17", "Statement": {"Action": "sts:AssumeRole", "Principal": {"Service": "ec2.amazonaws.com"}, "Effect": "Deny"} }' + register: iam_role_result + + # ============================================================ + # TESTS + # Note - there are waits placed after each action to account for inconsistencies in what + # is being returned when fetching key metadata. + # Combinations of manual waiters, checking expecting key values to actual key value, and static sleeps + # have all been tried, but none of those available options have solved the problem. + + - name: create a key + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + enable_key_rotation: no + register: key + + - name: assert that state is enabled + assert: + that: + - key is changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + # ------------------------------------------------------------------------------------------ + + - name: Add grant - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + purge_grants: yes + grants: + - name: test_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ aws_caller_info.arn }}" + constraints: + encryption_context_equals: + environment: test + application: testapp + operations: + - Decrypt + - RetireGrant + register: key + check_mode: yes + + - name: assert grant would have been added + assert: + that: + - key.changed + + - name: Add grant + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + purge_grants: yes + grants: + - name: test_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ aws_caller_info.arn }}" + constraints: + encryption_context_equals: + environment: test + application: testapp + operations: + - Decrypt + - RetireGrant + register: key + + - name: assert grant added + assert: + that: + - key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 1 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Add grant (idempotence) - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + purge_grants: yes + grants: + - name: test_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ aws_caller_info.arn }}" + constraints: + encryption_context_equals: + environment: test + application: testapp + operations: + - Decrypt + - RetireGrant + register: key + check_mode: yes + + - assert: + that: + - not key.changed + + - name: Add grant (idempotence) + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + purge_grants: yes + grants: + - name: test_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ aws_caller_info.arn }}" + constraints: + encryption_context_equals: + environment: test + application: testapp + operations: + - Decrypt + - RetireGrant + register: key + + - assert: + that: + - not key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 1 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + - name: Add a second grant + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + grants: + - name: another_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ aws_caller_info.arn }}" + constraints: + encryption_context_equals: + Environment: second + Application: anotherapp + operations: + - Decrypt + - RetireGrant + register: key + + - name: Assert grant added + assert: + that: + - key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 2 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Add a second grant again + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + grants: + - name: another_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ aws_caller_info.arn }}" + constraints: + encryption_context_equals: + Environment: second + Application: anotherapp + operations: + - Decrypt + - RetireGrant + register: key + + - name: Assert grant added + assert: + that: + - not key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 2 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + - name: Update the grants with purge_grants set + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + purge_grants: yes + grants: + - name: third_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ aws_caller_info.arn }}" + constraints: + encryption_context_equals: + environment: third + application: onemoreapp + operations: + - Decrypt + - RetireGrant + register: key + + - name: Assert grants replaced + assert: + that: + - key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 1 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + - name: Update third grant to change encryption context equals to subset + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + grants: + - name: third_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ aws_caller_info.arn }}" + constraints: + encryption_context_subset: + environment: third + application: onemoreapp + operations: + - Decrypt + - RetireGrant + register: key + + - name: Assert grants replaced + assert: + that: + - key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 1 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + - "'encryption_context_equals' not in key.grants[0].constraints" + - "'encryption_context_subset' in key.grants[0].constraints" + + always: + # ============================================================ + # CLEAN-UP + - name: finish off by deleting keys + aws_kms: + state: absent + alias: "{{ kms_key_alias }}" + pending_window: 7 + ignore_errors: True + + - name: remove the IAM role + iam_role: + name: '{{ kms_key_alias }}' + state: absent + ignore_errors: True diff --git a/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_modify.yml b/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_modify.yml new file mode 100644 index 00000000000..50ebec718a6 --- /dev/null +++ b/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_modify.yml @@ -0,0 +1,333 @@ +- block: + # ============================================================ + # PREPARATION + # + # Get some information about who we are before starting our tests + # we'll need this as soon as we start working on the policies + - name: get ARN of calling user + aws_caller_info: + register: aws_caller_info + + # IAM Roles completes before the Role is fully instantiated, create it here + # to ensure it exists when we need it for updating the policies + - name: create an IAM role that can do nothing + iam_role: + name: '{{ kms_key_alias }}' + state: present + assume_role_policy_document: '{"Version": "2012-10-17", "Statement": {"Action": "sts:AssumeRole", "Principal": {"Service": "ec2.amazonaws.com"}, "Effect": "Deny"} }' + register: iam_role_result + + # ============================================================ + # TESTS + # Note - there are waits placed after each action to account for inconsistencies in what + # is being returned when fetching key metadata. + # Combinations of manual waiters, checking expecting key values to actual key value, and static sleeps + # have all been tried, but none of those available options have solved the problem. + + - name: create a key + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + enable_key_rotation: no + register: key + + - name: assert that state is enabled + assert: + that: + - key is changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + # ------------------------------------------------------------------------------------------ + + - name: Save IDs for later + set_fact: + kms_key_id: '{{ key.key_id }}' + kms_key_arn: '{{ key.key_arn }}' + + - name: find facts about the key (by ID) + aws_kms_info: + key_id: '{{ kms_key_id }}' + register: new_key + + - name: check that a key was found + assert: + that: + - '"key_id" in new_key.kms_keys[0]' + - new_key.kms_keys[0].key_id | length >= 36 + - not new_key.kms_keys[0].key_id.startswith("arn:aws") + - '"key_arn" in new_key.kms_keys[0]' + - new_key.kms_keys[0].key_arn.endswith(new_key.kms_keys[0].key_id) + - new_key.kms_keys[0].key_arn.startswith("arn:aws") + - new_key.kms_keys[0].key_state == "Enabled" + - new_key.kms_keys[0].enabled == True + - new_key.kms_keys[0].tags | length == 1 + - new_key.kms_keys[0].tags['Hello'] == 'World' + - new_key.kms_keys[0].enable_key_rotation == False + - new_key.kms_keys[0].key_usage == 'ENCRYPT_DECRYPT' + - new_key.kms_keys[0].customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - new_key.kms_keys[0].grants | length == 0 + - new_key.kms_keys[0].key_policies | length == 1 + - new_key.kms_keys[0].key_policies[0].Id == 'key-default-1' + - new_key.kms_keys[0].description == '' + + - name: Update policy - check mode + aws_kms: + key_id: '{{ kms_key_id }}' + policy: "{{ lookup('template', 'console-policy.j2') }}" + register: key + check_mode: yes + + - assert: + that: + - key is changed + + - name: Update policy + aws_kms: + key_id: '{{ kms_key_id }}' + policy: "{{ lookup('template', 'console-policy.j2') }}" + register: key + + - name: Policy should have been changed + assert: + that: + - key is changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-consolepolicy-3' + - key.description == '' + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Update policy (idempotence) - check mode + aws_kms: + alias: "alias/{{ kms_key_alias }}" + policy: "{{ lookup('template', 'console-policy.j2') }}" + register: key + check_mode: yes + + - assert: + that: + - not key.changed + + - name: Update policy (idempotence) + aws_kms: + alias: "alias/{{ kms_key_alias }}" + policy: "{{ lookup('template', 'console-policy.j2') }}" + register: key + + - assert: + that: + - not key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-consolepolicy-3' + - key.description == '' + + # ------------------------------------------------------------------------------------------ + + - name: Update description - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + description: test key for testing + register: key + check_mode: yes + + - assert: + that: + - key.changed + + - name: Update description + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + description: test key for testing + register: key + + - assert: + that: + - key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-consolepolicy-3' + - key.description == 'test key for testing' + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Update description (idempotence) - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + description: test key for testing + register: key + check_mode: yes + + - assert: + that: + - not key.changed + + - name: Update description (idempotence) + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + description: test key for testing + register: key + + - assert: + that: + - not key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-consolepolicy-3' + - key.description == 'test key for testing' + + # ------------------------------------------------------------------------------------------ + + - name: grant user-style access to production secrets + aws_kms: + mode: grant + alias: "alias/{{ kms_key_alias }}" + role_name: '{{ kms_key_alias }}' + grant_types: "role,role grant" + register: new_key + + - assert: + that: + - new_key.changed + - "'changes_needed' in new_key" + + - name: remove access to production secrets from role + aws_kms: + mode: deny + alias: "alias/{{ kms_key_alias }}" + role_arn: "{{ iam_role_result.iam_role.arn }}" + register: new_key + + - assert: + that: + - new_key.changed + - "'changes_needed' in new_key" + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + # ------------------------------------------------------------------------------------------ + + - name: update policy to remove access to key rotation status + aws_kms: + alias: 'alias/{{ kms_key_alias }}' + policy: "{{ lookup('template', 'console-policy-no-key-rotation.j2') }}" + register: key + + - assert: + that: + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation is none + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-consolepolicy-3' + - key.description == 'test key for testing' + - "'Disable access to key rotation status' in {{ key.key_policies[0].Statement | map(attribute='Sid') }}" + + always: + # ============================================================ + # CLEAN-UP + - name: finish off by deleting keys + aws_kms: + state: absent + alias: "{{ kms_key_alias }}" + pending_window: 7 + ignore_errors: True + + - name: remove the IAM role + iam_role: + name: '{{ kms_key_alias }}' + state: absent + ignore_errors: True diff --git a/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_states.yml b/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_states.yml new file mode 100644 index 00000000000..31e91d5921d --- /dev/null +++ b/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_states.yml @@ -0,0 +1,557 @@ +- block: + # ============================================================ + # PREPARATION + # + # Get some information about who we are before starting our tests + # we'll need this as soon as we start working on the policies + - name: get ARN of calling user + aws_caller_info: + register: aws_caller_info + + # ============================================================ + # TESTS + # Note - there are waits placed after each action to account for inconsistencies in what + # is being returned when fetching key metadata. + # Combinations of manual waiters, checking expecting key values to actual key value, and static sleeps + # have all been tried, but none of those available options have solved the problem. + + - name: See whether key exists and its current state + aws_kms_info: + alias: '{{ kms_key_alias }}' + + - name: create a key - check mode + aws_kms: + alias: '{{ kms_key_alias }}-check' + tags: + Hello: World + state: present + enabled: yes + register: key_check + check_mode: yes + + - name: find facts about the check mode key + aws_kms_info: + alias: '{{ kms_key_alias }}-check' + register: check_key + + - name: ensure that check mode worked as expected + assert: + that: + - check_key.kms_keys | length == 0 + - key_check is changed + + - name: create a key + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + enable_key_rotation: no + register: key + + - name: assert that state is enabled + assert: + that: + - key is changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: create a key (idempotence) - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + register: key + check_mode: yes + + - assert: + that: + - key is not changed + + - name: create a key (idempotence) + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + register: key + check_mode: yes + + - assert: + that: + - key is not changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + # ------------------------------------------------------------------------------------------ + + - name: Save IDs for later + set_fact: + kms_key_id: '{{ key.key_id }}' + kms_key_arn: '{{ key.key_arn }}' + + - name: Enable key rotation - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + enable_key_rotation: yes + register: key + check_mode: yes + + - assert: + that: + - key.changed + + - name: Enable key rotation + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + enable_key_rotation: yes + register: key + + - name: assert that key rotation is enabled + assert: + that: + - key is changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == True + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Enable key rotation (idempotence) - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + enable_key_rotation: yes + register: key + check_mode: yes + + - assert: + that: + - not key.changed + + - name: Enable key rotation (idempotence) + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + enable_key_rotation: yes + register: key + + - assert: + that: + - not key is changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == True + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + # ------------------------------------------------------------------------------------------ + + - name: Disable key - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + enabled: no + register: key + check_mode: yes + + - assert: + that: + - key.changed + + - name: Disable key + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + enabled: no + register: key + + - name: assert that state is disabled + assert: + that: + - key is changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Disabled" + - key.enabled == False + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == True + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Disable key (idempotence) - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + enabled: no + register: key + check_mode: yes + + - assert: + that: + - not key.changed + + - name: Disable key (idempotence) + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + enabled: no + register: key + + - assert: + that: + - not key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Disabled" + - key.enabled == False + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == True + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + # ------------------------------------------------------------------------------------------ + + - name: Delete key - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: absent + register: key + check_mode: yes + + - assert: + that: + - key is changed + + - name: Delete key + aws_kms: + alias: '{{ kms_key_alias }}' + state: absent + register: key + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Assert that state is pending deletion + vars: + now_time: '{{ lookup("pipe", "date -u +%Y-%m-%d\ %H:%M:%S") }}' + deletion_time: '{{ key.deletion_date[:19] | to_datetime("%Y-%m-%dT%H:%M:%S") }}' + assert: + that: + - key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "PendingDeletion" + - key.enabled == False + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == False + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + # Times won't be perfect, allow a 24 hour window + - (( deletion_time | to_datetime ) - ( now_time | to_datetime )).days <= 30 + - (( deletion_time | to_datetime ) - ( now_time | to_datetime )).days >= 29 + + - name: Delete key (idempotence) - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: absent + register: key + check_mode: yes + + - assert: + that: + - not key.changed + + - name: Delete key (idempotence) + aws_kms: + alias: '{{ kms_key_alias }}' + state: absent + register: key + + - vars: + now_time: '{{ lookup("pipe", "date -u +%Y-%m-%d\ %H:%M:%S") }}' + deletion_time: '{{ key.deletion_date[:19] | to_datetime("%Y-%m-%dT%H:%M:%S") }}' + assert: + that: + - not key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "PendingDeletion" + - key.enabled == False + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == False + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + # Times won't be perfect, allow a 24 hour window + - (( deletion_time | to_datetime ) - ( now_time | to_datetime )).days <= 30 + - (( deletion_time | to_datetime ) - ( now_time | to_datetime )).days >= 29 + + # ------------------------------------------------------------------------------------------ + + - name: Cancel key deletion - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + register: key + check_mode: yes + + - assert: + that: + - key.changed + + - name: Cancel key deletion + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + register: key + + - assert: + that: + - key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == True + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + - "'deletion_date' not in key" + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Cancel key deletion (idempotence) - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + register: key + check_mode: yes + + - assert: + that: + - not key.changed + + - name: Cancel key deletion (idempotence) + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + register: key + + - assert: + that: + - not key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == True + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + - "'deletion_date' not in key" + + # ------------------------------------------------------------------------------------------ + + - name: delete the key with a specific deletion window + aws_kms: + alias: '{{ kms_key_alias }}' + state: absent + pending_window: 7 + register: delete_kms + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: assert that state is pending deletion + vars: + now_time: '{{ lookup("pipe", "date -u +%Y-%m-%d\ %H:%M:%S") }}' + deletion_time: '{{ delete_kms.deletion_date[:19] | to_datetime("%Y-%m-%dT%H:%M:%S") }}' + assert: + that: + - delete_kms.key_state == "PendingDeletion" + - delete_kms.changed + # Times won't be perfect, allow a 24 hour window + - (( deletion_time | to_datetime ) - ( now_time | to_datetime )).days <= 7 + - (( deletion_time | to_datetime ) - ( now_time | to_datetime )).days >= 6 + + # ============================================================ + # test different key usage and specs + - name: create kms key with different specs + aws_kms: + alias: '{{ kms_key_alias }}-diff-spec-usage' + purge_grants: yes + key_spec: ECC_NIST_P256 + key_usage: SIGN_VERIFY + register: create_diff_kms + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: verify different specs on kms key + assert: + that: + - '"key_id" in create_diff_kms' + - create_diff_kms.key_id | length >= 36 + - not create_diff_kms.key_id.startswith("arn:aws") + - '"key_arn" in create_diff_kms' + - create_diff_kms.key_arn.endswith(create_diff_kms.key_id) + - create_diff_kms.key_arn.startswith("arn:aws") + - create_diff_kms.key_usage == 'SIGN_VERIFY' + - create_diff_kms.customer_master_key_spec == 'ECC_NIST_P256' + + always: + # ============================================================ + # CLEAN-UP + - name: finish off by deleting keys + aws_kms: + state: absent + alias: "{{ item }}" + pending_window: 7 + ignore_errors: True + loop: + - "{{ kms_key_alias }}" + - "{{ kms_key_alias }}-diff-spec-usage" + - "{{ kms_key_alias }}-check" diff --git a/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_tagging.yml b/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_tagging.yml new file mode 100644 index 00000000000..60cb0dd837e --- /dev/null +++ b/tests/integration/targets/kms_key/roles/aws_kms/tasks/test_tagging.yml @@ -0,0 +1,202 @@ +- block: + # ============================================================ + # PREPARATION + # + # Get some information about who we are before starting our tests + # we'll need this as soon as we start working on the policies + - name: get ARN of calling user + aws_caller_info: + register: aws_caller_info + + # ============================================================ + # TESTS + # Note - there are waits placed after each action to account for inconsistencies in what + # is being returned when fetching key metadata. + # Combinations of manual waiters, checking expecting key values to actual key value, and static sleeps + # have all been tried, but none of those available options have solved the problem. + + - name: create a key + aws_kms: + alias: '{{ kms_key_alias }}' + tags: + Hello: World + state: present + enabled: yes + enable_key_rotation: no + register: key + + - name: assert that state is enabled + assert: + that: + - key is changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 1 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + + # ------------------------------------------------------------------------------------------ + + - name: Tag encryption key + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + tags: + tag_one: tag_one + tag_two: tag_two + register: key + + - name: Assert tags added + assert: + that: + - key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 3 + - key.tags['Hello'] == 'World' + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + - "'tag_one' in key.tags" + - "'tag_two' in key.tags" + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Modify tags - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + purge_tags: yes + tags: + tag_two: tag_two_updated + Tag Three: '{{ resource_prefix }}' + register: key + check_mode: yes + + - assert: + that: + - key.changed + + - name: Modify tags + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + purge_tags: yes + tags: + tag_two: tag_two_updated + Tag Three: '{{ resource_prefix }}' + register: key + + - name: Assert tags correctly changed + assert: + that: + - key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 2 + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + - "'tag_one' not in key.tags" + - "'tag_two' in key.tags" + - "key.tags.tag_two == 'tag_two_updated'" + - "'Tag Three' in key.tags" + - "key.tags['Tag Three'] == resource_prefix" + + - name: Sleep to wait for updates to propagate + wait_for: + timeout: 45 + + - name: Modify tags (idempotence) - check mode + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + purge_tags: yes + tags: + tag_two: tag_two_updated + Tag Three: '{{ resource_prefix }}' + register: key + check_mode: yes + + - assert: + that: + - not key.changed + + - name: Modify tags (idempotence) + aws_kms: + alias: '{{ kms_key_alias }}' + state: present + purge_tags: yes + tags: + tag_two: tag_two_updated + Tag Three: '{{ resource_prefix }}' + register: key + + - assert: + that: + - not key.changed + - '"key_id" in key' + - key.key_id | length >= 36 + - not key.key_id.startswith("arn:aws") + - '"key_arn" in key' + - key.key_arn.endswith(key.key_id) + - key.key_arn.startswith("arn:aws") + - key.key_state == "Enabled" + - key.enabled == True + - key.tags | length == 2 + - key.enable_key_rotation == false + - key.key_usage == 'ENCRYPT_DECRYPT' + - key.customer_master_key_spec == 'SYMMETRIC_DEFAULT' + - key.grants | length == 0 + - key.key_policies | length == 1 + - key.key_policies[0].Id == 'key-default-1' + - key.description == '' + - "'tag_one' not in key.tags" + - "'tag_two' in key.tags" + - "key.tags.tag_two == 'tag_two_updated'" + - "'Tag Three' in key.tags" + - "key.tags['Tag Three'] == resource_prefix" + + always: + # ============================================================ + # CLEAN-UP + - name: finish off by deleting keys + aws_kms: + state: absent + alias: "{{ kms_key_alias }}" + pending_window: 7 + ignore_errors: True diff --git a/tests/integration/targets/kms_key/roles/aws_kms/templates/console-policy-no-key-rotation.j2 b/tests/integration/targets/kms_key/roles/aws_kms/templates/console-policy-no-key-rotation.j2 new file mode 100644 index 00000000000..0e019d20295 --- /dev/null +++ b/tests/integration/targets/kms_key/roles/aws_kms/templates/console-policy-no-key-rotation.j2 @@ -0,0 +1,81 @@ +{ + "Id": "key-consolepolicy-3", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Enable IAM User Permissions", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::{{ aws_caller_info.account }}:root" + }, + "Action": "kms:*", + "Resource": "*" + }, + { + "Sid": "Allow access for Key Administrators", + "Effect": "Allow", + "Principal": { + "AWS": "{{ aws_caller_info.arn }}" + }, + "Action": [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:TagResource", + "kms:UntagResource", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + "Resource": "*" + }, + { + "Sid": "Allow use of the key", + "Effect": "Allow", + "Principal": { + "AWS": "{{ aws_caller_info.arn }}" + }, + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ], + "Resource": "*" + }, + { + "Sid": "Allow attachment of persistent resources", + "Effect": "Allow", + "Principal": { + "AWS": "{{ aws_caller_info.arn }}" + }, + "Action": [ + "kms:CreateGrant", + "kms:ListGrants", + "kms:RevokeGrant" + ], + "Resource": "*", + "Condition": { + "Bool": { + "kms:GrantIsForAWSResource": "true" + } + } + }, + { + "Sid": "Disable access to key rotation status", + "Effect": "Deny", + "Principal": { + "AWS": "{{ aws_caller_info.arn }}" + }, + "Action": "kms:GetKeyRotationStatus", + "Resource": "*" + } + ] +} diff --git a/tests/integration/targets/kms_key/roles/aws_kms/templates/console-policy.j2 b/tests/integration/targets/kms_key/roles/aws_kms/templates/console-policy.j2 new file mode 100644 index 00000000000..4b60ba58898 --- /dev/null +++ b/tests/integration/targets/kms_key/roles/aws_kms/templates/console-policy.j2 @@ -0,0 +1,72 @@ +{ + "Id": "key-consolepolicy-3", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Enable IAM User Permissions", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::{{ aws_caller_info.account }}:root" + }, + "Action": "kms:*", + "Resource": "*" + }, + { + "Sid": "Allow access for Key Administrators", + "Effect": "Allow", + "Principal": { + "AWS": "{{ aws_caller_info.arn }}" + }, + "Action": [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:TagResource", + "kms:UntagResource", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + "Resource": "*" + }, + { + "Sid": "Allow use of the key", + "Effect": "Allow", + "Principal": { + "AWS": "{{ aws_caller_info.arn }}" + }, + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ], + "Resource": "*" + }, + { + "Sid": "Allow attachment of persistent resources", + "Effect": "Allow", + "Principal": { + "AWS": "{{ aws_caller_info.arn }}" + }, + "Action": [ + "kms:CreateGrant", + "kms:ListGrants", + "kms:RevokeGrant" + ], + "Resource": "*", + "Condition": { + "Bool": { + "kms:GrantIsForAWSResource": "true" + } + } + } + ] +} diff --git a/tests/integration/targets/kms_key/runme.sh b/tests/integration/targets/kms_key/runme.sh new file mode 100755 index 00000000000..5b5b69fbd1d --- /dev/null +++ b/tests/integration/targets/kms_key/runme.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Beware: most of our tests here are run in parallel. +# To add new tests you'll need to add a new host to the inventory and a matching +# '{{ inventory_hostname }}'.yml file in roles/aws_kms/tasks/ + + +set -eux + +export ANSIBLE_ROLES_PATH=../ + +ansible-playbook main.yml -i inventory "$@"