diff --git a/changelogs/fragments/migrate_iam_group.yml b/changelogs/fragments/migrate_iam_group.yml new file mode 100644 index 00000000000..0fa4f7c0c36 --- /dev/null +++ b/changelogs/fragments/migrate_iam_group.yml @@ -0,0 +1,4 @@ +major_changes: +- iam_group - 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_group`` (https://github.com/ansible-collections/amazon.aws/pull/1755). diff --git a/meta/runtime.yml b/meta/runtime.yml index c627df5be2b..007f42aaa72 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -9,10 +9,10 @@ action_groups: - aws_s3 - backup_plan - backup_plan_info - - backup_tag - - backup_tag_info - backup_selection - backup_selection_info + - backup_tag + - backup_tag_info - backup_vault - backup_vault_info - cloudformation @@ -20,12 +20,12 @@ action_groups: - cloudtrail - cloudtrail_info - cloudwatch_metric_alarm + - cloudwatch_metric_alarm_info - cloudwatchevent_rule - cloudwatchevent_rule - cloudwatchlogs_log_group - cloudwatchlogs_log_group_info - cloudwatchlogs_log_group_metric_filter - - cloudwatch_metric_alarm_info - ec2_ami - ec2_ami_info - ec2_eip @@ -68,6 +68,7 @@ action_groups: - elb_application_lb_info - elb_classic_lb - execute_lambda + - iam_group - iam_instance_profile - iam_instance_profile_info - iam_policy diff --git a/plugins/modules/iam_group.py b/plugins/modules/iam_group.py new file mode 100644 index 00000000000..d9262b424db --- /dev/null +++ b/plugins/modules/iam_group.py @@ -0,0 +1,414 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: iam_group +version_added: 1.0.0 +version_added_collection: community.aws +short_description: Manage AWS IAM groups +description: + - Manage AWS IAM groups. +author: + - Nick Aslanidis (@naslanidis) + - Maksym Postument (@infectsoldier) +options: + name: + description: + - The name of the group to create. + required: true + type: str + managed_policies: + description: + - A list of managed policy ARNs or friendly names to attach to the role. + - To embed an inline policy, use M(amazon.aws.iam_policy). + required: false + type: list + elements: str + default: [] + aliases: ['managed_policy'] + users: + description: + - A list of existing users to add as members of the group. + required: false + type: list + elements: str + default: [] + state: + description: + - Create or remove the IAM group. + required: true + choices: [ 'present', 'absent' ] + type: str + purge_policies: + description: + - When I(purge_policies=true) any managed policies not listed in I(managed_policies) will be detatched. + required: false + default: false + type: bool + aliases: ['purge_policy', 'purge_managed_policies'] + purge_users: + description: + - When I(purge_users=true) users which are not included in I(users) will be detached. + required: false + default: false + type: bool +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Create a group + amazon.aws.iam_group: + name: testgroup1 + state: present + +- name: Create a group and attach a managed policy using its ARN + amazon.aws.iam_group: + name: testgroup1 + managed_policies: + - arn:aws:iam::aws:policy/AmazonSNSFullAccess + state: present + +- name: Create a group with users as members and attach a managed policy using its ARN + amazon.aws.iam_group: + name: testgroup1 + managed_policies: + - arn:aws:iam::aws:policy/AmazonSNSFullAccess + users: + - test_user1 + - test_user2 + state: present + +- name: Remove all managed policies from an existing group with an empty list + amazon.aws.iam_group: + name: testgroup1 + state: present + purge_policies: true + +- name: Remove all group members from an existing group + amazon.aws.iam_group: + name: testgroup1 + managed_policies: + - arn:aws:iam::aws:policy/AmazonSNSFullAccess + purge_users: true + state: present + +- name: Delete the group + amazon.aws.iam_group: + name: testgroup1 + state: absent + +""" +RETURN = r""" +iam_group: + description: dictionary containing all the group information including group membership + returned: success + type: complex + contains: + group: + description: dictionary containing all the group information + returned: success + type: complex + contains: + arn: + description: the Amazon Resource Name (ARN) specifying the group + type: str + sample: "arn:aws:iam::1234567890:group/testgroup1" + create_date: + description: the date and time, in ISO 8601 date-time format, when the group was created + type: str + sample: "2017-02-08T04:36:28+00:00" + group_id: + description: the stable and unique string identifying the group + type: str + sample: AGPA12345EXAMPLE54321 + group_name: + description: the friendly name that identifies the group + type: str + sample: testgroup1 + path: + description: the path to the group + type: str + sample: / + users: + description: list containing all the group members + returned: success + type: complex + contains: + arn: + description: the Amazon Resource Name (ARN) specifying the user + type: str + sample: "arn:aws:iam::1234567890:user/test_user1" + create_date: + description: the date and time, in ISO 8601 date-time format, when the user was created + type: str + sample: "2017-02-08T04:36:28+00:00" + user_id: + description: the stable and unique string identifying the user + type: str + sample: AIDA12345EXAMPLE54321 + user_name: + description: the friendly name that identifies the user + type: str + sample: testgroup1 + path: + description: the path to the user + type: str + sample: / +""" + +try: + import botocore +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + +from ansible_collections.amazon.aws.plugins.module_utils.arn import validate_aws_arn +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry + +from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule + + +def compare_attached_group_policies(current_attached_policies, new_attached_policies): + # If new_attached_policies is None it means we want to remove all policies + if len(current_attached_policies) > 0 and new_attached_policies is None: + return False + + current_attached_policies_arn_list = [] + for policy in current_attached_policies: + current_attached_policies_arn_list.append(policy["PolicyArn"]) + + if set(current_attached_policies_arn_list) == set(new_attached_policies): + return True + else: + return False + + +def compare_group_members(current_group_members, new_group_members): + # If new_attached_policies is None it means we want to remove all policies + if len(current_group_members) > 0 and new_group_members is None: + return False + if set(current_group_members) == set(new_group_members): + return True + else: + return False + + +def convert_friendly_names_to_arns(connection, module, policy_names): + if all(validate_aws_arn(policy, service="iam") for policy in policy_names if policy is not None): + return policy_names + allpolicies = {} + paginator = connection.get_paginator("list_policies") + policies = paginator.paginate().build_full_result()["Policies"] + + for policy in policies: + allpolicies[policy["PolicyName"]] = policy["Arn"] + allpolicies[policy["Arn"]] = policy["Arn"] + try: + return [allpolicies[policy] for policy in policy_names if policy is not None] + except KeyError as e: + module.fail_json(msg="Couldn't find policy: " + str(e)) + + +def create_or_update_group(connection, module): + params = dict() + params["GroupName"] = module.params.get("name") + managed_policies = module.params.get("managed_policies") + users = module.params.get("users") + purge_users = module.params.get("purge_users") + purge_policies = module.params.get("purge_policies") + changed = False + if managed_policies: + managed_policies = convert_friendly_names_to_arns(connection, module, managed_policies) + + # Get group + try: + group = get_group(connection, module, params["GroupName"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't get group") + + # If group is None, create it + if group is None: + # Check mode means we would create the group + if module.check_mode: + module.exit_json(changed=True) + + try: + group = connection.create_group(**params) + changed = True + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't create group") + + # Manage managed policies + current_attached_policies = get_attached_policy_list(connection, module, params["GroupName"]) + if not compare_attached_group_policies(current_attached_policies, managed_policies): + current_attached_policies_arn_list = [] + for policy in current_attached_policies: + current_attached_policies_arn_list.append(policy["PolicyArn"]) + + # If managed_policies has a single empty element we want to remove all attached policies + if purge_policies: + # Detach policies not present + for policy_arn in list(set(current_attached_policies_arn_list) - set(managed_policies)): + changed = True + if not module.check_mode: + try: + connection.detach_group_policy(GroupName=params["GroupName"], PolicyArn=policy_arn) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't detach policy from group {params['GroupName']}") + # If there are policies to adjust that aren't in the current list, then things have changed + # Otherwise the only changes were in purging above + if set(managed_policies) - set(current_attached_policies_arn_list): + changed = True + # If there are policies in managed_policies attach each policy + if managed_policies != [None] and not module.check_mode: + for policy_arn in managed_policies: + try: + connection.attach_group_policy(GroupName=params["GroupName"], PolicyArn=policy_arn) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't attach policy to group {params['GroupName']}") + + # Manage group memberships + try: + current_group_members = get_group(connection, module, params["GroupName"])["Users"] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, f"Couldn't get group {params['GroupName']}") + + current_group_members_list = [] + for member in current_group_members: + current_group_members_list.append(member["UserName"]) + + if not compare_group_members(current_group_members_list, users): + if purge_users: + for user in list(set(current_group_members_list) - set(users)): + # Ensure we mark things have changed if any user gets purged + changed = True + # Skip actions for check mode + if not module.check_mode: + try: + connection.remove_user_from_group(GroupName=params["GroupName"], UserName=user) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't remove user {user} from group {params['GroupName']}") + # If there are users to adjust that aren't in the current list, then things have changed + # Otherwise the only changes were in purging above + if set(users) - set(current_group_members_list): + changed = True + # Skip actions for check mode + if users != [None] and not module.check_mode: + for user in users: + try: + connection.add_user_to_group(GroupName=params["GroupName"], UserName=user) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't add user {user} to group {params['GroupName']}") + if module.check_mode: + module.exit_json(changed=changed) + + # Get the group again + try: + group = get_group(connection, module, params["GroupName"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, f"Couldn't get group {params['GroupName']}") + + module.exit_json(changed=changed, iam_group=camel_dict_to_snake_dict(group)) + + +def destroy_group(connection, module): + params = dict() + params["GroupName"] = module.params.get("name") + + try: + group = get_group(connection, module, params["GroupName"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, f"Couldn't get group {params['GroupName']}") + if group: + # Check mode means we would remove this group + if module.check_mode: + module.exit_json(changed=True) + + # Remove any attached policies otherwise deletion fails + try: + for policy in get_attached_policy_list(connection, module, params["GroupName"]): + connection.detach_group_policy(GroupName=params["GroupName"], PolicyArn=policy["PolicyArn"]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg=f"Couldn't remove policy from group {params['GroupName']}") + + # Remove any users in the group otherwise deletion fails + current_group_members_list = [] + try: + current_group_members = get_group(connection, module, params["GroupName"])["Users"] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, f"Couldn't get group {params['GroupName']}") + for member in current_group_members: + current_group_members_list.append(member["UserName"]) + for user in current_group_members_list: + try: + connection.remove_user_from_group(GroupName=params["GroupName"], UserName=user) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, f"Couldn't remove user {user} from group {params['GroupName']}") + + try: + connection.delete_group(**params) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, f"Couldn't delete group {params['GroupName']}") + + else: + module.exit_json(changed=False) + + module.exit_json(changed=True) + + +@AWSRetry.exponential_backoff() +def get_group(connection, module, name): + try: + paginator = connection.get_paginator("get_group") + return paginator.paginate(GroupName=name).build_full_result() + except is_boto3_error_code("NoSuchEntity"): + return None + + +@AWSRetry.exponential_backoff() +def get_attached_policy_list(connection, module, name): + try: + paginator = connection.get_paginator("list_attached_group_policies") + return paginator.paginate(GroupName=name).build_full_result()["AttachedPolicies"] + except is_boto3_error_code("NoSuchEntity"): + return None + + +def main(): + argument_spec = dict( + name=dict(required=True), + managed_policies=dict(default=[], type="list", aliases=["managed_policy"], elements="str"), + users=dict(default=[], type="list", elements="str"), + state=dict(choices=["present", "absent"], required=True), + purge_users=dict(default=False, type="bool"), + purge_policies=dict(default=False, type="bool", aliases=["purge_policy", "purge_managed_policies"]), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + connection = module.client("iam") + + state = module.params.get("state") + + if state == "present": + create_or_update_group(connection, module) + else: + destroy_group(connection, module) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/iam_policy.py b/plugins/modules/iam_policy.py index 009dd542e28..71650ac9872 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(community.aws.iam_group) and M(community.aws.iam_managed_policy) + M(amazon.aws.iam_group) and M(community.aws.iam_managed_policy) - This module was originally added to C(community.aws) in release 1.0.0. options: iam_type: @@ -61,7 +61,7 @@ # Advanced example, create two new groups and add a READ-ONLY policy to both # groups. - name: Create Two Groups, Mario and Luigi - community.aws.iam_group: + amazon.aws.iam_group: name: "{{ item }}" state: present loop: diff --git a/plugins/modules/iam_user.py b/plugins/modules/iam_user.py index f4f1483cdde..75e35ad5198 100644 --- a/plugins/modules/iam_user.py +++ b/plugins/modules/iam_user.py @@ -11,7 +11,7 @@ short_description: Manage AWS IAM users description: - A module to manage AWS IAM users. - - The module does not manage groups that users belong to, groups memberships can be managed using M(community.aws.iam_group). + - The module does not manage groups that users belong to, groups memberships can be managed using M(amazon.aws.iam_group). - This module was originally added to C(community.aws) in release 1.0.0. author: - Josh Souza (@joshsouza) @@ -102,7 +102,7 @@ EXAMPLES = r""" # Note: These examples do not set authentication details, see the AWS Guide for details. # Note: This module does not allow management of groups that users belong to. -# Groups should manage their membership directly using community.aws.iam_group, +# Groups should manage their membership directly using amazon.aws.iam_group, # as users belong to them. - name: Create a user diff --git a/tests/integration/targets/iam_group/aliases b/tests/integration/targets/iam_group/aliases new file mode 100644 index 00000000000..2da398045bd --- /dev/null +++ b/tests/integration/targets/iam_group/aliases @@ -0,0 +1,7 @@ +# reason: missing-policy +# It should be possible to test iam_groups by limiting which policies can be +# attached to the groups as well as which users can be added to the groups. +# Careful review is needed prior to adding this to the main CI. +unsupported + +cloud/aws diff --git a/tests/integration/targets/iam_group/defaults/main.yml b/tests/integration/targets/iam_group/defaults/main.yml new file mode 100644 index 00000000000..f5112b1a423 --- /dev/null +++ b/tests/integration/targets/iam_group/defaults/main.yml @@ -0,0 +1,3 @@ +--- +test_user: '{{ resource_prefix }}-user' +test_group: '{{ resource_prefix }}-group' diff --git a/tests/integration/targets/iam_group/meta/main.yml b/tests/integration/targets/iam_group/meta/main.yml new file mode 100644 index 00000000000..32cf5dda7ed --- /dev/null +++ b/tests/integration/targets/iam_group/meta/main.yml @@ -0,0 +1 @@ +dependencies: [] diff --git a/tests/integration/targets/iam_group/tasks/main.yml b/tests/integration/targets/iam_group/tasks/main.yml new file mode 100644 index 00000000000..a1240846cb8 --- /dev/null +++ b/tests/integration/targets/iam_group/tasks/main.yml @@ -0,0 +1,127 @@ +--- +- name: set up aws connection info + 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: + - name: ensure ansible user exists + iam_user: + name: '{{ test_user }}' + state: present + + - name: ensure group exists + iam_group: + name: '{{ test_group }}' + users: + - '{{ test_user }}' + state: present + register: iam_group + + - assert: + that: + - iam_group.iam_group.users + - iam_group is changed + + - name: add non existent user to group + iam_group: + name: '{{ test_group }}' + users: + - '{{ test_user }}' + - NonExistentUser + state: present + ignore_errors: yes + register: iam_group + + - name: assert that adding non existent user to group fails with helpful message + assert: + that: + - iam_group is failed + - iam_group.msg.startswith("Couldn't add user NonExistentUser to group {{ test_group }}") + + - name: remove a user + iam_group: + name: '{{ test_group }}' + purge_users: True + users: [] + state: present + register: iam_group + + - assert: + that: + - iam_group is changed + - not iam_group.iam_group.users + + - name: re-remove a user (no change) + iam_group: + name: '{{ test_group }}' + purge_users: True + users: [] + state: present + register: iam_group + + - assert: + that: + - iam_group is not changed + - not iam_group.iam_group.users + + - name: Add the user again + iam_group: + name: '{{ test_group }}' + users: + - '{{ test_user }}' + state: present + register: iam_group + + - assert: + that: + - iam_group is changed + - iam_group.iam_group.users + + - name: Re-add the user + iam_group: + name: '{{ test_group }}' + users: + - '{{ test_user }}' + state: present + register: iam_group + + - assert: + that: + - iam_group is not changed + - iam_group.iam_group.users + + - name: remove group + iam_group: + name: '{{ test_group }}' + state: absent + register: iam_group + + - assert: + that: + - iam_group is changed + + - name: re-remove group + iam_group: + name: '{{ test_group }}' + state: absent + register: iam_group + + - assert: + that: + - iam_group is not changed + + always: + - name: remove group + iam_group: + name: '{{ test_group }}' + state: absent + + - name: remove ansible user + iam_user: + name: '{{ test_user }}' + state: absent