Skip to content

Commit 44daa2d

Browse files
authored
Refactor iam_managed_policy module and add integration tests (#893)
Refactor iam_managed_policy module and add integration tests SUMMARY Refactor iam_managed_policy module to: Improve AWS retry backoff logic Add check_mode support Fix module exit on updates to policies when no changes are present Other changes: Add disabled integration tests ISSUE TYPE Bugfix Pull Request COMPONENT NAME iam_managed_policy ADDITIONAL INFORMATION Backoff logic only partially covered the module, and it didn't support check_mode or have any integration tests. Due to the nature of the IAM based modules the tests are intentionally disabled but have been run locally: ansible-test integration iam_managed_policy --allow-unsupported --docker PLAY RECAP ********************************************************************* testhost : ok=20 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 AWS ACTIONS: ['iam:CreatePolicy', 'iam:CreatePolicyVersion', 'iam:DeletePolicy', 'iam:DeletePolicyVersion', 'iam:GetPolicy', 'iam:GetPolicyVersion', 'iam:ListEntitiesForPolicy', 'iam:ListPolicies', 'iam:ListPolicyVersions', 'iam:SetDefaultPolicyVersion'] Reviewed-by: Alina Buzachis <None> Reviewed-by: Markus Bergholz <[email protected]>
1 parent c91acf6 commit 44daa2d

File tree

5 files changed

+284
-87
lines changed

5 files changed

+284
-87
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
minor_changes:
2+
- iam_managed_policy - refactor module adding ``check_mode`` and better AWSRetry backoff logic (https://github.com/ansible-collections/community.aws/pull/893).

plugins/modules/iam_managed_policy.py

Lines changed: 114 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
__metaclass__ = type
77

88

9-
DOCUMENTATION = '''
9+
DOCUMENTATION = r'''
1010
---
1111
module: iam_managed_policy
1212
version_added: 1.0.0
@@ -55,7 +55,7 @@
5555
- amazon.aws.ec2
5656
'''
5757

58-
EXAMPLES = '''
58+
EXAMPLES = r'''
5959
# Create Policy ex nihilo
6060
- name: Create IAM Managed Policy
6161
community.aws.iam_managed_policy:
@@ -107,11 +107,12 @@
107107
state: absent
108108
'''
109109

110-
RETURN = '''
110+
RETURN = r'''
111111
policy:
112112
description: Returns the policy json structure, when state == absent this will return the value of the removed policy.
113113
returned: success
114-
type: str
114+
type: complex
115+
contains: {}
115116
sample: '{
116117
"arn": "arn:aws:iam::aws:policy/AdministratorAccess "
117118
"attachment_count": 0,
@@ -142,14 +143,14 @@
142143

143144

144145
@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0)
145-
def list_policies_with_backoff(iam):
146-
paginator = iam.get_paginator('list_policies')
146+
def list_policies_with_backoff():
147+
paginator = client.get_paginator('list_policies')
147148
return paginator.paginate(Scope='Local').build_full_result()
148149

149150

150-
def get_policy_by_name(module, iam, name):
151+
def get_policy_by_name(name):
151152
try:
152-
response = list_policies_with_backoff(iam)
153+
response = list_policies_with_backoff()
153154
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
154155
module.fail_json_aws(e, msg="Couldn't list policies")
155156
for policy in response['Policies']:
@@ -158,32 +159,36 @@ def get_policy_by_name(module, iam, name):
158159
return None
159160

160161

161-
def delete_oldest_non_default_version(module, iam, policy):
162+
def delete_oldest_non_default_version(policy):
162163
try:
163-
versions = [v for v in iam.list_policy_versions(PolicyArn=policy['Arn'])['Versions']
164+
versions = [v for v in client.list_policy_versions(PolicyArn=policy['Arn'])['Versions']
164165
if not v['IsDefaultVersion']]
165166
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
166167
module.fail_json_aws(e, msg="Couldn't list policy versions")
167168
versions.sort(key=lambda v: v['CreateDate'], reverse=True)
168169
for v in versions[-1:]:
169170
try:
170-
iam.delete_policy_version(PolicyArn=policy['Arn'], VersionId=v['VersionId'])
171+
client.delete_policy_version(PolicyArn=policy['Arn'], VersionId=v['VersionId'])
171172
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
172173
module.fail_json_aws(e, msg="Couldn't delete policy version")
173174

174175

175176
# This needs to return policy_version, changed
176-
def get_or_create_policy_version(module, iam, policy, policy_document):
177+
def get_or_create_policy_version(policy, policy_document):
177178
try:
178-
versions = iam.list_policy_versions(PolicyArn=policy['Arn'])['Versions']
179+
versions = client.list_policy_versions(PolicyArn=policy['Arn'])['Versions']
179180
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
180181
module.fail_json_aws(e, msg="Couldn't list policy versions")
182+
181183
for v in versions:
182184
try:
183-
document = iam.get_policy_version(PolicyArn=policy['Arn'],
184-
VersionId=v['VersionId'])['PolicyVersion']['Document']
185+
document = client.get_policy_version(PolicyArn=policy['Arn'], VersionId=v['VersionId'])['PolicyVersion']['Document']
185186
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
186187
module.fail_json_aws(e, msg="Couldn't get policy version {0}".format(v['VersionId']))
188+
189+
if module.check_mode and compare_policies(document, json.loads(to_native(policy_document))):
190+
return v, True
191+
187192
# If the current policy matches the existing one
188193
if not compare_policies(document, json.loads(to_native(policy_document))):
189194
return v, False
@@ -195,71 +200,145 @@ def get_or_create_policy_version(module, iam, policy, policy_document):
195200
# and if that doesn't work, delete the oldest non default policy version
196201
# and try again.
197202
try:
198-
version = iam.create_policy_version(PolicyArn=policy['Arn'], PolicyDocument=policy_document)['PolicyVersion']
203+
version = client.create_policy_version(PolicyArn=policy['Arn'], PolicyDocument=policy_document)['PolicyVersion']
199204
return version, True
200205
except is_boto3_error_code('LimitExceeded'):
201-
delete_oldest_non_default_version(module, iam, policy)
206+
delete_oldest_non_default_version(policy)
202207
try:
203-
version = iam.create_policy_version(PolicyArn=policy['Arn'], PolicyDocument=policy_document)['PolicyVersion']
208+
version = client.create_policy_version(PolicyArn=policy['Arn'], PolicyDocument=policy_document)['PolicyVersion']
204209
return version, True
205210
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as second_e:
206211
module.fail_json_aws(second_e, msg="Couldn't create policy version")
207212
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except
208213
module.fail_json_aws(e, msg="Couldn't create policy version")
209214

210215

211-
def set_if_default(module, iam, policy, policy_version, is_default):
216+
def set_if_default(policy, policy_version, is_default):
212217
if is_default and not policy_version['IsDefaultVersion']:
213218
try:
214-
iam.set_default_policy_version(PolicyArn=policy['Arn'], VersionId=policy_version['VersionId'])
219+
client.set_default_policy_version(PolicyArn=policy['Arn'], VersionId=policy_version['VersionId'])
215220
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
216221
module.fail_json_aws(e, msg="Couldn't set default policy version")
217222
return True
218223
return False
219224

220225

221-
def set_if_only(module, iam, policy, policy_version, is_only):
226+
def set_if_only(policy, policy_version, is_only):
222227
if is_only:
223228
try:
224-
versions = [v for v in iam.list_policy_versions(PolicyArn=policy['Arn'])[
229+
versions = [v for v in client.list_policy_versions(PolicyArn=policy['Arn'])[
225230
'Versions'] if not v['IsDefaultVersion']]
226231
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
227232
module.fail_json_aws(e, msg="Couldn't list policy versions")
228233
for v in versions:
229234
try:
230-
iam.delete_policy_version(PolicyArn=policy['Arn'], VersionId=v['VersionId'])
235+
client.delete_policy_version(PolicyArn=policy['Arn'], VersionId=v['VersionId'])
231236
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
232237
module.fail_json_aws(e, msg="Couldn't delete policy version")
233238
return len(versions) > 0
234239
return False
235240

236241

237-
def detach_all_entities(module, iam, policy, **kwargs):
242+
def detach_all_entities(policy, **kwargs):
238243
try:
239-
entities = iam.list_entities_for_policy(PolicyArn=policy['Arn'], **kwargs)
244+
entities = client.list_entities_for_policy(PolicyArn=policy['Arn'], **kwargs)
240245
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
241246
module.fail_json_aws(e, msg="Couldn't detach list entities for policy {0}".format(policy['PolicyName']))
242247

243248
for g in entities['PolicyGroups']:
244249
try:
245-
iam.detach_group_policy(PolicyArn=policy['Arn'], GroupName=g['GroupName'])
250+
client.detach_group_policy(PolicyArn=policy['Arn'], GroupName=g['GroupName'])
246251
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
247252
module.fail_json_aws(e, msg="Couldn't detach group policy {0}".format(g['GroupName']))
248253
for u in entities['PolicyUsers']:
249254
try:
250-
iam.detach_user_policy(PolicyArn=policy['Arn'], UserName=u['UserName'])
255+
client.detach_user_policy(PolicyArn=policy['Arn'], UserName=u['UserName'])
251256
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
252257
module.fail_json_aws(e, msg="Couldn't detach user policy {0}".format(u['UserName']))
253258
for r in entities['PolicyRoles']:
254259
try:
255-
iam.detach_role_policy(PolicyArn=policy['Arn'], RoleName=r['RoleName'])
260+
client.detach_role_policy(PolicyArn=policy['Arn'], RoleName=r['RoleName'])
256261
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
257262
module.fail_json_aws(e, msg="Couldn't detach role policy {0}".format(r['RoleName']))
258263
if entities['IsTruncated']:
259-
detach_all_entities(module, iam, policy, marker=entities['Marker'])
264+
detach_all_entities(policy, marker=entities['Marker'])
265+
266+
267+
def create_or_update_policy(existing_policy):
268+
name = module.params.get('policy_name')
269+
description = module.params.get('policy_description')
270+
default = module.params.get('make_default')
271+
only = module.params.get('only_version')
272+
273+
policy = None
274+
275+
if module.params.get('policy') is not None:
276+
policy = json.dumps(json.loads(module.params.get('policy')))
277+
278+
if existing_policy is None:
279+
if module.check_mode:
280+
module.exit_json(changed=True)
281+
282+
# Create policy when none already exists
283+
try:
284+
rvalue = client.create_policy(PolicyName=name, Path='/', PolicyDocument=policy, Description=description)
285+
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
286+
module.fail_json_aws(e, msg="Couldn't create policy {0}".format(name))
287+
288+
module.exit_json(changed=True, policy=camel_dict_to_snake_dict(rvalue['Policy']))
289+
else:
290+
policy_version, changed = get_or_create_policy_version(existing_policy, policy)
291+
changed = set_if_default(existing_policy, policy_version, default) or changed
292+
changed = set_if_only(existing_policy, policy_version, only) or changed
293+
294+
# If anything has changed we need to refresh the policy
295+
if changed:
296+
try:
297+
updated_policy = client.get_policy(PolicyArn=existing_policy['Arn'])['Policy']
298+
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
299+
module.fail_json(msg="Couldn't get policy")
300+
301+
module.exit_json(changed=changed, policy=camel_dict_to_snake_dict(updated_policy))
302+
else:
303+
module.exit_json(changed=changed, policy=camel_dict_to_snake_dict(existing_policy))
304+
305+
306+
def delete_policy(existing_policy):
307+
# Check for existing policy
308+
if existing_policy:
309+
if module.check_mode:
310+
module.exit_json(changed=True)
311+
312+
# Detach policy
313+
detach_all_entities(existing_policy)
314+
# Delete Versions
315+
try:
316+
versions = client.list_policy_versions(PolicyArn=existing_policy['Arn'])['Versions']
317+
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
318+
module.fail_json_aws(e, msg="Couldn't list policy versions")
319+
for v in versions:
320+
if not v['IsDefaultVersion']:
321+
try:
322+
client.delete_policy_version(PolicyArn=existing_policy['Arn'], VersionId=v['VersionId'])
323+
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
324+
module.fail_json_aws(
325+
e, msg="Couldn't delete policy version {0}".format(v['VersionId']))
326+
# Delete policy
327+
try:
328+
client.delete_policy(PolicyArn=existing_policy['Arn'])
329+
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
330+
module.fail_json_aws(e, msg="Couldn't delete policy {0}".format(existing_policy['PolicyName']))
331+
332+
# This is the one case where we will return the old policy
333+
module.exit_json(changed=True, policy=camel_dict_to_snake_dict(existing_policy))
334+
else:
335+
module.exit_json(changed=False, policy=None)
260336

261337

262338
def main():
339+
global module
340+
global client
341+
263342
argument_spec = dict(
264343
policy_name=dict(required=True),
265344
policy_description=dict(default=''),
@@ -273,75 +352,23 @@ def main():
273352
module = AnsibleAWSModule(
274353
argument_spec=argument_spec,
275354
required_if=[['state', 'present', ['policy']]],
355+
supports_check_mode=True
276356
)
277357

278358
name = module.params.get('policy_name')
279-
description = module.params.get('policy_description')
280359
state = module.params.get('state')
281-
default = module.params.get('make_default')
282-
only = module.params.get('only_version')
283-
284-
policy = None
285-
286-
if module.params.get('policy') is not None:
287-
policy = json.dumps(json.loads(module.params.get('policy')))
288360

289361
try:
290-
iam = module.client('iam')
362+
client = module.client('iam', retry_decorator=AWSRetry.jittered_backoff())
291363
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
292364
module.fail_json_aws(e, msg='Failed to connect to AWS')
293365

294-
p = get_policy_by_name(module, iam, name)
295-
if state == 'present':
296-
if p is None:
297-
# No Policy so just create one
298-
try:
299-
rvalue = iam.create_policy(PolicyName=name, Path='/',
300-
PolicyDocument=policy, Description=description)
301-
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
302-
module.fail_json_aws(e, msg="Couldn't create policy {0}".format(name))
303-
304-
module.exit_json(changed=True, policy=camel_dict_to_snake_dict(rvalue['Policy']))
305-
else:
306-
policy_version, changed = get_or_create_policy_version(module, iam, p, policy)
307-
changed = set_if_default(module, iam, p, policy_version, default) or changed
308-
changed = set_if_only(module, iam, p, policy_version, only) or changed
309-
# If anything has changed we needto refresh the policy
310-
if changed:
311-
try:
312-
p = iam.get_policy(PolicyArn=p['Arn'])['Policy']
313-
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
314-
module.fail_json(msg="Couldn't get policy")
366+
existing_policy = get_policy_by_name(name)
315367

316-
module.exit_json(changed=changed, policy=camel_dict_to_snake_dict(p))
368+
if state == 'present':
369+
create_or_update_policy(existing_policy)
317370
else:
318-
# Check for existing policy
319-
if p:
320-
# Detach policy
321-
detach_all_entities(module, iam, p)
322-
# Delete Versions
323-
try:
324-
versions = iam.list_policy_versions(PolicyArn=p['Arn'])['Versions']
325-
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
326-
module.fail_json_aws(e, msg="Couldn't list policy versions")
327-
for v in versions:
328-
if not v['IsDefaultVersion']:
329-
try:
330-
iam.delete_policy_version(PolicyArn=p['Arn'], VersionId=v['VersionId'])
331-
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
332-
module.fail_json_aws(
333-
e, msg="Couldn't delete policy version {0}".format(v['VersionId']))
334-
# Delete policy
335-
try:
336-
iam.delete_policy(PolicyArn=p['Arn'])
337-
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
338-
module.fail_json_aws(e, msg="Couldn't delete policy {0}".format(p['PolicyName']))
339-
340-
# This is the one case where we will return the old policy
341-
module.exit_json(changed=True, policy=camel_dict_to_snake_dict(p))
342-
else:
343-
module.exit_json(changed=False, policy=None)
344-
# end main
371+
delete_policy(existing_policy)
345372

346373

347374
if __name__ == '__main__':
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# reason: missing-policy
2+
# It's not possible to control what permissions are granted to a policy.
3+
# This makes securely testing iam_policy very difficult
4+
unsupported
5+
6+
cloud/aws
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
policy_name: "{{ resource_prefix }}-policy"

0 commit comments

Comments
 (0)