From 410682a01dcdde95e1e69959331017c5b5926bee Mon Sep 17 00:00:00 2001
From: Rafael Guterres Jeffman <rjeffman@redhat.com>
Date: Wed, 4 Jan 2023 18:58:27 -0300
Subject: [PATCH] pwpolicy: Allow clearing policy values.

All values for pwpolicy can be cleared with an empty string in IPA CLI,
and this behavior was missing in ansible-freeipa.

As of today, there is an issue in FreeIPA that does not allow clearing
'minlength' policy. The is is tracked by the FreeIPA project through
https://pagure.io/freeipa/issue/9297

Fixes https://bugzilla.redhat.com/show_bug.cgi?id=2150334
---
 plugins/modules/ipapwpolicy.py                |  99 +++++++++----
 tests/pwpolicy/test_pwpolicy.yml              | 112 ++++++++++++++-
 .../test_pwpolicy_invalid_data_type.yml       | 134 ++++++++++++++++++
 3 files changed, 313 insertions(+), 32 deletions(-)
 create mode 100644 tests/pwpolicy/test_pwpolicy_invalid_data_type.yml

diff --git a/plugins/modules/ipapwpolicy.py b/plugins/modules/ipapwpolicy.py
index 4398c4e8..2dfc726b 100644
--- a/plugins/modules/ipapwpolicy.py
+++ b/plugins/modules/ipapwpolicy.py
@@ -46,82 +46,82 @@ options:
     aliases: ["cn"]
   maxlife:
     description: Maximum password lifetime (in days)
-    type: int
+    type: str
     required: false
     aliases: ["krbmaxpwdlife"]
   minlife:
     description: Minimum password lifetime (in hours)
-    type: int
+    type: str
     required: false
     aliases: ["krbminpwdlife"]
   history:
     description: Password history size
-    type: int
+    type: str
     required: false
     aliases: ["krbpwdhistorylength"]
   minclasses:
     description: Minimum number of character classes
-    type: int
+    type: str
     required: false
     aliases: ["krbpwdmindiffchars"]
   minlength:
     description: Minimum length of password
-    type: int
+    type: str
     required: false
     aliases: ["krbpwdminlength"]
   priority:
     description: Priority of the policy (higher number means lower priority)
-    type: int
+    type: str
     required: false
     aliases: ["cospriority"]
   maxfail:
     description: Consecutive failures before lockout
-    type: int
+    type: str
     required: false
     aliases: ["krbpwdmaxfailure"]
   failinterval:
     description: Period after which failure count will be reset (seconds)
-    type: int
+    type: str
     required: false
     aliases: ["krbpwdfailurecountinterval"]
   lockouttime:
     description: Period for which lockout is enforced (seconds)
-    type: int
+    type: str
     required: false
     aliases: ["krbpwdlockoutduration"]
   maxrepeat:
     description: >
       Maximum number of same consecutive characters.
       Requires IPA 4.9+
-    type: int
+    type: str
     required: false
     aliases: ["ipapwdmaxrepeat"]
   maxsequence:
     description: >
       The maximum length of monotonic character sequences (abcd).
       Requires IPA 4.9+
-    type: int
+    type: str
     required: false
     aliases: ["ipapwdmaxsequence"]
   dictcheck:
     description: >
       Check if the password is a dictionary word.
       Requires IPA 4.9+
-    type: bool
+    type: str
     required: false
     aliases: ["ipapwdictcheck"]
   usercheck:
     description: >
       Check if the password contains the username.
       Requires IPA 4.9+
-    type: bool
+    type: str
     required: false
     aliases: ["ipapwdusercheck"]
   gracelimit:
     description: >
       Number of LDAP authentications allowed after expiration.
       Requires IPA 4.10.1+
-    type: int
+    type: str
     required: false
     aliases: ["passwordgracelimit"]
   state:
@@ -242,31 +242,31 @@ def main():
                       default=None, required=False),
             # present
 
-            maxlife=dict(type="int", aliases=["krbmaxpwdlife"], default=None),
-            minlife=dict(type="int", aliases=["krbminpwdlife"], default=None),
-            history=dict(type="int", aliases=["krbpwdhistorylength"],
+            maxlife=dict(type="str", aliases=["krbmaxpwdlife"], default=None),
+            minlife=dict(type="str", aliases=["krbminpwdlife"], default=None),
+            history=dict(type="str", aliases=["krbpwdhistorylength"],
                          default=None),
-            minclasses=dict(type="int", aliases=["krbpwdmindiffchars"],
+            minclasses=dict(type="str", aliases=["krbpwdmindiffchars"],
                             default=None),
-            minlength=dict(type="int", aliases=["krbpwdminlength"],
+            minlength=dict(type="str", aliases=["krbpwdminlength"],
                            default=None),
-            priority=dict(type="int", aliases=["cospriority"], default=None),
-            maxfail=dict(type="int", aliases=["krbpwdmaxfailure"],
+            priority=dict(type="str", aliases=["cospriority"], default=None),
+            maxfail=dict(type="str", aliases=["krbpwdmaxfailure"],
                          default=None),
-            failinterval=dict(type="int",
+            failinterval=dict(type="str",
                               aliases=["krbpwdfailurecountinterval"],
                               default=None),
-            lockouttime=dict(type="int", aliases=["krbpwdlockoutduration"],
+            lockouttime=dict(type="str", aliases=["krbpwdlockoutduration"],
                              default=None),
-            maxrepeat=dict(type="int", aliases=["ipapwdmaxrepeat"],
+            maxrepeat=dict(type="str", aliases=["ipapwdmaxrepeat"],
                            default=None),
-            maxsequence=dict(type="int", aliases=["ipapwdmaxsequence"],
+            maxsequence=dict(type="str", aliases=["ipapwdmaxsequence"],
                              default=None),
-            dictcheck=dict(type="bool", aliases=["ipapwdictcheck"],
+            dictcheck=dict(type="str", aliases=["ipapwdictcheck"],
                            default=None),
-            usercheck=dict(type="bool", aliases=["ipapwusercheck"],
+            usercheck=dict(type="str", aliases=["ipapwusercheck"],
                            default=None),
-            gracelimit=dict(type="int", aliases=["passwordgracelimit"],
+            gracelimit=dict(type="str", aliases=["passwordgracelimit"],
                             default=None),
             # state
             state=dict(type="str", default="present",
@@ -325,7 +325,48 @@ def main():
 
     ansible_module.params_fail_used_invalid(invalid, state)
 
-    if gracelimit is not None:
+    # Ensure parameter values are valid and have proper type.
+    def int_or_empty_param(value, param):
+        if value is not None and value != "":
+            try:
+                value = int(value)
+            except ValueError:
+                ansible_module.fail_json(
+                    msg="Invalid value '%s' for argument '%s'" % (value, param)
+                )
+        return value
+
+    maxlife = int_or_empty_param(maxlife, "maxlife")
+    minlife = int_or_empty_param(minlife, "minlife")
+    history = int_or_empty_param(history, "history")
+    minclasses = int_or_empty_param(minclasses, "minclasses")
+    minlength = int_or_empty_param(minlength, "minlength")
+    priority = int_or_empty_param(priority, "priority")
+    maxfail = int_or_empty_param(maxfail, "maxfail")
+    failinterval = int_or_empty_param(failinterval, "failinterval")
+    lockouttime = int_or_empty_param(lockouttime, "lockouttime")
+    maxrepeat = int_or_empty_param(maxrepeat, "maxrepeat")
+    maxsequence = int_or_empty_param(maxsequence, "maxsequence")
+    gracelimit = int_or_empty_param(gracelimit, "gracelimit")
+
+    def bool_param(value, param):  # pylint: disable=R1710
+        # As of Ansible 2.14, values True, False, Yes an No, with variable
+        # capitalization are accepted by Ansible.
+        if not value:
+            return value
+        if value in ["TRUE", "True", "true", "YES", "Yes", "yes"]:
+            return True
+        if value in ["FALSE", "False", "false", "NO", "No", "no"]:
+            return False
+        ansible_module.fail_json(
+            msg="Invalid value '%s' for argument '%s'." % (value, param)
+        )
+
+    dictcheck = bool_param(dictcheck, "dictcheck")
+    usercheck = bool_param(usercheck, "usercheck")
+
+    # Ensure gracelimit has proper limit.
+    if gracelimit:
         if gracelimit < -1:
             ansible_module.fail_json(
                 msg="'gracelimit' must be no less than -1")
diff --git a/tests/pwpolicy/test_pwpolicy.yml b/tests/pwpolicy/test_pwpolicy.yml
index c4a480cd..fc02914b 100644
--- a/tests/pwpolicy/test_pwpolicy.yml
+++ b/tests/pwpolicy/test_pwpolicy.yml
@@ -121,7 +121,75 @@
     register: result
     failed_when: result.changed or result.failed
 
+  - name: Ensure presence of pwpolicies for group ops
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      minlife: 7
+      maxlife: 49
+      history: 5
+      priority: 1
+      lockouttime: 300
+      minlength: 8
+      minclasses: 5
+      maxfail: 3
+      failinterval: 5
+
+  - name: Ensure policies are cleared
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      minlife: ""
+      maxlife: ""
+      history: ""
+      # priority: ""
+      lockouttime: ""
+      minclasses: ""
+      maxfail: ""
+      failinterval: ""
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Ensure policies are cleared, again
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      minlife: ""
+      maxlife: ""
+      history: ""
+      # priority: ""
+      lockouttime: ""
+      minclasses: ""
+      maxfail: ""
+      failinterval: ""
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Ensure minlength is not cleared due to FreeIPA issue
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      minlength: ""
+    register: result
+    failed_when: result.changed or (result.failed and "int() argument must be a string, a bytes-like object" not in result.msg)
+    when: ipa_version is version("4.9", ">=")
+
+  - name: Ensure minlength is not cleared due to FreeIPA issue
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      minlength: ""
+    register: result
+    failed_when: not result.changed or result.failed
+    when: ipa_version is version("4.7", "<")
+
   - name: Execute tests if ipa_version >= 4.9.0
+    when: ipa_version is version("4.9", ">=")
     block:
     - name: Ensure maxrepeat of 2 for global_policy
       ipapwpolicy:
@@ -171,6 +239,13 @@
       register: result
       failed_when: not result.changed or result.failed
 
+    - name: Ensure usercheck and dictcheck have known values
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        dictcheck: false
+        usercheck: false
+
     - name: Ensure dictcheck is set for global_policy
       ipapwpolicy:
         ipaadmin_password: SomeADMINpassword
@@ -219,9 +294,26 @@
       register: result
       failed_when: not result.changed or result.failed
 
-    when: ipa_version is version("4.9", ">=")
+    - name: Ensure usercheck and dictcheck are cleared for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        dictcheck: ""
+        usercheck: ""
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure usercheck and dictcheck are cleared for global_policy, again
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        dictcheck: ""
+        usercheck: ""
+      register: result
+      failed_when: result.changed or result.failed
 
   - name: Execute tests if ipa_version >= 4.9.10
+    when: ipa_version is version("4.9.10", ">=")
     block:
     - name: Ensure grace limit is set to 10 for global_policy
       ipapwpolicy:
@@ -255,6 +347,22 @@
       register: result
       failed_when: not result.changed or result.failed
 
+    - name: Ensure grace limit is cleared for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        gracelimit: ""
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure grace limit is cleared for global_policy, again
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        gracelimit: ""
+      register: result
+      failed_when: result.changed or result.failed
+
     - name: Ensure grace limit is not set to -2 for global_policy
       ipapwpolicy:
         ipaadmin_password: SomeADMINpassword
@@ -262,5 +370,3 @@
         gracelimit: -2
       register: result
       failed_when: not result.failed and "must be at least -1" not in result.msg
-
-    when: ipa_version is version("4.9.10", ">=")
diff --git a/tests/pwpolicy/test_pwpolicy_invalid_data_type.yml b/tests/pwpolicy/test_pwpolicy_invalid_data_type.yml
new file mode 100644
index 00000000..8a1aaed7
--- /dev/null
+++ b/tests/pwpolicy/test_pwpolicy_invalid_data_type.yml
@@ -0,0 +1,134 @@
+---
+- name: Test pwpolicy invalid data types
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: true
+  gather_facts: false
+
+  tasks:
+  - name: Setup FreeIPA test facts.
+    ansible.builtin.import_tasks: ../env_freeipa_facts.yml
+
+  - name: Ensure presence of group ops
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      state: present
+
+  - name: Ensure invalid values raise proper error for argument minlife
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      minlife: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'minlife'" not in result.msg)
+
+  - name: Ensure invalid values raise proper error for argument maxlife
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      maxlife: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'maxlife'" not in result.msg)
+
+  - name: Ensure invalid values raise proper error for argument history
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      history: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'history'" not in result.msg)
+
+  - name: Ensure invalid values raise proper error for argument priority
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      priority: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'priority'" not in result.msg)
+
+  - name: Ensure invalid values raise proper error for argument lockouttime
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      lockouttime: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'lockouttime'" not in result.msg)
+
+  - name: Ensure invalid values raise proper error for argument minlength
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      minlength: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'minlength'" not in result.msg)
+
+  - name: Ensure invalid values raise proper error for argument minclasses
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      minclasses: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'minclasses'" not in result.msg)
+
+  - name: Ensure invalid values raise proper error for argument maxfail
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      maxfail: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'maxfail'" not in result.msg)
+
+  - name: Ensure invalid values raise proper error for argument failinterval
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      failinterval: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'failinterval'" not in result.msg)
+
+  - name: Ensure invalid values for dictcheck raise proper error.
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      dictcheck: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'dictcheck" not in result.msg)
+    when: ipa_version is version("4.9", ">=")
+
+  - name: Ensure invalid values for usercheck raise proper error.
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      usercheck: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'usercheck'" not in result.msg)
+    when: ipa_version is version("4.9", ">=")
+
+  - name: Ensure invalid values for gracelimit raise proper error.
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      gracelimit: "error"
+    register: result
+    failed_when: result.changed or (result.failed and "Invalid value 'error' for argument 'gracelimit'" not in result.msg)
+    when: ipa_version is version("4.9.10", ">=")
+
+  - name: Ensure absence of group ops
+    ipagroup:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: ops
+      state: absent
-- 
GitLab