Browse code

aws_kms enhancements (#31960)

* Allow creation and deletion of keys (deletion just schedules for
deletion, recreating an old key is just cancelling its deletion)
* Allow grants to be set, thus enabling encryption contexts to be
used with keys
* Allow tags to be added and modified
* Add testing for KMS module
* Tidy up aws_kms module to latest standards

Will Thames authored on 2019/02/13 12:06:58
Showing 7 changed files
... ...
@@ -86,6 +86,7 @@ packaging/release/ansible_release
86 86
 /test/results/junit/*.xml
87 87
 /test/results/logs/*.log
88 88
 /test/results/data/*.json
89
+/test/integration/cloud-config-aws.yml
89 90
 /test/integration/inventory.remote
90 91
 /test/integration/inventory.networking
91 92
 /test/integration/inventory.winrm
92 93
new file mode 100644
... ...
@@ -0,0 +1,2 @@
0
+minor_changes:
1
+  - aws_kms is now able to create keys and manage grants and tags
0 2
new file mode 100644
... ...
@@ -0,0 +1,54 @@
0
+{
1
+    "Version": "2012-10-17",
2
+    "Statement": [
3
+        {
4
+            "Sid": "AllowAccessToUnspecifiedKMSResources",
5
+            "Effect": "Allow",
6
+            "Action": [
7
+                "iam:ListRoles",
8
+                "kms:CancelKeyDeletion",
9
+                "kms:CreateAlias",
10
+                "kms:CreateGrant",
11
+                "kms:CreateKey",
12
+                "kms:DeleteAlias",
13
+                "kms:Describe*",
14
+                "kms:DisableKey",
15
+                "kms:EnableKey",
16
+                "kms:GenerateRandom",
17
+                "kms:Get*",
18
+                "kms:List*",
19
+                "kms:RetireGrant",
20
+                "kms:ScheduleKeyDeletion",
21
+                "kms:TagResource",
22
+                "kms:UntagResource",
23
+                "kms:UpdateGrant",
24
+                "kms:UpdateKeyDescription"
25
+            ],
26
+            "Resource": "*"
27
+        },
28
+        {
29
+            "Sid": "AllowAccessToSpecifiedIAMResources",
30
+            "Effect": "Allow",
31
+            "Action": [
32
+                "iam:CreateRole",
33
+                "iam:DeleteRole",
34
+                "iam:GetRole",
35
+                "iam:ListAttachedRolePolicies",
36
+                "iam:ListInstanceProfilesForRole",
37
+                "iam:PassRole",
38
+                "iam:UpdateAssumeRolePolicy"
39
+            ],
40
+            "Resource": "arn:aws:iam::{{aws_account}}:role/ansible-test-*"
41
+        },
42
+        {
43
+            "Sid": "AllowInstanceProfileCreation",
44
+            "Effect": "Allow",
45
+            "Action": [
46
+                "iam:AddRoleToInstanceProfile",
47
+                "iam:CreateInstanceProfile",
48
+                "iam:RemoveRoleFromInstanceProfile"
49
+            ],
50
+            "Resource": "arn:aws:iam::{{aws_account}}:instance-profile/ansible-test-*"
51
+        }
52
+    ]
53
+}
... ...
@@ -26,22 +26,29 @@ short_description: Perform various KMS management tasks.
26 26
 description:
27 27
      - Manage role/user access to a KMS key. Not designed for encrypting/decrypting.
28 28
 version_added: "2.3"
29
-requirements: [ boto3 ]
30 29
 options:
31 30
   mode:
32 31
     description:
33 32
     - Grant or deny access.
34
-    required: true
35 33
     default: grant
36 34
     choices: [ grant, deny ]
37
-  key_alias:
38
-    description:
39
-    - Alias label to the key. One of C(key_alias) or C(key_arn) are required.
35
+  alias:
36
+    description: An alias for a key. For safety, even though KMS does not require keys
37
+      to have an alias, this module expects all new keys to be given an alias
38
+      to make them easier to manage. Existing keys without an alias may be
39
+      referred to by I(key_id). Use M(aws_kms_facts) to find key ids. Required
40
+      if I(key_id) is not given. Note that passing a I(key_id) and I(alias)
41
+      will only cause a new alias to be added, an alias will never be renamed.
42
+      The 'alias/' prefix is optional.
40 43
     required: false
41
-  key_arn:
44
+    aliases:
45
+      - key_alias
46
+  key_id:
42 47
     description:
43
-    - Full ARN to the key. One of C(key_alias) or C(key_arn) are required.
48
+    - Key ID or ARN of the key. One of C(alias) or C(key_id) are required.
44 49
     required: false
50
+    aliases:
51
+      - key_arn
45 52
   role_name:
46 53
     description:
47 54
     - Role to allow/deny access. One of C(role_name) or C(role_arn) are required.
... ...
@@ -60,8 +67,62 @@ options:
60 60
     - Only cleans if changes are being made.
61 61
     type: bool
62 62
     default: true
63
-
64
-author: Ted Timmons (@tedder)
63
+  state:
64
+    description: Whether a key should be present or absent. Note that making an
65
+      existing key absent only schedules a key for deletion.  Passing a key that
66
+      is scheduled for deletion with state present will cancel key deletion.
67
+    required: False
68
+    choices:
69
+      - present
70
+      - absent
71
+    default: present
72
+    version_added: 2.8
73
+  enabled:
74
+    description: Whether or not a key is enabled
75
+    default: True
76
+    version_added: 2.8
77
+    type: bool
78
+  description:
79
+    description:
80
+      A description of the CMK. Use a description that helps you decide
81
+      whether the CMK is appropriate for a task.
82
+    version_added: 2.8
83
+  tags:
84
+    description: A dictionary of tags to apply to a key.
85
+    version_added: 2.8
86
+  purge_tags:
87
+    description: Whether the I(tags) argument should cause tags not in the list to
88
+      be removed
89
+    version_added: 2.8
90
+    default: False
91
+    type: bool
92
+  purge_grants:
93
+    description: Whether the I(grants) argument should cause grants not in the list to
94
+      be removed
95
+    default: False
96
+    version_added: 2.8
97
+    type: bool
98
+  grants:
99
+    description:
100
+      - A list of grants to apply to the key. Each item must contain I(grantee_principal).
101
+        Each item can optionally contain I(retiring_principal), I(operations), I(constraints),
102
+        I(name).
103
+      - Valid operations are C(Decrypt), C(Encrypt), C(GenerateDataKey), C(GenerateDataKeyWithoutPlaintext),
104
+        C(ReEncryptFrom), C(ReEncryptTo), C(CreateGrant), C(RetireGrant), C(DescribeKey), C(Verify) and
105
+        C(Sign)
106
+      - Constraints is a dict containing C(encryption_context_subset) or C(encryption_context_equals),
107
+        either or both being a dict specifying an encryption context match.
108
+        See U(https://docs.aws.amazon.com/kms/latest/APIReference/API_GrantConstraints.html)
109
+      - I(grantee_principal) and I(retiring_principal) must be ARNs
110
+    version_added: 2.8
111
+  policy:
112
+    description:
113
+      - policy to apply to the KMS key
114
+      - See U(https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html)
115
+    version_added: 2.8
116
+author:
117
+  - Ted Timmons (@tedder)
118
+  - Will Thames (@willthames)
65 119
 extends_documentation_fragment:
66 120
 - aws
67 121
 - ec2
... ...
@@ -72,18 +133,199 @@ EXAMPLES = '''
72 72
   aws_kms:
73 73
   args:
74 74
     mode: grant
75
-    key_alias: "alias/my_production_secrets"
75
+    alias: "alias/my_production_secrets"
76 76
     role_name: "prod-appServerRole-1R5AQG2BSEL6L"
77 77
     grant_types: "role,role grant"
78 78
 - name: remove access to production secrets from role
79 79
   aws_kms:
80 80
   args:
81 81
     mode: deny
82
-    key_alias: "alias/my_production_secrets"
82
+    alias: "alias/my_production_secrets"
83 83
     role_name: "prod-appServerRole-1R5AQG2BSEL6L"
84
+
85
+# Create a new KMS key
86
+- aws_kms:
87
+    alias: mykey
88
+    tags:
89
+      Name: myKey
90
+      Purpose: protect_stuff
91
+
92
+# Update previous key with more tags
93
+- aws_kms:
94
+    alias: mykey
95
+    tags:
96
+      Name: myKey
97
+      Purpose: protect_stuff
98
+      Owner: security_team
99
+
100
+# Update a known key with grants allowing an instance with the billing-prod IAM profile
101
+# to decrypt data encrypted with the environment: production, application: billing
102
+# encryption context
103
+- aws_kms:
104
+    key_id: abcd1234-abcd-1234-5678-ef1234567890
105
+    grants:
106
+      - name: billing_prod
107
+        grantee_principal: arn:aws:iam::1234567890123:role/billing_prod
108
+        constraints:
109
+          encryption_context_equals:
110
+            environment: production
111
+            application: billing
112
+        operations:
113
+          - Decrypt
114
+          - RetireGrant
84 115
 '''
85 116
 
86 117
 RETURN = '''
118
+key_id:
119
+  description: ID of key
120
+  type: str
121
+  returned: always
122
+  sample: abcd1234-abcd-1234-5678-ef1234567890
123
+key_arn:
124
+  description: ARN of key
125
+  type: str
126
+  returned: always
127
+  sample: arn:aws:kms:ap-southeast-2:123456789012:key/abcd1234-abcd-1234-5678-ef1234567890
128
+key_state:
129
+  description: The state of the key
130
+  type: str
131
+  returned: always
132
+  sample: PendingDeletion
133
+key_usage:
134
+  description: The cryptographic operations for which you can use the key.
135
+  type: str
136
+  returned: always
137
+  sample: ENCRYPT_DECRYPT
138
+origin:
139
+  description: The source of the key's key material. When this value is C(AWS_KMS),
140
+    AWS KMS created the key material. When this value is C(EXTERNAL), the
141
+    key material was imported or the CMK lacks key material.
142
+  type: str
143
+  returned: always
144
+  sample: AWS_KMS
145
+aws_account_id:
146
+  description: The AWS Account ID that the key belongs to
147
+  type: str
148
+  returned: always
149
+  sample: 1234567890123
150
+creation_date:
151
+  description: Date of creation of the key
152
+  type: str
153
+  returned: always
154
+  sample: "2017-04-18T15:12:08.551000+10:00"
155
+description:
156
+  description: Description of the key
157
+  type: str
158
+  returned: always
159
+  sample: "My Key for Protecting important stuff"
160
+enabled:
161
+  description: Whether the key is enabled. True if C(KeyState) is true.
162
+  type: str
163
+  returned: always
164
+  sample: false
165
+aliases:
166
+  description: list of aliases associated with the key
167
+  type: list
168
+  returned: always
169
+  sample:
170
+    - aws/acm
171
+    - aws/ebs
172
+policies:
173
+  description: list of policy documents for the keys. Empty when access is denied even if there are policies.
174
+  type: list
175
+  returned: always
176
+  sample:
177
+    Version: "2012-10-17"
178
+    Id: "auto-ebs-2"
179
+    Statement:
180
+    - Sid: "Allow access through EBS for all principals in the account that are authorized to use EBS"
181
+      Effect: "Allow"
182
+      Principal:
183
+        AWS: "*"
184
+      Action:
185
+      - "kms:Encrypt"
186
+      - "kms:Decrypt"
187
+      - "kms:ReEncrypt*"
188
+      - "kms:GenerateDataKey*"
189
+      - "kms:CreateGrant"
190
+      - "kms:DescribeKey"
191
+      Resource: "*"
192
+      Condition:
193
+        StringEquals:
194
+          kms:CallerAccount: "111111111111"
195
+          kms:ViaService: "ec2.ap-southeast-2.amazonaws.com"
196
+    - Sid: "Allow direct access to key metadata to the account"
197
+      Effect: "Allow"
198
+      Principal:
199
+        AWS: "arn:aws:iam::111111111111:root"
200
+      Action:
201
+      - "kms:Describe*"
202
+      - "kms:Get*"
203
+      - "kms:List*"
204
+      - "kms:RevokeGrant"
205
+      Resource: "*"
206
+tags:
207
+  description: dictionary of tags applied to the key
208
+  type: dict
209
+  returned: always
210
+  sample:
211
+    Name: myKey
212
+    Purpose: protecting_stuff
213
+grants:
214
+  description: list of grants associated with a key
215
+  type: complex
216
+  returned: always
217
+  contains:
218
+    constraints:
219
+      description: Constraints on the encryption context that the grant allows.
220
+        See U(https://docs.aws.amazon.com/kms/latest/APIReference/API_GrantConstraints.html) for further details
221
+      type: dict
222
+      returned: always
223
+      sample:
224
+        encryption_context_equals:
225
+           "aws:lambda:_function_arn": "arn:aws:lambda:ap-southeast-2:012345678912:function:xyz"
226
+    creation_date:
227
+      description: Date of creation of the grant
228
+      type: str
229
+      returned: always
230
+      sample: 2017-04-18T15:12:08+10:00
231
+    grant_id:
232
+      description: The unique ID for the grant
233
+      type: str
234
+      returned: always
235
+      sample: abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234
236
+    grantee_principal:
237
+      description: The principal that receives the grant's permissions
238
+      type: str
239
+      returned: always
240
+      sample: arn:aws:sts::0123456789012:assumed-role/lambda_xyz/xyz
241
+    issuing_account:
242
+      description: The AWS account under which the grant was issued
243
+      type: str
244
+      returned: always
245
+      sample: arn:aws:iam::01234567890:root
246
+    key_id:
247
+      description: The key ARN to which the grant applies.
248
+      type: str
249
+      returned: always
250
+      sample: arn:aws:kms:ap-southeast-2:123456789012:key/abcd1234-abcd-1234-5678-ef1234567890
251
+    name:
252
+      description: The friendly name that identifies the grant
253
+      type: str
254
+      returned: always
255
+      sample: xyz
256
+    operations:
257
+      description: The list of operations permitted by the grant
258
+      type: list
259
+      returned: always
260
+      sample:
261
+        - Decrypt
262
+        - RetireGrant
263
+    retiring_principal:
264
+      description: The principal that can retire the grant
265
+      type: str
266
+      returned: always
267
+      sample: arn:aws:sts::0123456789012:assumed-role/lambda_xyz/xyz
87 268
 changes_needed:
88 269
   description: grant types that would be changed/were changed.
89 270
   type: dict
... ...
@@ -103,22 +345,385 @@ statement_label = {
103 103
     'admin': 'Allow access for Key Administrators'
104 104
 }
105 105
 
106
-# import module snippets
107
-from ansible.module_utils.basic import AnsibleModule
108
-from ansible.module_utils.ec2 import boto_exception
106
+from ansible.module_utils.aws.core import AnsibleAWSModule, is_boto3_error_code
107
+from ansible.module_utils.ec2 import ec2_argument_spec
108
+from ansible.module_utils.ec2 import AWSRetry, camel_dict_to_snake_dict
109
+from ansible.module_utils.ec2 import boto3_tag_list_to_ansible_dict, ansible_dict_to_boto3_tag_list
110
+from ansible.module_utils.ec2 import compare_aws_tags
109 111
 from ansible.module_utils.six import string_types
110 112
 
111
-# import a class, we'll use a fully qualified path
112
-import ansible.module_utils.ec2
113
-
114
-import traceback
115 113
 import json
116 114
 
117 115
 try:
118 116
     import botocore
119
-    HAS_BOTO3 = True
120 117
 except ImportError:
121
-    HAS_BOTO3 = False
118
+    pass  # caught by AnsibleAWSModule
119
+
120
+
121
+@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
122
+def get_iam_roles_with_backoff(connection):
123
+    paginator = connection.get_paginator('list_roles')
124
+    return paginator.paginate().build_full_result()
125
+
126
+
127
+@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
128
+def get_kms_keys_with_backoff(connection):
129
+    paginator = connection.get_paginator('list_keys')
130
+    return paginator.paginate().build_full_result()
131
+
132
+
133
+@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
134
+def get_kms_aliases_with_backoff(connection):
135
+    paginator = connection.get_paginator('list_aliases')
136
+    return paginator.paginate().build_full_result()
137
+
138
+
139
+def get_kms_aliases_lookup(connection):
140
+    _aliases = dict()
141
+    for alias in get_kms_aliases_with_backoff(connection)['Aliases']:
142
+        # Not all aliases are actually associated with a key
143
+        if 'TargetKeyId' in alias:
144
+            # strip off leading 'alias/' and add it to key's aliases
145
+            if alias['TargetKeyId'] in _aliases:
146
+                _aliases[alias['TargetKeyId']].append(alias['AliasName'][6:])
147
+            else:
148
+                _aliases[alias['TargetKeyId']] = [alias['AliasName'][6:]]
149
+    return _aliases
150
+
151
+
152
+@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
153
+def get_kms_tags_with_backoff(connection, key_id, **kwargs):
154
+    return connection.list_resource_tags(KeyId=key_id, **kwargs)
155
+
156
+
157
+@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
158
+def get_kms_grants_with_backoff(connection, key_id):
159
+    params = dict(KeyId=key_id)
160
+    paginator = connection.get_paginator('list_grants')
161
+    return paginator.paginate(**params).build_full_result()
162
+
163
+
164
+@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
165
+def get_kms_metadata_with_backoff(connection, key_id):
166
+    return connection.describe_key(KeyId=key_id)
167
+
168
+
169
+@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
170
+def list_key_policies_with_backoff(connection, key_id):
171
+    paginator = connection.get_paginator('list_key_policies')
172
+    return paginator.paginate(KeyId=key_id).build_full_result()
173
+
174
+
175
+@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
176
+def get_key_policy_with_backoff(connection, key_id, policy_name):
177
+    return connection.get_key_policy(KeyId=key_id, PolicyName=policy_name)
178
+
179
+
180
+def get_kms_tags(connection, module, key_id):
181
+    # Handle pagination here as list_resource_tags does not have
182
+    # a paginator
183
+    kwargs = {}
184
+    tags = []
185
+    more = True
186
+    while more:
187
+        try:
188
+            tag_response = get_kms_tags_with_backoff(connection, key_id, **kwargs)
189
+            tags.extend(tag_response['Tags'])
190
+        except is_boto3_error_code('AccessDeniedException'):
191
+            tag_response = {}
192
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:  # pylint: disable=duplicate-except
193
+            module.fail_json_aws(e, msg="Failed to obtain key tags")
194
+        if tag_response.get('NextMarker'):
195
+            kwargs['Marker'] = tag_response['NextMarker']
196
+        else:
197
+            more = False
198
+    return tags
199
+
200
+
201
+def get_kms_policies(connection, module, key_id):
202
+    try:
203
+        policies = list_key_policies_with_backoff(connection, key_id)['PolicyNames']
204
+        return [get_key_policy_with_backoff(connection, key_id, policy)['Policy'] for
205
+                policy in policies]
206
+    except is_boto3_error_code('AccessDeniedException'):
207
+        return []
208
+    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:  # pylint: disable=duplicate-except
209
+        module.fail_json_aws(e, msg="Failed to obtain key policies")
210
+
211
+
212
+def key_matches_filter(key, filtr):
213
+    if filtr[0] == 'key-id':
214
+        return filtr[1] == key['key_id']
215
+    if filtr[0] == 'tag-key':
216
+        return filtr[1] in key['tags']
217
+    if filtr[0] == 'tag-value':
218
+        return filtr[1] in key['tags'].values()
219
+    if filtr[0] == 'alias':
220
+        return filtr[1] in key['aliases']
221
+    if filtr[0].startswith('tag:'):
222
+        return key['Tags'][filtr[0][4:]] == filtr[1]
223
+
224
+
225
+def key_matches_filters(key, filters):
226
+    if not filters:
227
+        return True
228
+    else:
229
+        return all([key_matches_filter(key, filtr) for filtr in filters.items()])
230
+
231
+
232
+def camel_to_snake_grant(grant):
233
+    ''' camel_to_snake_grant snakifies everything except the encryption context '''
234
+    constraints = grant.get('Constraints', {})
235
+    result = camel_dict_to_snake_dict(grant)
236
+    if 'EncryptionContextEquals' in constraints:
237
+        result['constraints']['encryption_context_equals'] = constraints['EncryptionContextEquals']
238
+    if 'EncryptionContextSubset' in constraints:
239
+        result['constraints']['encryption_context_subset'] = constraints['EncryptionContextSubset']
240
+    return result
241
+
242
+
243
+def get_key_details(connection, module, key_id):
244
+    try:
245
+        result = get_kms_metadata_with_backoff(connection, key_id)['KeyMetadata']
246
+    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
247
+        module.fail_json_aws(e, msg="Failed to obtain key metadata")
248
+    result['KeyArn'] = result.pop('Arn')
249
+
250
+    try:
251
+        aliases = get_kms_aliases_lookup(connection)
252
+    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
253
+        module.fail_json_aws(e, msg="Failed to obtain aliases")
254
+
255
+    result['aliases'] = aliases.get(result['KeyId'], [])
256
+
257
+    result = camel_dict_to_snake_dict(result)
258
+
259
+    # grants and tags get snakified differently
260
+    try:
261
+        result['grants'] = [camel_to_snake_grant(grant) for grant in
262
+                            get_kms_grants_with_backoff(connection, key_id)['Grants']]
263
+    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
264
+        module.fail_json_aws(e, msg="Failed to obtain key grants")
265
+    tags = get_kms_tags(connection, module, key_id)
266
+    result['tags'] = boto3_tag_list_to_ansible_dict(tags, 'TagKey', 'TagValue')
267
+    result['policies'] = get_kms_policies(connection, module, key_id)
268
+    return result
269
+
270
+
271
+def get_kms_facts(connection, module):
272
+    try:
273
+        keys = get_kms_keys_with_backoff(connection)['Keys']
274
+    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
275
+        module.fail_json_aws(e, msg="Failed to obtain keys")
276
+
277
+    return [get_key_details(connection, module, key['KeyId']) for key in keys]
278
+
279
+
280
+def convert_grant_params(grant, key):
281
+    grant_params = dict(KeyId=key['key_id'],
282
+                        GranteePrincipal=grant['grantee_principal'])
283
+    if grant.get('operations'):
284
+        grant_params['Operations'] = grant['operations']
285
+    if grant.get('retiring_principal'):
286
+        grant_params['RetiringPrincipal'] = grant['retiring_principal']
287
+    if grant.get('name'):
288
+        grant_params['Name'] = grant['name']
289
+    if grant.get('constraints'):
290
+        grant_params['Constraints'] = dict()
291
+        if grant['constraints'].get('encryption_context_subset'):
292
+            grant_params['Constraints']['EncryptionContextSubset'] = grant['constraints']['encryption_context_subset']
293
+        if grant['constraints'].get('encryption_context_equals'):
294
+            grant_params['Constraints']['EncryptionContextEquals'] = grant['constraints']['encryption_context_equals']
295
+    return grant_params
296
+
297
+
298
+def different_grant(existing_grant, desired_grant):
299
+    if existing_grant.get('grantee_principal') != desired_grant.get('grantee_principal'):
300
+        return True
301
+    if existing_grant.get('retiring_principal') != desired_grant.get('retiring_principal'):
302
+        return True
303
+    if set(existing_grant.get('operations', [])) != set(desired_grant.get('operations')):
304
+        return True
305
+    if existing_grant.get('constraints') != desired_grant.get('constraints'):
306
+        return True
307
+    return False
308
+
309
+
310
+def compare_grants(existing_grants, desired_grants, purge_grants=False):
311
+    existing_dict = dict((eg['name'], eg) for eg in existing_grants)
312
+    desired_dict = dict((dg['name'], dg) for dg in desired_grants)
313
+    to_add_keys = set(desired_dict.keys()) - set(existing_dict.keys())
314
+    if purge_grants:
315
+        to_remove_keys = set(existing_dict.keys()) - set(desired_dict.keys())
316
+    else:
317
+        to_remove_keys = set()
318
+    to_change_candidates = set(existing_dict.keys()) & set(desired_dict.keys())
319
+    for candidate in to_change_candidates:
320
+        if different_grant(existing_dict[candidate], desired_dict[candidate]):
321
+            to_add_keys.add(candidate)
322
+            to_remove_keys.add(candidate)
323
+
324
+    to_add = []
325
+    to_remove = []
326
+    for key in to_add_keys:
327
+        grant = desired_dict[key]
328
+        to_add.append(grant)
329
+    for key in to_remove_keys:
330
+        grant = existing_dict[key]
331
+        to_remove.append(grant)
332
+    return to_add, to_remove
333
+
334
+
335
+def ensure_enabled_disabled(connection, module, key):
336
+    changed = False
337
+    if key['key_state'] == 'Disabled' and module.params['enabled']:
338
+        try:
339
+            connection.enable_key(KeyId=key['key_id'])
340
+            changed = True
341
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
342
+            module.fail_json_aws(e, msg="Failed to enable key")
343
+
344
+    if key['key_state'] == 'Enabled' and not module.params['enabled']:
345
+        try:
346
+            connection.disable_key(KeyId=key['key_id'])
347
+            changed = True
348
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
349
+            module.fail_json_aws(e, msg="Failed to disable key")
350
+    return changed
351
+
352
+
353
+def update_key(connection, module, key):
354
+    changed = False
355
+    alias = module.params['alias']
356
+    if not alias.startswith('alias/'):
357
+        alias = 'alias/' + alias
358
+    aliases = get_kms_aliases_with_backoff(connection)['Aliases']
359
+    key_id = module.params.get('key_id')
360
+    if key_id:
361
+        # We will only add new aliases, not rename existing ones
362
+        if alias not in [_alias['AliasName'] for _alias in aliases]:
363
+            try:
364
+                connection.create_alias(KeyId=key_id, AliasName=alias)
365
+                changed = True
366
+            except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
367
+                module.fail_json_aws(msg="Failed create key alias")
368
+
369
+    if key['key_state'] == 'PendingDeletion':
370
+        try:
371
+            connection.cancel_key_deletion(KeyId=key['key_id'])
372
+            # key is disabled after deletion cancellation
373
+            # set this so that ensure_enabled_disabled works correctly
374
+            key['key_state'] = 'Disabled'
375
+            changed = True
376
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
377
+            module.fail_json_aws(e, msg="Failed to cancel key deletion")
378
+
379
+    changed = ensure_enabled_disabled(connection, module, key) or changed
380
+
381
+    description = module.params.get('description')
382
+    # don't update description if description is not set
383
+    # (means you can't remove a description completely)
384
+    if description and key['description'] != description:
385
+        try:
386
+            connection.update_key_description(KeyId=key['key_id'], Description=description)
387
+            changed = True
388
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
389
+            module.fail_json_aws(e, msg="Failed to update key description")
390
+
391
+    desired_tags = module.params.get('tags')
392
+    to_add, to_remove = compare_aws_tags(key['tags'], desired_tags,
393
+                                         module.params.get('purge_tags'))
394
+    if to_remove:
395
+        try:
396
+            connection.untag_resource(KeyId=key['key_id'], TagKeys=to_remove)
397
+            changed = True
398
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
399
+            module.fail_json_aws(e, msg="Unable to remove or update tag")
400
+    if to_add:
401
+        try:
402
+            connection.tag_resource(KeyId=key['key_id'],
403
+                                    Tags=[{'TagKey': tag_key, 'TagValue': desired_tags[tag_key]}
404
+                                          for tag_key in to_add])
405
+            changed = True
406
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
407
+            module.fail_json_aws(e, msg="Unable to add tag to key")
408
+
409
+    desired_grants = module.params.get('grants')
410
+    existing_grants = key['grants']
411
+
412
+    to_add, to_remove = compare_grants(existing_grants, desired_grants,
413
+                                       module.params.get('purge_grants'))
414
+    if to_remove:
415
+        for grant in to_remove:
416
+            try:
417
+                connection.retire_grant(KeyId=key['key_arn'], GrantId=grant['grant_id'])
418
+                changed = True
419
+            except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
420
+                module.fail_json_aws(e, msg="Unable to retire grant")
421
+
422
+    if to_add:
423
+        for grant in to_add:
424
+            grant_params = convert_grant_params(grant, key)
425
+            try:
426
+                connection.create_grant(**grant_params)
427
+                changed = True
428
+            except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
429
+                module.fail_json_aws(e, msg="Unable to create grant")
430
+
431
+    # make results consistent with kms_facts
432
+    result = get_key_details(connection, module, key['key_id'])
433
+    module.exit_json(changed=changed, **camel_dict_to_snake_dict(result))
434
+
435
+
436
+def create_key(connection, module):
437
+    params = dict(BypassPolicyLockoutSafetyCheck=False,
438
+                  Tags=ansible_dict_to_boto3_tag_list(module.params['tags']),
439
+                  KeyUsage='ENCRYPT_DECRYPT',
440
+                  Origin='AWS_KMS')
441
+    if module.params.get('description'):
442
+        params['Description'] = module.params['description']
443
+    if module.params.get('policy'):
444
+        params['Policy'] = module.params['policy']
445
+
446
+    try:
447
+        result = connection.create_key(**params)['KeyMetadata']
448
+    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
449
+        module.fail_json_aws(e, msg="Failed to create initial key")
450
+    key = get_key_details(connection, module, result['KeyId'])
451
+
452
+    alias = module.params['alias']
453
+    if not alias.startswith('alias/'):
454
+        alias = 'alias/' + alias
455
+    try:
456
+        connection.create_alias(AliasName=alias, TargetKeyId=key['key_id'])
457
+    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
458
+        module.fail_json_aws(e, msg="Failed to create alias")
459
+
460
+    ensure_enabled_disabled(connection, module, key)
461
+    for grant in module.params.get('grants'):
462
+        grant_params = convert_grant_params(grant, key)
463
+        try:
464
+            connection.create_grant(**grant_params)
465
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
466
+            module.fail_json_aws(e, msg="Failed to add grant to key")
467
+
468
+    # make results consistent with kms_facts
469
+    result = get_key_details(connection, module, key['key_id'])
470
+    module.exit_json(changed=True, **camel_dict_to_snake_dict(result))
471
+
472
+
473
+def delete_key(connection, module, key):
474
+    changed = False
475
+
476
+    if key['key_state'] != 'PendingDeletion':
477
+        try:
478
+            connection.schedule_key_deletion(KeyId=key['key_id'])
479
+            changed = True
480
+        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
481
+            module.fail_json_aws(e, msg="Failed to schedule key for deletion")
482
+
483
+    result = get_key_details(connection, module, key['key_id'])
484
+    module.exit_json(changed=changed, **camel_dict_to_snake_dict(result))
122 485
 
123 486
 
124 487
 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
184 184
 
185 185
                     if role_arn not in statement['Principal']['AWS']:  # needs to be added.
186 186
                         changes_needed[granttype] = 'add'
187
-                        statement['Principal']['AWS'].append(role_arn)
187
+                        if not dry_run:
188
+                            statement['Principal']['AWS'].append(role_arn)
188 189
                 elif role_arn in statement['Principal']['AWS']:  # not one the places the role should be
189 190
                     changes_needed[granttype] = 'remove'
190
-                    statement['Principal']['AWS'].remove(role_arn)
191
+                    if not dry_run:
192
+                        statement['Principal']['AWS'].remove(role_arn)
191 193
 
192 194
             elif mode == 'deny' and statement['Sid'] == statement_label[granttype] and role_arn in statement['Principal']['AWS']:
193 195
                 # we don't selectively deny. that's a grant with a
194 196
                 # smaller list. so deny=remove all of this arn.
195 197
                 changes_needed[granttype] = 'remove'
196
-                statement['Principal']['AWS'].remove(role_arn)
198
+                if not dry_run:
199
+                    statement['Principal']['AWS'].remove(role_arn)
197 200
 
198 201
     try:
199 202
         if len(changes_needed) and not dry_run:
... ...
@@ -236,43 +844,40 @@ def assert_policy_shape(policy):
236 236
 
237 237
 
238 238
 def main():
239
-    argument_spec = ansible.module_utils.ec2.ec2_argument_spec()
240
-    argument_spec.update(dict(
241
-        mode=dict(choices=['grant', 'deny'], default='grant'),
242
-        key_alias=dict(required=False, type='str'),
243
-        key_arn=dict(required=False, type='str'),
244
-        role_name=dict(required=False, type='str'),
245
-        role_arn=dict(required=False, type='str'),
246
-        grant_types=dict(required=False, type='list'),
247
-        clean_invalid_entries=dict(type='bool', default=True),
248
-    )
239
+    argument_spec = ec2_argument_spec()
240
+    argument_spec.update(
241
+        dict(
242
+            mode=dict(choices=['grant', 'deny'], default='grant'),
243
+            alias=dict(aliases=['key_alias']),
244
+            role_name=dict(),
245
+            role_arn=dict(),
246
+            grant_types=dict(type='list'),
247
+            clean_invalid_entries=dict(type='bool', default=True),
248
+            key_id=dict(aliases=['key_arn']),
249
+            description=dict(),
250
+            enabled=dict(type='bool', default=True),
251
+            tags=dict(type='dict', default={}),
252
+            purge_tags=dict(type='bool', default=False),
253
+            grants=dict(type='list', default=[]),
254
+            policy=dict(),
255
+            purge_grants=dict(type='bool', default=False),
256
+            state=dict(default='present', choices=['present', 'absent']),
257
+        )
249 258
     )
250 259
 
251
-    module = AnsibleModule(
260
+    module = AnsibleAWSModule(
252 261
         supports_check_mode=True,
253 262
         argument_spec=argument_spec,
254
-        required_one_of=[['key_alias', 'key_arn'], ['role_name', 'role_arn']],
255
-        required_if=[['mode', 'grant', ['grant_types']]]
263
+        required_one_of=[['alias', 'key_id']],
256 264
     )
257
-    if not HAS_BOTO3:
258
-        module.fail_json(msg='boto3 required for this module')
259 265
 
260 266
     result = {}
261 267
     mode = module.params['mode']
262 268
 
263
-    try:
264
-        region, ec2_url, aws_connect_kwargs = ansible.module_utils.ec2.get_aws_connection_info(module, boto3=True)
265
-        kms = ansible.module_utils.ec2.boto3_conn(module, conn_type='client', resource='kms', region=region, endpoint=ec2_url, **aws_connect_kwargs)
266
-        iam = ansible.module_utils.ec2.boto3_conn(module, conn_type='client', resource='iam', region=region, endpoint=ec2_url, **aws_connect_kwargs)
267
-    except botocore.exceptions.NoCredentialsError as e:
268
-        module.fail_json(msg='cannot connect to AWS', exception=traceback.format_exc())
269
-
270
-    try:
271
-        if module.params['key_alias'] and not module.params['key_arn']:
272
-            module.params['key_arn'] = get_arn_from_kms_alias(kms, module.params['key_alias'])
273
-        if not module.params['key_arn']:
274
-            module.fail_json(msg='key_arn or key_alias is required to {}'.format(mode))
269
+    kms = module.client('kms')
270
+    iam = module.client('iam')
275 271
 
272
+    if module.params['grant_types'] or mode == 'deny':
276 273
         if module.params['role_name'] and not module.params['role_arn']:
277 274
             module.params['role_arn'] = get_arn_from_role_name(iam, module.params['role_name'])
278 275
         if not module.params['role_arn']:
... ...
@@ -290,11 +895,31 @@ def main():
290 290
                        clean_invalid_entries=module.params['clean_invalid_entries'])
291 291
         result.update(ret)
292 292
 
293
-    except Exception as err:
294
-        error_msg = boto_exception(err)
295
-        module.fail_json(msg=error_msg, exception=traceback.format_exc())
296
-
297
-    module.exit_json(**result)
293
+        module.exit_json(**result)
294
+    else:
295
+        all_keys = get_kms_facts(kms, module)
296
+        key_id = module.params.get('key_id')
297
+        alias = module.params.get('alias')
298
+        if key_id:
299
+            filtr = ('key-id', key_id)
300
+        elif module.params.get('alias'):
301
+            filtr = ('alias', alias)
302
+
303
+        candidate_keys = [key for key in all_keys if key_matches_filter(key, filtr)]
304
+
305
+        if module.params.get('state') == 'present':
306
+            if candidate_keys:
307
+                update_key(kms, module, candidate_keys[0])
308
+            else:
309
+                if module.params.get('key_id'):
310
+                    module.fail_json(msg="Could not find key with id %s to update")
311
+                else:
312
+                    create_key(kms, module)
313
+        else:
314
+            if candidate_keys:
315
+                delete_key(kms, module, candidate_keys[0])
316
+            else:
317
+                module.exit_json(changed=False)
298 318
 
299 319
 
300 320
 if __name__ == '__main__':
301 321
new file mode 100644
... ...
@@ -0,0 +1,3 @@
0
+cloud/aws
1
+aws_kms_facts
2
+unsupported
0 3
new file mode 100644
... ...
@@ -0,0 +1,3 @@
0
+dependencies:
1
+  - prepare_tests
2
+  - setup_ec2
0 3
new file mode 100644
... ...
@@ -0,0 +1,394 @@
0
+- block:
1
+
2
+    # ============================================================
3
+    - name: See whether key exists and its current state
4
+      aws_kms_facts:
5
+        region: "{{ aws_region }}"
6
+        aws_access_key: "{{ aws_access_key }}"
7
+        aws_secret_key: "{{ aws_secret_key }}"
8
+        security_token: "{{ security_token }}"
9
+        filters:
10
+          alias: "{{ resource_prefix }}-kms"
11
+
12
+    - name: create a key
13
+      aws_kms:
14
+        region: "{{ aws_region }}"
15
+        aws_access_key: "{{ aws_access_key }}"
16
+        aws_secret_key: "{{ aws_secret_key }}"
17
+        security_token: "{{ security_token }}"
18
+        alias: "{{ resource_prefix }}-kms"
19
+        state: present
20
+        enabled: yes
21
+      register: create_kms
22
+
23
+    - name: assert that state is enabled
24
+      assert:
25
+        that:
26
+          - create_kms.key_state == "Enabled"
27
+
28
+    - name: find facts about the key
29
+      aws_kms_facts:
30
+        region: "{{ aws_region }}"
31
+        aws_access_key: "{{ aws_access_key }}"
32
+        aws_secret_key: "{{ aws_secret_key }}"
33
+        security_token: "{{ security_token }}"
34
+        filters:
35
+          alias: "{{ resource_prefix }}-kms"
36
+      register: new_key
37
+
38
+    - name: check that a key was found
39
+      assert:
40
+        that:
41
+          - new_key["keys"]|length == 1
42
+
43
+    - name: create an IAM role that can do nothing
44
+      iam_role:
45
+        name: "{{ resource_prefix }}-kms-role"
46
+        state: present
47
+        assume_role_policy_document: '{"Version": "2012-10-17", "Statement": {"Action": "sts:AssumeRole", "Principal": {"Service": "ec2.amazonaws.com"}, "Effect": "Deny"} }'
48
+        aws_access_key: "{{ aws_access_key }}"
49
+        aws_secret_key: "{{ aws_secret_key }}"
50
+        security_token: "{{ security_token }}"
51
+      register: iam_role_result
52
+
53
+    - name: grant user-style access to production secrets
54
+      aws_kms:
55
+        mode: grant
56
+        key_alias: "alias/{{ resource_prefix }}-kms"
57
+        role_name: "{{ resource_prefix }}-kms-role"
58
+        grant_types: "role,role grant"
59
+        aws_access_key: "{{ aws_access_key }}"
60
+        aws_secret_key: "{{ aws_secret_key }}"
61
+        security_token: "{{ security_token }}"
62
+        region: "{{ aws_region }}"
63
+
64
+    - name: find facts about the key
65
+      aws_kms_facts:
66
+        region: "{{ aws_region }}"
67
+        aws_access_key: "{{ aws_access_key }}"
68
+        aws_secret_key: "{{ aws_secret_key }}"
69
+        security_token: "{{ security_token }}"
70
+        filters:
71
+          alias: "{{ resource_prefix }}-kms"
72
+      register: new_key
73
+
74
+    - name: remove access to production secrets from role
75
+      aws_kms:
76
+        mode: deny
77
+        key_alias: "alias/{{ resource_prefix }}-kms"
78
+        role_arn: "{{ iam_role_result.iam_role.arn }}"
79
+        aws_access_key: "{{ aws_access_key }}"
80
+        aws_secret_key: "{{ aws_secret_key }}"
81
+        security_token: "{{ security_token }}"
82
+        region: "{{ aws_region }}"
83
+
84
+    - name: find facts about the key
85
+      aws_kms_facts:
86
+        region: "{{ aws_region }}"
87
+        aws_access_key: "{{ aws_access_key }}"
88
+        aws_secret_key: "{{ aws_secret_key }}"
89
+        security_token: "{{ security_token }}"
90
+        filters:
91
+          alias: "{{ resource_prefix }}-kms"
92
+      register: new_key
93
+
94
+    - fail:
95
+
96
+    - name: set aws environment base fact
97
+      set_fact:
98
+        aws_environment_base:
99
+          AWS_ACCESS_KEY_ID: "{{ aws_access_key }}"
100
+          AWS_SECRET_ACCESS_KEY: "{{ aws_secret_key }}"
101
+      no_log: True
102
+
103
+    - name: set aws environment fact
104
+      set_fact:
105
+        aws_environment: "{{ aws_environment_base|combine(security_token|ternary({'AWS_SECURITY_TOKEN': security_token}, {})) }}"
106
+      no_log: True
107
+
108
+    - name: get ARN of calling user
109
+      command: python -c 'import boto3,json; sts = boto3.client("sts"); print json.dumps(sts.get_caller_identity())'
110
+      changed_when: False
111
+      environment: "{{ aws_environment }}"
112
+      register: sts_get_caller_results
113
+
114
+    - name: set caller_arn
115
+      set_fact:
116
+        caller_arn: "{{ (sts_get_caller_results.stdout|from_json).Arn }}"
117
+
118
+    - name: Allow the IAM role to use a specific Encryption Context
119
+      aws_kms:
120
+        region: "{{ aws_region }}"
121
+        aws_access_key: "{{ aws_access_key }}"
122
+        aws_secret_key: "{{ aws_secret_key }}"
123
+        security_token: "{{ security_token }}"
124
+        alias: "{{ resource_prefix }}-kms"
125
+        state: present
126
+        purge_grants: yes
127
+        purge_tags: yes
128
+        grants:
129
+          - name: test_grant
130
+            grantee_principal: "{{ iam_role_result.iam_role.arn }}"
131
+            retiring_principal: "{{ caller_arn }}"
132
+            constraints:
133
+              encryption_context_equals:
134
+                environment: test
135
+                application: testapp
136
+            operations:
137
+              - Decrypt
138
+              - RetireGrant
139
+      register: grant_one
140
+
141
+    - name: assert grant added
142
+      assert:
143
+        that:
144
+          - grant_one.changed
145
+          - grant_one.grants|length == 1
146
+
147
+    - name: Add a second grant
148
+      kms:
149
+        region: "{{ aws_region }}"
150
+        aws_access_key: "{{ aws_access_key }}"
151
+        aws_secret_key: "{{ aws_secret_key }}"
152
+        security_token: "{{ security_token }}"
153
+        alias: "{{ resource_prefix }}-kms"
154
+        state: present
155
+        grants:
156
+          - name: another_grant
157
+            grantee_principal: "{{ iam_role_result.iam_role.arn }}"
158
+            retiring_principal: "{{ caller_arn }}"
159
+            constraints:
160
+              encryption_context_equals:
161
+                Environment: second
162
+                Application: anotherapp
163
+            operations:
164
+              - Decrypt
165
+              - RetireGrant
166
+      register: grant_two
167
+
168
+    - name: assert grant added
169
+      assert:
170
+        that:
171
+          - grant_two.changed
172
+          - grant_two.grants|length == 2
173
+
174
+    - name: Add a second grant again
175
+      aws_kms:
176
+        region: "{{ aws_region }}"
177
+        aws_access_key: "{{ aws_access_key }}"
178
+        aws_secret_key: "{{ aws_secret_key }}"
179
+        security_token: "{{ security_token }}"
180
+        alias: "{{ resource_prefix }}-kms"
181
+        state: present
182
+        grants:
183
+          - name: another_grant
184
+            grantee_principal: "{{ iam_role_result.iam_role.arn }}"
185
+            retiring_principal: "{{ caller_arn }}"
186
+            constraints:
187
+              encryption_context_equals:
188
+                Environment: second
189
+                Application: anotherapp
190
+            operations:
191
+              - Decrypt
192
+              - RetireGrant
193
+      register: grant_two_again
194
+
195
+    - name: assert grant added
196
+      assert:
197
+        that:
198
+          - not grant_two_again.changed
199
+          - grant_two_again.grants|length == 2
200
+
201
+    - name: Update the grants with purge_grants set
202
+      aws_kms:
203
+        region: "{{ aws_region }}"
204
+        aws_access_key: "{{ aws_access_key }}"
205
+        aws_secret_key: "{{ aws_secret_key }}"
206
+        security_token: "{{ security_token }}"
207
+        alias: "{{ resource_prefix }}-kms"
208
+        state: present
209
+        purge_grants: yes
210
+        grants:
211
+          - name: third_grant
212
+            grantee_principal: "{{ iam_role_result.iam_role.arn }}"
213
+            retiring_principal: "{{ caller_arn }}"
214
+            constraints:
215
+              encryption_context_equals:
216
+                environment: third
217
+                application: onemoreapp
218
+            operations:
219
+              - Decrypt
220
+              - RetireGrant
221
+      register: grant_three
222
+
223
+    - name: assert grants replaced
224
+      assert:
225
+        that:
226
+          - grant_three.changed
227
+          - grant_three.grants|length == 1
228
+
229
+    - name: update third grant to change encryption context equals to subset
230
+      aws_kms:
231
+        region: "{{ aws_region }}"
232
+        aws_access_key: "{{ aws_access_key }}"
233
+        aws_secret_key: "{{ aws_secret_key }}"
234
+        security_token: "{{ security_token }}"
235
+        alias: "{{ resource_prefix }}-kms"
236
+        state: present
237
+        grants:
238
+          - name: third_grant
239
+            grantee_principal: "{{ iam_role_result.iam_role.arn }}"
240
+            retiring_principal: "{{ caller_arn }}"
241
+            constraints:
242
+              encryption_context_subset:
243
+                environment: third
244
+                application: onemoreapp
245
+            operations:
246
+              - Decrypt
247
+              - RetireGrant
248
+      register: grant_three_update
249
+
250
+    - name: assert grants replaced
251
+      assert:
252
+        that:
253
+          - "grant_three_update.changed"
254
+          - "grant_three_update.grants|length == 1"
255
+          - "'encryption_context_equals' not in grant_three_update.grants[0].constraints"
256
+          - "'encryption_context_subset' in grant_three_update.grants[0].constraints"
257
+
258
+    - name: tag encryption key
259
+      aws_kms:
260
+        region: "{{ aws_region }}"
261
+        aws_access_key: "{{ aws_access_key }}"
262
+        aws_secret_key: "{{ aws_secret_key }}"
263
+        security_token: "{{ security_token }}"
264
+        alias: "{{ resource_prefix }}-kms"
265
+        state: present
266
+        tags:
267
+          tag_one: tag_one
268
+          tag_two: tag_two
269
+      register: tag_kms
270
+
271
+    - name: assert tags added and grants remain in place
272
+      assert:
273
+        that:
274
+          - "tag_kms.changed"
275
+          - "tag_kms.grants|length == 1"
276
+          - "'tag_one' in tag_kms.tags"
277
+          - "'tag_two' in tag_kms.tags"
278
+
279
+    - name: add, replace, remove tags
280
+      aws_kms:
281
+        region: "{{ aws_region }}"
282
+        aws_access_key: "{{ aws_access_key }}"
283
+        aws_secret_key: "{{ aws_secret_key }}"
284
+        security_token: "{{ security_token }}"
285
+        alias: "{{ resource_prefix }}-kms"
286
+        state: present
287
+        purge_tags: yes
288
+        tags:
289
+          tag_two: tag_two_updated
290
+          tag_three: tag_three
291
+      register: tag_kms_update
292
+
293
+    - name: assert tags correctly changed
294
+      assert:
295
+        that:
296
+          - "tag_kms_update.changed"
297
+          - "'tag_one' not in tag_kms_update.tags"
298
+          - "'tag_two' in tag_kms_update.tags"
299
+          - "tag_kms_update.tags.tag_two == 'tag_two_updated'"
300
+          - "'tag_three' in tag_kms_update.tags"
301
+
302
+    - name: make no real tag change
303
+      aws_kms:
304
+        region: "{{ aws_region }}"
305
+        aws_access_key: "{{ aws_access_key }}"
306
+        aws_secret_key: "{{ aws_secret_key }}"
307
+        security_token: "{{ security_token }}"
308
+        alias: "{{ resource_prefix }}-kms"
309
+        state: present
310
+      register: tag_kms_no_update
311
+
312
+    - name: assert no change to tags
313
+      assert:
314
+        that:
315
+          - "not tag_kms_no_update.changed"
316
+          - "'tag_one' not in tag_kms_no_update.tags"
317
+          - "'tag_two' in tag_kms_no_update.tags"
318
+          - "tag_kms_no_update.tags.tag_two == 'tag_two_updated'"
319
+          - "'tag_three' in tag_kms_no_update.tags"
320
+
321
+    - name: update the key's description and disable it
322
+      aws_kms:
323
+        region: "{{ aws_region }}"
324
+        aws_access_key: "{{ aws_access_key }}"
325
+        aws_secret_key: "{{ aws_secret_key }}"
326
+        security_token: "{{ security_token }}"
327
+        alias: "{{ resource_prefix }}-kms"
328
+        state: present
329
+        description: test key for testing
330
+        enabled: no
331
+      register: update_key
332
+
333
+    - name: assert that state is enabled
334
+      assert:
335
+        that:
336
+          - update_key.description == "test key for testing"
337
+          - update_key.key_state == "Disabled"
338
+          - update_key.changed
339
+
340
+    - name: delete the key
341
+      aws_kms:
342
+        region: "{{ aws_region }}"
343
+        aws_access_key: "{{ aws_access_key }}"
344
+        aws_secret_key: "{{ aws_secret_key }}"
345
+        security_token: "{{ security_token }}"
346
+        alias: "{{ resource_prefix }}-kms"
347
+        state: absent
348
+      register: delete_kms
349
+
350
+    - name: assert that state is pending deletion
351
+      assert:
352
+        that:
353
+          - delete_kms.key_state == "PendingDeletion"
354
+          - delete_kms.changed
355
+
356
+    - name: undelete and enable the key
357
+      aws_kms:
358
+        region: "{{ aws_region }}"
359
+        aws_access_key: "{{ aws_access_key }}"
360
+        aws_secret_key: "{{ aws_secret_key }}"
361
+        security_token: "{{ security_token }}"
362
+        alias: "{{ resource_prefix }}-kms"
363
+        state: present
364
+        enabled: yes
365
+      register: undelete_kms
366
+
367
+    - name: assert that state is enabled
368
+      assert:
369
+        that:
370
+          - undelete_kms.key_state == "Enabled"
371
+          - undelete_kms.changed
372
+
373
+  always:
374
+
375
+    # ============================================================
376
+    - name: finish off by deleting key
377
+      aws_kms:
378
+        state: absent
379
+        region: "{{ aws_region }}"
380
+        aws_access_key: "{{ aws_access_key }}"
381
+        aws_secret_key: "{{ aws_secret_key }}"
382
+        security_token: "{{ security_token }}"
383
+        alias: "{{ resource_prefix }}-kms"
384
+      register: destroy_result
385
+
386
+    - name: remove the IAM role
387
+      iam_role:
388
+        name: "{{ resource_prefix }}-kms-role"
389
+        state: absent
390
+        aws_access_key: "{{ aws_access_key }}"
391
+        aws_secret_key: "{{ aws_secret_key }}"
392
+        security_token: "{{ security_token }}"
393
+      register: iam_role_result