From a649a8dfe18f45f9545e3a511361b8b1b513cb55 Mon Sep 17 00:00:00 2001
From: Denis Karpelevich <dkarpele@redhat.com>
Date: Thu, 30 Mar 2023 21:07:52 +0200
Subject: [PATCH] [RFE] Allow multiple groups creation.

Adding an option `groups` to create multiple groups in one operation.
Adding tests (present/absent/external/nonposix) with server and
client context.
Simple example of `groups` option:
```
tasks:
- name: Ensure 2 groups are present
  ipagroup:
    ipaadmin_password: SomeADMINpassword
    groups:
    - name: group1
    - name: group2
```

Signed-off-by: Denis Karpelevich <dkarpele@redhat.com>
---
 README-group.md                               | 105 ++++-
 playbooks/{user => group}/add-group.yml       |   0
 .../{user => group}/add-groups-to-group.yml   |   0
 playbooks/group/add-groups.yml                |  32 ++
 .../{user => group}/add-user-to-group.yml     |   0
 playbooks/{user => group}/delete-group.yml    |   0
 plugins/modules/ipagroup.py                   | 286 ++++++++++---
 tests/group/create_groups_json.yml            |  13 +
 tests/group/groups.sh                         |  25 ++
 tests/group/test_group_client_context.yml     |  24 ++
 tests/group/test_groups.yml                   | 143 +++++++
 tests/group/test_groups_absent.yml            |  35 ++
 tests/group/test_groups_external_nonposix.yml | 395 ++++++++++++++++++
 tests/group/test_groups_present.yml           |  40 ++
 tests/group/test_groups_present_slice.yml     |  47 +++
 tests/sanity/ignore-2.12.txt                  |   1 +
 tests/sanity/ignore-2.13.txt                  |   1 +
 tests/sanity/ignore-2.14.txt                  |   1 +
 18 files changed, 1095 insertions(+), 53 deletions(-)
 rename playbooks/{user => group}/add-group.yml (100%)
 rename playbooks/{user => group}/add-groups-to-group.yml (100%)
 create mode 100644 playbooks/group/add-groups.yml
 rename playbooks/{user => group}/add-user-to-group.yml (100%)
 rename playbooks/{user => group}/delete-group.yml (100%)
 create mode 100644 tests/group/create_groups_json.yml
 create mode 100644 tests/group/groups.sh
 create mode 100644 tests/group/test_groups.yml
 create mode 100644 tests/group/test_groups_absent.yml
 create mode 100644 tests/group/test_groups_external_nonposix.yml
 create mode 100644 tests/group/test_groups_present.yml
 create mode 100644 tests/group/test_groups_present_slice.yml

diff --git a/README-group.md b/README-group.md
index 021fc105..181e407c 100644
--- a/README-group.md
+++ b/README-group.md
@@ -8,6 +8,9 @@ The group module allows to ensure presence and absence of groups and members of
 
 The group module is as compatible as possible to the Ansible upstream `ipa_group` module, but additionally offers to add users to a group and also to remove users from a group.
 
+## Note
+Ensuring presence (adding) of several groups with mixed types (`external`, `nonposix` and `posix`) requires a fix in FreeIPA. The module implements a workaround to automatically use `client` context if the fix is not present in the target node FreeIPA and if more than one group is provided to the task using the `groups` parameter. If `ipaapi_context` is forced to be `server`, the module will fail in this case.
+
 
 Features
 --------
@@ -71,6 +74,62 @@ Example playbook to add groups:
       name: appops
 ```
 
+These three `ipagroup` module calls can be combined into one with the `groups` variable:
+
+```yaml
+---
+- name: Playbook to handle groups
+  hosts: ipaserver
+
+  tasks:
+  - name: Ensure groups ops, sysops and appops are present
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups:
+      - name: ops
+        gidnumber: 1234
+      - name: sysops
+        user:
+        - pinky
+      - name: appops
+```
+
+You can also alternatively use a json file containing the groups, here `groups_present.json`:
+
+```json
+{
+  "groups": [
+    {
+      "name": "group1",
+      "description": "description group1"
+    },
+    {
+      "name": "group2",
+      "description": "description group2"
+    }
+  ]
+}
+```
+
+And ensure the presence of the groups with this example playbook:
+
+```yaml
+---
+- name: Tests
+  hosts: ipaserver
+  gather_facts: false
+
+  tasks:
+  - name: Include groups_present.json
+    include_vars:
+      file: groups_present.json
+
+  - name: Groups present
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups: "{{ groups }}"
+```
+
 Example playbook to add users to a group:
 
 ```yaml
@@ -112,11 +171,11 @@ Example playbook to add group members to a group:
 Example playbook to add members from a trusted realm to an external group:
 
 ```yaml
---
+---
 - name: Playbook to handle groups.
   hosts: ipaserver
-  became: true
-
+  
+  tasks:
   - name: Create an external group and add members from a trust to it.
     ipagroup:
       ipaadmin_password: SomeADMINpassword
@@ -127,6 +186,24 @@ Example playbook to add members from a trusted realm to an external group:
       - WINIPA\\Developers
 ```
 
+Example playbook to add nonposix and external groups:
+
+```yaml
+---
+- name: Playbook to add nonposix and external groups
+  hosts: ipaserver
+
+  tasks:
+  - name: Add nonposix group sysops and external group appops
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups:
+      - name: sysops
+        nonposix: true
+      - name: appops
+        external: true
+```
+
 Example playbook to remove groups:
 
 ```yaml
@@ -136,13 +213,29 @@ Example playbook to remove groups:
   become: true
 
   tasks:
-  # Remove goups sysops, appops and ops
+  # Remove groups sysops, appops and ops
   - ipagroup:
       ipaadmin_password: SomeADMINpassword
       name: sysops,appops,ops
       state: absent
 ```
 
+Example playbook to ensure groups are absent:
+
+```yaml
+---
+- name: Playbook to handle groups
+  hosts: ipaserver
+
+  tasks:
+  - name: Ensure groups ops and sysops are absent
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups:
+      - name: ops
+      - name: sysops
+      state: absent
+```
 
 Variables
 =========
@@ -152,8 +245,10 @@ Variable | Description | Required
 `ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no
 `ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no
 `ipaapi_context` | The context in which the module will execute. Executing in a server context is preferred. If not provided context will be determined by the execution environment. Valid values are `server` and `client`. | no
-`ipaapi_ldap_cache` | Use LDAP cache for IPA connection. The bool setting defaults to yes. (bool) | no
+`ipaapi_ldap_cache` | Use LDAP cache for IPA connection. The bool setting defaults to <br/>. (bool) | no
 `name` \| `cn` | The list of group name strings. | no
+`groups` | The list of group dicts. Each `groups` dict entry can contain group variables.<br>There is one required option in the `groups` dict:| no
+&nbsp; | `name` - The group name string of the entry. | yes
 `description` | The group description string. | no
 `gid` \| `gidnumber` | The GID integer. | no
 `posix` | Create a non-POSIX group or change a non-POSIX to a posix group. `nonposix`, `posix` and `external` are mutually exclusive. (bool) | no
diff --git a/playbooks/user/add-group.yml b/playbooks/group/add-group.yml
similarity index 100%
rename from playbooks/user/add-group.yml
rename to playbooks/group/add-group.yml
diff --git a/playbooks/user/add-groups-to-group.yml b/playbooks/group/add-groups-to-group.yml
similarity index 100%
rename from playbooks/user/add-groups-to-group.yml
rename to playbooks/group/add-groups-to-group.yml
diff --git a/playbooks/group/add-groups.yml b/playbooks/group/add-groups.yml
new file mode 100644
index 00000000..3b1802ff
--- /dev/null
+++ b/playbooks/group/add-groups.yml
@@ -0,0 +1,32 @@
+---
+- name: Playbook to handle multiple groups
+  hosts: ipaserver
+
+  tasks:
+  - name: Create multiple groups ops, sysops
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups:
+      - name: ops
+        gidnumber: 1234
+      - name: sysops
+
+  - name: Add user and group members to groups sysops and appops
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups:
+      - name: sysops
+        user:
+          - user1
+      - name: appops
+        group:
+          - group2
+
+  - name: Create multiple non-POSIX and external groups
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups:
+      - name: nongroup
+        nonposix: true
+      - name: extgroup
+        external: true
diff --git a/playbooks/user/add-user-to-group.yml b/playbooks/group/add-user-to-group.yml
similarity index 100%
rename from playbooks/user/add-user-to-group.yml
rename to playbooks/group/add-user-to-group.yml
diff --git a/playbooks/user/delete-group.yml b/playbooks/group/delete-group.yml
similarity index 100%
rename from playbooks/user/delete-group.yml
rename to playbooks/group/delete-group.yml
diff --git a/plugins/modules/ipagroup.py b/plugins/modules/ipagroup.py
index 2488f17f..e0d5bb12 100644
--- a/plugins/modules/ipagroup.py
+++ b/plugins/modules/ipagroup.py
@@ -41,8 +41,88 @@ options:
     description: The group name
     type: list
     elements: str
-    required: true
+    required: false
     aliases: ["cn"]
+  groups:
+    description: The list of group dicts (internally gid).
+    type: list
+    elements: dict
+    suboptions:
+      name:
+        description: The group (internally gid).
+        type: str
+        required: true
+        aliases: ["cn"]
+      description:
+        description: The group description
+        type: str
+        required: false
+      gid:
+        description: The GID
+        type: int
+        required: false
+        aliases: ["gidnumber"]
+      nonposix:
+        description: Create as a non-POSIX group
+        required: false
+        type: bool
+      external:
+        description: Allow adding external non-IPA members from trusted domains
+        required: false
+        type: bool
+      posix:
+        description:
+          Create a non-POSIX group or change a non-POSIX to a posix group.
+        required: false
+        type: bool
+      nomembers:
+        description: Suppress processing of membership attributes
+        required: false
+        type: bool
+      user:
+        description: List of user names assigned to this group.
+        required: false
+        type: list
+        elements: str
+      group:
+        description: List of group names assigned to this group.
+        required: false
+        type: list
+        elements: str
+      service:
+        description:
+        - List of service names assigned to this group.
+        - Only usable with IPA versions 4.7 and up.
+        required: false
+        type: list
+        elements: str
+      membermanager_user:
+        description:
+        - List of member manager users assigned to this group.
+        - Only usable with IPA versions 4.8.4 and up.
+        required: false
+        type: list
+        elements: str
+      membermanager_group:
+        description:
+        - List of member manager groups assigned to this group.
+        - Only usable with IPA versions 4.8.4 and up.
+        required: false
+        type: list
+        elements: str
+      externalmember:
+        description:
+        - List of members of a trusted domain in DOM\\name or name@domain form.
+        required: false
+        type: list
+        elements: str
+        aliases: ["ipaexternalmember", "external_member"]
+      idoverrideuser:
+        description:
+        - User ID overrides to add
+        required: false
+        type: list
+        elements: str
   description:
     description: The group description
     type: str
@@ -144,6 +224,14 @@ EXAMPLES = """
     ipaadmin_password: SomeADMINpassword
     name: appops
 
+# Create multiple groups ops, sysops
+- ipagroup:
+    ipaadmin_password: SomeADMINpassword
+    groups:
+    - name: ops
+      gidnumber: 1234
+    - name: sysops
+
 # Add user member pinky to group sysops
 - ipagroup:
     ipaadmin_password: SomeADMINpassword
@@ -160,7 +248,7 @@ EXAMPLES = """
     user:
     - brain
 
-# Add group members sysops and appops to group sysops
+# Add group members sysops and appops to group ops
 - ipagroup:
     ipaadmin_password: SomeADMINpassword
     name: ops
@@ -168,6 +256,17 @@ EXAMPLES = """
     - sysops
     - appops
 
+# Add user and group members to groups sysops and appops
+- ipagroup:
+    ipaadmin_password: SomeADMINpassword
+    groups:
+    - name: sysops
+      user:
+        - user1
+    - name: appops
+      group:
+        - group2
+
 # Create a non-POSIX group
 - ipagroup:
     ipaadmin_password: SomeADMINpassword
@@ -189,7 +288,16 @@ EXAMPLES = """
     - WINIPA\\Web Users
     - WINIPA\\Developers
 
-# Remove goups sysops, appops, ops and nongroup
+# Create multiple non-POSIX and external groups
+- ipagroup:
+    ipaadmin_password: SomeADMINpassword
+    groups:
+    - name: nongroup
+      nonposix: true
+    - name: extgroup
+      external: true
+
+# Remove groups sysops, appops, ops and nongroup
 - ipagroup:
     ipaadmin_password: SomeADMINpassword
     name: sysops,appops,ops, nongroup
@@ -203,6 +311,9 @@ from ansible.module_utils._text import to_text
 from ansible.module_utils.ansible_freeipa_module import \
     IPAAnsibleModule, compare_args_ipa, gen_add_del_lists, \
     gen_add_list, gen_intersection_list, api_check_param
+from ansible.module_utils import six
+if six.PY3:
+    unicode = str
 
 
 def find_group(module, name):
@@ -257,6 +368,22 @@ def gen_member_args(user, group, service, externalmember, idoverrideuser):
     return _args
 
 
+def check_parameters(module, state, action):
+    invalid = []
+    if state == "present":
+        if action == "member":
+            invalid = ["description", "gid", "posix", "nonposix", "external",
+                       "nomembers"]
+
+    else:
+        invalid = ["description", "gid", "posix", "nonposix", "external",
+                   "nomembers"]
+        if action == "group":
+            invalid.extend(["user", "group", "service", "externalmember"])
+
+    module.params_fail_used_invalid(invalid, state, action)
+
+
 def is_external_group(res_find):
     """Verify if the result group is an external group."""
     return res_find and 'ipaexternalgroup' in res_find['objectclass']
@@ -285,45 +412,63 @@ def check_objectclass_args(module, res_find, posix, external):
 
 
 def main():
+    group_spec = dict(
+        # present
+        description=dict(type="str", default=None),
+        gid=dict(type="int", aliases=["gidnumber"], default=None),
+        nonposix=dict(required=False, type='bool', default=None),
+        external=dict(required=False, type='bool', default=None),
+        posix=dict(required=False, type='bool', default=None),
+        nomembers=dict(required=False, type='bool', default=None),
+        user=dict(required=False, type='list', elements="str",
+                  default=None),
+        group=dict(required=False, type='list', elements="str",
+                   default=None),
+        service=dict(required=False, type='list', elements="str",
+                     default=None),
+        idoverrideuser=dict(required=False, type='list', elements="str",
+                            default=None),
+        membermanager_user=dict(required=False, type='list',
+                                elements="str", default=None),
+        membermanager_group=dict(required=False, type='list',
+                                 elements="str", default=None),
+        externalmember=dict(required=False, type='list', elements="str",
+                            default=None,
+                            aliases=[
+                                "ipaexternalmember",
+                                "external_member"
+                            ])
+    )
     ansible_module = IPAAnsibleModule(
         argument_spec=dict(
             # general
             name=dict(type="list", elements="str", aliases=["cn"],
-                      required=True),
-            # present
-            description=dict(type="str", default=None),
-            gid=dict(type="int", aliases=["gidnumber"], default=None),
-            nonposix=dict(required=False, type='bool', default=None),
-            external=dict(required=False, type='bool', default=None),
-            posix=dict(required=False, type='bool', default=None),
-            nomembers=dict(required=False, type='bool', default=None),
-            user=dict(required=False, type='list', elements="str",
-                      default=None),
-            group=dict(required=False, type='list', elements="str",
-                       default=None),
-            service=dict(required=False, type='list', elements="str",
-                         default=None),
-            idoverrideuser=dict(required=False, type='list', elements="str",
-                                default=None),
-            membermanager_user=dict(required=False, type='list',
-                                    elements="str", default=None),
-            membermanager_group=dict(required=False, type='list',
-                                     elements="str", default=None),
-            externalmember=dict(required=False, type='list', elements="str",
-                                default=None,
-                                aliases=[
-                                    "ipaexternalmember",
-                                    "external_member"
-                                ]),
+                      default=None, required=False),
+            groups=dict(type="list",
+                        default=None,
+                        options=dict(
+                            # Here name is a simple string
+                            name=dict(type="str", required=True,
+                                      aliases=["cn"]),
+                            # Add group specific parameters
+                            **group_spec
+                        ),
+                        elements='dict',
+                        required=False),
+            # general
             action=dict(type="str", default="group",
                         choices=["member", "group"]),
-            # state
             state=dict(type="str", default="present",
                        choices=["present", "absent"]),
+
+            # Add group specific parameters for simple use case
+            **group_spec
         ),
         # It does not make sense to set posix, nonposix or external at the
         # same time
-        mutually_exclusive=[['posix', 'nonposix', 'external']],
+        mutually_exclusive=[['posix', 'nonposix', 'external'],
+                            ["name", "groups"]],
+        required_one_of=[["name", "groups"]],
         supports_check_mode=True,
     )
 
@@ -333,6 +478,7 @@ def main():
 
     # general
     names = ansible_module.params_get("name")
+    groups = ansible_module.params_get("groups")
 
     # present
     description = ansible_module.params_get("description")
@@ -354,31 +500,26 @@ def main():
     state = ansible_module.params_get("state")
 
     # Check parameters
-    invalid = []
 
-    if state == "present":
-        if len(names) != 1:
-            ansible_module.fail_json(
-                msg="Only one group can be added at a time.")
-        if action == "member":
-            invalid = ["description", "gid", "posix", "nonposix", "external",
-                       "nomembers"]
+    if (names is None or len(names) < 1) and \
+       (groups is None or len(groups) < 1):
+        ansible_module.fail_json(msg="At least one name or groups is required")
 
-    if state == "absent":
-        if len(names) < 1:
+    if state == "present":
+        if names is not None and len(names) != 1:
             ansible_module.fail_json(
-                msg="No name given.")
-        invalid = ["description", "gid", "posix", "nonposix", "external",
-                   "nomembers"]
-        if action == "group":
-            invalid.extend(["user", "group", "service", "externalmember"])
+                msg="Only one group can be added at a time using 'name'.")
 
-    ansible_module.params_fail_used_invalid(invalid, state, action)
+    check_parameters(ansible_module, state, action)
 
     if external is False:
         ansible_module.fail_json(
             msg="group can not be non-external")
 
+    # Use groups if names is None
+    if groups is not None:
+        names = groups
+
     # Init
 
     changed = False
@@ -415,8 +556,57 @@ def main():
                 "supported by your IPA version")
 
         commands = []
+        group_set = set()
+
+        for group_name in names:
+            if isinstance(group_name, dict):
+                name = group_name.get("name")
+                if name in group_set:
+                    ansible_module.fail_json(
+                        msg="group '%s' is used more than once" % name)
+                group_set.add(name)
+                # present
+                description = group_name.get("description")
+                gid = group_name.get("gid")
+                nonposix = group_name.get("nonposix")
+                external = group_name.get("external")
+                idoverrideuser = group_name.get("idoverrideuser")
+                posix = group_name.get("posix")
+                # Check mutually exclusive condition for multiple groups
+                # creation. It's not possible to check it with
+                # `mutually_exclusive` argument in `IPAAnsibleModule` class
+                # because it accepts only (list[str] or list[list[str]]). Here
+                # we need to loop over all groups and fail on mutually
+                # exclusive ones.
+                if all((posix, nonposix)) or\
+                   all((posix, external)) or\
+                   all((nonposix, external)):
+                    ansible_module.fail_json(
+                        msg="parameters are mutually exclusive for group "
+                            "`{0}`: posix|nonposix|external".format(name))
+                # Duplicating the condition for multiple group creation
+                if external is False:
+                    ansible_module.fail_json(
+                        msg="group can not be non-external")
+                # If nonposix is used, set posix as not nonposix
+                if nonposix is not None:
+                    posix = not nonposix
+                user = group_name.get("user")
+                group = group_name.get("group")
+                service = group_name.get("service")
+                membermanager_user = group_name.get("membermanager_user")
+                membermanager_group = group_name.get("membermanager_group")
+                externalmember = group_name.get("externalmember")
+                nomembers = group_name.get("nomembers")
+
+                check_parameters(ansible_module, state, action)
+
+            elif isinstance(group_name, (str, unicode)):
+                name = group_name
+            else:
+                ansible_module.fail_json(msg="Group '%s' is not valid" %
+                                         repr(group_name))
 
-        for name in names:
             # Make sure group exists
             res_find = find_group(ansible_module, name)
 
diff --git a/tests/group/create_groups_json.yml b/tests/group/create_groups_json.yml
new file mode 100644
index 00000000..ef9dd393
--- /dev/null
+++ b/tests/group/create_groups_json.yml
@@ -0,0 +1,13 @@
+---
+- name: Create groups.json
+  hosts: localhost
+
+  tasks:
+  - name: Check if groups.json exists
+    ansible.builtin.stat:
+      path: groups.json
+    register: register_stat_groups
+
+  - name: Create groups.json
+    ansible.builtin.command: /bin/bash groups.sh 500
+    when: not register_stat_groups.stat.exists
diff --git a/tests/group/groups.sh b/tests/group/groups.sh
new file mode 100644
index 00000000..63e36c01
--- /dev/null
+++ b/tests/group/groups.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+
+NUM=${1-1000}
+FILE="groups.json"
+
+echo "{" > "$FILE"
+
+echo "  \"group_list\": [" >> "$FILE"
+
+for i in $(seq 1 "$NUM"); do
+    {
+        echo "    {"
+        echo "      \"name\": \"group$i\","
+        echo "      \"description\": \"group description $i\""
+    } >> "$FILE"
+    if [ "$i" -lt "$NUM" ]; then
+       echo "    }," >> "$FILE"
+    else
+       echo "    }" >> "$FILE"
+    fi
+done
+
+echo "  ]" >> "$FILE"
+
+echo "}" >> "$FILE"
diff --git a/tests/group/test_group_client_context.yml b/tests/group/test_group_client_context.yml
index 4a1d7ac9..8d0132de 100644
--- a/tests/group/test_group_client_context.yml
+++ b/tests/group/test_group_client_context.yml
@@ -37,3 +37,27 @@
   when: groups['ipaclients'] is not defined or not groups['ipaclients']
   vars:
     ipa_context: client
+
+- name: Test groups using client context, in client host.
+  ansible.builtin.import_playbook: test_groups.yml
+  when: groups['ipaclients']
+  vars:
+    ipa_test_host: ipaclients
+
+- name: Test groups using client context, in server host.
+  ansible.builtin.import_playbook: test_groups.yml
+  when: groups['ipaclients'] is not defined or not groups['ipaclients']
+  vars:
+    ipa_context: client
+
+- name: Test groups with mixed types using client context, in client host.
+  ansible.builtin.import_playbook: test_groups_external_nonposix.yml
+  when: groups['ipaclients']
+  vars:
+    ipa_test_host: ipaclients
+
+- name: Test groups with mixed types using client context, in server host.
+  ansible.builtin.import_playbook: test_groups_external_nonposix.yml
+  when: groups['ipaclients'] is not defined or not groups['ipaclients']
+  vars:
+    ipa_context: client
diff --git a/tests/group/test_groups.yml b/tests/group/test_groups.yml
new file mode 100644
index 00000000..648b35b8
--- /dev/null
+++ b/tests/group/test_groups.yml
@@ -0,0 +1,143 @@
+---
+- name: Test groups
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  gather_facts: true
+
+  tasks:
+  # setup
+  - name: Include tasks ../env_freeipa_facts.yml
+    ansible.builtin.include_tasks: ../env_freeipa_facts.yml
+
+  # GET FQDN_AT_DOMAIN
+
+  - name: Get fqdn_at_domain
+    ansible.builtin.set_fact:
+      fqdn_at_domain: "{{ ansible_facts['fqdn'] + '@' + ipaserver_realm }}"
+
+  # CLEANUP TEST ITEMS
+
+  - name: Remove test groups
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      name: group1,group2,group3,group4,group5,group6,group7,group8,group9,group10
+      state: absent
+
+  - name: Remove test users
+    ipauser:
+      ipaadmin_password: SomeADMINpassword
+      name: user1,user2,user3
+      state: absent
+
+  # CREATE TEST ITEMS
+
+  - name: Users user1..3 present
+    ipauser:
+      ipaadmin_password: SomeADMINpassword
+      users:
+      - name: user1
+        first: user1
+        last: Last
+      - name: user2
+        first: user2
+        last: Last
+      - name: user3
+        first: user3
+        last: Last
+
+  # TESTS
+
+  - name: Groups group1..10 present
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups:
+      - name: group1
+      - name: group2
+        user:
+          - user1
+          - user2
+          - user3
+      - name: group3
+        group:
+          - group1
+          - group2
+      - name: group4
+      - name: group5
+      - name: group6
+      - name: group7
+      - name: group8
+      - name: group9
+      - name: group10
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Groups group1..10 present again
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups:
+      - name: group1
+      - name: group2
+      - name: group3
+      - name: group4
+      - name: group5
+      - name: group6
+      - name: group7
+      - name: group8
+      - name: group9
+      - name: group10
+    register: result
+    failed_when: result.changed or result.failed
+
+  # failed_when: not result.failed has been added as this test needs to
+  # fail because two groups with the same name should be added in the same
+  # task.
+  - name: Duplicate names in groups failure test
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups:
+      - name: group1
+      - name: group2
+      - name: group3
+      - name: group3
+    register: result
+    failed_when: result.changed or not result.failed or "is used more than once" not in result.msg
+
+  - name: Groups/name and name group11 present
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      name: group11
+      groups:
+      - name: group11
+    register: result
+    failed_when: result.changed or not result.failed or "parameters are mutually exclusive" not in result.msg
+
+  - name: Groups/name and name are absent
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+    register: result
+    failed_when: result.changed or not result.failed or "one of the following is required" not in result.msg
+
+  - name: Name is absent
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      name:
+    register: result
+    failed_when: result.changed or not result.failed or "At least one name or groups is required" not in result.msg
+
+  - name: Only one group can be added at a time using name.
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      name: group11,group12
+    register: result
+    failed_when: result.changed or not result.failed or "Only one group can be added at a time using 'name'." not in result.msg
+
+  - name: Remove test groups
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      name: group1,group2,group3,group4,group5,group6,group7,group8,group9,group10
+      state: absent
+
+  - name: Remove test users
+    ipauser:
+      ipaadmin_password: SomeADMINpassword
+      name: user1,user2,user3
+      state: absent
diff --git a/tests/group/test_groups_absent.yml b/tests/group/test_groups_absent.yml
new file mode 100644
index 00000000..3238f4e2
--- /dev/null
+++ b/tests/group/test_groups_absent.yml
@@ -0,0 +1,35 @@
+---
+- name: Include create_groups_json.yml
+  ansible.builtin.import_playbook: create_groups_json.yml
+
+- name: Test groups absent
+  hosts: ipaserver
+  gather_facts: false
+
+  tasks:
+  - name: Include groups.json
+    ansible.builtin.include_vars:
+      file: groups.json  # noqa 505
+
+  - name: Initialize groups_names
+    ansible.builtin.set_fact:
+      groups_names: []
+
+  - name: Create dict with group names
+    ansible.builtin.set_fact:
+      groups_names: "{{ groups_names | default([]) + [{'name': item.name}] }}"
+    loop: "{{ group_list }}"
+
+  - name: Groups absent len:{{ group_list | length }}
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups: "{{ groups_names }}"
+      state: absent
+
+- name: Remove groups.json
+  hosts: localhost
+  tasks:
+  - name: Remove groups.json
+    ansible.builtin.file:
+      state: absent
+      path: groups.json
diff --git a/tests/group/test_groups_external_nonposix.yml b/tests/group/test_groups_external_nonposix.yml
new file mode 100644
index 00000000..5116b723
--- /dev/null
+++ b/tests/group/test_groups_external_nonposix.yml
@@ -0,0 +1,395 @@
+---
+- name: Test multiple external and nonposix groups
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  gather_facts: true
+
+  tasks:
+    # setup
+    - name: Include tasks ../env_freeipa_facts.yml
+      ansible.builtin.include_tasks: ../env_freeipa_facts.yml
+
+    # GET FQDN_AT_DOMAIN
+
+    - name: Get fqdn_at_domain
+      ansible.builtin.set_fact:
+        fqdn_at_domain: "{{ ansible_facts['fqdn'] + '@' + ipaserver_realm }}"
+
+    # CLEANUP TEST ITEMS
+
+    - name: Remove testing groups.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        name:
+        - extgroup
+        - nonposixgroup
+        - posixgroup
+        - fail_group
+        - group_1
+        - posix_group_1
+        - nonposix_group_1
+        - external_group_1
+        - external_group_2
+        state: absent
+
+    - name: Ensure test users testuser1, testuser2 and testuser3 are absent
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        name: testuser1,testuser2,testuser3
+        state: absent
+
+    # CREATE TEST ITEMS
+
+    - name: Ensure test users testuser1..testuser3 are present
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        users:
+        - name: testuser1
+          first: testuser1
+          last: Last
+        - name: testuser2
+          first: testuser2
+          last: Last
+        - name: testuser3
+          first: testuser3
+          last: Last
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Add nonposix group.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: extgroup
+          nonposix: true
+      register: result
+      failed_when: result.failed or not result.changed
+
+    - name: Add nonposix group, again.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: extgroup
+          nonposix: true
+      register: result
+      failed_when: result.failed or result.changed
+
+    - name: Set group to be external
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: extgroup
+          external: true
+      register: result
+      failed_when: result.failed or not result.changed
+
+    - name: Set group to be external, again.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: extgroup
+          external: true
+      register: result
+      failed_when: result.failed or result.changed
+
+    - name: Set external group to be non-external.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: extgroup
+          external: false
+      register: result
+      failed_when: not result.failed or "group can not be non-external" not in result.msg
+
+    - name: Set external group to be posix.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: extgroup
+          posix: true
+      register: result
+      failed_when: not result.failed or "Cannot change `external` group" not in result.msg
+
+    - name: Add nonposix group.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: posixgroup
+          nonposix: true
+      register: result
+      failed_when: result.failed or not result.changed
+
+    - name: Set group to be posix
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: posixgroup
+          posix: true
+      register: result
+      failed_when: result.failed or not result.changed
+
+    - name: Set group to be posix, again.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: posixgroup
+          posix: true
+      register: result
+      failed_when: result.failed or result.changed
+
+    - name: Set posix group to be external.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: posixgroup
+          external: true
+      register: result
+      failed_when: not result.failed or "Cannot change `posix` group" not in result.msg
+
+    - name: Set posix group to be non-posix.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: posixgroup
+          posix: false
+      register: result
+      failed_when: not result.failed or "Cannot change `posix` group" not in result.msg
+
+    - name: Set posix group to be non-posix.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: posixgroup
+          nonposix: true
+      register: result
+      failed_when: not result.failed or "Cannot change `posix` group" not in result.msg
+
+    - name: Add nonposix group.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: nonposixgroup
+          posix: false
+      register: result
+      failed_when: result.failed or not result.changed
+
+    - name: Add nonposix group, again.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: nonposixgroup
+          nonposix: true
+      register: result
+      failed_when: result.failed or result.changed
+
+
+    # NONPOSIX MEMBER TEST
+
+    - name: Ensure users testuser1, testuser2 and testuser3 are present in group nonposixgroup
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: nonposixgroup
+          nonposix: true
+          user:
+          - testuser1
+          - testuser2
+          - testuser3
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure users testuser1, testuser2 and testuser3 are present in group nonposixgroup again
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: nonposixgroup
+          nonposix: true
+          user:
+          - testuser1
+          - testuser2
+          - testuser3
+      register: result
+      failed_when: result.changed or result.failed
+
+
+    # POSIX MEMBER TEST
+
+    - name: Ensure users testuser1, testuser2 and testuser3 are present in group posixgroup
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: posixgroup
+          posix: true
+          user:
+          - testuser1
+          - testuser2
+          - testuser3
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure users testuser1, testuser2 and testuser3 are present in group posixgroup again
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: posixgroup
+          posix: true
+          user:
+          - testuser1
+          - testuser2
+          - testuser3
+      register: result
+      failed_when: result.changed or result.failed
+
+    # EXTERNAL MEMBER TEST (REQUIRES AD)
+
+    - name: Execute group tests if trust test environment is supported
+      when: trust_test_is_supported | default(false)
+      block:
+      - name: Ensure users testuser1, testuser2 and testuser3 are present in group externalgroup
+        ipagroup:
+          ipaadmin_password: SomeADMINpassword
+          groups:
+          - name: externalgroup
+            external: true
+            user:
+            - testuser1
+            - testuser2
+            - testuser3
+        register: result
+        failed_when: not result.changed or result.failed
+
+      - name: Ensure users testuser1, testuser2 and testuser3 are present in group externalgroup again
+        ipagroup:
+          ipaadmin_password: SomeADMINpassword
+          groups:
+          - name: externalgroup
+            external: true
+            user:
+            - testuser1
+            - testuser2
+            - testuser3
+        register: result
+        failed_when: result.changed or result.failed
+
+
+    # CONVERT NONPOSIX TO POSIX GROUP WITH USERS
+
+    - name: Ensure nonposix group nonposixgroup as posix
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: nonposixgroup
+          posix: true
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure nonposix group nonposixgroup as posix, again
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: nonposixgroup
+          posix: true
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure nonposix group nonposixgroup (now posix) has users still
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: nonposixgroup
+          posix: true
+          user:
+          - testuser1
+          - testuser2
+          - testuser3
+      register: result
+      failed_when: result.changed or result.failed
+
+    # FAIL ON COMBINATIONS OF NONPOSIX, POSIX AND EXTERNAL
+
+    - name: Fail to ensure group as nonposix and posix
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: firstgroup
+          nonposix: true
+          posix: false
+        - name: fail_group
+          nonposix: true
+          posix: true
+      register: result
+      failed_when: not result.failed or "parameters are mutually exclusive for group `fail_group`" not in result.msg
+
+    - name: Fail to ensure group as nonposix and external
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: firstgroup
+          nonposix: true
+          posix: false
+        - name: fail_group
+          nonposix: true
+          external: true
+      register: result
+      failed_when: not result.failed or "parameters are mutually exclusive for group `fail_group`" not in result.msg
+
+    - name: Fail to ensure group as posix and external
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+        - name: firstgroup
+          nonposix: true
+          posix: false
+        - name: fail_group
+          posix: true
+          external: true
+      register: result
+      failed_when: not result.failed or "parameters are mutually exclusive for group `fail_group`" not in result.msg
+
+    # GROUPS WITH MIXED TYPES
+
+    - name: Adding posix, nonposix and external groups in one batch
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+          - name: posix_group_1
+            posix: true
+          - name: nonposix_group_1
+            nonposix: true
+          - name: external_group_1
+            external: true
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Adding non-external and external groups in one batch
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        groups:
+          - name: non_external_group_2
+          - name: external_group_2
+            external: true
+      register: result
+      failed_when: not result.changed or result.failed
+
+    # CLEANUP
+
+    - name: Remove testing groups.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        name:
+          - extgroup
+          - nonposixgroup
+          - posixgroup
+          - fail_group
+          - group_1
+          - posix_group_1
+          - nonposix_group_1
+          - external_group_1
+          - external_group_2
+          - non_external_group_2
+        state: absent
+
+    - name: Ensure test users testuser1, testuser2 and testuser3 are absent
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        name: testuser1,testuser2,testuser3
+        state: absent
diff --git a/tests/group/test_groups_present.yml b/tests/group/test_groups_present.yml
new file mode 100644
index 00000000..71ed659d
--- /dev/null
+++ b/tests/group/test_groups_present.yml
@@ -0,0 +1,40 @@
+---
+- name: Include create_groups_json.yml
+  ansible.builtin.import_playbook: create_groups_json.yml
+
+- name: Test groups present
+  hosts: ipaserver
+  gather_facts: false
+
+  tasks:
+  - name: Include groups.json
+    ansible.builtin.include_vars:
+      file: groups.json  # noqa 505
+
+  - name: Groups present len:{{ group_list | length }}
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups: "{{ group_list }}"
+
+  - name: Initialize groups_names
+    ansible.builtin.set_fact:
+      groups_names: []
+
+  - name: Create dict with group names
+    ansible.builtin.set_fact:
+      groups_names: "{{ groups_names | default([]) + [{'name': item.name}] }}"
+    loop: "{{ group_list }}"
+
+  - name: Remove groups
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups: "{{ groups_names }}"
+      state: absent
+
+- name: Remove groups.json
+  hosts: localhost
+  tasks:
+  - name: Remove groups.json
+    ansible.builtin.file:
+      state: absent
+      path: groups.json
diff --git a/tests/group/test_groups_present_slice.yml b/tests/group/test_groups_present_slice.yml
new file mode 100644
index 00000000..3a5593a2
--- /dev/null
+++ b/tests/group/test_groups_present_slice.yml
@@ -0,0 +1,47 @@
+---
+- name: Include create_groups_json.yml
+  ansible.builtin.import_playbook: create_groups_json.yml
+
+- name: Test groups present slice
+  hosts: ipaserver
+  gather_facts: false
+
+  vars:
+    slice_size: 500
+  tasks:
+  - name: Include groups.json
+    ansible.builtin.include_vars:
+      file: groups.json  # noqa 505
+
+  - name: Size of groups slice.
+    ansible.builtin.debug:
+      msg: "{{ group_list | length }}"
+
+  - name: Groups present
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups: "{{ group_list[item : item + slice_size] }}"
+    loop: "{{ range(0, group_list | length, slice_size) | list }}"
+
+  - name: Initialize groups_names
+    ansible.builtin.set_fact:
+      groups_names: []
+
+  - name: Create dict with group names
+    ansible.builtin.set_fact:
+      groups_names: "{{ groups_names | default([]) + [{'name': item.name}] }}"
+    loop: "{{ group_list }}"
+
+  - name: Remove groups
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      groups: "{{ groups_names }}"
+      state: absent
+
+- name: Remove groups.json
+  hosts: localhost
+  tasks:
+  - name: Remove groups.json
+    ansible.builtin.file:
+      state: absent
+      path: groups.json
diff --git a/tests/sanity/ignore-2.12.txt b/tests/sanity/ignore-2.12.txt
index 364d9249..e639f478 100644
--- a/tests/sanity/ignore-2.12.txt
+++ b/tests/sanity/ignore-2.12.txt
@@ -39,6 +39,7 @@ tests/pytests/conftest.py pylint:ansible-format-automatic-specification
 tests/sanity/sanity.sh shebang!skip
 tests/user/users.sh shebang!skip
 tests/user/users_absent.sh shebang!skip
+tests/group/groups.sh shebang!skip
 tests/utils.py pylint:ansible-format-automatic-specification
 utils/ansible-doc-test shebang!skip
 utils/build-galaxy-release.sh shebang!skip
diff --git a/tests/sanity/ignore-2.13.txt b/tests/sanity/ignore-2.13.txt
index 0709427e..d5525e7b 100644
--- a/tests/sanity/ignore-2.13.txt
+++ b/tests/sanity/ignore-2.13.txt
@@ -21,6 +21,7 @@ tests/pytests/conftest.py pylint:ansible-format-automatic-specification
 tests/sanity/sanity.sh shebang!skip
 tests/user/users.sh shebang!skip
 tests/user/users_absent.sh shebang!skip
+tests/group/groups.sh shebang!skip
 tests/utils.py pylint:ansible-format-automatic-specification
 utils/ansible-doc-test shebang!skip
 utils/build-galaxy-release.sh shebang!skip
diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt
index 0709427e..d5525e7b 100644
--- a/tests/sanity/ignore-2.14.txt
+++ b/tests/sanity/ignore-2.14.txt
@@ -21,6 +21,7 @@ tests/pytests/conftest.py pylint:ansible-format-automatic-specification
 tests/sanity/sanity.sh shebang!skip
 tests/user/users.sh shebang!skip
 tests/user/users_absent.sh shebang!skip
+tests/group/groups.sh shebang!skip
 tests/utils.py pylint:ansible-format-automatic-specification
 utils/ansible-doc-test shebang!skip
 utils/build-galaxy-release.sh shebang!skip
-- 
GitLab