diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index e72b13a4d0f..00000000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Documentation -on: - push: - branches: - - master - pull_request: - branches: - - master -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true -jobs: - system-tests: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Setup virtual environment - working-directory: ./src/tests/system - run: | - sudo apt-get update - - # Install dependencies for python-ldap - sudo apt-get install -y libsasl2-dev python3-dev libldap2-dev libssl-dev - - pip3 install virtualenv - python3 -m venv .venv - source .venv/bin/activate - pip3 install -r ./requirements.txt - pip3 install -r ./docs/requirements.txt - - - name: Build docs - working-directory: ./src/tests/system - run: | - source .venv/bin/activate - cd docs - make html SPHINXOPTS="-W --keep-going" - - result: - name: All documentation builds correctly - if: ${{ always() }} - runs-on: ubuntu-latest - needs: [system-tests] - steps: - - name: Fail on failure - if: ${{ needs.system-tests.result != 'success' }} - run: exit 1 diff --git a/.github/workflows/static-code-analysis.yml b/.github/workflows/static-code-analysis.yml index d6355b24674..20519b4c64c 100644 --- a/.github/workflows/static-code-analysis.yml +++ b/.github/workflows/static-code-analysis.yml @@ -86,7 +86,7 @@ jobs: - name: mypy if: always() working-directory: ./src/tests/system - run: source .venv/bin/activate && mypy --install-types --non-interactive lib tests + run: source .venv/bin/activate && mypy --install-types --non-interactive tests result: name: All tests are successful diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index 4ed2fffba3c..00000000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 -sphinx: - configuration: src/tests/system/docs/conf.py -python: - install: - - requirements: src/tests/system/docs/requirements.txt -build: - os: ubuntu-22.04 - apt_packages: - - libldap2-dev - - libsasl2-dev - - libssl-dev - - python3-dev - tools: - python: '3.11' -formats: [] diff --git a/src/tests/system/conftest.py b/src/tests/system/conftest.py index f8adeed85b0..4ddffff1d50 100644 --- a/src/tests/system/conftest.py +++ b/src/tests/system/conftest.py @@ -3,8 +3,7 @@ from __future__ import annotations from pytest_mh import MultihostPlugin - -from lib.sssd.config import SSSDMultihostConfig +from sssd_test_framework.config import SSSDMultihostConfig # Load additional plugins pytest_plugins = ( diff --git a/src/tests/system/docs/Makefile b/src/tests/system/docs/Makefile deleted file mode 100644 index d4bb2cbb9ed..00000000000 --- a/src/tests/system/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/src/tests/system/docs/api.rst b/src/tests/system/docs/api.rst deleted file mode 100644 index e382da22935..00000000000 --- a/src/tests/system/docs/api.rst +++ /dev/null @@ -1,13 +0,0 @@ -API Reference -============= - -.. autosummary:: - :toctree: api - :nosignatures: - :recursive: - - lib.sssd.hosts - lib.sssd.misc - lib.sssd.roles - lib.sssd.topology - lib.sssd.utils diff --git a/src/tests/system/docs/concepts.rst b/src/tests/system/docs/concepts.rst deleted file mode 100644 index 5ccf7803afe..00000000000 --- a/src/tests/system/docs/concepts.rst +++ /dev/null @@ -1,113 +0,0 @@ -Core concepts and coding style -############################## - -The code must be fully typed and follow the black coding style. All code must be -validated using the following tools: - -* Check PEP8 compliance with `flake8 `__ and - `pycodestyle `__: ``flake8 . && pycodestyle .`` -* Sort imports with `isort `__: ``isort .`` -* Convert to `black `__ style: ``black .`` -* Check types with `mypy `__: ``mypy .`` - -Core concepts -************* - -* Each test starts fresh - - * Everything that is changed by the test is reverted when the test is finished - - * Execution of one test must not affect execution of some other test - -* Read and understand - - * What a test does and what data and setup does it requires must be clearly - visible from the test itself without jumping to other places in the code - - * Avoid using fixtures unless you have a very good reason to do so - -* Extend the API - - * If you miss some functionality, extend :doc:`lib.sssd ` with a - new, clear, documented and reusable API - - * Avoid calling commands on remote hosts directly from the tests, this belongs - to :mod:`lib.sssd.roles` - - * If you need to call a command, it most likely means that you want to extend - :mod:`lib.sssd.roles` module - -* Use topology parametrization whenever possible - - * This rapidly increases the code coverage - -Naming tests -************ - -Name your tests as ``test_feature__case``. For example: - -.. code-block:: python - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_id__shortname(): - pass - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_id__fqn(): - pass - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_id__name_with_space(): - pass - -About using fixtures -******************** - -`Fixtures `__ are a -great pytest tool to provide and share initial setup and prepare data for a -test. However, they can also be a great enemy if you overuse them or if you nest -them into multiple levels (using fixture inside a fixture). - -Overusing fixtures makes it quite difficult to understand what a test does and -what data and setup does it require. This is because the information is not -present directly in the test itself but on different place or places in the -code. Therefore you have to jump back and forth in the code in order to -understand what the test does. This is especially bad in testing projects like -SSSD that has so many components. - -Another big downside of using fixtures is that they do not allow slight -modifications of the setup. Most of the time, you need to write multiple tests -for single functionality. And even though it seems logical that these tests -share the same setup, it is most often not the case as each test usually -requires slight modification of the overall setup. If the setup is done with -fixtures and you need to add a new test case that requires slight modification -you either end up duplicating the fixture code, creating more fixtures or -refactoring the fixture and every single related test. This of course makes the -tests harder to understand and extend and it diminishes the benefit of using -fixtures. - -The SSSD test framework :doc:`lib.sssd ` makes the SSSD related setups quite -easy, with just a few lines of code where everything is clear out of the box -even without reading any documentation. Therefore there is no need to use -fixtures. - -.. warning:: - - The general recommendation is: **Avoid using fixtures** *unless you have a - very good reason to use it.* - -Organizing test cases -********************* - -Pytest allows you to write tests inside a class (starts with `Test`) or directly -inside a module (a function starting with `test_`). Even though it might be -logical to organize tests inside a class, it does not give you any benefit over -plain function and it create just one more level of organization that must be -correctly kept and maintained. - -.. warning:: - - **Avoid organizing tests into classes** *unless there is a food reason to - use them* (for example when you need to use a class-scoped fixture, however - this break "Each test starts fresh" principle so it is reserved for very - special cases). diff --git a/src/tests/system/docs/conf.py b/src/tests/system/docs/conf.py deleted file mode 100644 index f53c1530db9..00000000000 --- a/src/tests/system/docs/conf.py +++ /dev/null @@ -1,71 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -from __future__ import annotations - -# -- Path setup -------------------------------------------------------------- -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -import os -import sys - -sys.path.insert(0, os.path.abspath("..")) -sys.path.insert(0, os.path.abspath(".")) - -# -- Project information ----------------------------------------------------- - -project = "sssd" - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.autosummary", - "sphinx.ext.intersphinx", - "sphinx_design", - "extensions.directives.TopologyMark", -] - -# Add any paths that contain templates here, relative to this directory. -# templates_path = ['_templates'] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "sphinx_rtd_theme" - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ['_static'] - -autoclass_content = "both" -autodoc_default_options = { - "members": True, - "member-order": "bysource", - "special-members": "__call__", - "undoc-members": True, - "inherited-members": False, - "show-inheritance": True, -} - -intersphinx_mapping = { - "pytest_mh": ("https://pytest-mh.readthedocs.io/en/latest", None), -} diff --git a/src/tests/system/docs/config.rst b/src/tests/system/docs/config.rst deleted file mode 100644 index adca1ff6f2b..00000000000 --- a/src/tests/system/docs/config.rst +++ /dev/null @@ -1,328 +0,0 @@ -Multihost configuration -####################### - -The multihost configuration file contains definition of the domains, hosts and -their roles that are available to run the tests. It uses the `YAML -`__ language. - -Basic definition -**************** - -.. code-block:: yaml - - domains: - - id: - hosts: - - hostname: - role: - ssh: - host: (optional, defaults to host name) - port: (optional, defaults to 22) - username: (optional, defaults to "root") - password: (optional, defaults to "Secret123") - config: (optional, defaults to {}) - artifacts: (optional, defaults to {}) - -The top level element of the configuration is list of ``domains``. Each domain -has ``id`` attribute and defines the list of available hosts. - -* ``id``: domain identifier which is used in the path inside ``mh`` fixture, see :ref:`mh-fixture` -* ``hosts``: list of available hosts and their roles - - * ``hostname``: DNS host name, it may not necessarily be resolvable from the machine that runs pytest - * ``role``: host role - * ``ssh.host``: ssh host to connect to (it may be a resolvable host name or an - IP address), defaults to the value of ``hostname`` - * ``ssh.port``: ssh port, defaults to 22 - * ``ssh.username``: ssh username, defaults to ``root`` - * ``ssh.password``: ssh password for the user, defaults to ``Secret123`` - * ``config``: additional configuration, place for custom options, see :ref:`custom-config` - * ``artifacts``: list of artifacts that are automatically downloaded, see :ref:`gathering-artifacts` - -.. _available-roles: - -Available roles -*************** - -Currently available roles are: - -* ``client``: SSSD client enrolled into desired providers -* ``ldap``: 389ds directory server -* ``ipa``: FreeIPA server -* ``ad``: Active Directory server -* ``samba``: Samba DC -* ``nfs``: NFS server -* ``kdc``: KDC server - -client -====== - -SSSD client enrolled into the provider that you want to run the tests against. -If a keytab is required by the provider it must be present somewhere on the -host. The keytab is then specified in the additional configuration of the -provider host. - -.. code-block:: yaml - :caption: Client role example - - - hostname: client.test - role: client - config: - artifacts: - - /etc/sssd/* - - /var/log/sssd/* - - /var/lib/sss/db/* - -Additional configuration (host/config section) ----------------------------------------------- - -* :ref:`config-artifacts` - -.. seealso:: - - `Example setup of the Client host `__ - -ldap -==== - -Fresh installation of 389ds directory server with TLS/SSL enabled and no data -present (i.e. no object is present under the default naming context). - -.. code-block:: yaml - :caption: LDAP role example - - - hostname: master.ldap.test - role: ldap - config: - binddn: cn=Directory Manager - bindpw: Secret123 - client: - ldap_tls_reqcert: demand - ldap_tls_cacert: /data/certs/ca.crt - dns_discovery_domain: ldap.test - -Additional configuration (host/config section) ----------------------------------------------- - -* :ref:`config-artifacts` -* :ref:`config-ldap` -* :ref:`config-providers-client` - -.. seealso:: - - `Example setup of the LDAP host `__ - -ipa -=== - -Fresh installation of FreeIPA server with no additional data. - -.. code-block:: yaml - :caption: IPA role example - - - hostname: master.ipa.test - role: ipa - config: - client: - ipa_domain: ipa.test - krb5_keytab: /enrollment/ipa.keytab - ldap_krb5_keytab: /enrollment/ipa.keytab - -Additional configuration (host/config section) ----------------------------------------------- - -* :ref:`config-artifacts` -* :ref:`config-providers-client` - -.. seealso:: - - `Example setup of the IPA host `__ - -ad -== - -Fresh installation of Active Directory with no additional data. SSH is installed -on the host and user's default shell is set to PowerShell. - -The following extra schema must be installed: - -* `sudo schema `__ - -.. code-block:: yaml - :caption: AD role example - - - hostname: dc.ad.test - role: ad - username: Administrator@ad.test - password: vagrant - config: - binddn: Administrator@ad.test - bindpw: vagrant - client: - ad_domain: ad.test - krb5_keytab: /enrollment/ad.keytab - ldap_krb5_keytab: /enrollment/ad.keytab - -Additional configuration (host/config section) ----------------------------------------------- - -* :ref:`config-artifacts` -* :ref:`config-providers-client` - -.. seealso:: - - `Example setup of the AD host `__ - -samba -===== - -Fresh installation of Samba DC with no additional data. - -The following extra schema must be installed: - -* sudo schema `class `__, `attrs `__ - -.. code-block:: yaml - :caption: Samba role example - - - hostname: dc.samba.test - role: samba - config: - binddn: CN=Administrator,CN=Users,DC=samba,DC=test - bindpw: Secret123 - client: - ad_domain: samba.test - krb5_keytab: /enrollment/samba.keytab - ldap_krb5_keytab: /enrollment/samba.keytab - -Additional configuration (host/config section) ----------------------------------------------- - -* :ref:`config-artifacts` -* :ref:`config-ldap` -* :ref:`config-providers-client` - -.. seealso:: - - `Example setup of the Samba host `__ - -nfs -=== - -Fresh installation of NFS server, with the server running and no exported directories. - -.. code-block:: yaml - :caption: NFS role example - - - hostname: nfs.test - role: nfs - config: - exports_dir: /dev/shm/exports - -Additional configuration (host/config section) ----------------------------------------------- - -* ``exports_dir``: Path to the directory that will be used as a parent for all - directories that will be created and exported on the NFS server. On - containers, this should be ``/dev/shm/exports`` or other writable location - that runs on ``tmpfs`` file system. -* :ref:`config-artifacts` - -.. seealso:: - - `Example setup of the NFS host `__ - -kdc -=== - -Fresh installation of Kerberos KDC server, with the server running and no additional principals. - -.. code-block:: yaml - :caption: KDC role example - - - hostname: kdc.test - role: kdc - -Additional configuration (host/config section) ----------------------------------------------- - -* ``domain``: Default Kerberos domain. -* ``realm``: Default Kerberos realm. -* :ref:`config-artifacts` -* :ref:`config-providers-client` - -.. seealso:: - - `Example setup of the KDC host `__ - -Additional configuration (host/config section) -********************************************** - -.. _config-artifacts: - -Gathering artifacts -=================== - -The ``config`` section of the host definition can be also used to specify which -artifacts should be automatically collected from the host when a test is -finished using the ``artifacts`` keyword which contains a list of artifacts. The -values are path to the artifacts with a possible wildcard character. For -example: - -.. code-block:: yaml - - - hostname: client.test - role: client - config: - artifacts: - - /etc/sssd/* - - /var/log/sssd/* - - /var/lib/sss/db/* - -.. _config-ldap: - -LDAP configuration -================== - -This additional configuration can be used on roles with direct LDAP access. - -* ``binddn``: Bind DN to authentication with. -* ``bindpw``: Bind password of the user. - -.. code-block:: yaml - - - hostname: master.ldap.test - role: ldap - config: - binddn: cn=Directory Manager - bindpw: Secret123 - -.. _config-providers-client: - -Provider specific client configuration -====================================== - -``client`` section of the additional configuration can specify SSSD options -required for the client to successfully connect to the provider. It is a list of -key-value pairs that represent options from ``sssd.conf``. These options are -automatically put into the client's ``sssd.conf`` when a domain is imported from -the role using :meth:`lib.sssd.utils.sssd.HostSSSD.import_domain`. - -.. seealso:: - - :ref:`importing-domain` - -.. code-block:: yaml - :caption: Client config example - - - hostname: master.ipa.test - role: ipa - config: - client: - ipa_domain: ipa.test - krb5_keytab: /enrollment/ipa.keytab - ldap_krb5_keytab: /enrollment/ipa.keytab - -The example above will add the given options to ``sssd.conf``, these are -required by the client to successfully connect to the IPA server. The keytab -paths are local paths on the client host. diff --git a/src/tests/system/docs/course/course.rst b/src/tests/system/docs/course/course.rst deleted file mode 100644 index e9b54bbb7b9..00000000000 --- a/src/tests/system/docs/course/course.rst +++ /dev/null @@ -1,1159 +0,0 @@ -Crash Course -############ - -This is a crash course for SSSD's test framework. The course consists of -multiple task that show the fundamental features and API. First, try to find -the solution for the task by yourself using the information present in the -documentation and inside the hints. Then display the task's solution and compare -it with yours. - -Prepare the environment -*********************** - -See :doc:`../running-tests` to se how to prepare the environment and run the tests. - -Is everything working? -====================== - -You should be ready to execute the tests, if you setup the environment -correctly. Go to the system tests directory (``src/tests/system``) of SSSD -repository and run the tests from this course with: - -.. code-blocK:: text - - $ pytest --mh-config=./mhc.yaml --mh-log-path=./log -v ./docs/course/test_course.py - -Take the Course -*************** - -You can begin by creating a file inside the ``tests`` directory, for example -``tests/test_course.py`` and include the following imports: - -.. code-block:: python - - import pytest - - from lib.sssd.topology import KnownTopology, KnownTopologyGroup - from lib.sssd.roles.ad import AD - from lib.sssd.roles.client import Client - from lib.sssd.roles.generic import GenericADProvider, GenericProvider - from lib.sssd.roles.ipa import IPA - from lib.sssd.roles.ldap import LDAP - from lib.sssd.roles.samba import Samba - -Now try to run the file with ``pytest``: - -.. code-block:: console - - pytest --mh-config=./mhc.yaml --mh-log-path=./log -v ./tests/test_course.py - -Does it work? Good. Now, you can continue with the following tasks. - -* Tasks 1 to 14 will teach you how to write some basic tests for LDAP. -* Tasks 15 - 26 requires you to write the same tests but for IPA. You will see - that it is pretty much the same except some differences in primary group - IPA - creates primary groups automatically. -* Tasks 26 - 31 are about topology parametrization - writing single test for - multiple backends. - -.. dropdown:: Task 1 - :color: secondary - :icon: checklist - - Write your first test for the LDAP topology. The test does not have to do - anything, just define it and make sure you can run it successfully. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :class:`lib.sssd.topology.KnownTopology` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_01 - :end-before: end:task_01 - -.. dropdown:: Task 2 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser``. - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_02 - :end-before: end:task_02 - -.. dropdown:: Task 3 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser`` with uid and gid set to ``10001``. - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name, uid, gid. - #. Also check that the primary group of the user does not exist. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology`` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_03 - :end-before: end:task_03 - -.. dropdown:: Task 4 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser`` with uid and gid set to ``10001``. - #. Add new LDAP group named ``tuser`` with gid set to ``10001``. - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name, uid, - primary group name and gid. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_04 - :end-before: end:task_04 - -.. dropdown:: Task 5 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser`` with uid and gid set to ``10001``. - #. Add new LDAP group named ``tuser`` with gid set to ``10001``. - #. Add new LDAP group named ``users`` with gid set to ``20001``. - #. Add user ``tuser`` as a member of group ``users`` - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name, uid, - primary group name and gid. - #. Check that the user is member of ``users`` - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_05 - :end-before: end:task_05 - - .. seealso:: - - The memberof method allows you to use multiple input types. Including - group name (string), group id (int) and list of names or ids. - -.. dropdown:: Task 6 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser`` with uid and gid set to ``10001``. - #. Add new LDAP group named ``tuser`` with gid set to ``10001``. - #. Add two LDAP groups named ``users`` and ``admins`` without any gid set. - #. Add user ``tuser`` as a member of groups ``users`` and ``admins`` - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name, uid, - primary group name and gid. - #. Check that the user is member of both ``users`` and ``admins`` - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_06 - :end-before: end:task_06 - - .. note:: - - If you omit uid or gid attribute on user or group then the id is - automatically generated by the framework. This is useful for cases where - the id is not important. - -.. dropdown:: Task 7 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser`` with password set to ``Secret123``. - #. Start SSSD on the client. - #. Test that the user can authenticate via ``su`` with the password. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_07 - :end-before: end:task_07 - - .. note:: - - The password parameter defaults to ``Secret123`` so it can be omitted. - However, it is a good practice to set it explicitly when you test - authentication to help understand the test case. - -.. dropdown:: Task 8 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser`` with password set to ``Secret123``. - #. Start SSSD on the client. - #. Test that the user can authenticate via ``ssh`` with the password. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_08 - :end-before: end:task_08 - -.. dropdown:: Task 9 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Parametrize a test case argument with two values: ``su`` and ``ssh`` - #. Add new LDAP user named ``tuser`` with password set to ``Secret123``. - #. Start SSSD on the client. - #. Test that the user can authenticate via ``su`` and ``ssh`` with the password, - use the parametrized value to determine which method should be used. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * `@pytest.mark.parametrize `__ - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_09 - :end-before: end:task_09 - - .. note:: - - This produces two test runs: one for ``su`` authentication and one for - ``ssh``. It is better to parametrize the test instead of calling both - ``su`` and ``ssh`` in one test run so you can test only one thing at a - time if you ever need to debug failure. - -.. dropdown:: Task 10 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser`` with password set to ``Secret123``. - #. Add new sudo rule to LDAP that allows the user to run ``/bin/ls`` on ``ALL`` - hosts. - #. Select ``sssd`` authselect profile with ``with-sudo`` enabled. - #. Enable sudo responder in SSSD. - #. Start SSSD on the client. - #. Check that ``tuser`` can run only ``/bin/ls`` command and only as ``root``. - #. Check that running ``/bin/ls`` through ``sudo`` actually works for ``tuser``. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :meth:`lib.sssd.utils.sssd.SSSDCommonConfiguration.sudo` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - * :class:`lib.sssd.utils.authselect.AuthselectUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_10 - :end-before: end:task_10 - - .. note:: - - You need to enable ``with-sudo`` using authselect so sudo can read rules - from SSSD. You can use :meth:`lib.sssd.utils.sssd.SSSDCommonConfiguration.sudo` - as a shortcut for selecting authselect profile and enabling the sudo responder. - -.. dropdown:: Task 11 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser``. - #. Add new sudo rule to LDAP that allows the user to run ``/bin/ls`` on ``ALL`` - hosts but without requiring authentication (nopasswd). - #. Select ``sssd`` authselect profile with ``with-sudo`` enabled. - #. Enable sudo responder in SSSD. - #. Start SSSD on the client. - #. Check that ``tuser`` can run only ``/bin/ls`` command without a password and only as ``root``. - #. Check that running ``/bin/ls`` through ``sudo`` actually works for ``tuser`` without a password. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :meth:`lib.sssd.utils.sssd.SSSDCommonConfiguration.sudo` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - * :class:`lib.sssd.utils.authselect.AuthselectUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_11 - :end-before: end:task_11 - -.. dropdown:: Task 12 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser``. - #. Set ``use_fully_qualified_names`` to ``true`` on the client. - #. Start SSSD on the client. - #. Check that ``tuser`` does not exist. - #. Check that ``tuser@test`` exists. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. code-block:: python - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_12 - :end-before: end:task_12 - - .. note:: - - Changes to the configuration are automatically applied when calling - ``client.sssd.start()``. You can override this behavior by calling - ``client.sssd.start(apply_config=False)``. - -.. dropdown:: Task 13 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser``. - #. Set ``use_fully_qualified_name`` to ``true`` on the client (intentionally - create a typo in the option name). - #. Start SSSD on the client. - #. Assert that an ``Exception`` was risen - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * `pytest.raises `__ - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_13 - :end-before: end:task_13 - - .. note:: - - Starting SSSD with ``client.sssd.start()`` automatically validates - configuration with ``sssctl config-check``. If the validation fails, it - raises an exception. You can override this behavior by calling - ``client.sssd.start(check_config=False)``. - -.. dropdown:: Task 14 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP topology. - #. Add new LDAP user named ``tuser`` with uid and gid set to ``10001``. - #. Add new LDAP group named ``tuser`` with gid set to ``10001``, use rfc2307bis schema. - #. Add two LDAP groups named ``users`` and ``admins`` without any gid set, use rfc2307bis schema. - #. Add user ``tuser`` as a member of groups ``users`` and ``admins`` - #. Set ``ldap_schema`` to ``rfc2307bis`` on the client - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name, uid, - primary group name and gid. - #. Check that the user is member of both ``users`` and ``admins`` - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_14 - :end-before: end:task_14 - -.. dropdown:: Task 15 - :color: secondary - :icon: checklist - - Write your first test for the IPA topology. The test does not have to do - anything, just define it and make sure you can run it successfully. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :class:`lib.sssd.topology.KnownTopology` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_15 - :end-before: end:task_15 - -.. dropdown:: Task 16 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser``. - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_16 - :end-before: end:task_16 - -.. dropdown:: Task 17 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser`` with uid and gid set to ``10001``. - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name, uid, - primary group name and gid. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_17 - :end-before: end:task_17 - - .. note:: - - Unlike LDAP, IPA creates the primary group automatically therefore we do - not have to add it ourselves. - -.. dropdown:: Task 18 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser`` with uid and gid set to ``10001``. - #. Add new IPA group named ``users`` with gid set to ``20001``. - #. Add user ``tuser`` as a member of group ``users`` - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name, uid, - primary group name and gid. - #. Check that the user is member of ``users`` - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_18 - :end-before: end:task_18 - -.. dropdown:: Task 19 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser`` with uid and gid set to ``10001``. - #. Add new IPA group named ``users`` without any gid set. - #. Create a group object for IPA group ``admins`` that already exist (it is created by IPA installation) - #. Add user ``tuser`` as a member of groups ``users`` and ``admins`` - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name, uid, - primary group name and gid. - #. Check that the user is member of both ``users`` and ``admins`` - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_19 - :end-before: end:task_19 - -.. dropdown:: Task 20 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser`` with password set to ``Secret123``. - #. Start SSSD on the client. - #. Test that the user can authenticate via ``su`` with the password. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_20 - :end-before: end:task_20 - -.. dropdown:: Task 21 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser`` with password set to ``Secret123``. - #. Start SSSD on the client. - #. Test that the user can authenticate via ``ssh`` with the password. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_21 - :end-before: end:task_21 - -.. dropdown:: Task 22 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Parametrize a test case argument with two values: ``su`` and ``ssh`` - #. Add new IPA user named ``tuser`` with password set to ``Secret123``. - #. Start SSSD on the client. - #. Test that the user can authenticate via ``su`` and ``ssh`` with the password, - use the parametrized value to determine which method should be used. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * `@pytest.mark.parametrize `__ - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_22 - :end-before: end:task_22 - -.. dropdown:: Task 23 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser`` with password set to ``Secret123``. - #. Add new sudo rule to IPA that allows the user to run ``/bin/ls`` on ``ALL`` - hosts. - #. Select ``sssd`` authselect profile with ``with-sudo`` enabled. - #. Enable sudo responder in SSSD. - #. Start SSSD on the client. - #. Check that ``tuser`` can run only ``/bin/ls`` command and only as ``root``. - #. Check that running ``/bin/ls`` through ``sudo`` actually works for ``tuser``. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :meth:`lib.sssd.utils.sssd.SSSDCommonConfiguration.sudo` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_23 - :end-before: end:task_23 - -.. dropdown:: Task 24 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser``. - #. Add new sudo rule to IPA that allows the user to run ``/bin/ls`` on ``ALL`` - hosts but without requiring authentication (nopasswd). - #. Select ``sssd`` authselect profile with ``with-sudo`` enabled. - #. Enable sudo responder in SSSD. - #. Start SSSD on the client. - #. Check that ``tuser`` can run only ``/bin/ls`` command without a password and only as ``root``. - #. Check that running ``/bin/ls`` through ``sudo`` actually works for ``tuser`` without a password. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :meth:`lib.sssd.utils.sssd.SSSDCommonConfiguration.sudo` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. code-block:: python - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_24 - :end-before: end:task_24 - -.. dropdown:: Task 25 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser``. - #. Set ``use_fully_qualified_names`` to ``true`` on the client. - #. Start SSSD on the client. - #. Check that ``tuser`` does not exist. - #. Check that ``tuser@test`` exists. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ipa.IPA` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_25 - :end-before: end:task_25 - -.. dropdown:: Task 26 - :color: secondary - :icon: checklist - - #. Create a new test for IPA topology. - #. Add new IPA user named ``tuser``. - #. Set ``use_fully_qualified_name`` to ``true`` on the client (intentionally - create a typo in the option name). - #. Start SSSD on the client. - #. Assert that an ``Exception`` was risen - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopology` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.ldap.LDAP` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_26 - :end-before: end:task_26 - -.. dropdown:: Task 27 - :color: secondary - :icon: checklist - - #. Create a new parametrized test for LDAP, IPA, Samba and AD topology. - #. Add new user named ``tuser``. - #. Add new groups ``tgroup_1`` and ``tgroup_2`` - #. Add the user ``tuser`` as a member of ``tgroup_1`` and ``tgroup_2`` - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name. - #. Check that the user is member of ``tgroup_1`` and ``tgroup_2`` - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopologyGroup` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.generic.GenericProvider` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_27 - :end-before: end:task_27 - - .. note:: - - We can write single test that can be run on multiple topologies. This is - achieved by using well-defined API that is implemented by all providers. - However, there are some distinctions that you need to be aware of - for - example LDAP does not create primary group automatically, IPA creates it - automatically and Samba and AD uses ``Domain Users`` as the primary - group. - -.. dropdown:: Task 28 - :color: secondary - :icon: checklist - - #. Create a new parametrized test for Samba and AD topology. - #. Add new user named ``tuser``. - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name. - #. Check that the user is member of ``domain users`` (Active Directory built-in group) - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopologyGroup` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.generic.GenericADProvider` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_28 - :end-before: end:task_28 - -.. dropdown:: Task 29 - :color: secondary - :icon: checklist - - #. Create a new parametrized test for LDAP and IPA topology. - #. Add new user named ``tuser`` with uid and gid set to ``10001``. - #. Create user's primary group object only if the topology is LDAP - #. Start SSSD on the client. - #. Run ``id`` command on the client - #. Check ``id`` result: check that the user exist and has correct name, uid, - primary group name and gid. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-identity` - * :class:`lib.sssd.topology.KnownTopologyGroup` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.generic.GenericProvider` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_29 - :end-before: end:task_29 - -.. dropdown:: Task 30 - :color: secondary - :icon: checklist - - #. Create a new test for LDAP, IPA, Samba and AD topology. - #. Add new user named ``tuser``. - #. Add new sudo rule ``defaults`` and set ``!authenticate`` option - #. Add new sudo rule to that ``ALL`` users on ``ALL`` hosts run ``ALL`` commands. - #. Select ``sssd`` authselect profile with ``with-sudo`` enabled. - #. Enable sudo responder in SSSD. - #. Start SSSD on the client. - #. Check that ``tuser`` can run ``ALL`` commands without a password but only as ``root``. - #. Check that running ``/bin/ls`` through ``sudo`` actually works for ``tuser`` without a password. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopologyGroup` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.generic.GenericProvider` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :meth:`lib.sssd.utils.sssd.SSSDCommonConfiguration.sudo` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_30 - :end-before: end:task_30 - -.. dropdown:: Task 31 - :color: secondary - :icon: checklist - - #. Create a new parametrized test for LDAP, IPA, Samba and AD topology. - #. Parametrize a test case argument with two values: ``su`` and ``ssh`` - #. Add new user named ``tuser`` with password set to ``Secret123``. - #. Start SSSD on the client. - #. Test that the user can authenticate via ``su`` and ``ssh`` with the password, - use the parametrized value to determine which method should be used. - - .. dropdown:: Display hints - :color: info - :icon: light-bulb - - * `@pytest.mark.parametrize `__ - * :doc:`../writing-tests` - * :doc:`../guides/testing-authentication` - * :class:`lib.sssd.topology.KnownTopologyGroup` - * :class:`lib.sssd.roles.base.BaseLinuxRole` - * :class:`lib.sssd.roles.generic.GenericProvider` - * :class:`lib.sssd.roles.client.Client` - * :class:`lib.sssd.utils.sssd.SSSDUtils` - * :class:`lib.sssd.utils.authentication.AuthenticationUtils` - - .. dropdown:: Display solution - :color: success - :icon: check-circle - - .. literalinclude:: ./test_course.py - :language: python - :start-after: start:task_31 - :end-before: end:task_31 diff --git a/src/tests/system/docs/course/test_course.py b/src/tests/system/docs/course/test_course.py deleted file mode 100644 index cc5d2725149..00000000000 --- a/src/tests/system/docs/course/test_course.py +++ /dev/null @@ -1,812 +0,0 @@ -from __future__ import annotations - -import pytest - -from lib.sssd.roles.client import Client -from lib.sssd.roles.generic import GenericADProvider, GenericProvider -from lib.sssd.roles.ipa import IPA -from lib.sssd.roles.ldap import LDAP -from lib.sssd.topology import KnownTopology, KnownTopologyGroup - - -# start:task_01 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__skeleton(client: Client, ldap: LDAP): - """ - :title: Pytest-mh test skeleton for the LDAP topology that does nothing - :customerscenario: False - """ - pass - # end:task_01 - - -# start:task_02 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__id__name(client: Client, ldap: LDAP): - """ - :title: LDAP: Calling "id user" yields the expected user - :setup: - 1. Add LDAP user "tuser" - 2. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned - :customerscenario: False - """ - ldap.user("tuser").add() - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - # end:task_02 - - -# start:task_03 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__id__name_and_id(client: Client, ldap: LDAP): - """ - :title: LDAP: Calling "id user" yields the expected user, uid and gid - :setup: - 1. Add LDAP user "tuser" with uid=10001, gid=10001 - 2. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned, uid is 10001, gid is 10001 - :customerscenario: False - """ - ldap.user("tuser").add(uid=10001, gid=10001) - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.user.id == 10001 - assert result.group.name is None - assert result.group.id == 10001 - # end:task_03 - - -# start:task_04 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__id__primary_group(client: Client, ldap: LDAP): - """ - :title: LDAP: Calling "id user" yields the expected user and expected primary group - :setup: - 1. Add LDAP user "tuser" with uid=10001, gid=10001 - 2. Add LDAP group "tuser" with gid 10001 - 3. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned, user and group name is "tuser", uid and gid is 10001 - :customerscenario: False - """ - ldap.user("tuser").add(uid=10001, gid=10001) - ldap.group("tuser").add(gid=10001) - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.user.id == 10001 - assert result.group.name == "tuser" - assert result.group.id == 10001 - # end:task_04 - - -# start:task_05 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__id__one_supplementary_group(client: Client, ldap: LDAP): - """ - :title: LDAP: Calling "id user" yields the expected user and one expected supplementary groups - :setup: - 1. Add LDAP user "tuser" with uid=10001, gid=10001 - 2. Add LDAP group "tuser" with gid 10001 - 3. Add LDAP group "users" with gid 20001 - 4. Make user "tuser" member of "users" - 5. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned and the user is member of "users" - :customerscenario: False - """ - u = ldap.user("tuser").add(uid=10001, gid=10001) - ldap.group("tuser").add(gid=10001) - ldap.group("users").add(gid=20001).add_member(u) - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.user.id == 10001 - assert result.group.name == "tuser" - assert result.group.id == 10001 - assert result.memberof("users") - # end:task_05 - - -# start:task_06 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__id__two_supplementary_groups(client: Client, ldap: LDAP): - """ - :title: LDAP: Calling "id user" yields the expected user and two expected supplementary groups - :setup: - 1. Add LDAP user "tuser" with uid=10001, gid=10001 - 2. Add LDAP group "tuser" with gid 10001 - 3. Add LDAP group "users" - 4. Add LDAP group "admins" - 5. Make user "tuser" member of "users" and "admins" - 6. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned and the user is member of "users" and "admins" - :customerscenario: False - """ - u = ldap.user("tuser").add(uid=10001, gid=10001) - ldap.group("tuser").add(gid=10001) - ldap.group("users").add().add_member(u) - ldap.group("admins").add().add_member(u) - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.user.id == 10001 - assert result.group.name == "tuser" - assert result.group.id == 10001 - assert result.memberof(["users", "admins"]) - # end:task_06 - - -# start:task_07 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__su(client: Client, ldap: LDAP): - """ - :title: LDAP: Authenticate user with password using "su" - :setup: - 1. Add LDAP user "tuser" with password "Secret123" - 2. Start SSSD - :steps: - 1. Run "su tuser" with correct password - :expectedresults: - 1. Authentication is successful - :customerscenario: False - """ - ldap.user("tuser").add(password="Secret123") - - client.sssd.start() - assert client.auth.su.password("tuser", "Secret123") - # end:task_07 - - -# start:task_08 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__ssh(client: Client, ldap: LDAP): - """ - :title: LDAP: Authenticate user with password using "ssh" - :setup: - 1. Add LDAP user "tuser" with password "Secret123" - 2. Start SSSD - :steps: - 1. Run "ssh tuser@localhost" with correct password - :expectedresults: - 1. Authentication is successful - :customerscenario: False - """ - ldap.user("tuser").add(password="Secret123") - - client.sssd.start() - assert client.auth.ssh.password("tuser", "Secret123") - # end:task_08 - - -# start:task_09 -@pytest.mark.topology(KnownTopology.LDAP) -@pytest.mark.parametrize("method", ["su", "ssh"]) -def test_ldap__password_authentication(client: Client, ldap: LDAP, method: str): - """ - :title: LDAP: Authenticate user with password using "@method" - :setup: - 1. Add LDAP user "tuser" with password "Secret123" - 2. Start SSSD - :steps: - 1. Try authenticate the user with password using @method - :expectedresults: - 1. Authentication is successful - :customerscenario: False - """ - ldap.user("tuser").add(password="Secret123") - - client.sssd.start() - assert client.auth.parametrize(method).password("tuser", "Secret123") - # end:task_09 - - -# start:task_10 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__sudo__passwd(client: Client, ldap: LDAP): - """ - :title: LDAP: User can run command via sudo when authentication is required - :setup: - 1. Add LDAP user "tuser" with password "Secret123" - 2. Create sudo rule that allows the user to run "/bin/ls" as root on all hosts - 3. Enable SSSD sudo responder and configure sudo to use SSSD - 4. Start SSSD - :steps: - 1. Login as "tuser" and run "sudo -l" with user password - 2. Login as "tuser" and run "sudo /bin/ls /root" with user password - :expectedresults: - 1. The created rule is listed in the output - 2. The command run successfully as root - :customerscenario: False - """ - u = ldap.user("tuser").add(password="Secret123") - ldap.sudorule("allow_ls").add(user=u, host="ALL", command="/bin/ls") - - client.sssd.common.sudo() - client.sssd.start() - - assert client.auth.sudo.list("tuser", "Secret123", expected=["(root) /bin/ls"]) - assert client.auth.sudo.run("tuser", "Secret123", command="/bin/ls /root") - # end:task_10 - - -# start:task_11 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__sudo__nopasswd(client: Client, ldap: LDAP): - """ - :title: LDAP: User can run command via sudo when authentication is not required - :setup: - 1. Add LDAP user "tuser" - 2. Create sudo rule that allows the user to run "/bin/ls" as root on all hosts, !authenticate option is set - 3. Enable SSSD sudo responder and configure sudo to use SSSD - 4. Start SSSD - :steps: - 1. Login as "tuser" and run "sudo -l", no password is provided - 2. Login as "tuser" and run "sudo /bin/ls /root", no password is provided - :expectedresults: - 1. The created rule is listed in the output - 2. The command run successfully as root - :customerscenario: False - """ - u = ldap.user("tuser").add() - ldap.sudorule("allow_ls").add(user=u, host="ALL", command="/bin/ls", nopasswd=True) - - client.sssd.common.sudo() - client.sssd.start() - - assert client.auth.sudo.list("tuser", expected=["(root) NOPASSWD: /bin/ls"]) - assert client.auth.sudo.run("tuser", command="/bin/ls /root") - # end:task_11 - - -# start:task_12 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__id__required_fqn(client: Client, ldap: LDAP): - """ - :title: LDAP: Calling "id user@domain" yields the expected user - :setup: - 1. Add LDAP user "tuser" - 2. Set use_fully_qualified_names to true for the LDAP domain - 3. Start SSSD - :steps: - 1. Run "id tuser@domain" - :expectedresults: - 1. "tuser" is returned - :customerscenario: False - """ - ldap.user("tuser").add() - - client.sssd.domain["use_fully_qualified_names"] = "true" - client.sssd.start() - - assert client.tools.id("tuser") is None - assert client.tools.id("tuser@test") is not None - # end:task_12 - - -# start:task_13 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__config_typo(client: Client, ldap: LDAP): - """ - :title: LDAP: Typo in SSSD option makes sssctl config-check fail - :setup: - 1. Add LDAP user "tuser" - 2. Set use_fully_qualified_name (not _names) to true for the LDAP domain - :steps: - 1. Run sssctl config-check - :expectedresults: - 1. The command fails - :customerscenario: False - - Note that client.sssd.start() calls sssctl config-check prior starting SSSD. - """ - ldap.user("tuser").add() - - with pytest.raises(Exception): - client.sssd.domain["use_fully_qualified_name"] = "true" - client.sssd.start() - # end:task_13 - - -# start:task_14 -@pytest.mark.topology(KnownTopology.LDAP) -def test_ldap__id__rfc2307bis(client: Client, ldap: LDAP): - """ - :title: LDAP: Calling "id user" yields the expected user and groups with rfc2307bis schema - :setup: - 1. Add LDAP user "tuser" with uid=10001, gid=10001 - 2. Add LDAP group "tuser" with gid 10001 using rfc2307bis schema - 3. Add LDAP group "users" using rfc2307bis schema - 4. Add LDAP group "admins" using rfc2307bis schema - 5. Make user "tuser" member of "users" and "admins" - 6. Set ldap_schema = rfc2307bis for the LDAP domain - 7. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned and the user is member of "users" and "admins" - :customerscenario: False - """ - u = ldap.user("tuser").add(uid=10001, gid=10001) - ldap.group("tuser", rfc2307bis=True).add(gid=10001) - ldap.group("users", rfc2307bis=True).add().add_member(u) - ldap.group("admins", rfc2307bis=True).add().add_member(u) - - client.sssd.domain["ldap_schema"] = "rfc2307bis" - client.sssd.start() - - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.user.id == 10001 - assert result.group.name == "tuser" - assert result.group.id == 10001 - assert result.memberof(["users", "admins"]) - # end:task_14 - - -# start:task_15 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__skeleton(client: Client, ipa: IPA): - """ - :title: Pytest-mh test skeleton for the IPA topology that does nothing - :customerscenario: False - """ - pass - # end:task_15 - - -# start:task_16 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__id__name(client: Client, ipa: IPA): - """ - :title: IPA: Calling "id user" yields the expected user - :setup: - 1. Add IPA user "tuser" - 2. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned - :customerscenario: False - """ - ipa.user("tuser").add() - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - # end:task_16 - - -# start:task_17 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__id__primary_group(client: Client, ipa: IPA): - """ - :title: IPA: Calling "id user" yields the expected user and primary group - :setup: - 1. Add IPA user "tuser" with uid=10001, gid=10001 - 2. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned, uid is 10001, gid is 10001 - :customerscenario: False - """ - ipa.user("tuser").add(uid=10001, gid=10001) - - # Primary group is created automatically, we need to skip this step - # ipa.group('tuser').add(gid=10001) - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.user.id == 10001 - assert result.group.name == "tuser" - assert result.group.id == 10001 - # end:task_17 - - -# start:task_18 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__id__one_supplementary_group(client: Client, ipa: IPA): - """ - :title: IPA: Calling "id user" yields the expected user and one expected supplementary groups - :setup: - 1. Add IPA user "tuser" with uid=10001, gid=10001 - 2. Add IPA group "users" with gid 20001 - 3. Make user "tuser" member of "users" - 4. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned and the user is member of "users" - :customerscenario: False - """ - u = ipa.user("tuser").add(uid=10001, gid=10001) - # Primary group is created automatically, we need to skip this step - # ipa.group('tuser').add(gid=10001) - ipa.group("users").add(gid=20001).add_member(u) - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.user.id == 10001 - assert result.group.name == "tuser" - assert result.group.id == 10001 - assert result.memberof("users") - # end:task_18 - - -# start:task_19 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__id__two_supplementary_groups(client: Client, ipa: IPA): - """ - :title: IPA: Calling "id user" yields the expected user and two expected supplementary groups - :setup: - 1. Add IPA user "tuser" with uid=10001, gid=10001 - 2. Add IPA group "users" - 3. Make user "tuser" member of "users" and "admins" - 4. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned and the user is member of "users" and "admins" - :customerscenario: False - """ - u = ipa.user("tuser").add(uid=10001, gid=10001) - # Primary group is created automatically, we need to skip this step - # ipa.group('tuser').add(gid=10001) - ipa.group("users").add().add_member(u) - # Group admins is already present in IPA so we just omit add() and use add_member() only - # ipa.group('admins').add().add_member(u) - ipa.group("admins").add_member(u) - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.user.id == 10001 - assert result.group.name == "tuser" - assert result.group.id == 10001 - assert result.memberof(["users", "admins"]) - # end:task_19 - - -# start:task_20 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__su(client: Client, ipa: IPA): - """ - :title: IPA: Authenticate user with password using "su" - :setup: - 1. Add IPA user "tuser" with password "Secret123" - 2. Start SSSD - :steps: - 1. Run "su tuser" with correct password - :expectedresults: - 1. Authentication is successful - :customerscenario: False - """ - ipa.user("tuser").add(password="Secret123") - - client.sssd.start() - assert client.auth.su.password("tuser", "Secret123") - # end:task_20 - - -# start:task_21 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__ssh(client: Client, ipa: IPA): - """ - :title: IPA: Authenticate user with password using "ssh" - :setup: - 1. Add IPA user "tuser" with password "Secret123" - 2. Start SSSD - :steps: - 1. Run "ssh tuser@localhost" with correct password - :expectedresults: - 1. Authentication is successful - :customerscenario: False - """ - ipa.user("tuser").add(password="Secret123") - - client.sssd.start() - assert client.auth.ssh.password("tuser", "Secret123") - # end:task_21 - - -# start:task_22 -@pytest.mark.topology(KnownTopology.IPA) -@pytest.mark.parametrize("method", ["su", "ssh"]) -def test_ipa__password_authentication(client: Client, ipa: IPA, method: str): - """ - :title: IPA: Authenticate user with password using "@method" - :setup: - 1. Add IPA user "tuser" with password "Secret123" - 2. Start SSSD - :steps: - 1. Try authenticate the user with password using @method - :expectedresults: - 1. Authentication is successful - :customerscenario: False - """ - ipa.user("tuser").add(password="Secret123") - - client.sssd.start() - assert client.auth.parametrize(method).password("tuser", "Secret123") - # end:task_22 - - -# start:task_23 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__sudo__passwd(client: Client, ipa: IPA): - """ - :title: IPA: User can run command via sudo when authentication is required - :setup: - 1. Add IPA user "tuser" with password "Secret123" - 2. Create sudo rule that allows the user to run "/bin/ls" as root on all hosts - 3. Enable SSSD sudo responder and configure sudo to use SSSD - 4. Start SSSD - :steps: - 1. Login as "tuser" and run "sudo -l" with user password - 2. Login as "tuser" and run "sudo /bin/ls /root" with user password - :expectedresults: - 1. The created rule is listed in the output - 2. The command run successfully as root - :customerscenario: False - """ - u = ipa.user("tuser").add(password="Secret123") - ipa.sudorule("allow_ls").add(user=u, host="ALL", command="/bin/ls") - - client.sssd.common.sudo() - client.sssd.start() - - assert client.auth.sudo.list("tuser", "Secret123", expected=["(root) /bin/ls"]) - assert client.auth.sudo.run("tuser", "Secret123", command="/bin/ls /root") - # end:task_23 - - -# start:task_24 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__sudo__nopasswd(client: Client, ipa: IPA): - """ - :title: IPA: User can run command via sudo when authentication is not required - :setup: - 1. Add IPA user "tuser" - 2. Create sudo rule that allows the user to run "/bin/ls" as root on all hosts, !authenticate option is set - 3. Enable SSSD sudo responder and configure sudo to use SSSD - 4. Start SSSD - :steps: - 1. Login as "tuser" and run "sudo -l", no password is provided - 2. Login as "tuser" and run "sudo /bin/ls /root", no password is provided - :expectedresults: - 1. The created rule is listed in the output - 2. The command run successfully as root - :customerscenario: False - """ - u = ipa.user("tuser").add() - ipa.sudorule("allow_ls").add(user=u, host="ALL", command="/bin/ls", nopasswd=True) - - client.sssd.common.sudo() - client.sssd.start() - - assert client.auth.sudo.list("tuser", expected=["(root) NOPASSWD: /bin/ls"]) - assert client.auth.sudo.run("tuser", command="/bin/ls /root") - # end:task_24 - - -# start:task_25 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__id__required_fqn(client: Client, ipa: IPA): - """ - :title: IPA: Calling "id user@domain" yields the expected user - :setup: - 1. Add IPA user "tuser" - 2. Set use_fully_qualified_names to true for the IPA domain - 3. Start SSSD - :steps: - 1. Run "id tuser@domain" - :expectedresults: - 1. "tuser" is returned - :customerscenario: False - """ - ipa.user("tuser").add() - - client.sssd.domain["use_fully_qualified_names"] = "true" - client.sssd.start() - - assert client.tools.id("tuser") is None - assert client.tools.id("tuser@test") is not None - # end:task_25 - - -# start:task_26 -@pytest.mark.topology(KnownTopology.IPA) -def test_ipa__config_typo(client: Client, ipa: IPA): - """ - :title: IPA: Typo in SSSD option makes sssctl config-check fail - :setup: - 1. Add IPA user "tuser" - 2. Set use_fully_qualified_name (not _names) to true for the IPA domain - :steps: - 1. Run sssctl config-check - :expectedresults: - 1. The command fails - :customerscenario: False - - Note that client.sssd.start() calls sssctl config-check prior starting SSSD. - """ - ipa.user("tuser").add() - - with pytest.raises(Exception): - client.sssd.domain["use_fully_qualified_name"] = "true" - client.sssd.start() - # end:task_26 - - -# start:task_27 -@pytest.mark.topology(KnownTopologyGroup.AnyProvider) -def test_id__supplementary_groups(client: Client, provider: GenericProvider): - """ - :title: Calling "id user" yields the expected user and supplementary groups - :setup: - 1. Add user "tuser" - 2. Add group "tgroup_1" - 3. Add group "tgroup_2" - 4. Make user "tuser" member of "tgroup_1" and "tgroup_2" - 5. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned and the user is member of "tgroup_1" and "tgroup_2" - :customerscenario: False - """ - u = provider.user("tuser").add() - provider.group("tgroup_1").add().add_member(u) - provider.group("tgroup_2").add().add_member(u) - - client.sssd.start() - result = client.tools.id("tuser") - - assert result is not None - assert result.user.name == "tuser" - assert result.memberof(["tgroup_1", "tgroup_2"]) - # end:task_27 - - -# start:task_28 -@pytest.mark.topology(KnownTopologyGroup.AnyAD) -def test_ad__id__domain_users(client: Client, provider: GenericADProvider): - """ - :title: AD: The primary user group is "Domain Users" - :setup: - 1. Add user "tuser" - 2. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned and the primary group is "Domain Users" (case insensitive) - :customerscenario: False - """ - provider.user("tuser").add() - - client.sssd.start() - - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.group.name is not None - assert result.group.name.lower() == "domain users" - # end:task_28 - - -# start:task_29 -@pytest.mark.topology(KnownTopology.LDAP) -@pytest.mark.topology(KnownTopology.IPA) -def test_id__primary_group(client: Client, provider: GenericProvider): - """ - :title: Calling "id user" yields the expected user and expected primary group - :setup: - 1. Add user "tuser" with uid=10001, gid=10001 - 2. Add group "tuser" with gid 10001 - 3. Start SSSD - :steps: - 1. Run "id tuser" - :expectedresults: - 1. "tuser" is returned, user and group name is "tuser", uid and gid is 10001 - :customerscenario: False - """ - provider.user("tuser").add(uid=10001, gid=10001) - - if isinstance(provider, LDAP): - provider.group("tuser").add(gid=10001) - - client.sssd.start() - result = client.tools.id("tuser") - assert result is not None - assert result.user.name == "tuser" - assert result.user.id == 10001 - assert result.group.name == "tuser" - assert result.group.id == 10001 - # end:task_29 - - -# start:task_30 -@pytest.mark.topology(KnownTopologyGroup.AnyProvider) -def test_sudo__defaults_nopasswd(client: Client, provider: GenericProvider): - """ - :title: User can run command via sudo when authentication is not required in defaults - :setup: - 1. Add user "tuser" - 2. Create sudo rule "defaults" with sudoOption set to !authenticate - 3. Create sudo rule that allows the all users to run all commands as root on all hosts - 4. Enable SSSD sudo responder and configure sudo to use SSSD - 5. Start SSSD - :steps: - 1. Login as "tuser" and run "sudo -l", no password is provided - 2. Login as "tuser" and run "sudo /bin/ls /root", no password is provided - :expectedresults: - 1. The created rule is listed in the output - 2. The command run successfully as root - :customerscenario: False - """ - provider.user("tuser").add() - provider.sudorule("defaults").add(nopasswd=True) - provider.sudorule("allow_all").add(user="ALL", host="ALL", command="ALL") - - client.sssd.common.sudo() - client.sssd.start() - - assert client.auth.sudo.list("tuser", expected=["(root) ALL"]) - assert client.auth.sudo.run("tuser", command="/bin/ls /root") - # end:task_30 - - -# start:task_31 -@pytest.mark.topology(KnownTopologyGroup.AnyProvider) -@pytest.mark.parametrize("method", ["su", "ssh"]) -def test_authentication__password(client: Client, provider: GenericProvider, method: str): - """ - :title: Authenticate user with password using "@method" - :setup: - 1. Add user "tuser" with password "Secret123" - 2. Start SSSD - :steps: - 1. Try authenticate the user with password using @method - :expectedresults: - 1. Authentication is successful - :customerscenario: False - """ - provider.user("tuser").add(password="Secret123") - - client.sssd.start() - assert client.auth.parametrize(method).password("tuser", "Secret123") - # end:task_31 diff --git a/src/tests/system/docs/extensions/directives/TopologyMark.py b/src/tests/system/docs/extensions/directives/TopologyMark.py deleted file mode 100644 index 7f92103ebb2..00000000000 --- a/src/tests/system/docs/extensions/directives/TopologyMark.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import yaml -from pytest_mh import TopologyMark -from sphinx.directives.code import CodeBlock - -from lib.sssd.topology import KnownTopology, KnownTopologyGroup - - -class TopologyMarkDirective(CodeBlock): - """ - Convert :class:`TopologyMark` into yaml and wrap it in code-block directive. - """ - - def run(self): - obj = eval(self.arguments[0]) - if isinstance(obj, KnownTopology): - self.content = self.export(obj.value) - elif isinstance(obj, KnownTopologyGroup): - out = [] - for known_topology in obj.value: - out += self.export(known_topology.value) + [""] - self.content = out - elif isinstance(obj, TopologyMark): - self.content = self.export(obj) - else: - raise ValueError(f"Invalid argument: {self.arguments[0]}") - - # Set language - self.arguments[0] = "yaml" - - return super().run() - - def export(self, x: TopologyMark) -> list[str]: - return yaml.dump(x.export(), sort_keys=False).splitlines() - - -def setup(app): - app.add_directive("topology-mark", TopologyMarkDirective) - - return { - "version": "0.1", - "parallel_read_safe": True, - "parallel_write_safe": True, - } diff --git a/src/tests/system/docs/guides/index.rst b/src/tests/system/docs/guides/index.rst deleted file mode 100644 index f6416e895e4..00000000000 --- a/src/tests/system/docs/guides/index.rst +++ /dev/null @@ -1,12 +0,0 @@ -How to guides -############# - -.. toctree:: - - using-roles - ssh-client - testing-authentication - testing-autofs - testing-identity - testing-ldap-krb5 - local-users diff --git a/src/tests/system/docs/guides/local-users.rst b/src/tests/system/docs/guides/local-users.rst deleted file mode 100644 index 8c68d00d5b4..00000000000 --- a/src/tests/system/docs/guides/local-users.rst +++ /dev/null @@ -1,24 +0,0 @@ -Local users and groups -###################### - -Class :class:`lib.sssd.utils.local_users.LocalUsersUtils` provides API to -manage local users and groups. It shares the same generic API that is used -across provider roles such as LDAP or IPA, so it can be used in the same way. It -is available from the client role as -:attr:`lib.sssd.roles.client.Client.local`. - -All users and groups that are created during the test are automatically deleted. - -.. code-block:: python - :caption: Examples - - @pytest.mark.topology(KnownTopology.Client) - def test_local_users(client: Client): - u = client.local.user('tuser').add() - g = client.local.group('tgroup').add() - g.add_member(u) - - result = client.tools.id('tuser') - assert result is not None - assert result.user.name == 'tuser' - assert result.memberof('tgroup') diff --git a/src/tests/system/docs/guides/ssh-client.rst b/src/tests/system/docs/guides/ssh-client.rst deleted file mode 100644 index f608661e2e8..00000000000 --- a/src/tests/system/docs/guides/ssh-client.rst +++ /dev/null @@ -1,56 +0,0 @@ -Connecting to host via SSH -########################## - -You can use :class:`pytest_mh.ssh.SSHClient` to connect to any host as any -user. It is not recommended to instantiate this class on yourself but you should -rather use :meth:`pytest_mh.MultihostRole.ssh` to get the client -object. - -Once you establish SSH connections, you can run commands on the remote host in -both blocking and non-blocking mode. - -.. code-block:: python - :caption: Example calls - - @pytest.mark.topology(KnownTopology.Client) - def test_ssh_client(client: Client): - # Establish connection as user 'ci' with given password - with client.ssh('ci', 'Secret123') as ssh: - # Run command - result = ssh.run('echo "Hello World"') - assert result.stdout == 'Hello World' - - # Run multiline commands - result = ssh.run(''' - echo "Hello" - echo "World" - ''') - assert result.stdout_lines == ['Hello', 'World'] - - # Provide custom environment - result = ssh.run('echo $TEST', env={'TEST': 'Hello World'}) - assert result.stdout == 'Hello World' - - # Provide input - result = ssh.run('cat', input='Hello World') - assert result.stdout == 'Hello World' - - # Set working directory - result = ssh.run('pwd', cwd='/') - assert result.stdout == '/' - - # Run exec-style arguments - result = ssh.exec(['echo', 'Hello World']) - assert result.stdout == 'Hello World' - - # Run non-blocking commands - process = ssh.async_run('echo "Non-blocking Hello World"') - result = process.wait() - assert result.stdout == 'Non-blocking Hello World' - - # Interact more, process.wait() is called automatically - with ssh.async_run('bash') as process: - process.stdin.write('echo Hello\n') - assert next(process.stdout) == 'Hello' - process.stdin.write('echo World\n') - assert next(process.stdout) == 'World' diff --git a/src/tests/system/docs/guides/testing-authentication.rst b/src/tests/system/docs/guides/testing-authentication.rst deleted file mode 100644 index 6672dfe2539..00000000000 --- a/src/tests/system/docs/guides/testing-authentication.rst +++ /dev/null @@ -1,73 +0,0 @@ -Testing authentication and sudo -############################### - -Class :class:`lib.sssd.utils.authentication.AuthenticationUtils` provides access -to su, ssh and sudo commands which can be used to test user authentication via -various channels. The class can be accessed from the ``client`` fixture as -``client.auth``. - -.. code-block:: python - :caption: Test authentication via su - - @pytest.mark.topology(KnownTopology.LDAP) - def test_su(client: Client, ldap: LDAP): - ldap.user('test').add(password="Secret123") - client.sssd.start() - - assert client.auth.su.password('test', 'Secret123') - -.. code-block:: python - :caption: Test authentication via ssh - - @pytest.mark.topology(KnownTopology.LDAP) - def test_ssh(client: Client, ldap: LDAP): - ldap.user('test').add(password="Secret123") - client.sssd.start() - - assert client.auth.ssh.password('test', 'Secret123') - -.. note:: - - Since su and ssh shares the same interface, it is also possible to write a - parametrized test for both authentication methods. - - .. code-block:: python - - @pytest.mark.topology(KnownTopology.LDAP) - @pytest.mark.parametrize('method', ['su', 'ssh']) - def test_auth(client: Client, ldap: LDAP, method: str): - ldap.user('test').add(password="Secret123") - - client.sssd.start() - assert client.auth.parametrize(method).password('test', 'Secret123') - -.. code-block:: python - :caption: Test sudo -l - - @pytest.mark.topology(KnownTopology.LDAP) - def test_sudo_list(client: Client, ldap: LDAP): - u = ldap.user('test').add(password="Secret123") - ldap.sudorule('testrule').add(user=u, host='ALL', command='/bin/ls') - - client.sssd.common.sudo() - client.sssd.start() - - # Test that user can run sudo - assert client.auth.sudo.list(u.name, 'Secret123') - - # Test that user can run particular commands - assert client.auth.sudo.list(u.name, 'Secret123', expected=['(root) /bin/ls']) - -.. code-block:: python - :caption: Test sudo run without password - - @pytest.mark.topology(KnownTopology.LDAP) - def test_sudo_list(client: Client, ldap: LDAP): - u = ldap.user('test').add(password="Secret123") - ldap.sudorule('testrule').add(user=u, host='ALL', command='/bin/ls', nopasswd=True) - - client.sssd.common.sudo() - client.sssd.start() - - # Test that user can run /bin/ls without additional authentication - assert client.auth.sudo.run('test', command='/bin/ls') diff --git a/src/tests/system/docs/guides/testing-autofs.rst b/src/tests/system/docs/guides/testing-autofs.rst deleted file mode 100644 index d57e7c1b437..00000000000 --- a/src/tests/system/docs/guides/testing-autofs.rst +++ /dev/null @@ -1,120 +0,0 @@ -Testing autofs / automount -########################## - -Class :class:`lib.sssd.utils.automount.AutomountUtils` provides API that can be -used to test autofs / automount functionality. It can test that the mount point -works correctly and also that all defined maps where correctly read. - -You can use ``nfs`` role (:class:`lib.sssd.roles.nfs.NFS`) in order to test -automount correctly. This role allows you to export directories over NFS. -Additionally, all provider roles provides generic API to the automount through -their ``automount`` field: - -* :attr:`lib.sssd.roles.ldap.LDAP.automount` -* :attr:`lib.sssd.roles.ipa.IPA.automount` -* :attr:`lib.sssd.roles.samba.Samba.automount` -* :attr:`lib.sssd.roles.ad.AD.automount` - -.. note:: - - To access the nfs role, you need to add additional hostname to the - ``mhc.yaml`` multihost configuration. For example: - - .. code-block:: yaml - - - hostname: nfs.test - role: nfs - config: - exports_dir: /dev/shm/exports - - ``exports_dir`` is the location where all directories exported through - :meth:`lib.sssd.roles.nfs.NFS.export` will be created. - -.. code-block:: python - :caption: automount test example - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_automount(client: Client, provider: GenericProvider, nfs: NFS): - nfs_export1 = nfs.export('export1').add() - nfs_export2 = nfs.export('export2').add() - nfs_export3 = nfs.export('sub/export3').add() - - # Create automount maps - auto_master = provider.automount.map('auto.master').add() - auto_home = provider.automount.map('auto.home').add() - auto_sub = provider.automount.map('auto.sub').add() - - # Create mount points - auto_master.key('/ehome').add(info=auto_home) - auto_master.key('/esub/sub1/sub2').add(info=auto_sub) - - # Create mount keys - key1 = auto_home.key('export1').add(info=nfs_export1) - key2 = auto_home.key('export2').add(info=nfs_export2) - key3 = auto_sub.key('export3').add(info=nfs_export3) - - # Start SSSD - client.sssd.common.autofs() - client.sssd.start() - - # Reload automounter in order fetch updated maps - client.automount.reload() - - # Check that we can mount all directories on correct locations - assert client.automount.mount('/ehome/export1', nfs_export1) - assert client.automount.mount('/ehome/export2', nfs_export2) - assert client.automount.mount('/esub/sub1/sub2/export3', nfs_export3) - - # Check that the maps are correctly fetched - assert client.automount.dumpmaps() == { - '/ehome': { - 'map': 'auto.home', - 'keys': [str(key1), str(key2)] - }, - '/esub/sub1/sub2': { - 'map': 'auto.sub', - 'keys': [str(key3)] - }, - } - -.. code-block:: python - :caption: Testing IPA autofs locations - - @pytest.mark.topology(KnownTopology.IPA) - def test_ipa_autofs_location(client: Client, ipa: IPA, nfs: NFS): - nfs_export1 = nfs.export('export1').add() - nfs_export2 = nfs.export('export2').add() - - # Create new automount location - boston = ipa.automount.location('boston').add() - - # Create automount maps - auto_master = boston.map('auto.master').add() - auto_home = boston.map('auto.home').add() - - # Create mount points - auto_master.key('/ehome').add(info=auto_home) - - # Create mount keys - key1 = auto_home.key('export1').add(info=nfs_export1) - key2 = auto_home.key('export2').add(info=nfs_export2) - - # Start SSSD - client.sssd.common.autofs() - client.sssd.domain['ipa_automount_location'] = 'boston' - client.sssd.start() - - # Reload automounter in order fetch updated maps - client.automount.reload() - - # Check that we can mount all directories on correct locations - assert client.automount.mount('/ehome/export1', nfs_export1) - assert client.automount.mount('/ehome/export2', nfs_export2) - - # Check that the maps are correctly fetched - assert client.automount.dumpmaps() == { - '/ehome': { - 'map': 'auto.home', - 'keys': [str(key1), str(key2)] - }, - } diff --git a/src/tests/system/docs/guides/testing-identity.rst b/src/tests/system/docs/guides/testing-identity.rst deleted file mode 100644 index 2da2a00154b..00000000000 --- a/src/tests/system/docs/guides/testing-identity.rst +++ /dev/null @@ -1,63 +0,0 @@ -Testing identity -################ - -Class :class:`lib.sssd.utils.tools.LinuxToolsUtils` provides access to common -system tools, especially the ``id`` and ``getent`` commands which can be used to -assert identity information returned from SSSD. The class can be accessed from -the ``client`` fixture as ``client.tools``. - -.. code-block:: python - :caption: id command example - - @pytest.mark.topology(KnownTopology.LDAP) - def test_id(client: Client, ldap: LDAP): - # Create user - user = ldap.user('user-1').add(uid=10001, gid=10001, password='Secret123') - - # Create group - group = ldap.group('group-1').add(gid=20001) - group.add_member(user) - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.user.id == 10001 - assert result.group.id == 10001 # primary group - assert result.group.name is None - assert result.memberof('group-1') - -.. code-block:: python - :caption: getent command example - - @pytest.mark.topology(KnownTopology.LDAP) - def test_getent(client: Client, ldap: LDAP): - # Create user - user = ldap.user('user-1').add(uid=10001, gid=10001, password='Secret123', shell='/bin/sh') - - # Create group - group = ldap.group('group-1').add(gid=20001) - group.add_member(user) - - # Start SSSD - client.sssd.start() - - # Call `getent passwd user-1` and assert the result - result = client.tools.getent.passwd('user-1') - assert result is not None - assert result.name == 'user-1' - assert result.uid == 10001 - assert result.gid == 10001 - assert result.home == '/home/user-1' - assert result.shell == '/bin/sh' - assert result.gecos is None - - # Call `getent group group-1` and assert the result - result = client.tools.getent.group('group-1') - assert result is not None - assert result.name == 'group-1' - assert result.gid == 20001 - assert result.members == ['user-1'] diff --git a/src/tests/system/docs/guides/testing-ldap-krb5.rst b/src/tests/system/docs/guides/testing-ldap-krb5.rst deleted file mode 100644 index 4b05c869151..00000000000 --- a/src/tests/system/docs/guides/testing-ldap-krb5.rst +++ /dev/null @@ -1,48 +0,0 @@ -Testing LDAP with Kerberos -########################## - -SSSD's LDAP provider can be configured to use Kerberos as the authentication -provider. The framework provides tools to automatically configure the LDAP -domain with ``auth_provider = krb5``, using the Kerberos configuration from -given KDC role object. It also provides means to run Kerberos tools such as -``kinit``, ``klist`` and ``kdestroy``. - -.. seealso:: - - * :class:`lib.sssd.roles.kdc.KDC` - * :class:`lib.sssd.utils.authentication.KerberosAuthenticationUtils` - * :attr:`lib.sssd.utils.authentication.AuthenticationUtils.kerberos` - -.. note:: - - To access the KDC role, you need to add additional hostname to the - ``mhc.yaml`` multihost configuration. For example: - - .. code-block:: yaml - - - hostname: kdc.test - role: kdc - config: - realm: TEST - domain: test - client: - krb5_server: kdc.test - krb5_kpasswd: kdc.test - krb5_realm: TEST - - -.. code-block:: python - :caption: LDAP with Kerberos authentication example - - @pytest.mark.topology(KnownTopology.LDAP) - def test_kdc(client: Client, ldap: LDAP, kdc: KDC): - ldap.user('tuser').add() - kdc.principal('tuser').add() - - client.sssd.common.krb5_auth(kdc) - client.sssd.start() - - with client.ssh('tuser', 'Secret123') as ssh: - with client.auth.kerberos(ssh) as krb: - result = krb.klist() - assert f'krbtgt/{kdc.realm}@{kdc.realm}' in result.stdout diff --git a/src/tests/system/docs/guides/using-roles.rst b/src/tests/system/docs/guides/using-roles.rst deleted file mode 100644 index 3ceb57603a8..00000000000 --- a/src/tests/system/docs/guides/using-roles.rst +++ /dev/null @@ -1,475 +0,0 @@ -Using multihost roles -##################### - -Multihost role is the main object that gives you access to the remote host. Role -represents a service that runs on the host and the role object provides -interface to manipulate the service or the host -- for example creating a user -on the IPA server or changing configuration on the client. - -.. note:: - - Role objects are created at the start of each test and destroyed when the - test is finished. **They create a backup of the current state of the remote - host and restore modified state back to the original when the test ends.** - - **Therefore as long as you use only the role object, you can be assure that - everything you change through the role's API is restored to its original - state automatically.** For example if you add a new user, it is deleted. If - you create a new file, it is deleted. If you modify existing file, its - content is restored. - -.. warning:: - - All services supports full backup and restore except Active Directory where - this functionality is limited. Active Directory does not provide reasonably - fast backup mechanism therefore the framework only supports partial backup. - It will work as expected as long as you only touch newly created objects and - do not modify any existing object. - -Available roles -*************** - -There are multiple roles available. - -* ``ad`` -- Active Directory Domain Controller -* ``ipa`` -- IPA server -* ``ldap`` -- 389ds server -* ``samba`` -- Samba Domain Controller -* ``client`` -- SSSD client - -Each role is accessible through pytest fixture. - -Using provider roles -******************** - -Provider roles, that is those that represents identity management service (ad, -samba, ipa, ldap), provide interface to manipulate the service. For example -managing users and groups. These roles implements a generic interface -:class:`~lib.sssd.roles.generic.GenericProvider` and further extends this -interface with service specifics. -:class:`~lib.sssd.roles.generic.GenericProvider` can be used when writing -tests that can run against multiple providers (see -:ref:`topology-parametrization`). - -.. note:: - - Samba and AD roles also implements - :class:`~lib.sssd.roles.generic.GenericADProvider` which extends - :class:`~lib.sssd.roles.generic.GenericProvider` with Samba and Active - Directory features. This can be used to write single test that can run on - both Samba and Active Directory but can not run with other provider. - -Example: Adding users and groups -================================ - -User management is done through a user object which can be returned directly -from the role. This object provides ``add``, ``modify``, ``delete`` and ``get`` -methods that implements the :class:`~lib.sssd.roles.generic.GenericUser` -interface. Each identity management service can extend this interface with -service specific behavior (for example ldap allows to use the ``rfc2307bis`` -schema and organize users into different containers). Group management works in -the same way but :class:`~lib.sssd.roles.generic.GenericGroup` is -implemented. - -.. code-block:: python - - @pytest.mark.topology(KnownTopology.IPA) - def test_ipa(ipa: IPA): - # Create user - user = ipa.user('user-1').add(password='Secret123') - - # Create group - group = ipa.group('group-1').add() - - # Add user to the group - group.add_member(user) - - @pytest.mark.topology(KnownTopology.LDAP) - def test_ldap(ldap: LDAP): - # Create user - user = ldap.user('user-1', basedn='cn=users').add(uid=10001, gid=10001, password='Secret123') - - # Create user primary group - ldap.group('user-1', basedn='cn=groups', rfc2307bis=True).add(gid=10001) - - # Create group - group = ldap.group('group-1', basedn='cn=groups', rfc2307bis=True).add(gid=20001) - - # Add user to the group - group.add_member(user) - - @pytest.mark.topology(KnownTopology.AD) - @pytest.mark.topology(KnownTopology.IPA) - @pytest.mark.topology(KnownTopology.LDAP) - @pytest.mark.topology(KnownTopology.Samba) - def test_generic(provider: GenericProvider): - # Create user - user = provider.user('user-1').add() - - # Create group - group = provider.group('group-1').add() - - # Add user to the group - group.add_member(user) - -.. seealso:: - - See the following role objects: - :class:`~lib.sssd.roles.ad.AD`, - :class:`~lib.sssd.roles.ipa.IPA`, - :class:`~lib.sssd.roles.ldap.LDAP`, - :class:`~lib.sssd.roles.samba.Samba` - -Using the client role -********************* - -The client role is the heart of any multihost test as it allows you to manage -and test SSSD. You can see the whole API here: -:class:`~lib.sssd.roles.client.Client`. - -.. note:: - - Client role, as well as all other roles, contains multihost utility objects. - These objects implements some share features like: - - * creating directories and files: :class:`pytest_mh.utils.fs.LinuxFileSystem` - * starting and stopping systemd services: :class:`pytest_mh.utils.services.SystemdServices` - * working with SSSD: :class:`lib.sssd.utils.sssd.SSSDUtils` - * running standard tools such as ``id`` or ``getent``: :class:`lib.sssd.utils.tools.LinuxToolsUtils` - - .. code-block:: python - :caption: Example: Working with files and directories - - @pytest.mark.topology(KnownTopology.LDAP) - def test_files(client: Client): - # Read file - nsswitch = client.fs.read('/etc/nsswitch.conf') - - # Write file - client.fs.write('/etc/krb5.conf', ''' - [logging] - default = FILE:/var/log/krb5libs.log - - [libdefaults] - ticket_lifetime = 24h - renew_lifetime = 7d - forwardable = true - rdns = false - ''') - - # Create directory - client.fs.mkdir('/tmp/newdir', mode='0600') - - .. code-block:: python - :caption: Example: Managing services - - @pytest.mark.topology(KnownTopology.LDAP) - def test_service(ldap: LDAP): - # Stop directory server - ldap.svc.stop('dirsrv.target') - -Managing SSSD -============= - -SSSD on the host is stopped and its cache and logs are cleared automatically -when we entry a test to ensure that each test starts with a fresh state. You can -access the :class:`~lib.sssd.utils.sssd.SSSDUtils` through ``client.sssd`` -attribute. - -:class:`~lib.sssd.utils.sssd.SSSDUtils` allows you to start, stop and -restart SSSD as well as change configuration. - -Configuring SSSD ----------------- - -Configuration object can be accessed directly through ``client.sssd.config``. - -.. code-block:: python - - @pytest.mark.topology(KnownTopology.Client) - def test_client(client: Client): - # client.sssd.config[section] = dict[option, value as string] - client.sssd.config['nss'] = { - 'entry_cache_timeout': 'true', - 'override_homedir': '%U', - ... - } - - # client.sssd.config[section][option] = value as string - client.sssd.config['domain/test']['use_fully_qualified_names'] = 'true' - -You can also access each section directly by using a shortcut: - -.. code-block:: python - - @pytest.mark.topology(KnownTopology.Client) - def test_client(client: Client): - # there is shortcut for each responder - client.sssd.nss = { - 'entry_cache_timeout': 'true', - 'override_homedir': '%U', - ... - } - - # also for domain and subdomain - client.sssd.dom('test')['use_fully_qualified_names'] = 'true' - client.sssd.subdom('test', 'subdomname')['use_fully_qualified_names'] = 'false' - -It is possible to further simplify access to a selected domain. - -.. code-block:: python - :emphasize-lines: 9 - - @pytest.mark.topology(KnownTopology.Client) - def test_client(client: Client): - # select a default domain (this does not affect sssd.conf) - client.sssd.default_domain = 'test' - - # these three are equivalent - client.sssd.config['domain/test']['use_fully_qualified_names'] = 'true' - client.sssd.dom('test')['use_fully_qualified_names'] = 'true' - client.sssd.domain['use_fully_qualified_names'] = 'true' - -.. _importing-domain: - -Importing SSSD domain from provider role ----------------------------------------- - -Each multihost configuration may require slightly different SSSD config -- for -example it needs to specify correct domain, hostname and keytab location. -Therefore each host in multihost configuration may specify additional options -for SSSD: - -.. code-block:: yaml - :emphasize-lines: 14 - - root_password: 'Secret123' - domains: - - name: test - type: sssd - hosts: - - hostname: client.test - role: client - - - hostname: master.ldap.test - role: ldap - config: - binddn: cn=Directory Manager - bindpw: Secret123 - client: - ldap_tls_reqcert: demand - ldap_tls_cacert: /data/certs/ca.crt - dns_discovery_domain: ldap.test - -Each host also has default values for server uri, id provider and other options. -These value can be imported using -:meth:`~lib.sssd.utils.sssd.SSSDUtils.import_domain`. The first imported -domain is set as the default domain and its configuration can be accessed by -``client.sssd.domain``. - -.. code-block:: python - :emphasize-lines: 3 - - @pytest.mark.topology(KnownTopology.LDAP) - def test_client(client: Client, ldap: LDAP): - client.sssd.import_domain('test', ldap) - client.sssd.domain['use_fully_qualified_names'] = 'true' - - conf = client.sssd.config_dumps() - print(conf) - - # Outputs: - # - # [sssd] - # config_file_version = 2 - # services = nss, pam - # domains = test - # - # [domain/test] - # ldap_tls_reqcert = demand - # ldap_tls_cacert = /data/certs/ca.crt - # dns_discovery_domain = ldap.test - # id_provider = ldap - # ldap_uri = ldap://master.ldap.test - # use_fully_qualified_names = true - -Each topology from :class:`lib.sssd.topology.KnownTopology` already contains a -default SSSD domain named ``test``, therefore you do not need to import the -domain manually. - -.. code-block:: python - :emphasize-lines: 3 - - @pytest.mark.topology(KnownTopology.LDAP) - def test_client(client: Client, ldap: LDAP): - # the domain is already imported - # client.sssd.import_domain('test', ldap) - client.sssd.domain['use_fully_qualified_names'] = 'true' - - conf = client.sssd.config_dumps() - print(conf) - - # Outputs: - # - # [sssd] - # config_file_version = 2 - # services = nss, pam - # domains = test - # - # [domain/test] - # ldap_tls_reqcert = demand - # ldap_tls_cacert = /data/certs/ca.crt - # dns_discovery_domain = ldap.test - # id_provider = ldap - # ldap_uri = ldap://master.ldap.test - # use_fully_qualified_names = true - -Starting SSSD -------------- - -You can start, stop and restart SSSD. If the operation fails, the reason is -visible in the multihost logs. By default, current SSSD configuration is -automatically written to the host and checked with ``sssctl config-check`` when -calling :meth:`~lib.sssd.utils.sssd.SSSDUtils.start` and -:meth:`~lib.sssd.utils.sssd.SSSDUtils.restart`. - -.. code-block:: python - - @pytest.mark.topology(KnownTopology.LDAP) - def test_client(client: Client, ldap: LDAP): - client.sssd.domain['use_fully_qualified_names'] = 'true' - - # write sssd.conf, check for typos and start sssd - client.sssd.start() - - client.sssd.domain['use_fully_qualified_names'] = 'false' - - # avoid changing sssd.conf and config check and restart sssd - client.sssd.restart(apply_config=False, check_config=False) - - # stop sssd and clear cache and start (config is applied) - client.sssd.stop() - client.sssd.clear() - client.sssd.start() - -Asserting properties -==================== - -:class:`~lib.sssd.utils.tools.LinuxToolsUtils` can be accessed through -``client.tools``. This gives you access to standard Linux commands such as -``id`` and ``getent``. Output of these commands is fully parsed to allow simple -assertions. - -.. code-block:: python - - @pytest.mark.topology(KnownTopology.LDAP) - def test_ldap_id(client: Client, ldap: LDAP): - # Create organizational units - ou_users = ldap.ou('users').add() - ou_groups = ldap.ou('groups').add() - - # Create user - user = ldap.user('user-1', basedn=ou_users).add(uid=10001, gid=10001, password='Secret123') - - # Create group - group = ldap.group('group-1', basedn=ou_groups, rfc2307bis=True).add(gid=20001) - group.add_member(user) - - # Set schema and start SSSD - client.sssd.domain['ldap_schema'] = 'rfc2307bis' - client.sssd.start() - - # Assert the user - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.user.id == 10001 - assert result.group.id == 10001 - assert result.group.name is None # The primary group does not exist - assert result.memberof('group-1') - - client.sssd.domain['use_fully_qualified_names'] = 'true' - client.sssd.restart() - - # User can not be accessed by shortname - result = client.tools.id('user-1') - assert result is None - - # Find the user with fully qualified name - result = client.tools.id('user-1@test') - assert result is not None - assert result.user.name == 'user-1@test' - assert result.user.id == 10001 - assert result.group.id == 10001 - assert result.group.name is None # The primary group does not exist - assert result.memberof('group-1@test') - - -Topology parametrization -************************ - -All tools that are described in this document allows us to write tests for any -topology and we can even write tests that can be run on multiple topologies -without changing the code. - - -.. code-block:: python - - @pytest.mark.topology(KnownTopology.AD) - @pytest.mark.topology(KnownTopology.IPA) - @pytest.mark.topology(KnownTopology.LDAP) - @pytest.mark.topology(KnownTopology.Samba) - def test_generic_id(client: Client, provider: GenericProvider): - # Create user - user = provider.user('user-1').add(uid=10001, gid=10001) - - # Create group - group = provider.group('group-1').add(gid=20001) - group.add_member(user) - - client.sssd.start() - - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.user.id == 10001 - assert result.group.id == 10001 - assert result.memberof('group-1') - - client.sssd.domain['use_fully_qualified_names'] = 'true' - client.sssd.restart() - - result = client.tools.id('user-1') - assert result is None - - result = client.tools.id('user-1@test') - assert result is not None - assert result.user.name == 'user-1@test' - assert result.user.id == 10001 - assert result.group.id == 10001 - assert result.memberof('group-1@test') - -Low level access to remote host -******************************* - -If you are missing some functionality, you probably want to extend any existing -role or utility class and implement support for your requirements. However, if -needed, you can also run commands on the host directly: - -.. code-block:: python - - @pytest.mark.topology(KnownTopology.AD) - def test_client(client: Client, ad: AD): - # Commands are executed in bash on Linux systems - client.host.ssh.run('echo "test"') - - # And in Powershell on Windows - ad.host.ssh.run('Write-Output "test"') - -.. seealso:: - - You can read the API reference for: - - * roles: :mod:`lib.sssd.roles` - * utils: :mod:`lib.sssd.utils` - * hosts: :mod:`lib.sssd.hosts` diff --git a/src/tests/system/docs/index.rst b/src/tests/system/docs/index.rst deleted file mode 100644 index 2a4065a456c..00000000000 --- a/src/tests/system/docs/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -sssd - writing system tests -########################### - -.. toctree:: - :maxdepth: 2 - - running-tests - writing-tests - concepts - marks - config - guides/index - course/course - api diff --git a/src/tests/system/docs/marks.rst b/src/tests/system/docs/marks.rst deleted file mode 100644 index 436fb480fa4..00000000000 --- a/src/tests/system/docs/marks.rst +++ /dev/null @@ -1,154 +0,0 @@ -Additional markers and metadata -############################### - -Additional test metadata -************************ - -The following metadata are **required** to be present in docstring of each test. -These metadata are used to organize test in Polarion to provide evidency and -traceability for enterprise releases. - -.. code-block:: python - :caption: Required metadata - - def test_example(): - """ - :title: Human readable test title - :setup: - 1. Setup step - ... - N. Setup step - :steps: - 1. Assert step - ... - N. Assert step - :expectedresults: - 1. Expected result of assert step 1 - ... - N. Expected result of assert step N - :teardown: - 1. Teardown step - ... - N. Teardown step - :customerscenario: False|True - """ - -* **title**: Simple test case description. -* **setup**: All steps required to setup the environment before assertions (e.g. - what users are created). -* **steps**: Individual test or assertion steps. -* **expectedresults**: Expected result of each step. -* **teardown** (optional): All steps required to teardown environment. This - field is usually omitted. But it can be used to document some very specific - teardown steps if required. -* **customerscenario**: Is this test related to a Red Hat Customer Case? - -.. code-block:: python - :caption: Metadata example - - @pytest.mark.topology(KnownTopology.Client) - def test_kcm__tgt_renewal(client: Client, kdc: KDC): - """ - :title: Automatic ticket-granting ticket renewal. - :setup: - 1. Add Kerberos principal "tuser" to KDC - 2. Add local user "tuser" - 3. Enable TGT renewal in KCM - 4. Start SSSD - :steps: - 1. Authenticate as "tuser" over SSH - 2. Kinit as "tuser" and request renewable ticket - 3. Wait until automatic renewal is triggered and check that is was renewed - :expectedresults: - 1. User is logged into the host - 2. TGT is available - 3. TGT was renewed - :customerscenario: False - """ - -Additional markers -****************** - -Besides the ``topology`` mark, that is required and that defines which hosts -from the multihost configuration are relevant for the test, there are also other -marks that you can use to enhance the testing experience. - -@pytest.mark.ticket -=================== - -The `ticket mark `__ can -associate a test with Github issues and Bugzilla or JIRA tickets. - -The ``@pytest.mark.ticket`` takes one or more keyword arguments that represents -the tracker tool and the ticket identifier. The value may be single ticket or -list of tickets. - -.. code-block:: python - :caption: Examples - - @pytest.mark.ticket(gh=3433) - def test_gh() - pass - - @pytest.mark.ticket(bz=5003433) - def test_bz() - pass - - @pytest.mark.ticket(jira="SSSD-3433") - def test_jira() - pass - - @pytest.mark.ticket(gh=3433, bz=5003433, jira="SSSD-3433") - def test_all() - pass - - @pytest.mark.ticket(gh=3433, bz=[5003433, 5003434], jira="SSSD-3433") - def test_multi() - pass - -You can then run tests that are relevant only to the selected ticket: - -.. code-block:: text - - cd src/tests/system - pytest --mh-config=mhc.yaml --mh-lazy-ssh -v --ticket=gh#3433 - -@pytest.mark.tier -================= - -The `tier mark `__ can -associate a test with a specific tier. - -The ``@pytest.mark.tier`` takes single number as an argument. - -.. code-block:: python - :caption: Examples - - @pytest.mark.tier(0) - def test_tier0() - pass - - @pytest.mark.tier(1) - def test_tier1() - pass - -You can then run tests that are relevant only to the selected ticket: - -.. code-block:: text - - cd src/tests/system - pytest --mh-config=mhc.yaml --mh-lazy-ssh -v --tier=1 - -Tier definition -=============== - -.. list-table:: Tier definition - :align: center - :widths: 10 90 - :header-rows: 1 - :stub-columns: 1 - - * - Tier - - Description - * - @TODO - - diff --git a/src/tests/system/docs/requirements.txt b/src/tests/system/docs/requirements.txt deleted file mode 100644 index 1be7d64df40..00000000000 --- a/src/tests/system/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -sphinx >= 3.0 -sphinx_rtd_theme >= 1.1.0 -sphinx_design --r ../requirements.txt diff --git a/src/tests/system/docs/running-tests.rst b/src/tests/system/docs/running-tests.rst deleted file mode 100644 index a6304147954..00000000000 --- a/src/tests/system/docs/running-tests.rst +++ /dev/null @@ -1,208 +0,0 @@ -Running tests -############# - -Installing requirements -*********************** - -The tests are written in Python using the `pytest`_ framework and additional -Python packages. The list of all required packages is stored in -`requirements.txt`_. It is recommended to install the requirements inside Python -virtual environment. - -.. code-block:: text - - # Install python-ldap dependencies - sudo dnf install -y gcc python3-devel openldap-devel - - # Install test dependencies - python3 -m venv .venv - source .venv/bin/activate - pip3 install -r ./requirements.txt - -Important pytest plugins -======================== - -The tests requires several pytest plugins that are very important and worth -mentioning here. - -* `pytest-mh`_: Adds support for multihost testing. This is the core plugin that - is fundamental for writing and running the tests. -* `pytest-ticket`_: Adds ``@pytest.mark.ticket(...)`` and ``--ticket`` command - line option. It is used to run only tests related to particular tickets. -* `pytest-tier`_: Adds ``@pytest.mark.tier(...)`` and ``--tier`` command line - option. It is used to run only tests from given tier. - -Setting up multihost environment -******************************** - -Even though our tests are run locally with ``pytest``, they actually run -commands on remote machines to make the setup more flexible and avoid changing -anything on your host. The SSSD upstream tests use `sssd-ci-containers`_ project -that provides set of needed containers (client, LDAP, IPA, Samba, NFS, KDC, ...) -and Active Directory vagrant box and this documentation uses this project in all -listed examples. - -.. _sssd-ci-containers: https://github.com/SSSD/sssd-ci-containers - -.. note:: - - You can also provide set of your own hosts. However, you will need to modify - the `multihost configuration`_. - -Starting up the containers -========================== - -#. Clone the `sssd-ci-containers`_ repository -#. Switch to the directory -#. Start the containers -#. Start the Active Directory vagrant box - -.. code-block:: text - - git clone https://github.com/SSSD/sssd-ci-containers.git - cd sssd-ci-containers - -Start the containers --------------------- - -This code snippet will install required dependencies (podman and docker-compose -bridge for podman), installs certificates, setup dns and start the containers. - -.. code-block:: bash - - sudo dnf install -y podman podman-docker docker-compose - sudo systemctl enable --now podman.socket - sudo setsebool -P container_manage_cgroup true - - cp env.example .env - sudo make trust-ca - sudo make setup-dns - sudo make up - -.. warning:: - - ``make setup-dns`` disables systemd-resolved and configures NetworkManager - to resolve related domains through dnsmasq and a DNS server running in one - of the containers. See the `script`_ and `dnsmasq`_ configuration for more - details. - - If you see ``Could not determine IP address`` error when running tests, it - means that the DNS server is not reachable. Make sure that the DNS server is - running by starting the container with ``sudo make up`` and then run ``sudo - make setup-dns`` again. - - If you don't want to modify your system so extensively, you can run ``sudo - make setup-dns-files`` instead. This will only append records to your - ``/etc/hosts`` file to make the host names resolvable. SRV or PTR lookups - will not work, but that is not required to run the tests. - -Start Active directory vagrant box ----------------------------------- - -The `sssd-ci-containers`_ project also provides an Active Directory virtual -machine (`vagrant`_ box), because it can not be put in a container. A Samba -container can be used to mimic Active Directory for most test cases, but you -need to start the virtual machine in order to test SSSD against real Active -Directory. - -.. _script: https://github.com/SSSD/sssd-ci-containers/blob/master/src/tools/setup-dns.sh -.. _dnsmasq: https://github.com/SSSD/sssd-ci-containers/blob/master/data/configs/dnsmasq.conf -.. _vagrant: https://www.vagrantup.com - -It is recommended (but not necessary) to use vagrant from -``quay.io/sssd/vagrant:latest`` container to avoid issues with vagrant plugin -installation. - -.. code-block:: text - - # Install dependencies - sudo dnf remove -y vagrant - sudo dnf install -y libvirt qemu-kvm - sudo systemctl start libvirtd - - # Add the following to ~/.bashrc and ‘source ~/.bashrc’ - function vagrant { - dir="${VAGRANT_HOME:-$HOME/.vagrant.d}" - mkdir -p "$dir/"{boxes,data,tmp} - - podman run -it --rm \ - -e LIBVIRT_DEFAULT_URI \ - -v /var/run/libvirt/:/var/run/libvirt/ \ - -v "$dir/boxes:/vagrant/boxes" \ - -v "$dir/data:/vagrant/data" \ - -v "$dir/tmp:/vagrant/tmp" \ - -v $(realpath "${PWD}"):${PWD} \ - -w $(realpath "${PWD}") \ - --network host \ - --security-opt label=disable \ - quay.io/sssd/vagrant:latest \ - vagrant $@ - } - - # Start and provision Active Directory virtual machine - cd sssd-ci-containers/src - vagrant up ad - - # Enroll client into the Active Directory domain - sudo podman exec client bash -c "echo vagrant | realm join ad.test" - sudo podman exec client cp /etc/krb5.keytab /enrollment/ad.keytab - sudo podman exec client rm /etc/krb5.keytab - -.. note:: - - It is not required to have the Active Directory machine running in order to - run the tests. If you run the tests with ``--mh-lazy-ssh`` (as shown in the - example below) and the AD host is not running, pytest will simply skip the - tests that requires Active Directory. - -Multihost configuration -======================= - -Multihost configuration defines the domains and hosts that will be used for -testing SSSD. It describes what ``domains`` are available. Each domain defines -how many ``hosts`` are in the domain and each host provides or implements a -given ``role``. - -The `multihost configuration`_ bundled within the SSSD source code is designed -to work with the `sssd-ci-containers`_ project out of the box. If you chose to -create your own hosts, you need to alter the configuration to make it work with -your environment. - -.. seealso:: - - More information about the multihost configuration can be found in - :doc:`config`. - -Running tests -************* - -Now, if you have setup the environment, you can run the tests with ``pytest``. - -.. code-block:: text - - cd src/tests/system - pytest --mh-config=mhc.yaml --mh-lazy-ssh -v - -.. note:: - - You can use ``-k`` parameter to `filter tests - `__. - -.. seealso:: - - The `pytest-mh`_ plugin also provides several additional command line options - for pytest, see its documentation for more information. - - You will find at least ``--mh-log-path`` and ``--mh-topology`` very useful. - - * ``--mh-log-path=mh.log``: Logs multihost messages into ``mh.log`` file - * ``--mh-log-path=/dev/stderr``: Logs multihost messages to standard error output - * ``--mh-topology=ldap``: Only run ldap tests (you can also use ``ipa``, - ``ad``, ``samba``, ``client``) - -.. _pytest: https://pytest.org= -.. _requirements.txt: https://github.com/SSSD/sssd/blob/master/src/tests/system/requirements.txt -.. _multihost configuration: https://github.com/SSSD/sssd/blob/master/src/tests/system/mhc.yaml -.. _pytest-mh: https://pytest-mh.readthedocs.io -.. _pytest-ticket: https://github.com/next-actions/pytest-ticket -.. _pytest-tier: https://github.com/next-actions/pytest-tier diff --git a/src/tests/system/docs/writing-tests.rst b/src/tests/system/docs/writing-tests.rst deleted file mode 100644 index 4f3050e8f08..00000000000 --- a/src/tests/system/docs/writing-tests.rst +++ /dev/null @@ -1,408 +0,0 @@ -Writing new test -################ - -The tests are written using the `pytest`_ framework, `pytest-mh`_ plugin and -SSSD specific extensions that implements related :mod:`~lib.sssd.hosts` and -:mod:`~lib.sssd.roles`. - -This article covers the basic knowledge required to write a new test. After you -finish it, make sure to go through the :doc:`course/course` and -:doc:`guides/index`. You will also benefit from reading the :doc:`api`. - -.. seealso:: - - It is highly recommended to read `pytest`_ and `pytest-mh`_ documentation so - you can write your tests with all features and tools that are available. - -.. _pytest: https://docs.pytest.org -.. _pytest-mh: https://pytest-mh.readthedocs.io - -Using the topology marker -************************* - -Each test that requires access to hosts defined in multihost configuration must -be marked with a ``topology`` marker. This marker provides information about the -topology that is required to run the test and defines fixture mapping between a -short fixture name and a host from the multihost configuration (this is -explained later in `Deep dive into multihost fixtures`_). - -The marker is used as: - -.. code-block:: python - - import pytest - - - @pytest.mark.topology(name, topology, fixtures ...) - def test_example(): - assert True - -Where ``name`` is the human-readable topology name that is visible in ``pytest`` -verbose output, you can also use this name to filter tests that you want to run -(with the ``-k`` or ``--mh-topology`` parameter). The next argument, -``topology``, is instance of :class:`pytest_mh.Topology` and then follows -keyword arguments as a fixture mapping - this part is covered later. - -.. seealso:: - - You can read more about the topology marker at :mod:`pytest_mh`, - specifically at :class:`pytest_mh.TopologyMark`. It is also worth - to read the complete documentation of :mod:`pytest_mh` module. - -There is a number of predefined topologies in -:class:`lib.sssd.topology.KnownTopology` that can be used directly as the -topology marker argument. It is recommended to use this instead of providing -your own topology unless it is really necessary. - -.. code-block:: python - - import pytest - - from lib.sssd.topology import KnownTopology - from lib.sssd.roles.client import Client - from lib.sssd.roles.ldap import LDAP - - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP): - assert True - -The example above already uses the fixture mapping mentioned earlier. It uses -the fixture ``client`` that points to the client host and ``ldap`` that can be -used to manipulate with the host that provides the ldap role. This is thoroughly -covered in the next section. - -Deep dive into multihost fixtures -********************************* - -The previous example showed how to use -:attr:`lib.sssd.topology.KnownTopology.LDAP` to define the required topology and -provide ``client`` and ``ldap`` fixtures. This section described the mechanics -underneath so you can correctly write your own tests. - -Defining a topology -=================== - -Simply put, topology defines the requirements that must be matched by multihost -configuration in order to run the selected test. If the requirements are not -fulfilled, the test is omitted. - -The requirements are: - -* How many domains are needed -* What domain ids are needed -* How many hosts of specific role are needed inside a domain - -For example the following topology (written in yaml) requires one domain of id -``sssd`` and the domain must contain one host that has the ``client`` role and -one host that has the ``ldap`` role. - -.. code-block:: yaml - - - id: sssd - hosts: - client: 1 - ldap: 1 - -There are :class:`pytest_mh.Topology` and :class:`pytest_mh.TopologyDomain` -that you can use to put it in the code: - -.. code-block:: python - - Topology( - TopologyDomain('sssd', client=1, ldap=1) - ) - -.. _mh-fixture: - -Using the mh fixture -==================== - -.. warning:: - - Using the ``mh`` fixture directly is not recommended. Please see - `Using dynamic fixtures`_ to learn how to avoid using this fixture by - creating a fixture mapping. - -The :func:`pytest_mh.mh` is a fixture that is always available to a -test that is marked with the topology marker. It provides access to domains by -id and to hosts by role. Each host object is created as an instance of -specific :mod:`lib.sssd.roles`. - -We can use this fixture to access either group of hosts with -``mh.$domain-id.$role`` or individual host with -``mh.$domain-id.$role[$index]``. The following snippet shows how to access the -hosts from our example topology. - -.. code-block:: python - - import pytest - - from pytest_mh import Multihost, Topology, TopologyDomain - - - @pytest.mark.topology('ldap', Topology(TopologyDomain('sssd', client=1, ldap=1))) - def test_example(mh: Multihost): - assert mh.sssd.client[0].role == 'client' - assert mh.sssd.ldap[0].role == 'ldap' - -We can also take advantage of Python type hints to let our editor provide us -code suggestions. - -.. code-block:: python - - import pytest - - from pytest_mh import Multihost, Topology, TopologyDomain - - from lib.sssd.roles.client import Client - from lib.sssd.roles.ldap import LDAP - - - @pytest.mark.topology('ldap', Topology(TopologyDomain('sssd', client=1, ldap=1))) - def test_example(mh: Multihost): - client: Client = mh.sssd.client[0] - ldap: LDAP = mh.sssd.ldap[0] - - assert client.role == 'client' - assert ldap.role == 'ldap' - -Once the test run is finished, this fixture automatically initiates a teardown -process that rollbacks any change done on the remote host. - -Using dynamic fixtures -====================== - -.. warning:: - - Creating custom topologies and fixture mapping is not recommended and should - be used only when it is really needed. See the following section `Using - known topologies`_ to learn how to use predefined topologies in order to - shorten the code and provide naming consistency across all tests. - -The topology marker allows us to create a mapping between our own fixture name -and specific path inside the ``mh`` fixture by providing additional keyword-only -arguments to the marker. - -The example above can be rewritten as: - -.. code-block:: python - :emphasize-lines: 9 - - import pytest - - from pytest_mh import Topology, TopologyDomain - - from lib.sssd.roles.client import Client - from lib.sssd.roles.ldap import LDAP - - - @pytest.mark.topology( - 'ldap', Topology(TopologyDomain('sssd', client=1, ldap=1)), - client='sssd.client[0]', ldap='sssd.ldap[0]' - ) - def test_example(client: Client, ldap: LDAP): - assert client.role == 'client' - assert ldap.role == 'ldap' - -By adding the fixture mapping, we tell :mod:`pytest_mh` to -dynamically create ``client`` and ``ldap`` fixtures for the test run and set it -to the value of individual hosts inside the ``mh`` fixture which is still used -under the hood. - -We can also make a fixture for a group of hosts if our test would benefit from -it. - -.. code-block:: python - :emphasize-lines: 9 - - import pytest - - from pytest_mh import Topology, TopologyDomain - - from lib.sssd.roles.client import Client - - - @pytest.mark.topology( - 'ldap', Topology(TopologyDomain('sssd', client=1, ldap=1)), - clients='sssd.client' - ) - def test_example(clients: list[Client]): - for client in clients: - assert client.role == 'client' - -.. note:: - - We don't have to provide mapping for every single host, it is up to us - which hosts will be used. It is even possible to combine fixture mapping - and at the same time use ``mh`` fixture as well: - - .. code-block:: python - - def test_example(mh: Multihost, clients: list[Client]) - - It is also possible to request multiple fixtures for a single host. This - can be used in test parametrization as we will see later. - - .. code-block:: python - :emphasize-lines: 3 - - @pytest.mark.topology( - 'ldap', Topology(TopologyDomain('sssd', client=1, ldap=1)), - ldap='sssd.ldap[0]', provider='sssd.ldap[0]' - ) - -Using known topologies -====================== - -This article already covered lots of ways of achieving the same thing to show -how the plugin works. This section now describes the **recommended** usage by -introducing :class:`lib.sssd.topology.KnownTopology` class. - -This class provides predefined :class:`pytest_mh.TopologyMark` that -can be used directly as parameter to the topology marker. Under the hood, it -is the very same thing that was already explained. - -The topology from previous examples is simply -:attr:`lib.sssd.topology.KnownTopology.LDAP`. And we can use it like: - -.. code-block:: python - :emphasize-lines: 8 - - import pytest - - from lib.sssd.topology import KnownTopology - from lib.sssd.roles.client import Client - from lib.sssd.roles.ldap import LDAP - - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP): - assert client.role == 'client' - assert ldap.role == 'ldap' - -.. note:: - - If you get to a point when existing topologies are not enough, feel free - to define a new one inside :class:`lib.sssd.topology.KnownTopology` and use - the new entry so it can be reused later by other test when needed. - -.. _topology-parametrization: - -Topology parametrization -************************ - -We can run single test against multiple SSSD providers by topology -parametrization. This is achieved by assigning multiple topology markers to -a single test. - -.. code-block:: python - - import pytest - - from lib.sssd.topology import KnownTopology - from lib.sssd.roles.client import Client - from lib.sssd.roles.generic import GenericProvider - - @pytest.mark.topology(KnownTopology.LDAP) - @pytest.mark.topology(KnownTopology.IPA) - @pytest.mark.topology(KnownTopology.AD) - @pytest.mark.topology(KnownTopology.Samba) - def test_example(client: Client, provider: GenericProvider): - assert True - -Now, if we run the test, we can see that it was executed multiple times and each -time with a different topology. Therefore the ``provider`` points to the -expected host (``sssd.ldap[0]`` for ldap, ``sssd.ipa[0]`` for ipa etc.). - -.. note:: - - It is best practice to mark as many topologies as possible, triggering - multiple providers, when the test case allows it. - -.. code-block:: console - - $ pytest --mh-config=mhc.yaml -k test_example -v - ... - tests/test_basic.py::test_example (samba) PASSED [ 12%] - tests/test_basic.py::test_example (ad) PASSED [ 25%] - tests/test_basic.py::test_example (ipa) PASSED [ 37%] - tests/test_basic.py::test_example (ldap) PASSED - ... - -This is internally achieved by providing two fixtures for the server host. We -can look at how :attr:`lib.sssd.topology.KnownTopology.LDAP` is defined to see an -example: - -.. code-block:: python - :emphasize-lines: 4 - - LDAP = TopologyMark( - name='ldap', - topology=Topology(TopologyDomain('sssd', client=1, ldap=1)), - fixtures=dict(client='sssd.client[0]', ldap='sssd.ldap[0]', provider='sssd.ldap[0]') - ) - -We can go even further and use ``@pytest.mark.parametrize`` to test against -multiple values. - -.. code-block:: python - :emphasize-lines: 6 - - import pytest - - from lib.sssd.topology import KnownTopology - from lib.sssd.roles import Client, GenericProvider - - @pytest.mark.parametrize('mockvalue', [1, 2]) - @pytest.mark.topology(KnownTopology.LDAP) - @pytest.mark.topology(KnownTopology.IPA) - @pytest.mark.topology(KnownTopology.AD) - @pytest.mark.topology(KnownTopology.Samba) - def test_example(client: Client, provider: GenericProvider, mockvalue: int): - assert True - - -Now the test is run for each topology twice, once with ``mockvalue=1`` and the -second time with ``mockvalue=2``. - -.. code-block:: console - - $ pytest --mh-config=mhc.yaml -k test_example -v - ... - tests/test_basic.py::test_example[1] (samba) PASSED [ 12%] - tests/test_basic.py::test_example[1] (ad) PASSED [ 25%] - tests/test_basic.py::test_example[1] (ipa) PASSED [ 37%] - tests/test_basic.py::test_example[1] (ldap) PASSED [ 50%] - tests/test_basic.py::test_example[2] (samba) PASSED [ 62%] - tests/test_basic.py::test_example[2] (ad) PASSED [ 75%] - tests/test_basic.py::test_example[2] (ipa) PASSED [ 87%] - tests/test_basic.py::test_example[2] (ldap) PASSED - ... - -.. note:: - - The previous examples can be made shorter by using - :class:`lib.sssd.topology.KnownTopologyGroup`, which groups multiple topologies - together so they can be used in parametrization. For example: - - .. code-block:: python - :emphasize-lines: 7 - - import pytest - - from lib.sssd.topology import KnownTopologyGroup - from lib.sssd.roles import Client, GenericProvider - - @pytest.mark.parametrize('mockvalue', [1, 2]) - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_example(client: Client, provider: GenericProvider, mockvalue: int): - assert True - -.. seealso:: - - This article explained how to define a new test case and integrate it with - the multihost plugin in order to run tests that require access to multiple - machines, however it did not provide any information on how to actually run - commands on remote hosts. This is explained in articles in - :doc:`guides/index`, especially in :doc:`guides/using-roles`. diff --git a/src/tests/system/lib/__init__.py b/src/tests/system/lib/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/tests/system/lib/sssd/__init__.py b/src/tests/system/lib/sssd/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/tests/system/lib/sssd/config.py b/src/tests/system/lib/sssd/config.py deleted file mode 100644 index b70678fda03..00000000000 --- a/src/tests/system/lib/sssd/config.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -from typing import Any, Type - -from pytest_mh import MultihostConfig, MultihostDomain, MultihostHost, MultihostRole, TopologyMark - -from .topology import SSSDTopologyMark - -__all__ = [ - "SSSDMultihostConfig", - "SSSDMultihostDomain", -] - - -class SSSDMultihostConfig(MultihostConfig): - @property - def TopologyMarkClass(self) -> Type[TopologyMark]: - return SSSDTopologyMark - - @property - def id_to_domain_class(self) -> dict[str, Type[MultihostDomain]]: - """ - Map domain id to domain class. Asterisk ``*`` can be used as fallback - value. - - :rtype: Class name. - """ - return {"*": SSSDMultihostDomain} - - -class SSSDMultihostDomain(MultihostDomain[SSSDMultihostConfig]): - def __init__(self, config: SSSDMultihostConfig, confdict: dict[str, Any]) -> None: - super().__init__(config, confdict) - - @property - def role_to_host_class(self) -> dict[str, Type[MultihostHost]]: - """ - Map role to host class. Asterisk ``*`` can be used as fallback value. - - :rtype: Class name. - """ - from .hosts.ad import ADHost - from .hosts.ipa import IPAHost - from .hosts.kdc import KDCHost - from .hosts.ldap import LDAPHost - from .hosts.nfs import NFSHost - from .hosts.samba import SambaHost - - return { - "ad": ADHost, - "ldap": LDAPHost, - "ipa": IPAHost, - "samba": SambaHost, - "nfs": NFSHost, - "kdc": KDCHost, - } - - @property - def role_to_role_class(self) -> dict[str, Type[MultihostRole]]: - """ - Map role to role class. Asterisk ``*`` can be used as fallback value. - - :rtype: Class name. - """ - from .roles.ad import AD - from .roles.client import Client - from .roles.ipa import IPA - from .roles.kdc import KDC - from .roles.ldap import LDAP - from .roles.nfs import NFS - from .roles.samba import Samba - - return { - "client": Client, - "ad": AD, - "ipa": IPA, - "ldap": LDAP, - "samba": Samba, - "nfs": NFS, - "kdc": KDC, - } diff --git a/src/tests/system/lib/sssd/hosts/__init__.py b/src/tests/system/lib/sssd/hosts/__init__.py deleted file mode 100644 index 604243ca259..00000000000 --- a/src/tests/system/lib/sssd/hosts/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""SSSD multihost hosts.""" -from __future__ import annotations diff --git a/src/tests/system/lib/sssd/hosts/ad.py b/src/tests/system/lib/sssd/hosts/ad.py deleted file mode 100644 index 312f5dd9c7f..00000000000 --- a/src/tests/system/lib/sssd/hosts/ad.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Active Directory multihost host.""" - -from __future__ import annotations - -from .base import BaseDomainHost - -__all__ = [ - "ADHost", -] - - -class ADHost(BaseDomainHost): - """ - Active Directory host object. - - Provides features specific to Active Directory domain controller. - - .. warning:: - - Backup and restore functionality of a domain controller is quite limited - when compared to other backends. Unfortunately, a full backup and - restore of a domain controller is not possible without a complete system - backup and reboot which takes too long time and is not suitable for - setting an exact state for each test. Therefore a limited backup and - restore is provided which only deletes all added objects. It works well - if a test does not modify any existing data but only uses new - objects like newly added users and groups. - - If the test modifies existing data, it needs to make sure to revert - the modifications manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - # Additional client configuration - self.client.setdefault("id_provider", "ad") - self.client.setdefault("access_provider", "ad") - self.client.setdefault("ad_server", self.hostname) - self.client.setdefault("dyndns_update", False) - - # Lazy properties - self.__naming_context: str | None = None - - @property - def naming_context(self) -> str: - """ - Default naming context. - - :raises ValueError: If default naming context can not be obtained. - :rtype: str - """ - if not self.__naming_context: - result = self.ssh.run("Write-Host (Get-ADRootDSE).rootDomainNamingContext") - nc = result.stdout.strip() - if not nc: - raise ValueError("Unable to find default naming context") - - self.__naming_context = nc - - return self.__naming_context - - def disconnect(self) -> None: - return - - def backup(self) -> None: - """ - Perform limited backup of the domain controller data. Currently only - content under ``$default_naming_context`` is backed up. - - This is done by performing simple LDAP search on the base dn. This - operation is usually very fast. - """ - self.ssh.run( - rf""" - Remove-Item C:\multihost_backup.txt - $result = Get-ADObject -SearchBase '{self.naming_context}' -Filter "*" - foreach ($r in $result) {{ - $r.DistinguishedName | Add-Content -Path C:\multihost_backup.txt - }} - """ - ) - self._backup_location = "C:\\multihost_backup.txt" - - def restore(self) -> None: - """ - Perform limited restoration of the domain controller state. - - This is done by removing all records under ``$default_naming_context`` - that are not present in the original state. - """ - if not self._backup_location: - return - - self.ssh.run( - rf""" - $backup = Get-Content "{self._backup_location}" - $result = Get-ADObject -SearchBase '{self.naming_context}' -Filter "*" - foreach ($r in $result) {{ - if (!$backup.contains($r.DistinguishedName)) {{ - Write-Host "Removing: $r" - Try {{ - Remove-ADObject -Identity $r.DistinguishedName -Recursive -Confirm:$False - }} Catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {{ - # Ignore not found error as the object may have been deleted by recursion - }} - }} - }} - - # If we got here, make sure we exit with 0 - Exit 0 - """ - ) diff --git a/src/tests/system/lib/sssd/hosts/base.py b/src/tests/system/lib/sssd/hosts/base.py deleted file mode 100644 index e7b26f4dc38..00000000000 --- a/src/tests/system/lib/sssd/hosts/base.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Base classes and objects for SSSD specific multihost hosts.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any - -import ldap -import ldap.ldapobject -from pytest_mh import MultihostHost -from pytest_mh.ssh import SSHPowerShellProcess - -from ..config import SSSDMultihostDomain - -__all__ = [ - "BaseHost", - "BaseBackupHost", - "BaseDomainHost", - "BaseLDAPDomainHost", -] - - -class BaseHost(MultihostHost[SSSDMultihostDomain]): - """ - Base class for all SSSD hosts. - """ - - pass - - -class BaseBackupHost(BaseHost, ABC): - """ - Base class for all hosts that supports automatic backup and restore. - - A backup of the host is created before starting a test case and all changes - done in the test case to the host are automatically reverted when the test - run is finished. - - .. warning:: - - There might be some limitations on what data can and can not be restored - that depends on particular host. See the documentation of each host - class to learn if a full or partial restoration is done. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.__backup_created: bool = False - """True if backup of the backend was already created.""" - - self._backup_location: str | None = None - """Backup file or folder location.""" - - def pytest_teardown(self) -> None: - """ - Called once after all tests are finished. - """ - if self._backup_location is not None: - if self.ssh.shell is SSHPowerShellProcess: - self.ssh.exec(["Remove-Item", "-Force", "-Recurse", self._backup_location]) - else: - self.ssh.exec(["rm", "-fr", self._backup_location]) - - super().teardown() - - def setup(self) -> None: - """ - Called before execution of each test. - - Perform backup of the backend. - """ - super().setup() - - # Make sure to backup the data only once - if not self.__backup_created: - self.backup() - self.__backup_created = True - - def teardown(self) -> None: - """ - Called after execution of each test. - - Perform teardown of the backend, the backend is restored to its original - state where is was before the test was executed. - """ - if self.__backup_created: - self.restore() - super().teardown() - - @abstractmethod - def backup(self) -> None: - """ - Backup backend data. - """ - pass - - @abstractmethod - def restore(self) -> None: - """ - Restore backend data. - """ - pass - - -class BaseDomainHost(BaseBackupHost): - """ - Base class for all domain (backend) hosts. - - This class extends the multihost configuration with ``config.client`` - section that can contain additional SSSD configuration for the domain to - allow connection to the domain (like keytab and certificate locations, - domain name, etc.). - - .. code-block:: yaml - :caption: Example multihost configuration - :emphasize-lines: 4-7 - - - hostname: master.ipa.test - role: ipa - config: - client: - ipa_domain: ipa.test - krb5_keytab: /enrollment/ipa.keytab - ldap_krb5_keytab: /enrollment/ipa.keytab - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.client: dict[str, Any] = self.config.get("client", {}) - - -class BaseLDAPDomainHost(BaseDomainHost): - """ - Base class for all domain (backend) hosts that require direct LDAP access to - manipulate data (like 389ds or SambaDC). - - Extends :class:`BaseDomainHost` to manage LDAP connection and adds - ``config.binddn`` and ``config.bindpw`` multihost configuration options. - - .. code-block:: yaml - :caption: Example multihost configuration - :emphasize-lines: 6-7 - - - hostname: master.ldap.test - role: ldap - config: - binddn: cn=Directory Manager - bindpw: Secret123 - client: - ldap_tls_reqcert: demand - ldap_tls_cacert: /data/certs/ca.crt - dns_discovery_domain: ldap.test - - .. note:: - - The LDAP connection is not opened immediately, but only when - :attr:`conn` is accessed for the first time. - """ - - def __init__(self, *args, tls: bool = True, **kwargs) -> None: - """ - :param tls: Require TLS connection, defaults to True - :type tls: bool, optional - """ - super().__init__(*args, **kwargs) - - self.tls: bool = tls - """Use TLS when establishing connection or no?""" - - self.binddn: str = self.config.get("binddn", "cn=Directory Manager") - """Bind DN ``config.binddn``, defaults to ``cn=Directory Manager``""" - - self.bindpw: str = self.config.get("bindpw", "Secret123") - """Bind password ``config.bindpw``, defaults to ``Secret123``""" - - # Lazy properties. - self.__conn: ldap.ldapobject.LDAPObject | None = None - self.__naming_context: str | None = None - - @property - def conn(self) -> ldap.ldapobject.LDAPObject: - """ - LDAP connection (``python-ldap`` library). - - :rtype: ldap.ldapobject.LDAPObject - """ - if not self.__conn: - newconn = ldap.initialize(f"ldap://{self.ssh_host}") - newconn.protocol_version = ldap.VERSION3 - newconn.set_option(ldap.OPT_REFERRALS, 0) - - if self.tls: - newconn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) - newconn.set_option(ldap.OPT_X_TLS_NEWCTX, 0) - newconn.start_tls_s() - - newconn.simple_bind_s(self.binddn, self.bindpw) - self.__conn = newconn - - return self.__conn - - @property - def naming_context(self) -> str: - """ - Default naming context. - - :raises ValueError: If default naming context can not be obtained. - :rtype: str - """ - if not self.__naming_context: - attr = "defaultNamingContext" - result = self.conn.search_s("", ldap.SCOPE_BASE, attrlist=[attr]) - if len(result) != 1: - raise ValueError(f"Unexpected number of results for rootDSE query: {len(result)}") - - (_, values) = result[0] - if attr not in values: - raise ValueError(f"Unable to find {attr}") - - self.__naming_context = str(values[attr][0].decode("utf-8")) - - return self.__naming_context - - def disconnect(self) -> None: - """ - Disconnect LDAP connection. - """ - if self.__conn is not None: - self.__conn.unbind() - self.__conn = None - - def ldap_result_to_dict( - self, result: list[tuple[str, dict[str, list[bytes]]]] - ) -> dict[str, dict[str, list[bytes]]]: - """ - Convert result from python-ldap library from tuple into a dictionary - to simplify lookup by distinguished name. - - :param result: Search result from python-ldap. - :type result: tuple[str, dict[str, list[bytes]]] - :return: Dictionary with distinguished name as key and attributes as value. - :rtype: dict[str, dict[str, list[bytes]]] - """ - return dict((dn, attrs) for dn, attrs in result if dn is not None) diff --git a/src/tests/system/lib/sssd/hosts/ipa.py b/src/tests/system/lib/sssd/hosts/ipa.py deleted file mode 100644 index c09e2ad09cd..00000000000 --- a/src/tests/system/lib/sssd/hosts/ipa.py +++ /dev/null @@ -1,83 +0,0 @@ -"""IPA multihost host.""" - -from __future__ import annotations - -from .base import BaseDomainHost - -__all__ = [ - "IPAHost", -] - - -class IPAHost(BaseDomainHost): - """ - IPA host object. - - Provides features specific to IPA server. - - This class adds ``config.adminpw`` multihost configuration option to set - password of the IPA admin user so we can obtain Kerberos TGT for the user - automatically. - - .. code-block:: yaml - :caption: Example multihost configuration - :emphasize-lines: 6 - - - hostname: master.ipa.test - role: ipa - config: - adminpw: Secret123 - client: - ipa_domain: ipa.test - krb5_keytab: /enrollment/ipa.keytab - ldap_krb5_keytab: /enrollment/ipa.keytab - - .. note:: - - Full backup and restore is supported. However, the operation relies on - ``ipa-backup`` and ``ipa-restore`` commands which can take several - seconds to finish. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.adminpw: str = self.config.get("adminpw", "Secret123") - """Password of the admin user, defaults to ``Secret123``.""" - - # Additional client configuration - self.client.setdefault("id_provider", "ipa") - self.client.setdefault("access_provider", "ipa") - self.client.setdefault("ipa_server", self.hostname) - self.client.setdefault("dyndns_update", False) - - def kinit(self) -> None: - """ - Obtain ``admin`` user Kerberos TGT. - """ - self.ssh.exec(["kinit", "admin"], input=self.adminpw) - - def backup(self) -> None: - """ - Backup all IPA server data. - - This is done by calling ``ipa-backup --data --online`` on the server - and can take several seconds to finish. - """ - self.ssh.run("ipa-backup --data --online") - cmd = self.ssh.run("ls /var/lib/ipa/backup | tail -n 1") - self._backup_location = cmd.stdout.strip() - - def restore(self) -> None: - """ - Restore all IPA server data to its original state. - - This is done by calling ``ipa-restore --data --online`` on the server - and can take several seconds to finish. - """ - if not self._backup_location: - return - - self.ssh.exec( - ["ipa-restore", "--unattended", "--password", self.adminpw, "--data", "--online", self._backup_location] - ) diff --git a/src/tests/system/lib/sssd/hosts/kdc.py b/src/tests/system/lib/sssd/hosts/kdc.py deleted file mode 100644 index c1256487ab5..00000000000 --- a/src/tests/system/lib/sssd/hosts/kdc.py +++ /dev/null @@ -1,62 +0,0 @@ -"""KDC multihost host.""" - -from __future__ import annotations - -from .base import BaseDomainHost - -__all__ = [ - "KDCHost", -] - - -class KDCHost(BaseDomainHost): - """ - Kerberos KDC server host object. - - Provides features specific to Kerberos KDC. - - This class adds ``config.realm`` and ``config.domain`` multihost - configuration options to set the default kerberos realm and domain. - - .. code-block:: yaml - :caption: Example multihost configuration - :emphasize-lines: 6-7 - - - hostname: kdc.test - role: kdc - config: - realm: TEST - domain: test - client: - krb5_server: kdc.test - krb5_kpasswd: kdc.test - krb5_realm: TEST - - .. note:: - - Full backup and restore is supported. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.realm: str = self.config.get("realm", "TEST") - self.krbdomain: str = self.config.get("domain", "test") - - self.client["auth_provider"] = "krb5" - - def backup(self) -> None: - """ - Backup KDC server. - """ - self.ssh.run('kdb5_util dump /tmp/mh.kdc.kdb.backup && rm -f "/tmp/mh.kdc.kdb.backup.dump_ok"') - self._backup_location = "/tmp/mh.kdc.kdb.backup" - - def restore(self) -> None: - """ - Restore KDC server to its initial contents. - """ - if not self._backup_location: - return - - self.ssh.run(f'kdb5_util load "{self._backup_location}"') diff --git a/src/tests/system/lib/sssd/hosts/ldap.py b/src/tests/system/lib/sssd/hosts/ldap.py deleted file mode 100644 index b99485425eb..00000000000 --- a/src/tests/system/lib/sssd/hosts/ldap.py +++ /dev/null @@ -1,113 +0,0 @@ -"""LDAP multihost host.""" - -from __future__ import annotations - -import ldap - -from .base import BaseLDAPDomainHost - -__all__ = [ - "LDAPHost", -] - - -class LDAPHost(BaseLDAPDomainHost): - """ - LDAP host object. - - Provides features specific to native directory server like 389ds. - - .. note:: - - Full backup and restore is supported. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - # Additional client configuration - self.client.setdefault("id_provider", "ldap") - self.client.setdefault("ldap_uri", f"ldap://{self.hostname}") - - # Backup of original data - self.__backup: dict[str, dict[str, list[bytes]]] = {} - - def backup(self) -> None: - """ - Backup all directory server data. - - Full backup of ``cn=config`` and default naming context is performed. - This is done by simple LDAP search on given base dn and remembering the - contents. The operation is usually very fast. - """ - data = self.conn.search_s(self.naming_context, ldap.SCOPE_SUBTREE) - config = self.conn.search_s("cn=config", ldap.SCOPE_BASE) - nc = self.conn.search_s(self.naming_context, ldap.SCOPE_BASE, attrlist=["aci"]) - - dct = self.ldap_result_to_dict(data) - dct.update(self.ldap_result_to_dict(config)) - dct.update(self.ldap_result_to_dict(nc)) - self.__backup = dct - - def restore(self) -> None: - """ - Restore directory server data. - - Current directory server content in ``cn=config`` and default naming - context is modified to its original data. This is done by computing a - difference between original data obtained by :func:`backup` and then - calling add, delete and modify operations to convert current state to - the original state. This operation is usually very fast. - """ - data = self.conn.search_s(self.naming_context, ldap.SCOPE_SUBTREE) - config = self.conn.search_s("cn=config", ldap.SCOPE_BASE) - nc = self.conn.search_s(self.naming_context, ldap.SCOPE_BASE, attrlist=["aci"]) - - # Convert list of tuples to dictionary for better lookup - data = self.ldap_result_to_dict(data) - data.update(self.ldap_result_to_dict(config)) - data.update(self.ldap_result_to_dict(nc)) - - for dn, attrs in reversed(data.items()): - # Restore records that were modified - if dn in self.__backup: - original_attrs = self.__backup[dn] - modlist = ldap.modlist.modifyModlist(attrs, original_attrs) - modlist = self.__filter_modlist(dn, modlist) - if modlist: - self.conn.modify_s(dn, modlist) - - for dn, attrs in reversed(data.items()): - # Delete records that were added - if dn not in self.__backup: - self.conn.delete_s(dn) - continue - - for dn, attrs in self.__backup.items(): - # Add back records that were deleted - if dn not in data: - self.conn.add_s(dn, list(attrs.items())) - - def __filter_modlist(self, dn: str, modlist: list) -> list: - """ - Remove special items that can not be modified from ``modlist``. - - :param dn: Object's DN. - :type dn: str - :param modlist: LDAP modlist. - :type modlist: list - :return: Filtered modlist. - :rtype: list - """ - if dn != "cn=config": - return modlist - - result = [] - for op, attr, value in modlist: - # We are not allowed to touch these - if attr.startswith("nsslapd"): - continue - - result.append((op, attr, value)) - - return result diff --git a/src/tests/system/lib/sssd/hosts/nfs.py b/src/tests/system/lib/sssd/hosts/nfs.py deleted file mode 100644 index 77006b0cebc..00000000000 --- a/src/tests/system/lib/sssd/hosts/nfs.py +++ /dev/null @@ -1,66 +0,0 @@ -"""NFS multihost host.""" - -from __future__ import annotations - -from .base import BaseBackupHost - -__all__ = [ - "NFSHost", -] - - -class NFSHost(BaseBackupHost): - """ - NFS server host object. - - Provides features specific to NFS server. - - This class adds ``config.exports_dir`` multihost configuration option to set - the top level NFS exports directory where additional shares are created by - individual test cases. - - .. code-block:: yaml - :caption: Example multihost configuration - :emphasize-lines: 4 - - - hostname: nfs.test - role: nfs - config: - exports_dir: /dev/shm/exports - - .. note:: - - Full backup and restore is supported. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.exports_dir: str = self.config.get("exports_dir", "/exports").rstrip("/") - """Top level NFS exports directory, defaults to ``/exports``.""" - - def backup(self) -> None: - """ - Backup NFS server. - """ - self.ssh.run( - rf""" - tar --ignore-failed-read -czvf /tmp/mh.nfs.backup.tgz "{self.exports_dir}" /etc/exports /etc/exports.d - """ - ) - self._backup_location = "/tmp/mh.nfs.backup.tgz" - - def restore(self) -> None: - """ - Restore NFS server to its initial contents. - """ - if not self._backup_location: - return - - self.ssh.run( - rf""" - rm -fr "{self.exports_dir}/*" - rm -fr /etc/exports.d/* - tar -xf "{self._backup_location}" -C / - exportfs -r - """ - ) diff --git a/src/tests/system/lib/sssd/hosts/samba.py b/src/tests/system/lib/sssd/hosts/samba.py deleted file mode 100644 index 9b8d42d6bf9..00000000000 --- a/src/tests/system/lib/sssd/hosts/samba.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Samba multihost host.""" - -from __future__ import annotations - -from .base import BaseLDAPDomainHost - -__all__ = [ - "SambaHost", -] - - -class SambaHost(BaseLDAPDomainHost): - """ - Samba host object. - - Provides features specific to Samba server. - - .. note:: - - Full backup and restore is supported. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - # Additional client configuration - self.client.setdefault("id_provider", "ad") - self.client.setdefault("access_provider", "ad") - self.client.setdefault("ad_server", self.hostname) - self.client.setdefault("dyndns_update", False) - - def backup(self) -> None: - """ - Backup all Samba server data. - - This is done by creating a backup of Samba database. This operation - is usually very fast. - """ - self.ssh.run( - """ - set -e - systemctl stop samba - rm -fr /var/lib/samba.bak - cp -r /var/lib/samba /var/lib/samba.bak - systemctl start samba - - # systemctl finishes before samba is fully started, wait for it to start listening on ldap port - timeout 5s bash -c 'until netstat -ltp 2> /dev/null | grep :ldap &> /dev/null; do :; done' - """ - ) - self._backup_location = "/var/lib/samba.bak" - - def restore(self) -> None: - """ - Restore all Samba server data to its original value. - - This is done by overriding current database with the backup created - by :func:`backup`. This operation is usually very fast. - """ - if not self._backup_location: - return - - self.disconnect() - self.ssh.run( - f""" - set -e - systemctl stop samba - rm -fr /var/lib/samba - cp -r "{self._backup_location}" /var/lib/samba - systemctl start samba - samba-tool ntacl sysvolreset - - # systemctl finishes before samba is fully started, wait for it to start listening on ldap port - timeout 60s bash -c 'until netstat -ltp 2> /dev/null | grep :ldap &> /dev/null; do :; done' - """ - ) - self.disconnect() diff --git a/src/tests/system/lib/sssd/misc/__init__.py b/src/tests/system/lib/sssd/misc/__init__.py deleted file mode 100644 index 2d3faffc1b1..00000000000 --- a/src/tests/system/lib/sssd/misc/__init__.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from typing import Any - - -def attrs_parse(lines: list[str], attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Parse LDAP attributes from output. - - :param lines: Output. - :type lines: list[str] - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - out: dict[str, list[str]] = {} - for line in lines: - line = line.strip() - if not line: - continue - - (key, value) = map(lambda x: x.strip(), line.split(":", 1)) - if attrs is None or key in attrs: - out.setdefault(key, []) - out[key].append(value) - - return out - - -def attrs_include_value(attr: Any | list[Any] | None, value: Any) -> list[Any]: - """ - Include ``value`` to attribute list if it is not yet present. - - If ``attr`` is not a list, then it is first converted into a list. - - :param attr: List of attribute values or a single value. - :type attr: Any | list[Any] - :param value: Value to add to the list. - :type value: Any - :return: New list with the value included. - :rtype: list[Any] - """ - attr = to_list(attr) - - if value not in attr: - return [*attr, value] - - return attr - - -def to_list(value: Any | list[Any] | None) -> list[Any]: - """ - Convert value into a list. - - - if value is ``None`` then return an empty list - - if value is already a list then return it unchanged - - if value is not a list then return ``[value]`` - - :param value: Value that should be converted to a list. - :type value: Any | list[Any] | None - :return: List with the value as an element. - :rtype: list[Any] - """ - if value is None: - return [] - - if isinstance(value, list): - return value - - return [value] - - -def to_list_of_strings(value: Any | list[Any] | None) -> list[str]: - """ - Convert given list or single value to list of strings. - - The ``value`` is first converted to a list and then ``str(item)`` is run on - each of its item. - - :param value: Value to convert. - :type value: Any | list[Any] | None - :return: List of strings. - :rtype: list[str] - """ - return [str(x) for x in to_list(value)] - - -def to_list_without_none(r_list: list[Any]) -> list[Any]: - """ - Remove all elements that are ``None`` from the list. - - :param r_list: List of all elements. - :type r_list: list[Any] - :return: New list with all values from the given list that are not ``None``. - :rtype: list[Any] - """ - return [x for x in r_list if x is not None] diff --git a/src/tests/system/lib/sssd/misc/ssh.py b/src/tests/system/lib/sssd/misc/ssh.py deleted file mode 100644 index 6007a6c3218..00000000000 --- a/src/tests/system/lib/sssd/misc/ssh.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -import shlex -from typing import Any - -from pytest_mh.ssh import SSHClient, SSHLog, SSHProcess - -from . import to_list_of_strings - - -class SSHKillableProcess(object): - """ - Run an asynchronous process that requires ``SIGTERM`` to be terminated. - """ - - def __init__( - self, - client: SSHClient, - argv: list[Any], - *, - cwd: str | None = None, - env: dict[str, Any] | None = None, - input: str | None = None, - read_timeout: float = 2, - log_level: SSHLog = SSHLog.Full, - ) -> None: - """ - :param client: SSH client. - :type client: SSHClient - :param argv: Command to run. - :type argv: list[Any] - :param cwd: Working directory, defaults to None (= do not change) - :type cwd: str | None, optional - :param env: Additional environment variables, defaults to None - :type env: dict[str, Any] | None, optional - :param input: Content of standard input, defaults to None - :type input: str | None, optional - :param read_timeout: Timeout in seconds, how long should the client wait for output, defaults to 30 seconds - :type read_timeout: float, optional - :param log_level: Log level, defaults to SSHLog.Full - :type log_level: SSHLog, optional - """ - if env is None: - env = {} - - argv = to_list_of_strings(argv) - command = shlex.join(argv) - pidfile = "/tmp/.mh.sshkillableprocess.pid" - - self.client: SSHClient = client - self.process: SSHProcess = client.async_run( - f""" - set -m - {command} & - echo $! &> "{pidfile}" - fg - """, - cwd=cwd, - env=env, - input=input, - read_timeout=read_timeout, - log_level=log_level, - ) - - # Get pid - result = self.client.run( - f""" - until [ -f "{pidfile}" ]; do sleep 0.005; done - cat "{pidfile}" - rm -f "{pidfile}" - """ - ) - - self.pid = result.stdout.strip() - """Process id.""" - - self.kill_delay: int = 0 - """Wait ``kill_delay`` seconds before killing the process.""" - - self.__killed: bool = False - - def kill(self) -> None: - if self.__killed: - return - - self.client.run(f"sleep {self.kill_delay}; kill {self.pid}") - self.__killed = True - - def __enter__(self) -> SSHKillableProcess: - return self - - def __exit__(self, exception_type, exception_value, traceback) -> None: - self.kill() - self.process.wait() diff --git a/src/tests/system/lib/sssd/roles/__init__.py b/src/tests/system/lib/sssd/roles/__init__.py deleted file mode 100644 index 11d2ef56967..00000000000 --- a/src/tests/system/lib/sssd/roles/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""SSSD multihost roles.""" -from __future__ import annotations diff --git a/src/tests/system/lib/sssd/roles/ad.py b/src/tests/system/lib/sssd/roles/ad.py deleted file mode 100644 index 97323880820..00000000000 --- a/src/tests/system/lib/sssd/roles/ad.py +++ /dev/null @@ -1,1226 +0,0 @@ -"""Active Directory multihost role.""" - -from __future__ import annotations - -from typing import Any - -from pytest_mh.cli import CLIBuilderArgs -from pytest_mh.ssh import SSHClient, SSHPowerShellProcess, SSHProcessResult - -from ..hosts.ad import ADHost -from ..misc import attrs_include_value, attrs_parse -from .base import BaseObject, BaseWindowsRole, DeleteAttribute -from .nfs import NFSExport - -__all__ = [ - "AD", - "ADAutomount", - "ADAutomountMap", - "ADGroup", - "ADObject", - "ADOrganizationalUnit", - "ADSudoRule", - "ADUser", -] - - -class AD(BaseWindowsRole[ADHost]): - """ - AD service management. - """ - - """ - Active Directory role. - - Provides unified Python API for managing objects in the Active Directory - domain controller. - - .. code-block:: python - :caption: Creating user and group - - @pytest.mark.topology(KnownTopology.AD) - def test_example(ad: AD): - u = ad.user('tuser').add() - g = ad.group('tgroup').add() - g.add_member(u) - - .. note:: - - The role object is instantiated automatically as a dynamic pytest - fixture by the multihost plugin. You should not create the object - manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.auto_ou: dict[str, bool] = {} - """Organizational units that were automatically created.""" - - self.automount: ADAutomount = ADAutomount(self) - """ - Manage automount maps and keys. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.AD) - def test_example_autofs(client: Client, ad: AD, nfs: NFS): - nfs_export1 = nfs.export('export1').add() - nfs_export2 = nfs.export('export2').add() - nfs_export3 = nfs.export('sub/export3').add() - - # Create automount maps - auto_master = ad.automount.map('auto.master').add() - auto_home = ad.automount.map('auto.home').add() - auto_sub = ad.automount.map('auto.sub').add() - - # Create mount points - auto_master.key('/ehome').add(info=auto_home) - auto_master.key('/esub/sub1/sub2').add(info=auto_sub) - - # Create mount keys - key1 = auto_home.key('export1').add(info=nfs_export1) - key2 = auto_home.key('export2').add(info=nfs_export2) - key3 = auto_sub.key('export3').add(info=nfs_export3) - - # Start SSSD - client.sssd.common.autofs() - client.sssd.start() - - # Reload automounter in order to fetch updated maps - client.automount.reload() - - # Check that we can mount all directories on correct locations - assert client.automount.mount('/ehome/export1', nfs_export1) - assert client.automount.mount('/ehome/export2', nfs_export2) - assert client.automount.mount('/esub/sub1/sub2/export3', nfs_export3) - - # Check that the maps are correctly fetched - assert client.automount.dumpmaps() == { - '/ehome': { - 'map': 'auto.home', - 'keys': [str(key1), str(key2)] - }, - '/esub/sub1/sub2': { - 'map': 'auto.sub', - 'keys': [str(key3)] - }, - } - """ - - def ssh(self, user: str, password: str, *, shell=SSHPowerShellProcess) -> SSHClient: - """ - Open SSH connection to the host as given user. - - :param user: Username. - :type user: str - :param password: User password. - :type password: str - :param shell: Shell that will run the commands, defaults to SSHPowerShellProcess - :type shell: str, optional - :return: SSH client connection. - :rtype: SSHClient - """ - return super().ssh(user, password, shell=shell) - - def user(self, name: str, basedn: ADObject | str | None = "cn=users") -> ADUser: - """ - Get user object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.AD) - def test_example(client: Client, ad: AD): - # Create user - ad.user('user-1').add() - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.group.name == 'domain users' - - :param name: User name. - :type name: str - :param basedn: Base dn, defaults to ``cn=users`` - :type basedn: ADObject | str | None, optional - :return: New user object. - :rtype: ADUser - """ - return ADUser(self, name, basedn) - - def group(self, name: str, basedn: ADObject | str | None = "cn=users") -> ADGroup: - """ - Get group object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.AD) - def test_example_group(client: Client, ad: AD): - # Create user - user = ad.user('user-1').add() - - # Create secondary group and add user as a member - ad.group('group-1').add().add_member(user) - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.group.name == 'domain users' - assert result.memberof('group-1') - - :param name: Group name. - :type name: str - :param basedn: Base dn, defaults to ``cn=users`` - :type basedn: ADObject | str | None, optional - :return: New group object. - :rtype: ADGroup - """ - return ADGroup(self, name, basedn) - - def ou(self, name: str, basedn: ADObject | str | None = None) -> ADOrganizationalUnit: - """ - Get organizational unit object. - - .. code-blocK:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.AD) - def test_example(client: Client, ad: AD): - # Create organizational unit for sudo rules - ou = ad.ou('mysudoers').add() - - # Create user - ad.user('user-1').add() - - # Create sudo rule - ad.sudorule('testrule', basedn=ou).add(user='ALL', host='ALL', command='/bin/ls') - - client.sssd.common.sudo() - client.sssd.start() - - # Test that user can run /bin/ls - assert client.auth.sudo.run('user-1', 'Secret123', command='/bin/ls') - - :param name: Unit name. - :type name: str - :param basedn: Base dn, defaults to None - :type basedn: ADObject | str | None, optional - :return: New organizational unit object. - :rtype: ADOrganizationalUnit - """ - return ADOrganizationalUnit(self, name, basedn) - - def sudorule(self, name: str, basedn: ADObject | str | None = "ou=sudoers") -> ADSudoRule: - """ - Get sudo rule object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.AD) - def test_example(client: Client, ad: AD): - user = ad.user('user-1').add(password="Secret123") - ad.sudorule('testrule').add(user=user, host='ALL', command='/bin/ls') - - client.sssd.common.sudo() - client.sssd.start() - - # Test that user can run /bin/ls - assert client.auth.sudo.run('user-1', 'Secret123', command='/bin/ls') - - :param name: Rule name. - :type name: str - :param basedn: Base dn, defaults to ``ou=sudoers`` - :type basedn: ADObject | str | None, optional - :return: New sudo rule object. - :rtype: ADSudoRule - """ - - return ADSudoRule(self, name, basedn) - - -class ADObject(BaseObject[ADHost, AD]): - """ - Base class for Active Directory object management. - - Provides shortcuts for command execution and implementation of :meth:`get` - and :meth:`delete` methods. - """ - - def __init__( - self, - role: AD, - command_group: str, - name: str, - rdn: str, - basedn: ADObject | str | None = None, - default_ou: str | None = None, - ) -> None: - """ - :param role: AD role object. - :type role: AD - :param command_group: AD command group. - :type command_group: str - :param name: Object name. - :type name: str - :param rdn: Relative distinguished name. - :type rdn: str - :param basedn: Base dn, defaults to None - :type basedn: ADObject | str | None, optional - :param default_ou: Name of default organizational unit that is automatically - created if basedn is set to ou=$default_ou, defaults to None. - :type default_ou: str | None, optional - """ - super().__init__(role) - - self.command_group: str = command_group - """Active Directory Powershell module command group.""" - - self.name: str = name - """Object name.""" - - self.rdn: str = rdn - """Object relative DN.""" - - self.basedn: ADObject | str | None = basedn - """Object base DN.""" - - self.dn: str = self._dn(rdn, basedn) - """Object DN.""" - - self.path: str = self._path(basedn) - """Object path (DN of the parent container).""" - - self.default_ou: str | None = default_ou - """Default organizational unit that usually holds this object.""" - - self._identity: CLIBuilderArgs = {"Identity": (self.cli.option.VALUE, self.dn)} - """Identity parameter used in powershell commands.""" - - self.__create_default_ou(basedn, self.default_ou) - - def __create_default_ou(self, basedn: ADObject | str | None, default_ou: str | None) -> None: - """ - If default base dn is used we want to make sure that the container - (usually an organizational unit) exit. This is to allow nicely working - topology parametrization when the base dn is not specified and created - inside the test because not all backends supports base dn (e.g. IPA). - - :param basedn: Selected base DN. - :type basedn: ADObject | str | None - :param default_ou: Default name of organizational unit. - :type default_ou: str | None - """ - if default_ou is None: - return - - if basedn is None or not isinstance(basedn, str): - return - - if basedn.lower() != f"ou={default_ou}" or default_ou in self.role.auto_ou: - return - - self.role.ou(default_ou).add() - self.role.auto_ou[default_ou] = True - - def _dn(self, rdn: str, basedn: ADObject | str | None = None) -> str: - """ - Get distinguished name of an object. - - :param rdn: Relative DN. - :type rdn: str - :param basedn: Base DN, defaults to None - :type basedn: ADObject | str | None, optional - :return: Distinguished name combined from rdn+dn+naming-context. - :rtype: str - """ - if isinstance(basedn, ADObject): - return f"{rdn},{basedn.dn}" - - if not basedn: - return f"{rdn},{self.role.host.naming_context}" - - return f"{rdn},{basedn},{self.role.host.naming_context}" - - def _path(self, basedn: ADObject | str | None = None) -> str: - """ - Get object LDAP path. - - :param basedn: Base DN, defaults to None - :type basedn: ADObject | str | None, optional - :return: Distinguished name of the parent container combined from basedn+naming-context. - :rtype: str - """ - if isinstance(basedn, ADObject): - return basedn.dn - - if not basedn: - return self.role.host.naming_context - - return f"{basedn},{self.role.host.naming_context}" - - def _exec(self, op: str, args: list[str] | str | None = None, **kwargs) -> SSHProcessResult: - """ - Execute AD command. - - .. code-block:: console - - $ $op-AD$command_group $args - for example >>> New-ADUser tuser - - :param op: Command group operation (usually New, Set, Remove, Get) - :type op: str - :param args: List of additional command arguments, defaults to None - :type args: list[str] | None, optional - :return: SSH process result. - :rtype: SSHProcessResult - """ - if args is None: - args = [] - - if isinstance(args, list): - args = " ".join(args) - elif args is None: - args = "" - - return self.role.host.ssh.run( - f""" - Import-Module ActiveDirectory - {op}-AD{self.command_group} {args} - """, - **kwargs, - ) - - def _add(self, attrs: CLIBuilderArgs) -> None: - """ - Add Active Directory object. - - :param attrs: Object attributes in :class:`pytest_mh.cli.CLIBuilder` format, defaults to dict() - :type attrs: pytest_mh.cli.CLIBuilderArgs, optional - """ - self._exec("New", self.cli.args(attrs, quote_value=True)) - - def _modify(self, attrs: CLIBuilderArgs) -> None: - """ - Modifiy Active Directory object. - - :param attrs: Object attributes in :class:`pytest_mh.cli.CLIBuilder` format, defaults to dict() - :type attrs: pytest_mh.cli.CLIBuilderArgs, optional - """ - self._exec("Set", self.cli.args(attrs, quote_value=True)) - - def delete(self) -> None: - """ - Delete Active Directory object. - """ - args: CLIBuilderArgs = { - "Confirm": (self.cli.option.SWITCH, False), - **self._identity, - } - self._exec("Remove", self.cli.args(args, quote_value=True)) - - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get AD object attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - cmd = self._exec("Get", self.cli.args(self._identity, quote_value=True)) - return attrs_parse(cmd.stdout_lines, attrs) - - def _attrs_to_hash(self, attrs: dict[str, Any]) -> str | None: - """ - Convert attributes into an Powershell hash table records. - - :param attrs: Attributes names and values. - :type attrs: dict[str, Any] - :return: Attributes in powershell hash record format. - :rtype: str | None - """ - out = "" - for key, value in attrs.items(): - if value is not None: - if isinstance(value, list): - values = [f'"{x}"' for x in value] - out += f'"{key}"={",".join(values)};' - else: - out += f'"{key}"="{value}";' - - if not out: - return None - - return "@{" + out.rstrip(";") + "}" - - -class ADOrganizationalUnit(ADObject): - """ - AD organizational unit management. - """ - - def __init__(self, role: AD, name: str, basedn: ADObject | str | None = None) -> None: - """ - :param role: AD role object. - :type role: AD - :param name: Unit name. - :type name: str - :param basedn: Base dn, defaults to None - :type basedn: ADObject | str | None, optional - """ - super().__init__(role, "OrganizationalUnit", name, f"ou={name}", basedn) - - def add(self) -> ADOrganizationalUnit: - """ - Create new AD organizational unit. - - :return: Self. - :rtype: ADOrganizationalUnit - """ - attrs: CLIBuilderArgs = { - "Name": (self.cli.option.VALUE, self.name), - "Path": (self.cli.option.VALUE, self.path), - "ProtectedFromAccidentalDeletion": (self.cli.option.PLAIN, "$False"), - } - - self._add(attrs) - return self - - -class ADUser(ADObject): - """ - AD user management. - """ - - def __init__(self, role: AD, name: str, basedn: ADObject | str | None = "cn=users") -> None: - """ - :param role: AD role object. - :type role: AD - :param name: User name. - :type name: str - :param basedn: Base dn, defaults to 'cn=users' - :type basedn: ADObject | str | None, optional - """ - # There is no automatically created default ou because cn=users already exists - super().__init__(role, "User", name, f"cn={name}", basedn, default_ou=None) - - def add( - self, - *, - uid: int | None = None, - gid: int | None = None, - password: str = "Secret123", - home: str | None = None, - gecos: str | None = None, - shell: str | None = None, - ) -> ADUser: - """ - Create new AD user. - - Parameters that are not set are ignored. - - :param uid: User id, defaults to None - :type uid: int | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | None, optional - :param password: Password (cannot be None), defaults to 'Secret123' - :type password: str, optional - :param home: Home directory, defaults to None - :type home: str | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | None, optional - :param shell: Login shell, defaults to None - :type shell: str | None, optional - :return: Self. - :rtype: ADUser - """ - unix_attrs = { - "uid": self.name, - "uidNumber": uid, - "gidNumber": gid, - "unixHomeDirectory": home, - "gecos": gecos, - "loginShell": shell, - } - - attrs: CLIBuilderArgs = { - "Name": (self.cli.option.VALUE, self.name), - "AccountPassword": (self.cli.option.PLAIN, f'(ConvertTo-SecureString "{password}" -AsPlainText -force)'), - "OtherAttributes": (self.cli.option.PLAIN, self._attrs_to_hash(unix_attrs)), - "Enabled": (self.cli.option.PLAIN, "$True"), - "Path": (self.cli.option.VALUE, self.path), - } - - self._add(attrs) - return self - - def modify( - self, - *, - uid: int | DeleteAttribute | None = None, - gid: int | DeleteAttribute | None = None, - home: str | DeleteAttribute | None = None, - gecos: str | DeleteAttribute | None = None, - shell: str | DeleteAttribute | None = None, - ) -> ADUser: - """ - Modify existing AD user. - - Parameters that are not set are ignored. If needed, you can delete an - attribute by setting the value to :attr:`Delete`. - - :param uid: User id, defaults to None - :type uid: int | DeleteAttribute | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | DeleteAttribute | None, optional - :param home: Home directory, defaults to None - :type home: str | DeleteAttribute | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | DeleteAttribute | None, optional - :param shell: Login shell, defaults to None - :type shell: str | DeleteAttribute | None, optional - :return: Self. - :rtype: ADUser - """ - unix_attrs = { - "uidNumber": uid, - "gidNumber": gid, - "unixHomeDirectory": home, - "gecos": gecos, - "loginShell": shell, - } - - clear = [key for key, value in unix_attrs.items() if isinstance(value, DeleteAttribute)] - replace = { - key: value - for key, value in unix_attrs.items() - if value is not None and not isinstance(value, DeleteAttribute) - } - - attrs: CLIBuilderArgs = { - **self._identity, - "Replace": (self.cli.option.PLAIN, self._attrs_to_hash(replace)), - "Clear": (self.cli.option.PLAIN, ",".join(clear) if clear else None), - } - - self._modify(attrs) - return self - - -class ADGroup(ADObject): - """ - AD group management. - """ - - def __init__(self, role: AD, name: str, basedn: ADObject | str | None = "cn=users") -> None: - """ - :param role: AD role object. - :type role: AD - :param name: Group name. - :type name: str - :param basedn: Base dn, defaults to 'cn=users' - :type basedn: ADObject | str | None, optional - """ - # There is no automatically created default ou because cn=users already exists - super().__init__(role, "Group", name, f"cn={name}", basedn, default_ou=None) - - def add( - self, - *, - gid: int | None = None, - description: str | None = None, - scope: str = "Global", - category: str = "Security", - ) -> ADGroup: - """ - Create new AD group. - - :param gid: Group id, defaults to None - :type gid: int | None, optional - :param description: Description, defaults to None - :type description: str | None, optional - :param scope: Scope ('Global', 'Universal', 'DomainLocal'), defaults to 'Global' - :type scope: str, optional - :param category: Category ('Distribution', 'Security'), defaults to 'Security' - :type category: str, optional - :return: Self. - :rtype: ADGroup - """ - unix_attrs = { - "gidNumber": gid, - "description": description, - } - - attrs: CLIBuilderArgs = { - "Name": (self.cli.option.VALUE, self.name), - "GroupScope": (self.cli.option.VALUE, scope), - "GroupCategory": (self.cli.option.VALUE, category), - "OtherAttributes": (self.cli.option.PLAIN, self._attrs_to_hash(unix_attrs)), - "Path": (self.cli.option.VALUE, self.path), - } - - self._add(attrs) - return self - - def modify( - self, - *, - gid: int | DeleteAttribute | None = None, - description: str | DeleteAttribute | None = None, - ) -> ADGroup: - """ - Modify existing AD group. - - Parameters that are not set are ignored. If needed, you can delete an - attribute by setting the value to :attr:`Delete`. - - :param gid: Group id, defaults to None - :type gid: int | DeleteAttribute | None, optional - :param description: Description, defaults to None - :type description: str | DeleteAttribute | None, optional - :return: Self. - :rtype: ADUser - """ - unix_attrs = { - "gidNumber": gid, - "description": description, - } - - clear = [key for key, value in unix_attrs.items() if isinstance(value, DeleteAttribute)] - replace = { - key: value - for key, value in unix_attrs.items() - if value is not None and not isinstance(value, DeleteAttribute) - } - - attrs: CLIBuilderArgs = { - **self._identity, - "Replace": (self.cli.option.PLAIN, self._attrs_to_hash(replace)), - "Clear": (self.cli.option.PLAIN, ",".join(clear) if clear else None), - } - - self._modify(attrs) - return self - - def add_member(self, member: ADUser | ADGroup) -> ADGroup: - """ - Add group member. - - :param member: User or group to add as a member. - :type member: ADUser | ADGroup - :return: Self. - :rtype: ADGroup - """ - return self.add_members([member]) - - def add_members(self, members: list[ADUser | ADGroup]) -> ADGroup: - """ - Add multiple group members. - - :param member: List of users or groups to add as members. - :type member: list[ADUser | ADGroup] - :return: Self. - :rtype: ADGroup - """ - self.role.host.ssh.run( - f""" - Import-Module ActiveDirectory - Add-ADGroupMember -Identity '{self.dn}' -Members {self.__get_members(members)} - """ - ) - return self - - def remove_member(self, member: ADUser | ADGroup) -> ADGroup: - """ - Remove group member. - - :param member: User or group to remove from the group. - :type member: ADUser | ADGroup - :return: Self. - :rtype: ADGroup - """ - return self.remove_members([member]) - - def remove_members(self, members: list[ADUser | ADGroup]) -> ADGroup: - """ - Remove multiple group members. - - :param member: List of users or groups to remove from the group. - :type member: list[ADUser | ADGroup] - :return: Self. - :rtype: ADGroup - """ - self.role.host.ssh.run( - f""" - Import-Module ActiveDirectory - Remove-ADGroupMember -Confirm:$False -Identity '{self.dn}' -Members {self.__get_members(members)} - """ - ) - return self - - def __get_members(self, members: list[ADUser | ADGroup]) -> str: - return ",".join([f'"{x.dn}"' for x in members]) - - -class ADSudoRule(ADObject): - """ - AD sudo rule management. - """ - - def __init__( - self, - role: AD, - name: str, - basedn: ADObject | str | None = "ou=sudoers", - ) -> None: - """ - :param role: AD role object. - :type role: AD - :param name: Sudo rule name. - :type name: str - :param basedn: Base dn, defaults to None - :type basedn: ADObject | str | None, optional - """ - super().__init__(role, "Object", name, f"cn={name}", basedn, default_ou="sudoers") - - def add( - self, - *, - user: int | str | ADUser | ADGroup | list[int | str | ADUser | ADGroup] | None = None, - host: str | list[str] | None = None, - command: str | list[str] | None = None, - option: str | list[str] | None = None, - runasuser: int | str | ADUser | ADGroup | list[int | str | ADUser | ADGroup] | None = None, - runasgroup: int | str | ADGroup | list[int | str | ADGroup] | None = None, - notbefore: str | list[str] | None = None, - notafter: str | list[str] | None = None, - order: int | list[int] | None = None, - nopasswd: bool | None = None, - ) -> ADSudoRule: - """ - Create new sudo rule. - - :param user: sudoUser attribute, defaults to None - :type user: int | str | ADUser | ADGroup | list[int | str | ADUser | ADGroup], optional - :param host: sudoHost attribute, defaults to None - :type host: str | list[str], optional - :param command: sudoCommand attribute, defaults to None - :type command: str | list[str], optional - :param option: sudoOption attribute, defaults to None - :type option: str | list[str] | None, optional - :param runasuser: sudoRunAsUser attribute, defaults to None - :type runasuser: int | str | ADUser | ADGroup | list[int | str | ADUser | ADGroup] | None, optional - :param runasgroup: sudoRunAsGroup attribute, defaults to None - :type runasgroup: int | str | ADGroup | list[int | str | ADGroup] | None, optional - :param notbefore: sudoNotBefore attribute, defaults to None - :type notbefore: str | list[str] | None, optional - :param notafter: sudoNotAfter attribute, defaults to None - :type notafter: str | list[str] | None, optional - :param order: sudoOrder attribute, defaults to None - :type order: int | list[int] | None, optional - :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) - :type nopasswd: bool | None, optional - :return: Self. - :rtype: ADSudoRule - """ - attrs = { - "objectClass": "sudoRole", - "sudoUser": self.__sudo_user(user), - "sudoHost": host, - "sudoCommand": command, - "sudoOption": option, - "sudoRunAsUser": self.__sudo_user(runasuser), - "sudoRunAsGroup": self.__sudo_group(runasgroup), - "sudoNotBefore": notbefore, - "sudoNotAfter": notafter, - "sudoOrder": order, - } - - if nopasswd is True: - attrs["sudoOption"] = attrs_include_value(attrs["sudoOption"], "!authenticate") - elif nopasswd is False: - attrs["sudoOption"] = attrs_include_value(attrs["sudoOption"], "authenticate") - - args: CLIBuilderArgs = { - "Name": (self.cli.option.VALUE, self.name), - "Type": (self.cli.option.VALUE, "sudoRole"), - "OtherAttributes": (self.cli.option.PLAIN, self._attrs_to_hash(attrs)), - "Path": (self.cli.option.VALUE, self.path), - } - - self._add(args) - return self - - def modify( - self, - *, - user: int | str | ADUser | ADGroup | list[int | str | ADUser | ADGroup] | DeleteAttribute | None = None, - host: str | list[str] | DeleteAttribute | None = None, - command: str | list[str] | DeleteAttribute | None = None, - option: str | list[str] | DeleteAttribute | None = None, - runasuser: int | str | ADUser | ADGroup | list[int | str | ADUser | ADGroup] | DeleteAttribute | None = None, - runasgroup: int | str | ADGroup | list[int | str | ADGroup] | DeleteAttribute | None = None, - notbefore: str | list[str] | DeleteAttribute | None = None, - notafter: str | list[str] | DeleteAttribute | None = None, - order: int | list[int] | DeleteAttribute | None = None, - nopasswd: bool | None = None, - ) -> ADSudoRule: - """ - Modify existing sudo rule. - - Parameters that are not set are ignored. If needed, you can delete an - attribute by setting the value to :attr:`Delete`. - - :param user: sudoUser attribute, defaults to None - :type user: int | str | ADUser | ADGroup | list[int | str | ADUser | ADGroup] - | DeleteAttribute | None, optional - :param host: sudoHost attribute, defaults to None - :type host: str | list[str] | DeleteAttribute | None, optional - :param command: sudoCommand attribute, defaults to None - :type command: str | list[str] | DeleteAttribute | None, optional - :param option: sudoOption attribute, defaults to None - :type option: str | list[str] | DeleteAttribute | None, optional - :param runasuser: sudoRunAsUsere attribute, defaults to None - :type runasuser: int | str | ADUser | ADGroup | list[int | str | ADUser | ADGroup] - | DeleteAttribute | None, optional - :param runasgroup: sudoRunAsGroup attribute, defaults to None - :type runasgroup: int | str | ADGroup | list[int | str | ADGroup] | DeleteAttribute | None, optional - :param notbefore: sudoNotBefore attribute, defaults to None - :type notbefore: str | list[str] | DeleteAttribute | None, optional - :param notafter: sudoNotAfter attribute, defaults to None - :type notafter: str | list[str] | DeleteAttribute | None, optional - :param order: sudoOrder attribute, defaults to None - :type order: int | list[int] | DeleteAttribute | None, optional - :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) - :type nopasswd: bool | None, optional - :return: Self. - :rtype: ADSudoRule - """ - attrs = { - "sudoUser": self.__sudo_user(user), - "sudoHost": host, - "sudoCommand": command, - "sudoOption": option, - "sudoRunAsUser": self.__sudo_user(runasuser), - "sudoRunAsGroup": self.__sudo_group(runasgroup), - "sudoNotBefore": notbefore, - "sudoNotAfter": notafter, - "sudoOrder": order, - } - - if nopasswd is True: - attrs["sudoOption"] = attrs_include_value(attrs["sudoOption"], "!authenticate") - elif nopasswd is False: - attrs["sudoOption"] = attrs_include_value(attrs["sudoOption"], "authenticate") - - clear = [key for key, value in attrs.items() if isinstance(value, DeleteAttribute)] - replace = { - key: value for key, value in attrs.items() if value is not None and not isinstance(value, DeleteAttribute) - } - - args: CLIBuilderArgs = { - **self._identity, - "Replace": (self.cli.option.PLAIN, self._attrs_to_hash(replace)), - "Clear": (self.cli.option.PLAIN, ",".join(clear) if clear else None), - } - - self._modify(args) - return self - - def __sudo_user( - self, sudo_user: None | DeleteAttribute | int | str | ADUser | ADGroup | list[int | str | ADUser | ADGroup] - ) -> list[str] | DeleteAttribute | None: - def _get_value(value: int | str | ADUser | ADGroup) -> str: - if isinstance(value, ADUser): - return value.name - - if isinstance(value, ADGroup): - return "%" + value.name - - if isinstance(value, str): - return value - - if isinstance(value, int): - return "#" + str(value) - - raise ValueError(f"Unsupported type: {type(value)}") - - if sudo_user is None: - return None - - if isinstance(sudo_user, DeleteAttribute): - return sudo_user - - if not isinstance(sudo_user, list): - return [_get_value(sudo_user)] - - out = [] - for value in sudo_user: - out.append(_get_value(value)) - - return out - - def __sudo_group( - self, sudo_group: None | DeleteAttribute | int | str | ADGroup | list[int | str | ADGroup] - ) -> list[str] | DeleteAttribute | None: - def _get_value(value: int | str | ADGroup): - if isinstance(value, ADGroup): - return value.name - - if isinstance(value, str): - return value - - if isinstance(value, int): - return "#" + str(value) - - raise ValueError(f"Unsupported type: {type(value)}") - - if sudo_group is None: - return None - - if isinstance(sudo_group, DeleteAttribute): - return sudo_group - - if not isinstance(sudo_group, list): - return [_get_value(sudo_group)] - - out = [] - for value in sudo_group: - out.append(_get_value(value)) - - return out - - -class ADAutomount(object): - """ - AD automount management. - """ - - def __init__(self, role: AD) -> None: - """ - :param role: AD role object. - :type role: AD - """ - self.__role = role - - def map(self, name: str, basedn: ADObject | str | None = "ou=autofs") -> ADAutomountMap: - """ - Get automount map object. - - :param name: Automount map name. - :type name: str - :param basedn: Base dn, defaults to ``ou=autofs`` - :type basedn: ADObject | str | None, optional - :return: New automount map object. - :rtype: ADAutomountMap - """ - return ADAutomountMap(self.__role, name, basedn) - - def key(self, name: str, map: ADAutomountMap) -> ADAutomountKey: - """ - Get automount key object. - - :param name: Automount key name. - :type name: str - :param map: Automount map that is a parent to this key. - :type map: ADAutomountMap - :return: New automount key object. - :rtype: ADAutomountKey - """ - return ADAutomountKey(self.__role, name, map) - - -class ADAutomountMap(ADObject): - """ - AD automount map management. - """ - - def __init__( - self, - role: AD, - name: str, - basedn: ADObject | str | None = "ou=autofs", - ) -> None: - """ - :param role: AD role object. - :type role: AD - :param name: Automount map name. - :type name: str - :param basedn: Base dn, defaults to ``ou=autofs`` - :type basedn: ADObject | str | None, optional - """ - super().__init__(role, "Object", name, f"cn={name}", basedn, default_ou="autofs") - - def add( - self, - ) -> ADAutomountMap: - """ - Create new AD automount map. - - :return: Self. - :rtype: ADAutomountMap - """ - attrs = { - "objectClass": "nisMap", - "cn": self.name, - "nisMapName": self.name, - } - - args: CLIBuilderArgs = { - "Name": (self.cli.option.VALUE, self.name), - "Type": (self.cli.option.VALUE, "nisMap"), - "OtherAttributes": (self.cli.option.PLAIN, self._attrs_to_hash(attrs)), - "Path": (self.cli.option.VALUE, self.path), - } - - self._add(args) - return self - - def key(self, name: str) -> ADAutomountKey: - """ - Get automount key object for this map. - - :param name: Automount key name. - :type name: str - :return: New automount key object. - :rtype: ADAutomountKey - """ - return ADAutomountKey(self.role, name, self) - - -class ADAutomountKey(ADObject): - """ - AD automount key management. - """ - - def __init__( - self, - role: AD, - name: str, - map: ADAutomountMap, - ) -> None: - """ - :param role: AD role object. - :type role: AD - :param name: Automount key name. - :type name: str - :param map: Automount map that is a parent to this key. - :type map: ADAutomountMap - """ - super().__init__(role, "Object", name, f"cn={name}", map) - self.map: ADAutomountMap = map - self.info: str | None = None - - def add(self, *, info: str | NFSExport | ADAutomountMap) -> ADAutomountKey: - """ - Create new AD automount key. - - :param info: Automount information. - :type info: str | NFSExport | ADAutomountMap - :return: Self. - :rtype: ADAutomountKey - """ - parsed = self.__get_info(info) - if isinstance(parsed, DeleteAttribute) or parsed is None: - # This should not happen, it is here just to silence mypy - raise ValueError("Invalid value of info attribute") - - attrs = { - "objectClass": "nisObject", - "cn": self.name, - "nisMapEntry": parsed, - "nisMapName": self.map.name, - } - - args: CLIBuilderArgs = { - "Name": (self.cli.option.VALUE, self.name), - "Type": (self.cli.option.VALUE, "nisObject"), - "OtherAttributes": (self.cli.option.PLAIN, self._attrs_to_hash(attrs)), - "Path": (self.cli.option.VALUE, self.path), - } - - self._add(args) - self.info = parsed - return self - - def modify( - self, - *, - info: str | NFSExport | ADAutomountMap | DeleteAttribute | None = None, - ) -> ADAutomountKey: - """ - Modify existing AD automount key. - - Parameters that are not set are ignored. If needed, you can delete an - attribute by setting the value to :attr:`Delete`. - - :param info: Automount information, defaults to ``None`` - :type info: str | NFSExport | ADAutomountMap | DeleteAttribute | None - :return: Self. - :rtype: ADAutomountKey - """ - parsed = self.__get_info(info) - attrs = { - "nisMapEntry": parsed, - } - - clear = [key for key, value in attrs.items() if isinstance(value, DeleteAttribute)] - replace = { - key: value for key, value in attrs.items() if value is not None and not isinstance(value, DeleteAttribute) - } - - args: CLIBuilderArgs = { - **self._identity, - "Replace": (self.cli.option.PLAIN, self._attrs_to_hash(replace)), - "Clear": (self.cli.option.PLAIN, ",".join(clear) if clear else None), - } - - self._modify(args) - self.info = parsed if not isinstance(parsed, DeleteAttribute) else "" - return self - - def dump(self) -> str: - """ - Dump the key in the ``automount -m`` format. - - .. code-block:: text - - export1 | -fstype=nfs,rw,sync,no_root_squash nfs.test:/dev/shm/exports/export1 - - You can also call ``str(key)`` instead of ``key.dump()``. - - :return: Key information in ``automount -m`` format. - :rtype: str - """ - return f"{self.name} | {self.info}" - - def __str__(self) -> str: - """ - Alias for :meth:`dump` method. - - :return: Key information in ``automount -m`` format. - :rtype: str - """ - return self.dump() - - def __get_info( - self, info: str | NFSExport | ADAutomountMap | DeleteAttribute | None - ) -> str | DeleteAttribute | None: - if isinstance(info, NFSExport): - return info.get() - - if isinstance(info, ADAutomountMap): - return info.name - - return info diff --git a/src/tests/system/lib/sssd/roles/base.py b/src/tests/system/lib/sssd/roles/base.py deleted file mode 100644 index 1c8a21b2279..00000000000 --- a/src/tests/system/lib/sssd/roles/base.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Base classes and objects for SSSD specific multihost roles.""" - -from __future__ import annotations - -from abc import abstractmethod -from typing import Any, Generic, TypeGuard, TypeVar - -from pytest_mh import MultihostRole -from pytest_mh.cli import CLIBuilder -from pytest_mh.utils.fs import LinuxFileSystem -from pytest_mh.utils.services import SystemdServices - -from ..hosts.base import BaseHost, BaseLDAPDomainHost -from ..utils.authentication import AuthenticationUtils -from ..utils.authselect import AuthselectUtils -from ..utils.ldap import LDAPUtils -from ..utils.tools import LinuxToolsUtils - -HostType = TypeVar("HostType", bound=BaseHost) -RoleType = TypeVar("RoleType", bound=MultihostRole) -LDAPHostType = TypeVar("LDAPHostType", bound=BaseLDAPDomainHost) - - -__all__ = [ - "HostType", - "RoleType", - "LDAPHostType", - "DeleteAttribute", - "BaseObject", - "BaseRole", - "BaseLinuxRole", - "BaseLinuxLDAPRole", - "BaseWindowsRole", -] - - -class DeleteAttribute(object): - """ - This class is used to distinguish between setting an attribute to an empty - value and deleting it completely. - """ - - pass - - -class BaseObject(Generic[HostType, RoleType]): - """ - Base class for object management classes (like users or groups). - - It provides shortcuts to low level functionality to easily enable execution - of remote commands. It also defines multiple helper methods that are shared - across roles. - """ - - def __init__(self, role: RoleType) -> None: - self.role: RoleType = role - """Multihost role object.""" - - self.host: HostType = role.host - """Multihost host object.""" - - self.cli: CLIBuilder = self.host.cli - """Command line builder to easy build command line for execution.""" - - -class BaseRole(MultihostRole[HostType]): - """ - Base role class. Roles are the main interface to the remote hosts that can - be directly accessed in test cases as fixtures. - - All changes to the remote host that were done through the role object API - are automatically reverted when a test is finished. - """ - - Delete: DeleteAttribute = DeleteAttribute() - """ - Use this to indicate that you want to delete an attribute instead of setting - it to an empty value. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - def is_delete_attribute(self, value: Any) -> TypeGuard[DeleteAttribute]: - """ - Return ``True`` if the value is :attr:`DeleteAttribute` - - :param value: Value to test. - :type value: Any - :return: Return ``True`` if the value is :attr:`DeleteAttribute` - :rtype: TypeGuard[DeleteAttribute] - """ - return isinstance(value, DeleteAttribute) - - -class BaseLinuxRole(BaseRole[HostType]): - """ - Base linux role. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.authselect: AuthselectUtils = AuthselectUtils(self.host) - """ - Manage nsswitch and PAM configuration. - """ - - self.fs: LinuxFileSystem = LinuxFileSystem(self.host) - """ - File system manipulation. - """ - - self.svc: SystemdServices = SystemdServices(self.host) - """ - Systemd service management. - """ - - self.tools: LinuxToolsUtils = LinuxToolsUtils(self.host, self.fs) - """ - Standard tools interface. - """ - - self.auth: AuthenticationUtils = AuthenticationUtils(self.host) - """ - Authentication helpers. - """ - - -class BaseLinuxLDAPRole(BaseLinuxRole[LDAPHostType]): - """ - Base Linux role for roles that require direct LDAP access. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.ldap: LDAPUtils = LDAPUtils(self.host) - """Provides methods for direct LDAP access to the LDAP server.""" - - self.auto_ou: dict[str, bool] = {} - """Organizational units that were automatically created.""" - - @abstractmethod - def ou(self, name: str, basedn=None): - pass - - -class BaseWindowsRole(BaseRole[HostType]): - """ - Base Windows role. - """ - - pass diff --git a/src/tests/system/lib/sssd/roles/client.py b/src/tests/system/lib/sssd/roles/client.py deleted file mode 100644 index bf4b252bf02..00000000000 --- a/src/tests/system/lib/sssd/roles/client.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Client multihost role.""" - -from __future__ import annotations - -from ..hosts.base import BaseHost -from ..topology import SSSDTopologyMark -from ..utils.automount import AutomountUtils -from ..utils.local_users import LocalUsersUtils -from ..utils.sssd import SSSDUtils -from .base import BaseLinuxRole - -__all__ = [ - "Client", -] - - -class Client(BaseLinuxRole[BaseHost]): - """ - SSSD Client role. - - Provides unified Python API for managing and testing SSSD. - - .. code-block:: python - :caption: Starting SSSD - - @pytest.mark.topology(KnownTopology.Client) - def test_example(client: Client): - client.sssd.start() - - .. note:: - - The role object is instantiated automatically as a dynamic pytest - fixture by the multihost plugin. You should not create the object - manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.sssd: SSSDUtils = SSSDUtils(self.host, self.fs, self.svc, self.authselect, load_config=False) - """ - Managing and configuring SSSD. - """ - - self.automount: AutomountUtils = AutomountUtils(self.host, self.svc) - """ - Methods for testing automount. - """ - - self.local: LocalUsersUtils = LocalUsersUtils(self.host) - """ - Managing local users and groups. - """ - - def setup(self) -> None: - """ - Called before execution of each test. - - Setup client host: - - #. stop sssd - #. clear sssd cache, logs and configuration - #. import implicit domains from topology marker - """ - super().setup() - self.sssd.stop() - self.sssd.clear(db=True, memcache=True, logs=True, config=True) - - if self.mh.data.topology_mark is not None: - if not isinstance(self.mh.data.topology_mark, SSSDTopologyMark): - raise ValueError("Multihost data does not have SSSDTopologyMark") - - for domain, path in self.mh.data.topology_mark.domains.items(): - role = self.mh._lookup(path) - if isinstance(role, list): - raise ValueError("List is not expected") - - self.sssd.import_domain(domain, role) diff --git a/src/tests/system/lib/sssd/roles/generic.py b/src/tests/system/lib/sssd/roles/generic.py deleted file mode 100644 index c87f033e466..00000000000 --- a/src/tests/system/lib/sssd/roles/generic.py +++ /dev/null @@ -1,647 +0,0 @@ -"""Generic roles used with topology parametrization.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - -from pytest_mh import MultihostRole - -from ..hosts.base import BaseHost -from .base import BaseObject -from .nfs import NFSExport - -__all__ = [ - "GenericProvider", - "GenericADProvider", - "GenericUser", - "GenericGroup", - "GenericSudoRule", - "GenericAutomount", - "GenericAutomountMap", - "GenericAutomountKey", -] - - -class GenericProvider(ABC, MultihostRole[BaseHost]): - """ - Generic provider interface. All providers implements this interface. - - .. note:: - - This class provides generic interface for provider roles. It can be used - for type hinting only on parametrized tests that runs on multiple - topologies. - """ - - @abstractmethod - def user(self, name: str) -> GenericUser: - """ - Get user object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_example(client: Client, provider: GenericProvider): - # Create user - provider.user('user-1').add() - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - - :param name: User name. - :type name: str - :return: New user object. - :rtype: GenericUser - """ - pass - - @abstractmethod - def group(self, name: str) -> GenericGroup: - """ - Get group object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_example(client: Client, provider: GenericProvider): - # Create user - user = provider.user('user-1').add() - - # Create secondary group and add user as a member - provider.group('group-1').add().add_member(user) - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.memberof('group-1') - - :param name: Group name. - :type name: str - :return: New group object. - :rtype: GenericGroup - """ - pass - - @abstractmethod - def sudorule(self, name: str) -> GenericSudoRule: - """ - Get sudo rule object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_example(client: Client, provider: GenericProvider): - user = provider.user('user-1').add(password="Secret123") - provider.sudorule('testrule').add(user=user, host='ALL', command='/bin/ls') - - client.sssd.common.sudo() - client.sssd.start() - - # Test that user can run /bin/ls - assert client.auth.sudo.run('user-1', 'Secret123', command='/bin/ls') - - :param name: Sudo rule name. - :type name: str - :return: New sudo rule object. - :rtype: GenericSudoRule - """ - pass - - @property - @abstractmethod - def automount(self) -> GenericAutomount: - """ - Manage automount maps and keys. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_example(client: Client, provider: GenericProvider, nfs: NFS): - nfs_export1 = nfs.export('export1').add() - nfs_export2 = nfs.export('export2').add() - nfs_export3 = nfs.export('sub/export3').add() - - # Create automount maps - auto_master = provider.automount.map('auto.master').add() - auto_home = provider.automount.map('auto.home').add() - auto_sub = provider.automount.map('auto.sub').add() - - # Create mount points - auto_master.key('/ehome').add(info=auto_home) - auto_master.key('/esub/sub1/sub2').add(info=auto_sub) - - # Create mount keys - key1 = auto_home.key('export1').add(info=nfs_export1) - key2 = auto_home.key('export2').add(info=nfs_export2) - key3 = auto_sub.key('export3').add(info=nfs_export3) - - # Start SSSD - client.sssd.common.autofs() - client.sssd.start() - - # Reload automounter in order to fetch updated maps - client.automount.reload() - - # Check that we can mount all directories on correct locations - assert client.automount.mount('/ehome/export1', nfs_export1) - assert client.automount.mount('/ehome/export2', nfs_export2) - assert client.automount.mount('/esub/sub1/sub2/export3', nfs_export3) - - # Check that the maps are correctly fetched - assert client.automount.dumpmaps() == { - '/ehome': { - 'map': 'auto.home', - 'keys': [str(key1), str(key2)] - }, - '/esub/sub1/sub2': { - 'map': 'auto.sub', - 'keys': [str(key3)] - }, - } - """ - pass - - -class GenericADProvider(GenericProvider): - """ - Generic Active Directory provider interface. Active Directory and Samba - providers implements this interface. - - .. note:: - - This class provides generic interface for Active Directory-based - roles. It can be used for type hinting only on parametrized tests - that runs on both Samba and Active Directory. - """ - - pass - - -class GenericUser(ABC, BaseObject): - """ - Generic user management. - """ - - @abstractmethod - def add( - self, - *, - uid: int | None = None, - gid: int | None = None, - password: str = "Secret123", - home: str | None = None, - gecos: str | None = None, - shell: str | None = None, - ) -> GenericUser: - """ - Create a new user. - - Parameters that are not set are ignored. - - :param uid: User id, defaults to None - :type uid: int | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | None, optional - :param password: User password, defaults to 'Secret123' - :type password: str, optional - :param home: Home directory, defaults to None - :type home: str | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | None, optional - :param shell: Login shell, defaults to None - :type shell: str | None, optional - :return: Self. - :rtype: GenericUser - """ - pass - - @abstractmethod - def modify( - self, - *, - uid: int | None = None, - gid: int | None = None, - password: str | None = None, - home: str | None = None, - gecos: str | None = None, - shell: str | None = None, - ) -> GenericUser: - """ - Modify existing user. - - Parameters that are not set are ignored. - - :param uid: User id, defaults to None - :type uid: int | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | None, optional - :param password: Password, defaults to None - :type password: str, optional - :param home: Home directory, defaults to None - :type home: str | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | None, optional - :param shell: Login shell, defaults to None - :type shell: str | None, optional - :return: Self. - :rtype: GenericUser - """ - pass - - @abstractmethod - def delete(self) -> None: - """ - Delete the user. - """ - pass - - @abstractmethod - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get user attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - pass - - -class GenericGroup(ABC, BaseObject): - """ - Generic group management. - """ - - @abstractmethod - def add( - self, - *, - gid: int | None = None, - description: str | None = None, - ) -> GenericGroup: - """ - Create a new group. - - Parameters that are not set are ignored. - - :param gid: Group id, defaults to None - :type gid: int | None, optional - :param description: Description, defaults to None - :type description: str | None, optional - :return: Self. - :rtype: GenericGroup - """ - pass - - @abstractmethod - def modify( - self, - *, - gid: int | None = None, - description: str | None = None, - ) -> GenericGroup: - """ - Modify existing group. - - Parameters that are not set are ignored. - - :param gid: Group id, defaults to None - :type gid: int | None, optional - :param description: Description, defaults to None - :type description: str | None, optional - :return: Self. - :rtype: GenericGroup - """ - pass - - @abstractmethod - def delete(self) -> None: - """ - Delete the group. - """ - pass - - @abstractmethod - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get group attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - pass - - @abstractmethod - def add_member(self, member: GenericUser | GenericGroup) -> GenericGroup: - """ - Add group member. - - :param member: User or group to add as a member. - :type member: GenericUser | GenericGroup - :return: Self. - :rtype: GenericGroup - """ - pass - - @abstractmethod - def add_members(self, members: list[GenericUser | GenericGroup]) -> GenericGroup: - """ - Add multiple group members. - - :param member: List of users or groups to add as members. - :type member: list[GenericUser | GenericGroup] - :return: Self. - :rtype: GenericGroup - """ - pass - - @abstractmethod - def remove_member(self, member: GenericUser | GenericGroup) -> GenericGroup: - """ - Remove group member. - - :param member: User or group to remove from the group. - :type member: GenericUser | GenericGroup - :return: Self. - :rtype: GenericGroup - """ - pass - - @abstractmethod - def remove_members(self, members: list[GenericUser | GenericGroup]) -> GenericGroup: - """ - Remove multiple group members. - - :param member: List of users or groups to remove from the group. - :type member: list[GenericUser | GenericGroup] - :return: Self. - :rtype: GenericGroup - """ - pass - - -class GenericSudoRule(ABC, BaseObject): - """ - Generic sudo rule management. - """ - - @abstractmethod - def add( - self, - *, - user: str | GenericUser | GenericGroup | list[str | GenericUser | GenericGroup] | None = None, - host: str | list[str] | None = None, - command: str | list[str] | None = None, - option: str | list[str] | None = None, - runasuser: str | GenericUser | GenericGroup | list[str | GenericUser | GenericGroup] | None = None, - runasgroup: str | GenericGroup | list[str | GenericGroup] | None = None, - order: int | None = None, - nopasswd: bool | None = None, - ) -> GenericSudoRule: - """ - Create new sudo rule. - - :param user: sudoUser attribute, defaults to None - :type user: str | GenericUser | GenericGroup | list[str | GenericUser | GenericGroup] | None, optional - :param host: sudoHost attribute, defaults to None - :type host: str | list[str] | None, optional - :param command: sudoCommand attribute, defaults to None - :type command: str | list[str] | None, optional - :param option: sudoOption attribute, defaults to None - :type option: str | list[str] | None, optional - :param runasuser: sudoRunAsUser attribute, defaults to None - :type runasuser: str | GenericUser | GenericGroup | list[str | GenericUser | GenericGroup] | None, optional - :param runasgroup: sudoRunAsGroup attribute, defaults to None - :type runasgroup: str | GenericGroup | list[str | GenericGroup] | None, optional - :param order: sudoOrder attribute, defaults to None - :type order: int | None, optional - :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) - :type nopasswd: bool | None, optional - :return: _description_ - :rtype: GenericSudoRule - """ - pass - - @abstractmethod - def modify( - self, - *, - user: str | GenericUser | GenericGroup | list[str | GenericUser | GenericGroup] | None = None, - host: str | list[str] | None = None, - command: str | list[str] | None = None, - option: str | list[str] | None = None, - runasuser: str | GenericUser | GenericGroup | list[str | GenericUser | GenericGroup] | None = None, - runasgroup: str | GenericGroup | list[str | GenericGroup] | None = None, - order: int | None = None, - nopasswd: bool | None = None, - ) -> GenericSudoRule: - """ - Create new sudo rule. - - :param user: sudoUser attribute, defaults to None - :type user: str | GenericUser | GenericGroup | list[str | GenericUser | GenericGroup] | None, optional - :param host: sudoHost attribute, defaults to None - :type host: str | list[str] | None, optional - :param command: sudoCommand attribute, defaults to None - :type command: str | list[str] | None, optional - :param option: sudoOption attribute, defaults to None - :type option: str | list[str] | None, optional - :param runasuser: sudoRunAsUser attribute, defaults to None - :type runasuser: str | GenericUser | GenericGroup | list[str | GenericUser | GenericGroup] | None, optional - :param runasgroup: sudoRunAsGroup attribute, defaults to None - :type runasgroup: str | GenericGroup | list[str | GenericGroup] | None, optional - :param order: sudoOrder attribute, defaults to None - :type order: int | None, optional - :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) - :type nopasswd: bool | None, optional - :return: _description_ - :rtype: GenericSudoRule - """ - pass - - @abstractmethod - def delete(self) -> None: - """ - Delete the sudo rule. - """ - pass - - @abstractmethod - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get sudo rule attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - pass - - -class GenericAutomount(ABC): - """ - Generic automount management. - """ - - @abstractmethod - def map(self, name: str) -> GenericAutomountMap: - """ - Get automount map object. - - :param name: Automount map name. - :type name: str - :return: New automount map object. - :rtype: GenericAutomountMap - """ - pass - - @abstractmethod - def key(self, name: str, map: GenericAutomountMap) -> GenericAutomountKey: - """ - Get automount key object. - - :param name: Automount key name. - :type name: str - :param map: Automount map that is a parent to this key. - :type map: GenericAutomountMap - :return: New automount key object. - :rtype: GenericAutomountKey - """ - pass - - -class GenericAutomountMap(ABC, BaseObject): - """ - Generic automount map management. - """ - - @abstractmethod - def add(self) -> GenericAutomountMap: - """ - Create new automount map. - - :return: Self. - :rtype: GenericAutomountMap - """ - pass - - @abstractmethod - def key(self, name: str) -> GenericAutomountKey: - """ - Get automount key object for this map. - - :param name: Automount key name. - :type name: str - :return: New automount key object. - :rtype: GenericAutomountKey - """ - pass - - @abstractmethod - def delete(self) -> None: - """ - Delete the automout map. - """ - pass - - @abstractmethod - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get automount map attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - pass - - -class GenericAutomountKey(ABC, BaseObject): - """ - Generic automount key management. - """ - - @abstractmethod - def add(self, *, info: str | NFSExport | GenericAutomountMap) -> GenericAutomountKey: - """ - Create new automount key. - - :param info: Automount information. - :type info: str | NFSExport | GenericAutomountMap - :return: Self. - :rtype: GenericAutomountKey - """ - pass - - @abstractmethod - def modify( - self, - *, - info: str | NFSExport | GenericAutomountMap | None = None, - ) -> GenericAutomountKey: - """ - Modify existing automount key. - - :param info: Automount information, defaults to ``None`` - :type info: str | NFSExport | GenericAutomountMap | None - :return: Self. - :rtype: GenericAutomountKey - """ - pass - - @abstractmethod - def delete(self) -> None: - """ - Delete the automount key. - """ - pass - - @abstractmethod - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get automount key attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - pass - - @abstractmethod - def dump(self) -> str: - """ - Dump the key in the ``automount -m`` format. - - .. code-block:: text - - export1 | -fstype=nfs,rw,sync,no_root_squash nfs.test:/dev/shm/exports/export1 - - You can also call ``str(key)`` instead of ``key.dump()``. - - :return: Key information in ``automount -m`` format. - :rtype: str - """ - pass - - @abstractmethod - def __str__(self) -> str: - pass diff --git a/src/tests/system/lib/sssd/roles/ipa.py b/src/tests/system/lib/sssd/roles/ipa.py deleted file mode 100644 index d15d9be21fa..00000000000 --- a/src/tests/system/lib/sssd/roles/ipa.py +++ /dev/null @@ -1,1104 +0,0 @@ -"""IPA multihost role.""" - -from __future__ import annotations - -from typing import Any - -from pytest_mh.cli import CLIBuilderArgs -from pytest_mh.ssh import SSHProcessResult - -from ..hosts.ipa import IPAHost -from ..misc import attrs_include_value, attrs_parse, to_list, to_list_of_strings -from .base import BaseLinuxRole, BaseObject -from .nfs import NFSExport - -__all__ = [ - "IPA", - "IPAObject", - "IPAUser", - "IPAGroup", - "IPASudoRule", - "IPAAutomount", - "IPAAutomountLocation", - "IPAAutomountMap", - "IPAAutomountKey", -] - - -class IPA(BaseLinuxRole[IPAHost]): - """ - IPA role. - - Provides unified Python API for managing objects in the IPA server. - - .. code-block:: python - :caption: Creating user and group - - @pytest.mark.topology(KnownTopology.IPA) - def test_example(ipa: IPA): - u = ipa.user('tuser').add() - g = ipa.group('tgroup').add() - g.add_member(u) - - .. note:: - - The role object is instantiated automatically as a dynamic pytest - fixture by the multihost plugin. You should not create the object - manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.automount: IPAAutomount = IPAAutomount(self) - """ - Manage automount locations, maps and keys. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.IPA) - def test_example(client: Client, ipa: IPA, nfs: NFS): - nfs_export1 = nfs.export('export1').add() - nfs_export2 = nfs.export('export2').add() - nfs_export3 = nfs.export('sub/export3').add() - - # Create automout location - loc = ipa.automount.location('boston').add() - - # Create automount maps - auto_master = loc.map('auto.master').add() - auto_home = loc.map('auto.home').add() - auto_sub = loc.map('auto.sub').add() - - # Create mount points - auto_master.key('/ehome').add(info=auto_home) - auto_master.key('/esub/sub1/sub2').add(info=auto_sub) - - # Create mount keys - key1 = auto_home.key('export1').add(info=nfs_export1) - key2 = auto_home.key('export2').add(info=nfs_export2) - key3 = auto_sub.key('export3').add(info=nfs_export3) - - # Start SSSD - client.sssd.common.autofs() - client.sssd.domain['ipa_automount_location'] = 'boston' - client.sssd.start() - - # Reload automounter in order to fetch updated maps - client.automount.reload() - - # Check that we can mount all directories on correct locations - assert client.automount.mount('/ehome/export1', nfs_export1) - assert client.automount.mount('/ehome/export2', nfs_export2) - assert client.automount.mount('/esub/sub1/sub2/export3', nfs_export3) - - # Check that the maps are correctly fetched - assert client.automount.dumpmaps() == { - '/ehome': { - 'map': 'auto.home', - 'keys': [str(key1), str(key2)] - }, - '/esub/sub1/sub2': { - 'map': 'auto.sub', - 'keys': [str(key3)] - }, - } - """ - - def setup(self) -> None: - """ - Obtain IPA admin Kerberos TGT. - """ - super().setup() - self.host.kinit() - - def user(self, name: str) -> IPAUser: - """ - Get user object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.IPA) - def test_example(client: Client, ipa: IPA): - # Create user - ipa.user('user-1').add() - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.group.name == 'user-1' - - :param name: User name. - :type name: str - :return: New user object. - :rtype: IPAUser - """ - return IPAUser(self, name) - - def group(self, name: str) -> IPAGroup: - """ - Get group object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.IPA) - def test_example_group(client: Client, ipa: IPA): - # Create user - user = ipa.user('user-1').add() - - # Create secondary group and add user as a member - ipa.group('group-1').add().add_member(user) - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.group.name == 'user-1' - assert result.memberof('group-1') - - :param name: Group name. - :type name: str - :return: New group object. - :rtype: IPAGroup - """ - return IPAGroup(self, name) - - def sudorule(self, name: str) -> IPASudoRule: - """ - Get sudo rule object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.IPA) - def test_example(client: Client, ipa: IPA): - user = ipa.user('user-1').add(password="Secret123") - ipa.sudorule('testrule').add(user=user, host='ALL', command='/bin/ls') - - client.sssd.common.sudo() - client.sssd.start() - - # Test that user can run /bin/ls - assert client.auth.sudo.run('user-1', 'Secret123', command='/bin/ls') - - :param name: Sudo rule name. - :type name: str - :return: New sudo rule object. - :rtype: IPASudoRule - """ - return IPASudoRule(self, name) - - -class IPAObject(BaseObject[IPAHost, IPA]): - """ - Base class for IPA object management. - - Provides shortcuts for command execution and implementation of :meth:`get` - and :meth:`delete` methods. - """ - - def __init__(self, role: IPA, name: str, command_group: str) -> None: - """ - :param role: IPA role object. - :type role: IPA - :param name: Object name. - :type name: str - :param command_group: IPA command group. - :type command: str - """ - super().__init__(role) - self.command_group: str = command_group - """IPA cli command group.""" - - self.name: str = name - """Object name.""" - - def _exec(self, op: str, args: list[str] | None = None, **kwargs) -> SSHProcessResult: - """ - Execute IPA command. - - .. code-block:: console - - $ ipa $command_group-$op $name $args - for example >>> ipa user-add tuser - - :param op: Command group operation (usually add, mod, del, show) - :type op: str - :param args: List of additional command arguments, defaults to None - :type args: list[str] | None, optional - :return: SSH process result. - :rtype: SSHProcessResult - """ - if args is None: - args = [] - - return self.role.host.ssh.exec(["ipa", f"{self.command_group}-{op}", self.name, *args], **kwargs) - - def _add(self, attrs: CLIBuilderArgs | None = None, input: str | None = None): - """ - Add IPA object. - - :param attrs: Object attributes in :class:`pytest_mh.cli.CLIBuilder` format, defaults to None - :type attrs: pytest_mh.cli.CLIBuilderArgs | None, optional - :param input: Contents of standard input given to the executed command, defaults to None - :type input: str | None, optional - """ - if attrs is None: - attrs = {} - - self._exec("add", self.cli.args(attrs), input=input) - - def _modify(self, attrs: CLIBuilderArgs, input: str | None = None): - """ - Modify IPA object. - - :param attrs: Object attributes in :class:`pytest_mh.cli.CLIBuilder` format, defaults to dict() - :type attrs: pytest_mh.cli.CLIBuilderArgs, optional - :param input: Contents of standard input given to the executed command, defaults to None - :type input: str | None, optional - """ - self._exec("mod", self.cli.args(attrs), input=input) - - def delete(self) -> None: - """ - Delete IPA object. - """ - self._exec("del") - - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get IPA object attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - cmd = self._exec("show", ["--all", "--raw"]) - - # Remove first line that contains the object name and not attribute - return attrs_parse(cmd.stdout_lines[1:], attrs) - - -class IPAUser(IPAObject): - """ - IPA user management. - """ - - def __init__(self, role: IPA, name: str) -> None: - """ - :param role: IPA role object. - :type role: IPA - :param name: User name. - :type name: str - """ - super().__init__(role, name, command_group="user") - - def add( - self, - *, - uid: int | None = None, - gid: int | None = None, - password: str | None = "Secret123", - home: str | None = None, - gecos: str | None = None, - shell: str | None = None, - require_password_reset: bool = False, - ) -> IPAUser: - """ - Create new IPA user. - - Parameters that are not set are ignored. - - :param uid: User id, defaults to None - :type uid: int | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | None, optional - :param password: Password, defaults to 'Secret123' - :type password: str | None, optional - :param home: Home directory, defaults to None - :type home: str | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | None, optional - :param shell: Login shell, defaults to None - :type shell: str | None, optional - :param require_password_reset: Require password reset on first login, defaults to False - :type require_password_reset: bool, optional - :return: Self. - :rtype: IPAUser - """ - attrs = { - "first": (self.cli.option.VALUE, self.name), - "last": (self.cli.option.VALUE, self.name), - "uid": (self.cli.option.VALUE, uid), - "gidnumber": (self.cli.option.VALUE, gid), - "homedir": (self.cli.option.VALUE, home), - "gecos": (self.cli.option.VALUE, gecos), - "shell": (self.cli.option.VALUE, shell), - "password": (self.cli.option.SWITCH, True) if password is not None else None, - } - - if not require_password_reset: - attrs["password-expiration"] = (self.cli.option.VALUE, "20380805120000Z") - - self._add(attrs, input=password) - return self - - def modify( - self, - *, - uid: int | None = None, - gid: int | None = None, - password: str | None = None, - home: str | None = None, - gecos: str | None = None, - shell: str | None = None, - ) -> IPAUser: - """ - Modify existing IPA user. - - Parameters that are not set are ignored. - - :param uid: User id, defaults to None - :type uid: int | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | None, optional - :param password: Password, defaults to 'Secret123' - :type password: str | None, optional - :param home: Home directory, defaults to None - :type home: str | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | None, optional - :param shell: Login shell, defaults to None - :type shell: str | None, optional - :return: Self. - :rtype: IPAUser - """ - attrs = { - "uid": (self.cli.option.VALUE, uid), - "gidnumber": (self.cli.option.VALUE, gid), - "homedir": (self.cli.option.VALUE, home), - "gecos": (self.cli.option.VALUE, gecos), - "shell": (self.cli.option.VALUE, shell), - "password": (self.cli.option.SWITCH, True) if password is not None else None, - } - - self._modify(attrs, input=password) - return self - - -class IPAGroup(IPAObject): - """ - IPA group management. - """ - - def __init__(self, role: IPA, name: str) -> None: - """ - :param role: IPA role object. - :type role: IPA - :param name: Group name. - :type name: str - """ - super().__init__(role, name, command_group="group") - - def add( - self, - *, - gid: int | None = None, - description: str | None = None, - nonposix: bool = False, - external: bool = False, - ) -> IPAGroup: - """ - Create new IPA group. - - Parameters that are not set are ignored. - - :param gid: Group id, defaults to None - :type gid: int | None, optional - :param description: Description, defaults to None - :type description: str | None, optional - :param nonposix: Group is non-posix group, defaults to False - :type nonposix: bool, optional - :param external: Group is external group, defaults to False - :type external: bool, optional - :return: Self. - :rtype: IPAGroup - """ - attrs = { - "gid": (self.cli.option.VALUE, gid), - "desc": (self.cli.option.VALUE, description), - "nonposix": (self.cli.option.SWITCH, True) if nonposix else None, - "external": (self.cli.option.SWITCH, True) if external else None, - } - - self._add(attrs) - return self - - def modify( - self, - *, - gid: int | None = None, - description: str | None = None, - ) -> IPAGroup: - """ - Modify existing IPA group. - - Parameters that are not set are ignored. - - :param gid: Group id, defaults to None - :type gid: int | None, optional - :param description: Description, defaults to None - :type description: str | None, optional - :return: Self. - :rtype: IPAGroup - """ - attrs: CLIBuilderArgs = { - "gid": (self.cli.option.VALUE, gid), - "desc": (self.cli.option.VALUE, description), - } - - self._modify(attrs) - return self - - def add_member(self, member: IPAUser | IPAGroup) -> IPAGroup: - """ - Add group member. - - :param member: User or group to add as a member. - :type member: IPAUser | IPAGroup - :return: Self. - :rtype: IPAGroup - """ - return self.add_members([member]) - - def add_members(self, members: list[IPAUser | IPAGroup]) -> IPAGroup: - """ - Add multiple group members. - - :param member: List of users or groups to add as members. - :type member: list[IPAUser | IPAGroup] - :return: Self. - :rtype: IPAGroup - """ - self._exec("add-member", self.__get_member_args(members)) - return self - - def remove_member(self, member: IPAUser | IPAGroup) -> IPAGroup: - """ - Remove group member. - - :param member: User or group to remove from the group. - :type member: IPAUser | IPAGroup - :return: Self. - :rtype: IPAGroup - """ - return self.remove_members([member]) - - def remove_members(self, members: list[IPAUser | IPAGroup]) -> IPAGroup: - """ - Remove multiple group members. - - :param member: List of users or groups to remove from the group. - :type member: list[IPAUser | IPAGroup] - :return: Self. - :rtype: IPAGroup - """ - self._exec("remove-member", self.__get_member_args(members)) - return self - - def __get_member_args(self, members: list[IPAUser | IPAGroup]) -> list[str]: - users = [x for item in members if isinstance(item, IPAUser) for x in ("--users", item.name)] - groups = [x for item in members if isinstance(item, IPAGroup) for x in ("--groups", item.name)] - return [*users, *groups] - - -class IPASudoRule(IPAObject): - """ - IPA sudo rule management. - """ - - def __init__(self, role: IPA, name: str) -> None: - """ - :param role: IPA role object. - :type role: IPA - :param name: Sudo rule name. - :type name: str - """ - super().__init__(role, name, command_group="sudorule") - self.__rule: dict[str, Any] = dict() - - def add( - self, - *, - user: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None = None, - host: str | list[str] | None = None, - command: str | list[str] | None = None, - option: str | list[str] | None = None, - runasuser: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None = None, - runasgroup: str | IPAGroup | list[str | IPAGroup] | None = None, - order: int | None = None, - nopasswd: bool | None = None, - ) -> IPASudoRule: - """ - Create new sudo rule. - - :param user: sudoUser attribute, defaults to None - :type user: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None, optional - :param host: sudoHost attribute, defaults to None - :type host: str | list[str] | None, optional - :param command: sudoCommand attribute, defaults to None - :type command: str | list[str] | None, optional - :param option: sudoOption attribute, defaults to None - :type option: str | list[str] | None, optional - :param runasuser: sudoRunAsUser attribute, defaults to None - :type runasuser: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None, optional - :param runasgroup: sudoRunAsGroup attribute, defaults to None - :type runasgroup: str | IPAGroup | list[str | IPAGroup] | None, optional - :param order: sudoOrder attribute, defaults to None - :type order: int | None, optional - :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) - :type nopasswd: bool | None, optional - :return: _description_ - :rtype: IPASudoRule - """ - # Remember arguments so we can use them in modify if needed - self.__rule = dict( - user=user, - host=host, - command=command, - option=option, - runasuser=runasuser, - runasgroup=runasgroup, - order=order, - nopasswd=nopasswd, - ) - - # Prepare data - (allow_commands, deny_commands, cmdcat) = self.__get_commands(command) - (hosts, hostcat) = self.__get_hosts(host) - (users, groups, usercat) = self.__get_users_and_groups(user) - options = to_list_of_strings(option) - (runasuser_users, runasuser_groups, runasusercat) = self.__get_run_as_user(runasuser) - (runasgroup_groups, runasgroupcat) = self.__get_run_as_group(runasgroup) - - if nopasswd is True: - options = attrs_include_value(options, "!authenticate") - elif nopasswd is False: - options = attrs_include_value(options, "authenticate") - - # Add commands - for cmd in allow_commands + deny_commands: - self.role.host.ssh.run(f'ipa sudocmd-find "{cmd}" || ipa sudocmd-add "{cmd}"') - - # Add command group for commands allowed by this rule - self.role.host.ssh.run(f'ipa sudocmdgroup-add "{self.name}_allow"') - args = self.__args_from_list("sudocmds", allow_commands) - self.__exec_with_args("sudocmdgroup-add-member", f"{self.name}_allow", args) - - # Add command groups for commands denied by this rule - self.role.host.ssh.run(f'ipa sudocmdgroup-add "{self.name}_deny"') - args = self.__args_from_list("sudocmds", deny_commands) - self.__exec_with_args("sudocmdgroup-add-member", f"{self.name}_deny", args) - - # Add sudo rule - args = "" if order is None else f'"{order}"' - args += f" {cmdcat} {usercat} {hostcat} {runasusercat} {runasgroupcat}" - self.role.host.ssh.run(f'ipa sudorule-add "{self.name}" {args}') - - # Allow and deny commands through command groups - if not cmdcat: - self.role.host.ssh.run(f'ipa sudorule-add-allow-command "{self.name}" "--sudocmdgroups={self.name}_allow"') - self.role.host.ssh.run(f'ipa sudorule-add-deny-command "{self.name}" "--sudocmdgroups={self.name}_deny"') - - # Add hosts - args = self.__args_from_list("hosts", hosts) - self.__exec_with_args("sudorule-add-host", self.name, args) - - # Add options - for opt in options: - self.role.host.ssh.run(f'ipa sudorule-add-option "{self.name}" "--sudooption={opt}"') - - # Add run as user - args_users = self.__args_from_list("users", runasuser_users) - args_groups = self.__args_from_list("groups", runasuser_groups) - self.__exec_with_args("sudorule-add-runasuser", self.name, args_users + args_groups) - - # Add run as group - args = self.__args_from_list("groups", runasgroup_groups) - self.__exec_with_args("sudorule-add-runasgroup", self.name, args) - - # Add users and groups - args_users = self.__args_from_list("users", users) - args_groups = self.__args_from_list("groups", groups) - self.__exec_with_args("sudorule-add-user", self.name, args_users + args_groups) - - return self - - def modify( - self, - *, - user: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None = None, - host: str | list[str] | None = None, - command: str | list[str] | None = None, - option: str | list[str] | None = None, - runasuser: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None = None, - runasgroup: str | IPAGroup | list[str | IPAGroup] | None = None, - order: int | None = None, - nopasswd: bool | None = None, - ) -> IPASudoRule: - """ - Modify existing IPA sudo rule. - - :param user: sudoUser attribute, defaults to None - :type user: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None, optional - :param host: sudoHost attribute, defaults to None - :type host: str | list[str] | None, optional - :param command: sudoCommand attribute, defaults to None - :type command: str | list[str] | None, optional - :param option: sudoOption attribute, defaults to None - :type option: str | list[str] | None, optional - :param runasuser: sudoRunAsUser attribute, defaults to None - :type runasuser: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None, optional - :param runasgroup: sudoRunAsGroup attribute, defaults to None - :type runasgroup: str | IPAGroup | list[str | IPAGroup] | None, optional - :param order: sudoOrder attribute, defaults to None - :type order: int | None, optional - :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) - :type nopasswd: bool | None, optional - :return: _description_ - :rtype: IPASudoRule - """ - self.delete() - print(self.__rule) - self.add( - user=user if user is not None else self.__rule.get("user", None), - host=host if host is not None else self.__rule.get("host", None), - command=command if command is not None else self.__rule.get("command", None), - option=option if option is not None else self.__rule.get("option", None), - runasuser=runasuser if runasuser is not None else self.__rule.get("runasuser", None), - runasgroup=runasgroup if runasgroup is not None else self.__rule.get("runasgroup", None), - order=order if order is not None else self.__rule.get("order", None), - nopasswd=nopasswd if nopasswd is not None else self.__rule.get("nopasswd", None), - ) - - return self - - def delete(self) -> None: - """ - Delete sudo rule from IPA. - """ - self.role.host.ssh.run(f'ipa sudorule-del "{self.name}"') - self.role.host.ssh.run(f'ipa sudocmdgroup-del "{self.name}_allow"') - self.role.host.ssh.run(f'ipa sudocmdgroup-del "{self.name}_deny"') - - def __get_commands(self, value: str | list[str] | None) -> tuple[list[str], list[str], str]: - allow_commands = [] - deny_commands = [] - category = "" - for cmd in to_list_of_strings(value): - if cmd == "ALL": - category = "--cmdcat=all" - continue - - if cmd.startswith("!"): - deny_commands.append(cmd[1:]) - continue - - allow_commands.append(cmd) - - return (allow_commands, deny_commands, category) - - def __get_hosts(self, value: str | list[str] | None) -> tuple[list[str], str]: - hosts = [] - category = "" - for host in to_list_of_strings(value): - if host == "ALL": - category = "--hostcat=all" - continue - - hosts.append(host) - - return (hosts, category) - - def __get_users_and_groups( - self, value: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None - ) -> tuple[list[str], list[str], str]: - users = [] - groups = [] - category = "" - for item in to_list(value): - if isinstance(item, str) and item == "ALL": - category = "--usercat=all" - continue - - if isinstance(item, IPAGroup): - groups.append(item.name) - continue - - if isinstance(item, str) and item.startswith("%"): - groups.append(item[1:]) - continue - - if isinstance(item, IPAUser): - users.append(item.name) - continue - - if isinstance(item, str): - users.append(item) - continue - - raise ValueError(f"Unsupported type: {type(item)}") - - return (users, groups, category) - - def __get_run_as_user( - self, value: str | IPAUser | IPAGroup | list[str | IPAUser | IPAGroup] | None - ) -> tuple[list[str], list[str], str]: - (users, groups, category) = self.__get_users_and_groups(value) - if category: - category = "--runasusercat=all" - - return (users, groups, category) - - def __get_run_as_group(self, value: str | IPAGroup | list[str | IPAGroup] | None) -> tuple[list[str], str]: - groups = [] - category = "" - for item in to_list(value): - if isinstance(item, str) and item == "ALL": - category = "--runasgroupcat=all" - continue - - if isinstance(item, IPAGroup): - groups.append(item.name) - continue - - if isinstance(item, str): - groups.append(item) - continue - - raise ValueError(f"Unsupported type: {type(item)}") - - return (groups, category) - - def __args_from_list(self, option: str, value: list[str]) -> str: - if not value: - return "" - - args = "" - for cmd in value: - args += f' "--{option}={cmd}"' - - return args - - def __exec_with_args(self, cmd: str, name: str, args: str) -> None: - if args: - self.role.host.ssh.run(f'ipa {cmd} "{name}" {args}') - - -class IPAAutomount(object): - """ - IPA automount management. - """ - - def __init__(self, role: IPA) -> None: - """ - :param role: IPA role object. - :type role: IPA - """ - self.__role = role - - def location(self, name: str) -> IPAAutomountLocation: - """ - Get automount location object. - - :param name: Automount location name - :type name: str - :return: New automount location object. - :rtype: IPAAutomountLocation - """ - return IPAAutomountLocation(self.__role, name) - - def map(self, name: str, location: str = "default") -> IPAAutomountMap: - """ - Get automount map object. - - :param name: Automount map name. - :type name: str - :param location: Automount map location, defaults to ``default`` - :type location: str - :return: New automount map object. - :rtype: IPAAutomountMap - """ - return IPAAutomountMap(self.__role, name, location) - - def key(self, name: str, map: IPAAutomountMap) -> IPAAutomountKey: - """ - Get automount key object. - - :param name: Automount key name. - :type name: str - :param map: Automount map that is a parent to this key. - :type map: IPAAutomountMap - :return: New automount key object. - :rtype: IPAAutomountKey - """ - return IPAAutomountKey(self.__role, name, map) - - -class IPAAutomountLocation(IPAObject): - """ - IPA automount location management. - """ - - def __init__( - self, - role: IPA, - name: str, - ) -> None: - """ - :param role: IPA role object. - :type role: IPA - :param location: Automount map location - :type location: str - """ - super().__init__(role, name, command_group="automountlocation") - - def add( - self, - ) -> IPAAutomountLocation: - """ - Create new IPA automount location. - - :return: Self. - :rtype: IPAAutomountLocation - """ - self._add() - - # Delete auto.master and auto.direct maps that are automatically created - # in a newly added location. This makes the IPA initial state consistent - # with other providers and the tests can be more explicit. - self.map("auto.master").delete() - self.map("auto.direct").delete() - - return self - - def map(self, name: str) -> IPAAutomountMap: - """ - Get automount map object for this location. - - :param name: Automount map name. - :type name: str - :return: New automount map object. - :rtype: IPAAutomountMap - """ - return IPAAutomountMap(self.role, name, self) - - -class IPAAutomountMap(IPAObject): - """ - IPA automount map management. - """ - - def __init__(self, role: IPA, name: str, location: IPAAutomountLocation | str = "default") -> None: - """ - :param role: IPA role object. - :type role: IPA - :param name: Automount map name. - :type name: str - :param location: Automount map location, defaults to ``default`` - :type location: IPAAutomountLocation | str - """ - super().__init__(role, name, command_group="automountmap") - self.location: IPAAutomountLocation = self.__get_location(location) - - def __get_location(self, location: IPAAutomountLocation | str) -> IPAAutomountLocation: - if isinstance(location, str): - return IPAAutomountLocation(self.role, location) - elif isinstance(location, IPAAutomountLocation): - return location - else: - raise ValueError(f"Unexepected location type: {type(location)}") - - def _exec(self, op: str, args: list[str] | None = None, **kwargs) -> SSHProcessResult: - """ - Execute automoutmap IPA command. - - .. code-block:: console - - $ ipa automountmap-$op $location $mapname $args - for example >>> ipa automountmap-add default-location newmap - - :param op: Command group operation (usually add, mod, del, show) - :type op: str - :param args: List of additional command arguments, defaults to None - :type args: list[str] | None, optional - :return: SSH process result. - :rtype: SSHProcessResult - """ - if args is None: - args = [] - - defargs = self.cli.args( - { - "location": (self.cli.option.POSITIONAL, self.location.name), - "mapname": (self.cli.option.POSITIONAL, self.name), - } - ) - return self.role.host.ssh.exec(["ipa", f"{self.command_group}-{op}", *defargs, *args], **kwargs) - - def add( - self, - ) -> IPAAutomountMap: - """ - Create new IPA Automount map. - - :return: Self. - :rtype: IPAAutomountMap - """ - self._add() - return self - - def key(self, name: str) -> IPAAutomountKey: - """ - Get automount key object for this map. - - :param name: Automount key name. - :type name: str - :return: New automount key object. - :rtype: IPAAutomountKey - """ - return IPAAutomountKey(self.role, name, self) - - -class IPAAutomountKey(IPAObject): - """ - IPA automount key management. - """ - - def __init__( - self, - role: IPA, - name: str, - map: IPAAutomountMap, - ) -> None: - """ - :param role: IPA role object. - :type role: IPA - :param name: Automount key name. - :type name: str - :param map: Automount map that is a parent to this key. - :type map: IPAAutomountMap - """ - super().__init__(role, name, command_group="automountkey") - self.map: IPAAutomountMap = map - self.info: str | None = None - - def _exec(self, op: str, args: list[str] | None = None, **kwargs) -> SSHProcessResult: - """ - Execute automoutkey IPA command. - - .. code-block:: console - - $ ipa automountkey-$op $location $mapname $keyname $args - for example >>> ipa automountkey-add default-location newmap newkey --info=autofsinfo - - :param op: Command group operation (usually add, mod, del, show) - :type op: str - :param args: List of additional command arguments, defaults to None - :type args: list[str] | None, optional - :return: SSH process result. - :rtype: SSHProcessResult - """ - if args is None: - args = [] - - defargs = self.cli.args( - { - "location": (self.cli.option.POSITIONAL, self.map.location.name), - "mapname": (self.cli.option.POSITIONAL, self.map.name), - "key": (self.cli.option.VALUE, self.name), - } - ) - return self.role.host.ssh.exec(["ipa", f"{self.command_group}-{op}", *defargs, *args], **kwargs) - - def add(self, *, info: str | NFSExport | IPAAutomountMap) -> IPAAutomountKey: - """ - Create new IPA automount key. - - :param info: Automount information - :type info: str | NFSExport | IPAAutomountMap - :return: Self. - :rtype: IPAAutomountKey - """ - parsed: str | None = self.__get_info(info) - attrs: CLIBuilderArgs = {"info": (self.cli.option.VALUE, parsed)} - - self._add(attrs) - self.info = parsed - return self - - def modify( - self, - *, - info: str | NFSExport | IPAAutomountMap | None = None, - ) -> IPAAutomountKey: - """ - Modify existing IPA automount key. - - :param info: Automount information, defaults to ``None`` - :type info: str | NFSExport | IPAAutomountMap | None - :return: Self. - :rtype: IPAAutomountKey - """ - parsed: str | None = self.__get_info(info) - attrs: CLIBuilderArgs = { - "info": (self.cli.option.VALUE, parsed), - } - - self._modify(attrs) - self.info = parsed - return self - - def dump(self) -> str: - """ - Dump the key in the ``automount -m`` format. - - .. code-block:: text - - export1 | -fstype=nfs,rw,sync,no_root_squash nfs.test:/dev/shm/exports/export1 - - You can also call ``str(key)`` instead of ``key.dump()``. - - :return: Key information in ``automount -m`` format. - :rtype: str - """ - return f"{self.name} | {self.info}" - - def __str__(self) -> str: - """ - Alias for :meth:`dump` method. - - :return: Key information in ``automount -m`` format. - :rtype: str - """ - return self.dump() - - def __get_info(self, info: str | NFSExport | IPAAutomountMap | None) -> str | None: - if isinstance(info, NFSExport): - return info.get() - - if isinstance(info, IPAAutomountMap): - return info.name - - return info diff --git a/src/tests/system/lib/sssd/roles/kdc.py b/src/tests/system/lib/sssd/roles/kdc.py deleted file mode 100644 index 388c63028b8..00000000000 --- a/src/tests/system/lib/sssd/roles/kdc.py +++ /dev/null @@ -1,247 +0,0 @@ -"""KDC multihost role.""" - -from __future__ import annotations - -import textwrap - -from pytest_mh.ssh import SSHProcessResult - -from ..hosts.kdc import KDCHost -from .base import BaseLinuxRole, BaseObject - -__all__ = [ - "KDC", - "KDCPrincipal", -] - - -class KDC(BaseLinuxRole[KDCHost]): - """ - Kerberos KDC role. - - Provides unified Python API for managing objects in the Kerberos KDC. - - .. code-block:: python - :caption: Creating user and group - - @pytest.mark.topology(KnownTopology.KDC) - def test_example(kdc: KDC): - kdc.principal('tuser').add() - - .. note:: - - The role object is instantiated automatically as a dynamic pytest - fixture by the multihost plugin. You should not create the object - manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.realm: str = self.host.realm - """Default Kerberos realm.""" - - self.tgt: str = f"krbtgt/{self.realm}@{self.realm}" - """Full name of Ticket Granting Ticket (e.g. krbtgt/REALM@REALM.""" - - def qualify(self, name: str) -> str: - """ - Create a qualified principal name (princ@REALM). - - :param name: Principal name without the REALM part. - :type name: str - :return: Full principal name. - :rtype: str - """ - if "@" in name: - return name - - return f"{name}@{self.realm}" - - def kadmin(self, command: str) -> SSHProcessResult: - """ - Run kadmin command on the KDC. - - :param command: kadmin command - :type command: str - """ - result = self.host.ssh.exec(["kadmin.local", "-q", command]) - - # Remove "Authenticating as principal root/admin@TEST with password." - # from the output and keep only output of the command itself. - result.stdout_lines = result.stdout_lines[1:] - result.stdout = "\n".join(result.stdout_lines) - - return result - - def list_principals(self) -> list[str]: - """ - List existing Kerberos principals. - - :return: List of Kerberos principals. - :rtype: list[str] - """ - result = self.kadmin("listprincs") - return result.stdout_lines - - def principal(self, name: str) -> KDCPrincipal: - """ - Get Kerberos principal object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP, kdc: KDC): - ldap.user('tuser').add() - kdc.principal('tuser').add() - - client.sssd.common.krb5_auth(kdc) - client.sssd.start() - - with client.ssh('tuser', 'Secret123') as ssh: - with client.auth.kerberos(ssh) as krb: - assert krb.has_tgt(kdc.realm) - - :param name: Principal name. - :type name: str - :return: New principal object. - :rtype: KDCPrincipal - """ - return KDCPrincipal(self, name) - - def config(self) -> str: - """ - Get krb5.conf contents. - - :return: Kerberos configuration. - :rtype: str - """ - return textwrap.dedent( - f""" - [logging] - default = FILE:/var/log/krb5libs.log - kdc = FILE:/var/log/krb5kdc.log - admin_server = FILE:/var/log/kadmind.log - - [libdefaults] - default_realm = {self.host.realm} - default_ccache_name = KCM: - dns_lookup_realm = false - dns_lookup_kdc = false - ticket_lifetime = 24h - renew_lifetime = 7d - forwardable = yes - - [realms] - {self.host.realm} = {{ - kdc = {self.host.hostname}:88 - admin_server = {self.host.hostname}:749 - max_life = 7d - max_renewable_life = 14d - }} - - [domain_realm] - .{self.host.krbdomain} = {self.host.realm} - {self.host.krbdomain} = {self.host.realm} - """ - ).lstrip() - - -class KDCPrincipal(BaseObject[KDCHost, KDC]): - """ - Kerberos principals management. - """ - - def __init__(self, role: KDC, name: str) -> None: - """ - :param role: KDC role object. - :type role: KDC - :param name: Principal name. - :type name: str - """ - super().__init__(role) - - self.name: str = name - """Principal name.""" - - def add(self, *, password: str | None = "Secret123") -> KDCPrincipal: - """ - Add a new Kerberos principal. - - Random password is generated if ``password`` is ``None``. - - :param password: Principal's password, defaults to 'Secret123' - :type password: str | None - :return: Self. - :rtype: KDCPrincipal - """ - if password is not None: - self.role.kadmin(f'addprinc -pw "{password}" "{self.name}"') - else: - self.role.kadmin(f'addprinc -randkey "{self.name}"') - - return self - - def get(self) -> dict[str, str]: - """ - Retrieve principal information. - - :return: Principal information. - :rtype: dict[str, str] - """ - result = self.role.kadmin(f'getprinc "{self.name}"') - out = {} - for line in result.stdout_lines: - (key, value) = line.split(":", maxsplit=1) - out[key] = value.strip() - - return out - - def delete(self) -> None: - """ - Delete existing Kerberos principal. - """ - self.role.kadmin(f'delprinc -force "{self.name}"') - - def set_string(self, key: str, value: str) -> KDCPrincipal: - """ - Set principal's string attribute. - - :param key: Attribute name. - :type key: str - :param value: Atribute value. - :type value: str - :return: Self. - :rtype: KDCPrincipal - """ - self.role.kadmin(f'setstr "{self.name}" "{key}" "{value}"') - return self - - def get_strings(self) -> dict[str, str]: - """ - Get all principal's string attributes. - - :return: String attributes. - :rtype: dict[str, str] - """ - result = self.role.kadmin(f'getstrs "{self.name}"') - out = {} - for line in result.stdout_lines: - (key, value) = line.split(":", maxsplit=1) - out[key] = value.strip() - - return out - - def get_string(self, key: str) -> str | None: - """ - Set principal's string attribute. - - :param key: Attribute name. - :type key: str - :return: Attribute's value or None if not found. - :rtype: str | None - """ - attrs = self.get_strings() - - return attrs.get(key, None) diff --git a/src/tests/system/lib/sssd/roles/ldap.py b/src/tests/system/lib/sssd/roles/ldap.py deleted file mode 100644 index debef669e4b..00000000000 --- a/src/tests/system/lib/sssd/roles/ldap.py +++ /dev/null @@ -1,1400 +0,0 @@ -"""LDAP multihost role.""" - -from __future__ import annotations - -from enum import Enum -from typing import Any, Generic, Protocol, TypeVar - -import ldap -import ldap.ldapobject - -from ..hosts.ldap import LDAPHost -from ..misc import attrs_include_value, to_list_without_none -from ..utils.ldap import LDAPRecordAttributes, LDAPUtils -from .base import BaseLinuxLDAPRole, BaseObject, DeleteAttribute, HostType -from .nfs import NFSExport - -__all__ = [ - "ProtocolName", - "LDAPRoleType", - "LDAPUserType", - "LDAPGroupType", - "LDAP", - "LDAPObject", - "LDAPACI", - "LDAPOrganizationalUnit", - "LDAPUser", - "LDAPGroup", - "LDAPSudoRule", - "LDAPAutomount", - "LDAPAutomountMap", - "LDAPAutomountKey", -] - - -class ProtocolName(Protocol): - """ - Used to hint that the type must contain name attribute. - """ - - name: str - - -LDAPRoleType = TypeVar("LDAPRoleType", bound=BaseLinuxLDAPRole) -LDAPUserType = TypeVar("LDAPUserType", bound=ProtocolName) -LDAPGroupType = TypeVar("LDAPGroupType", bound=ProtocolName) - - -class LDAP(BaseLinuxLDAPRole[LDAPHost]): - """ - LDAP role. - - Provides unified Python API for managing objects in the LDAP server. - - .. code-block:: python - :caption: Creating user and group - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(ldap: LDAP): - u = ldap.user('tuser').add() - g = ldap.group('tgroup').add() - g.add_member(u) - - .. note:: - - The role object is instantiated automatically as a dynamic pytest - fixture by the multihost plugin. You should not create the object - manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.auto_uid: int = 23000 - """The next automatically assigned user id.""" - - self.auto_gid: int = 33000 - """The next automatically assigned group id.""" - - self.aci: LDAPACI = LDAPACI(self) - """Manage LDAP ACI records.""" - - self.automount: LDAPAutomount[LDAPHost, LDAP] = LDAPAutomount[LDAPHost, LDAP](self) - """ - Manage automount maps and keys. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example_autofs(client: Client, ldap: LDAP, nfs: NFS): - nfs_export1 = nfs.export('export1').add() - nfs_export2 = nfs.export('export2').add() - nfs_export3 = nfs.export('sub/export3').add() - - # Create automount maps - auto_master = ldap.automount.map('auto.master').add() - auto_home = ldap.automount.map('auto.home').add() - auto_sub = ldap.automount.map('auto.sub').add() - - # Create mount points - auto_master.key('/ehome').add(info=auto_home) - auto_master.key('/esub/sub1/sub2').add(info=auto_sub) - - # Create mount keys - key1 = auto_home.key('export1').add(info=nfs_export1) - key2 = auto_home.key('export2').add(info=nfs_export2) - key3 = auto_sub.key('export3').add(info=nfs_export3) - - # Start SSSD - client.sssd.common.autofs() - client.sssd.start() - - # Reload automounter in order to fetch updated maps - client.automount.reload() - - # Check that we can mount all directories on correct locations - assert client.automount.mount('/ehome/export1', nfs_export1) - assert client.automount.mount('/ehome/export2', nfs_export2) - assert client.automount.mount('/esub/sub1/sub2/export3', nfs_export3) - - # Check that the maps are correctly fetched - assert client.automount.dumpmaps() == { - '/ehome': { - 'map': 'auto.home', - 'keys': [str(key1), str(key2)] - }, - '/esub/sub1/sub2': { - 'map': 'auto.sub', - 'keys': [str(key3)] - }, - } - """ - - def _generate_uid(self) -> int: - """ - Generate next user id value. - - :return: User id. - :rtype: int - """ - self.auto_uid += 1 - return self.auto_uid - - def _generate_gid(self) -> int: - """ - Generate next group id value. - - :return: Group id. - :rtype: int - """ - self.auto_gid += 1 - return self.auto_gid - - def ou(self, name: str, basedn: LDAPObject | str | None = None) -> LDAPOrganizationalUnit[LDAPHost, LDAP]: - """ - Get organizational unit object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP): - # Create user - ou = ldap.ou('my-users').add() - ldap.user('user-1', basedn=ou).add() - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and test that the user was found - result = client.tools.id('user-1') is not None - - :param name: Unit name. - :type name: str - :param basedn: Base dn, defaults to None - :type basedn: LDAPObject | str | None, optional - :return: New organizational unit object. - :rtype: LDAPOrganizationalUnit[LDAPHost, LDAP] - """ - return LDAPOrganizationalUnit[LDAPHost, LDAP](self, name, basedn) - - def user(self, name: str, basedn: LDAPObject | str | None = "ou=users") -> LDAPUser: - """ - Get user object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP): - # Create user - ldap.user('user-1').add(uid=10001, gid=10001) - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.user.id == 10001 - assert result.group.id == 10001 # primary group - assert result.group.name is None - - :param name: User name. - :type name: str - :param basedn: Base dn, defaults to ``ou=users`` - :type basedn: LDAPObject | str | None, optional - :return: New user object. - :rtype: LDAPUser - """ - return LDAPUser(self, name, basedn) - - def group( - self, name: str, basedn: LDAPObject | str | None = "ou=groups", *, rfc2307bis: bool = False - ) -> LDAPGroup: - """ - Get user object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP): - # Create user - user = ldap.user('user-1').add(uid=10001, gid=10001) - - # Create primary group - ldap.group('user-1').add(gid=10001) - - # Create secondary group and add user as a member - ldap.group('group-1').add(gid=20001).add_member(user) - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.user.id == 10001 - assert result.group.id == 10001 # primary group - assert result.group.name == 'user-1' - assert result.memberof('group-1') - - - :param name: Group name. - :type name: str - :param basedn: Base dn, defaults to ``ou=groups`` - :type basedn: LDAPObject | str | None, optional - :param rfc2307bis: If True, rfc2307bis schema is used, defaults to False - :type rfc2307bis: bool, optional - :return: New group object. - :rtype: LDAPGroup - """ - return LDAPGroup(self, name, basedn, rfc2307bis=rfc2307bis) - - def sudorule( - self, name: str, basedn: LDAPObject | str | None = "ou=sudoers" - ) -> LDAPSudoRule[LDAPHost, LDAP, LDAPUser, LDAPGroup]: - """ - Get sudo rule object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP): - user = ldap.user('user-1').add(password="Secret123") - ldap.sudorule('testrule').add(user=user, host='ALL', command='/bin/ls') - - client.sssd.common.sudo() - client.sssd.start() - - # Test that user can run /bin/ls - assert client.auth.sudo.run('user-1', 'Secret123', command='/bin/ls') - - :param name: Rule name. - :type name: str - :param basedn: Base dn, defaults to ``ou=sudoers`` - :type basedn: LDAPObject | str | None, optional - :return: New sudo rule object. - :rtype: LDAPSudoRule[LDAPHost, LDAP, LDAPUser, LDAPGroup] - """ - return LDAPSudoRule[LDAPHost, LDAP, LDAPUser, LDAPGroup](self, LDAPUser, LDAPGroup, name, basedn) - - -class LDAPObject(BaseObject[HostType, LDAPRoleType]): - """ - Base class for LDAP object management. - - Provides shortcuts for command execution and implementation of :meth:`get` - and :meth:`delete` methods. - """ - - def __init__( - self, - role: LDAPRoleType, - name: str, - rdn: str, - basedn: LDAPObject | str | None = None, - default_ou: str | None = None, - ) -> None: - """ - :param role: LDAP role object. - :type role: LDAPRoleType - :param name: Object name. - :type name: str - :param rdn: Relative distinguished name. - :type rdn: str - :param basedn: Base dn, defaults to None - :type basedn: LDAPObject | str | None, optional - :param default_ou: Name of default organizational unit that is automatically - created if basedn is set to ou=$default_ou, defaults to None. - :type default_ou: str | None, optional - """ - super().__init__(role) - - self.name: str = name - """Object name.""" - - self.rdn: str = rdn - """Object relative DN.""" - - self.basedn: LDAPObject | str | None = basedn - """Object base DN.""" - - self.dn: str = self._dn(rdn, basedn) - """Object DN.""" - - self.default_ou: str | None = default_ou - """Default organizational unit that usually holds this object.""" - - self.__create_default_ou(basedn, self.default_ou) - - def __create_default_ou(self, basedn: LDAPObject | str | None, default_ou: str | None) -> None: - """ - If default base dn is used we want to make sure that the container - (usually an organizational unit) exit. This is to allow nicely working - topology parametrization when the base dn is not specified and created - inside the test because not all backends supports base dn (e.g. IPA). - - :param basedn: Selected base DN. - :type basedn: LDAPObject | str | None - :param default_ou: Default name of organizational unit. - :type default_ou: str | None - """ - if default_ou is None: - return - - if basedn is None or not isinstance(basedn, str): - return - - if basedn.lower() != f"ou={default_ou}" or default_ou in self.role.auto_ou: - return - - self.role.ou(default_ou).add() - self.role.auto_ou[default_ou] = True - - def _dn(self, rdn: str, basedn: LDAPObject | str | None = None) -> str: - """ - Get distinguished name of an object. - - :param rdn: Relative DN. - :type rdn: str - :param basedn: Base DN, defaults to None - :type basedn: LDAPObject | str | None, optional - :return: Distinguished name combined from rdn+dn+naming-context. - :rtype: str - """ - if isinstance(basedn, LDAPObject): - return f"{rdn},{basedn.dn}" - - return self.role.ldap.dn(rdn, basedn) - - def _default(self, value: Any, default: Any) -> Any: - """ - :return: Value if not None, default value otherwise. - :rtype: Any - """ - if value is None: - return default - - return value - - def _hash_password(self, password: str | None | DeleteAttribute) -> str | None | DeleteAttribute: - """ - Compute sha256 hash of a password that can be used as a value. - - Return original value If password is None or DeleteAttribute. - - :param password: Password to hash. - :type password: str - :return: Base64 of sha256 hash digest. - :rtype: str - """ - if password is None or isinstance(password, DeleteAttribute): - # Return unchanged value to simplify attribute modification - return password - - return self.role.ldap.hash_password(password) - - def _add(self, attrs: LDAPRecordAttributes) -> None: - """ - Add LDAP record. - - :param attrs: LDAP attributes. - :type attrs: LDAPRecordAttributes - """ - self.role.ldap.add(self.dn, attrs) - - def _modify( - self, - *, - add: LDAPRecordAttributes | None = None, - replace: LDAPRecordAttributes | None = None, - delete: LDAPRecordAttributes | None = None, - ) -> None: - """ - Modify LDAP record. - - :param add: Attributes and values to add, defaults to None - :type add: LDAPRecordAttributes | None, optional - :param replace: Attributes and values to replace, defaults to None - :type replace: LDAPRecordAttributes | None, optional - :param delete: Attributes and values to delete, defaults to None - :type delete: LDAPRecordAttributes | None, optional - """ - self.role.ldap.modify(self.dn, add=add, replace=replace, delete=delete) - - def _set(self, attrs: LDAPRecordAttributes) -> None: - """ - Set LDAP record attributes to specific values. - - This is similar to modify. The attributes are either replaced with their - given values or the whole attribute is deleted if DeleteAttribute is - set as the value. - - :param attrs: Dictionary with attribute name as the key. - :type attrs: LDAPRecordAttributes - """ - replace: dict[str, Any] = {} - delete: dict[str, Any] = {} - for attr, value in attrs.items(): - if value is None: - continue - - if isinstance(value, DeleteAttribute): - delete[attr] = None - continue - - replace[attr] = value - - self.role.ldap.modify(self.dn, replace=replace, delete=delete) - - def delete(self) -> None: - """ - Delete LDAP record.. - """ - self.role.ldap.delete(self.dn) - - def get(self, attrs: list[str] | None = None, opattrs: bool = False) -> dict[str, list[str]] | None: - """ - Get LDAP record attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :param opattrs: If True, operational attributes are returned as well, defaults to False - :type opattrs: bool, optional - :raises ValueError: If multiple objects with the same dn exists. - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - attrs = ["*"] if attrs is None else attrs - if opattrs: - attrs.append("+") - - result = self.role.ldap.conn.search_s(self.dn, ldap.SCOPE_BASE, attrlist=attrs) - if not result: - return None - - if len(result) != 1: - raise ValueError(f"Multiple objects returned on base search for {self.dn}") - - (_, result_attrs) = result[0] - - return {k: [i.decode("utf-8") for i in v] for k, v in result_attrs.items()} - - -class LDAPACI(object): - """ - LDAP ACI records management. - """ - - def __init__(self, role: LDAP) -> None: - """ - :param role: LDAP role object. - :type role: LDAP - """ - self.role: LDAP = role - self.ldap: LDAPUtils = self.role.ldap - self.dn: str = self.ldap.naming_context - - def add(self, value: str): - """ - Add new ACI record. - - :param value: ACI value - :type value: str - """ - self.ldap.modify(self.dn, add={"aci": value}) - - def modify(self, old: str, new: str): - """ - Modify existing ACI record. - - :param old: Old ACI value - :type old: str - :param new: New ACI value - :type new: str - """ - self.delete(old) - self.add(new) - - def delete(self, value: str): - """ - Delete existing ACI record. - - :param value: ACI value - :type value: str - """ - self.ldap.modify(self.dn, delete={"aci": value}) - - -class LDAPOrganizationalUnit(LDAPObject[HostType, LDAPRoleType]): - """ - LDAP organizational unit management. - """ - - def __init__(self, role: LDAPRoleType, name: str, basedn: LDAPObject | str | None = None) -> None: - """ - :param role: LDAP role object. - :type role: LDAPRoleType - :param name: Unit name. - :type name: str - :param basedn: Base dn, defaults to None - :type basedn: LDAPObject | str | None, optional - """ - super().__init__(role, name, f"ou={name}", basedn) - - def add(self) -> LDAPOrganizationalUnit: - """ - Create new LDAP organizational unit. - - :return: Self. - :rtype: LDAPOrganizationalUnit - """ - attrs: LDAPRecordAttributes = {"objectClass": "organizationalUnit", "ou": self.name} - - self._add(attrs) - return self - - -class LDAPUser(LDAPObject[LDAPHost, LDAP]): - """ - LDAP user management. - """ - - def __init__(self, role: LDAP, name: str, basedn: LDAPObject | str | None = "ou=users") -> None: - """ - :param role: LDAP role object. - :type role: LDAP - :param name: User name. - :type name: str - :param basedn: Base dn, defaults to ``ou=users`` - :type basedn: LDAPObject | str | None, optional - """ - super().__init__(role, name, f"cn={name}", basedn, default_ou="users") - - def add( - self, - *, - uid: int | None = None, - gid: int | None = None, - password: str | None = "Secret123", - home: str | None = None, - gecos: str | None = None, - shell: str | None = None, - shadowMin: int | None = None, - shadowMax: int | None = None, - shadowWarning: int | None = None, - shadowLastChange: int | None = None, - ) -> LDAPUser: - """ - Create new LDAP user. - - User and group id is assigned automatically if they are not set. Other - parameters that are not set are ignored. - - :param uid: User id, defaults to None - :type uid: int | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | None, optional - :param password: Password, defaults to 'Secret123' - :type password: str, optional - :param home: Home directory, defaults to None - :type home: str | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | None, optional - :param shell: Login shell, defaults to None - :type shell: str | None, optional - :param shadowMin: shadowmin LDAP attribute, defaults to None - :type shadowMin: int | None, optional - :param shadowMax: shadowmax LDAP attribute, defaults to None - :type shadowMax: int | None, optional - :param shadowWarning: shadowwarning LDAP attribute, defaults to None - :type shadowWarning: int | None, optional - :param shadowLastChange: shadowlastchage LDAP attribute, defaults to None - :type shadowLastChange: int | None, optional - :return: Self. - :rtype: LDAPUser - """ - # Assign uid and gid automatically if not present to have the same - # interface as other services. - if uid is None: - uid = self.role._generate_uid() - - if gid is None: - gid = uid - - attrs = { - "objectClass": ["posixAccount"], - "cn": self.name, - "uid": self.name, - "uidNumber": uid, - "gidNumber": gid, - "homeDirectory": self._default(home, f"/home/{self.name}"), - "userPassword": self._hash_password(password), - "gecos": gecos, - "loginShell": shell, - "shadowMin": shadowMin, - "shadowMax": shadowMax, - "shadowWarning": shadowWarning, - "shadowLastChange": shadowLastChange, - } - - if to_list_without_none([shadowMin, shadowMax, shadowWarning, shadowLastChange]): - attrs["objectClass"].append("shadowAccount") - - self._add(attrs) - return self - - def modify( - self, - *, - uid: int | DeleteAttribute | None = None, - gid: int | DeleteAttribute | None = None, - password: str | DeleteAttribute | None = None, - home: str | DeleteAttribute | None = None, - gecos: str | DeleteAttribute | None = None, - shell: str | DeleteAttribute | None = None, - shadowMin: int | DeleteAttribute | None = None, - shadowMax: int | DeleteAttribute | None = None, - shadowWarning: int | DeleteAttribute | None = None, - shadowLastChange: int | DeleteAttribute | None = None, - ) -> LDAPUser: - """ - Modify existing LDAP user. - - Parameters that are not set are ignored. If needed, you can delete an - attribute by setting the value to :attr:`Delete`. - - :param uid: User id, defaults to None - :type uid: int | DeleteAttribute | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | DeleteAttribute | None, optional - :param home: Home directory, defaults to None - :type home: str | DeleteAttribute | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | DeleteAttribute | None, optional - :param shell: Login shell, defaults to None - :type shell: str | DeleteAttribute | None, optional - :param shadowMin: shadowmin LDAP attribute, defaults to None - :type shadowMin: int | DeleteAttribute | None, optional - :param shadowMax: shadowmax LDAP attribute, defaults to None - :type shadowMax: int | DeleteAttribute | None, optional - :param shadowWarning: shadowwarning LDAP attribute, defaults to None - :type shadowWarning: int | DeleteAttribute | None, optional - :param shadowLastChange: shadowlastchage LDAP attribute, defaults to None - :type shadowLastChange: int | DeleteAttribute | None, optional - :return: Self. - :rtype: LDAPUser - """ - attrs: LDAPRecordAttributes = { - "uidNumber": uid, - "gidNumber": gid, - "homeDirectory": home, - "userPassword": self._hash_password(password), - "gecos": gecos, - "loginShell": shell, - "shadowMin": shadowMin, - "shadowMax": shadowMax, - "shadowWarning": shadowWarning, - "shadowLastChange": shadowLastChange, - } - - self._set(attrs) - return self - - -class LDAPGroup(LDAPObject[LDAPHost, LDAP]): - """ - LDAP group management. - """ - - def __init__( - self, role: LDAP, name: str, basedn: LDAPObject | str | None = "ou=groups", *, rfc2307bis: bool = False - ) -> None: - """ - :param role: LDAP role object. - :type role: LDAP - :param name: Group name. - :type name: str - :param basedn: Base dn, defaults to ``ou=groups`` - :type basedn: LDAPObject | str | None, optional - :param rfc2307bis: If True, rfc2307bis schema is used, defaults to False - :type rfc2307bis: bool, optional - """ - super().__init__(role, name, f"cn={name}", basedn, default_ou="groups") - - self.rfc2307bis: bool = rfc2307bis - """True if rfc2307bis schema should be used.""" - - if not self.rfc2307bis: - self.object_class = ["posixGroup"] - self.member_attr = "memberUid" - else: - self.object_class = ["posixGroup", "groupOfNames"] - self.member_attr = "member" - - def __members(self, values: list[LDAPUser | LDAPGroup | str] | None) -> list[str] | None: - if values is None: - return None - - if self.rfc2307bis: - return [x.dn if isinstance(x, LDAPObject) else self._dn(x) for x in values] - - return [x.name if isinstance(x, LDAPObject) else x for x in values] - - def add( - self, - *, - gid: int | None = None, - members: list[LDAPUser | LDAPGroup | str] | None = None, - password: str | None = None, - description: str | None = None, - ) -> LDAPGroup: - """ - Create new LDAP group. - - Group id is assigned automatically if it is not set. Other parameters - that are not set are ignored. - - :param gid: _description_, defaults to None - :type gid: int | None, optional - :param members: List of group members, defaults to None - :type members: list[LDAPUser | LDAPGroup | str] | None, optional - :param password: Group password, defaults to None - :type password: str | None, optional - :param description: Description, defaults to None - :type description: str | None, optional - :return: Self. - :rtype: LDAPGroup - """ - # Assign gid automatically if not present to have the same - # interface as other services. - if gid is None: - gid = self.role._generate_gid() - - attrs = { - "objectClass": self.object_class, - "cn": self.name, - "gidNumber": gid, - "userPassword": self._hash_password(password), - "description": description, - self.member_attr: self.__members(members), - } - - self._add(attrs) - return self - - def modify( - self, - *, - gid: int | DeleteAttribute | None = None, - members: list[LDAPUser | LDAPGroup | str] | DeleteAttribute | None = None, - password: str | DeleteAttribute | None = None, - description: str | DeleteAttribute | None = None, - ) -> LDAPGroup: - """ - Modify existing LDAP group. - - Parameters that are not set are ignored. If needed, you can delete an - attribute by setting the value to :attr:`Delete`. - - :param gid: Group id, defaults to None - :type gid: int | DeleteAttribute | None, optional - :param members: List of group members, defaults to None - :type members: list[LDAPUser | LDAPGroup | str] | DeleteAttribute | None, optional - :param password: Group password, defaults to None - :type password: str | DeleteAttribute | None, optional - :param description: Description, defaults to None - :type description: str | DeleteAttribute | None, optional - :return: Self. - :rtype: LDAPGroup - """ - attrs = { - "gidNumber": gid, - "userPassword": self._hash_password(password), - "description": description, - self.member_attr: self.__members(members) if not isinstance(members, DeleteAttribute) else members, - } - - self._set(attrs) - return self - - def add_member(self, member: LDAPUser | LDAPGroup | str) -> LDAPGroup: - """ - Add group member. - - :param member: User or group (on rfc2307bis schema) to add as a member. - :type member: LDAPUser | LDAPGroup | str - :return: Self. - :rtype: LDAPGroup - """ - return self.add_members([member]) - - def add_members(self, members: list[LDAPUser | LDAPGroup | str]) -> LDAPGroup: - """ - Add multiple group members. - - :param members: Users or groups (on rfc2307bis schema) to add as members. - :type members: list[LDAPUser | LDAPGroup | str] - :return: Self. - :rtype: LDAPGroup - """ - self._modify(add={self.member_attr: self.__members(members)}) - return self - - def remove_member(self, member: LDAPUser | LDAPGroup | str) -> LDAPGroup: - """ - Remove group member. - - :param member: User or group (on rfc2307bis schema) to add as a member. - :type member: LDAPUser | LDAPGroup | str - :return: Self. - :rtype: LDAPGroup - """ - return self.remove_members([member]) - - def remove_members(self, members: list[LDAPUser | LDAPGroup | str]) -> LDAPGroup: - """ - Remove multiple group members. - - :param members: Users or groups (on rfc2307bis schema) to add as members. - :type members: list[LDAPUser | LDAPGroup | str] - :return: Self. - :rtype: LDAPGroup - """ - self._modify(delete={self.member_attr: self.__members(members)}) - return self - - -class LDAPSudoRule(Generic[HostType, LDAPRoleType, LDAPUserType, LDAPGroupType], LDAPObject[HostType, LDAPRoleType]): - """ - LDAP sudo rule management. - """ - - def __init__( - self, - role: LDAPRoleType, - user_cls: type[LDAPUserType], - group_cls: type[LDAPGroupType], - name: str, - basedn: LDAPObject | str | None = "ou=sudoers", - ) -> None: - """ - :param role: LDAP role object. - :type role: LDAPRoleType - :param user_cls: User class. - :type user_cls: type[LDAPUserType] - :param group_cls: Group class- - :type group_cls: type[LDAPGroupType] - :param name: Sudo rule name. - :type name: str - :param basedn: Base dn, defaults to ``ou=sudoers`` - :type basedn: LDAPObject | str | None, optional - """ - super().__init__(role, name, f"cn={name}", basedn, default_ou="sudoers") - - self.user_cls: type[LDAPUserType] = user_cls - """User class.""" - - self.group_cls: type[LDAPGroupType] = group_cls - """Group class.""" - - def add( - self, - *, - user: int | str | LDAPUserType | LDAPGroupType | list[int | str | LDAPUserType | LDAPGroupType] | None = None, - host: str | list[str] | None = None, - command: str | list[str] | None = None, - option: str | list[str] | None = None, - runasuser: int - | str - | LDAPUserType - | LDAPGroupType - | list[int | str | LDAPUserType | LDAPGroupType] - | None = None, - runasgroup: int | str | LDAPGroupType | list[int | str | LDAPGroupType] | None = None, - notbefore: str | list[str] | None = None, - notafter: str | list[str] | None = None, - order: int | list[int] | None = None, - nopasswd: bool | None = None, - ) -> LDAPSudoRule: - """ - Create new sudo rule. - - :param user: sudoUser attribute, defaults to None - :type user: int | str | LDAPUserType | LDAPGroupType | list[int | str | LDAPUserType | LDAPGroupType], optional - :param host: sudoHost attribute, defaults to None - :type host: str | list[str], optional - :param command: sudoCommand attribute, defaults to None - :type command: str | list[str], optional - :param option: sudoOption attribute, defaults to None - :type option: str | list[str] | None, optional - :param runasuser: sudoRunAsUser attribute, defaults to None - :type runasuser: int | str | LDAPUserType | LDAPGroupType - | list[int | str | LDAPUserType | LDAPGroupType] | None, optional - :param runasgroup: sudoRunAsGroup attribute, defaults to None - :type runasgroup: int | str | LDAPGroupType | list[int | str | LDAPGroupType] | None, optional - :param notbefore: sudoNotBefore attribute, defaults to None - :type notbefore: str | list[str] | None, optional - :param notafter: sudoNotAfter attribute, defaults to None - :type notafter: str | list[str] | None, optional - :param order: sudoOrder attribute, defaults to None - :type order: int | list[int] | None, optional - :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) - :type nopasswd: bool | None, optional - :return: Self. - :rtype: LDAPSudoRule - """ - attrs = { - "objectClass": "sudoRole", - "cn": self.name, - "sudoUser": self.__sudo_user(user), - "sudoHost": host, - "sudoCommand": command, - "sudoOption": option, - "sudoRunAsUser": self.__sudo_user(runasuser), - "sudoRunAsGroup": self.__sudo_group(runasgroup), - "sudoNotBefore": notbefore, - "sudoNotAfter": notafter, - "sudoOrder": order, - } - - if nopasswd is True: - attrs["sudoOption"] = attrs_include_value(attrs["sudoOption"], "!authenticate") - elif nopasswd is False: - attrs["sudoOption"] = attrs_include_value(attrs["sudoOption"], "authenticate") - - self._add(attrs) - return self - - def modify( - self, - *, - user: int - | str - | LDAPUserType - | LDAPGroupType - | list[int | str | LDAPUserType | LDAPGroupType] - | DeleteAttribute - | None = None, - host: str | list[str] | DeleteAttribute | None = None, - command: str | list[str] | DeleteAttribute | None = None, - option: str | list[str] | DeleteAttribute | None = None, - runasuser: int - | str - | LDAPUserType - | LDAPGroupType - | list[int | str | LDAPUserType | LDAPGroupType] - | DeleteAttribute - | None = None, - runasgroup: int | str | LDAPGroupType | list[int | str | LDAPGroupType] | DeleteAttribute | None = None, - notbefore: str | list[str] | DeleteAttribute | None = None, - notafter: str | list[str] | DeleteAttribute | None = None, - order: int | list[int] | DeleteAttribute | None = None, - nopasswd: bool | None = None, - ) -> LDAPSudoRule: - """ - Modify existing sudo rule. - - Parameters that are not set are ignored. If needed, you can delete an - attribute by setting the value to :attr:`Delete`. - - :param user: sudoUser attribute, defaults to None - :type user: int | str | LDAPUserType | LDAPGroupType | list[int | str | LDAPUserType | LDAPGroupType] - | DeleteAttribute | None, optional - :param host: sudoHost attribute, defaults to None - :type host: str | list[str] | DeleteAttribute | None, optional - :param command: sudoCommand attribute, defaults to None - :type command: str | list[str] | DeleteAttribute | None, optional - :param option: sudoOption attribute, defaults to None - :type option: str | list[str] | DeleteAttribute | None, optional - :param runasuser: sudoRunAsUsere attribute, defaults to None - :type runasuser: int | str | LDAPUserType | LDAPGroupType | list[int | str | LDAPUserType | LDAPGroupType] - | DeleteAttribute | None, optional - :param runasgroup: sudoRunAsGroup attribute, defaults to None - :type runasgroup: int | str | LDAPGroupType | list[int | str | LDAPGroupType] | DeleteAttribute | None, - optional - :param notbefore: sudoNotBefore attribute, defaults to None - :type notbefore: str | list[str] | DeleteAttribute | None, optional - :param notafter: sudoNotAfter attribute, defaults to None - :type notafter: str | list[str] | DeleteAttribute | None, optional - :param order: sudoOrder attribute, defaults to None - :type order: int | list[int] | DeleteAttribute | None, optional - :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) - :type nopasswd: bool | None, optional - :return: Self. - :rtype: LDAPSudoRule - """ - attrs = { - "sudoUser": self.__sudo_user(user), - "sudoHost": host, - "sudoCommand": command, - "sudoOption": option, - "sudoRunAsUser": self.__sudo_user(runasuser), - "sudoRunAsGroup": self.__sudo_group(runasgroup), - "sudoNotBefore": notbefore, - "sudoNotAfter": notafter, - "sudoOrder": order, - } - - if nopasswd is True: - attrs["sudoOption"] = attrs_include_value(attrs["sudoOption"], "!authenticate") - elif nopasswd is False: - attrs["sudoOption"] = attrs_include_value(attrs["sudoOption"], "authenticate") - - self._set(attrs) - return self - - def __sudo_user( - self, - sudo_user: None - | DeleteAttribute - | int - | str - | LDAPUserType - | LDAPGroupType - | list[int | str | LDAPUserType | LDAPGroupType], - ) -> list[str] | DeleteAttribute | None: - def _get_value(value: int | str | LDAPUserType | LDAPGroupType): - if isinstance(value, self.user_cls): - return value.name - - if isinstance(value, self.group_cls): - return "%" + value.name - - if isinstance(value, str): - return value - - if isinstance(value, int): - return "#" + str(value) - - raise ValueError(f"Unsupported type: {type(value)}") - - if sudo_user is None: - return None - - if isinstance(sudo_user, DeleteAttribute): - return sudo_user - - if not isinstance(sudo_user, list): - return [_get_value(sudo_user)] - - out = [] - for value in sudo_user: - out.append(_get_value(value)) - - return out - - def __sudo_group( - self, sudo_group: None | DeleteAttribute | int | str | LDAPGroupType | list[int | str | LDAPGroupType] - ) -> list[str] | DeleteAttribute | None: - def _get_value(value: int | str | LDAPGroupType): - if isinstance(value, self.group_cls): - return value.name - - if isinstance(value, str): - return value - - if isinstance(value, int): - return "#" + str(value) - - raise ValueError(f"Unsupported type: {type(value)}") - - if sudo_group is None: - return None - - if isinstance(sudo_group, DeleteAttribute): - return sudo_group - - if not isinstance(sudo_group, list): - return [_get_value(sudo_group)] - - out = [] - for value in sudo_group: - out.append(_get_value(value)) - - return out - - -class LDAPAutomount(Generic[HostType, LDAPRoleType]): - """ - LDAP automount management. - """ - - class Schema(Enum): - """ - LDAP automount schema. - """ - - RFC2307 = ("rfc2307",) - RFC2307bis = ("rfc2307bis",) - AD = ("ad",) - - def __init__(self, role: LDAPRoleType) -> None: - """ - :param role: LDAP role object. - :type role: LDAPRoleType - """ - self.__role: LDAPRoleType = role - self.__schema: LDAPAutomount.Schema = self.Schema.RFC2307 - - def map( - self, name: str, basedn: LDAPObject | str | None = "ou=autofs" - ) -> LDAPAutomountMap[HostType, LDAPRoleType]: - """ - Get automount map object. - - :param name: Automount map name. - :type name: str - :param basedn: Base dn, defaults to ``ou=autofs`` - :type basedn: LDAPObject | str | None, optional - :return: New automount map object. - :rtype: LDAPAutomountMap[HostType, LDAPRoleType]: - """ - return LDAPAutomountMap[HostType, LDAPRoleType](self.__role, name, basedn, schema=self.__schema) - - def key(self, name: str, map: LDAPAutomountMap) -> LDAPAutomountKey[HostType, LDAPRoleType]: - """ - Get automount key object. - - :param name: Automount key name. - :type name: str - :param map: Automount map that is a parent to this key. - :type map: LDAPAutomountMap - :return: New automount key object. - :rtype: LDAPAutomountKey[HostType, LDAPRoleType] - """ - return LDAPAutomountKey[HostType, LDAPRoleType](self.__role, name, map, schema=self.__schema) - - def set_schema(self, schema: "LDAPAutomount.Schema"): - """ - Set automount LDAP schema. - - :param schema: LDAP Schema. - :type schema: LDAPAutomount.Schema - """ - self.__schema = schema - - -class LDAPAutomountMap(LDAPObject[HostType, LDAPRoleType]): - """ - LDAP automount map management. - """ - - def __init__( - self, - role: LDAPRoleType, - name: str, - basedn: LDAPObject | str | None = "ou=autofs", - *, - schema: LDAPAutomount.Schema = LDAPAutomount.Schema.RFC2307, - ) -> None: - """ - :param role: LDAP role object. - :type role: LDAP - :param name: Automount map name. - :type name: str - :param basedn: Base dn, defaults to ``ou=autofs`` - :type basedn: LDAPObject | str | None, optional - :param schema: LDAP Automount schema, defaults to ``LDAPAutomount.Schema.RFC2307`` - :type schema: LDAPAutomount.Schema - """ - self.__schema: LDAPAutomount.Schema = schema - self.__attrs: dict[str, str] = self.__get_attrs_map(schema) - super().__init__(role, name, f'{self.__attrs["rdn"]}={name}', basedn, default_ou="autofs") - - def __get_attrs_map(self, schema: LDAPAutomount.Schema) -> dict[str, str]: - if schema == LDAPAutomount.Schema.RFC2307: - return { - "objectClass": "nisMap", - "rdn": "nisMapName", - "automountMapName": "nisMapName", - } - elif schema == LDAPAutomount.Schema.RFC2307bis: - return { - "objectClass": "automountMap", - "rdn": "automountMapName", - "automountMapName": "automountMapName", - } - elif schema == LDAPAutomount.Schema.AD: - return { - "objectClass": "nisMap", - "rdn": "cn", - "automountMapName": "nisMapName", - } - else: - raise ValueError(f"Unknown schema: {schema}") - - def add( - self, - ) -> LDAPAutomountMap: - """ - Create new LDAP automount map. - - :return: Self. - :rtype: LDAPAutomountMap - """ - attrs: LDAPRecordAttributes = { - "objectClass": self.__attrs["objectClass"], - self.__attrs["automountMapName"]: self.name, - } - - if self.__schema == LDAPAutomount.Schema.AD: - attrs["cn"] = self.name - - self._add(attrs) - return self - - def key(self, name: str) -> LDAPAutomountKey[HostType, LDAPRoleType]: - """ - Get automount key object for this map. - - :param name: Automount key name. - :type name: str - :return: New automount key object. - :rtype: LDAPAutomountKey - """ - return LDAPAutomountKey(self.role, name, self, schema=self.__schema) - - -class LDAPAutomountKey(LDAPObject[HostType, LDAPRoleType]): - """ - LDAP automount key management. - """ - - def __init__( - self, - role: LDAPRoleType, - name: str, - map: LDAPAutomountMap, - *, - schema: LDAPAutomount.Schema = LDAPAutomount.Schema.RFC2307, - ) -> None: - """ - :param role: LDAP role object. - :type role: LDAPRoleType - :param name: Automount key name. - :type name: str - :param map: Automount map that is a parent to this key. - :type map: LDAPAutomountMap - :param schema: LDAP Automount schema, defaults to ``LDAPAutomount.Schema.RFC2307`` - :type schema: LDAPAutomount.Schema - """ - self.__schema: LDAPAutomount.Schema = schema - self.__attrs: dict[str, str] = self.__get_attrs_map(schema) - - super().__init__(role, name, f'{self.__attrs["rdn"]}={name}', map) - self.map: LDAPAutomountMap = map - self.info: str = "" - - def __get_attrs_map(self, schema: LDAPAutomount.Schema) -> dict[str, str]: - if schema == LDAPAutomount.Schema.RFC2307: - return { - "objectClass": "nisObject", - "rdn": "cn", - "automountKey": "cn", - "automountInformation": "nisMapEntry", - } - elif schema == LDAPAutomount.Schema.RFC2307bis: - return { - "objectClass": "automount", - "rdn": "automountKey", - "automountKey": "automountKey", - "automountInformation": "automountInformation", - } - elif schema == LDAPAutomount.Schema.AD: - return { - "objectClass": "nisObject", - "rdn": "cn", - "automountKey": "cn", - "automountInformation": "nisMapEntry", - } - else: - raise ValueError(f"Unknown schema: {schema}") - - def add(self, *, info: str | NFSExport | LDAPAutomountMap) -> LDAPAutomountKey: - """ - Create new LDAP automount key. - - :param info: Automount information. - :type info: str | NFSExport | LDAPAutomountMap - :return: Self. - :rtype: LDAPAutomountKey - """ - parsed = self.__get_info(info) - if isinstance(parsed, DeleteAttribute) or parsed is None: - # This should not happen, it is here just to silence mypy - raise ValueError("Invalid value of info attribute") - - attrs = { - "objectClass": self.__attrs["objectClass"], - self.__attrs["automountKey"]: self.name, - self.__attrs["automountInformation"]: parsed, - } - - if self.__schema in [LDAPAutomount.Schema.RFC2307, LDAPAutomount.Schema.AD]: - attrs["nisMapName"] = self.map.name - - self._add(attrs) - self.info = parsed - return self - - def modify( - self, - *, - info: str | NFSExport | LDAPAutomountMap | DeleteAttribute | None = None, - ) -> LDAPAutomountKey: - """ - Modify existing LDAP automount key. - - :param info: Automount information, defaults to ``None`` - :type info: str | NFSExport | LDAPAutomountMap | DeleteAttribute | None - :return: Self. - :rtype: LDAPAutomountKey - """ - parsed = self.__get_info(info) - attrs = { - self.__attrs["automountInformation"]: parsed, - } - - self._set(attrs) - self.info = parsed if not isinstance(parsed, DeleteAttribute) else "" - return self - - def dump(self) -> str: - """ - Dump the key in the ``automount -m`` format. - - .. code-block:: text - - export1 | -fstype=nfs,rw,sync,no_root_squash nfs.test:/dev/shm/exports/export1 - - You can also call ``str(key)`` instead of ``key.dump()``. - - :return: Key information in ``automount -m`` format. - :rtype: str - """ - return f"{self.name} | {self.info}" - - def __str__(self) -> str: - """ - Alias for :meth:`dump` method. - - :return: Key information in ``automount -m`` format. - :rtype: str - """ - return self.dump() - - def __get_info(self, info: str | NFSExport | LDAPAutomountMap | DeleteAttribute | None): - if isinstance(info, NFSExport): - return info.get() - - if isinstance(info, LDAPAutomountMap): - return info.name - - return info diff --git a/src/tests/system/lib/sssd/roles/nfs.py b/src/tests/system/lib/sssd/roles/nfs.py deleted file mode 100644 index 697eda214f6..00000000000 --- a/src/tests/system/lib/sssd/roles/nfs.py +++ /dev/null @@ -1,113 +0,0 @@ -"""NFS multihost role.""" - -from __future__ import annotations - -from ..hosts.nfs import NFSHost -from .base import BaseLinuxRole, BaseObject - -__all__ = [ - "NFS", - "NFSExport", -] - - -class NFS(BaseLinuxRole[NFSHost]): - """ - NFS role. - - Provides unified Python API for managing shared folders on the NFS server. - - .. code-block:: python - :caption: Creating user and group - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(nfs: NFS): - nfs.export('test').add() - - .. note:: - - The role object is instantiated automatically as a dynamic pytest - fixture by the multihost plugin. You should not create the object - manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.hostname: str = self.host.hostname - """NFS server hostname.""" - - self.exports_dir: str = self.host.exports_dir - """Top level exports directory.""" - - def exportfs_reload(self) -> None: - """ - Reexport all directories. - """ - self.host.ssh.run("exportfs -r && exportfs -s") - - def export( - self, - path: str, - ) -> NFSExport: - """ - Get export object. - - :param path: Path relative to the top level exports directory. - :type path: str - :return: New export object. - :rtype: NFSExport - """ - return NFSExport(self, path) - - -class NFSExport(BaseObject[NFSHost, NFS]): - """ - NFS shared folder management. - """ - - def __init__(self, role: NFS, path: str) -> None: - super().__init__(role) - - self.hostname: str = role.hostname - """NFS server hostname.""" - - self.path: str = path.strip("/") - """Exported path relative to the top level exports directory.""" - - self.fullpath: str = f"{self.role.exports_dir}/{self.path}" - """Absolute path of the exported directory.""" - - self.exports_file = f'/etc/exports.d/{path.replace("/", "_")}.exports' - """NFS exports file that manages this directory.""" - - self.opts: str = "rw,sync,no_root_squash" - """NFS export options, defaults to ``rw,sync,no_root_squash``.""" - - def add(self, *, opts: str = "rw,sync,no_root_squash", reload: bool = True) -> NFSExport: - """ - Start sharing this directory. - - :param opts: NFS export options, defaults to 'rw,sync,no_root_squash' - :type opts: str, optional - :param reload: Immediately reexport all directories, defaults to True - :type reload: bool, optional - :return: Self. - :rtype: NFSExport - """ - self.role.fs.mkdir_p(self.fullpath, mode="a=rwx") - self.role.fs.write(self.exports_file, f"{self.fullpath} *({opts})") - self.opts = opts - - if reload: - self.role.exportfs_reload() - - return self - - def get(self) -> str: - """ - Get NFS export specification for automounter. - - :rtype: str - """ - return f"-fstype=nfs,{self.opts} {self.hostname}:{self.fullpath}" diff --git a/src/tests/system/lib/sssd/roles/samba.py b/src/tests/system/lib/sssd/roles/samba.py deleted file mode 100644 index 397c74ae3f9..00000000000 --- a/src/tests/system/lib/sssd/roles/samba.py +++ /dev/null @@ -1,564 +0,0 @@ -"""Samba multihost role.""" - -from __future__ import annotations - -from typing import Any, TypeAlias - -import ldap.modlist -from pytest_mh.cli import CLIBuilderArgs -from pytest_mh.ssh import SSHProcessResult - -from ..hosts.samba import SambaHost -from ..misc import attrs_parse, to_list_of_strings -from .base import BaseLinuxLDAPRole, BaseObject, DeleteAttribute -from .ldap import LDAPAutomount, LDAPObject, LDAPOrganizationalUnit, LDAPSudoRule - -__all__ = [ - "Samba", - "SambaObject", - "SambaUser", - "SambaGroup", - "SambaOrganizationalUnit", - "SambaAutomount", - "SambaSudoRule", -] - - -class Samba(BaseLinuxLDAPRole[SambaHost]): - """ - Samba role. - - Provides unified Python API for managing objects in the Samba domain controller. - - .. code-block:: python - :caption: Creating user and group - - @pytest.mark.topology(KnownTopology.Samba) - def test_example(samba: Samba): - u = samba.user('tuser').add() - g = samba.group('tgroup').add() - g.add_member(u) - - .. note:: - - The role object is instantiated automatically as a dynamic pytest - fixture by the multihost plugin. You should not create the object - manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.automount: SambaAutomount = SambaAutomount(self) - """ - Manage automount maps and keys. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.Samba) - def test_example_autofs(client: Client, samba: Samba, nfs: NFS): - nfs_export1 = nfs.export('export1').add() - nfs_export2 = nfs.export('export2').add() - nfs_export3 = nfs.export('sub/export3').add() - - # Create automount maps - auto_master = samba.automount.map('auto.master').add() - auto_home = samba.automount.map('auto.home').add() - auto_sub = samba.automount.map('auto.sub').add() - - # Create mount points - auto_master.key('/ehome').add(info=auto_home) - auto_master.key('/esub/sub1/sub2').add(info=auto_sub) - - # Create mount keys - key1 = auto_home.key('export1').add(info=nfs_export1) - key2 = auto_home.key('export2').add(info=nfs_export2) - key3 = auto_sub.key('export3').add(info=nfs_export3) - - # Start SSSD - client.sssd.common.autofs() - client.sssd.start() - - # Reload automounter in order to fetch updated maps - client.automount.reload() - - # Check that we can mount all directories on correct locations - assert client.automount.mount('/ehome/export1', nfs_export1) - assert client.automount.mount('/ehome/export2', nfs_export2) - assert client.automount.mount('/esub/sub1/sub2/export3', nfs_export3) - - # Check that the maps are correctly fetched - assert client.automount.dumpmaps() == { - '/ehome': { - 'map': 'auto.home', - 'keys': [str(key1), str(key2)] - }, - '/esub/sub1/sub2': { - 'map': 'auto.sub', - 'keys': [str(key3)] - }, - } - """ - - # Set AD schema for automount - self.automount.set_schema(self.automount.Schema.AD) - - def user(self, name: str) -> SambaUser: - """ - Get user object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.Samba) - def test_example(client: Client, samba: Samba): - # Create user - samba.user('user-1').add() - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.group.name == 'domain users' - - :param name: User name. - :type name: str - :return: New user object. - :rtype: SambaUser - """ - return SambaUser(self, name) - - def group(self, name: str) -> SambaGroup: - """ - Get group object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.Samba) - def test_example(client: Client, samba: Samba): - # Create user - user = samba.user('user-1').add() - - # Create secondary group and add user as a member - samba.group('group-1').add().add_member(user) - - # Start SSSD - client.sssd.start() - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.group.name == 'domain users' - assert result.memberof('group-1') - - :param name: Group name. - :type name: str - :return: New group object. - :rtype: SambaGroup - """ - return SambaGroup(self, name) - - def ou(self, name: str, basedn: LDAPObject | str | None = None) -> SambaOrganizationalUnit: - """ - Get organizational unit object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.Samba) - def test_example(client: Client, samba: Samba): - # Create organizational unit for sudo rules - ou = samba.ou('mysudoers').add() - - # Create user - samba.user('user-1').add() - - # Create sudo rule - samba.sudorule('testrule', basedn=ou).add(user='ALL', host='ALL', command='/bin/ls') - - client.sssd.common.sudo() - client.sssd.start() - - # Test that user can run /bin/ls - assert client.auth.sudo.run('user-1', 'Secret123', command='/bin/ls') - - :param name: Unit name. - :type name: str - :param basedn: Base dn, defaults to None - :type basedn: LDAPObject | str | None, optional - :return: New organizational unit object. - :rtype: SambaOrganizationalUnit - """ - return SambaOrganizationalUnit(self, name, basedn) - - def sudorule(self, name: str, basedn: LDAPObject | str | None = "ou=sudoers") -> SambaSudoRule: - """ - Get sudo rule object. - - .. code-blocK:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.Samba) - def test_example(client: Client, samba: Samba): - user = samba.user('user-1').add(password="Secret123") - samba.sudorule('testrule').add(user=user, host='ALL', command='/bin/ls') - - client.sssd.common.sudo() - client.sssd.start() - - # Test that user can run /bin/ls - assert client.auth.sudo.run('user-1', 'Secret123', command='/bin/ls') - - :param name: Rule name. - :type name: str - :param basedn: Base dn, defaults to ``ou=sudoers`` - :type basedn: LDAPObject | str | None, optional - :return: New sudo rule object. - :rtype: SambaSudoRule - """ - return SambaSudoRule(self, SambaUser, SambaGroup, name, basedn) - - -class SambaObject(BaseObject): - """ - Base class for Samba DC object management. - - Provides shortcuts for command execution and implementation of :meth:`get` - and :meth:`delete` methods. - """ - - def __init__(self, role: Samba, command: str, name: str) -> None: - """ - :param role: Samba role object. - :type role: Samba - :param command: Samba command group. - :type command: str - :param name: Object name. - :type name: str - """ - super().__init__(role) - - self.command: str = command - """Samba-tool command.""" - - self.name: str = name - """Object name.""" - - def _exec(self, op: str, args: list[str] | None = None, **kwargs) -> SSHProcessResult: - """ - Execute samba-tool command. - - .. code-block:: console - - $ samba-tool $command $ op $name $args - for example >>> samba-tool user add tuser - - :param op: Command group operation (usually add, delete, show) - :type op: str - :param args: List of additional command arguments, defaults to None - :type args: list[str] | None, optional - :return: SSH process result. - :rtype: SSHProcessResult - """ - if args is None: - args = [] - - return self.role.host.ssh.exec(["samba-tool", self.command, op, self.name, *args], **kwargs) - - def _add(self, attrs: CLIBuilderArgs) -> None: - """ - Add Samba object. - - :param attrs: Object attributes in :class:`pytest_mh.cli.CLIBuilder` format, defaults to dict() - :type attrs: pytest_mh.cli.CLIBuilderArgs, optional - """ - self._exec("add", self.cli.args(attrs)) - - def _modify(self, attrs: dict[str, Any | list[Any] | DeleteAttribute | None]) -> None: - """ - Modify Samba object. - - :param attrs: Attributes to modify. - :type attrs: dict[str, Any | list[Any] | DeleteAttribute | None] - """ - obj = self.get() - - # Remove dn and distinguishedName attributes - dn = obj.pop("dn")[0] - del obj["distinguishedName"] - - # Build old attrs - old_attrs = {k: [str(i).encode("utf-8") for i in v] for k, v in obj.items()} - - # Update object - for attr, value in attrs.items(): - if value is None: - continue - - if isinstance(value, DeleteAttribute): - del obj[attr] - continue - - if not isinstance(value, list): - obj[attr] = [str(value)] - continue - - obj[attr] = to_list_of_strings(value) - - # Build new attrs - new_attrs = {k: [str(i).encode("utf-8") for i in v] for k, v in obj.items()} - - # Build diff - modlist = ldap.modlist.modifyModlist(old_attrs, new_attrs) - if modlist: - self.role.host.conn.modify_s(dn, modlist) - - def delete(self) -> None: - """ - Delete Samba object. - """ - self._exec("delete") - - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get Samba object attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - cmd = self._exec("show") - return attrs_parse(cmd.stdout_lines, attrs) - - -class SambaUser(SambaObject): - """ - Samba user management. - """ - - def __init__(self, role: Samba, name: str) -> None: - """ - :param role: Samba role object. - :type role: Samba - :param name: User name. - :type name: str - """ - super().__init__(role, "user", name) - - def add( - self, - *, - uid: int | None = None, - gid: int | None = None, - password: str | None = "Secret123", - home: str | None = None, - gecos: str | None = None, - shell: str | None = None, - ) -> SambaUser: - """ - Create new Samba user. - - Parameters that are not set are ignored. - - :param uid: User id, defaults to None - :type uid: int | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | None, optional - :param password: Password, defaults to 'Secret123' - :type password: str, optional - :param home: Home directory, defaults to None - :type home: str | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | None, optional - :param shell: Login shell, defaults to None - :type shell: str | None, optional - :return: Self. - :rtype: SambaUser - """ - attrs: CLIBuilderArgs = { - "password": (self.cli.option.POSITIONAL, password), - "given-name": (self.cli.option.VALUE, self.name), - "surname": (self.cli.option.VALUE, self.name), - "uid-number": (self.cli.option.VALUE, uid), - "gid-number": (self.cli.option.VALUE, gid), - "unix-home": (self.cli.option.VALUE, home), - "gecos": (self.cli.option.VALUE, gecos), - "login-shell": (self.cli.option.VALUE, shell), - } - - self._add(attrs) - return self - - def modify( - self, - *, - uid: int | DeleteAttribute | None = None, - gid: int | DeleteAttribute | None = None, - home: str | DeleteAttribute | None = None, - gecos: str | DeleteAttribute | None = None, - shell: str | DeleteAttribute | None = None, - ) -> SambaUser: - """ - Modify existing Samba user. - - Parameters that are not set are ignored. If needed, you can delete an - attribute by setting the value to :attr:`Delete`. - - :param uid: User id, defaults to None - :type uid: int | DeleteAttribute | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | DeleteAttribute | None, optional - :param home: Home directory, defaults to None - :type home: str | DeleteAttribute | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | DeleteAttribute | None, optional - :param shell: Login shell, defaults to None - :type shell: str | DeleteAttribute | None, optional - :return: Self. - :rtype: SambaUser - """ - attrs: dict[str, Any] = { - "uidNumber": uid, - "gidNumber": gid, - "unixHomeDirectory": home, - "gecos": gecos, - "loginShell": shell, - } - - self._modify(attrs) - return self - - -class SambaGroup(SambaObject): - """ - Samba group management. - """ - - def __init__(self, role: Samba, name: str) -> None: - """ - :param role: Samba role object. - :type role: Samba - :param name: Group name. - :type name: str - """ - super().__init__(role, "group", name) - - def add( - self, - *, - gid: int | None = None, - description: str | None = None, - scope: str = "Global", - category: str = "Security", - ) -> SambaGroup: - """ - Create new Samba group. - - :param gid: Group id, defaults to None - :type gid: int | None, optional - :param description: Description, defaults to None - :type description: str | None, optional - :param scope: Scope ('Global', 'Universal', 'DomainLocal'), defaults to 'Global' - :type scope: str, optional - :param category: Category ('Distribution', 'Security'), defaults to 'Security' - :type category: str, optional - :return: Self. - :rtype: SambaGroup - """ - attrs: CLIBuilderArgs = { - "gid-number": (self.cli.option.VALUE, gid), - "description": (self.cli.option.VALUE, description), - "group-scope": (self.cli.option.VALUE, scope), - "group-type": (self.cli.option.VALUE, category), - } - - self._add(attrs) - return self - - def modify( - self, - *, - gid: int | DeleteAttribute | None = None, - description: str | DeleteAttribute | None = None, - ) -> SambaGroup: - """ - Modify existing Samba group. - - Parameters that are not set are ignored. If needed, you can delete an - attribute by setting the value to :attr:`Delete`. - - :param gid: Group id, defaults to None - :type gid: int | DeleteAttribute | None, optional - :param description: Description, defaults to None - :type description: str | DeleteAttribute | None, optional - :return: Self. - :rtype: SambaUser - """ - attrs: dict[str, Any] = { - "gidNumber": gid, - "description": description, - } - - self._modify(attrs) - return self - - def add_member(self, member: SambaUser | SambaGroup) -> SambaGroup: - """ - Add group member. - - :param member: User or group to add as a member. - :type member: SambaUser | SambaGroup - :return: Self. - :rtype: SambaGroup - """ - return self.add_members([member]) - - def add_members(self, members: list[SambaUser | SambaGroup]) -> SambaGroup: - """ - Add multiple group members. - - :param member: List of users or groups to add as members. - :type member: list[SambaUser | SambaGroup] - :return: Self. - :rtype: SambaGroup - """ - self._exec("addmembers", self.__get_member_args(members)) - return self - - def remove_member(self, member: SambaUser | SambaGroup) -> SambaGroup: - """ - Remove group member. - - :param member: User or group to remove from the group. - :type member: SambaUser | SambaGroup - :return: Self. - :rtype: SambaGroup - """ - return self.remove_members([member]) - - def remove_members(self, members: list[SambaUser | SambaGroup]) -> SambaGroup: - """ - Remove multiple group members. - - :param member: List of users or groups to remove from the group. - :type member: list[SambaUser | SambaGroup] - :return: Self. - :rtype: SambaGroup - """ - self._exec("removemembers", self.__get_member_args(members)) - return self - - def __get_member_args(self, members: list[SambaUser | SambaGroup]) -> list[str]: - return [",".join([x.name for x in members])] - - -SambaOrganizationalUnit: TypeAlias = LDAPOrganizationalUnit[SambaHost, Samba] -SambaAutomount: TypeAlias = LDAPAutomount[SambaHost, Samba] -SambaSudoRule: TypeAlias = LDAPSudoRule[SambaHost, Samba, SambaUser, SambaGroup] diff --git a/src/tests/system/lib/sssd/topology.py b/src/tests/system/lib/sssd/topology.py deleted file mode 100644 index 598bb68738b..00000000000 --- a/src/tests/system/lib/sssd/topology.py +++ /dev/null @@ -1,215 +0,0 @@ -"""SSSD predefined well-known topologies.""" - -from __future__ import annotations - -from enum import unique -from typing import Any, Mapping, Tuple, final - -import pytest -from pytest_mh import KnownTopologyBase, KnownTopologyGroupBase, Topology, TopologyDomain, TopologyMark - -__all__ = [ - "KnownTopology", - "KnownTopologyGroup", -] - - -class SSSDTopologyMark(TopologyMark): - """ - Topology mark is used to describe test case requirements. It defines: - - * **name**, that is used to identify topology in pytest output - * **topology** (:class:Topology) that is required to run the test - * **fixtures** that are available during the test run - * **domains** that will be automatically configured on the client - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(name, topology, domains, fixture1='path1', fixture2='path2', ...) - def test_fixture_name(fixture1: BaseRole, fixture2: BaseRole, ...): - assert True - - Fixture path points to a host in the multihost configuration and can be - either in the form of ``$domain-type.$role`` (all host of given role) or - ``$domain-type.$role[$index]`` (specific host on given index). - - The ``name`` is visible in verbose pytest output after the test name, for example: - - .. code-block:: console - - tests/test_basic.py::test_case (topology-name) PASSED - """ - - def __init__( - self, - name: str, - topology: Topology, - fixtures: dict[str, str] | None = None, - domains: dict[str, str] | None = None, - ) -> None: - """ - :param name: Topology name used in pytest output. - :type name: str - :param topology: Topology required to run the test. - :type topology: Topology - :param fixtures: Dynamically created fixtures available during the test run. - :type fixtures: dict[str, str] | None, optional - :param domains: Automatically created SSSD domains on client host - :type domains: dict[str, str] | None, optional - """ - super().__init__(name, topology, fixtures) - - self.domains: dict[str, str] = domains if domains is not None else {} - """Map hosts to SSSD domains.""" - - def export(self) -> dict: - """ - Export the topology mark into a dictionary object that can be easily - converted to JSON, YAML or other formats. - - .. code-block:: python - - { - 'name': 'client', - 'fixtures': { 'client': 'sssd.client[0]' }, - 'topology': [ - { - 'type': 'sssd', - 'hosts': { 'client': 1 } - } - ], - 'domains': { 'test': 'sssd.ldap[0]' }, - } - - :rtype: dict - """ - d = super().export() - d["domains"] = self.domains - - return d - - @classmethod - def _CreateFromArgs(cls, item: pytest.Function, args: Tuple, kwargs: Mapping[str, Any]) -> TopologyMark: - """ - Create :class:`TopologyMark` from pytest marker arguments. - - .. warning:: - - This should only be called internally. You can inherit from - :class:`TopologyMark` and override this in order to add additional - attributes to the marker. - - :param item: Pytest item. - :type item: pytest.Function - :raises ValueError: If the marker is invalid. - :return: Instance of TopologyMark. - :rtype: TopologyMark - """ - # First three parameters are positional, the rest are keyword arguments. - if len(args) != 2 and len(args) != 3: - nodeid = item.parent.nodeid if item.parent is not None else "" - error = f"{nodeid}::{item.originalname}: invalid arguments for @pytest.mark.topology" - raise ValueError(error) - - name = args[0] - topology = args[1] - domains = args[2] if len(args) == 3 else {} - fixtures = {k: str(v) for k, v in kwargs.items()} - - return cls(name, topology, fixtures, domains) - - -@final -@unique -class KnownTopology(KnownTopologyBase): - """ - Well-known topologies that can be given to ``pytest.mark.topology`` - directly. It is expected to use these values in favor of providing - custom marker values. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_ldap(client: Client, ldap: LDAP): - assert True - """ - - Client = SSSDTopologyMark( - name="client", - topology=Topology(TopologyDomain("sssd", client=1, kdc=1)), - fixtures=dict(client="sssd.client[0]", kdc="sssd.kdc[0]"), - ) - """ - .. topology-mark:: KnownTopology.Client - """ - - LDAP = SSSDTopologyMark( - name="ldap", - topology=Topology(TopologyDomain("sssd", client=1, ldap=1, nfs=1, kdc=1)), - domains=dict(test="sssd.ldap[0]"), - fixtures=dict( - client="sssd.client[0]", ldap="sssd.ldap[0]", provider="sssd.ldap[0]", nfs="sssd.nfs[0]", kdc="sssd.kdc[0]" - ), - ) - """ - .. topology-mark:: KnownTopology.LDAP - """ - - IPA = SSSDTopologyMark( - name="ipa", - topology=Topology(TopologyDomain("sssd", client=1, ipa=1, nfs=1)), - domains=dict(test="sssd.ipa[0]"), - fixtures=dict(client="sssd.client[0]", ipa="sssd.ipa[0]", provider="sssd.ipa[0]", nfs="sssd.nfs[0]"), - ) - """ - .. topology-mark:: KnownTopology.IPA - """ - - AD = SSSDTopologyMark( - name="ad", - topology=Topology(TopologyDomain("sssd", client=1, ad=1, nfs=1)), - domains=dict(test="sssd.ad[0]"), - fixtures=dict(client="sssd.client[0]", ad="sssd.ad[0]", provider="sssd.ad[0]", nfs="sssd.nfs[0]"), - ) - """ - .. topology-mark:: KnownTopology.AD - """ - - Samba = SSSDTopologyMark( - name="samba", - topology=Topology(TopologyDomain("sssd", client=1, samba=1, nfs=1)), - domains={"test": "sssd.samba[0]"}, - fixtures=dict(client="sssd.client[0]", samba="sssd.samba[0]", provider="sssd.samba[0]", nfs="sssd.nfs[0]"), - ) - """ - .. topology-mark:: KnownTopology.Samba - """ - - -class KnownTopologyGroup(KnownTopologyGroupBase): - """ - Groups of well-known topologies that can be given to ``pytest.mark.topology`` - directly. It is expected to use these values in favor of providing - custom marker values. - - The test is parametrized and runs multiple times, once per each topology. - - .. code-block:: python - :caption: Example usage (runs on AD, IPA, LDAP and Samba topology) - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_ldap(client: Client, provider: GenericProvider): - assert True - """ - - AnyProvider = [KnownTopology.AD, KnownTopology.IPA, KnownTopology.LDAP, KnownTopology.Samba] - """ - .. topology-mark:: KnownTopologyGroup.AnyProvider - """ - - AnyAD = [KnownTopology.AD, KnownTopology.Samba] - """ - .. topology-mark:: KnownTopologyGroup.AnyAD - """ diff --git a/src/tests/system/lib/sssd/utils/__init__.py b/src/tests/system/lib/sssd/utils/__init__.py deleted file mode 100644 index 9c52fff5f7d..00000000000 --- a/src/tests/system/lib/sssd/utils/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""SSSD multihost utils used by roles.""" -from __future__ import annotations diff --git a/src/tests/system/lib/sssd/utils/authentication.py b/src/tests/system/lib/sssd/utils/authentication.py deleted file mode 100644 index 9e1d5a88423..00000000000 --- a/src/tests/system/lib/sssd/utils/authentication.py +++ /dev/null @@ -1,792 +0,0 @@ -"""Testing authentications and authorization mechanisms.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from pytest_mh import MultihostHost, MultihostUtility -from pytest_mh.ssh import SSHClient, SSHProcessResult - -__all__ = [ - "AuthenticationUtils", - "KerberosAuthenticationUtils", - "SSHAuthenticationUtils", - "SUAuthenticationUtils", - "SudoAuthenticationUtils", -] - - -class AuthenticationUtils(MultihostUtility[MultihostHost]): - """ - Methods for testing various authentication and authorization mechanisms. - - It executes commands on remote host in order to test authentication and - authorization via su, ssh, sudo and kerberos. - - .. note:: - - Since the authentication via su and ssh command can be mostly done via - the same mechanisms (like password or two-factor authentication), it - implements the same API. Therefore you can test su and ssh in the same - test case through parametrization. - - .. code-block:: python - :caption: Example - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - @pytest.mark.parametrize('method', ['su', 'ssh']) - def test_example(client: Client, provider: GenericProvider, method: str): - ldap.user('tuser').add(password='Secret123') - - client.sssd.start() - assert client.auth.parametrize(method).password('tuser', 'Secret123') - """ - - def __init__(self, host: MultihostHost) -> None: - """ - :param host: Remote host. - :type host: MultihostHost - """ - super().__init__(host) - - self.su: SUAuthenticationUtils = SUAuthenticationUtils(host) - """ - Test authentication and authorization via su. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP): - ldap.user('tuser').add(password='Secret123') - - client.sssd.start() - assert client.auth.su.password('tuser', 'Secret123') - """ - - self.sudo: SudoAuthenticationUtils = SudoAuthenticationUtils(host) - """ - Test authentication and authorization via sudo. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP): - u = ldap.user('tuser').add(password='Secret123') - ldap.sudorule('allow_ls').add(user=u, host='ALL', command='/bin/ls') - - client.sssd.common.sudo() - client.sssd.start() - - assert client.auth.sudo.list('tuser', 'Secret123', expected=['(root) /bin/ls']) - assert client.auth.sudo.run('tuser', 'Secret123', command='/bin/ls /root') - """ - - self.ssh: SSHAuthenticationUtils = SSHAuthenticationUtils(host) - """ - Test authentication and authorization via ssh. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP): - ldap.user('tuser').add(password='Secret123') - - client.sssd.start() - assert client.auth.ssh.password('tuser', 'Secret123') - """ - - def parametrize(self, method: str) -> SUAuthenticationUtils | SSHAuthenticationUtils: - """ - Return authentication tool based on the method. The method can be - either ``su`` or ``ssh``. - - :param method: ``su`` or ``ssh`` - :type method: str - :raises ValueError: If invalid method is specified. - :return: Authentication tool. - :rtype: HostSU | HostSSH - """ - - allowed = ["su", "ssh"] - if method not in allowed: - raise ValueError(f"Unknown method {method}, choose from {allowed}.") - - return getattr(self, method) - - def kerberos(self, ssh: SSHClient) -> KerberosAuthenticationUtils: - """ - Test authentication and authorization via Kerberos. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.LDAP) - def test_example(client: Client, ldap: LDAP, kdc: KDC): - ldap.user('tuser').add() - kdc.principal('tuser').add() - - client.sssd.common.krb5_auth(kdc) - client.sssd.start() - - with client.ssh('tuser', 'Secret123') as ssh: - with client.auth.kerberos(ssh) as krb: - assert krb.has_tgt(kdc.realm) - - :param ssh: SSH connection for the target user. - :type ssh: SSHClient - :return: Kerberos authentication object. - :rtype: KerberosAuthenticationUtils - """ - return KerberosAuthenticationUtils(self.host, ssh) - - -class SUAuthenticationUtils(MultihostUtility[MultihostHost]): - """ - Methods for testing authentication and authorization via su. - """ - - def password(self, username: str, password: str) -> bool: - """ - Call ``su - $username`` and authenticate the user with password. - - :param name: User name. - :type name: str - :param password: User password. - :type password: str - :return: True if authentication was successful, False otherwise. - :rtype: bool - """ - - result = self.host.ssh.expect_nobody( - rf""" - # It takes some time to get authentication failure - set timeout 10 - set prompt "\n.*\[#\$>\] $" - - spawn su - "{username}" - - expect {{ - "Password:" {{send "{password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - -re $prompt {{puts "expect result: Password authentication successful"; exit 0}} - "Authentication failure" {{puts "expect result: Authentication failure"; exit 4}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - puts "expect result: Unexpected code path" - exit 3 - """ - ) - - return result.rc == 0 - - def password_expired(self, username: str, password: str, new_password: str) -> bool: - """ - Call ``su - $username`` and authenticate the user with password, expect - that the password is expired and change it to the new password. - - :param username: User name. - :type name: str - :param password: Old, expired user password. - :type password: str - :param new_password: New user password. - :type new_password: str - :return: True if authentication and password change was successful, False otherwise. - :rtype: bool - """ - result = self.host.ssh.expect_nobody( - rf""" - # It takes some time to get authentication failure - set timeout 10 - set prompt "\n.*\[#\$>\] $" - - spawn su - "{username}" - - expect {{ - "Password:" {{send "{password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - "Password expired. Change your password now." {{ }} - -re $prompt {{puts "expect result: Authentication succeeded without password change"; exit 3}} - "Authentication failure" {{puts "expect result: Authentication failure"; exit 4}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - "Current Password:" {{send "{password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - "New password:" {{send "{new_password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - "Retype new password:" {{send "{new_password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - -re $prompt {{puts "expect result: Password change was successful"; exit 0}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - puts "expect result: Unexpected code path" - exit 3 - """ - ) - - return result.rc == 0 - - -class SSHAuthenticationUtils(MultihostUtility[MultihostHost]): - """ - Methods for testing authentication and authorization via ssh. - """ - - def __init__(self, host: MultihostHost) -> None: - """ - :param host: Multihost host. - :type host: MultihostHost - """ - super().__init__(host) - - self.opts = "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" - """SSH CLI options.""" - - def password(self, username: str, password: str) -> bool: - """ - SSH to the remote host and authenticate the user with password. - - :param name: User name. - :type name: str - :param password: User password. - :type password: str - :return: True if authentication was successful, False otherwise. - :rtype: bool - """ - - result = self.host.ssh.expect_nobody( - rf""" - # It takes some time to get authentication failure - set timeout 10 - set prompt "\n.*\[#\$>\] $" - - spawn ssh {self.opts} \ - -o PreferredAuthentications=password \ - -o NumberOfPasswordPrompts=1 \ - -l "{username}" localhost - - expect {{ - "password:" {{send "{password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - -re $prompt {{puts "expect result: Password authentication successful"; exit 0}} - "{username}@localhost: Permission denied" {{puts "expect result: Authentication failure"; exit 4}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - puts "expect result: Unexpected code path" - exit 3 - """ - ) - - return result.rc == 0 - - def password_expired(self, username: str, password: str, new_password: str) -> bool: - """ - SSH to the remote host and authenticate the user with password, expect - that the password is expired and change it to the new password. - - :param username: User name. - :type name: str - :param password: Old, expired user password. - :type password: str - :param new_password: New user password. - :type new_password: str - :return: True if authentication and password change was successful, False otherwise. - :rtype: bool - """ - result = self.host.ssh.expect_nobody( - rf""" - # It takes some time to get authentication failure - set timeout 10 - set prompt "\n.*\[#\$>\] $" - - spawn ssh {self.opts} \ - -o PreferredAuthentications=password \ - -o NumberOfPasswordPrompts=1 \ - -l "{username}" localhost - - expect {{ - "password:" {{send "{password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - "Password expired. Change your password now." {{ }} - -re $prompt {{puts "expect result: Authentication succeeded without password change"; exit 3}} - "{username}@localhost: Permission denied" {{puts "expect result: Authentication failure"; exit 4}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - "Current Password:" {{send "{password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - "New password:" {{send "{new_password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - "Retype new password:" {{send "{new_password}\n"}} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - "passwd: all authentication tokens updated successfully." {{ }} - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Unexpected end of file"; exit 2}} - }} - - expect {{ - timeout {{puts "expect result: Unexpected output"; exit 1}} - eof {{puts "expect result: Password change was successful"; exit 0}} - }} - - puts "expect result: Unexpected code path" - exit 3 - """ - ) - - return result.rc == 0 - - -class SudoAuthenticationUtils(MultihostUtility[MultihostHost]): - """ - Methods for testing authentication and authorization via sudo. - """ - - def run(self, username: str, password: str | None = None, *, command: str) -> bool: - """ - Execute sudo command. - - :param username: Username that calls sudo. - :type username: str - :param password: User password, defaults to None - :type password: str | None, optional - :param command: Command to execute (make sure to properly escape any quotes). - :type command: str - :return: True if the command was successful, False if the command failed or the user can not run sudo. - :rtype: bool - """ - result = self.host.ssh.run( - f'su - "{username}" -c "sudo --stdin {command}"', input=password, raise_on_error=False - ) - - return result.rc == 0 - - def list(self, username: str, password: str | None = None, *, expected: list[str] | None = None) -> bool: - """ - List commands that the user can run under sudo. - - :param username: Username that runs sudo. - :type username: str - :param password: User password, defaults to None - :type password: str | None, optional - :param expected: List of expected commands (formatted as sudo output), defaults to None - :type expected: list[str] | None, optional - :return: True if the user can run sudo and allowed commands match expected commands (if set), False otherwise. - :rtype: bool - """ - result = self.host.ssh.run(f'su - "{username}" -c "sudo --stdin -l"', input=password, raise_on_error=False) - if result.rc != 0: - return False - - if expected is None: - return True - - allowed = [] - for line in reversed(result.stdout_lines): - if not line.startswith(" "): - break - allowed.append(line.strip()) - - for line in expected: - if line not in allowed: - return False - allowed.remove(line) - - if len(allowed) > 0: - return False - - return True - - -class KerberosAuthenticationUtils(MultihostUtility[MultihostHost]): - """ - Methods for testing Kerberos authentication and KCM. - """ - - def __init__(self, host: MultihostHost, ssh: SSHClient | None = None) -> None: - """ - :param host: Multihost host. - :type host: MultihostHost - :param ssh: SSH client for the target user, defaults to None - :type ssh: SSHClient | None, optional - """ - super().__init__(host) - - self.ssh: SSHClient = ssh if ssh is not None else host.ssh - """SSH client for the target user.""" - - def kinit( - self, principal: str, *, password: str, realm: str | None = None, args: list[str] | None = None - ) -> SSHProcessResult: - """ - Run ``kinit`` command. - - Principal can be without the realm part. The realm can be given in - separate parameter ``realm``, in such case the principal name is - constructed as ``$principal@$realm``. If the principal does not contain - realm specification and ``realm`` parameter is not set then the default - realm is used. - - :param principal: Kerberos principal. - :type principal: str - :param password: Principal's password. - :type password: str - :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None - :type realm: str | None, optional - :param args: Additional parameters to ``klist``, defaults to None - :type args: list[str] | None, optional - :return: Command result. - :rtype: SSHProcessResult - """ - if args is None: - args = [] - - if realm is not None: - principal = f"{principal}@{realm}" - - return self.ssh.exec(["kinit", *args, principal], input=password) - - def kvno(self, principal: str, *, realm: str | None = None, args: list[str] | None = None) -> SSHProcessResult: - """ - Run ``kvno`` command. - - Principal can be without the realm part. The realm can be given in - separate parameter ``realm``, in such case the principal name is - constructed as ``$principal@$realm``. If the principal does not contain - realm specification and ``realm`` parameter is not set then the default - realm is used. - - :param principal: Kerberos principal. - :type principal: str - :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None - :type realm: str | None, optional - :param args: Additional parameters to ``klist``, defaults to None - :type args: list[str] | None, optional - :return: Command result. - :rtype: SSHProcessResult - """ - if args is None: - args = [] - - if realm is not None: - principal = f"{principal}@{realm}" - - return self.ssh.exec(["kvno", *args, principal]) - - def klist(self, *, args: list[str] | None = None) -> SSHProcessResult: - """ - Run ``klist`` command. - - :param args: Additional parameters to ``klist``, defaults to None - :type args: list[str] | None, optional - :return: Command result. - :rtype: SSHProcessResult - """ - if args is None: - args = [] - - return self.ssh.exec(["klist", *args]) - - def kswitch(self, principal: str, realm: str) -> SSHProcessResult: - """ - Run ``kswitch -p principal@realm`` command. - - :param principal: Kerberos principal. - :type principal: str - :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``) - :type realm: str - :return: Command result. - :rtype: SSHProcessResult - """ - if "@" not in principal: - principal = f"{principal}@{realm}" - - return self.ssh.exec(["kswitch", "-p", principal]) - - def kdestroy( - self, *, all: bool = False, ccache: str | None = None, principal: str | None = None, realm: str | None = None - ) -> SSHProcessResult: - """ - Run ``kdestroy`` command. - - Principal can be without the realm part. The realm can be given in - separate parameter ``realm``, in such case the principal name is - constructed as ``$principal@$realm``. If the principal does not contain - realm specification and ``realm`` parameter is not set then the default - realm is used. - - :param all: Destroy all ccaches (``kdestroy -A``), defaults to False - :type all: bool, optional - :param ccache: Destroy specific ccache (``kdestroy -c $cache``), defaults to None - :type ccache: str | None, optional - :param principal: Destroy ccache for given principal (``kdestroy -p $princ``), defaults to None - :type principal: str | None, optional - :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None - :type realm: str | None, optional - :return: Command result. - :rtype: SSHProcessResult - """ - args = [] - - if all: - args.append("-A") - - if ccache is not None: - args.append("-c") - args.append(ccache) - - if realm is not None and principal is not None: - principal = f"{principal}@{realm}" - - if principal is not None: - args.append("-p") - args.append(principal) - - return self.ssh.exec(["kdestroy", *args]) - - def has_tgt(self, principal: str | None, realm: str) -> bool: - """ - Check that the user has obtained Kerberos Ticket Granting Ticket for - given principle. If ``principal`` is ``None`` then primary principal is - checked. - - :param principal: Expected principal for which the TGT was obtained (without the realm part). - :type principle: str | None - :param realm: Expected realm for which the TGT was obtained. - :type realm: str - :return: True if TGT is available, False otherwise. - :rtype: bool - """ - if principal is not None: - result = self.klist() - return f"krbtgt/{realm}@{realm}" in result.stdout - - principals = self.list_principals() - tickets = principals.get(f"{principal}@{realm}", []) - - return "krbtgt/{realm}@{realm}" in tickets - - def has_primary_cache(self, principal: str, realm: str) -> bool: - """ - Check that the ccache for given principal is the primary one. - - :param principal: Kerberos principal. - :type principal: str - :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``) - :type realm: str - :return: True if the ccache for given principal is the primary one. - :rtype: bool - """ - result = self.ssh.exec(["klist", "-l"], raise_on_error=False) - if result.rc != 0: - return False - - if len(result.stdout_lines) <= 2: - return False - - primary = result.stdout_lines[2] - - return f"{principal}@{realm}" in primary - - def has_tickets(self, principal: str, realm: str, expected: list[str]) -> bool: - """ - Check that the ccache contains all tickets from ``expected`` and nothing - more. - - :param principal: Kerberos principal. - :type principal: str - :param realm: Kerberos realm that is appended to the principal - (``$principal@$realm``) - :type realm: str - :param expected: List of tickets that must be present in the ccache. - :type expected: list[str] - :return: True if the ccache contains exactly ``expected`` tickets. - :rtype: bool - """ - ccaches = self.list_principals() - principal = f"{principal}@{realm}" - - if principal not in ccaches: - return False - - return ccaches[principal] == expected - - def cache_count(self) -> int: - """ - Return number of existing credential caches (or number of principals) - for active user (klist -l). - - :return: Number of existing ccaches. - :rtype: int - """ - result = self.ssh.exec(["klist", "-l"], raise_on_error=False) - if result.rc != 0: - return 0 - - if len(result.stdout_lines) <= 2: - return 0 - - return len(result.stdout_lines) - 2 - - def list_principals(self, env: dict[str, Any] | None = None) -> dict[str, list[str]]: - """ - List all principals that have existing credential cache. - - :param env: Additional environment variables passed to ``klist -A`` command, defaults to None - :type env: dict[str, Any] | None, optional - :return: Dictionary with principal as the key and list of available tickets as value. - :rtype: dict[str, list[str]] - """ - - def __parse_output(result: SSHProcessResult) -> dict[str, list[str]]: - ccache_principal: str | None = None - ccache: dict[str, list[str]] = dict() - - for line in result.stdout_lines: - if line.startswith("Default principal"): - ccache_principal = line.split()[-1] - ccache.setdefault(ccache_principal, []) - continue - - if ccache_principal is not None and "@" in line: - ticket = line.split()[-1] - ccache[ccache_principal].append(ticket) - - return ccache - - result = self.ssh.exec(["klist", "-A"], env=env, raise_on_error=False) - if result.rc != 0: - return dict() - - return __parse_output(result) - - def list_ccaches(self) -> dict[str, str]: - """ - List all available ccaches. - - :return: Dictionary with principal as the key and ccache name as value. - :rtype: dict[str, str] - """ - - def __parse_output(result: SSHProcessResult) -> dict[str, str]: - if len(result.stdout_lines) <= 2: - return dict() - - ccaches: dict[str, str] = dict() - for line in result.stdout_lines[2:]: - (principal, ccache) = line.split(maxsplit=2) - ccaches[principal] = ccache - - return ccaches - - result = self.ssh.exec(["klist", "-l"], raise_on_error=False) - if result.rc != 0: - return dict() - - return __parse_output(result) - - def list_tgt_times(self, realm: str) -> tuple[datetime, datetime]: - """ - Return start and expiration time of primary ccache TGT. - - :param realm: Expected realm for which the TGT was obtained. - :type realm: str - :return: (start time, expiration time) of the TGT - :rtype: tuple[int, int] - """ - tgt = f"krbtgt/{realm}@{realm}" - result = self.klist() - for line in result.stdout_lines: - if tgt in line: - (sdate, stime, edate, etime, principal) = line.split(maxsplit=5) - - start = None - end = None - - # format may be different on different hosts - for format in ["%m/%d/%y %H:%M:%S", "%m/%d/%Y %H:%M:%S"]: - try: - start = datetime.strptime(f"{sdate} {stime}", format) - end = datetime.strptime(f"{edate} {etime}", format) - except ValueError: - continue - - if start is None: - raise ValueError(f"Unable to parse datetime: {sdate} {stime}") - - if end is None: - raise ValueError(f"Unable to parse datetime: {edate} {etime}") - - return (start, end) - - raise Exception("TGT was not found") - - def __enter__(self) -> KerberosAuthenticationUtils: - """ - Connect to the host over ssh if not already connected. - - :return: Self.. - :rtype: HostKerberos - """ - self.ssh.connect() - return self - - def __exit__(self, exception_type, exception_value, traceback) -> None: - """ - Disconnect. - """ - self.kdestroy(all=True) diff --git a/src/tests/system/lib/sssd/utils/authselect.py b/src/tests/system/lib/sssd/utils/authselect.py deleted file mode 100644 index 825eb0cae6e..00000000000 --- a/src/tests/system/lib/sssd/utils/authselect.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Selecting authselect profiles.""" - -from __future__ import annotations - -from pytest_mh import MultihostHost, MultihostUtility - -__all__ = [ - "AuthselectUtils", -] - - -class AuthselectUtils(MultihostUtility[MultihostHost]): - """ - Use authselect to configure nsswitch and PAM. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_example(client: Client, provider: GenericProvider): - client.authselect.select('sssd', ['with-mkhomedir']) - - .. note:: - - All changes are automatically reverted when a test is finished. - """ - - def __init__(self, host: MultihostHost) -> None: - """ - :param host: Remote host instance. - :type host: MultihostHost - """ - super().__init__(host) - self.__backup: str | None = None - - def teardown(self): - """ - Revert to original state. - - :meta private: - """ - if self.__backup is not None: - self.host.ssh.exec(["authselect", "backup-restore", self.__backup]) - self.host.ssh.exec(["rm", "-fr", f"/var/lib/authselect/backups/{self.__backup}"]) - self.__backup = None - - super().teardown() - - def select(self, profile: str, features: list[str] = []) -> None: - """ - Select an authselect profile. - - :param profile: Autheselect profile name. - :type profile: str - :param features: Authselect features to enable, defaults to [] - :type features: list[str], optional - """ - backup = [] - if self.__backup is None: - self.__backup = "multihost.backup" - backup = [f"--backup={self.__backup}"] - - self.host.ssh.exec(["authselect", "select", profile, *features, "--force", *backup]) diff --git a/src/tests/system/lib/sssd/utils/automount.py b/src/tests/system/lib/sssd/utils/automount.py deleted file mode 100644 index 01c7ac63fc6..00000000000 --- a/src/tests/system/lib/sssd/utils/automount.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Testing autofs/automount.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pytest_mh import MultihostHost, MultihostUtility -from pytest_mh.utils.services import SystemdServices - -if TYPE_CHECKING: - from ..roles.nfs import NFSExport - - -__all__ = [ - "AutomountUtils", -] - - -class AutomountUtils(MultihostUtility[MultihostHost]): - """ - Methods for testing automount. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopologyGroup.AnyProvider) - def test_example_(client: Client, provider: GenericProvider, nfs: NFS): - nfs_export1 = nfs.export('export1').add() - nfs_export2 = nfs.export('export2').add() - nfs_export3 = nfs.export('sub/export3').add() - - # Create automount maps - auto_master = provider.automount.map('auto.master').add() - auto_home = provider.automount.map('auto.home').add() - auto_sub = provider.automount.map('auto.sub').add() - - # Create mount points - auto_master.key('/ehome').add(info=auto_home) - auto_master.key('/esub/sub1/sub2').add(info=auto_sub) - - # Create mount keys - key1 = auto_home.key('export1').add(info=nfs_export1) - key2 = auto_home.key('export2').add(info=nfs_export2) - key3 = auto_sub.key('export3').add(info=nfs_export3) - - # Start SSSD - client.sssd.common.autofs() - client.sssd.start() - - # Reload automounter in order to fetch updated maps - client.automount.reload() - - # Check that we can mount all directories on correct locations - assert client.automount.mount('/ehome/export1', nfs_export1) - assert client.automount.mount('/ehome/export2', nfs_export2) - assert client.automount.mount('/esub/sub1/sub2/export3', nfs_export3) - - # Check that the maps are correctly fetched - assert client.automount.dumpmaps() == { - '/ehome': { - 'map': 'auto.home', - 'keys': [str(key1), str(key2)] - }, - '/esub/sub1/sub2': { - 'map': 'auto.sub', - 'keys': [str(key3)] - }, - } - - .. note:: - - All changes are automatically reverted when a test is finished. - """ - - def __init__(self, host: MultihostHost, svc: SystemdServices) -> None: - """ - :param host: Remote host instance. - :type host: MultihostHost - """ - super().__init__(host) - self.svc: SystemdServices = svc - self.__started: bool = False - - def reload(self) -> None: - """ - Reload autofs maps. - """ - self.svc.start("autofs") - self.svc.reload("autofs") - - def mount(self, path: str, export: NFSExport) -> bool: - """ - Try to mount the autofs directory by accessing it. Returns ``True`` - if the mount was successful, ``False`` otherwise. - - :param path: Path to the autofs mount point. - :type path: str - :param export: Expected NFS location that should be mounted on the mount point. - :type export: NFSExport - :return: ``True`` if the mount was successful, ``False`` otherwise. - :rtype: bool - """ - - result = self.host.ssh.run( - rf""" - set -ex - pushd "{path}" - mount | grep "{export.hostname}:{export.fullpath} on {path}" - popd - umount "{path}" - """, - raise_on_error=False, - ) - - return result.rc == 0 - - def dumpmaps(self) -> dict[str, dict[str, str | list[str]]]: - """ - Calls ``automount -m``, parses its output into a dictionary and returns the dictionary. - - .. code-block:: python - :caption: Dictionary format - - { - '$mountpoint': { - 'map': '$mapname', - 'keys': ['$key1', '$key2'] - } - } - - .. code-block:: python - :caption: Example - - { - '/ehome': { - 'map': 'auto.home', - 'keys': [ - 'export1 | -fstype=nfs,rw,sync,no_root_squash nfs.test:/dev/shm/exports/export1', - 'export2 | -fstype=nfs,rw,sync,no_root_squash nfs.test:/dev/shm/exports/export2' - ] - }, - '/esub/sub1/sub2': { - 'map': 'auto.sub', - 'keys': ['export3 | -fstype=nfs,rw,sync,no_root_squash nfs.test:/dev/shm/exports/sub/export3'] - }, - } - - .. note:: - - Only mountpoints defined by SSSD are present in the output. - - :return: Parsed ``automount -m`` output. - :rtype: dict[str, dict[str, list[str]]] - """ - result = self.host.ssh.run("automount -m") - - def parse_result(lines: list[str]) -> dict[str, dict[str, str | list[str]]]: - mountpoints: dict[str, dict[str, str | list[str]]] = {} - for i, l in enumerate(lines): - if l.startswith("Mount point: "): - point = l.replace("Mount point: ", "").strip() - for k, l2 in enumerate(lines[i + 1 :], i + 1): - if l2.startswith("Mount point: "): - break - - data = lines[i + 1 : k] - if "instance type(s): sss" not in data: - continue - - data.remove("source(s):") - data.remove("instance type(s): sss") - - mapname = None - for k, item in enumerate(data): - if item.startswith("map: "): - mapname = item.replace("map: ", "").strip() - del data[k] - - # Ignore if the map name is unreadable, this should not happen - if mapname is None: - continue - - data = [x.strip() for x in data if x] - mountpoints[point] = {"map": mapname, "keys": data} - - return mountpoints - - return parse_result([x.strip() for x in result.stdout_lines]) diff --git a/src/tests/system/lib/sssd/utils/ldap.py b/src/tests/system/lib/sssd/utils/ldap.py deleted file mode 100644 index 06a0ca4ec90..00000000000 --- a/src/tests/system/lib/sssd/utils/ldap.py +++ /dev/null @@ -1,168 +0,0 @@ -"Direct LDAP access to an LDAP server." - -from __future__ import annotations - -import base64 -import hashlib -from typing import Any, TypeAlias - -import ldap -import ldap.ldapobject -from pytest_mh import MultihostUtility - -from ..hosts.base import BaseLDAPDomainHost - -__all__ = [ - "LDAPRecordAttributes", - "LDAPUtils", -] - - -LDAPRecordAttributes: TypeAlias = dict[str, Any | list[Any] | None] -"""LDAP Record Attributes dictionary type.""" - - -class LDAPUtils(MultihostUtility[BaseLDAPDomainHost]): - """ - Methods for direct LDAP access to an LDAP server. - """ - - @property - def conn(self) -> ldap.ldapobject.LDAPObject: - """ - LDAP connection for direct manipulation with the directory server - through ``python-ldap``. - - :rtype: ldap.ldapobject.LDAPObject - """ - return self.host.conn - - @property - def naming_context(self) -> str: - """ - Default naming context. - - :rtype: str - """ - return self.host.naming_context - - def hash_password(self, password: str) -> str: - """ - Compute sha256 hash of a password that can be used as a value. - - :param password: Password to hash. - :type password: str - :return: Base64 of sha256 hash digest. - :rtype: str - """ - digest = hashlib.sha256(password.encode("utf-8")).digest() - b64 = base64.b64encode(digest) - - return "{SHA256}" + b64.decode("utf-8") - - def dn(self, rdn: str, basedn: str | None = None) -> str: - """ - Get distinguished name of an object. - - :param rdn: Relative DN. - :type rdn: str - :param basedn: Base DN, defaults to None - :type basedn: str | None, optional - :return: Distinguished name combined as rdn+dn+naming-context. - :rtype: str - """ - if not basedn: - return f"{rdn},{self.naming_context}" - - return f"{rdn},{basedn},{self.naming_context}" - - def add(self, dn: str, attrs: LDAPRecordAttributes) -> None: - """ - Add an LDAP entry. - - :param dn: Distinguished name. - :type dn: str - :param attrs: Attributes, key is attribute name. - :type attrs: LDAPRecordAttributes - """ - addlist = [] - for attr, values in attrs.items(): - bytes_values = self.__values_to_bytes(values) - - # Skip if the value is None - if bytes_values is None: - continue - - addlist.append((attr, bytes_values)) - - self.conn.add_s(dn, addlist) - - def delete(self, dn: str) -> None: - """ - Delete LDAP entry. - - :param dn: Distinguished name. - :type dn: str - """ - self.conn.delete_s(dn) - - def modify( - self, - dn: str, - *, - add: LDAPRecordAttributes | None = None, - replace: LDAPRecordAttributes | None = None, - delete: LDAPRecordAttributes | None = None, - ) -> None: - """ - Modify LDAP entry. - - :param dn: Distinguished name. - :type dn: str - :param add: Attributes to add, defaults to None - :type add: LDAPRecordAttributes | None, optional - :param replace: Attributes to replace, defaults to None - :type replace: LDAPRecordAttributes | None, optional - :param delete: Attributes to delete, defaults to None - :type delete: LDAPRecordAttributes | None, optional - """ - modlist = [] - - if add is None: - add = {} - - if replace is None: - replace = {} - - if delete is None: - delete = {} - - for attr, values in add.items(): - modlist.append((ldap.MOD_ADD, attr, self.__values_to_bytes(values))) - - for attr, values in replace.items(): - modlist.append((ldap.MOD_REPLACE, attr, self.__values_to_bytes(values))) - - for attr, values in delete.items(): - modlist.append((ldap.MOD_DELETE, attr, self.__values_to_bytes(values))) - - self.conn.modify_s(dn, modlist) - - def __values_to_bytes(self, values: Any | list[Any]) -> list[bytes] | None: - """ - Convert values to bytes. Any value is converted to string and then - encoded into bytes. The input can be either single value or list of - values or None in which case None is returned. - - :param values: Values. - :type values: Any | list[Any] - :return: Values converted to bytes. - :rtype: list[bytes] - """ - if values is None: - return None - - if not isinstance(values, list): - values = [values] - - return [str(v).encode("utf-8") for v in values] diff --git a/src/tests/system/lib/sssd/utils/local_users.py b/src/tests/system/lib/sssd/utils/local_users.py deleted file mode 100644 index bcbb0c79257..00000000000 --- a/src/tests/system/lib/sssd/utils/local_users.py +++ /dev/null @@ -1,410 +0,0 @@ -"Managing local users and groups." - -from __future__ import annotations - -import jc -from pytest_mh import MultihostHost, MultihostUtility -from pytest_mh.cli import CLIBuilder, CLIBuilderArgs -from pytest_mh.ssh import SSHLog - -__all__ = [ - "LocalGroup", - "LocalUser", - "LocalUsersUtils", -] - - -class LocalUsersUtils(MultihostUtility[MultihostHost]): - """ - Management of local users and groups. - - .. note:: - - All changes are automatically reverted when a test is finished. - """ - - def __init__(self, host: MultihostHost) -> None: - """ - :param host: Remote host instance. - :type host: MultihostHost - """ - super().__init__(host) - - self.cli: CLIBuilder = CLIBuilder(host.ssh) - self._users: list[str] = [] - self._groups: list[str] = [] - - def teardown(self) -> None: - """ - Delete any added user and group. - """ - cmd = "" - - if self._users: - cmd += "\n".join([f"userdel '{x}' --force --remove" for x in self._users]) - cmd += "\n" - - if self._groups: - cmd += "\n".join([f"groupdel '{x}' --force" for x in self._groups]) - cmd += "\n" - - if cmd: - self.host.ssh.run("set -e\n\n" + cmd) - - super().teardown() - - def user(self, name: str) -> LocalUser: - """ - Get user object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.Client) - def test_example(client: Client): - # Create user - client.local.user('user-1').add(uid=10001) - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.user.id == 10001 - assert result.group.name == 'user-1' - assert result.group.id == 10001 - - :param name: User name. - :type name: str - :return: New user object. - :rtype: LocalUser - """ - return LocalUser(self, name) - - def group(self, name: str) -> LocalGroup: - """ - Get group object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.Client) - def test_example(client: Client): - # Create user - user = client.local.user('user-1').add(uid=10001) - - # Create secondary group and add user as a member - client.local.group('group-1').add().add_member(user) - - # Call `id user-1` and assert the result - result = client.tools.id('user-1') - assert result is not None - assert result.user.name == 'user-1' - assert result.user.id == 10001 - assert result.group.name == 'user-1' - assert result.group.id == 10001 - assert result.memberof('group-1') - - :param name: Group name. - :type name: str - :return: New group object. - :rtype: LocalGroup - """ - return LocalGroup(self, name) - - -class LocalUser(object): - """ - Management of local users. - """ - - def __init__(self, util: LocalUsersUtils, name: str) -> None: - """ - :param util: LocalUsersUtils utility object. - :type util: LocalUsersUtils - :param name: User name. - :type name: str - """ - self.util = util - self.name = name - - def add( - self, - *, - uid: int | None = None, - gid: int | None = None, - password: str | None = "Secret123", - home: str | None = None, - gecos: str | None = None, - shell: str | None = None, - ) -> LocalUser: - """ - Create new local user. - - :param uid: User id, defaults to None - :type uid: int | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | None, optional - :param password: Password, defaults to 'Secret123' - :type password: str, optional - :param home: Home directory, defaults to None - :type home: str | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | None, optional - :param shell: Login shell, defaults to None - :type shell: str | None, optional - :return: Self. - :rtype: LocalUser - """ - args: CLIBuilderArgs = { - "name": (self.util.cli.option.POSITIONAL, self.name), - "uid": (self.util.cli.option.VALUE, uid), - "gid": (self.util.cli.option.VALUE, gid), - "home": (self.util.cli.option.VALUE, home), - "gecos": (self.util.cli.option.VALUE, gecos), - "shell": (self.util.cli.option.VALUE, shell), - } - - passwd = f" && passwd --stdin '{self.name}'" if password else "" - self.util.logger.info(f'Creating local user "{self.name}" on {self.util.host.hostname}') - self.util.host.ssh.run(self.util.cli.command("useradd", args) + passwd, input=password, log_level=SSHLog.Error) - - self.util._users.append(self.name) - return self - - def modify( - self, - *, - uid: int | None = None, - gid: int | None = None, - password: str | None = None, - home: str | None = None, - gecos: str | None = None, - shell: str | None = None, - ) -> LocalUser: - """ - Modify existing local user. - - Parameters that are not set are ignored. - - :param uid: User id, defaults to None - :type uid: int | None, optional - :param gid: Primary group id, defaults to None - :type gid: int | None, optional - :param home: Home directory, defaults to None - :type home: str | None, optional - :param gecos: GECOS, defaults to None - :type gecos: str | None, optional - :param shell: Login shell, defaults to None - :type shell: str | None, optional - :return: Self. - :rtype: LocalUser - """ - - args: CLIBuilderArgs = { - "name": (self.util.cli.option.POSITIONAL, self.name), - "uid": (self.util.cli.option.VALUE, uid), - "gid": (self.util.cli.option.VALUE, gid), - "home": (self.util.cli.option.VALUE, home), - "gecos": (self.util.cli.option.VALUE, gecos), - "shell": (self.util.cli.option.VALUE, shell), - } - - passwd = f" && passwd --stdin '{self.name}'" if password else "" - self.util.logger.info(f'Modifying local user "{self.name}" on {self.util.host.hostname}') - self.util.host.ssh.run(self.util.cli.command("usermod", args) + passwd, input=password, log_level=SSHLog.Error) - - return self - - def delete(self) -> None: - """ - Delete the user. - """ - self.util.logger.info(f'Deleting local user "{self.name}" on {self.util.host.hostname}') - self.util.host.ssh.run(f"userdel '{self.name}' --force --remove", log_level=SSHLog.Error) - self.util._users.remove(self.name) - - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get user attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - self.util.logger.info(f'Fetching local user "{self.name}" on {self.util.host.hostname}') - result = self.util.host.ssh.exec(["getent", "passwd", self.name], raise_on_error=False, log_level=SSHLog.Error) - if result.rc != 0: - return {} - - jcresult = jc.parse("passwd", result.stdout) - if not jcresult: - return {} - - if not isinstance(jcresult, list): - raise TypeError(f"Unexpected type: {type(jcresult)}, expecting list") - - if not isinstance(jcresult[0], dict): - raise TypeError(f"Unexpected type: {type(jcresult[0])}, expecting dict") - - return {k: [str(v)] for k, v in jcresult[0].items() if not attrs or k in attrs} - - -class LocalGroup(object): - """ - Management of local groups. - """ - - def __init__(self, util: LocalUsersUtils, name: str) -> None: - """ - :param util: LocalUsersUtils utility object. - :type util: LocalUsersUtils - :param name: Group name. - :type name: str - """ - self.util = util - self.name = name - - def add( - self, - *, - gid: int | None = None, - ) -> LocalGroup: - """ - Create new local group. - - :param gid: Group id, defaults to None - :type gid: int | None, optional - :return: Self. - :rtype: LocalGroup - """ - args: CLIBuilderArgs = { - "name": (self.util.cli.option.POSITIONAL, self.name), - "gid": (self.util.cli.option.VALUE, gid), - } - - self.util.logger.info(f'Creating local group "{self.name}" on {self.util.host.hostname}') - self.util.host.ssh.run(self.util.cli.command("groupadd", args), log_level=SSHLog.Silent) - self.util._groups.append(self.name) - - return self - - def modify( - self, - *, - gid: int | None = None, - ) -> LocalGroup: - """ - Modify existing local group. - - Parameters that are not set are ignored. - - :param gid: Group id, defaults to None - :type gid: int | None, optional - :return: Self. - :rtype: LocalGroup - """ - - args: CLIBuilderArgs = { - "name": (self.util.cli.option.POSITIONAL, self.name), - "gid": (self.util.cli.option.VALUE, gid), - } - - self.util.logger.info(f'Modifying local group "{self.name}" on {self.util.host.hostname}') - self.util.host.ssh.run(self.util.cli.command("groupmod", args), log_level=SSHLog.Error) - - return self - - def delete(self) -> None: - """ - Delete the group. - """ - self.util.logger.info(f'Deleting local group "{self.name}" on {self.util.host.hostname}') - self.util.host.ssh.run(f"groupdel '{self.name}' --force", log_level=SSHLog.Error) - self.util._groups.remove(self.name) - - def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: - """ - Get group attributes. - - :param attrs: If set, only requested attributes are returned, defaults to None - :type attrs: list[str] | None, optional - :return: Dictionary with attribute name as a key. - :rtype: dict[str, list[str]] - """ - self.util.logger.info(f'Fetching local group "{self.name}" on {self.util.host.hostname}') - result = self.util.host.ssh.exec(["getent", "group", self.name], raise_on_error=False, log_level=SSHLog.Silent) - if result.rc != 0: - return {} - - jcresult = jc.parse("group", result.stdout) - if not jcresult: - return {} - - if not isinstance(jcresult, list): - raise TypeError(f"Unexpected type: {type(jcresult)}, expecting list") - - if not isinstance(jcresult[0], dict): - raise TypeError(f"Unexpected type: {type(jcresult[0])}, expecting dict") - - return {k: [str(v)] for k, v in jcresult[0].items() if not attrs or k in attrs} - - def add_member(self, member: LocalUser) -> LocalGroup: - """ - Add group member. - - :param member: User or group to add as a member. - :type member: LocalUser - :return: Self. - :rtype: LocalGroup - """ - return self.add_members([member]) - - def add_members(self, members: list[LocalUser]) -> LocalGroup: - """ - Add multiple group members. - - :param member: List of users or groups to add as members. - :type member: list[LocalUser] - :return: Self. - :rtype: LocalGroup - """ - self.util.logger.info(f'Adding members to group "{self.name}" on {self.util.host.hostname}') - - if not members: - return self - - cmd = "\n".join([f"groupmems --group '{self.name}' --add '{x.name}'" for x in members]) - self.util.host.ssh.run("set -ex\n" + cmd, log_level=SSHLog.Error) - - return self - - def remove_member(self, member: LocalUser) -> LocalGroup: - """ - Remove group member. - - :param member: User or group to remove from the group. - :type member: LocalUser - :return: Self. - :rtype: LocalGroup - """ - return self.remove_members([member]) - - def remove_members(self, members: list[LocalUser]) -> LocalGroup: - """ - Remove multiple group members. - - :param member: List of users or groups to remove from the group. - :type member: list[LocalUser] - :return: Self. - :rtype: LocalGroup - """ - self.util.logger.info(f'Removing members from group "{self.name}" on {self.util.host.hostname}') - - if not members: - return self - - cmd = "\n".join([f"groupmems --group '{self.name}' --delete '{x.name}'" for x in members]) - self.util.host.ssh.run("set -ex\n" + cmd, log_level=SSHLog.Error) - - return self diff --git a/src/tests/system/lib/sssd/utils/sssd.py b/src/tests/system/lib/sssd/utils/sssd.py deleted file mode 100644 index c612e1021a2..00000000000 --- a/src/tests/system/lib/sssd/utils/sssd.py +++ /dev/null @@ -1,771 +0,0 @@ -"""Manage and configure SSSD.""" - -from __future__ import annotations - -import configparser -from io import StringIO -from typing import TYPE_CHECKING - -from pytest_mh import MultihostHost, MultihostUtility -from pytest_mh._private.multihost import MultihostRole -from pytest_mh.ssh import SSHLog, SSHProcess, SSHProcessResult - -from ..hosts.base import BaseDomainHost - -if TYPE_CHECKING: - from pytest_mh.utils.fs import LinuxFileSystem - from pytest_mh.utils.services import SystemdServices - - from ..roles.base import BaseRole - from ..roles.kdc import KDC - from .authselect import AuthselectUtils - - -__all__ = [ - "SSSDCommonConfiguration", - "SSSDUtils", -] - - -class SSSDUtils(MultihostUtility[MultihostHost]): - """ - Manage and configure SSSD. - - .. note:: - - All changes are automatically reverted when a test is finished. - """ - - def __init__( - self, - host: MultihostHost, - fs: LinuxFileSystem, - svc: SystemdServices, - authselect: AuthselectUtils, - load_config: bool = False, - ) -> None: - """ - :param host: Multihost host. - :type host: MultihostHost - :param fs: File system utils. - :type fs: LinuxFileSystem - :param svc: Systemd utils. - :type svc: SystemdServices - :param authselect: Authselect utils. - :type authselect: AuthselectUtils - :param load_config: If True, existing configuration is loaded to - :attr:`config`, otherwise default configuration is generated, - defaults to False - :type load_config: bool, optional - """ """""" - super().__init__(host) - - self.authselect: AuthselectUtils = authselect - """Authselect utils.""" - - self.fs: LinuxFileSystem = fs - """Filesystem utils.""" - - self.svc: SystemdServices = svc - """Systemd utils.""" - - self.config: configparser.ConfigParser = configparser.ConfigParser(interpolation=None) - """SSSD configuration object.""" - - self.default_domain: str | None = None - """Default SSSD domain.""" - - self.__load_config: bool = load_config - - self.common: SSSDCommonConfiguration = SSSDCommonConfiguration(self) - """ - Shortcuts to setup common SSSD configurations. - """ - - self.logs: SSSDLogsPath = SSSDLogsPath(self) - """ - Shortcuts to SSSD log paths. - """ - - def setup(self) -> None: - """ - Setup SSSD on the host. - - - override systemd unit to disable burst limiting, otherwise we will be - unable to restart the service frequently - - reload systemd to apply change to the unit file - - load configuration from the host (if requested in constructor) or set - default configuration otherwise - - :meta private: - """ - # Disable burst limiting to allow often sssd restarts for tests - self.fs.mkdir("/etc/systemd/system/sssd.service.d") - self.fs.write( - "/etc/systemd/system/sssd.service.d/override.conf", - """ - [Unit] - StartLimitIntervalSec=0 - StartLimitBurst=0 - """, - ) - self.svc.reload_daemon() - - if self.__load_config: - self.config_load() - return - - # Set default configuration - self.config.read_string( - """ - [sssd] - config_file_version = 2 - services = nss, pam - """ - ) - - def async_start( - self, - service="sssd", - *, - apply_config: bool = True, - check_config: bool = True, - debug_level: str | None = "0xfff0", - ) -> SSHProcess: - """ - Start SSSD service. Non-blocking call. - - :param service: Service to start, defaults to 'sssd' - :type service: str, optional - :param apply_config: Apply current configuration, defaults to True - :type apply_config: bool, optional - :param check_config: Check configuration for typos, defaults to True - :type check_config: bool, optional - :param debug_level: Automatically set debug level to the given value, defaults to 0xfff0 - :type debug_level: str | None, optional - :return: Running SSH process. - :rtype: SSHProcess - """ - if apply_config: - self.config_apply(check_config=check_config, debug_level=debug_level) - - return self.svc.async_start(service) - - def start( - self, - service="sssd", - *, - raise_on_error: bool = True, - apply_config: bool = True, - check_config: bool = True, - debug_level: str | None = "0xfff0", - ) -> SSHProcessResult: - """ - Start SSSD service. The call will wait until the operation is finished. - - :param service: Service to start, defaults to 'sssd' - :type service: str, optional - :param raise_on_error: Raise exception on error, defaults to True - :type raise_on_error: bool, optional - :param apply_config: Apply current configuration, defaults to True - :type apply_config: bool, optional - :param check_config: Check configuration for typos, defaults to True - :type check_config: bool, optional - :param debug_level: Automatically set debug level to the given value, defaults to 0xfff0 - :type debug_level: str | None, optional - :return: SSH process result. - :rtype: SSHProcessResult - """ - if apply_config: - self.config_apply(check_config=check_config, debug_level=debug_level) - - # Also stop kcm so it can pick up changes when started again by socket-activation - if service == "sssd": - self.svc.stop("sssd-kcm.service") - - return self.svc.start(service, raise_on_error=raise_on_error) - - def async_stop(self, service="sssd") -> SSHProcess: - """ - Stop SSSD service. Non-blocking call. - - :param service: Service to start, defaults to 'sssd' - :type service: str, optional - :return: Running SSH process. - :rtype: SSHProcess - """ - return self.svc.async_stop(service) - - def stop(self, service="sssd", *, raise_on_error: bool = True) -> SSHProcessResult: - """ - Stop SSSD service. The call will wait until the operation is finished. - - :param service: Service to start, defaults to 'sssd' - :type service: str, optional - :param raise_on_error: Raise exception on error, defaults to True - :type raise_on_error: bool, optional - :return: SSH process result. - :rtype: SSHProcess - """ - return self.svc.stop(service, raise_on_error=raise_on_error) - - def async_restart( - self, - service="sssd", - *, - apply_config: bool = True, - check_config: bool = True, - debug_level: str | None = "0xfff0", - ) -> SSHProcess: - """ - Restart SSSD service. Non-blocking call. - - :param service: Service to start, defaults to 'sssd' - :type service: str, optional - :param apply_config: Apply current configuration, defaults to True - :type apply_config: bool, optional - :param check_config: Check configuration for typos, defaults to True - :type check_config: bool, optional - :param debug_level: Automatically set debug level to the given value, defaults to 0xfff0 - :type debug_level: str | None, optional - :return: Running SSH process. - :rtype: SSHProcess - """ - if apply_config: - self.config_apply(check_config=check_config, debug_level=debug_level) - - return self.svc.async_restart(service) - - def restart( - self, - service="sssd", - *, - raise_on_error: bool = True, - apply_config: bool = True, - check_config: bool = True, - debug_level: str | None = "0xfff0", - ) -> SSHProcessResult: - """ - Restart SSSD service. The call will wait until the operation is finished. - - :param service: Service to start, defaults to 'sssd' - :type service: str, optional - :param raise_on_error: Raise exception on error, defaults to True - :type raise_on_error: bool, optional - :param apply_config: Apply current configuration, defaults to True - :type apply_config: bool, optional - :param check_config: Check configuration for typos, defaults to True - :type check_config: bool, optional - :param debug_level: Automatically set debug level to the given value, defaults to 0xfff0 - :type debug_level: str | None, optional - :return: SSH process result. - :rtype: SSHProcessResult - """ - if apply_config: - self.config_apply(check_config=check_config, debug_level=debug_level) - - return self.svc.restart(service, raise_on_error=raise_on_error) - - def clear(self, *, db: bool = True, memcache: bool = True, config: bool = False, logs: bool = False): - """ - Clear SSSD data. - - :param db: Remove cache and database, defaults to True - :type db: bool, optional - :param memcache: Remove in-memory cache, defaults to True - :type memcache: bool, optional - :param config: Remove configuration files, defaults to False - :type config: bool, optional - :param logs: Remove logs, defaults to False - :type logs: bool, optional - """ - cmd = "rm -fr" - - if db: - cmd += " /var/lib/sss/db/*" - - if memcache: - cmd += " /var/lib/sss/mc/*" - - if config: - cmd += " /etc/sssd/*.conf /etc/sssd/conf.d/*" - - if logs: - cmd += " /var/log/sssd/*" - - self.host.ssh.run(cmd) - - def enable_responder(self, responder: str) -> None: - """ - Include the responder in the [sssd]/service option. - - :param responder: Responder to enable. - :type responder: str - """ - self.config.setdefault("sssd", {}) - svc = self.config["sssd"].get("services", "") - if responder not in svc: - self.config["sssd"]["services"] += ", " + responder - self.config["sssd"]["services"].lstrip(", ") - - def import_domain(self, name: str, role: MultihostRole) -> None: - """ - Import SSSD domain from role object. - - :param name: SSSD domain name. - :type name: str - :param role: Provider role object to use for import. - :type role: MultihostRole - :raises ValueError: If unsupported provider is given. - """ - host = role.host - - if not isinstance(host, BaseDomainHost): - raise ValueError(f"Host type {type(host)} can not be imported as domain") - - self.config[f"domain/{name}"] = host.client - self.config["sssd"].setdefault("domains", "") - - if not self.config["sssd"]["domains"]: - self.config["sssd"]["domains"] = name - elif name not in [x.strip() for x in self.config["sssd"]["domains"].split(",")]: - self.config["sssd"]["domains"] += ", " + name - - if self.default_domain is None: - self.default_domain = name - - def merge_domain(self, name: str, role: BaseRole) -> None: - """ - Merge SSSD domain configuration from role object into the domain. - - If domain name is not provided then the default domain is used. - - :param name: Target SSSD domain name - :type name: str - :param role: Provider role object to use for import. - :type role: BaseRole - :raises ValueError: If unsupported provider is given. - """ - if not isinstance(role.host, BaseDomainHost): - raise ValueError(f"Host type {type(role.host)} can not be imported as domain") - - if name is None: - name = self.default_domain - - if f"domain/{name}" not in self.config: - raise ValueError(f'Domain "{name}" does not yet exist, create it first') - - self.dom(name).update(role.host.client) - - def config_dumps(self) -> str: - """ - Get current SSSD configuration. - - :return: SSSD configuration. - :rtype: str - """ - return self.__config_dumps(self.config) - - def config_load(self) -> None: - """ - Load remote SSSD configuration. - """ - result = self.host.ssh.exec(["cat", "/etc/sssd/sssd.conf"], log_level=SSHLog.Short) - self.config.clear() - self.config.read_string(result.stdout) - - def config_apply(self, check_config: bool = True, debug_level: str | None = "0xfff0") -> None: - """ - Apply current configuration on remote host. - - :param check_config: Check configuration for typos, defaults to True - :type check_config: bool, optional - :param debug_level: Automatically set debug level to the given value, defaults to 0xfff0 - :type debug_level: str | None, optional - """ - cfg = self.__set_debug_level(debug_level) - contents = self.__config_dumps(cfg) - self.fs.write("/etc/sssd/sssd.conf", contents, mode="0600") - - if check_config: - self.host.ssh.run("sssctl config-check") - - def section(self, name: str) -> configparser.SectionProxy: - """ - Get sssd.conf section. - - :param name: Section name. - :type name: str - :return: Section configuration object. - :rtype: configparser.SectionProxy - """ - return self.__get(name) - - def dom(self, name: str) -> configparser.SectionProxy: - """ - Get sssd.conf domain section. - - :param name: Domain name. - :type name: str - :return: Section configuration object. - :rtype: configparser.SectionProxy - """ - return self.section(f"domain/{name}") - - def subdom(self, domain: str, subdomain: str) -> configparser.SectionProxy: - """ - Get sssd.conf subdomain section. - - :param domain: Domain name. - :type domain: str - :param subdomain: Subdomain name. - :type subdomain: str - :return: Section configuration object. - :rtype: configparser.SectionProxy - """ - return self.section(f"domain/{domain}/{subdomain}") - - @property - def domain(self) -> configparser.SectionProxy: - """ - Default domain section configuration object. - - Default domain is the first domain imported by :func:`import_domain`. - - :raises ValueError: If no default domain is set. - :return: Section configuration object. - :rtype: configparser.SectionProxy - """ - if self.default_domain is None: - raise ValueError(f"{self.__class__}.default_domain is not set") - - return self.dom(self.default_domain) - - @domain.setter - def domain(self, value: dict[str, str]) -> None: - if self.default_domain is None: - raise ValueError(f"{self.__class__}.default_domain is not set") - - self.config[f"domain/{self.default_domain}"] = value - - @domain.deleter - def domain(self) -> None: - if self.default_domain is None: - raise ValueError(f"{self.__class__}.default_domain is not set") - - del self.config[f"domain/{self.default_domain}"] - - def __get(self, section: str) -> configparser.SectionProxy: - self.config.setdefault(section, {}) - return self.config[section] - - def __set(self, section: str, value: dict[str, str]) -> None: - self.config[section] = value - - def __del(self, section: str) -> None: - del self.config[section] - - @property - def sssd(self) -> configparser.SectionProxy: - """ - Configuration of the sssd section of sssd.conf. - """ - return self.__get("sssd") - - @sssd.setter - def sssd(self, value: dict[str, str]) -> None: - return self.__set("sssd", value) - - @sssd.deleter - def sssd(self) -> None: - return self.__del("sssd") - - @property - def autofs(self) -> configparser.SectionProxy: - """ - Configuration of the autofs section of sssd.conf. - """ - return self.__get("autofs") - - @autofs.setter - def autofs(self, value: dict[str, str]) -> None: - return self.__set("autofs", value) - - @autofs.deleter - def autofs(self) -> None: - return self.__del("autofs") - - @property - def ifp(self) -> configparser.SectionProxy: - """ - Configuration of the ifp section of sssd.conf. - """ - return self.__get("ifp") - - @ifp.setter - def ifp(self, value: dict[str, str]) -> None: - return self.__set("ifp", value) - - @ifp.deleter - def ifp(self) -> None: - return self.__del("ifp") - - @property - def kcm(self) -> configparser.SectionProxy: - """ - Configuration of the kcm section of sssd.conf. - """ - return self.__get("kcm") - - @kcm.setter - def kcm(self, value: dict[str, str]) -> None: - return self.__set("kcm", value) - - @kcm.deleter - def kcm(self) -> None: - return self.__del("kcm") - - @property - def nss(self) -> configparser.SectionProxy: - """ - Configuration of the nss section of sssd.conf. - """ - return self.__get("nss") - - @nss.setter - def nss(self, value: dict[str, str]) -> None: - return self.__set("nss", value) - - @nss.deleter - def nss(self) -> None: - return self.__del("nss") - - @property - def pac(self) -> configparser.SectionProxy: - """ - Configuration of the pac section of sssd.conf. - """ - return self.__get("pac") - - @pac.setter - def pac(self, value: dict[str, str]) -> None: - return self.__set("pac", value) - - @pac.deleter - def pac(self) -> None: - return self.__del("pac") - - @property - def pam(self) -> configparser.SectionProxy: - """ - Configuration of the pam section of sssd.conf. - """ - return self.__get("pam") - - @pam.setter - def pam(self, value: dict[str, str]) -> None: - return self.__set("pam", value) - - @pam.deleter - def pam(self) -> None: - return self.__del("pam") - - @property - def ssh(self) -> configparser.SectionProxy: - """ - Configuration of the ssh section of sssd.conf. - """ - return self.__get("ssh") - - @ssh.setter - def ssh(self, value: dict[str, str]) -> None: - return self.__set("ssh", value) - - @ssh.deleter - def ssh(self) -> None: - return self.__del("ssh") - - @property - def sudo(self) -> configparser.SectionProxy: - """ - Configuration of the sudo section of sssd.conf. - """ - return self.__get("sudo") - - @sudo.setter - def sudo(self, value: dict[str, str]) -> None: - return self.__set("sudo", value) - - @sudo.deleter - def sudo(self) -> None: - return self.__del("sudo") - - @staticmethod - def __config_dumps(cfg: configparser.ConfigParser) -> str: - """Convert configparser to string.""" - with StringIO() as ss: - cfg.write(ss) - ss.seek(0) - return ss.read() - - def __set_debug_level(self, debug_level: str | None = None) -> configparser.ConfigParser: - """Set debug level in all sections.""" - cfg = configparser.ConfigParser() - cfg.read_dict(self.config) - - if debug_level is None: - return self.config - - sections = ["sssd", "autofs", "ifp", "kcm", "nss", "pac", "pam", "ssh", "sudo"] - sections += [section for section in cfg.keys() if section.startswith("domain/")] - - for section in sections: - cfg.setdefault(section, {}) - if "debug_level" not in cfg[section]: - cfg[section]["debug_level"] = debug_level - - return cfg - - -class SSSDLogsPath(object): - def __init__(self, sssd: SSSDUtils) -> None: - self.__sssd: SSSDUtils = sssd - - @property - def autofs(self) -> str: - """Return path to SSSD autofs logs.""" - return "/var/lib/sssd/sssd_autofs.log" - - @property - def ifp(self) -> str: - """Return path to SSSD ifp logs.""" - return "/var/lib/sssd/sssd_ifp.log" - - @property - def kcm(self) -> str: - """Return path to SSSD kcm logs.""" - return "/var/lib/sssd/sssd_kcm.log" - - @property - def nss(self) -> str: - """Return path to SSSD nss logs.""" - return "/var/lib/sssd/sssd_nss.log" - - @property - def pac(self) -> str: - """Return path to SSSD pac logs.""" - return "/var/lib/sssd/sssd_pac.log" - - @property - def pam(self) -> str: - """Return path to SSSD pam logs.""" - return "/var/lib/sssd/sssd_pam.log" - - @property - def ssh(self) -> str: - """Return path to SSSD ssh logs.""" - return "/var/lib/sssd/sssd_ssh.log" - - @property - def sudo(self) -> str: - """Return path to SSSD sudo logs.""" - return "/var/lib/sssd/sssd_sudo.log" - - def domain(self, name: str | None = None) -> str: - """ - Return path to SSSD domain log for given domain. If the domain name is - not set then :attr:`SSSDUtils.default_domain` is used. - - :param name: Domain name, defaults to None (=:attr:`SSSDUtils.default_domain`) - :type name: str | None, optional - :return: Path to SSSD domain log. - :rtype: str - """ - if name is None: - name = self.__sssd.default_domain - - return f"/var/log/sssd/sssd_{name}.log" - - -class SSSDCommonConfiguration(object): - """ - Setup common SSSD configurations. - - This class provides shortcuts to setup SSSD for common scenarios. - """ - - def __init__(self, sssd: SSSDUtils) -> None: - self.sssd: SSSDUtils = sssd - """SSSD utils.""" - - def local(self) -> None: - """ - Create ``local`` SSSD domain for local users. - - This is a proxy domain that uses nss_files and PAM system-auth service. - """ - self.sssd.dom("local").update( - enabled="true", - id_provider="proxy", - proxy_lib_name="files", - proxy_pam_target="system-auth", - ) - - def krb5_auth(self, kdc: KDC, domain: str | None = None) -> None: - """ - Configure auth_provider to krb5, using the KDC from the multihost - configuration. - - #. Merge KDC configuration into the given domain (or default domain) - #. Generate /etc/krb5.conf from given KDC role - - :param kdc: KDC role object. - :type kdc: KDC - :param domain: Existing domain name, defaults to None (= default domain) - :type domain: str | None, optional - :raises ValueError: if invalid domain is given. - """ - if domain is None: - domain = self.sssd.default_domain - - if domain is None: - raise ValueError("No domain specified!") - - self.sssd.merge_domain(domain, kdc) - self.sssd.fs.write("/etc/krb5.conf", kdc.config(), user="root", group="root", mode="0644") - - def kcm(self, kdc: KDC, *, local_domain: bool = True) -> None: - """ - Configure Kerberos to allow KCM tests. - - #. Generate /etc/krb5.conf from given KDC role - #. If ``local_domain`` is ``True``, create an SSSD domain ``local`` for local users - - :param kdc: KDC role object. - :type kdc: KDC - :param local_domain: Create ``local`` SSSD domain for local users, defaults to ``True`` - :type bool: If ``True`` a ``local`` SSSD domain for local users is created - """ - self.sssd.fs.write("/etc/krb5.conf", kdc.config(), user="root", group="root", mode="0644") - if local_domain: - self.local() - - def sudo(self) -> None: - """ - Configure SSSD with sudo. - - #. Select authselect sssd profile with 'with-sudo' - #. Enable sudo responder - """ - self.sssd.authselect.select("sssd", ["with-sudo"]) - self.sssd.enable_responder("sudo") - - def autofs(self) -> None: - """ - Configure SSSD with autofs. - - #. Select authselect sssd profile - #. Enable autofs responder - """ - self.sssd.authselect.select("sssd") - self.sssd.enable_responder("autofs") diff --git a/src/tests/system/lib/sssd/utils/tools.py b/src/tests/system/lib/sssd/utils/tools.py deleted file mode 100644 index 5b89f00b066..00000000000 --- a/src/tests/system/lib/sssd/utils/tools.py +++ /dev/null @@ -1,447 +0,0 @@ -"""Run various standard Linux commands on remote host.""" - -from __future__ import annotations - -from typing import Any - -import jc -from pytest_mh import MultihostHost, MultihostUtility -from pytest_mh.ssh import SSHProcess, SSHProcessResult -from pytest_mh.utils.fs import LinuxFileSystem - -from ..misc.ssh import SSHKillableProcess - -__all__ = [ - "GetentUtils", - "GroupEntry", - "IdEntry", - "LinuxToolsUtils", - "PasswdEntry", - "UnixGroup", - "UnixObject", - "UnixUser", -] - - -class UnixObject(object): - """ - Generic Unix object. - """ - - def __init__(self, id: int | None, name: str | None) -> None: - """ - :param id: Object ID. - :type id: int | None - :param name: Object name. - :type name: str | None - """ - self.id: int | None = id - """ - ID. - """ - - self.name: str | None = name - """ - Name. - """ - - def __str__(self) -> str: - return f'({self.id},"{self.name}")' - - def __repr__(self) -> str: - return str(self) - - def __eq__(self, o: object) -> bool: - if isinstance(o, str): - return o == self.name - elif isinstance(o, int): - return o == self.id - elif isinstance(o, tuple): - if len(o) != 2 or not isinstance(o[0], int) or not isinstance(o[1], str): - raise NotImplementedError(f"Unable to compare {type(o)} with {self.__class__}") - - (id, name) = o - return id == self.id and name == self.name - elif isinstance(o, UnixObject): - # Fallback to identity comparison - return NotImplemented - - raise NotImplementedError(f"Unable to compare {type(o)} with {self.__class__}") - - -class UnixUser(UnixObject): - """ - Unix user. - """ - - pass - - -class UnixGroup(UnixObject): - """ - Unix group. - """ - - pass - - -class IdEntry(object): - """ - Result of ``id`` - """ - - def __init__(self, user: UnixUser, group: UnixGroup, groups: list[UnixGroup]) -> None: - self.user: UnixUser = user - """ - User information. - """ - - self.group: UnixGroup = group - """ - Primary group. - """ - - self.groups: list[UnixGroup] = groups - """ - Secondary groups. - """ - - def memberof(self, groups: int | str | tuple[int, str] | list[int | str | tuple[int, str]]) -> bool: - """ - Check if the user is member of give group(s). - - Group specification can be either a single gid or group name. But it can - be also a tuple of (gid, name) where both gid and name must match or list - of groups where the user must be member of all given groups. - - :param groups: _description_ - :type groups: int | str | tuple - :return: _description_ - :rtype: bool - """ - if isinstance(groups, (int, str, tuple)): - return groups in self.groups - - return all(x in self.groups for x in groups) - - def __str__(self) -> str: - return f"{{user={str(self.user)},group={str(self.group)},groups={str(self.groups)}}}" - - def __repr__(self) -> str: - return str(self) - - @classmethod - def FromDict(cls, d: dict[str, Any]) -> IdEntry: - user = UnixUser(d["uid"]["id"], d["uid"].get("name", None)) - group = UnixGroup(d["gid"]["id"], d["gid"].get("name", None)) - groups = [] - - for secondary_group in d["groups"]: - groups.append(UnixGroup(secondary_group["id"], secondary_group.get("name", None))) - - return cls(user, group, groups) - - @classmethod - def FromOutput(cls, stdout: str) -> IdEntry: - jcresult = jc.parse("id", stdout) - - if not isinstance(jcresult, dict): - raise TypeError(f"Unexpected type: {type(jcresult)}, expecting dict") - - return cls.FromDict(jcresult) - - -class PasswdEntry(object): - """ - Result of ``getent group`` - """ - - def __init__(self, name: str, password: str, uid: int, gid: int, gecos: str, home: str, shell: str) -> None: - self.name: str | None = name - """ - User name. - """ - - self.password: str | None = password - """ - User password. - """ - - self.uid: int = uid - """ - User id. - """ - - self.gid: int = gid - """ - Group id. - """ - - self.gecos: str | None = gecos - """ - GECOS. - """ - - self.home: str | None = home - """ - Home directory. - """ - - self.shell: str | None = shell - """ - Login shell. - """ - - def __str__(self) -> str: - return f"({self.name}:{self.password}:{self.uid}:{self.gid}:{self.gecos}:{self.home}:{self.shell})" - - def __repr__(self) -> str: - return str(self) - - @classmethod - def FromDict(cls, d: dict[str, Any]) -> PasswdEntry: - return cls( - name=d.get("username", None), - password=d.get("password", None), - uid=d.get("uid", None), - gid=d.get("gid", None), - gecos=d.get("gecos", None), - home=d.get("home", None), - shell=d.get("shell", None), - ) - - @classmethod - def FromOutput(cls, stdout: str) -> PasswdEntry: - result = jc.parse("passwd", stdout) - - if not isinstance(result, list): - raise TypeError(f"Unexpected type: {type(result)}, expecting list") - - if len(result) != 1: - raise ValueError("More then one entry was returned") - - return cls.FromDict(result[0]) - - -class GroupEntry(object): - """ - Result of ``getent group`` - """ - - def __init__(self, name: str, password: str, gid: int, members: list[str]) -> None: - self.name: str | None = name - """ - Group name. - """ - - self.password: str | None = password - """ - Group password. - """ - - self.gid: int = gid - """ - Group id. - """ - - self.members: list[str] = members - """ - Group members. - """ - - def __str__(self) -> str: - return f'({self.name}:{self.password}:{self.gid}:{",".join(self.members)})' - - def __repr__(self) -> str: - return str(self) - - @classmethod - def FromDict(cls, d: dict[str, Any]) -> GroupEntry: - return cls( - name=d.get("group_name", None), - password=d.get("password", None), - gid=d.get("gid", None), - members=d.get("members", []), - ) - - @classmethod - def FromOutput(cls, stdout: str) -> GroupEntry: - result = jc.parse("group", stdout) - - if not isinstance(result, list): - raise TypeError(f"Unexpected type: {type(result)}, expecting list") - - if len(result) != 1: - raise ValueError("More then one entry was returned") - - return cls.FromDict(result[0]) - - -class LinuxToolsUtils(MultihostUtility[MultihostHost]): - """ - Run various standard commands on remote host. - """ - - def __init__(self, host: MultihostHost, fs: LinuxFileSystem) -> None: - """ - :param host: Remote host. - :type host: MultihostHost - """ - super().__init__(host) - - self.getent: GetentUtils = GetentUtils(host) - """ - Run ``getent`` command. - """ - - self.__fs: LinuxFileSystem = fs - self.__rollback: list[str] = [] - - def id(self, name: str) -> IdEntry | None: - """ - Run ``id`` command. - - :param name: User name or id. - :type name: str | int - :return: id data, None if not found - :rtype: IdEntry | None - """ - command = self.host.ssh.exec(["id", name], raise_on_error=False) - if command.rc != 0: - return None - - return IdEntry.FromOutput(command.stdout) - - def grep(self, pattern: str, paths: str | list[str], args: list[str] | None = None) -> bool: - """ - Run ``grep`` command. - - :param pattern: Pattern to match. - :type pattern: str - :param paths: Paths to search. - :type paths: str | list[str] - :param args: Additional arguments to ``grep`` command, defaults to None. - :type args: list[str] | None, optional - :return: True if grep returned 0, False otherwise. - :rtype: bool - """ - if args is None: - args = [] - - paths = [paths] if isinstance(paths, str) else paths - command = self.host.ssh.exec(["grep", *args, pattern, *paths]) - - return command.rc == 0 - - def tcpdump(self, pcap_path: str, args: list[Any] | None = None) -> SSHKillableProcess: - """ - Run tcpdump. The packets are captured in ``pcap_path``. - - :param pcap_path: Path to the capture file. - :type pcap_path: str - :param args: Arguments to ``tcpdump``, defaults to None - :type args: list[Any] | None, optional - :return: Killable process. - :rtype: SSHKillableProcess - """ - if args is None: - args = [] - - self.__fs.backup(pcap_path) - - command = SSHKillableProcess(self.host.ssh, ["tcpdump", *args, "-w", pcap_path]) - - # tcpdump requires some time to process and capture packets - command.kill_delay = 1 - - return command - - def tshark(self, args: list[Any] | None = None) -> SSHProcessResult: - """ - Execute tshark command with given arguments. - - :param args: Arguments to ``tshark``, defaults to None - :type args: list[Any] | None, optional - :return: SSH Process result - :rtype: SSHProcessResult - """ - if args is None: - args = [] - - return self.host.ssh.exec(["tshark", *args]) - - def teardown(self): - """ - Revert all changes. - - :meta private: - """ - cmd = "\n".join(reversed(self.__rollback)) - if cmd: - self.host.ssh.run(cmd) - - super().teardown() - - -class KillCommand(object): - def __init__(self, host: MultihostHost, process: SSHProcess, pid: int) -> None: - self.host = host - self.process = process - self.pid = pid - self.__killed: bool = False - - def kill(self) -> None: - if self.__killed: - return - - self.host.ssh.exec(["kill", self.pid]) - self.__killed = True - - def __enter__(self) -> KillCommand: - return self - - def __exit__(self, exception_type, exception_value, traceback) -> None: - self.kill() - self.process.wait() - - -class GetentUtils(MultihostUtility[MultihostHost]): - """ - Interface to getent command. - """ - - def __init__(self, host: MultihostHost) -> None: - """ - :param host: Remote host. - :type host: MultihostHost - """ - super().__init__(host) - - def passwd(self, name: str | int) -> PasswdEntry | None: - """ - Call ``getent passwd $name`` - - :param name: User name or id. - :type name: str | int - :return: passwd data, None if not found - :rtype: PasswdEntry | None - """ - return self.__exec(PasswdEntry, "passwd", name) - - def group(self, name: str | int) -> GroupEntry | None: - """ - Call ``getent group $name`` - - :param name: Group name or id. - :type name: str | int - :return: group data, None if not found - :rtype: PasswdEntry | None - """ - return self.__exec(GroupEntry, "group", name) - - def __exec(self, cls, cmd: str, name: str | int) -> Any: - command = self.host.ssh.exec(["getent", cmd, name], raise_on_error=False) - if command.rc != 0: - return None - - return cls.FromOutput(command.stdout) diff --git a/src/tests/system/requirements.txt b/src/tests/system/requirements.txt index 8922ac4d114..91229f4896d 100644 --- a/src/tests/system/requirements.txt +++ b/src/tests/system/requirements.txt @@ -1,6 +1,5 @@ -jc pytest -python-ldap git+https://github.com/next-actions/pytest-mh git+https://github.com/next-actions/pytest-ticket git+https://github.com/next-actions/pytest-tier +git+https://github.com/SSSD/sssd-test-framework diff --git a/src/tests/system/tests/test_kcm.py b/src/tests/system/tests/test_kcm.py index b282e3ec270..af20b0bc5e6 100644 --- a/src/tests/system/tests/test_kcm.py +++ b/src/tests/system/tests/test_kcm.py @@ -3,10 +3,9 @@ import time import pytest - -from lib.sssd.roles.client import Client -from lib.sssd.roles.kdc import KDC -from lib.sssd.topology import KnownTopology +from sssd_test_framework.roles.client import Client +from sssd_test_framework.roles.kdc import KDC +from sssd_test_framework.topology import KnownTopology @pytest.mark.tier(0) diff --git a/src/tests/system/tests/test_shadow.py b/src/tests/system/tests/test_shadow.py index 367ec9d16e9..7117e48c4ab 100644 --- a/src/tests/system/tests/test_shadow.py +++ b/src/tests/system/tests/test_shadow.py @@ -1,10 +1,9 @@ from __future__ import annotations import pytest - -from lib.sssd.roles.client import Client -from lib.sssd.roles.ldap import LDAP -from lib.sssd.topology import KnownTopology +from sssd_test_framework.roles.client import Client +from sssd_test_framework.roles.ldap import LDAP +from sssd_test_framework.topology import KnownTopology @pytest.mark.tier(0) diff --git a/src/tests/system/tests/test_sssctl.py b/src/tests/system/tests/test_sssctl.py index e6655dfeb55..80fe47e5192 100644 --- a/src/tests/system/tests/test_sssctl.py +++ b/src/tests/system/tests/test_sssctl.py @@ -1,9 +1,8 @@ from __future__ import annotations import pytest - -from lib.sssd.roles.client import Client -from lib.sssd.topology import KnownTopology +from sssd_test_framework.roles.client import Client +from sssd_test_framework.topology import KnownTopology @pytest.mark.tier(0) diff --git a/src/tests/system/tests/test_sudo.py b/src/tests/system/tests/test_sudo.py index 05b4b9b241b..92ebffa1f60 100644 --- a/src/tests/system/tests/test_sudo.py +++ b/src/tests/system/tests/test_sudo.py @@ -5,13 +5,12 @@ from datetime import datetime, timedelta import pytest - -from lib.sssd.roles.ad import AD -from lib.sssd.roles.client import Client -from lib.sssd.roles.generic import GenericADProvider, GenericProvider -from lib.sssd.roles.ldap import LDAP -from lib.sssd.roles.samba import Samba -from lib.sssd.topology import KnownTopology, KnownTopologyGroup +from sssd_test_framework.roles.ad import AD +from sssd_test_framework.roles.client import Client +from sssd_test_framework.roles.generic import GenericADProvider, GenericProvider +from sssd_test_framework.roles.ldap import LDAP +from sssd_test_framework.roles.samba import Samba +from sssd_test_framework.topology import KnownTopology, KnownTopologyGroup @pytest.mark.tier(0)