diff --git a/docs/guides/index.rst b/docs/guides/index.rst index d36ff384..ac0cd0f7 100644 --- a/docs/guides/index.rst +++ b/docs/guides/index.rst @@ -14,3 +14,4 @@ How to guides testing-offline testing-passkey local-users + pam diff --git a/docs/guides/pam.rst b/docs/guides/pam.rst new file mode 100644 index 00000000..7665b519 --- /dev/null +++ b/docs/guides/pam.rst @@ -0,0 +1,54 @@ +Pluggable Authentication Module (PAM) +##################################### + +Class :class:`sssd_test_framework.utils.pam.PAMUtils` provides +an API to manage PAM modules; pam_access and pam_faillock. + +pam_access: A module that performs host based access control on a system. + +.. code-block:: python + :caption: Example PAM Access usage + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_example(client: Client, provider: GenericProvider): + # Add users + provider.user("user-1").add() + provider.user("user-1").add() + + # Add rule to permit "user-1" and deny "user-2" + client.pam.access.add(["+:user-1:ALL","-:user-2:NONE"]) + client.sssd.common.pam(["with-pamaccess"]) + client.sssd.start() + + # Check the results + assert client.auth.ssh.password("user-1", "Secret123") + assert not client.auth.ssh.password("user-2", "Secret123") + +pam_faillock: A module that sets login attempts and lock out time. + +.. code-block:: python + :caption: Example PAM Faillock usage + + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_example(client: Client, provider: GenericProvider): + # Add user + provider.user("user-1").add() + + # Setup faillock + client.pam.faillock.config() + client.sssd.common.pam(["with-faillock"]) + + # Start SSSD + client.sssd.start() + + # Check the results + assert client.auth.ssh.password("user-1", "Secret123") + + # Three failed login attempts + for i in range(3): + assert not client.auth.ssh.password("user-1", "bad_password") + + assert not client.auth.ssh.password("user-1", "Secret123") + + # Reset user lockout + client.pam.faillock("user-1").reset + assert client.auth.ssh.password("user-1", "Secret123") diff --git a/sssd_test_framework/roles/client.py b/sssd_test_framework/roles/client.py index c3df1c74..8351b097 100644 --- a/sssd_test_framework/roles/client.py +++ b/sssd_test_framework/roles/client.py @@ -7,6 +7,7 @@ from ..utils.automount import AutomountUtils from ..utils.ldb import LDBUtils from ..utils.local_users import LocalUsersUtils +from ..utils.pam import PAMUtils from ..utils.sss_override import SSSOverrideUtils from ..utils.sssctl import SSSCTLUtils from ..utils.sssd import SSSDUtils @@ -70,6 +71,11 @@ def __init__(self, *args, **kwargs) -> None: Managing local overrides users and groups. """ + self.pam: PAMUtils = PAMUtils(self.host, self.fs) + """ + Managing PAM modules; pam_access and pam_faillock. + """ + def setup(self) -> None: """ Called before execution of each test. diff --git a/sssd_test_framework/utils/pam.py b/sssd_test_framework/utils/pam.py new file mode 100644 index 00000000..89c3e613 --- /dev/null +++ b/sssd_test_framework/utils/pam.py @@ -0,0 +1,235 @@ +""""PAM Tools.""" + +from __future__ import annotations + +from pytest_mh import MultihostHost, MultihostUtility +from pytest_mh.utils.fs import LinuxFileSystem + +__all__ = [ + "PAMUtils", + "PAMAccess", + "PAMFaillock", +] + + +class PAMUtils(MultihostUtility[MultihostHost]): + """ + Management of PAM modules + """ + + def __init__(self, host: MultihostHost, fs: LinuxFileSystem) -> None: + """ + :param host: Remote host instance + :type host: MultihostHost + """ + super().__init__(host) + + self.file = None + self.fs: LinuxFileSystem = fs + + def access(self, file: str | None = None) -> PAMAccess: + """ + :param file: PAM Access file name. + :type file: str, optional + :return: PAM Access object + :rtype: PAMAccess + """ + if file is None: + return PAMAccess(self) + + return PAMAccess(self, file) + + def faillock(self, user: str | None = None, file: str | None = None) -> PAMFaillock: + """ + :param user: Username. + :type user: str, optional + :param file: PAM Faillock file name. + :type file: str, optional + :return: PAM Faillock object + :rtype: PAMFaillock + """ + if file is None: + return PAMFaillock(self, user) + if user is None: + return PAMFaillock(self, file) + if user and file is None: + return PAMFaillock(self) + + return PAMFaillock(self, user, file) + + +class PAMAccess: + """ + Management of PAM Access on the client host. + + .. code-block:: python + :caption: Example usage + + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_example(client: Client, provider: GenericProvider): + # Add users + provider.user("user-1").add() + provider.user("user-1").add() + + # Add rule to permit "user-1" and deny "user-2" + client.pam.access.add(["+:user-1:ALL","-:user-2:NONE"]) + + client.sssd.common.pam(["with-pamaccess"]) + client.sssd.start() + + # Check the results + assert client.auth.ssh.password("user-1", "Secret123") + assert not client.auth.ssh.password("user-2", "Secret123") + """ + + def __init__(self, util: PAMUtils, file: str = "/etc/security/access.conf") -> None: + """ + :param util: PAMUtils utility object + :type util: PAMUtils + :param file: File name of access file + :type file: str, optional + """ + self.util: PAMUtils = util + self.file: str = file + + def get(self) -> list[str]: + """ + Get PAM rules from access file. + :return: List of PAM access rules + :rtype: list + """ + result = self.util.fs.read(self.file).split("\n") + self.util.logger.info(f"{result} are in {self.file} on {self.util.host.hostname}") + + return result + + def add(self, rules: list[str]) -> PAMAccess: + """ + Add one or more rules to access file. + :param rules: Access rule + :type rules: list[str], required + :return: self + :rtype: PAMAccess + """ + content = "" + for i in rules: + content += f"{i}\n" + + self.util.logger.info(f"{content} written to {self.file} on {self.util.host.hostname}") + self.util.fs.write(self.file, content) + + return self + + def delete(self, rules: list[str]) -> PAMAccess: + """ + Delete one or more rules from access file. + :param rules: PAM Access rule + :type rules: list[str], required + """ + result = self.get() + for i in result: + if i in rules: + result.pop() + + content = "" + for i in result: + content += f"{i}\n" + + self.util.logger.info(f"{content} written to {self.file} on {self.util.host.hostname}") + self.util.fs.write(self.file, content) + + return self + + +class PAMFaillock: + """ + Management of PAM Faillock on the client host. + + .. code-block:: python + :caption: Example usage + + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_example(client: Client, provider: GenericProvider): + # Add user + provider.user("user-1").add() + + # Setup faillock + client.pam.faillock.config() + client.sssd.common.pam(["with-faillock"]) + + # Start SSSD + client.sssd.start() + + # Check the results + assert client.auth.ssh.password("user-1", "Secret123") + + # Three failed login attempts + for i in range(3): + assert not client.auth.ssh.password("user-1", "bad_password") + + assert not client.auth.ssh.password("user-1", "Secret123") + + # Reset user lockout + client.pam.faillock("user-1").reset + assert client.auth.ssh.password("user-1", "Secret123") + """ + + def __init__(self, util: PAMUtils, user: str | None = None, file: str = "/etc/security/faillock.conf") -> None: + """ + :param util: PAMUtils object + :type util: PAMUtils + :param user: User + :type user: str, optional + :param file: Faillock configuration file + :type file: str, optional + """ + self.util: PAMUtils = util + self.user: str | None = user + self.file: str = file + + def config(self, deny: int | None = 3, unlock_time: int | None = 300) -> None: + """ + Configure the settings for PAM faillock. + :param deny: Deny attempts + :type deny: int, defaults to 3 + :param unlock_time: Unlock timeout in seconds + :type unlock_time: int, defaults to 300 + :return: Self + :rtype: PAMFaillock + """ + content = f"deny={deny}\nunlock_time={unlock_time}\nsilent" + + self.util.logger.info(f"{content} written to {self.file} on {self.util.host.hostname}") + self.util.fs.write(self.file, content) + + def get_config(self) -> str: + """ + Get the configuration for PAM Faillock. + :return: Contents of faillock.conf + :rtype: str + """ + result = self.util.fs.read(self.file) + + return result + + def info(self) -> str: + """ + Get user faillock information. + :return: Output from faillock + :rtype: str + """ + self.util.logger.info(f"Getting faillock information for {self.user} on {self.util.host.hostname}") + result = self.util.host.ssh.exec(["faillock", "--user", self.user]) + + return result.stdout + + def reset(self) -> PAMFaillock: + """ + Reset user tally information. + :return: Self + :rtype: PAMFaillock + """ + self.util.logger.info(f"Resetting faillock tally for {self.user} on {self.util.host.hostname}") + self.util.host.ssh.exec(["faillock", "--user", self.user, "--reset"]) + + return self diff --git a/sssd_test_framework/utils/sssd.py b/sssd_test_framework/utils/sssd.py index 7dc7b0df..6c26f60c 100644 --- a/sssd_test_framework/utils/sssd.py +++ b/sssd_test_framework/utils/sssd.py @@ -783,3 +783,16 @@ def autofs(self) -> None: """ self.sssd.authselect.select("sssd") self.sssd.enable_responder("autofs") + + def pam(self, features: [str] = []) -> None: + """ + Configure SSSD with pam. + + #. Select authselect sssd profile + #. Enable pam responder + + :param features: list of authselect features + :type features: list[str], optional + """ + self.sssd.authselect.select("sssd", features) + self.sssd.enable_responder("pam")