diff --git a/.gitignore b/.gitignore index 07a759e1b7..d93ebbdc62 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,7 @@ packaging/release/ansible_release /test/results/junit/*.xml /test/results/logs/*.log /test/results/data/*.json +/test/integration/cloud-config-aws.yml /test/integration/inventory.remote /test/integration/inventory.networking /test/integration/inventory.winrm diff --git a/changelogs/fragments/aws_kms_grants.yml b/changelogs/fragments/aws_kms_grants.yml new file mode 100644 index 0000000000..017c8b16a3 --- /dev/null +++ b/changelogs/fragments/aws_kms_grants.yml @@ -0,0 +1,2 @@ +minor_changes: + - aws_kms is now able to create keys and manage grants and tags diff --git a/hacking/aws_config/testing_policies/kms-policy.json b/hacking/aws_config/testing_policies/kms-policy.json new file mode 100644 index 0000000000..0ddc760633 --- /dev/null +++ b/hacking/aws_config/testing_policies/kms-policy.json @@ -0,0 +1,54 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAccessToUnspecifiedKMSResources", + "Effect": "Allow", + "Action": [ + "iam:ListRoles", + "kms:CancelKeyDeletion", + "kms:CreateAlias", + "kms:CreateGrant", + "kms:CreateKey", + "kms:DeleteAlias", + "kms:Describe*", + "kms:DisableKey", + "kms:EnableKey", + "kms:GenerateRandom", + "kms:Get*", + "kms:List*", + "kms:RetireGrant", + "kms:ScheduleKeyDeletion", + "kms:TagResource", + "kms:UntagResource", + "kms:UpdateGrant", + "kms:UpdateKeyDescription" + ], + "Resource": "*" + }, + { + "Sid": "AllowAccessToSpecifiedIAMResources", + "Effect": "Allow", + "Action": [ + "iam:CreateRole", + "iam:DeleteRole", + "iam:GetRole", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:PassRole", + "iam:UpdateAssumeRolePolicy" + ], + "Resource": "arn:aws:iam::{{aws_account}}:role/ansible-test-*" + }, + { + "Sid": "AllowInstanceProfileCreation", + "Effect": "Allow", + "Action": [ + "iam:AddRoleToInstanceProfile", + "iam:CreateInstanceProfile", + "iam:RemoveRoleFromInstanceProfile" + ], + "Resource": "arn:aws:iam::{{aws_account}}:instance-profile/ansible-test-*" + } + ] +} diff --git a/lib/ansible/modules/cloud/amazon/aws_kms.py b/lib/ansible/modules/cloud/amazon/aws_kms.py index 2615f630e3..f694a9a9f2 100644 --- a/lib/ansible/modules/cloud/amazon/aws_kms.py +++ b/lib/ansible/modules/cloud/amazon/aws_kms.py @@ -26,22 +26,29 @@ short_description: Perform various KMS management tasks. description: - Manage role/user access to a KMS key. Not designed for encrypting/decrypting. version_added: "2.3" -requirements: [ boto3 ] options: mode: description: - Grant or deny access. - required: true default: grant choices: [ grant, deny ] - key_alias: - description: - - Alias label to the key. One of C(key_alias) or C(key_arn) are required. + 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(aws_kms_facts) to find key ids. Required + if I(key_id) is not given. 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 'alias/' prefix is optional. required: false - key_arn: + aliases: + - key_alias + key_id: description: - - Full ARN to the key. One of C(key_alias) or C(key_arn) are required. + - Key ID or ARN of the key. One of C(alias) or C(key_id) are required. required: false + aliases: + - key_arn role_name: description: - Role to allow/deny access. One of C(role_name) or C(role_arn) are required. @@ -60,8 +67,62 @@ options: - Only cleans if changes are being made. type: bool default: true - -author: Ted Timmons (@tedder) + state: + description: Whether a key should be present or absent. Note that making an + existing key absent only schedules a key for deletion. Passing a key that + is scheduled for deletion with state present will cancel key deletion. + required: False + choices: + - present + - absent + default: present + version_added: 2.8 + enabled: + description: Whether or not a key is enabled + default: True + version_added: 2.8 + type: bool + description: + description: + A description of the CMK. Use a description that helps you decide + whether the CMK is appropriate for a task. + version_added: 2.8 + tags: + description: A dictionary of tags to apply to a key. + version_added: 2.8 + purge_tags: + description: Whether the I(tags) argument should cause tags not in the list to + be removed + version_added: 2.8 + default: False + type: bool + purge_grants: + description: Whether the I(grants) argument should cause grants not in the list to + be removed + default: False + version_added: 2.8 + 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). + - Valid operations are C(Decrypt), C(Encrypt), C(GenerateDataKey), C(GenerateDataKeyWithoutPlaintext), + C(ReEncryptFrom), C(ReEncryptTo), C(CreateGrant), C(RetireGrant), C(DescribeKey), C(Verify) and + C(Sign) + - 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) + - I(grantee_principal) and I(retiring_principal) must be ARNs + version_added: 2.8 + policy: + description: + - policy to apply to the KMS key + - See U(https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html) + version_added: 2.8 +author: + - Ted Timmons (@tedder) + - Will Thames (@willthames) extends_documentation_fragment: - aws - ec2 @@ -72,18 +133,199 @@ EXAMPLES = ''' aws_kms: args: mode: grant - key_alias: "alias/my_production_secrets" + alias: "alias/my_production_secrets" role_name: "prod-appServerRole-1R5AQG2BSEL6L" grant_types: "role,role grant" - name: remove access to production secrets from role aws_kms: args: mode: deny - key_alias: "alias/my_production_secrets" + alias: "alias/my_production_secrets" role_name: "prod-appServerRole-1R5AQG2BSEL6L" + +# Create a new KMS key +- aws_kms: + alias: mykey + tags: + Name: myKey + Purpose: protect_stuff + +# Update previous key with more tags +- aws_kms: + 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 +- aws_kms: + 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 ''' RETURN = ''' +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 + 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 of creation of the key + type: str + returned: always + sample: "2017-04-18T15:12:08.551000+10:00" +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 C(KeyState) is true. + type: str + 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 keys. Empty when access is denied even if there are policies. + type: list + returned: always + 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: "*" +tags: + description: dictionary of tags applied to the key + type: dict + returned: always + sample: + Name: myKey + Purpose: protecting_stuff +grants: + description: list of grants associated with a key + type: complex + 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 @@ -103,22 +345,385 @@ statement_label = { 'admin': 'Allow access for Key Administrators' } -# import module snippets -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.ec2 import boto_exception +from ansible.module_utils.aws.core import AnsibleAWSModule, is_boto3_error_code +from ansible.module_utils.ec2 import ec2_argument_spec +from ansible.module_utils.ec2 import AWSRetry, camel_dict_to_snake_dict +from ansible.module_utils.ec2 import boto3_tag_list_to_ansible_dict, ansible_dict_to_boto3_tag_list +from ansible.module_utils.ec2 import compare_aws_tags from ansible.module_utils.six import string_types -# import a class, we'll use a fully qualified path -import ansible.module_utils.ec2 - -import traceback import json try: import botocore - HAS_BOTO3 = True except ImportError: - HAS_BOTO3 = False + pass # caught by AnsibleAWSModule + + +@AWSRetry.backoff(tries=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.backoff(tries=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.backoff(tries=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.backoff(tries=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.backoff(tries=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.backoff(tries=5, delay=5, backoff=2.0) +def get_kms_metadata_with_backoff(connection, key_id): + return connection.describe_key(KeyId=key_id) + + +@AWSRetry.backoff(tries=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.backoff(tries=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 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:'): + return key['Tags'][filtr[0][4:]] == 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 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") + + 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) + 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_id'], + 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 ensure_enabled_disabled(connection, module, key): + changed = False + if key['key_state'] == 'Disabled' and module.params['enabled']: + try: + connection.enable_key(KeyId=key['key_id']) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to enable key") + + if key['key_state'] == 'Enabled' and not module.params['enabled']: + try: + connection.disable_key(KeyId=key['key_id']) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to disable key") + return changed + + +def update_key(connection, module, key): + changed = False + alias = module.params['alias'] + if not alias.startswith('alias/'): + alias = 'alias/' + alias + aliases = get_kms_aliases_with_backoff(connection)['Aliases'] + key_id = module.params.get('key_id') + if key_id: + # We will only add new aliases, not rename existing ones + if alias not in [_alias['AliasName'] for _alias in aliases]: + try: + connection.create_alias(KeyId=key_id, AliasName=alias) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(msg="Failed create key alias") + + if key['key_state'] == 'PendingDeletion': + try: + connection.cancel_key_deletion(KeyId=key['key_id']) + # key is disabled after deletion cancellation + # set this so that ensure_enabled_disabled works correctly + key['key_state'] = 'Disabled' + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to cancel key deletion") + + changed = ensure_enabled_disabled(connection, module, key) or changed + + description = module.params.get('description') + # don't update description if description is not set + # (means you can't remove a description completely) + if description and key['description'] != description: + try: + connection.update_key_description(KeyId=key['key_id'], Description=description) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to update key description") + + desired_tags = module.params.get('tags') + to_add, to_remove = compare_aws_tags(key['tags'], desired_tags, + module.params.get('purge_tags')) + if to_remove: + try: + connection.untag_resource(KeyId=key['key_id'], TagKeys=to_remove) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Unable to remove or update tag") + if to_add: + try: + connection.tag_resource(KeyId=key['key_id'], + Tags=[{'TagKey': tag_key, 'TagValue': desired_tags[tag_key]} + for tag_key in to_add]) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Unable to add tag to key") + + desired_grants = module.params.get('grants') + existing_grants = key['grants'] + + to_add, to_remove = compare_grants(existing_grants, desired_grants, + module.params.get('purge_grants')) + if to_remove: + for grant in to_remove: + try: + connection.retire_grant(KeyId=key['key_arn'], GrantId=grant['grant_id']) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Unable to retire grant") + + if to_add: + for grant in to_add: + grant_params = convert_grant_params(grant, key) + try: + connection.create_grant(**grant_params) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Unable to create grant") + + # make results consistent with kms_facts + result = get_key_details(connection, module, key['key_id']) + module.exit_json(changed=changed, **camel_dict_to_snake_dict(result)) + + +def create_key(connection, module): + params = dict(BypassPolicyLockoutSafetyCheck=False, + Tags=ansible_dict_to_boto3_tag_list(module.params['tags']), + KeyUsage='ENCRYPT_DECRYPT', + Origin='AWS_KMS') + 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']) + + alias = module.params['alias'] + if not alias.startswith('alias/'): + alias = 'alias/' + alias + try: + connection.create_alias(AliasName=alias, TargetKeyId=key['key_id']) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to create alias") + + ensure_enabled_disabled(connection, module, key) + for grant in module.params.get('grants'): + 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="Failed to add grant to key") + + # make results consistent with kms_facts + result = get_key_details(connection, module, key['key_id']) + module.exit_json(changed=True, **camel_dict_to_snake_dict(result)) + + +def delete_key(connection, module, key): + changed = False + + if key['key_state'] != 'PendingDeletion': + try: + connection.schedule_key_deletion(KeyId=key['key_id']) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to schedule key for deletion") + + result = get_key_details(connection, module, key['key_id']) + module.exit_json(changed=changed, **camel_dict_to_snake_dict(result)) def get_arn_from_kms_alias(kms, aliasname): @@ -184,16 +789,19 @@ def do_grant(kms, keyarn, role_arn, granttypes, mode='grant', dry_run=True, clea if role_arn not in statement['Principal']['AWS']: # needs to be added. changes_needed[granttype] = 'add' - statement['Principal']['AWS'].append(role_arn) + if not dry_run: + statement['Principal']['AWS'].append(role_arn) elif role_arn in statement['Principal']['AWS']: # not one the places the role should be changes_needed[granttype] = 'remove' - statement['Principal']['AWS'].remove(role_arn) + if not dry_run: + statement['Principal']['AWS'].remove(role_arn) elif mode == 'deny' and statement['Sid'] == statement_label[granttype] 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. changes_needed[granttype] = 'remove' - statement['Principal']['AWS'].remove(role_arn) + if not dry_run: + statement['Principal']['AWS'].remove(role_arn) try: if len(changes_needed) and not dry_run: @@ -236,43 +844,40 @@ def assert_policy_shape(policy): def main(): - argument_spec = ansible.module_utils.ec2.ec2_argument_spec() - argument_spec.update(dict( - mode=dict(choices=['grant', 'deny'], default='grant'), - key_alias=dict(required=False, type='str'), - key_arn=dict(required=False, type='str'), - role_name=dict(required=False, type='str'), - role_arn=dict(required=False, type='str'), - grant_types=dict(required=False, type='list'), - clean_invalid_entries=dict(type='bool', default=True), - ) + argument_spec = ec2_argument_spec() + argument_spec.update( + dict( + mode=dict(choices=['grant', 'deny'], default='grant'), + alias=dict(aliases=['key_alias']), + role_name=dict(), + role_arn=dict(), + grant_types=dict(type='list'), + clean_invalid_entries=dict(type='bool', default=True), + key_id=dict(aliases=['key_arn']), + description=dict(), + enabled=dict(type='bool', default=True), + tags=dict(type='dict', default={}), + purge_tags=dict(type='bool', default=False), + grants=dict(type='list', default=[]), + policy=dict(), + purge_grants=dict(type='bool', default=False), + state=dict(default='present', choices=['present', 'absent']), + ) ) - module = AnsibleModule( + module = AnsibleAWSModule( supports_check_mode=True, argument_spec=argument_spec, - required_one_of=[['key_alias', 'key_arn'], ['role_name', 'role_arn']], - required_if=[['mode', 'grant', ['grant_types']]] + required_one_of=[['alias', 'key_id']], ) - if not HAS_BOTO3: - module.fail_json(msg='boto3 required for this module') result = {} mode = module.params['mode'] - try: - region, ec2_url, aws_connect_kwargs = ansible.module_utils.ec2.get_aws_connection_info(module, boto3=True) - kms = ansible.module_utils.ec2.boto3_conn(module, conn_type='client', resource='kms', region=region, endpoint=ec2_url, **aws_connect_kwargs) - iam = ansible.module_utils.ec2.boto3_conn(module, conn_type='client', resource='iam', region=region, endpoint=ec2_url, **aws_connect_kwargs) - except botocore.exceptions.NoCredentialsError as e: - module.fail_json(msg='cannot connect to AWS', exception=traceback.format_exc()) - - try: - if module.params['key_alias'] and not module.params['key_arn']: - module.params['key_arn'] = get_arn_from_kms_alias(kms, module.params['key_alias']) - if not module.params['key_arn']: - module.fail_json(msg='key_arn or key_alias is required to {}'.format(mode)) + kms = module.client('kms') + iam = module.client('iam') + if module.params['grant_types'] or mode == 'deny': if module.params['role_name'] and not module.params['role_arn']: module.params['role_arn'] = get_arn_from_role_name(iam, module.params['role_name']) if not module.params['role_arn']: @@ -290,11 +895,31 @@ def main(): clean_invalid_entries=module.params['clean_invalid_entries']) result.update(ret) - except Exception as err: - error_msg = boto_exception(err) - module.fail_json(msg=error_msg, exception=traceback.format_exc()) + module.exit_json(**result) + else: + all_keys = get_kms_facts(kms, module) + key_id = module.params.get('key_id') + alias = module.params.get('alias') + if key_id: + filtr = ('key-id', key_id) + elif module.params.get('alias'): + filtr = ('alias', alias) - module.exit_json(**result) + candidate_keys = [key for key in all_keys if key_matches_filter(key, filtr)] + + if module.params.get('state') == 'present': + if candidate_keys: + update_key(kms, module, candidate_keys[0]) + else: + if module.params.get('key_id'): + module.fail_json(msg="Could not find key with id %s to update") + else: + create_key(kms, module) + else: + if candidate_keys: + delete_key(kms, module, candidate_keys[0]) + else: + module.exit_json(changed=False) if __name__ == '__main__': diff --git a/test/integration/targets/aws_kms/aliases b/test/integration/targets/aws_kms/aliases new file mode 100644 index 0000000000..fa30a6a83f --- /dev/null +++ b/test/integration/targets/aws_kms/aliases @@ -0,0 +1,3 @@ +cloud/aws +aws_kms_facts +unsupported diff --git a/test/integration/targets/aws_kms/meta/main.yml b/test/integration/targets/aws_kms/meta/main.yml new file mode 100644 index 0000000000..1f64f1169a --- /dev/null +++ b/test/integration/targets/aws_kms/meta/main.yml @@ -0,0 +1,3 @@ +dependencies: + - prepare_tests + - setup_ec2 diff --git a/test/integration/targets/aws_kms/tasks/main.yml b/test/integration/targets/aws_kms/tasks/main.yml new file mode 100644 index 0000000000..40d1129d10 --- /dev/null +++ b/test/integration/targets/aws_kms/tasks/main.yml @@ -0,0 +1,394 @@ +- block: + + # ============================================================ + - name: See whether key exists and its current state + aws_kms_facts: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + filters: + alias: "{{ resource_prefix }}-kms" + + - name: create a key + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + enabled: yes + register: create_kms + + - name: assert that state is enabled + assert: + that: + - create_kms.key_state == "Enabled" + + - name: find facts about the key + aws_kms_facts: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + filters: + alias: "{{ resource_prefix }}-kms" + register: new_key + + - name: check that a key was found + assert: + that: + - new_key["keys"]|length == 1 + + - name: create an IAM role that can do nothing + iam_role: + name: "{{ resource_prefix }}-kms-role" + state: present + assume_role_policy_document: '{"Version": "2012-10-17", "Statement": {"Action": "sts:AssumeRole", "Principal": {"Service": "ec2.amazonaws.com"}, "Effect": "Deny"} }' + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + register: iam_role_result + + - name: grant user-style access to production secrets + aws_kms: + mode: grant + key_alias: "alias/{{ resource_prefix }}-kms" + role_name: "{{ resource_prefix }}-kms-role" + grant_types: "role,role grant" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + region: "{{ aws_region }}" + + - name: find facts about the key + aws_kms_facts: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + filters: + alias: "{{ resource_prefix }}-kms" + register: new_key + + - name: remove access to production secrets from role + aws_kms: + mode: deny + key_alias: "alias/{{ resource_prefix }}-kms" + role_arn: "{{ iam_role_result.iam_role.arn }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + region: "{{ aws_region }}" + + - name: find facts about the key + aws_kms_facts: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + filters: + alias: "{{ resource_prefix }}-kms" + register: new_key + + - fail: + + - name: set aws environment base fact + set_fact: + aws_environment_base: + AWS_ACCESS_KEY_ID: "{{ aws_access_key }}" + AWS_SECRET_ACCESS_KEY: "{{ aws_secret_key }}" + no_log: True + + - name: set aws environment fact + set_fact: + aws_environment: "{{ aws_environment_base|combine(security_token|ternary({'AWS_SECURITY_TOKEN': security_token}, {})) }}" + no_log: True + + - name: get ARN of calling user + command: python -c 'import boto3,json; sts = boto3.client("sts"); print json.dumps(sts.get_caller_identity())' + changed_when: False + environment: "{{ aws_environment }}" + register: sts_get_caller_results + + - name: set caller_arn + set_fact: + caller_arn: "{{ (sts_get_caller_results.stdout|from_json).Arn }}" + + - name: Allow the IAM role to use a specific Encryption Context + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + purge_grants: yes + purge_tags: yes + grants: + - name: test_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ caller_arn }}" + constraints: + encryption_context_equals: + environment: test + application: testapp + operations: + - Decrypt + - RetireGrant + register: grant_one + + - name: assert grant added + assert: + that: + - grant_one.changed + - grant_one.grants|length == 1 + + - name: Add a second grant + kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + grants: + - name: another_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ caller_arn }}" + constraints: + encryption_context_equals: + Environment: second + Application: anotherapp + operations: + - Decrypt + - RetireGrant + register: grant_two + + - name: assert grant added + assert: + that: + - grant_two.changed + - grant_two.grants|length == 2 + + - name: Add a second grant again + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + grants: + - name: another_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ caller_arn }}" + constraints: + encryption_context_equals: + Environment: second + Application: anotherapp + operations: + - Decrypt + - RetireGrant + register: grant_two_again + + - name: assert grant added + assert: + that: + - not grant_two_again.changed + - grant_two_again.grants|length == 2 + + - name: Update the grants with purge_grants set + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + purge_grants: yes + grants: + - name: third_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ caller_arn }}" + constraints: + encryption_context_equals: + environment: third + application: onemoreapp + operations: + - Decrypt + - RetireGrant + register: grant_three + + - name: assert grants replaced + assert: + that: + - grant_three.changed + - grant_three.grants|length == 1 + + - name: update third grant to change encryption context equals to subset + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + grants: + - name: third_grant + grantee_principal: "{{ iam_role_result.iam_role.arn }}" + retiring_principal: "{{ caller_arn }}" + constraints: + encryption_context_subset: + environment: third + application: onemoreapp + operations: + - Decrypt + - RetireGrant + register: grant_three_update + + - name: assert grants replaced + assert: + that: + - "grant_three_update.changed" + - "grant_three_update.grants|length == 1" + - "'encryption_context_equals' not in grant_three_update.grants[0].constraints" + - "'encryption_context_subset' in grant_three_update.grants[0].constraints" + + - name: tag encryption key + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + tags: + tag_one: tag_one + tag_two: tag_two + register: tag_kms + + - name: assert tags added and grants remain in place + assert: + that: + - "tag_kms.changed" + - "tag_kms.grants|length == 1" + - "'tag_one' in tag_kms.tags" + - "'tag_two' in tag_kms.tags" + + - name: add, replace, remove tags + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + purge_tags: yes + tags: + tag_two: tag_two_updated + tag_three: tag_three + register: tag_kms_update + + - name: assert tags correctly changed + assert: + that: + - "tag_kms_update.changed" + - "'tag_one' not in tag_kms_update.tags" + - "'tag_two' in tag_kms_update.tags" + - "tag_kms_update.tags.tag_two == 'tag_two_updated'" + - "'tag_three' in tag_kms_update.tags" + + - name: make no real tag change + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + register: tag_kms_no_update + + - name: assert no change to tags + assert: + that: + - "not tag_kms_no_update.changed" + - "'tag_one' not in tag_kms_no_update.tags" + - "'tag_two' in tag_kms_no_update.tags" + - "tag_kms_no_update.tags.tag_two == 'tag_two_updated'" + - "'tag_three' in tag_kms_no_update.tags" + + - name: update the key's description and disable it + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + description: test key for testing + enabled: no + register: update_key + + - name: assert that state is enabled + assert: + that: + - update_key.description == "test key for testing" + - update_key.key_state == "Disabled" + - update_key.changed + + - name: delete the key + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: absent + register: delete_kms + + - name: assert that state is pending deletion + assert: + that: + - delete_kms.key_state == "PendingDeletion" + - delete_kms.changed + + - name: undelete and enable the key + aws_kms: + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + state: present + enabled: yes + register: undelete_kms + + - name: assert that state is enabled + assert: + that: + - undelete_kms.key_state == "Enabled" + - undelete_kms.changed + + always: + + # ============================================================ + - name: finish off by deleting key + aws_kms: + state: absent + region: "{{ aws_region }}" + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + alias: "{{ resource_prefix }}-kms" + register: destroy_result + + - name: remove the IAM role + iam_role: + name: "{{ resource_prefix }}-kms-role" + state: absent + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + register: iam_role_result