Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/guides/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ How-To Guides
.. toctree::

skipping-conditional-tests
testing-adforest
testing-authentication
testing-autofs
testing-dbus
Expand Down
113 changes: 113 additions & 0 deletions docs/guides/testing-adforest.rst
Original file line number Diff line number Diff line change
@@ -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`
2 changes: 1 addition & 1 deletion sssd_test_framework/hosts/ad.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
155 changes: 133 additions & 22 deletions sssd_test_framework/roles/ad.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Comment thread
danlavu marked this conversation as resolved.
"""
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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -2270,6 +2362,25 @@ def filesyspath(self, path: str) -> GPO:

return self

@classmethod
Comment thread
danlavu marked this conversation as resolved.
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):
"""
Expand Down
21 changes: 21 additions & 0 deletions sssd_test_framework/topology.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from .config import SSSDTopologyMark
from .topology_controllers import (
ADForestTopologyController,
ADTopologyController,
BigLDAPTopologyController,
ClientTopologyController,
Expand Down Expand Up @@ -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)),
Expand Down
Loading
Loading