diff --git a/README-sysaccount.md b/README-sysaccount.md
new file mode 100644
index 00000000..6c412e77
--- /dev/null
+++ b/README-sysaccount.md
@@ -0,0 +1,196 @@
+Sysaccount module
+============
+
+Description
+-----------
+
+The sysaccount module allows to ensure presence and absence of system accounts.
+
+Features
+--------
+
+* Sysaccount management
+
+
+Supported FreeIPA Versions
+--------------------------
+
+FreeIPA versions 4.4.0 and up are supported by the ipasysaccount module.
+
+
+Requirements
+------------
+
+**Controller**
+* Ansible version: 2.15+
+
+**Node**
+* Supported FreeIPA version (see above)
+
+
+Usage
+=====
+
+Example inventory file
+
+```ini
+[ipaserver]
+ipaserver.test.local
+```
+
+
+Example playbook to make sure sysaccount "my-app" is present with random password:
+
+```yaml
+---
+- name: Playbook to manage IPA sysaccount.
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure sysaccount "my-app" is present with random password
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ random: true
+ register: result
+
+ - name: Print generated random password
+ debug:
+ var: result.sysaccount.randompassword
+
+```
+
+
+Example playbook to make sure sysaccount "my-app" is present with given password:
+
+```yaml
+---
+- name: Playbook to manage IPA sysaccount.
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure sysaccount "my-app" is present with given password
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ password: SomeAPPpassword
+```
+
+
+Example playbook to make sure sysaccount "my-app" is absent:
+
+```yaml
+---
+- name: Playbook to manage IPA sysaccount.
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure sysaccount "my-app" is absent
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ state: absent
+```
+
+Example playbook to ensure existing sysaccount my-app is privileged
+
+```yaml
+---
+- name: Playbook to manage IPA sysaccount.
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure existing sysaccount my-app is privileged
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ privileged: true
+```
+
+Example playbook to ensure existing sysaccount my-app is not privileged
+
+```yaml
+---
+- name: Playbook to manage IPA sysaccount.
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure existing sysaccount my-app is not privileged
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ privileged: false
+```
+
+Example playbook to ensure existing sysaccount my-app is disabled
+
+```yaml
+---
+- name: Playbook to manage IPA sysaccount.
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure existing sysaccount my-app is disabled
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ state: disabled
+```
+
+Example playbook to ensure existing sysaccount my-app is enabled
+
+```yaml
+---
+- name: Playbook to manage IPA sysaccount.
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure existing sysaccount my-app is enabled
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ state: enabled
+```
+
+
+Variables
+---------
+
+Variable | Description | Required
+-------- | ----------- | --------
+`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no
+`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no
+`ipaapi_context` | The context in which the module will execute. Executing in a server context is preferred. If not provided context will be determined by the execution environment. Valid values are `server` and `client`. | no
+`ipaapi_ldap_cache` | Use LDAP cache for IPA connection. The bool setting defaults to true. (bool) | no
+`name` \| `login` | The list of sysaccount name strings - internally uid. (list of strings) | yes
+`description` | A description for the sysaccount. (string) | no
+`privileged` | Allow password updates without reset. This flag is not replicated. It is needed to set privileged on all servers, where it is needed. (bool) | no
+`random` | Generate a random user password. (bool) | no
+`password` \| `userpassword` | Set the password. (string) | no
+`update_password` | Set password for a sysaccount in present state only on creation or always. It can be one of `always` or `on_create` and defaults to `always`. | no
+`state` | The state to ensure. It can be one of `present`, `absent`, 'enabled', 'disabled', default: `present`. | no
+
+
+Return Values
+=============
+
+There are only return values if a random passwords has been generated.
+
+Variable | Description | Returned When
+-------- | ----------- | -------------
+`sysaccount` | Sysaccount dict (dict)
Options: | Always
+ | `randompassword` - The generated random password | If random is yes and sysaccount did not exist or update_password is yes
+
+
+
+Authors
+=======
+
+Thomas Woerner
diff --git a/README.md b/README.md
index 8e8c64a8..7558b23a 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ Features
* Modules for sudocmd management
* Modules for sudocmdgroup management
* Modules for sudorule management
+* Modules for sysaccount management
* Modules for topology management
* Modules for trust management
* Modules for user management
@@ -465,6 +466,7 @@ Modules in plugin/modules
* [ipasudocmd](README-sudocmd.md)
* [ipasudocmdgroup](README-sudocmdgroup.md)
* [ipasudorule](README-sudorule.md)
+* [ipasysaccount](README-sysaccount.md)
* [ipatopologysegment](README-topology.md)
* [ipatopologysuffix](README-topology.md)
* [ipatrust](README-trust.md)
diff --git a/playbooks/sysaccount/sysaccount-absent.yml b/playbooks/sysaccount/sysaccount-absent.yml
new file mode 100644
index 00000000..e0f19fb2
--- /dev/null
+++ b/playbooks/sysaccount/sysaccount-absent.yml
@@ -0,0 +1,11 @@
+---
+- name: Sysaccount example
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure sysaccount my-app is absent
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ state: absent
diff --git a/playbooks/sysaccount/sysaccount-disabled.yml b/playbooks/sysaccount/sysaccount-disabled.yml
new file mode 100644
index 00000000..cd59fd03
--- /dev/null
+++ b/playbooks/sysaccount/sysaccount-disabled.yml
@@ -0,0 +1,11 @@
+---
+- name: Sysaccount example
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure sysaccount my-app is disabled
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ state: disabled
diff --git a/playbooks/sysaccount/sysaccount-enabled.yml b/playbooks/sysaccount/sysaccount-enabled.yml
new file mode 100644
index 00000000..353be207
--- /dev/null
+++ b/playbooks/sysaccount/sysaccount-enabled.yml
@@ -0,0 +1,11 @@
+---
+- name: Sysaccount example
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure sysaccount my-app is enabled
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ state: enabled
diff --git a/playbooks/sysaccount/sysaccount-present.yml b/playbooks/sysaccount/sysaccount-present.yml
new file mode 100644
index 00000000..52306ba4
--- /dev/null
+++ b/playbooks/sysaccount/sysaccount-present.yml
@@ -0,0 +1,11 @@
+---
+- name: Sysaccount example
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure sysaccount my-app is present with random password
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ random: true
diff --git a/playbooks/sysaccount/sysaccount-privileged.yml b/playbooks/sysaccount/sysaccount-privileged.yml
new file mode 100644
index 00000000..cca53fe7
--- /dev/null
+++ b/playbooks/sysaccount/sysaccount-privileged.yml
@@ -0,0 +1,11 @@
+---
+- name: Sysaccount example
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure sysaccount my-app is privileged
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ privileged: true
diff --git a/playbooks/sysaccount/sysaccount-unprivileged.yml b/playbooks/sysaccount/sysaccount-unprivileged.yml
new file mode 100644
index 00000000..397b9962
--- /dev/null
+++ b/playbooks/sysaccount/sysaccount-unprivileged.yml
@@ -0,0 +1,11 @@
+---
+- name: Sysaccount example
+ hosts: ipaserver
+ become: false
+
+ tasks:
+ - name: Ensure sysaccount my-app is not privileged
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ privileged: false
diff --git a/plugins/modules/ipasysaccount.py b/plugins/modules/ipasysaccount.py
new file mode 100644
index 00000000..f050396d
--- /dev/null
+++ b/plugins/modules/ipasysaccount.py
@@ -0,0 +1,309 @@
+# -*- coding: utf-8 -*-
+
+# Authors:
+# Thomas Woerner
+#
+# Copyright (C) 2025 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+from __future__ import (absolute_import, division, print_function)
+
+__metaclass__ = type
+
+ANSIBLE_METADATA = {
+ "metadata_version": "1.0",
+ "supported_by": "community",
+ "status": ["preview"],
+}
+
+DOCUMENTATION = """
+---
+module: ipasysaccount
+short_description: Manage FreeIPA system account
+description: Manage FreeIPA system account
+extends_documentation_fragment:
+ - ipamodule_base_docs
+ - ipamodule_base_docs.delete_continue
+options:
+ name:
+ description: The list of sysaccount name strings (internally uid).
+ required: true
+ type: list
+ elements: str
+ aliases: ["login"]
+ description:
+ description: A description for the sysaccount.
+ type: str
+ required: false
+ privileged:
+ description: Allow password updates without reset.
+ type: bool
+ required: false
+ random:
+ description: Generate a random user password.
+ required: false
+ type: bool
+ password:
+ description: Set the user password.
+ required: false
+ type: str
+ aliases: ["userpassword"]
+ update_password:
+ description:
+ Set password for a sysaccount in present state only on creation or always
+ type: str
+ choices: ["always", "on_create"]
+ required: false
+ state:
+ description: The state to ensure.
+ choices: ["present", "absent", "enabled", "disabled"]
+ default: present
+ type: str
+author:
+ - Thomas Woerner (@t-woerner)
+"""
+
+EXAMPLES = """
+# Ensure sysaccount my-app is present
+- ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ random: true
+
+# Ensure sysaccount my-app is absent
+- ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ state: absent
+
+# Ensure existing sysaccount my-app is privileged
+- ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ privileged: true
+
+# Ensure existing sysaccount my-app is not privileged
+- ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ privileged: false
+
+# Ensure existing sysaccount my-app is disabled
+- ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ state: disabled
+
+# Ensure existing sysaccount my-app is enabled
+- ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ name: my-app
+ state: enabled
+"""
+
+RETURN = """
+sysaccount:
+ description: Sysaccount dict with random password
+ returned: |
+ If random is yes and user sysaccount not exist or update_password is yes
+ type: dict
+ contains:
+ randompassword:
+ description: The generated random password
+ type: str
+"""
+
+
+from ansible.module_utils.ansible_freeipa_module import \
+ IPAAnsibleModule, compare_args_ipa, ipalib_errors
+from ansible.module_utils import six
+
+if six.PY3:
+ unicode = str
+
+
+def find_sysaccount(module, name):
+ """Find if a sysaccount with the given name already exist."""
+ try:
+ _result = module.ipa_command("sysaccount_show", name, {"all": True})
+ except ipalib_errors.NotFound:
+ # An exception is raised if sysaccount name is not found.
+ return None
+ return _result["result"]
+
+
+def gen_args(description, random, privileged, password):
+ _args = {}
+ if description is not None:
+ _args["description"] = description
+ if random is not None:
+ _args["random"] = random
+ if privileged is not None:
+ _args["privileged"] = privileged
+ if password is not None:
+ _args["userpassword"] = password
+ return _args
+
+
+# pylint: disable=unused-argument
+def result_handler(module, result, command, name, args, exit_args, errors):
+ if "random" in args and command in ["sysaccount_add", "sysaccount_mod"] \
+ and "randompassword" in result["result"]:
+ exit_args["randompassword"] = \
+ result["result"]["randompassword"]
+
+
+def main():
+ ansible_module = IPAAnsibleModule(
+ argument_spec=dict(
+ # general
+ name=dict(type="list", elements="str", required=True,
+ aliases=["login"]),
+ # present
+ description=dict(required=False, type='str', default=None),
+ random=dict(required=False, type='bool', default=None),
+ privileged=dict(required=False, type='bool', default=None),
+ password=dict(required=False, type='str',
+ aliases=["userpassword"], default=None),
+
+ # mod
+ update_password=dict(type='str', default=None, no_log=False,
+ choices=['always', 'on_create']),
+
+ # state
+ state=dict(type="str", default="present",
+ choices=["present", "absent", "enabled", "disabled"]),
+ ),
+ supports_check_mode=True,
+ ipa_module_options=["delete_continue"],
+ mutually_exclusive=[["random", "password"]]
+ )
+
+ ansible_module._ansible_debug = True
+
+ # Get parameters
+
+ # general
+ names = ansible_module.params_get("name")
+
+ # present
+ description = ansible_module.params_get("description")
+ random = ansible_module.params_get("random")
+ privileged = ansible_module.params_get("privileged")
+ password = ansible_module.params_get("password")
+
+ # mod
+ update_password = ansible_module.params_get("update_password")
+
+ # absent
+ delete_continue = ansible_module.params_get("delete_continue")
+
+ # state
+ state = ansible_module.params_get("state")
+
+ # Check parameters
+
+ invalid = []
+
+ if state == "present" and len(names) != 1:
+ ansible_module.fail_json(
+ msg="Only one sysaccount can be added at a time.")
+
+ if state in ["absent", "enabled", "disabled"]:
+ if len(names) < 1:
+ ansible_module.fail_json(msg="No name given.")
+ invalid = ["description", "random", "privileged", "password"]
+
+ ansible_module.params_fail_used_invalid(invalid, state)
+
+ # Init
+
+ changed = False
+ exit_args = {}
+
+ # Connect to IPA API
+ with ansible_module.ipa_connect():
+
+ if not ansible_module.ipa_command_exists("sysaccount_add"):
+ ansible_module.fail_json(
+ msg=("Managing sysaccounts is not supported by your "
+ "IPA version")
+ )
+
+ commands = []
+ for name in names:
+ # Make sure sysaccount exists
+ res_find = find_sysaccount(ansible_module, name)
+
+ # Create command
+ if state == "present":
+
+ # Generate args
+ args = gen_args(description, random, privileged, password)
+
+ # Found the sysaccount
+ if res_find is not None:
+ # Ignore password and random with
+ # update_password == on_create
+ if update_password == "on_create":
+ if "userpassword" in args:
+ del args["userpassword"]
+ if "random" in args:
+ del args["random"]
+ # if using "random:false" password should not be
+ # generated.
+ if not args.get("random", True):
+ del args["random"]
+
+ # For all settings is args, check if there are
+ # different settings in the find result.
+ # If yes: modify
+ if not compare_args_ipa(ansible_module, args,
+ res_find):
+ commands.append([name, "sysaccount_mod", args])
+ else:
+ commands.append([name, "sysaccount_add", args])
+
+ elif state == "absent":
+ if res_find is not None:
+ commands.append(
+ [name, "sysaccount_del", {"continue": delete_continue}]
+ )
+
+ elif state == "enabled":
+ if res_find is not None and res_find["nsaccountlock"]:
+ commands.append([name, "sysaccount_enable", {}])
+
+ elif state == "disabled":
+ if res_find is not None and not res_find["nsaccountlock"]:
+ commands.append([name, "sysaccount_disable", {}])
+
+ else:
+ ansible_module.fail_json(msg="Unkown state '%s'" % state)
+
+ # Execute commands
+
+ changed = ansible_module.execute_ipa_commands(
+ commands, result_handler, keeponly=["randompassword"],
+ exit_args=exit_args)
+
+ # Done
+
+ ansible_module.exit_json(changed=changed, sysaccount=exit_args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/sysaccount/test_sysaccount.yml b/tests/sysaccount/test_sysaccount.yml
new file mode 100644
index 00000000..f0e037f9
--- /dev/null
+++ b/tests/sysaccount/test_sysaccount.yml
@@ -0,0 +1,150 @@
+---
+- name: Test sysaccount
+ hosts: "{{ ipa_test_host | default('ipaserver') }}"
+ # It is normally not needed to set "become" to "true" for a module test.
+ # Only set it to true if it is needed to execute commands as root.
+ become: false
+ # Enable "gather_facts" only if "ansible_facts" variable needs to be used.
+ gather_facts: false
+ module_defaults:
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ ipaapi_context: "{{ ipa_context | default(omit) }}"
+
+ tasks:
+
+ - name: Verify sysaccount tests are possible
+ ansible.builtin.shell:
+ cmd: |
+ echo SomeADMINpassword | kinit -c {{ krb5ccname }} admin > /dev/null
+ RESULT=$(KRB5CCNAME={{ krb5ccname }} ipa sysaccount-add --help)
+ kdestroy -A -c {{ krb5ccname }} > /dev/null
+ echo $RESULT
+ vars:
+ krb5ccname: "__check_ipa_sysaccount_add__"
+ register: check_sysaccount_add
+
+ - name: Execute tests
+ when: '"ipa: ERROR: unknown command" not in check_sysaccount_add.stderr'
+ block:
+
+ # CLEANUP TEST ITEMS
+
+ - name: Ensure sysaccount my-app is absent
+ ipasysaccount:
+ name: my-app
+ state: absent
+
+ # CREATE TEST ITEMS
+
+ # TESTS
+
+ - name: Ensure sysaccount my-app is present with random password
+ ipasysaccount:
+ name: my-app
+ random: true
+ register: result
+ failed_when: not result.changed or
+ result.sysaccount.randompassword is not defined or
+ result.failed
+
+ - name: Ensure sysaccount my-app is present, again with updated random password and update_password always
+ ipasysaccount:
+ name: my-app
+ random: true
+ register: result2
+ failed_when: not result2.changed or
+ result2.sysaccount.randompassword is not defined or
+ result2.sysaccount.randompassword == result.sysaccount.randompassword or
+ result2.failed
+
+ - name: Ensure sysaccount my-app is present, again with random password and update_password on_create
+ ipasysaccount:
+ name: my-app
+ random: true
+ update_password: on_create
+ register: result
+ failed_when: not result2.changed or
+ result.sysaccount.randompassword is defined or
+ result.failed
+
+ # more tests here
+
+ - name: Ensure sysaccount my-app is disabled
+ ipasysaccount:
+ name: my-app
+ state: disabled
+ register: result
+ failed_when: not result.changed or result.failed
+
+ - name: Ensure sysaccount my-app is disabled, again
+ ipasysaccount:
+ name: my-app
+ state: disabled
+ register: result
+ failed_when: result.changed or result.failed
+
+ - name: Ensure sysaccount my-app is enabled
+ ipasysaccount:
+ name: my-app
+ state: enabled
+ register: result
+ failed_when: not result.changed or result.failed
+
+ - name: Ensure sysaccount my-app is enabled, again
+ ipasysaccount:
+ name: my-app
+ state: enabled
+ register: result
+ failed_when: result.changed or result.failed
+
+ - name: Ensure sysaccount my-app is privileged
+ ipasysaccount:
+ name: my-app
+ privileged: true
+ register: result
+ failed_when: not result.changed or result.failed
+
+ - name: Ensure sysaccount my-app is privileged, again
+ ipasysaccount:
+ name: my-app
+ privileged: true
+ register: result
+ failed_when: result.changed or result.failed
+
+ # ADDITIONAL TEST HERE?
+
+ - name: Ensure sysaccount my-app is not privileged
+ ipasysaccount:
+ name: my-app
+ privileged: false
+ register: result
+ failed_when: not result.changed or result.failed
+
+ - name: Ensure sysaccount my-app is not privileged, again
+ ipasysaccount:
+ name: my-app
+ privileged: false
+ register: result
+ failed_when: result.changed or result.failed
+
+ - name: Ensure sysaccount my-app is absent
+ ipasysaccount:
+ name: my-app
+ state: absent
+ register: result
+ failed_when: not result.changed or result.failed
+
+ - name: Ensure sysaccount my-app is absent again
+ ipasysaccount:
+ name: my-app
+ state: absent
+ register: result
+ failed_when: result.changed or result.failed
+
+ # CLEANUP TEST ITEMS
+
+ - name: Ensure sysaccount my-app is absent
+ ipasysaccount:
+ name: my-app
+ state: absent
diff --git a/tests/sysaccount/test_sysaccount_client_context.yml b/tests/sysaccount/test_sysaccount_client_context.yml
new file mode 100644
index 00000000..be8541d7
--- /dev/null
+++ b/tests/sysaccount/test_sysaccount_client_context.yml
@@ -0,0 +1,40 @@
+---
+- name: Test sysaccount
+ hosts: ipaclients, ipaserver
+ # It is normally not needed to set "become" to "true" for a module test.
+ # Only set it to true if it is needed to execute commands as root.
+ become: false
+ # Enable "gather_facts" only if "ansible_facts" variable needs to be used.
+ gather_facts: false
+
+ tasks:
+ - name: Include FreeIPA facts.
+ ansible.builtin.include_tasks: ../env_freeipa_facts.yml
+
+ # Test will only be executed if host is not a server.
+ - name: Execute with server context in the client.
+ ipasysaccount:
+ ipaadmin_password: SomeADMINpassword
+ ipaapi_context: server
+ name: ThisShouldNotWork
+ register: result
+ failed_when: not (result.failed and result.msg is regex("No module named '*ipaserver'*"))
+ when: ipa_host_is_client
+
+# Import basic module tests, and execute with ipa_context set to 'client'.
+# If ipaclients is set, it will be executed using the client, if not,
+# ipaserver will be used.
+#
+# With this setup, tests can be executed against an IPA client, against
+# an IPA server using "client" context, and ensure that tests are executed
+# in upstream CI.
+
+- name: Test sysaccount using client context, in client host.
+ import_playbook: test_sysaccount.yml
+ when: groups['ipaclients']
+ vars:
+ ipa_test_host: ipaclients
+
+- name: Test sysaccount using client context, in server host.
+ import_playbook: test_sysaccount.yml
+ when: groups['ipaclients'] is not defined or not groups['ipaclients']