diff --git a/docs/guides/index.rst b/docs/guides/index.rst index 4d546caf..446ab072 100644 --- a/docs/guides/index.rst +++ b/docs/guides/index.rst @@ -4,6 +4,7 @@ How-To Guides .. toctree:: skipping-conditional-tests + testing-adforest testing-authentication testing-autofs testing-dbus diff --git a/docs/guides/testing-adforest.rst b/docs/guides/testing-adforest.rst new file mode 100644 index 00000000..ac6d2106 --- /dev/null +++ b/docs/guides/testing-adforest.rst @@ -0,0 +1,114 @@ +Testing AD Forest +################# + +Use :attr:`~sssd_test_framework.topology.KnownTopology.ADForest` for a single +Active Directory forest with a root domain, a child domain, and a tree domain. + +Topology +======== + +The multihost configuration must provide one client and three ``ad`` hosts, +**in this order**: forest root, child domain, tree domain. + +.. code-block:: yaml + :caption: Example ``mhc.yaml`` hosts (role ``ad`` order matters) + + - hostname: client.test + role: client + + - hostname: dc.ad.test + role: ad + config: + client: + ad_domain: ad.test + + - hostname: dc.child.ad.test + role: ad + config: + client: + ad_domain: child.ad.test + + - hostname: dc.tree.test + role: ad + config: + client: + ad_domain: tree.test + +Role fixtures from the topology mark: + +* ``client`` — SSSD client +* ``ad`` — forest root (``sssd.ad[0]``) +* ``ad_child`` — child domain (``sssd.ad[1]``) +* ``ad_tree`` — tree domain (``sssd.ad[2]``) + +Forest trusts and domain relationships must already exist (lab / IdM-CI +provisioning). The topology controller does **not** enroll the client and does +**not** set a ``provider`` fixture or auto-import an SSSD domain. + +Joining a domain +================ + +Request one of the join fixtures from +:mod:`sssd_test_framework.fixtures`. Each fixture sets the client hostname, +leaves any forest domain, joins the target with ``realm``, stops ``sssd`` +(so a later ``client.sssd.start()`` loads the test configuration — ``realmd`` +starts SSSD on join and ``systemctl start`` is a no-op while it is active), +and leaves again on teardown. The fixture **yields the** +:class:`~sssd_test_framework.roles.ad.AD` +**role for that domain** — use that object for users, groups, GPOs, and +``client.sssd.import_domain``. + +.. list-table:: + :header-rows: 1 + :widths: 25 30 45 + + * - Fixture + - Joins + - Yields + * - ``join_ad_root`` + - forest root + - ``ad`` + * - ``join_ad_child`` + - child domain + - ``ad_child`` + * - ``join_ad_tree`` + - tree domain + - ``ad_tree`` + +.. warning:: + + Do not invent a ``provider`` alias that always points at the root. Creating + users or groups on ``ad`` while the client is joined to the child or tree + puts objects in the wrong domain. Prefer the yielded join fixture, or the + matching role (``ad`` / ``ad_child`` / ``ad_tree``). + +Example +======= + +.. code-block:: python + :caption: Join child domain, create a user there, import SSSD domain + + @pytest.mark.topology(KnownTopology.ADForest) + def test_forest__child_lookup(client: Client, join_ad_child: AD, ad: AD): + user = join_ad_child.user("child-user").add() + + client.sssd.import_domain("test", join_ad_child) + client.sssd.start() + + assert client.tools.id(user.name) is not None + # Root DC is still available when the test needs cross-domain data + assert ad.domain != join_ad_child.domain + +When joined to the forest root, SSSD can discover child and tree domains for +lookup and authentication of users in those domains. When joined to the child +or tree, configure and import the SSSD domain from the same role you joined. + +.. seealso:: + + * :attr:`sssd_test_framework.topology.KnownTopology.ADForest` + * :class:`sssd_test_framework.topology_controllers.ADForestTopologyController` + * :func:`sssd_test_framework.fixtures.join_ad_root` + * :func:`sssd_test_framework.fixtures.join_ad_child` + * :func:`sssd_test_framework.fixtures.join_ad_tree` + * :ref:`importing-domain` + * :doc:`testing-gpo` diff --git a/sssd_test_framework/fixtures.py b/sssd_test_framework/fixtures.py index d9775db0..a89d32af 100644 --- a/sssd_test_framework/fixtures.py +++ b/sssd_test_framework/fixtures.py @@ -5,6 +5,10 @@ import os import pytest +from pytest_mh import mh_fixture + +from .roles.ad import AD +from .roles.client import Client @pytest.fixture(scope="session") @@ -43,3 +47,108 @@ def testdatadir(moduledatadir: str, request: pytest.FixtureRequest) -> str: name = request.node.originalname return os.path.join(moduledatadir, name) + + +def _ad_forest_leave(client: Client, domain: AD) -> None: + client.host.conn.exec( + ["realm", "leave", "--unattended", domain.domain], + input=domain.host.adminpw, + raise_on_error=False, + ) + + +def _ad_forest_join(client: Client, target: AD, forest: tuple[AD, ...]) -> str: + """Leave any forest domain, set hostname, join ``target``. Return prior hostname.""" + old_hostname = client.host.conn.run("hostname").stdout.strip() + short_hostname = old_hostname.split(".")[0].strip() + hostname = f"{short_hostname}.{target.domain}" + + client.fs.write("/etc/hostname", f"{hostname}\n") + client.host.conn.run(f"hostname {hostname}") + + for domain in forest: + _ad_forest_leave(client, domain) + + client.fs.rm("/etc/krb5.conf") + client.fs.rm("/etc/krb5.keytab") + + # Stale computer objects (especially after leave/rejoin across forest domains) + # cause "Insufficient permissions to join the domain". + for domain in forest: + domain.host.conn.run( + f""" + Import-Module ActiveDirectory + Get-ADComputer -Identity '{short_hostname}' -ErrorAction SilentlyContinue | + Remove-ADComputer -Confirm:$false + """, + raise_on_error=False, + ) + + result = client.host.conn.exec(["realm", "join", target.domain], input=target.host.adminpw, raise_on_error=False) + if result.rc != 0: + _ad_forest_leave(client, target) + target.host.conn.run( + f""" + Import-Module ActiveDirectory + Get-ADComputer -Identity '{short_hostname}' -ErrorAction SilentlyContinue | + Remove-ADComputer -Confirm:$false + """, + raise_on_error=False, + ) + client.host.conn.exec(["realm", "join", target.domain], input=target.host.adminpw) + + # realmd starts sssd with its own conf; stop it so later client.sssd.start() + # applies the test-written configuration (systemctl start is a no-op if active). + client.sssd.stop(raise_on_error=False) + + return old_hostname + + +def _ad_forest_restore_hostname(client: Client, hostname: str) -> None: + client.fs.write("/etc/hostname", f"{hostname}\n") + client.host.conn.run(f"hostname {hostname}", raise_on_error=False) + + +@mh_fixture() +def join_ad_root(client: Client, ad: AD, ad_child: AD, ad_tree: AD): + """ + Join the client to the AD forest root. + + Yields ``ad`` — use it for users/groups and ``client.sssd.import_domain``. + For :attr:`~sssd_test_framework.topology.KnownTopology.ADForest`. Leaves on teardown. + """ + forest = (ad, ad_child, ad_tree) + old_hostname = _ad_forest_join(client, ad, forest) + yield ad + _ad_forest_leave(client, ad) + _ad_forest_restore_hostname(client, old_hostname) + + +@mh_fixture() +def join_ad_child(client: Client, ad: AD, ad_child: AD, ad_tree: AD): + """ + Join the client to the AD child domain. + + Yields ``ad_child`` — use it for users/groups and ``client.sssd.import_domain``. + For :attr:`~sssd_test_framework.topology.KnownTopology.ADForest`. Leaves on teardown. + """ + forest = (ad, ad_child, ad_tree) + old_hostname = _ad_forest_join(client, ad_child, forest) + yield ad_child + _ad_forest_leave(client, ad_child) + _ad_forest_restore_hostname(client, old_hostname) + + +@mh_fixture() +def join_ad_tree(client: Client, ad: AD, ad_child: AD, ad_tree: AD): + """ + Join the client to the AD tree domain. + + Yields ``ad_tree`` — use it for users/groups and ``client.sssd.import_domain``. + For :attr:`~sssd_test_framework.topology.KnownTopology.ADForest`. Leaves on teardown. + """ + forest = (ad, ad_child, ad_tree) + old_hostname = _ad_forest_join(client, ad_tree, forest) + yield ad_tree + _ad_forest_leave(client, ad_tree) + _ad_forest_restore_hostname(client, old_hostname) diff --git a/sssd_test_framework/hosts/ad.py b/sssd_test_framework/hosts/ad.py index d603795c..ade95bd5 100644 --- a/sssd_test_framework/hosts/ad.py +++ b/sssd_test_framework/hosts/ad.py @@ -91,7 +91,7 @@ def naming_context(self) -> str: :rtype: str """ if not self.__naming_context: - result = self.conn.run("Write-Host (Get-ADRootDSE).rootDomainNamingContext") + result = self.conn.run("Write-Host (Get-ADRootDSE).defaultNamingContext") nc = result.stdout.strip() if not nc: raise ValueError("Unable to find default naming context") diff --git a/sssd_test_framework/roles/ad.py b/sssd_test_framework/roles/ad.py index 64f2c336..32d379e1 100644 --- a/sssd_test_framework/roles/ad.py +++ b/sssd_test_framework/roles/ad.py @@ -1270,15 +1270,68 @@ def add_members(self, members: list[GroupMemberField]) -> ADGroup: """ Add multiple group members. + Same-domain members use ``Add-ADGroupMember``. Cross-domain Universal + members use ``DirectoryEntry.Invoke('Add')`` with a server-qualified + LDAP URL (``LDAP://dc.other.domain/CN=...``) and retries — bare DNs + fail with ``0x80072030`` for tree domains when the local DC cannot + resolve the object. + :param members: List of users or groups to add as members. :type members: list[GroupMemberField] :return: Self. :rtype: ADGroup """ - self.role.host.conn.run(f""" - Import-Module ActiveDirectory - Add-ADGroupMember -Identity '{self.dn}' -Members {self.__get_members(members)} - """) + naming_context = self.role.host.naming_context.lower() + same_domain: list[str] = [] + # Cross-domain entries are LDAP URLs. Prefer server-qualified URLs + # (LDAP://dc.other.domain/CN=...) so the local DC can resolve tree- + # domain objects; bare LDAP://DN fails with 0x80072030 without that. + cross_domain: list[str] = [] + for member in members: + dn = self.__member_dn(member) + if self.__member_naming_context(member).lower() == naming_context: + same_domain.append(dn) + elif isinstance(member, ADObject): + cross_domain.append(f"LDAP://{member.role.host.hostname}/{dn}") + else: + cross_domain.append(f"LDAP://{dn}") + + if same_domain: + members_ps = ",".join(f'"{dn}"' for dn in same_domain) + self.role.host.conn.run(f""" + Import-Module ActiveDirectory + Add-ADGroupMember -Identity '{self.dn}' -Members {members_ps} + """) + + if cross_domain: + member_list = ",".join(f"'{url}'" for url in cross_domain) + self.role.host.conn.run(f""" + $ErrorActionPreference = 'Stop' + foreach ($m in @({member_list})) {{ + $added = $false + $last = $null + for ($i = 0; $i -lt 20; $i++) {{ + try {{ + $group = New-Object System.DirectoryServices.DirectoryEntry('LDAP://{self.dn}') + $group.Invoke('Add', $m) + $group.CommitChanges() + $group.Dispose() + $added = $true + break + }} catch {{ + $last = $_ + if ("$last" -match 'already a member of the group|ATTRIBUTE_OR_VALUE_EXISTS') {{ + $added = $true + break + }} + Start-Sleep -Seconds 15 + }} + }} + if (-not $added) {{ + throw "Failed to add member $m to '{self.dn}': $last" + }} + }} + """) return self def remove_member(self, member: GroupMemberField) -> ADGroup: @@ -1301,10 +1354,11 @@ def remove_members(self, members: list[GroupMemberField]) -> ADGroup: :return: Self. :rtype: ADGroup """ + members_ps = ",".join(f'"{self.__member_dn(x)}"' for x in members) self.role.host.conn.run(f""" Import-Module ActiveDirectory - Remove-ADGroupMember -Confirm:$False -Identity '{self.dn}' -Members {self.__get_members(members)} - """) + Remove-ADGroupMember -Confirm:$False -Identity '{self.dn}' -Members {members_ps} + """) return self def __member_dn(self, member: GroupMemberField) -> str: @@ -1314,8 +1368,13 @@ def __member_dn(self, member: GroupMemberField) -> str: return member return member.name - def __get_members(self, members: list[GroupMemberField]) -> str: - return ",".join([f'"{self.__member_dn(x)}"' for x in members]) + def __member_naming_context(self, member: GroupMemberField) -> str: + """Domain NC of ``member`` (not a parent-domain suffix match).""" + if isinstance(member, ADObject): + return member.role.host.naming_context + dn = self.__member_dn(member) + dc_parts = [part for part in dn.split(",") if part.lower().startswith("dc=")] + return ",".join(dc_parts) if dc_parts else dn class ADNetgroup(ADObject, GenericNetgroup): diff --git a/sssd_test_framework/topology.py b/sssd_test_framework/topology.py index 7d6815ed..31e63277 100644 --- a/sssd_test_framework/topology.py +++ b/sssd_test_framework/topology.py @@ -9,6 +9,7 @@ from .config import SSSDTopologyMark from .topology_controllers import ( + ADForestTopologyController, ADTopologyController, ClientTopologyController, GDMTopologyController, @@ -168,6 +169,26 @@ def test_ldap(client: Client, ldap: LDAP): .. topology-mark:: KnownTopology.AD """ + ADForest = SSSDTopologyMark( + name="ad-forest", + topology=Topology(TopologyDomain("sssd", client=1, ad=3)), + controller=ADForestTopologyController(), + fixtures=dict( + client="sssd.client[0]", + ad="sssd.ad[0]", + ad_child="sssd.ad[1]", + ad_tree="sssd.ad[2]", + ), + ) + """ + AD forest with root, child, and tree domains (three ``ad`` hosts, ordered + root → child → tree). Does not enroll the client or set ``provider`` / + auto-import an SSSD domain — use ``join_ad_root``, ``join_ad_child``, or + ``join_ad_tree`` and create objects / ``import_domain`` on that role. + + .. topology-mark:: KnownTopology.ADForest + """ + Samba = SSSDTopologyMark( name="samba", topology=Topology(TopologyDomain("sssd", client=1, samba=1, nfs=1)), diff --git a/sssd_test_framework/topology_controllers.py b/sssd_test_framework/topology_controllers.py index b02fdb45..ededc93e 100644 --- a/sssd_test_framework/topology_controllers.py +++ b/sssd_test_framework/topology_controllers.py @@ -21,6 +21,7 @@ "LDAPKRB5TopologyController", "IPATopologyController", "ADTopologyController", + "ADForestTopologyController", "SambaTopologyController", "IPATrustADTopologyController", "IPATrustSambaTopologyController", @@ -257,6 +258,30 @@ def topology_setup(self, client: ClientHost, provider: ADHost | SambaHost) -> No super().topology_setup() +class ADForestTopologyController(ProvisionedBackupTopologyController): + """ + AD forest topology: root, child, and tree domain controllers. + + Multihost hosts with role ``ad`` must be ordered root, child, tree. + Does not enroll the client; use ``join_ad_root``, ``join_ad_child``, or + ``join_ad_tree`` fixtures. Forest trusts must already exist (lab / IdM-CI). + """ + + @BackupTopologyController.restore_vanilla_on_error + def topology_setup( + self, + client: ClientHost, + ad: ADHost, + ad_child: ADHost, + ad_tree: ADHost, + ) -> None: + self.logger.info(f"AD forest: root={ad.domain}, child={ad_child.domain}, tree={ad_tree.domain}") + if self.provisioned: + self.logger.info(f"Topology '{self.name}' is already provisioned") + return + super().topology_setup() + + class SambaTopologyController(ADTopologyController): """ Samba Topology Controller.