From 65f1ba38b1cc3b26bbadbb7e64798826bd9aeb8b Mon Sep 17 00:00:00 2001 From: Mandar Kulkarni Date: Wed, 27 Sep 2023 10:20:17 -0700 Subject: [PATCH] Promote iam_managed_policy module (#1762) Promote iam_managed_policy module SUMMARY Migrate iam_managed_policy module from community.aws ISSUE TYPE Bugfix Pull Request Docs Pull Request Feature Pull Request New Module Pull Request COMPONENT NAME ADDITIONAL INFORMATION Reviewed-by: Helen Bailey Reviewed-by: Bikouo Aubin --- .../fragments/migrate_iam_managed_policy.yml | 4 + meta/runtime.yml | 1 + plugins/modules/iam_managed_policy.py | 380 ++++++++++++++++++ plugins/modules/iam_policy.py | 2 +- plugins/modules/rds_instance.py | 2 +- .../targets/iam_managed_policy/aliases | 6 + .../iam_managed_policy/defaults/main.yml | 2 + .../targets/iam_managed_policy/meta/main.yml | 1 + .../targets/iam_managed_policy/tasks/main.yml | 160 ++++++++ 9 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/migrate_iam_managed_policy.yml create mode 100644 plugins/modules/iam_managed_policy.py create mode 100644 tests/integration/targets/iam_managed_policy/aliases create mode 100644 tests/integration/targets/iam_managed_policy/defaults/main.yml create mode 100644 tests/integration/targets/iam_managed_policy/meta/main.yml create mode 100644 tests/integration/targets/iam_managed_policy/tasks/main.yml diff --git a/changelogs/fragments/migrate_iam_managed_policy.yml b/changelogs/fragments/migrate_iam_managed_policy.yml new file mode 100644 index 00000000000..fcbccde3900 --- /dev/null +++ b/changelogs/fragments/migrate_iam_managed_policy.yml @@ -0,0 +1,4 @@ +major_changes: +- iam_managed_policy - The module has been migrated from the ``community.aws`` collection. + Playbooks using the Fully Qualified Collection Name for this module should be updated + to use ``amazon.aws.iam_managed_policy`` (https://github.com/ansible-collections/amazon.aws/pull/1762). diff --git a/meta/runtime.yml b/meta/runtime.yml index 7f75e0d5224..da395988b6f 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -71,6 +71,7 @@ action_groups: - iam_group - iam_instance_profile - iam_instance_profile_info + - iam_managed_policy - iam_policy - iam_policy_info - iam_role diff --git a/plugins/modules/iam_managed_policy.py b/plugins/modules/iam_managed_policy.py new file mode 100644 index 00000000000..f1d8e55f9ce --- /dev/null +++ b/plugins/modules/iam_managed_policy.py @@ -0,0 +1,380 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: iam_managed_policy +version_added: 1.0.0 +version_added_collection: community.aws +short_description: Manage User Managed IAM policies +description: + - Allows creating and removing managed IAM policies +options: + policy_name: + description: + - The name of the managed policy. + required: True + type: str + policy_description: + description: + - A helpful description of this policy, this value is immutable and only set when creating a new policy. + default: '' + type: str + policy: + description: + - A properly json formatted policy + type: json + make_default: + description: + - Make this revision the default revision. + default: True + type: bool + only_version: + description: + - Remove all other non default revisions, if this is used with C(make_default) it will result in all other versions of this policy being deleted. + type: bool + default: false + state: + description: + - Should this managed policy be present or absent. Set to absent to detach all entities from this policy and remove it if found. + default: present + choices: [ "present", "absent" ] + type: str + +author: + - "Dan Kozlowski (@dkhenry)" +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Create a policy +- name: Create IAM Managed Policy + amazon.aws.iam_managed_policy: + policy_name: "ManagedPolicy" + policy_description: "A Helpful managed policy" + policy: "{{ lookup('template', 'managed_policy.json.j2') }}" + state: present + +# Update a policy with a new default version +- name: Update an IAM Managed Policy with new default version + amazon.aws.iam_managed_policy: + policy_name: "ManagedPolicy" + policy: "{{ lookup('file', 'managed_policy_update.json') }}" + state: present + +# Update a policy with a new non default version +- name: Update an IAM Managed Policy with a non default version + amazon.aws.iam_managed_policy: + policy_name: "ManagedPolicy" + policy: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: "logs:CreateLogGroup" + Resource: "*" + make_default: false + state: present + +# Update a policy and make it the only version and the default version +- name: Update an IAM Managed Policy with default version as the only version + amazon.aws.iam_managed_policy: + policy_name: "ManagedPolicy" + policy: | + { + "Version": "2012-10-17", + "Statement":[{ + "Effect": "Allow", + "Action": "logs:PutRetentionPolicy", + "Resource": "*" + }] + } + only_version: true + state: present + +# Remove a policy +- name: Remove an existing IAM Managed Policy + amazon.aws.iam_managed_policy: + policy_name: "ManagedPolicy" + state: absent +""" + +RETURN = r""" +policy: + description: Returns the policy json structure, when state == absent this will return the value of the removed policy. + returned: success + type: complex + contains: {} + sample: '{ + "arn": "arn:aws:iam::aws:policy/AdministratorAccess " + "attachment_count": 0, + "create_date": "2017-03-01T15:42:55.981000+00:00", + "default_version_id": "v1", + "is_attachable": true, + "path": "/", + "policy_id": "ANPA1245EXAMPLE54321", + "policy_name": "AdministratorAccess", + "update_date": "2017-03-01T15:42:55.981000+00:00" + }' +""" + +import json + +try: + import botocore +except ImportError: + pass # Handled by AnsibleAWSModule + +from ansible.module_utils._text import to_native +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.policy import compare_policies +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry + +from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule + + +@AWSRetry.jittered_backoff(retries=5, delay=5, backoff=2.0) +def list_policies_with_backoff(): + paginator = client.get_paginator("list_policies") + return paginator.paginate(Scope="Local").build_full_result() + + +def get_policy_by_name(name): + try: + response = list_policies_with_backoff() + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't list policies") + for policy in response["Policies"]: + if policy["PolicyName"] == name: + return policy + return None + + +def delete_oldest_non_default_version(policy): + try: + versions = [ + v for v in client.list_policy_versions(PolicyArn=policy["Arn"])["Versions"] if not v["IsDefaultVersion"] + ] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't list policy versions") + versions.sort(key=lambda v: v["CreateDate"], reverse=True) + for v in versions[-1:]: + try: + client.delete_policy_version(PolicyArn=policy["Arn"], VersionId=v["VersionId"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't delete policy version") + + +# This needs to return policy_version, changed +def get_or_create_policy_version(policy, policy_document): + try: + versions = client.list_policy_versions(PolicyArn=policy["Arn"])["Versions"] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't list policy versions") + + for v in versions: + try: + document = client.get_policy_version(PolicyArn=policy["Arn"], VersionId=v["VersionId"])["PolicyVersion"][ + "Document" + ] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't get policy version {v['VersionId']}") + + if module.check_mode and compare_policies(document, json.loads(to_native(policy_document))): + return v, True + + # If the current policy matches the existing one + if not compare_policies(document, json.loads(to_native(policy_document))): + return v, False + + # No existing version so create one + # There is a service limit (typically 5) of policy versions. + # + # Rather than assume that it is 5, we'll try to create the policy + # and if that doesn't work, delete the oldest non default policy version + # and try again. + try: + version = client.create_policy_version(PolicyArn=policy["Arn"], PolicyDocument=policy_document)["PolicyVersion"] + return version, True + except is_boto3_error_code("LimitExceeded"): + delete_oldest_non_default_version(policy) + try: + version = client.create_policy_version(PolicyArn=policy["Arn"], PolicyDocument=policy_document)[ + "PolicyVersion" + ] + return version, True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as second_e: + module.fail_json_aws(second_e, msg="Couldn't create policy version") + except ( + botocore.exceptions.ClientError, + botocore.exceptions.BotoCoreError, + ) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="Couldn't create policy version") + + +def set_if_default(policy, policy_version, is_default): + if is_default and not policy_version["IsDefaultVersion"]: + try: + client.set_default_policy_version(PolicyArn=policy["Arn"], VersionId=policy_version["VersionId"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't set default policy version") + return True + return False + + +def set_if_only(policy, policy_version, is_only): + if is_only: + try: + versions = [ + v for v in client.list_policy_versions(PolicyArn=policy["Arn"])["Versions"] if not v["IsDefaultVersion"] + ] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't list policy versions") + for v in versions: + try: + client.delete_policy_version(PolicyArn=policy["Arn"], VersionId=v["VersionId"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't delete policy version") + return len(versions) > 0 + return False + + +def detach_all_entities(policy, **kwargs): + try: + entities = client.list_entities_for_policy(PolicyArn=policy["Arn"], **kwargs) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't detach list entities for policy {policy['PolicyName']}") + + for g in entities["PolicyGroups"]: + try: + client.detach_group_policy(PolicyArn=policy["Arn"], GroupName=g["GroupName"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't detach group policy {g['GroupName']}") + for u in entities["PolicyUsers"]: + try: + client.detach_user_policy(PolicyArn=policy["Arn"], UserName=u["UserName"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't detach user policy {u['UserName']}") + for r in entities["PolicyRoles"]: + try: + client.detach_role_policy(PolicyArn=policy["Arn"], RoleName=r["RoleName"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't detach role policy {r['RoleName']}") + if entities["IsTruncated"]: + detach_all_entities(policy, marker=entities["Marker"]) + + +def create_or_update_policy(existing_policy): + name = module.params.get("policy_name") + description = module.params.get("policy_description") + default = module.params.get("make_default") + only = module.params.get("only_version") + + policy = None + + if module.params.get("policy") is not None: + policy = json.dumps(json.loads(module.params.get("policy"))) + + if existing_policy is None: + if module.check_mode: + module.exit_json(changed=True) + + # Create policy when none already exists + try: + rvalue = client.create_policy(PolicyName=name, Path="/", PolicyDocument=policy, Description=description) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't create policy {name}") + + module.exit_json(changed=True, policy=camel_dict_to_snake_dict(rvalue["Policy"])) + else: + policy_version, changed = get_or_create_policy_version(existing_policy, policy) + changed = set_if_default(existing_policy, policy_version, default) or changed + changed = set_if_only(existing_policy, policy_version, only) or changed + + # If anything has changed we need to refresh the policy + if changed: + try: + updated_policy = client.get_policy(PolicyArn=existing_policy["Arn"])["Policy"] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json(msg="Couldn't get policy") + + module.exit_json(changed=changed, policy=camel_dict_to_snake_dict(updated_policy)) + else: + module.exit_json(changed=changed, policy=camel_dict_to_snake_dict(existing_policy)) + + +def delete_policy(existing_policy): + # Check for existing policy + if existing_policy: + if module.check_mode: + module.exit_json(changed=True) + + # Detach policy + detach_all_entities(existing_policy) + # Delete Versions + try: + versions = client.list_policy_versions(PolicyArn=existing_policy["Arn"])["Versions"] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't list policy versions") + for v in versions: + if not v["IsDefaultVersion"]: + try: + client.delete_policy_version(PolicyArn=existing_policy["Arn"], VersionId=v["VersionId"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't delete policy version {v['VersionId']}") + # Delete policy + try: + client.delete_policy(PolicyArn=existing_policy["Arn"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't delete policy {existing_policy['PolicyName']}") + + # This is the one case where we will return the old policy + module.exit_json(changed=True, policy=camel_dict_to_snake_dict(existing_policy)) + else: + module.exit_json(changed=False, policy=None) + + +def main(): + global module + global client + + argument_spec = dict( + policy_name=dict(required=True), + policy_description=dict(default=""), + policy=dict(type="json"), + make_default=dict(type="bool", default=True), + only_version=dict(type="bool", default=False), + state=dict(default="present", choices=["present", "absent"]), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + required_if=[["state", "present", ["policy"]]], + supports_check_mode=True, + ) + + name = module.params.get("policy_name") + state = module.params.get("state") + + try: + client = module.client("iam", retry_decorator=AWSRetry.jittered_backoff()) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to connect to AWS") + + existing_policy = get_policy_by_name(name) + + if state == "present": + create_or_update_policy(existing_policy) + else: + delete_policy(existing_policy) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/iam_policy.py b/plugins/modules/iam_policy.py index 71650ac9872..c2012018c15 100644 --- a/plugins/modules/iam_policy.py +++ b/plugins/modules/iam_policy.py @@ -12,7 +12,7 @@ description: - Allows uploading or removing inline IAM policies for IAM users, groups or roles. - To administer managed policies please see M(community.aws.iam_user), M(community.aws.iam_role), - M(amazon.aws.iam_group) and M(community.aws.iam_managed_policy) + M(amazon.aws.iam_group) and M(amazon.aws.iam_managed_policy) - This module was originally added to C(community.aws) in release 1.0.0. options: iam_type: diff --git a/plugins/modules/rds_instance.py b/plugins/modules/rds_instance.py index 6607f21dc9b..2f6124a5199 100644 --- a/plugins/modules/rds_instance.py +++ b/plugins/modules/rds_instance.py @@ -500,7 +500,7 @@ # Add IAM role to db instance - name: Create IAM policy - community.aws.iam_managed_policy: + amazon.aws.iam_managed_policy: policy_name: "my-policy" policy: "{{ lookup('file','files/policy.json') }}" state: present diff --git a/tests/integration/targets/iam_managed_policy/aliases b/tests/integration/targets/iam_managed_policy/aliases new file mode 100644 index 00000000000..839bd014bd7 --- /dev/null +++ b/tests/integration/targets/iam_managed_policy/aliases @@ -0,0 +1,6 @@ +# reason: missing-policy +# It's not possible to control what permissions are granted to a policy. +# This makes securely testing iam_policy very difficult +unsupported + +cloud/aws diff --git a/tests/integration/targets/iam_managed_policy/defaults/main.yml b/tests/integration/targets/iam_managed_policy/defaults/main.yml new file mode 100644 index 00000000000..a6edcacefae --- /dev/null +++ b/tests/integration/targets/iam_managed_policy/defaults/main.yml @@ -0,0 +1,2 @@ +--- +policy_name: "{{ resource_prefix }}-policy" diff --git a/tests/integration/targets/iam_managed_policy/meta/main.yml b/tests/integration/targets/iam_managed_policy/meta/main.yml new file mode 100644 index 00000000000..32cf5dda7ed --- /dev/null +++ b/tests/integration/targets/iam_managed_policy/meta/main.yml @@ -0,0 +1 @@ +dependencies: [] diff --git a/tests/integration/targets/iam_managed_policy/tasks/main.yml b/tests/integration/targets/iam_managed_policy/tasks/main.yml new file mode 100644 index 00000000000..9aba195eee7 --- /dev/null +++ b/tests/integration/targets/iam_managed_policy/tasks/main.yml @@ -0,0 +1,160 @@ +--- +- name: "Run integration tests for IAM managed policy" + module_defaults: + group/aws: + access_key: "{{ aws_access_key }}" + secret_key: "{{ aws_secret_key }}" + session_token: "{{ security_token | default(omit) }}" + region: "{{ aws_region }}" + collections: + - amazon.aws + block: + ## Test policy creation + - name: Create IAM managed policy - check mode + iam_managed_policy: + policy_name: "{{ policy_name }}" + policy: + Version: "2012-10-17" + Statement: + - Effect: "Deny" + Action: "logs:CreateLogGroup" + Resource: "*" + state: present + register: result + check_mode: yes + + - name: Create IAM managed policy - check mode + assert: + that: + - result.changed + + - name: Create IAM managed policy + iam_managed_policy: + policy_name: "{{ policy_name }}" + policy: + Version: "2012-10-17" + Statement: + - Effect: "Deny" + Action: "logs:CreateLogGroup" + Resource: "*" + state: present + register: result + + - name: Create IAM managed policy + assert: + that: + - result.changed + - result.policy.policy_name == policy_name + + - name: Create IAM managed policy - idempotency check + iam_managed_policy: + policy_name: "{{ policy_name }}" + policy: + Version: "2012-10-17" + Statement: + - Effect: "Deny" + Action: "logs:CreateLogGroup" + Resource: "*" + state: present + register: result + + - name: Create IAM managed policy - idempotency check + assert: + that: + - not result.changed + + ## Test policy update + - name: Update IAM managed policy - check mode + iam_managed_policy: + policy_name: "{{ policy_name }}" + policy: + Version: "2012-10-17" + Statement: + - Effect: "Deny" + Action: "logs:Describe*" + Resource: "*" + state: present + register: result + check_mode: yes + + - name: Update IAM managed policy - check mode + assert: + that: + - result.changed + + - name: Update IAM managed policy + iam_managed_policy: + policy_name: "{{ policy_name }}" + policy: + Version: "2012-10-17" + Statement: + - Effect: "Deny" + Action: "logs:Describe*" + Resource: "*" + state: present + register: result + + - name: Update IAM managed policy + assert: + that: + - result.changed + - result.policy.policy_name == policy_name + + - name: Update IAM managed policy - idempotency check + iam_managed_policy: + policy_name: "{{ policy_name }}" + policy: + Version: "2012-10-17" + Statement: + - Effect: "Deny" + Action: "logs:Describe*" + Resource: "*" + state: present + register: result + + - name: Update IAM managed policy - idempotency check + assert: + that: + - not result.changed + + ## Test policy deletion + - name: Delete IAM managed policy - check mode + iam_managed_policy: + policy_name: "{{ policy_name }}" + state: absent + register: result + check_mode: yes + + - name: Delete IAM managed policy - check mode + assert: + that: + - result.changed + + - name: Delete IAM managed policy + iam_managed_policy: + policy_name: "{{ policy_name }}" + state: absent + register: result + + - name: Delete IAM managed policy + assert: + that: + - result.changed + + - name: Delete IAM managed policy - idempotency check + iam_managed_policy: + policy_name: "{{ policy_name }}" + state: absent + register: result + + - name: Delete IAM managed policy - idempotency check + assert: + that: + - not result.changed + + always: + - name: Delete IAM managed policy + iam_managed_policy: + policy_name: "{{ policy_name }}" + state: absent + ignore_errors: yes