From 12463188a97666a9dcc984c5641db90b6c17d9c9 Mon Sep 17 00:00:00 2001 From: Thomas Woerner Date: Fri, 25 Feb 2022 18:09:47 +0100 Subject: [PATCH] [DRAFT]: Add support for state:query to user module --- .../module_utils/ansible_freeipa_module.py | 79 ++++++++ plugins/modules/ipauser.py | 145 ++++++++++++++- tests/user/test_user_query.yml | 173 ++++++++++++++++++ 3 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 tests/user/test_user_query.yml diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py index 354ed3cfaa..e405478bd3 100644 --- a/plugins/module_utils/ansible_freeipa_module.py +++ b/plugins/module_utils/ansible_freeipa_module.py @@ -1271,6 +1271,85 @@ def exception_handler(module, ex, exit_args, one_name): return changed + def execute_query(self, names, prefix, name_ipa_param, + query_param, query_command, query_param_settings): + """ + Execute query state. + + Parameters + ---------- + names: The main items to return + It names is not None and not an empty list then all items + found with "item_find" are returned, else the items in names. + prefix: The prefix for use with several main items + The prefix is "users" for the "user" module. It is used + if only the list of main items (example: users) is returned. + name_ipa_param: The IPA param name of the name parameter + This is for example "uid" that is used for the user name in + the user module. + query_param: The parameters to return + The parameters that should be returned. If query_param is + ["ALL"], all parameters in ipa_pram_names will be returned. + query_param_settings: IPA base parameters, all and mapping + The dict provides all parameters the "ALL" list and the + mapping of the default module paramter name to IPA option name + if it is not the same. + Example: "uid" for user name of the user commands. + query_command: The Query function + This is a module function that returns the structure(s) from + the show or find command. + + """ + + def store_params(exit_args, name, prefix, name_ipa_param, result, + params): + if params is None: + exit_args.setdefault(prefix, []).append( + result[name_ipa_param]) + return + for field in params: + if field not in query_param_settings["ALL"]: + self.fail_json( + msg="query_param '%s' is not supported" % field) + if "mapping" in query_param_settings and \ + field in query_param_settings["mapping"]: + ipa_field = query_param_settings["mapping"][field] + else: + ipa_field = field + + if ipa_field in result: + value = result[ipa_field] + if name is None: + exit_args[field] = value + else: + exit_args.setdefault(name, {})[field] = value + + # Create exit_args + exit_args = {} + + if query_param == ["BASE"]: + query_param = query_param_settings["BASE"] + elif query_param == ["ALL"]: + query_param = query_param_settings["ALL"] + + if names and isinstance(names, list): + with_name = len(names) > 1 + for name in names: + result = query_command(self, name) + if result: + store_params(exit_args, name if with_name else None, + prefix, name_ipa_param, result, + query_param) + else: + results = query_command(self, None) + if results is not None: + for result in results: + name = result[name_ipa_param] + store_params(exit_args, name, prefix, name_ipa_param, + result, query_param) + + return exit_args + class FreeIPABaseModule(IPAAnsibleModule): """ Base class for FreeIPA Ansible modules. diff --git a/plugins/modules/ipauser.py b/plugins/modules/ipauser.py index ec18522931..b7d3aa9806 100644 --- a/plugins/modules/ipauser.py +++ b/plugins/modules/ipauser.py @@ -384,6 +384,9 @@ default: "always" choices: ["always", "on_create"] required: false + query_param: + description: The fields to query with state=query + required: false action: description: Work on user or member level default: "user" @@ -393,7 +396,8 @@ default: present choices: ["present", "absent", "enabled", "disabled", - "unlocked", "undeleted"] + "unlocked", "undeleted", + "query"] author: - Thomas Woerner """ @@ -481,7 +485,7 @@ unicode = str -def find_user(module, name): +def user_show(module, name): _args = { "all": True, } @@ -501,6 +505,49 @@ def find_user(module, name): return _result +def convert_result(res): + _res = {} + for key in res: + if key in ["manager", "krbprincipalname", "ipacertmapdata"]: + _res[key] = [to_text(x) for x in (res.get(key) or [])] + elif key == "usercertificate": + _res[key] = [encode_certificate(x) for x in (res.get(key) or [])] + elif isinstance(res[key], list) and len(res[key]) == 1: + # All single value parameters should not be lists + # This does not apply to manager, krbprincipalname, + # usercertificate and ipacertmapdata + _res[key] = to_text(res[key][0]) + elif key in ["uidNumber", "gidNumber"]: + _res[key] = int(res[key]) + else: + _res[key] = to_text(res[key]) + return _res + + +def user_find(module, name, sizelimit=None, timelimit=None): + _args = {"all": True} + + if sizelimit is not None: + _args["sizelimit"] = sizelimit + if timelimit is not None: + _args["timelimit"] = timelimit + + try: + if name: + _args["uid"] = name + _result = module.ipa_command_no_name("user_find", _args).get("result") + if _result: + if name: + _result = convert_result(_result[0]) + else: + _result = [convert_result(res) for res in _result] + + except ipalib_errors.NotFound: + return None + else: + return _result + + def gen_args(first, last, fullname, displayname, initials, homedir, shell, email, principalexpiration, passwordexpiration, password, random, uid, gid, city, userstate, postalcode, phone, mobile, @@ -618,6 +665,15 @@ def check_parameters( # pylint: disable=unused-argument "certificate", "certmapdata", ]) + if state == "query": + invalid.append("users") + + if action == "member": + module.fail_json( + msg="Query is not possible with action=member") + else: + invalid.append("query_param") + if state != "absent" and preserve is not None: module.fail_json( msg="Preserve is only possible for state=absent") @@ -742,6 +798,59 @@ def exception_handler(module, ex, errors, exit_args, one_name): return False +query_param_settings = { + # password, randompassword and krbprincipalkey may not be in the returned + # information even in server context. + "ALL": [ + "objectclass", "ipauniqueid", "login", "first", "last", "fullname", + "displayname", "initials", "homedir", "shell", "email", + "principalexpiration", "passwordexpiration", "uid", "gid", "city", + "userstate", "postalcode", "phone", "mobile", "pager", "fax", + "orgunit", "title", "carlicense", "sshpubkey", "userauthtype", + "userclass", "radius", "radiususer", "departmentnumber", + "employeenumber", "employeetype", "preferredlanguage", "manager", + "principal", "certificate", "certmapdata", "gecos", "krblastpwdchange", + "krblastadminunlock", "krbextradata", "krbticketflags", + "krbloginfailedcount", "krblastsuccessfulauth", "has_password", + "has_keytab", "preserved", "memberof_group", "disabled" + ], + "BASE": [ + "login", "first", "last", "shell", "principal", "uid", "gid", + "disabled" + ], + "mapping": { + "login": "uid", + "first": "givenname", + "last": "sn", + "fullname": "cn", + "homedir": "homedirectory", + "shell": "loginshell", + "email": "mail", + "principalexpiration": "krbprincipalexpiration", + "passwordexpiration": "krbpasswordexpiration", + "uid": "uidnumber", + "gid": "gidnumber", + "city": "l", + "userstate": "st", + "postalcode": "postalcode", + "phone": "telephonenumber", + "mobile": "mobile", + "pager": "pager", + "fax": "facsimiletelephonenumber", + "orgunit": "ou", + "sshpubkey": "ipasshpubkey", + "userauthtype": "ipauserauthtype", + "radius": "ipatokenradiusconfiglink", + "radiususer": "ipatokenradiususername", + "preferredlanguage": "preferredlanguage", + "principal": "krbprincipalname", + "certificate": "usercertificate", + "certmapdata": "ipacertmapdata", + "disabled": "nsaccountock" + } +} + + def main(): user_spec = dict( # present @@ -833,18 +942,25 @@ def main(): update_password=dict(type='str', default=None, no_log=False, choices=['always', 'on_create']), + # query + query_param=dict(type="list", default=None, + choices=["ALL"].extend( + query_param_settings["ALL"]), + required=False), + # general action=dict(type="str", default="user", choices=["member", "user"]), state=dict(type="str", default="present", choices=["present", "absent", "enabled", "disabled", - "unlocked", "undeleted"]), + "unlocked", "undeleted", "query"]), # Add user specific parameters for simple use case **user_spec ), mutually_exclusive=[["name", "users"]], - required_one_of=[["name", "users"]], + # Required one of [["name", "users"]] has been removed as there is + # an extra test below and it is not working with state=query supports_check_mode=True, ) @@ -911,15 +1027,19 @@ def main(): preserve = ansible_module.params_get("preserve") # mod update_password = ansible_module.params_get("update_password") + # query + query_param = ansible_module.params_get("query_param") + # general action = ansible_module.params_get("action") state = ansible_module.params_get("state") # Check parameters - if (names is None or len(names) < 1) and \ - (users is None or len(users) < 1): - ansible_module.fail_json(msg="One of name and users is required") + if state != "query": + if (names is None or len(names) < 1) and \ + (users is None or len(users) < 1): + ansible_module.fail_json(msg="One of name and users is required") if state == "present": if names is not None and len(names) != 1: @@ -949,6 +1069,13 @@ def main(): # Connect to IPA API with ansible_module.ipa_connect(): + if state == "query": + exit_args = ansible_module.execute_query( + names, "users", "uid", query_param, user_find, + query_param_settings) + + ansible_module.exit_json(changed=False, user=exit_args) + # Check version specific settings server_realm = ansible_module.ipa_get_realm() @@ -1076,7 +1203,7 @@ def main(): "your IPA version") # Make sure user exists - res_find = find_user(ansible_module, name) + res_find = user_show(ansible_module, name) # Create command if state == "present": @@ -1136,7 +1263,7 @@ def main(): principal_add, principal_del = gen_add_del_lists( principal, res_find.get("krbprincipalname")) # Principals are not returned as utf8 for IPA using - # python2 using user_find, therefore we need to + # python2 using user_show, therefore we need to # convert the principals that we should remove. principal_del = [to_text(x) for x in principal_del] diff --git a/tests/user/test_user_query.yml b/tests/user/test_user_query.yml new file mode 100644 index 0000000000..7e3392e3f6 --- /dev/null +++ b/tests/user/test_user_query.yml @@ -0,0 +1,173 @@ +--- +- name: Test ipauser random password generation + hosts: ipaserver + become: true + + tasks: + + # CLEANUP + + - name: Ensure users "testuser1" and "testuser2" are absent + ipauser: + ipaadmin_password: SomeADMINpassword + name: + - testuser1 + - testuser2 + - non-existing-user + state: absent + + # CREATE TEST ITEMS + + - name: Ensure users "testuser1" and "testuser2" are present + ipauser: + ipaadmin_password: SomeADMINpassword + users: + - name: testuser1 + first: first1 + last: last1 + - name: testuser2 + first: first2 + last: last2 + + - name: Query user "non-exitsing-user" + ipauser: + ipaadmin_password: SomeADMINpassword + name: + - non-existing-user + query_param: ALL + state: query + register: result + failed_when: result.changed or result.failed + + - name: Print query information + debug: + var: result + + - name: Fail on non empty query result + fail: + msg: "{{ result['user'] }} is not empty" + when: result['user'] | length > 0 + + - name: Query all users + ipauser: + ipaadmin_password: SomeADMINpassword + state: query + register: result + failed_when: result.changed or result.failed + + - name: Print query information + debug: + var: result + + - name: Fail on missing "testuser1" in query result + fail: + msg: "'testuser1' not in query result {{ result['user']['users'] }}" + when: ("testuser1" not in result["user"]["users"]) + + - name: Fail on missing "testuser2" in query result + fail: + msg: "'testuser2' not in query result {{ result['user']['users'] }}" + when: ("testuser2" not in result["user"]["users"]) + + - name: Fail on "non-existing-user" in query result + fail: + msg: "'non-existing-user' in query result {{ result['user']['users'] }}" + when: ("non-existing-user" in result["user"]["users"]) + + - name: Query users "testuser1", "testuser2" and "non-existing-user" + ipauser: + ipaadmin_password: SomeADMINpassword + name: + - testuser1 + - testuser2 + - non-existing-user + state: query + register: result + failed_when: result.changed or result.failed + + - name: Fail on missing "testuser1" in query result + fail: + msg: "'testuser1' not in query result {{ result['user']['users'] }}" + when: ("testuser1" not in result["user"]["users"]) + + - name: Fail on missing "testuser2" in query result + fail: + msg: "'testuser2' not in query result {{ result['user']['users'] }}" + when: ("testuser2" not in result["user"]["users"]) + + - name: Fail on "non-existing-user" in query result + fail: + msg: "'non-existing-user' in query result {{ result['user']['users'] }}" + when: ("non-existing-user" in result["user"]["users"]) + + - name: Query all user parameters for "testuser1" + ipauser: + ipaadmin_password: SomeADMINpassword + name: + - testuser1 + query_param: ALL + state: query + register: result + failed_when: result.changed or result.failed + + - name: Print query information + debug: + var: result + + - name: Fail on missing information in query result + fail: + msg: "Query result {{ result['user'] }} is incomplete" + when: (result["user"]["displayname"] != "first1 last1" or + result["user"]["first"] != "first1" or + result["user"]["fullname"] != "first1 last1" or + result["user"]["initials"] != "fl" or + result["user"]["last"] != "last1" or + result["user"]["login"] != "testuser1") + + - name: Query "uid", "first" and "last" parameters for all users + ipauser: + ipaadmin_password: SomeADMINpassword + query_param: + - uid + - first + - last + state: query + register: result + failed_when: result.changed or result.failed + + - name: Print query information + debug: + var: result + + - name: Fail on less than 3 users in result + fail: + msg: "{{ result['user'] }} is not empty" + when: result['user'] | length < 3 + + - name: Fail on missing "testuser1" information in query result + fail: + msg: "'testuser1' not in query result {{ result['user'] }}" + when: ("testuser1" not in result["user"] or + result["user"]["testuser1"]["first"] != "first1" or + result["user"]["testuser1"]["last"] != "last1" or + "uid" not in result["user"]["testuser1"] or + result["user"]["testuser1"] | length != 3) + + - name: Fail on missing "testuser2" information in query result + fail: + msg: "'testuser2' not in query result {{ result['user'] }}" + when: ("testuser2" not in result["user"] or + result["user"]["testuser2"]["first"] != "first2" or + result["user"]["testuser2"]["last"] != "last2" or + "uid" not in result["user"]["testuser2"] or + result["user"]["testuser2"] | length != 3) + + # CLEANUP + + - name: Ensure users "testuser1" and "testuser2" are absent + ipauser: + ipaadmin_password: SomeADMINpassword + name: + - testuser1 + - testuser2 + state: absent