diff --git a/share/ansible/roles/ci_run/tasks/alpine.yml b/share/ansible/roles/ci_run/tasks/alpine.yml index abd0f2502..e6d1ca991 100644 --- a/share/ansible/roles/ci_run/tasks/alpine.yml +++ b/share/ansible/roles/ci_run/tasks/alpine.yml @@ -22,6 +22,12 @@ - pkgconf state: present +- name: Make sure expect is found + ansible.builtin.file: + src: /usr/bin/expect + dest: /bin/expect + state: link + - name: Build configuration ansible.builtin.command: > ./autogen.sh diff --git a/share/ansible/roles/ci_run/tasks/debian.yml b/share/ansible/roles/ci_run/tasks/debian.yml index 7a5353029..cff6fd345 100644 --- a/share/ansible/roles/ci_run/tasks/debian.yml +++ b/share/ansible/roles/ci_run/tasks/debian.yml @@ -9,6 +9,7 @@ - name: Ensure dependencies are installed ansible.builtin.apt: name: + - expect - gpg - libbsd-dev - libcmocka-dev diff --git a/share/ansible/roles/ci_run/tasks/fedora.yml b/share/ansible/roles/ci_run/tasks/fedora.yml index b1bf6e742..4dbcac866 100644 --- a/share/ansible/roles/ci_run/tasks/fedora.yml +++ b/share/ansible/roles/ci_run/tasks/fedora.yml @@ -8,6 +8,7 @@ use_backend: dnf4 name: - dnf-plugins-core + - expect - gawk - libcmocka-devel - systemd-devel @@ -19,6 +20,24 @@ register: dnf_result changed_when: '"Nothing to do" not in dnf_result.stdout' +- name: Temporary workaround to create the newusers PAM service file + ansible.builtin.copy: + src: /etc/pam.d/chfn + dest: /etc/pam.d/newusers + remote_src: yes + owner: root + group: root + mode: '0644' + +- name: Temporary workaround to create the groupmems PAM service file + ansible.builtin.copy: + src: /etc/pam.d/chfn + dest: /etc/pam.d/groupmems + remote_src: yes + owner: root + group: root + mode: '0644' + - name: Build configuration ansible.builtin.command: > ./autogen.sh diff --git a/share/ansible/roles/ci_run/tasks/opensuse.yml b/share/ansible/roles/ci_run/tasks/opensuse.yml index 92a779b98..5888f4ebe 100644 --- a/share/ansible/roles/ci_run/tasks/opensuse.yml +++ b/share/ansible/roles/ci_run/tasks/opensuse.yml @@ -9,6 +9,7 @@ - autoconf - automake - diffutils + - expect - gawk - gcc - gettext-tools diff --git a/tests/system/framework/roles/shadow.py b/tests/system/framework/roles/shadow.py index 39cbb35b7..c05ab531a 100644 --- a/tests/system/framework/roles/shadow.py +++ b/tests/system/framework/roles/shadow.py @@ -3,17 +3,21 @@ from __future__ import annotations import shlex -from typing import Dict +from typing import Dict, Tuple from pytest_mh.conn import ProcessLogLevel, ProcessResult from ..hosts.shadow import ShadowHost +from ..misc.errors import ExpectScriptError from .base import BaseLinuxRole __all__ = [ "Shadow", ] +DEFAULT_INTERACTIVE_TIMEOUT: int = 60 +"""Default timeout for interactive sessions.""" + class Shadow(BaseLinuxRole[ShadowHost]): """ @@ -142,3 +146,128 @@ def chage(self, *args) -> ProcessResult: self.host.discard_file("/etc/shadow") return cmd + + def newusers(self, *args, users_data: str | None = None) -> ProcessResult: + """ + Update or create new users in batch. + + Updates or creates multiple users by reading account information in passwd + format. If `users_data` is provided, it's passed via stdin and takes + precedence; otherwise, the command reads from a file specified in `args`. + """ + if users_data: + cmd_args = " ".join(args) + self.logger.info(f"Creating users from stdin on {self.host.hostname}") + cmd = self.host.conn.run(f"echo '{users_data}' | newusers {cmd_args}", log_level=ProcessLogLevel.Error) + else: + args_dict = self._parse_args(args) + self.logger.info(f'Creating users from "{args_dict["name"]}" on {self.host.hostname}') + cmd = self.host.conn.run("newusers " + args[0], log_level=ProcessLogLevel.Error) + + self.host.discard_file("/etc/passwd") + self.host.discard_file("/etc/shadow") + self.host.discard_file("/etc/group") + self.host.discard_file("/etc/gshadow") + + return cmd + + def groupmems(self, *args, run_as: str = "root", password: str | None = None) -> ProcessResult: + """ + Administer members of a user's primary group. + + The groupmems command allows management of group membership lists. + If `run_as` is provided, then the `password` must be provided and the command groupmems run under this user. + If `run_as` isn't provided, then the command is run under `root`. + """ + args_dict = self._parse_args(args) + + if run_as == "root": + self.logger.info(f'Administer {args_dict["name"]} group membership as root on {self.host.hostname}') + cmd = self.host.conn.run("groupmems " + args[0], log_level=ProcessLogLevel.Error) + else: + self.logger.info(f'Administer {args_dict["name"]} group membership on {self.host.hostname}') + cmd = self.host.conn.run( + f"echo '{password}' | su - {run_as} -c 'groupmems {args[0]}'", log_level=ProcessLogLevel.Error + ) + + self.host.discard_file("/etc/group") + self.host.discard_file("/etc/gshadow") + + return cmd + + def newgrp(self, *args, run_as: str = "root") -> Tuple[ProcessResult, int]: + """ + Log in to a new group. + + The newgrp command is used to change the current group ID during a login session. + Returns the process result and the group ID after the change. + """ + args_dict = self._parse_args(args) + + self.logger.info(f'Changing {run_as} to group "{args_dict["name"]}" on {self.host.hostname}') + + # Use expect to handle the interactive newgrp session + result = self.host.conn.expect( + rf""" + set timeout {DEFAULT_INTERACTIVE_TIMEOUT} + set prompt "\[#\$>\] $" + + if {{ "{run_as}" eq "root" }} {{ + spawn newgrp {args[0]} + }} else {{ + spawn su - {run_as} + expect {{ + -re $prompt {{send "newgrp {args[0]}\n"}} + timeout {{puts "expect result: Timeout waiting for su prompt"; exit 201}} + eof {{puts "expect result: Unexpected end of file"; exit 202}} + }} + }} + + expect {{ + -re $prompt {{send "id -g\n"}} + timeout {{puts "expect result: Timeout waiting for newgrp prompt"; exit 201}} + eof {{puts "expect result: Unexpected end of file"; exit 202}} + }} + + expect {{ + -re "(\[0-9\]+)" {{ + set gid $expect_out(1,string) + send "exit\n" + puts "newgrp_gid:$gid" + }} + timeout {{puts "expect result: Timeout waiting for id output"; exit 201}} + eof {{puts "expect result: Unexpected end of file"; exit 202}} + }} + + if {{ "{run_as}" ne "root" }} {{ + expect {{ + -re $prompt {{ + send "exit\n" + }} + timeout {{puts "expect result: Timeout waiting for original su prompt"; exit 201}} + }} + }} + + expect {{ + eof {{exit 0}} + timeout {{exit 201}} + }} + """, + verbose=False, + ) + + if result.rc > 200: + raise ExpectScriptError(result.rc) + + gid_line = None + for line in result.stdout_lines: + if "newgrp_gid:" in line: + gid_line = line + break + + if gid_line is None: + raise ValueError("Current GID is required for newgrp") + + current_gid = int(gid_line.split(":")[1]) + + return result, current_gid diff --git a/tests/system/tests/test_groupmems.py b/tests/system/tests/test_groupmems.py new file mode 100644 index 000000000..f59d49c86 --- /dev/null +++ b/tests/system/tests/test_groupmems.py @@ -0,0 +1,41 @@ +""" +Test groupmems +""" + +from __future__ import annotations + +import pytest + +from framework.roles.shadow import Shadow +from framework.topology import KnownTopology + + +@pytest.mark.topology(KnownTopology.Shadow) +def test_groupmems__add_user_as_root(shadow: Shadow): + """ + :title: Add user to group as root user + :setup: + 1. Create test user and group + :steps: + 1. Add user to group using groupmems as root + 2. Check group and gshadow entry + :expectedresults: + 1. User is added to group + 2. group and gshadow entry values are correct + :customerscenario: False + """ + shadow.useradd("tuser") + shadow.groupadd("tgroup") + + shadow.groupmems("-g tgroup -a tuser") + + group_entry = shadow.tools.getent.group("tgroup") + assert group_entry is not None, "Group should be found" + assert group_entry.name == "tgroup", "Incorrect groupname" + assert "tuser" in group_entry.members, "User should be member of group" + + if shadow.host.features["gshadow"]: + gshadow_entry = shadow.tools.getent.gshadow("tgroup") + assert gshadow_entry is not None, "Group should be found" + assert gshadow_entry.name == "tgroup", "Incorrect groupname" + assert gshadow_entry.password == "!", "Incorrect password" diff --git a/tests/system/tests/test_newgrp.py b/tests/system/tests/test_newgrp.py new file mode 100644 index 000000000..b955a8226 --- /dev/null +++ b/tests/system/tests/test_newgrp.py @@ -0,0 +1,34 @@ +""" +Test newgrp +""" + +from __future__ import annotations + +import pytest + +from framework.roles.shadow import Shadow +from framework.topology import KnownTopology + + +@pytest.mark.topology(KnownTopology.Shadow) +def test_newgrp__change_to_new_group(shadow: Shadow): + """ + :title: Change to a new group using newgrp + :setup: + 1. Create test user and group + 2. Add user to the group + :steps: + 1. Use newgrp to change to the new group + 2. Check that the group change worked + :expectedresults: + 1. newgrp command succeeds + 2. Current group ID matches the new group + :customerscenario: False + """ + shadow.useradd("tuser") + shadow.groupadd("tgroup") + shadow.usermod("-a -G tgroup tuser") + + cmd, gid = shadow.newgrp("tgroup", run_as="tuser") + assert cmd.rc == 0, "newgrp command should succeed" + assert gid == 1001, f"Current GID should be {1001}, got {gid}" diff --git a/tests/system/tests/test_newusers.py b/tests/system/tests/test_newusers.py new file mode 100644 index 000000000..0cc584c6b --- /dev/null +++ b/tests/system/tests/test_newusers.py @@ -0,0 +1,185 @@ +""" +Test newusers +""" + +from __future__ import annotations + +import pytest + +from framework.misc import days_since_epoch +from framework.roles.shadow import Shadow +from framework.topology import KnownTopology + + +@pytest.mark.topology(KnownTopology.Shadow) +def test_newusers__create_users_from_stdin(shadow: Shadow): + """ + :title: Create multiple users from stdin + :setup: + 1. Prepare and create user data from stdin + :steps: + 1. Check the first user's passwd, shadow, group, gshadow, and home folder + 2. Check second user's passwd, shadow, group, gshadow, and home folder + :expectedresults: + 1. First user's passwd, shadow, group, gshadow, and home folders are correct + 2. Second user's passwd, shadow, group, gshadow, and home folders are correct + :customerscenario: False + """ + users_data = ( + "tuser1:Secret123:1001:1001:Test User One:/home/tuser1:/bin/bash\n" + "tuser2:Secret123:1002:1002:Test User Two:/home/tuser2:/bin/bash" + ) + + shadow.newusers(users_data=users_data) + + passwd_entry = shadow.tools.getent.passwd("tuser1") + assert passwd_entry is not None, "tuser1 user should be found in passwd" + assert passwd_entry.name == "tuser1", "Incorrect username" + assert passwd_entry.password == "x", "Incorrect password" + assert passwd_entry.uid == 1001, "Incorrect UID" + assert passwd_entry.gid == 1001, "Incorrect GID" + assert passwd_entry.gecos == "Test User One", "Incorrect GECOS" + assert passwd_entry.home == "/home/tuser1", "Incorrect home folder" + assert passwd_entry.shell == "/bin/bash", "Incorrect shell" + + shadow_entry = shadow.tools.getent.shadow("tuser1") + assert shadow_entry is not None, "tuser1 user should be found in shadow" + assert shadow_entry.name == "tuser1", "Incorrect username" + assert shadow_entry.password is not None, "Incorrect password" + assert shadow_entry.last_changed == days_since_epoch(), "Incorrect last changed" + assert shadow_entry.min_days == 0, "Incorrect min days" + assert shadow_entry.max_days == 99999, "Incorrect max days" + assert shadow_entry.warn_days == 7, "Incorrect warn days" + + group_entry = shadow.tools.getent.group("tuser1") + assert group_entry is not None, "tuser1 group should be found" + assert group_entry.name == "tuser1", "Incorrect group name" + assert group_entry.gid == 1001, "Incorrect GID" + + if shadow.host.features["gshadow"]: + gshadow_entry = shadow.tools.getent.gshadow("tuser1") + assert gshadow_entry is not None, "tuser1 group should be found" + assert gshadow_entry.name == "tuser1", "Incorrect group name" + assert gshadow_entry.password == "*", "Incorrect password" + + assert shadow.fs.exists("/home/tuser1"), "tuser1 home folder should exist" + + passwd_entry = shadow.tools.getent.passwd("tuser2") + assert passwd_entry is not None, "tuser2 user should be found in passwd" + assert passwd_entry.name == "tuser2", "Incorrect username" + assert passwd_entry.password == "x", "Incorrect password" + assert passwd_entry.uid == 1002, "Incorrect UID" + assert passwd_entry.gid == 1002, "Incorrect GID" + assert passwd_entry.gecos == "Test User Two", "Incorrect GECOS" + assert passwd_entry.home == "/home/tuser2", "Incorrect home folder" + assert passwd_entry.shell == "/bin/bash", "Incorrect shell" + + shadow_entry = shadow.tools.getent.shadow("tuser2") + assert shadow_entry is not None, "tuser2 user should be found in shadow" + assert shadow_entry.name == "tuser2", "Incorrect username" + assert shadow_entry.password is not None, "Incorrect password" + assert shadow_entry.last_changed == days_since_epoch(), "Incorrect last changed" + assert shadow_entry.min_days == 0, "Incorrect min days" + assert shadow_entry.max_days == 99999, "Incorrect max days" + assert shadow_entry.warn_days == 7, "Incorrect warn days" + + group_entry = shadow.tools.getent.group("tuser2") + assert group_entry is not None, "tuser2 group should be found" + assert group_entry.name == "tuser2", "Incorrect group name" + assert group_entry.gid == 1002, "Incorrect GID" + + if shadow.host.features["gshadow"]: + gshadow_entry = shadow.tools.getent.gshadow("tuser2") + assert gshadow_entry is not None, "tuser2 group should be found" + assert gshadow_entry.name == "tuser2", "Incorrect group name" + assert gshadow_entry.password == "*", "Incorrect password" + + assert shadow.fs.exists("/home/tuser2"), "tuser2 home folder should exist" + + +@pytest.mark.topology(KnownTopology.Shadow) +def test_newusers__create_users_from_file(shadow: Shadow): + """ + :title: Create multiple users using file input + :setup: + 1. Prepare and create user data from input file + :steps: + 1. Check the first user's passwd, shadow, group, gshadow, and home folder + 2. Check second user's passwd, shadow, group, gshadow, and home folder + :expectedresults: + 1. First user's passwd, shadow, group, gshadow, and home folders are correct + 2. Second user's passwd, shadow, group, gshadow, and home folders are correct + :customerscenario: False + """ + temp_file = "/tmp/test_newusers_data.txt" + users_data = ( + "tuser1:Secret123:1001:1001:Test User One:/home/tuser1:/bin/bash\n" + "tuser2:Secret123:1002:1002:Test User Two:/home/tuser2:/bin/bash" + ) + shadow.fs.write(temp_file, users_data) + + shadow.newusers(temp_file) + + passwd_entry = shadow.tools.getent.passwd("tuser1") + assert passwd_entry is not None, "tuser1 user should be found in passwd" + assert passwd_entry.name == "tuser1", "Incorrect username" + assert passwd_entry.password == "x", "Incorrect password" + assert passwd_entry.uid == 1001, "Incorrect UID" + assert passwd_entry.gid == 1001, "Incorrect GID" + assert passwd_entry.gecos == "Test User One", "Incorrect GECOS" + assert passwd_entry.home == "/home/tuser1", "Incorrect home folder" + assert passwd_entry.shell == "/bin/bash", "Incorrect shell" + + shadow_entry = shadow.tools.getent.shadow("tuser1") + assert shadow_entry is not None, "tuser1 user should be found in shadow" + assert shadow_entry.name == "tuser1", "Incorrect username" + assert shadow_entry.password is not None, "Incorrect password" + assert shadow_entry.last_changed == days_since_epoch(), "Incorrect last changed" + assert shadow_entry.min_days == 0, "Incorrect min days" + assert shadow_entry.max_days == 99999, "Incorrect max days" + assert shadow_entry.warn_days == 7, "Incorrect warn days" + + group_entry = shadow.tools.getent.group("tuser1") + assert group_entry is not None, "tuser1 group should be found" + assert group_entry.name == "tuser1", "Incorrect group name" + assert group_entry.gid == 1001, "Incorrect GID" + + if shadow.host.features["gshadow"]: + gshadow_entry = shadow.tools.getent.gshadow("tuser1") + assert gshadow_entry is not None, "tuser1 group should be found" + assert gshadow_entry.name == "tuser1", "Incorrect group name" + assert gshadow_entry.password == "*", "Incorrect password" + + assert shadow.fs.exists("/home/tuser1"), "tuser1 home folder should exist" + + passwd_entry = shadow.tools.getent.passwd("tuser2") + assert passwd_entry is not None, "tuser2 user should be found in passwd" + assert passwd_entry.name == "tuser2", "Incorrect username" + assert passwd_entry.password == "x", "Incorrect password" + assert passwd_entry.uid == 1002, "Incorrect UID" + assert passwd_entry.gid == 1002, "Incorrect GID" + assert passwd_entry.gecos == "Test User Two", "Incorrect GECOS" + assert passwd_entry.home == "/home/tuser2", "Incorrect home folder" + assert passwd_entry.shell == "/bin/bash", "Incorrect shell" + + shadow_entry = shadow.tools.getent.shadow("tuser2") + assert shadow_entry is not None, "tuser2 user should be found in shadow" + assert shadow_entry.name == "tuser2", "Incorrect username" + assert shadow_entry.password is not None, "Incorrect password" + assert shadow_entry.last_changed == days_since_epoch(), "Incorrect last changed" + assert shadow_entry.min_days == 0, "Incorrect min days" + assert shadow_entry.max_days == 99999, "Incorrect max days" + assert shadow_entry.warn_days == 7, "Incorrect warn days" + + group_entry = shadow.tools.getent.group("tuser2") + assert group_entry is not None, "tuser2 group should be found" + assert group_entry.name == "tuser2", "Incorrect group name" + assert group_entry.gid == 1002, "Incorrect GID" + + if shadow.host.features["gshadow"]: + gshadow_entry = shadow.tools.getent.gshadow("tuser2") + assert gshadow_entry is not None, "tuser2 group should be found" + assert gshadow_entry.name == "tuser2", "Incorrect group name" + assert gshadow_entry.password == "*", "Incorrect password" + + assert shadow.fs.exists("/home/tuser2"), "tuser2 home folder should exist"