diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py index 3c25c7953..5e3cd5b4e 100644 --- a/plugins/module_utils/ansible_freeipa_module.py +++ b/plugins/module_utils/ansible_freeipa_module.py @@ -1166,6 +1166,218 @@ def exception_handler(module, ex, exit_args, one_name): return changed + def fetch_objects( + self, object_type, object_key, ipa_param_mapping, + obj_filter=None, encoding_fn=None, sizelimit=None, timelimit=None + ): + """ + Fetch objects using IPA API *_find calls. + + Parameters + ---------- + object_type: string + The IPA object to search (e.g.: "user", "automember"). + object_key: list + The IPA attribute name that uniquely identify the object. + ipa_param_mapping: dict + A mapping of :. It must also + include the 'object_key' keys mapping (e.g. 'name': 'uid' for + users). + obj_filter: function + An optional predicate function that evaluates to True if the + object is to be added to the result set. + encoding_fn: dict + An optional mapping : . The + encoding function will be applied to the 'ipa attribute', if a + function is not provided a 'best guess' one will be used + (either the buitin for the defined data type, or 'to_text'). + sizelimit: int + Optionally set the size limit for the IPA API query. + timelimit: int + Optionally set the time limit for the IPA API query. + + """ + self.warn("Fetching objects might be limited by 'sizelimit'.") + self.warn("Fetching objects might fail due to 'timelimit'.") + + if not isinstance(object_key, (list, tuple)): + object_key = [object_key] + if not obj_filter: + obj_filter = bool + fetch_params = self.params_get("fetch_param") or [] + param_ipa_map = {v: k for k, v in ipa_param_mapping.items()} + encoding_fn = encoding_fn or {} + + # retrieve all objects. + try: + _args = {"all": True} + if sizelimit is not None: + _args["sizelimit"] = sizelimit + if timelimit is not None: + _args["timelimit"] = timelimit + _result = self.ipa_command_no_name( + "%s_find" % object_type, _args + ) + except ipalib_errors.NotFound: + return [] + else: + _result = _result.get("result") + + # Filter objects to be returned. + _result = [res for res in _result if obj_filter(res)] + + for res in _result: + # All single value parameters should not be lists + for param in res: + mod_arg = param_ipa_map.get(param) + arg_desc = self.argument_spec.get(mod_arg) + # Mark params that will not be returned. + if ( + mod_arg not in object_key + and "all" not in fetch_params + and mod_arg not in fetch_params + and (arg_desc is None or arg_desc.get("no_log")) + ): + res[param] = None + continue + # Format data to be returned. + # Apply encoding function to parameters + encode = encoding_fn.get(param) + # if encode function is not set, apply the builtin + # conversion for the specified data type. + if not encode: + param_type = (arg_desc or {}).get("type", "str") + arg_type = getattr(sys.modules["builtins"], param_type) + if ( + param_type in ("list", "tuple", "str") + or not arg_type + ): + encode = to_text + else: + encode = arg_type + + if not isinstance(res[param], (list, tuple)): + res[param] = [res[param]] + # Apply data encoding + res[param] = [encode(x) for x in res[param]] + # Return only actual object if list has a single element. + if len(res[param]) == 1: + res[param] = res[param][0] + + # Return object data, if requested. + if fetch_params: + return [ + { + param_ipa_map[k]: v + for k, v in res.items() + if v is not None and k in param_ipa_map + } + for res in _result + ] + # Otherwise, return the object "keys" + # If object key is compound, each object is returend as a dict. + return [ + res[ipa_param_mapping[object_key[0]]] + if len(object_key) == 1 + else {k: res[ipa_param_mapping[k]] for k in object_key} + for res in _result + ] + + def execute_fetched(self, exit_args, names, prefix, name_ipa_param, + fetch_params, ipa_param_mapping, + ipa_param_converter, + show_function, find_function): + """ + Execute fetched state. + + Parameters + ---------- + exit_args: Exit args dict + This is the dict that will be resturned with + ansible_module.exit_json + 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. + fetch_params: The parameters to return + The parameters that should be returned. If fetch_params is + ["all"], all parameters in ipa_pram_names will be returned. + ipa_param_mapping: IPA param mapping + This is the mapping of the default module paramter name to + IPA option name. + Example: "uid" for user name of the user commands. + ipa_param_converter: Parameter converter + This is an extra parameter converter for parameters that + need to be converted into integers for example. + show_function: The function to show one entry + This is "user-show" for the user command. + find_function: The function to find several entries + This is "user-find" for the user command. + + Example (ipauser module): + + if state == "fetched": + changed = ansible_module.execute_fetched( + exit_args, "users", "uid", fetch_params, ipa_param_mapping, + names, user_show, user_find) + ansible_module.exit_json(changed=False, user=exit_args) + + """ + + def store_params(exit_args, name, prefix, name_ipa_param, result, + params, ipa_param_converter): + if params is None: + exit_args.setdefault(prefix, []).append( + result[name_ipa_param]) + return + for field in params: + ipa_field = ipa_param_mapping[field] + + if ipa_field in result: + value = result[ipa_field] + if ipa_param_converter and \ + field in ipa_param_converter: + if isinstance(value, list): + value = [ipa_param_converter[field](val) + for val in value] + else: + value = ipa_param_converter[field](value) + else: + if isinstance(value, list): + value = [to_text(val) for val in value] + else: + value = to_text(value) + if name is None: + exit_args[field] = value + else: + exit_args.setdefault(name, {})[field] = value + + if fetch_params == ["all"]: + fetch_params = ipa_param_mapping.keys() + + if names and isinstance(names, list): + with_name = len(names) > 1 + for name in names: + result = show_function(self, name) + if result: + store_params(exit_args, name if with_name else None, + prefix, name_ipa_param, result, + fetch_params, ipa_param_converter) + else: + results = find_function(self) + if results is not None: + for result in results: + name = result[name_ipa_param] + store_params(exit_args, name, prefix, name_ipa_param, + result, fetch_params, ipa_param_converter) + + return False + class FreeIPABaseModule(IPAAnsibleModule): """ Base class for FreeIPA Ansible modules. diff --git a/plugins/modules/ipaservice.py b/plugins/modules/ipaservice.py index bc7377126..5444f2a62 100644 --- a/plugins/modules/ipaservice.py +++ b/plugins/modules/ipaservice.py @@ -148,6 +148,9 @@ required: false default: True type: bool + fetch_param: + description: The fields to fetch with state=fetched + required: false action: description: Work on service or member level default: service @@ -155,7 +158,7 @@ state: description: State to ensure default: present - choices: ["present", "absent", "disabled"] + choices: ["present", "absent", "disabled", "fetched"] author: - Rafael Jeffman """ @@ -303,7 +306,12 @@ def check_parameters(module, state, action, names): 'allow_retrieve_keytab_group', 'allow_retrieve_keytab_host', 'allow_retrieve_keytab_hostgroup'] - if state == 'present': + if state == 'fetched': + if action != 'service': + module.fail_json(msg="Can only fetch if action is 'service'.") + invalid = ['delete_continue'] + + elif state == 'present': if len(names) != 1: module.fail_json(msg="Only one service can be added at a time.") @@ -347,7 +355,7 @@ def init_ansible_module(): argument_spec=dict( # general name=dict(type="list", aliases=["service"], default=None, - required=True), + required=False), # service attributesstr certificate=dict(type="list", aliases=['usercertificate'], default=None, required=False), @@ -392,6 +400,11 @@ def init_ansible_module(): allow_retrieve_keytab_hostgroup=dict( type="list", required=False, aliases=['ipaallowedtoperform_read_keys_hostgroup']), + # fetched + fetch_param=dict(type="list", default=None, + choices=["all"].extend(ipa_param_mapping.keys()), + required=False), + # absent delete_continue=dict(type="bool", required=False, aliases=['continue']), # action @@ -399,7 +412,7 @@ def init_ansible_module(): choices=["member", "service"]), # state state=dict(type="str", default="present", - choices=["present", "absent", "disabled"]), + choices=["present", "absent", "disabled", "fetched"]), ), supports_check_mode=True, ) @@ -409,6 +422,20 @@ def init_ansible_module(): return ansible_module +ipa_param_mapping = { + "principal": "krbprincipalname", + "certificate": "usercertificate", + "pac_type": "ipakrbauthzdata", + "auth_ind": "krbprincipalauthind", + "requires_pre_auth": "ipakrbrequirespreauth", + "ok_as_delegate": "ipakrbokasdelegate", + "ok_to_auth_as_delegate": "ipakrboktoauthasdelegate", + "netbiosname": "ipantflatname", + "host": "managedby_host", + "service": "krbcanonicalname", +} + + def main(): ansible_module = init_ansible_module() @@ -461,6 +488,35 @@ def main(): commands = [] keytab_members = ["user", "group", "host", "hostgroup"] + if state == "fetched": + encoding_fn = { + "usercertificate": encode_certificate + } + # set filter based on "name" + if names: + names = [ + p.lower() if "@" in p + else "%s@%s".lower() % (p.lower(), api_get_realm().lower()) + for p in names + ] + + def object_filter(res): + return any( + ( + to_text(svc).lower().startswith(n) + for svc in res["krbcanonicalname"] for n in names + ) + ) + + # fetch objects + fetched = ansible_module.fetch_objects( + "service", ["service"], ipa_param_mapping, + object_filter, encoding_fn=encoding_fn + ) + exit_args["services"] = fetched + ansible_module.exit_json(changed=False, service=exit_args) + names = [] + for name in names: res_find = find_service(ansible_module, name) res_principals = [] diff --git a/plugins/modules/ipauser.py b/plugins/modules/ipauser.py index aee71cd22..4f90b33a4 100644 --- a/plugins/modules/ipauser.py +++ b/plugins/modules/ipauser.py @@ -384,6 +384,9 @@ default: "always" choices: ["always", "on_create"] required: false + fetch_param: + description: The fields to fetch with state=fetched + 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", + "fetched"] author: - Thomas Woerner """ @@ -501,6 +505,59 @@ def find_user(module, name): return _result +def simplify_result(res): + # Transform each principal to a string + if "krbprincipalname" in res: + res["krbprincipalname"] = [ + to_text(x) for x in (res.get("krbprincipalname") or []) + ] + # Transform each certificate to a string + if "usercertificate" in res: + res["usercertificate"] = [ + encode_certificate(x) for x in + (res.get("usercertificate") or []) + ] + # All single value parameters should not be lists + for param in res: + if isinstance(res[param], list) and len(res[param]) == 1 and \ + param not in ["manager", "krbprincipalname", "usercertificate", + "ipacertmapdata"]: + res[param] = res[param][0] + if param in []: + res[param] = int(res[param]) + + +def user_show_simplified(module, name): + _args = { + "all": True, + } + + try: + _result = module.ipa_command("user_show", name, _args).get("result") + except ipalib_errors.NotFound: + return None + + simplify_result(_result) + + return _result + + +def user_find(module): + _args = { + "all": True, + } + + try: + _result = module.ipa_command_no_name("user_find", _args).get("result") + except ipalib_errors.NotFound: + return None + + for res in _result: + simplify_result(res) + + 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 +675,15 @@ def check_parameters( # pylint: disable=unused-argument "certificate", "certmapdata", ]) + if state == "fetched": + invalid.append("users") + + if action == "member": + module.fail_json( + msg="Fetched is not possible with action=member") + else: + invalid.append("fetch_param") + if state != "absent" and preserve is not None: module.fail_json( msg="Preserve is only possible for state=absent") @@ -742,6 +808,49 @@ def exception_handler(module, ex, errors, exit_args, one_name): return False +# ARGH: Globals... +ipa_param_mapping = { + "login": "uid", + "first": "givenname", + "last": "sn", + "fullname": "cn", + "displayname": "displayname", + "initials": "initials", + "homedir": "homedirectory", + "shell": "loginshell", + "email": "mail", + "principalexpiration": "krbprincipalexpiration", + "passwordexpiration": "krbpasswordexpiration", + # "password": "userpassword", Never return passwords + # "randompassword": "randompassword", Never return passwords + "uid": "uidnumber", + "gid": "gidnumber", + "city": "l", + "userstate": "st", + "postalcode": "postalcode", + "phone": "telephonenumber", + "mobile": "mobile", + "pager": "pager", + "fax": "facsimiletelephonenumber", + "orgunit": "ou", + "title": "title", + "carlicense": "carlicense", + "sshpubkey": "ipasshpubkey", + "userauthtype": "ipauserauthtype", + "userclass": "userclass", + "radius": "ipatokenradiusconfiglink", + "radiususer": "ipatokenradiususername", + "departmentnumber": "departmentnumber", + "employeenumber": "employeenumber", + "employeetype": "employeetype", + "preferredlanguage": "preferredlanguage", + "manager": "manager", + "principal": "krbprincipalname", + "certificate": "usercertificate", + "certmapdata": "ipacertmapdata", +} + + def main(): user_spec = dict( # present @@ -833,18 +942,24 @@ def main(): update_password=dict(type='str', default=None, no_log=False, choices=['always', 'on_create']), + # fetched + fetch_param=dict(type="list", default=None, + choices=["all"].extend(ipa_param_mapping.keys()), + 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", "fetched"]), # 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=fetched supports_check_mode=True, ) @@ -915,9 +1030,10 @@ def main(): # 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 != "fetched": + 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: @@ -965,6 +1081,17 @@ def main(): commands = [] user_set = set() + if state == "fetched": + encoding_fn = { + "usercertificate": encode_certificate + } + exit_args["users"] = ansible_module.fetch_objects( + "user", ["login"], ipa_param_mapping, + lambda r: r["uid"][0] in names if names else True, + encoding_fn=encoding_fn + ) + ansible_module.exit_json(changed=False, user=exit_args) + for user in names: if isinstance(user, dict): name = user.get("name") @@ -1141,7 +1268,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 find_user, 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/service/test_service_fetched.yml b/tests/service/test_service_fetched.yml new file mode 100644 index 000000000..312aa70f3 --- /dev/null +++ b/tests/service/test_service_fetched.yml @@ -0,0 +1,112 @@ +--- +- name: Test ipaservice fetch + hosts: ipaserver + become: true + + tasks: + - include_tasks: ../env_freeipa_facts.yml + + - when: ipa_version is version('4.7.0', '>=') + block: + # SETUP + - name: Generate self-signed certificates. + shell: + cmd: | + openssl req -x509 -newkey rsa:2048 -days 365 -nodes -keyout "private{{ item }}.key" -out "cert{{ item }}.pem" -subj '/CN=test' + openssl x509 -outform der -in "cert{{ item }}.pem" -out "cert{{ item }}.der" + base64 "cert{{ item }}.der" -w5000 > "cert{{ item }}.b64" + with_items: [1, 2] + become: no + delegate_to: localhost + + - name: Setup test environment + include_tasks: env_setup.yml + + - name: Ensure service is present + ipaservice: + ipaadmin_password: SomeADMINpassword + ipaapi_context: "{{ ipa_context | default(omit) }}" + name: "HTTP/{{ svc_fqdn }}" + pac_type: + - MS-PAC + - PAD + auth_ind: otp + skip_host_check: no + force: yes + requires_pre_auth: yes + ok_as_delegate: no + ok_to_auth_as_delegate: no + certificate: + - "{{ lookup('file', 'cert1.b64') }}" + register: result + failed_when: not result.changed or result.failed + + # TESTS + - name: Fetch service information + ipaservice: + ipaadmin_password: SomeADMINpassword + name: + - DNS/fedsrv.ipa.test + fetch_param: all + state: fetched + register: result + failed_when: result.changed or result.failed + + - name: Print fetched information + debug: + var: result + + - name: Fetch service information + ipaservice: + ipaadmin_password: SomeADMINpassword + fetch_param: + - host + - principal + state: fetched + register: result + failed_when: result.changed or result.failed + + - name: Print fetched information + debug: + var: result + + - name: Fetch service information + ipaservice: + ipaadmin_password: SomeADMINpassword + state: fetched + register: result + failed_when: result.changed or result.failed + + - name: Print fetched information + debug: + var: result + + - name: Fetch service information + ipaservice: + ipaadmin_password: SomeADMINpassword + name: + - DNS/fedsrv.ipa.test + - http/fedsrv.ipa.test + - mysvc/fedsrv.ipa.test + fetch_param: all + state: fetched + register: result + failed_when: result.changed or result.failed + + - name: Print fetched information + debug: + var: result + + always: + # CLEANUP + - name: Cleanup test environment + include_tasks: env_cleanup.yml + + - name: Remove certificate files. + shell: + cmd: rm -f "private{{ item }}.key" "cert{{ item }}.pem" "cert{{ item }}.der" "cert{{ item }}.b64" + with_items: [1, 2] + become: no + delegate_to: localhost + args: + warn: no # suppres warning for not using the `file` module. diff --git a/tests/user/test_user_fetched.yml b/tests/user/test_user_fetched.yml new file mode 100644 index 000000000..f093a33cd --- /dev/null +++ b/tests/user/test_user_fetched.yml @@ -0,0 +1,100 @@ +--- +- name: Test ipauser random password generation + hosts: ipaserver + become: true + + tasks: + # setup + - name: Users user1 and user2 present + ipauser: + ipaadmin_password: SomeADMINpassword + users: + - name: user1 + first: first1 + last: last1 + - name: user2 + first: first2 + last: last2 + + # tests + - name: Fetch user list + ipauser: + ipaadmin_password: SomeADMINpassword + state: fetched + register: result + failed_when: result.changed or result.failed + + - name: Print fetched information + debug: + var: result + + - name: Fetch user user1 information + ipauser: + ipaadmin_password: SomeADMINpassword + name: + - user1 + fetch_param: all + state: fetched + register: result + failed_when: result.changed or result.failed + + - name: Print fetched information + debug: + var: result + + - name: Fetch users information + ipauser: + ipaadmin_password: SomeADMINpassword + name: + - user1 + - user2 + - user100 + state: fetched + register: result + failed_when: result.changed or result.failed + + - name: Print fetched information + debug: + var: result + + - name: Fetch user information for given parameters + ipauser: + ipaadmin_password: SomeADMINpassword + fetch_param: + - uid + - first + - last + state: fetched + register: result + failed_when: result.changed or result.failed + + - name: Print fetched information + debug: + var: result + + - name: Fetch user information for given parameters for selected users + ipauser: + ipaadmin_password: SomeADMINpassword + name: + - user1 + - user100 + fetch_param: + - uid + - first + - last + state: fetched + register: result + failed_when: result.changed or result.failed + + - name: Print fetched information + debug: + var: result + + # cleanup + - name: Users user1 and user2 absent + ipauser: + ipaadmin_password: SomeADMINpassword + name: + - user1 + - user2 + state: absent