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..a16f6285 --- /dev/null +++ b/docs/guides/testing-adforest.rst @@ -0,0 +1,113 @@ +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:`tests.adforest_fixtures` in the SSSD system test tree +(``src/tests/system/tests/adforest_fixtures.py``). 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` + * Join fixtures in SSSD ``tests.adforest_fixtures`` (``join_ad_root``, ``join_ad_child``, ``join_ad_tree``) + * :ref:`importing-domain` + * :doc:`testing-gpo` 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 4d325f57..fb5cf22e 100644 --- a/sssd_test_framework/roles/ad.py +++ b/sssd_test_framework/roles/ad.py @@ -6,11 +6,10 @@ import re import textwrap import uuid -from datetime import datetime from typing import Any, TypeAlias, cast from pytest_mh.cli import CLIBuilderArgs -from pytest_mh.conn import ProcessResult +from pytest_mh.conn import ProcessError, ProcessResult from ..hosts.ad import ADHost from ..misc import ( @@ -523,6 +522,42 @@ def test_ad__gpo_is_set_to_enforcing(client: Client, ad: AD): """ return GPO(self, name) + def scrub_orphan_gpo_links(self, site: str = "Default-First-Site-Name") -> None: + """ + Drop gPLink entries that still reference deleted GPO objects. + + Remove-GPLink cannot unlink after the GPO container is gone; rewrite + gPLink on the domain and the given AD site instead. + """ + site_escaped = site.replace("'", "''") + self.host.conn.run( + f""" + Import-Module ActiveDirectory + function Set-CleanGPLink([string]$targetDn) {{ + $obj = Get-ADObject -Identity $targetDn -Properties gPLink -ErrorAction SilentlyContinue + if (-not $obj -or -not $obj.gPLink) {{ return }} + $kept = @() + foreach ($chunk in [regex]::Matches($obj.gPLink, '\\[(.*?)\\]')) {{ + $entry = $chunk.Groups[1].Value + if ($entry -match 'CN=\\{{[^}}]+\\}},CN=Policies,CN=System,[^;]+') {{ + $gpoDn = $Matches[0] + if (Get-ADObject -Identity $gpoDn -ErrorAction SilentlyContinue) {{ + $kept += "[$entry]" + }} + }} + }} + Set-ADObject -Identity $targetDn -Replace @{{gPLink=($kept -join '')}} + }} + $domain = (Get-ADDomain).DistinguishedName + Set-CleanGPLink $domain + $configNc = (Get-ADRootDSE).configurationNamingContext + $siteName = '{site_escaped}' + $siteDn = "CN=$siteName,CN=Sites," + $configNc + Set-CleanGPLink $siteDn + """, + raise_on_error=False, + ) + def sudorule(self, name: str, basedn: ADObject | str | None = "ou=sudoers") -> ADSudoRule: """ Get sudo rule object. @@ -1086,25 +1121,21 @@ def reset(self, password: str | None = "Secret123") -> ADUser: return self - def expire(self, expiration: str | None = "19700101000000") -> ADUser: + def expire(self, expiration: str | None = "129465018000000000") -> ADUser: """ - Set user password expiration date and time. + Expire the user account. - :param expiration: Date and time for user password expiration, defaults to 19700101000000 - :type expiration: str | None, optional + Sets ``accountExpires`` directly (legacy ad_forest ``ad_set_expire_user``). + + :param expiration: ``accountExpires`` value in Windows filetime (100-ns + intervals since 1601-01-01 UTC). Default is 2009-12-31. + :type expiration: str | None :return: Self. :rtype: ADUser """ if expiration is None: - expiration = "19700101000000" - expire = datetime.strptime(expiration, "%Y%m%d%H%M%S") - expire_format = expire.strftime("%m/%d/%Y %H:%M:%S") - - attrs: CLIBuilderArgs = {**self._identity, "DateTime": (self.cli.option.VALUE, f"{expire_format}")} - - args = " ".join(self.cli.args(attrs, quote_value=True)) - self.role.host.conn.run(f"Set-ADAccountExpiration {args}") - + expiration = "129465018000000000" + self.role.host.conn.run(f"Set-ADUser -Identity '{self.dn}' -Replace @{{accountExpires={expiration}}}") return self def password_change_at_logon(self, **kwargs) -> ADUser: @@ -1264,15 +1295,70 @@ 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: list[GroupMemberField] = [] + for member in members: + dn = self.__member_dn(member) + if self.__member_naming_context(member).lower() == naming_context: + same_domain.append(dn) + else: + cross_domain.append(member) + + 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: + cross_urls: list[str] = [] + for member in cross_domain: + dn = self.__member_dn(member) + if isinstance(member, ADObject): + cross_urls.append(f"LDAP://{member.role.host.hostname}/{dn}") + else: + cross_urls.append(f"LDAP://{dn}") + member_list = ",".join(f"'{url}'" for url in cross_urls) + 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: @@ -1295,9 +1381,10 @@ 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 @@ -1308,8 +1395,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): @@ -2270,6 +2362,25 @@ def filesyspath(self, path: str) -> GPO: return self + @classmethod + def cleanup(cls, *gpos: GPO | None) -> None: + """Unlink and delete GPOs, then scrub orphan gPLink entries on the domain.""" + ad: AD | None = None + for gpo in gpos: + if gpo is None: + continue + ad = gpo.role + try: + gpo.unlink() + except ProcessError: + pass + try: + gpo.delete() + except ProcessError: + pass + if ad is not None: + ad.scrub_orphan_gpo_links() + class ADPasswordPolicy(GenericPasswordPolicy): """ diff --git a/sssd_test_framework/topology.py b/sssd_test_framework/topology.py index b254249d..cd0ba5f4 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, BigLDAPTopologyController, ClientTopologyController, @@ -169,6 +170,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 b610b3cb..93342b5b 100644 --- a/sssd_test_framework/topology_controllers.py +++ b/sssd_test_framework/topology_controllers.py @@ -23,6 +23,7 @@ "BigLDAPTopologyController", "IPATopologyController", "ADTopologyController", + "ADForestTopologyController", "SambaTopologyController", "IPATrustADTopologyController", "IPATrustSambaTopologyController", @@ -295,6 +296,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.