From 432c0b75fcfef7075802922bf592682f965a2d68 Mon Sep 17 00:00:00 2001 From: Thomas Woerner Date: Tue, 23 Jun 2026 12:49:29 +0200 Subject: [PATCH 1/5] Update pre-commit repo versions, deactivate parseable for ansible-lint ansible-lint was updated to v26.4.0, yamllint to v1.38.0 and pylint to v4.0.6. parseable was deactivated in .ansible-lint as this option is not supported any more. --- .ansible-lint | 2 +- .pre-commit-config.yaml | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.ansible-lint b/.ansible-lint index 28fc3ac2..5b753c1e 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -23,7 +23,7 @@ kinds: - tasks: '**/tasks_*.yml' - tasks: '**/env_*.yml' -parseable: true +# parseable: true quiet: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b8ca89b..54419b67 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ --- repos: - repo: https://github.com/ansible/ansible-lint.git - rev: v25.9.2 + rev: v26.4.0 hooks: - id: ansible-lint always_run: false @@ -18,10 +18,9 @@ repos: --profile production --exclude tests/integration/ --exclude tests/unit/ - --parseable --nocolor - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint files: \.(yaml|yml)$ @@ -30,7 +29,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pycqa/pylint - rev: v4.0.2 + rev: v4.0.6 hooks: - id: pylint args: From 12bbf74d524cb5d612affe737903712854392eb4 Mon Sep 17 00:00:00 2001 From: Thomas Woerner Date: Tue, 23 Jun 2026 12:53:21 +0200 Subject: [PATCH 2/5] setup.cfg: Set max-public-methods to 25 With the updates for PARAM_MAPPING in IPAAnsibleModule it is needed to increase max public methods. --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index b0cbe64d..60ad9586 100644 --- a/setup.cfg +++ b/setup.cfg @@ -89,6 +89,7 @@ ignored-modules = [pylint.DESIGN] max-attributes=12 +max-public-methods=25 [pylint.REFACTORING] max-nested-blocks = 9 From 964345aed88838d9c21b98784a8a41bd295dfa04 Mon Sep 17 00:00:00 2001 From: Thomas Woerner Date: Tue, 16 Jun 2026 13:07:55 +0200 Subject: [PATCH 3/5] ansible_freeipa_module.py: New PARAM_MAPPING and query support New functions and methods have been added to reduce code complexity and duplication in several modules, as well as to enable query mode in the modules. New functions: gen_member_add_del_lists Compute member add/del lists for all member params marked with "member": True in PARAM_MAPPING. Handles sync, add-only and remove-only modes based on action and state. gen_member_args_from_mapping Build a member args dict for compare_args_ipa() from PARAM_MAPPING. Replaces per-module gen_member_args() functions. New static methods: gen_args_from_mapping Generate IPA command args dict from PARAM_MAPPING. Replaces per-module gen_args() functions. extract_params Extract parameter values from module params using PARAM_MAPPING. extract_params_from_entry Extract parameter values from a users/hosts dict entry using PARAM_MAPPING. build_query_param_settings Build query parameter settings from PARAM_MAPPING and QUERY_FIELDS for use with execute_query. New methods: execute_query Execute query state for modules supporting state: query. _extract_query_fields Extract requested fields from a query result using the mapping. The params_fail_used_invalid method has been extended to accept optional params and param_mapping arguments for use with extracted params dicts. PARAM_MAPPING is an ordered dict that maps Ansible parameter names to their metadata. Each entry is a dict with optional keys: "api_name" IPA attribute name if different from the Ansible parameter name. "gen_args" Set to False to exclude the parameter from gen_args_from_mapping (used for members and parameters handled separately). "member" Set to True to include the parameter in gen_member_add_del_lists and gen_member_args_from_mapping. "module_param" Set to True for top-level module parameters that are not part of the entity (e.g. action, state, query_param, users). "return_only" Set to True for parameters that are only returned by IPA and never sent (excluded from gen_args_from_mapping). "nonempty_list" Set to True if an empty list value should not be sent to IPA. "lowercase" Set to True to lowercase values before comparison. "convert_to" Conversion type for the parameter value (e.g. "str", "int", "bool"). An empty dict {} means the Ansible name equals the IPA attribute name and the parameter is included in gen_args_from_mapping with no special handling. For query support a QUERY_FIELDS dict is needed with "prefix" for the grouping key in PKEY_ONLY results, "primary_key" for the IPA name attribute and "base" for the list of essential field names returned by default. --- .../module_utils/ansible_freeipa_module.py | 415 +++++++++++++++++- 1 file changed, 413 insertions(+), 2 deletions(-) diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py index 2f861efa..1295bb00 100644 --- a/plugins/module_utils/ansible_freeipa_module.py +++ b/plugins/module_utils/ansible_freeipa_module.py @@ -666,6 +666,108 @@ def gen_intersection_list(user_list, res_list): return list(set(res_list or []).intersection(set(user_list or []))) +def gen_member_add_del_lists(param_mapping, params, res_find, + action, state): + """ + Compute member add/del lists for all member params in param_mapping. + + Scans param_mapping for entries with "member": True and computes + add and del lists based on action and state: + + - action != "member", state == "present": sync mode using + gen_add_del_lists (both additions and removals) + - action == "member", state == "present": add-only mode using + gen_add_list + - action == "member", state == "absent": remove-only mode using + gen_intersection_list + + For any other action/state combination an empty dict is returned. + + Parameters + ---------- + param_mapping: dict + The module's PARAM_MAPPING dict. Entries with "member": True + are processed. + params: dict + Extracted parameter values keyed by ansible parameter name. + res_find: dict + The IPA find result for the entity. May be an empty dict for + newly created entities. + action: str + The module's action value ("member" or the entity action name). + state: str + The module's state value ("present" or "absent"). + + Returns + ------- + dict + Mapping of ansible parameter names to (add_list, del_list) + tuples. + """ + if state == "present" and action != "member": + mode = "sync" + elif state == "present" and action == "member": + mode = "add" + elif state == "absent" and action == "member": + mode = "remove" + else: + return {} + + result = {} + for ansible_name, entry in param_mapping.items(): + if not entry.get("member"): + continue + + value = params.get(ansible_name) + find_key = entry.get("api_name", ansible_name) + existing = res_find.get(find_key) + + if mode == "sync": + result[ansible_name] = gen_add_del_lists(value, existing) + elif mode == "add": + result[ansible_name] = ( + gen_add_list(value, existing), []) + else: + result[ansible_name] = ( + [], gen_intersection_list(value, existing)) + + return result + + +def gen_member_args_from_mapping(param_mapping, params): + """ + Build a member args dict for compare_args_ipa() from param_mapping. + + Scans param_mapping for entries with "member": True and builds a + dict mapping the IPA attribute name (api_name) to the parameter + value for all member params where the value is not None. + + This replaces per-module gen_member_args() functions. + + Parameters + ---------- + param_mapping: dict + The module's PARAM_MAPPING dict. + params: dict + Extracted parameter values keyed by ansible parameter name. + + Returns + ------- + dict + IPA attribute names as keys, parameter values as values. + Suitable for passing to compare_args_ipa(). + """ + _args = {} + for ansible_name, entry in param_mapping.items(): + if not entry.get("member"): + continue + value = params.get(ansible_name) + if value is not None: + find_key = entry.get("api_name", ansible_name) + _args[find_key] = value + return _args + + def encode_certificate(cert): """ Encode a certificate using base64. @@ -1226,7 +1328,8 @@ class IPAAnsibleModule(AnsibleModule): return module_params_get_with_type_cast( self, name, datatype, allow_empty) - def params_fail_used_invalid(self, invalid_params, state, action=None): + def params_fail_used_invalid(self, invalid_params, state, action=None, + params=None, param_mapping=None): """ Fail module execution if one of the invalid parameters is not None. @@ -1238,6 +1341,12 @@ class IPAAnsibleModule(AnsibleModule): State being tested. action: Action being tested (optional). + params: + Extracted params dict to check (optional, defaults to + self.params). + param_mapping: + Parameter mapping dict (optional). When provided, params + marked with "module_param" are checked via self.params. """ if action is None: @@ -1246,8 +1355,15 @@ class IPAAnsibleModule(AnsibleModule): msg = "Argument '{0}' can not be used with action "\ "'{2}' and state '{1}'" + _params = params if params is not None else self.params for param in invalid_params: - if self.params.get(param) is not None: + if param_mapping is not None \ + and param in param_mapping \ + and param_mapping[param].get("module_param"): + value = self.params.get(param) + else: + value = _params.get(param) + if value is not None: self.fail_json(msg=msg.format(param, state, action)) def ipa_command(self, command, name, args): @@ -1605,6 +1721,301 @@ class IPAAnsibleModule(AnsibleModule): return changed + @staticmethod + def _parse_mapping_entry(ansible_name, entry): + """ + Return (ansible_name, ipa_name) from a dict-of-dicts mapping entry. + + If entry has "api_name", that is the IPA attribute name. + Otherwise, the IPA name equals the ansible name. + + """ + return ansible_name, entry.get("api_name", ansible_name) + + @staticmethod + def gen_args_from_mapping(param_mapping, params): + """ + Generate IPA command args dict from a parameter mapping. + + Parameters + ---------- + param_mapping: dict + The module's parameter mapping dict. Keys are ansible param + names, values are dicts with optional keys: "api_name", + "nonempty_list", "convert_to", "gen_args", "return_only". + params: dict + Parameter values keyed by ansible name. + + Returns + ------- + dict + IPA attribute names as keys, suitable for IPA API commands + and compare_args_ipa(). + + """ + _args = {} + for ansible_name, entry in param_mapping.items(): + if entry.get("gen_args") is False \ + or entry.get("return_only") \ + or entry.get("module_param"): + continue + + ipa_name = entry.get("api_name", ansible_name) + value = params.get(ansible_name) + + if value is None: + continue + + if entry.get("nonempty_list") and len(value) == 0: + continue + + convert = entry.get("convert_to") + if convert == "text": + value = to_text(str(value)) + + _args[ipa_name] = value + + return _args + + @staticmethod + def extract_params(module, param_mapping): + """Extract parameter values from module params using mapping.""" + params = {} + for ansible_name, entry in param_mapping.items(): + if entry.get("return_only") or entry.get("module_param"): + continue + type_cast = entry.get("type_cast") + if type_cast is not None: + params[ansible_name] = module.params_get_with_type_cast( + ansible_name, type_cast, + allow_empty=entry.get("allow_empty", False)) + elif entry.get("lowercase"): + allow_empty = entry.get("allow_empty_list_item") + params[ansible_name] = module.params_get_lowercase( + ansible_name, allow_empty_list_item=bool(allow_empty)) + elif entry.get("allow_empty_list_item"): + params[ansible_name] = module.params_get( + ansible_name, allow_empty_list_item=True) + else: + params[ansible_name] = module.params_get(ansible_name) + return params + + @staticmethod + def extract_params_from_entry(entry_dict, param_mapping): + """Extract parameter values from a dict using mapping.""" + params = {} + for ansible_name, entry in param_mapping.items(): + if entry.get("return_only") or entry.get("module_param"): + continue + value = entry_dict.get(ansible_name) + if value is not None and entry.get("lowercase"): + value = convert_param_value_to_lowercase(value) + params[ansible_name] = value + return params + + @staticmethod + def build_query_param_settings(param_mapping, query_fields): + """ + Build query_param_settings from a parameter mapping. + + Parameters + ---------- + param_mapping: dict + The module's parameter mapping dict. + query_fields: list of str + Ansible-name fields for the BASE query set. + + Returns + ------- + dict + Dict with "ALL", "BASE", "mapping", and "param_mapping" + keys, compatible with execute_query(). + + """ + all_fields = [] + mapping = {} + for ansible_name, entry in param_mapping.items(): + if entry.get("query") is False \ + or entry.get("module_param"): + continue + all_fields.append(ansible_name) + ipa_name = entry.get("api_name", ansible_name) + if ansible_name != ipa_name: + mapping[ansible_name] = ipa_name + return { + "ALL": all_fields, + "BASE": query_fields.get("base", []), + "PRIMARY_KEY": query_fields.get("primary_key", []), + "PREFIX": query_fields.get("prefix"), + "mapping": mapping, + "param_mapping": param_mapping, + } + + def _extract_query_fields(self, result, fields, mapping, + param_mapping=None): + """ + Extract query fields from an IPA result using name mapping. + + Parameters + ---------- + result: dict + The IPA result dict (after convert_result if provided). + fields: list of str + The Ansible-friendly field names to extract. + mapping: dict + Mapping of ansible_name -> ipa_attribute_name. + param_mapping: dict or None + The full parameter mapping dict. When provided, entry + metadata such as "type" is used for value conversion. + + Returns + ------- + dict + Extracted fields with Ansible-friendly names as keys. + + """ + output = {} + for field in fields: + ipa_field = mapping.get(field, field) + if ipa_field in result: + value = result[ipa_field] + if param_mapping is not None: + entry = param_mapping.get(field, {}) + field_type = entry.get("type") + try: + if field_type == "bool": + if isinstance( + value, + (str, unicode) # pylint: disable=W0012,E0606 + ): + value = value.lower() in ("true", "1", "yes") + else: + value = bool(value) + elif field_type == "int": + value = int(value) + except (ValueError, TypeError) as e: + self.fail_json( + msg="Parameter '%s' could not be converted " + "to %s: %s" % (field, field_type, str(e)) + ) + output[field] = value + return output + + def execute_query(self, names, query_param, find_command, + query_param_settings, convert_result=None): + """ + Execute query state. + + Parameters + ---------- + names: list of str or None + The item names to query. If None or empty, all items are + queried using find_command(module, None). + prefix: str + The grouping key for name-only results (e.g., "users"). + name_ipa_param: str + The IPA attribute name for the item name (e.g., "uid"). + query_param: list of str or None + The fields to return. ["ALL"] for all fields, ["BASE"] for + base fields, a custom list for specific fields, or None to + return only item names. + find_command: callable + Module function: find_command(module, name) returning a dict + for a single item or a list of dicts for all items (when + name is None). Returns None if not found. + query_param_settings: dict + Module-defined dict with "ALL" (list of all Ansible field + names), "BASE" (list of essential field names), and + "mapping" (dict of ansible_name -> ipa_attribute_name for + names that differ). + convert_result: callable or None + Optional function to convert a raw IPA result dict to + Ansible-friendly values (e.g., unwrap single-element lists, + encode certificates). Applied to each result before field + extraction. Default: None. + + Returns + ------- + dict + The query results structured for exit_json. + + """ + if query_param is not None and \ + set(query_param).intersection(["PKEY_ONLY", "BASE", "ALL"]): + if len(query_param) > 1: + self.fail_json( + msg="query_params PKEY_ONLY, BASE or ALL can " + "only be used alone" + ) + exit_args = {} + mapping = query_param_settings.get("mapping", {}) + param_mapping = query_param_settings.get("param_mapping") + + prefix = query_param_settings.get("PREFIX") + name_ipa_param = query_param_settings.get("PRIMARY_KEY") + + # Resolve query_param to a concrete field list + if query_param == ["PKEY_ONLY"]: + resolved_fields = None + elif query_param == ["BASE"]: + # explicit BASE + resolved_fields = query_param_settings["BASE"] + elif query_param == ["ALL"]: + resolved_fields = query_param_settings["ALL"] + elif query_param is not None: + resolved_fields = query_param + else: + # Use BASE always if query_param is not set + resolved_fields = query_param_settings["BASE"] + + # Validate requested fields + if resolved_fields is not None: + all_fields = query_param_settings["ALL"] + for field in resolved_fields: + if field not in all_fields: + self.fail_json( + msg="query_param '%s' is not supported" % field) + + # If no names have been given, use [None] to get all items with the + # find command + _names = names + if _names is None or not isinstance(_names, list): + _names = [None] + single_item = len(_names) == 1 + + # For all names in _names convert results and add the requested + # fields to exit_args + for name in _names: + results = find_command(self, name) + if not isinstance(results, list): + results = [results] + for result in results: + if result is None: + continue + if convert_result is not None: + result = convert_result(result) + if name_ipa_param not in result: + self.fail_json( + msg="execute_query: primary key '%s' missing " + "from result for '%s'" % (name_ipa_param, name) + ) + item_name = result[name_ipa_param] + if query_param == ["PKEY_ONLY"]: + exit_args.setdefault(prefix, []).append(item_name) + else: + fields = self._extract_query_fields( + result, resolved_fields, mapping, + param_mapping) + if name is not None: + if single_item: + exit_args = fields + else: + exit_args[name] = fields + else: + exit_args[item_name] = fields + + return exit_args + def tm_warn(self, warning): ts = time.time() # pylint: disable=super-with-arguments From fcf4a60de68edcfb9020855e2292e8d82ad82bf9 Mon Sep 17 00:00:00 2001 From: Thomas Woerner Date: Tue, 16 Jun 2026 13:10:12 +0200 Subject: [PATCH 4/5] ipauser: Use PARAM_MAPPING and query state support The ipauser module has been reworked to use the new PARAM_MAPPING added to ansible_freeipa_module. The member handling for manager, principal, certificate and certmapdata has been simplified by using gen_member_add_del_lists. The member entries in PARAM_MAPPING are now marked with "member": True. This replaces the manual calls to gen_add_del_lists, gen_add_list and gen_intersection_list across three separate action/state branches with a single unified call. The new query state allows to retrieve user information from IPA. The query_param option controls which fields are returned: BASE for essential fields, ALL for all fields, PKEY_ONLY for user names only, or a list of specific field names. Here is the updated documentation of the module: README-user.md New tests for the query state can be found at: tests/user/test_user_query.yml --- README-user.md | 96 +++- plugins/modules/ipauser.py | 998 ++++++++++++++------------------- tests/user/test_user.yml | 16 + tests/user/test_user_query.yml | 181 ++++++ 4 files changed, 718 insertions(+), 573 deletions(-) create mode 100644 tests/user/test_user_query.yml diff --git a/README-user.md b/README-user.md index 1c45a0bd..0409b196 100644 --- a/README-user.md +++ b/README-user.md @@ -368,6 +368,100 @@ Example playbook to ensure users are absent: state: absent ``` + +Example playbook to query a user and print the base fields: + +```yaml +--- +- name: Playbook to query users + hosts: ipaserver + become: true + + tasks: + - name: Query user pinky + ipauser: + ipaadmin_password: SomeADMINpassword + name: pinky + state: query + register: result + + - name: Print user info + debug: + var: result.user +``` + + +Example playbook to query specific fields of a user: + +```yaml +--- +- name: Playbook to query users + hosts: ipaserver + become: true + + tasks: + - name: Query first and last name of user pinky + ipauser: + ipaadmin_password: SomeADMINpassword + name: pinky + query_param: + - first + - last + - email + state: query + register: result + + - name: Print user info + debug: + var: result.user +``` + + +Example playbook to query all fields of a user: + +```yaml +--- +- name: Playbook to query users + hosts: ipaserver + become: true + + tasks: + - name: Query all fields of user pinky + ipauser: + ipaadmin_password: SomeADMINpassword + name: pinky + query_param: ALL + state: query + register: result + + - name: Print user info + debug: + var: result.user +``` + + +Example playbook to query only the names of all users: + +```yaml +--- +- name: Playbook to query users + hosts: ipaserver + become: true + + tasks: + - name: Query all user names + ipauser: + ipaadmin_password: SomeADMINpassword + query_param: PKEY_ONLY + state: query + register: result + + - name: Print user names + debug: + var: result.user.users +``` + + When using FreeIPA 4.8.0+, SMB logon script, profile, home directory and home drive can be set for users. In the example playbook to set SMB attributes note that `smb_profile_path` and `smb_home_dir` use paths in UNC format, which includes backslashes ('\\`). If the paths are quoted, the backslash needs to be escaped becoming "\\", so the path `\\server\dir` becomes `"\\\\server\\dir"`. If the paths are unquoted the slashes do not have to be escaped. @@ -416,7 +510,7 @@ Variable | Description | Required `update_password` | Set password for a user in present state only on creation or always. It can be one of `always` or `on_create` and defaults to `always`. | no `preserve` | Delete a user, keeping the entry available for future use. (bool) | no `action` | Work on user or member level. It can be on of `member` or `user` and defaults to `user`. | no -`state` | The state to ensure. It can be one of `present`, `absent`, `enabled`, `disabled`, `renamed`, `unlocked` or `undeleted`, default: `present`. Only `names` or `users` with only `name` set are allowed if state is not `present`. | yes +`state` | The state to ensure. It can be one of `present`, `absent`, `enabled`, `disabled`, `renamed`, `unlocked`, `undeleted` or `query`, default: `present`. Only `names` or `users` with only `name` set are allowed if state is not `present`. | yes diff --git a/plugins/modules/ipauser.py b/plugins/modules/ipauser.py index 7c57f996..846dd59e 100644 --- a/plugins/modules/ipauser.py +++ b/plugins/modules/ipauser.py @@ -608,6 +608,11 @@ options: type: str choices: ["always", "on_create"] required: false + query_param: + description: The fields to query with state=query + required: false + type: list + elements: str action: description: Work on user or member level type: str @@ -620,7 +625,7 @@ options: choices: ["present", "absent", "enabled", "disabled", "unlocked", "undeleted", - "renamed"] + "renamed", "query"] author: - Thomas Woerner (@t-woerner) """ @@ -714,6 +719,39 @@ EXAMPLES = """ name: someuser rename: anotheruser state: renamed + +# Query base fields of a user +- ipauser: + ipaadmin_password: SomeADMINpassword + name: pinky + state: query + register: result + +# Query specific fields of a user +- ipauser: + ipaadmin_password: SomeADMINpassword + name: pinky + query_param: + - first + - last + - email + state: query + register: result + +# Query all fields of a user +- ipauser: + ipaadmin_password: SomeADMINpassword + name: pinky + query_param: ALL + state: query + register: result + +# Query only the names of all users +- ipauser: + ipaadmin_password: SomeADMINpassword + query_param: PKEY_ONLY + state: query + register: result """ RETURN = """ @@ -741,16 +779,16 @@ user: from ansible.module_utils.ansible_freeipa_module import \ - IPAAnsibleModule, compare_args_ipa, gen_add_del_lists, convert_date, \ + IPAAnsibleModule, compare_args_ipa, gen_member_add_del_lists, \ + convert_date, \ encode_certificate, load_cert_from_str, DN_x500_text, to_text, \ - ipalib_errors, gen_add_list, gen_intersection_list, \ - convert_input_certificates, date_string + ipalib_errors, convert_input_certificates, date_string from ansible.module_utils import six if six.PY3: unicode = str -def find_user(module, name): +def user_show(module, name): _args = { "all": True, } @@ -774,116 +812,56 @@ def find_user(module, name): return _result -def gen_args(first, last, fullname, displayname, initials, homedir, gecos, - shell, email, principalexpiration, passwordexpiration, password, - random, uid, gid, street, city, userstate, postalcode, phone, - mobile, pager, fax, orgunit, title, carlicense, sshpubkey, - userauthtype, userclass, radius, radiususer, departmentnumber, - employeenumber, employeetype, preferredlanguage, smb_logon_script, - smb_profile_path, smb_home_dir, smb_home_drive, idp, idp_user_id, - noprivate, nomembers): - # principal, manager, certificate and certmapdata are handled not in here - _args = {} - if first is not None: - _args["givenname"] = first - if last is not None: - _args["sn"] = last - if fullname is not None: - _args["cn"] = fullname - if displayname is not None: - _args["displayname"] = displayname - if initials is not None: - _args["initials"] = initials - if homedir is not None: - _args["homedirectory"] = homedir - if gecos is not None: - _args["gecos"] = gecos - if shell is not None: - _args["loginshell"] = shell - if email is not None and len(email) > 0: - _args["mail"] = email - if principalexpiration is not None: - _args["krbprincipalexpiration"] = principalexpiration - if passwordexpiration is not None: - _args["krbpasswordexpiration"] = passwordexpiration - if password is not None: - _args["userpassword"] = password - if random is not None: - _args["random"] = random - if uid is not None: - _args["uidnumber"] = to_text(str(uid)) - if gid is not None: - _args["gidnumber"] = to_text(str(gid)) - if street is not None: - _args["street"] = street - if city is not None: - _args["l"] = city - if userstate is not None: - _args["st"] = userstate - if postalcode is not None: - _args["postalcode"] = postalcode - if phone is not None and len(phone) > 0: - _args["telephonenumber"] = phone - if mobile is not None and len(mobile) > 0: - _args["mobile"] = mobile - if pager is not None and len(pager) > 0: - _args["pager"] = pager - if fax is not None and len(fax) > 0: - _args["facsimiletelephonenumber"] = fax - if orgunit is not None: - _args["ou"] = orgunit - if title is not None: - _args["title"] = title - if carlicense is not None and len(carlicense) > 0: - _args["carlicense"] = carlicense - if sshpubkey is not None and len(sshpubkey) > 0: - _args["ipasshpubkey"] = sshpubkey - if userauthtype is not None and len(userauthtype) > 0: - _args["ipauserauthtype"] = userauthtype - if userclass is not None: - _args["userclass"] = userclass - if radius is not None: - _args["ipatokenradiusconfiglink"] = radius - if radiususer is not None: - _args["ipatokenradiususername"] = radiususer - if departmentnumber is not None: - _args["departmentnumber"] = departmentnumber - if employeenumber is not None: - _args["employeenumber"] = employeenumber - if employeetype is not None: - _args["employeetype"] = employeetype - if preferredlanguage is not None: - _args["preferredlanguage"] = preferredlanguage - if idp is not None: - _args["ipaidpconfiglink"] = idp - if idp_user_id is not None: - _args["ipaidpsub"] = idp_user_id - if noprivate is not None: - _args["noprivate"] = noprivate - if nomembers is not None: - _args["no_members"] = nomembers - if smb_logon_script is not None: - _args["ipantlogonscript"] = smb_logon_script - if smb_profile_path is not None: - _args["ipantprofilepath"] = smb_profile_path - if smb_home_dir is not None: - _args["ipanthomedirectory"] = smb_home_dir - if smb_home_drive is not None: - _args["ipanthomedirectorydrive"] = smb_home_drive - return _args +def query_convert_result(module, res): + _res = {} + try: + 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, tuple)): + if 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]) + else: + _res[key] = [to_text(x) for x in res[key]] + elif key in ["uidnumber", "gidnumber"]: + _res[key] = int(res[key]) + else: + _res[key] = to_text(res[key]) + except (TypeError, ValueError) as e: + module.fail_json( + msg="Failed to convert query result for '%s': %s" + % (key, str(e))) + return _res -def check_parameters( # pylint: disable=unused-argument - module, state, action, first, last, fullname, displayname, initials, - homedir, gecos, shell, email, principal, principalexpiration, - passwordexpiration, password, random, uid, gid, street, city, phone, - mobile, pager, fax, orgunit, title, manager, carlicense, sshpubkey, - userauthtype, userclass, radius, radiususer, departmentnumber, - employeenumber, employeetype, preferredlanguage, certificate, - certmapdata, noprivate, nomembers, preserve, update_password, - smb_logon_script, smb_profile_path, smb_home_dir, smb_home_drive, - idp, ipa_user_id, rename -): +def user_find(module, name): + _args = {"all": True} + + try: + if name: + _args["uid"] = name + _result = module.ipa_command_no_name("user_find", _args).get("result") + if _result: + if name: + _result = _result[0] + + except ipalib_errors.NotFound: + return None + + return _result + + +def check_parameters(module, state, action, preserve, user_params): + rename = user_params.get("rename") + certmapdata = user_params.get("certmapdata") + if state == "present" and action == "user": invalid = ["preserve"] else: @@ -906,6 +884,15 @@ def check_parameters( # pylint: disable=unused-argument invalid.extend( ["principal", "manager", "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") @@ -923,35 +910,36 @@ def check_parameters( # pylint: disable=unused-argument module.fail_json( msg="Action member can not be used with state: renamed.") - module.params_fail_used_invalid(invalid, state, action) + module.params_fail_used_invalid(invalid, state, action, user_params, + PARAM_MAPPING) if certmapdata is not None: for x in certmapdata: - certificate = x.get("certificate") + _certificate = x.get("certificate") issuer = x.get("issuer") subject = x.get("subject") data = x.get("data") if data is not None: - if certificate is not None or issuer is not None or \ + if _certificate is not None or issuer is not None or \ subject is not None: module.fail_json( msg="certmapdata: data can not be used with " "certificate, issuer or subject") check_certmapdata(data) - if certificate is not None \ + if _certificate is not None \ and (issuer is not None or subject is not None): module.fail_json( msg="certmapdata: certificate can not be used with " "issuer or subject") - if data is None and certificate is None: + if data is None and _certificate is None: if issuer is None: module.fail_json(msg="certmapdata: issuer is missing") if subject is None: module.fail_json(msg="certmapdata: subject is missing") - -def check_userauthtype(module, userauthtype): + # Check if userauthtype is invalid + userauthtype = user_params.get("userauthtype") _invalid = module.ipa_command_invalid_param_choices( "user_add", "ipauserauthtype", userauthtype) if _invalid: @@ -960,37 +948,66 @@ def check_userauthtype(module, userauthtype): "by your IPA version" % "','".join(_invalid)) -def extend_emails(email, default_email_domain): +def convert_params(ansible_module, user_params, default_email_domain, + server_realm, state): + """Convert parameter values in user_params in-place.""" + # Convert date fields + for date_field in ("principalexpiration", "passwordexpiration"): + val = user_params.get(date_field) + if ( + val + and isinstance( + val, (str, unicode) # pylint: disable=W0012,E0606 + ) + ): + if val[-1] != "Z": + val = val + "Z" + user_params[date_field] = convert_date(val) + + # Extend email addresses without "@" with default domain + email = user_params.get("email") if email is not None: - return ["%s@%s" % (_email, default_email_domain) - if "@" not in _email else _email - for _email in email] - return email + user_params["email"] = [ + "%s@%s" % (_email, default_email_domain) + if "@" not in _email else _email + for _email in email + ] + # Add realm to principals missing "@" + principal = user_params.get("principal") + if principal is not None: + user_params["principal"] = [ + x if "@" in x else x + "@" + server_realm + for x in principal + ] -def convert_certmapdata(certmapdata): - if certmapdata is None: - return None + # Convert certmapdata dicts to X509 strings + certmapdata = user_params.get("certmapdata") + if certmapdata is not None: + _result = [] + for x in certmapdata: + certificate = x.get("certificate") + issuer = x.get("issuer") + subject = x.get("subject") + data = x.get("data") - _result = [] - for x in certmapdata: - certificate = x.get("certificate") - issuer = x.get("issuer") - subject = x.get("subject") - data = x.get("data") + if data is None: + if issuer is None and subject is None: + cert = load_cert_from_str(certificate) + issuer = cert.issuer + subject = cert.subject - if data is None: - if issuer is None and subject is None: - cert = load_cert_from_str(certificate) - issuer = cert.issuer - subject = cert.subject + _result.append("X509:%s%s" % (DN_x500_text(issuer), + DN_x500_text(subject))) + else: + _result.append(data) - _result.append("X509:%s%s" % (DN_x500_text(issuer), - DN_x500_text(subject))) - else: - _result.append(data) + user_params["certmapdata"] = _result - return _result + # Normalize base64 certificates + certificate = user_params.get("certificate") + user_params["certificate"] = convert_input_certificates( + ansible_module, certificate, state) def check_certmapdata(data): @@ -1028,6 +1045,107 @@ def result_handler(module, result, command, name, args, exit_args, errors) +# password, randompassword and krbprincipalkey may not be in the returned +# information even in server context. +PARAM_MAPPING = { + # Read-only system fields + "dn": {"return_only": True}, + "objectclass": {"return_only": True}, + "ipauniqueid": {"return_only": True}, + "ipantsecurityidentifier": {"return_only": True}, + + # Query-only: name is the primary key, handled separately + "name": {"api_name": "uid", "gen_args": False}, + + # Writable params + "first": {"api_name": "givenname"}, + "last": {"api_name": "sn"}, + "fullname": {"api_name": "cn"}, + "displayname": {}, + "initials": {}, + "homedir": {"api_name": "homedirectory"}, + "shell": {"api_name": "loginshell"}, + "email": {"api_name": "mail", "nonempty_list": True}, + "principalexpiration": {"api_name": "krbprincipalexpiration"}, + "passwordexpiration": {"api_name": "krbpasswordexpiration"}, + "uid": {"api_name": "uidnumber", "type": "int", "convert_to": "text"}, + "gid": {"api_name": "gidnumber", "type": "int", "convert_to": "text"}, + "city": {"api_name": "l"}, + "userstate": {"api_name": "st"}, + "postalcode": {}, + "phone": {"api_name": "telephonenumber", "nonempty_list": True}, + "mobile": {"nonempty_list": True}, + "pager": {"nonempty_list": True}, + "fax": {"api_name": "facsimiletelephonenumber", "nonempty_list": True}, + "orgunit": {"api_name": "ou"}, + "title": {}, + "carlicense": {"nonempty_list": True}, + "sshpubkey": {"api_name": "ipasshpubkey", "nonempty_list": True, + "allow_empty_list_item": True}, + "userauthtype": {"api_name": "ipauserauthtype", "nonempty_list": True, + "allow_empty_list_item": True}, + "userclass": {}, + "radius": {"api_name": "ipatokenradiusconfiglink"}, + "radiususer": {"api_name": "ipatokenradiususername"}, + "departmentnumber": {}, + "employeenumber": {}, + "employeetype": {}, + "preferredlanguage": {}, + + # Query-only: handled separately via member commands + "manager": {"gen_args": False, "member": True}, + "principal": {"api_name": "krbprincipalname", "gen_args": False, + "member": True}, + "certificate": {"api_name": "usercertificate", "gen_args": False, + "member": True}, + "certmapdata": {"api_name": "ipacertmapdata", "gen_args": False, + "member": True}, + + # Writable params not queryable by name + "gecos": {}, + "password": {"api_name": "userpassword"}, + "random": {"query": False}, + "street": {}, + "rename": {"gen_args": False}, + "noprivate": {"query": False}, + "nomembers": {"api_name": "no_members", "query": False}, + "idp": {"api_name": "ipaidpconfiglink"}, + "idp_user_id": {"api_name": "ipaidpsub"}, + "smb_logon_script": {"api_name": "ipantlogonscript"}, + "smb_profile_path": {"api_name": "ipantprofilepath"}, + "smb_home_dir": {"api_name": "ipanthomedirectory"}, + "smb_home_drive": {"api_name": "ipanthomedirectorydrive"}, + + # Read-only status fields + "krblastpwdchange": {"return_only": True}, + "krblastadminunlock": {"return_only": True}, + "krbextradata": {"return_only": True}, + "krbticketflags": {"return_only": True}, + "krbloginfailedcount": {"return_only": True}, + "krblastsuccessfulauth": {"return_only": True}, + "has_password": {"return_only": True}, + "has_keytab": {"return_only": True}, + "preserved": {"type": "bool", "return_only": True}, + "memberof_group": {"return_only": True}, + "disabled": {"api_name": "nsaccountlock", "type": "bool", + "gen_args": False}, + + # Module-level params (not per-item, checked via self.params) + "query_param": {"module_param": True}, + "users": {"module_param": True}, + "preserve": {"module_param": True}, + "update_password": {"module_param": True}, +} + +QUERY_FIELDS = { + "prefix": "users", + "primary_key": "uid", + "base": [ + "name", "first", "last", "shell", "principal", "uid", "gid", "disabled" + ] +} + + def main(): user_spec = dict( # present @@ -1115,6 +1233,9 @@ def main(): aliases=["new_name"]), ) + query_param_settings = IPAAnsibleModule.build_query_param_settings( + PARAM_MAPPING, QUERY_FIELDS) + ansible_module = IPAAnsibleModule( argument_spec=dict( # general @@ -1139,18 +1260,28 @@ def main(): update_password=dict(type='str', default=None, no_log=False, choices=['always', 'on_create']), + # query + query_param=dict( + type="list", elements="str", default=None, + choices=["ALL", "BASE", "PKEY_ONLY"] + + 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", "renamed"]), + "unlocked", "undeleted", "renamed", + "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, ) @@ -1162,80 +1293,24 @@ def main(): names = ansible_module.params_get("name") users = ansible_module.params_get("users") - # present - first = ansible_module.params_get("first") - last = ansible_module.params_get("last") - fullname = ansible_module.params_get("fullname") - displayname = ansible_module.params_get("displayname") - initials = ansible_module.params_get("initials") - homedir = ansible_module.params_get("homedir") - gecos = ansible_module.params_get("gecos") - shell = ansible_module.params_get("shell") - email = ansible_module.params_get("email") - principal = ansible_module.params_get("principal") - principalexpiration = ansible_module.params_get( - "principalexpiration") - if principalexpiration is not None: - if principalexpiration[:-1] != "Z": - principalexpiration = principalexpiration + "Z" - principalexpiration = convert_date(principalexpiration) - passwordexpiration = ansible_module.params_get("passwordexpiration") - if passwordexpiration is not None: - if passwordexpiration[:-1] != "Z": - passwordexpiration = passwordexpiration + "Z" - passwordexpiration = convert_date(passwordexpiration) - password = ansible_module.params_get("password") - random = ansible_module.params_get("random") - uid = ansible_module.params_get("uid") - gid = ansible_module.params_get("gid") - street = ansible_module.params_get("street") - city = ansible_module.params_get("city") - userstate = ansible_module.params_get("userstate") - postalcode = ansible_module.params_get("postalcode") - phone = ansible_module.params_get("phone") - mobile = ansible_module.params_get("mobile") - pager = ansible_module.params_get("pager") - fax = ansible_module.params_get("fax") - orgunit = ansible_module.params_get("orgunit") - title = ansible_module.params_get("title") - manager = ansible_module.params_get("manager") - carlicense = ansible_module.params_get("carlicense") - sshpubkey = ansible_module.params_get("sshpubkey", - allow_empty_list_item=True) - userauthtype = ansible_module.params_get("userauthtype", - allow_empty_list_item=True) - userclass = ansible_module.params_get("userclass") - radius = ansible_module.params_get("radius") - radiususer = ansible_module.params_get("radiususer") - departmentnumber = ansible_module.params_get("departmentnumber") - employeenumber = ansible_module.params_get("employeenumber") - employeetype = ansible_module.params_get("employeetype") - preferredlanguage = ansible_module.params_get("preferredlanguage") - smb_logon_script = ansible_module.params_get("smb_logon_script") - smb_profile_path = ansible_module.params_get("smb_profile_path") - smb_home_dir = ansible_module.params_get("smb_home_dir") - smb_home_drive = ansible_module.params_get("smb_home_drive") - idp = ansible_module.params_get("idp") - idp_user_id = ansible_module.params_get("idp_user_id") - certificate = ansible_module.params_get("certificate") - certmapdata = ansible_module.params_get("certmapdata") - noprivate = ansible_module.params_get("noprivate") - nomembers = ansible_module.params_get("nomembers") - # deleted preserve = ansible_module.params_get("preserve") - # mod update_password = ansible_module.params_get("update_password") - # rename - rename = ansible_module.params_get("rename") + + # 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") + elif users is not None: + ansible_module.fail_json( + msg="users can not be used with state=query, use name instead") if state in ["present", "renamed"]: if names is not None and len(names) != 1: @@ -1246,23 +1321,6 @@ def main(): # Use users if names is None if users is not None: names = users - else: - check_parameters( - ansible_module, state, action, - first, last, fullname, displayname, initials, homedir, gecos, - shell, email, - principal, principalexpiration, passwordexpiration, password, - random, - uid, gid, street, city, phone, mobile, pager, fax, orgunit, title, - manager, carlicense, sshpubkey, userauthtype, userclass, radius, - radiususer, departmentnumber, employeenumber, employeetype, - preferredlanguage, certificate, certmapdata, noprivate, nomembers, - preserve, update_password, smb_logon_script, smb_profile_path, - smb_home_dir, smb_home_drive, idp, idp_user_id, rename, - ) - certificate = convert_input_certificates(ansible_module, certificate, - state) - certmapdata = convert_certmapdata(certmapdata) # Init @@ -1272,23 +1330,20 @@ def main(): # Connect to IPA API with ansible_module.ipa_connect(): - # Check version specific settings + if state == "query": + exit_args = ansible_module.execute_query( + names, query_param, user_find, query_param_settings, + convert_result=lambda res: query_convert_result( + ansible_module, res) + ) + + ansible_module.exit_json(changed=False, user=exit_args) server_realm = ansible_module.ipa_get_realm() - # Check API specific parameters - - check_userauthtype(ansible_module, userauthtype) - - # Default email domain - result = ansible_module.ipa_command_no_name("config_show", {}) default_email_domain = result["result"]["ipadefaultemaildomain"][0] - # Extend email addresses - - email = extend_emails(email, default_email_domain) - # commands commands = [] @@ -1301,88 +1356,9 @@ def main(): ansible_module.fail_json( msg="user '%s' is used more than once" % name) user_set.add(name) - # present - first = user.get("first") - last = user.get("last") - fullname = user.get("fullname") - displayname = user.get("displayname") - initials = user.get("initials") - homedir = user.get("homedir") - gecos = user.get("gecos") - shell = user.get("shell") - email = user.get("email") - principal = user.get("principal") - principalexpiration = user.get("principalexpiration") - if principalexpiration is not None: - if principalexpiration[:-1] != "Z": - principalexpiration = principalexpiration + "Z" - principalexpiration = convert_date(principalexpiration) - passwordexpiration = user.get("passwordexpiration") - if passwordexpiration is not None: - if passwordexpiration[:-1] != "Z": - passwordexpiration = passwordexpiration + "Z" - passwordexpiration = convert_date(passwordexpiration) - password = user.get("password") - random = user.get("random") - uid = user.get("uid") - gid = user.get("gid") - street = user.get("street") - city = user.get("city") - userstate = user.get("userstate") - postalcode = user.get("postalcode") - phone = user.get("phone") - mobile = user.get("mobile") - pager = user.get("pager") - fax = user.get("fax") - orgunit = user.get("orgunit") - title = user.get("title") - manager = user.get("manager") - carlicense = user.get("carlicense") - sshpubkey = user.get("sshpubkey") - userauthtype = user.get("userauthtype") - userclass = user.get("userclass") - radius = user.get("radius") - radiususer = user.get("radiususer") - departmentnumber = user.get("departmentnumber") - employeenumber = user.get("employeenumber") - employeetype = user.get("employeetype") - preferredlanguage = user.get("preferredlanguage") - smb_logon_script = user.get("smb_logon_script") - smb_profile_path = user.get("smb_profile_path") - smb_home_dir = user.get("smb_home_dir") - smb_home_drive = user.get("smb_home_drive") - idp = user.get("idp") - idp_user_id = user.get("idp_user_id") - rename = user.get("rename") - certificate = user.get("certificate") - certmapdata = user.get("certmapdata") - noprivate = user.get("noprivate") - nomembers = user.get("nomembers") - check_parameters( - ansible_module, state, action, - first, last, fullname, displayname, initials, homedir, - gecos, shell, email, principal, principalexpiration, - passwordexpiration, password, random, uid, gid, street, - city, phone, mobile, pager, fax, orgunit, title, manager, - carlicense, sshpubkey, userauthtype, userclass, radius, - radiususer, departmentnumber, employeenumber, - employeetype, preferredlanguage, certificate, - certmapdata, noprivate, nomembers, preserve, - update_password, smb_logon_script, smb_profile_path, - smb_home_dir, smb_home_drive, idp, idp_user_id, rename, - ) - certificate = convert_input_certificates(ansible_module, - certificate, state) - certmapdata = convert_certmapdata(certmapdata) - - # Check API specific parameters - - check_userauthtype(ansible_module, userauthtype) - - # Extend email addresses - - email = extend_emails(email, default_email_domain) + user_params = IPAAnsibleModule.extract_params_from_entry( + user, PARAM_MAPPING) elif ( isinstance( @@ -1390,22 +1366,27 @@ def main(): ) ): name = user + user_params = IPAAnsibleModule.extract_params( + ansible_module, PARAM_MAPPING) else: ansible_module.fail_json(msg="User '%s' is not valid" % repr(user)) + # Never reached, just added to make pylint happy + name = None + user_params = {} - # Fix principals: add realm if missing - # We need the connected API for the realm, therefore it can not - # be part of check_parameters as this is used also before the - # connection to the API has been established. - if principal is not None: - principal = [x if "@" in x else x + "@" + server_realm - for x in principal] + # Unpack params used directly in business logic + first = user_params.get("first") + last = user_params.get("last") + passwordexpiration = user_params.get("passwordexpiration") + rename = user_params.get("rename") - # Check passwordexpiration availability. - # We need the connected API for this test, therefore it can not - # be part of check_parameters as this is used also before the - # connection to the API has been established. + check_parameters(ansible_module, state, action, preserve, + user_params) + convert_params(ansible_module, user_params, + default_email_domain, server_realm, state) + + # Check passwordexpiration availability if passwordexpiration is not None and \ not ansible_module.ipa_command_param_exists( "user_add", "krbpasswordexpiration"): @@ -1413,11 +1394,8 @@ def main(): msg="The use of passwordexpiration is not supported by " "your IPA version") - # Check certmapdata availability. - # We need the connected API for this test, therefore it can not - # be part of check_parameters as this is used also before the - # connection to the API has been established. - if certmapdata is not None and \ + # Check certmapdata availability + if user_params.get("certmapdata") is not None and \ not ansible_module.ipa_command_exists("user_add_certmapdata"): ansible_module.fail_json( msg="The use of certmapdata is not supported by " @@ -1426,8 +1404,10 @@ def main(): # Check if SMB attributes are available if ( any([ - smb_logon_script, smb_profile_path, smb_home_dir, - smb_home_drive + user_params.get("smb_logon_script"), + user_params.get("smb_profile_path"), + user_params.get("smb_home_dir"), + user_params.get("smb_home_drive"), ]) and not ansible_module.ipa_command_param_exists( "user_mod", "ipanthomedirectory" @@ -1440,9 +1420,9 @@ def main(): # Check if IdP support is available require_idp = ( - idp is not None - or idp_user_id is not None - or userauthtype == "idp" + user_params.get("idp") is not None + or user_params.get("idp_user_id") is not None + or user_params.get("userauthtype") == "idp" ) has_idp_support = ansible_module.ipa_command_param_exists( "user_add", "ipaidpconfiglink" @@ -1452,23 +1432,13 @@ def main(): msg="Your IPA version does not support External IdP.") # Make sure user exists - res_find = find_user(ansible_module, name) + res_find = user_show(ansible_module, name) # Create command if state == "present": # Generate args - args = gen_args( - first, last, fullname, displayname, initials, homedir, - gecos, - shell, email, principalexpiration, passwordexpiration, - password, random, uid, gid, street, city, userstate, - postalcode, phone, mobile, pager, fax, orgunit, title, - carlicense, sshpubkey, userauthtype, userclass, radius, - radiususer, departmentnumber, employeenumber, employeetype, - preferredlanguage, smb_logon_script, smb_profile_path, - smb_home_dir, smb_home_drive, idp, idp_user_id, noprivate, - nomembers, - ) + args = IPAAnsibleModule.gen_args_from_mapping( + PARAM_MAPPING, user_params) if action == "user": # Found the user @@ -1523,174 +1493,11 @@ def main(): commands.append([name, "user_add", args]) if smb_attrs: commands.append([name, "user_mod", smb_attrs]) - # Handle members: principal, manager, certificate and - # certmapdata - if res_find is not None: - # Generate addition and removal lists - manager_add, manager_del = gen_add_del_lists( - manager, res_find.get("manager")) - - 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 - # convert the principals that we should remove. - principal_del = [to_text(x) for x in principal_del] - - certificate_add, certificate_del = gen_add_del_lists( - certificate, res_find.get("usercertificate")) - - certmapdata_add, certmapdata_del = gen_add_del_lists( - certmapdata, res_find.get("ipacertmapdata")) - - else: - # Use given managers and principals - manager_add = manager or [] - manager_del = [] - principal_add = principal or [] - principal_del = [] - certificate_add = certificate or [] - certificate_del = [] - certmapdata_add = certmapdata or [] - certmapdata_del = [] - - # Remove canonical principal from principal_del - canonical_principal = name + "@" + server_realm - if canonical_principal in principal_del: - principal_del.remove(canonical_principal) - - # Add managers - if len(manager_add) > 0: - commands.append([name, "user_add_manager", - { - "user": manager_add, - }]) - # Remove managers - if len(manager_del) > 0: - commands.append([name, "user_remove_manager", - { - "user": manager_del, - }]) - - # Principals need to be added and removed one by one, - # because if entry already exists, the processing of - # the remaining enries is stopped. The same applies to - # the removal of non-existing entries. - - # Add principals - if len(principal_add) > 0: - for _principal in principal_add: - commands.append([name, "user_add_principal", - { - "krbprincipalname": - _principal, - }]) - # Remove principals - if len(principal_del) > 0: - for _principal in principal_del: - commands.append([name, "user_remove_principal", - { - "krbprincipalname": - _principal, - }]) - - # Certificates need to be added and removed one by one, - # because if entry already exists, the processing of - # the remaining enries is stopped. The same applies to - # the removal of non-existing entries. - - # Add certificates - if len(certificate_add) > 0: - for _certificate in certificate_add: - commands.append([name, "user_add_cert", - { - "usercertificate": - _certificate, - }]) - # Remove certificates - if len(certificate_del) > 0: - for _certificate in certificate_del: - commands.append([name, "user_remove_cert", - { - "usercertificate": - _certificate, - }]) - - # certmapdata need to be added and removed one by one, - # because issuer and subject can only be done one by - # one reliably (https://pagure.io/freeipa/issue/8097) - - # Add certmapdata - if len(certmapdata_add) > 0: - for _data in certmapdata_add: - commands.append([name, "user_add_certmapdata", - gen_certmapdata_args(_data)]) - # Remove certmapdata - if len(certmapdata_del) > 0: - for _data in certmapdata_del: - commands.append([name, "user_remove_certmapdata", - gen_certmapdata_args(_data)]) - elif action == "member": if res_find is None: ansible_module.fail_json( msg="No user '%s'" % name) - # Ensure managers are present - manager_add = gen_add_list( - manager, res_find.get("manager")) - if manager_add is not None and len(manager_add) > 0: - commands.append([name, "user_add_manager", - { - "user": manager_add, - }]) - - # Principals need to be added and removed one by one, - # because if entry already exists, the processing of - # the remaining enries is stopped. The same applies to - # the removal of non-existing entries. - - # Ensure principals are present - principal_add = gen_add_list( - principal, res_find.get("krbprincipalname")) - if principal_add is not None and len(principal_add) > 0: - for _principal in principal_add: - commands.append([name, "user_add_principal", - { - "krbprincipalname": - _principal, - }]) - - # Certificates need to be added and removed one by one, - # because if entry already exists, the processing of - # the remaining enries is stopped. The same applies to - # the removal of non-existing entries. - - # Ensure certificates are present - certificate_add = gen_add_list( - certificate, res_find.get("usercertificate")) - if certificate_add is not None and \ - len(certificate_add) > 0: - for _certificate in certificate_add: - commands.append([name, "user_add_cert", - { - "usercertificate": - _certificate, - }]) - - # certmapdata need to be added and removed one by one, - # because issuer and subject can only be done one by - # one reliably (https://pagure.io/freeipa/issue/8097) - - # Ensure certmapdata are present - certmapdata_add = gen_add_list( - certmapdata, res_find.get("ipacertmapdata")) - if certmapdata_add is not None and \ - len(certmapdata_add) > 0: - for _data in certmapdata_add: - commands.append([name, "user_add_certmapdata", - gen_certmapdata_args(_data)]) - elif state == "absent": if action == "user": if res_find is not None: @@ -1707,60 +1514,6 @@ def main(): ansible_module.fail_json( msg="No user '%s'" % name) - # Ensure managers are absent - manager_del = gen_intersection_list( - manager, res_find.get("manager")) - if manager_del is not None and len(manager_del) > 0: - commands.append([name, "user_remove_manager", - { - "user": manager_del, - }]) - - # Principals need to be added and removed one by one, - # because if entry already exists, the processing of - # the remaining enries is stopped. The same applies to - # the removal of non-existing entries. - - # Ensure principals are absent - principal_del = gen_intersection_list( - principal, res_find.get("krbprincipalname")) - if principal_del is not None and len(principal_del) > 0: - commands.append([name, "user_remove_principal", - { - "krbprincipalname": principal_del, - }]) - - # Certificates need to be added and removed one by one, - # because if entry already exists, the processing of - # the remaining enries is stopped. The same applies to - # the removal of non-existing entries. - - # Ensure certificates are absent - certificate_del = gen_intersection_list( - certificate, res_find.get("usercertificate")) - if certificate_del is not None and \ - len(certificate_del) > 0: - for _certificate in certificate_del: - commands.append([name, "user_remove_cert", - { - "usercertificate": - _certificate, - }]) - - # certmapdata need to be added and removed one by one, - # because issuer and subject can only be done one by - # one reliably (https://pagure.io/freeipa/issue/8097) - - # Ensure certmapdata are absent - certmapdata_del = gen_intersection_list( - certmapdata, res_find.get("ipacertmapdata")) - if certmapdata_del is not None and \ - len(certmapdata_del) > 0: - # Using issuer and subject can only be done one by - # one reliably (https://pagure.io/freeipa/issue/8097) - for _data in certmapdata_del: - commands.append([name, "user_remove_certmapdata", - gen_certmapdata_args(_data)]) elif state == "undeleted": if res_find is not None: if res_find.get("preserved", False): @@ -1797,6 +1550,107 @@ def main(): else: ansible_module.fail_json(msg="Unkown state '%s'" % state) + # Handle members: principal, manager, certificate and + # certmapdata + member_lists = gen_member_add_del_lists( + PARAM_MAPPING, user_params, + res_find or {}, action, state) + manager_add, manager_del = member_lists.get( + "manager", ([], [])) + principal_add, principal_del = member_lists.get( + "principal", ([], [])) + certificate_add, certificate_del = member_lists.get( + "certificate", ([], [])) + certmapdata_add, certmapdata_del = member_lists.get( + "certmapdata", ([], [])) + + # Principals are not returned as utf8 for IPA using + # 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] + + # Remove canonical principal from principal_del. This only + # applies to state=present, where principal_del is derived + # from a full sync of the requested principal list; for + # state=absent,action=member the canonical principal is a + # removal the caller explicitly asked for. + if state == "present": + canonical_principal = name + "@" + server_realm + if canonical_principal in principal_del: + principal_del.remove(canonical_principal) + + # Add managers + if len(manager_add) > 0: + commands.append([name, "user_add_manager", + { + "user": manager_add, + }]) + # Remove managers + if len(manager_del) > 0: + commands.append([name, "user_remove_manager", + { + "user": manager_del, + }]) + + # Principals need to be added and removed one by one, + # because if entry already exists, the processing of + # the remaining enries is stopped. The same applies to + # the removal of non-existing entries. + + # Add principals + if len(principal_add) > 0: + for _principal in principal_add: + commands.append([name, "user_add_principal", + { + "krbprincipalname": + _principal, + }]) + # Remove principals + if len(principal_del) > 0: + for _principal in principal_del: + commands.append([name, "user_remove_principal", + { + "krbprincipalname": + _principal, + }]) + + # Certificates need to be added and removed one by one, + # because if entry already exists, the processing of + # the remaining enries is stopped. The same applies to + # the removal of non-existing entries. + + # Add certificates + if len(certificate_add) > 0: + for _certificate in certificate_add: + commands.append([name, "user_add_cert", + { + "usercertificate": + _certificate, + }]) + # Remove certificates + if len(certificate_del) > 0: + for _certificate in certificate_del: + commands.append([name, "user_remove_cert", + { + "usercertificate": + _certificate, + }]) + + # certmapdata need to be added and removed one by one, + # because issuer and subject can only be done one by + # one reliably (https://pagure.io/freeipa/issue/8097) + + # Add certmapdata + if len(certmapdata_add) > 0: + for _data in certmapdata_add: + commands.append([name, "user_add_certmapdata", + gen_certmapdata_args(_data)]) + # Remove certmapdata + if len(certmapdata_del) > 0: + for _data in certmapdata_del: + commands.append([name, "user_remove_certmapdata", + gen_certmapdata_args(_data)]) + del user_set # Execute commands diff --git a/tests/user/test_user.yml b/tests/user/test_user.yml index 3dcc3936..16bdf079 100644 --- a/tests/user/test_user.yml +++ b/tests/user/test_user.yml @@ -128,6 +128,22 @@ register: result failed_when: result.changed or result.failed + - name: User pinky with email "pinky" + ipauser: + ipaadmin_password: SomeADMINpassword + name: pinky + email: pinky + register: result + failed_when: not result.changed or result.failed + + - name: User pinky with email "pinky", again + ipauser: + ipaadmin_password: SomeADMINpassword + name: pinky + email: pinky + register: result + failed_when: result.changed or result.failed + - name: User pinky present with changed settings ipauser: ipaadmin_password: SomeADMINpassword diff --git a/tests/user/test_user_query.yml b/tests/user/test_user_query.yml new file mode 100644 index 00000000..0bfc8318 --- /dev/null +++ b/tests/user/test_user_query.yml @@ -0,0 +1,181 @@ +--- +- name: Test user query + hosts: "{{ ipa_test_host | default('ipaserver') }}" + become: false + gather_facts: false + module_defaults: + ipauser: + ipaadmin_password: SomeADMINpassword + ipaapi_context: "{{ ipa_context | default(omit) }}" + + tasks: + + # CLEANUP + + - name: Ensure users "testuser1" and "testuser2" are absent + ipauser: + name: + - testuser1 + - testuser2 + - non-existing-user + state: absent + + # CREATE TEST ITEMS + + - name: Ensure users "testuser1" and "testuser2" are present + ipauser: + users: + - name: testuser1 + first: first1 + last: last1 + - name: testuser2 + first: first2 + last: last2 + + - name: Query user "non-existing-user" + ipauser: + name: + - non-existing-user + #query_param: PKEY_ONLY + state: query + register: result + failed_when: result.changed or result.failed + + - name: Print query information + ansible.builtin.debug: + var: result.user + + - name: Query all users + ipauser: + state: query + query_param: PKEY_ONLY + register: result + failed_when: result.changed or result.failed + + - name: Print query information + ansible.builtin.debug: + var: result.user + + - name: Fail on missing "testuser1" in query result + ansible.builtin.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 + ansible.builtin.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 + ansible.builtin.fail: + msg: "'non-existing-user' in query result {{ result.user.users }}" + when: ("non-existing-user" in result.user.users) + + - name: Query users "testuser1" and "testuser2" + ipauser: + name: + - testuser1 + - testuser2 + state: query + register: result + failed_when: result.changed or result.failed + + - name: Print query information + ansible.builtin.debug: + var: result.user + + - name: Fail on missing "testuser1" in query result + ansible.builtin.fail: + msg: "'testuser1' not in query result {{ result.user }}" + when: ("testuser1" not in result.user) + + - name: Fail on missing "testuser2" in query result + ansible.builtin.fail: + msg: "'testuser2' not in query result {{ result.user }}" + when: ("testuser2" not in result.user) + + - name: Fail on "non-existing-user" in query result + ansible.builtin.fail: + msg: "'non-existing-user' in query result {{ result.user }}" + when: ("non-existing-user" in result.user) + + - name: Query all user parameters for "testuser1" + ipauser: + name: + - testuser1 + query_param: ALL + state: query + register: result + failed_when: result.changed or result.failed + + - name: Print query information + ansible.builtin.debug: + var: result.user + + - name: Fail on missing information in query result + ansible.builtin.fail: + msg: "Query result {{ result.user }} is incomplete" + when: (result.user.disabled or + result.user.displayname != "first1 last1" or + "uid=testuser1,cn=users,cn=accounts,dc=" not in result.user.dn or + "testuser1@" not in result.user.email or + result.user.first != "first1" or + result.user.fullname != "first1 last1" or + result.user.gecos != "first1 last1" or + result.user.gid < 1 or + result.user.homedir != "/home/testuser1" or + result.user.initials != "fl" or + result.user.last != "last1" or + result.user.name != "testuser1" or + "objectclass" not in result.user or + result.user.preserved or + result.user.principal | length != 1 or + "testuser1@" not in result.user.principal[0] or + "/bin/" not in result.user.shell or + result.user.uid < 1) + + - name: Query "uid", "first" and "last" parameters for all users + ipauser: + query_param: + - uid + - first + - last + state: query + register: result + failed_when: result.changed or result.failed + + - name: Print query information + ansible.builtin.debug: + var: result.user + + - name: Fail on less than 3 users in result + ansible.builtin.fail: + msg: "{{ result.user }} is not empty" + when: result.user | length < 3 + + - name: Fail on missing "testuser1" information in query result + ansible.builtin.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 + result.user.testuser1.uid < 1 or + result.user.testuser1 | length != 3) + + - name: Fail on missing "testuser2" information in query result + ansible.builtin.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 + result.user.testuser2.uid < 1 or + result.user.testuser2 | length != 3) + + # CLEANUP + + - name: Ensure users "testuser1" and "testuser2" are absent + ipauser: + name: + - testuser1 + - testuser2 + state: absent From 5e057cfbcd7eb693b99f5da072d6e403f2e595e2 Mon Sep 17 00:00:00 2001 From: Thomas Woerner Date: Tue, 16 Jun 2026 18:01:34 +0200 Subject: [PATCH 5/5] test_group_case_insensitive.yml: Fix wrong user absent task The task used a users dict with name, first and last name given and state:absent. This combination does not make sense and was silenty ignored before by ipauser module. --- tests/group/test_group_case_insensitive.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/group/test_group_case_insensitive.yml b/tests/group/test_group_case_insensitive.yml index 22fcc0d4..f4bda70d 100644 --- a/tests/group/test_group_case_insensitive.yml +++ b/tests/group/test_group_case_insensitive.yml @@ -136,7 +136,7 @@ - name: "Ensure test users test_users are absent" ipauser: - users: "{{ test_users }}" + name: "{{ user_names }}" state: absent - name: Test in all IPA versions 8.4.4+