From 506357b870bd49724a2c631e4628eaa6684a9f65 Mon Sep 17 00:00:00 2001 From: Dan Lavu <dlavu@redhat.com> Date: Wed, 10 Apr 2024 23:21:32 -0400 Subject: [PATCH] roles: extended gpo feature to samba role * added SambaSite object * added SambaComputer object * added GenericGPO class and methods * added some ldap variables to perform ldap functions --- sssd_test_framework/hosts/samba.py | 3 + sssd_test_framework/roles/ad.py | 64 +++-- sssd_test_framework/roles/generic.py | 106 ++++++++ sssd_test_framework/roles/samba.py | 382 ++++++++++++++++++++++++++- 4 files changed, 524 insertions(+), 31 deletions(-) diff --git a/sssd_test_framework/hosts/samba.py b/sssd_test_framework/hosts/samba.py index 352189b0..2bd50d36 100644 --- a/sssd_test_framework/hosts/samba.py +++ b/sssd_test_framework/hosts/samba.py @@ -30,6 +30,9 @@ def __init__(self, *args, **kwargs) -> None: self._features: dict[str, bool] | None = None + self.admin: str = self.config.get("username", "Administrator") + """Username of the admin user, defaults to value of ``Administrator``.""" + self.adminpw: str = self.config.get("adminpw", self.bindpw) """Password of the admin user, defaults to value of ``bindpw``.""" diff --git a/sssd_test_framework/roles/ad.py b/sssd_test_framework/roles/ad.py index 5efdf344..58281fe6 100644 --- a/sssd_test_framework/roles/ad.py +++ b/sssd_test_framework/roles/ad.py @@ -1655,16 +1655,18 @@ def __init__(self, role: AD, name: str) -> None: self._search_base: str = f"cn=policies,cn=system,{self.role.host.naming_context}" """Group policy search base.""" - self._dn = self.get("DistinguishedName") + self._dn = self._get("DistinguishedName") """Group policy dn.""" - self._cn = self.get("CN") + self._cn = self._get("CN") """Group policy cn.""" - def get(self, key: str) -> str | None: + def _get(self, key: str) -> str | None: """ Get group policy attributes. + This method is unique for the GPO class, unlike SambaGPO class, the ADObject class is not inherited. + :param key: Attribute to get. :type key: str :return: Key value. @@ -1713,8 +1715,8 @@ def add(self) -> GPO: """ self.role.host.conn.run(f'New-GPO -name "{self.name}"') - self._cn = self.get("CN") - self._dn = self.get("DistinguishedName") + self._cn = self._get("CN") + self._dn = self._get("DistinguishedName") self.role.host.conn.run( rf""" @@ -1733,35 +1735,39 @@ def add(self) -> GPO: def link( self, - op: str | None = "New", target: str | None = None, - args: list[str] | str | None = None, + enforced: bool | None = None, + disabled: bool | None = False, + order: int | None = 0, ) -> GPO: """ - Link the group policy to the a target object inside the directory, a site, domain or an ou. - - ..Note:: - The New and Set cmdlets are identical. To modify an an existing link, - change the $op parameter to "Set", i.e. to disable 'Enforced' + Link the group policy to the target object inside the directory, a site, domain or an ou. - ou_policy.link("Set", args=["-Enforced No"]) - - :param op: Cmdlet operation, defaults to "New" - :type op: str, optional :param target: Group policy target :type target: str, optional - :param args: Additional arguments - :type args: list[str] | None, optional + :param enforced: Enforced the policy + :type enforced: bool, optional + :param disabled: Disable the policy + :type disabled: bool, optional + :param order: Order number + :type order: int, optional :return: Group policy object :rtype: GPO """ - if args is None: - args = [] + args: str = "" - if isinstance(args, list): - args = " ".join(args) - elif args is None: - args = "" + if enforced is True: + args = args + " -Enforce Yes" + if enforced is False: + args = args + " -Enforce No" + + if disabled is True: + args = args + " -LinkEnabled No" + else: + args = args + " -LinkEnabled Yes" + + if order != 0: + args = args + f" -Order {str(order)}" if target is None and self.target is None: self.target = "Default-First-Site-Name" @@ -1769,7 +1775,13 @@ def link( if target is not None and self.target is None: self.target = target - self.role.host.conn.run(f'{op}-GPLink -Guid "{self._cn}" -Target "{self.target}" -LinkEnabled Yes {args}') + # The cmdlets take the same arguements, but one is for new links and the other is for existing links. + # This is combined to simplify gpo management. + new_link = self.role.host.conn.run( + f'New-GPLink -Guid "{self._cn}" -Target "{self.target}" {args}', raise_on_error=False + ) + if new_link.rc != 0: + self.role.host.conn.run(f'Set-GPLink -Guid "{self._cn}" -Target "{self.target}" {args}') return self @@ -1836,7 +1848,7 @@ def policy(self, logon_rights: dict[str, list[ADObject]], cfg: dict[str, Any] | This method does the remaining configuration of the group policy. It updates 'GptTmpl.inf' with security logon right keys with the SIDs of users and groups - objects. The *Remote* keys can be omitted, in which the corresponding keys values + objects. The *Remote* keys can be omitted, in which the interactive key's value will then be used. To add users and groups to the policy, the SID must be used for the values. The diff --git a/sssd_test_framework/roles/generic.py b/sssd_test_framework/roles/generic.py index b1892f63..6e1797d0 100644 --- a/sssd_test_framework/roles/generic.py +++ b/sssd_test_framework/roles/generic.py @@ -24,6 +24,7 @@ "GenericAutomount", "GenericAutomountMap", "GenericAutomountKey", + "GenericGPO", ] @@ -255,6 +256,39 @@ def test_example(client: Client, provider: GenericProvider, nfs: NFS): """ pass + @abstractmethod + def gpo(self, name: str) -> GenericGPO: + """ + Get group policy object. + + .. code-block:: python + :caption: Example usage + + @pytest.mark.topology(KnownTopologies.GenericAD) + def test_gpo_is_set_to_enforcing(client: Client, provider: GenericProvider): + user = provider.user("user").add() + allow_user = provider.user("allow_user").add() + deny_user = provider.user("deny_user").add() + + provider.gpo("test policy").add().policy( + { + "SeInteractiveLogonRight": [allow_user, ad.group("Domain Admins")], + "SeRemoteInteractiveLogonRight": [allow_user, ad.group("Domain Admins")], + "SeDenyInteractiveLogonRight": [deny_user], + "SeDenyRemoteInteractiveLogonRight": [deny_user], + } + ).link() + + client.sssd.domain["ad_gpo_access_control"] = "enforcing" + client.sssd.start() + + assert client.auth.ssh.password(username="allow_user", password="Secret123") + assert not client.auth.ssh.password(username="user", password="Secret123") + assert not client.auth.ssh.password(username="deny_user", password="Secret123") + + """ + pass + class GenericADProvider(GenericProvider): """ @@ -961,3 +995,75 @@ def dump(self) -> str: @abstractmethod def __str__(self) -> str: pass + + +class GenericGPO( + ABC, + BaseObject, +): + """ + Generic GPO management. + """ + + @property + @abstractmethod + def name(self): + """ + GPO name. + """ + pass + + @abstractmethod + def get(self, key: str) -> str | None: + """ + Get GPO attribute. + """ + pass + + @abstractmethod + def delete(self) -> None: + """ + Delete GPO. + """ + pass + + @abstractmethod + def add(self) -> GenericGPO: + """ + Add GPO. + """ + pass + + @abstractmethod + def link( + self, + op: str | None = None, + target: str | None = None, + enforced: bool | None = False, + disabled: bool | None = False, + ) -> GenericGPO: + """ + Link GPO. + """ + pass + + @abstractmethod + def unlink(self) -> None: + """ + Unlink GPO. + """ + pass + + @abstractmethod + def permissions(self, target: str, permission_level: str, target_type: str | None = "Group") -> GenericGPO: + """ + Configure GPO permissions. + """ + pass + + @abstractmethod + def policy(self, logon_rights: dict[str, list[GenericUser]], cfg: dict[str, Any] | None = None) -> GenericGPO: + """ + GPO configuration. + """ + pass diff --git a/sssd_test_framework/roles/samba.py b/sssd_test_framework/roles/samba.py index f6dd21f7..625f8e41 100644 --- a/sssd_test_framework/roles/samba.py +++ b/sssd_test_framework/roles/samba.py @@ -2,27 +2,30 @@ from __future__ import annotations +import base64 +import configparser from typing import Any, TypeAlias import ldap.modlist from pytest_mh.cli import CLIBuilderArgs from pytest_mh.conn import ProcessResult -from sssd_test_framework.utils.ldap import LDAPRecordAttributes - from ..hosts.samba import SambaHost from ..misc import attrs_parse, to_list_of_strings +from ..utils.ldap import LDAPRecordAttributes from .base import BaseLinuxLDAPRole, BaseObject, DeleteAttribute from .ldap import LDAPAutomount, LDAPNetgroup, LDAPNetgroupMember, LDAPObject, LDAPOrganizationalUnit, LDAPSudoRule __all__ = [ "Samba", "SambaObject", + "SambaComputer", "SambaUser", "SambaGroup", "SambaOrganizationalUnit", "SambaAutomount", "SambaSudoRule", + "SambaGPO", ] @@ -143,7 +146,7 @@ def test_example(client: Client, samba: Samba): assert result.user.name == 'user-1' assert result.group.name == 'domain users' - :param name: User name. + :param name: Username. :type name: str :return: New user object. :rtype: SambaUser @@ -225,6 +228,65 @@ def test_example_netgroup(client: Client, samba: Samba): """ return SambaNetgroup(self, name, basedn) + def computer(self, name: str) -> SambaComputer: + """ + Get computer object. + + .. code-block:: python + :caption: Example usage + + @pytest.mark.topology(KnownTopology.Samba) + def test_example(client: Client, samba: Samba): + # Create OU + ou = samba.ou("test").add().dn + # Move computer object + samba.computer(client.host.hostname.split(".")[0]).move(ou) + + client.sssd.start() + + :param name: Computer name. + :type name: str + :return: New computer object. + :rtype: ADComputer + """ + return SambaComputer(self, name) + + def gpo(self, name: str) -> SambaGPO: + """ + Get group policy object. + + .. code-block:: python + :caption: Example usage + + @pytest.mark.topology(KnownTopology.AD) + def test_ad__gpo_is_set_to_enforcing(client: Client, samba: Samba): + user = ad.user("user").add() + allow_user = ad.user("allow_user").add() + deny_user = ad.user("deny_user").add() + + ad.gpo("test policy").add().policy( + { + "SeInteractiveLogonRight": [allow_user, ad.group("Domain Admins")], + "SeRemoteInteractiveLogonRight": [allow_user, ad.group("Domain Admins")], + "SeDenyInteractiveLogonRight": [deny_user], + "SeDenyRemoteInteractiveLogonRight": [deny_user], + } + ).link() + + client.sssd.domain["ad_gpo_access_control"] = "enforcing" + client.sssd.start() + + assert client.auth.ssh.password(username="allow_user", password="Secret123") + assert not client.auth.ssh.password(username="user", password="Secret123") + assert not client.auth.ssh.password(username="deny_user", password="Secret123") + + :param name: Name of the GPO. + :type name: str + :return: New GPO object. + :rtype: SambaGPO + """ + return SambaGPO(self, name) + def ou(self, name: str, basedn: LDAPObject | str | None = None) -> SambaOrganizationalUnit: """ Get organizational unit object. @@ -258,6 +320,25 @@ def test_example(client: Client, samba: Samba): """ return SambaOrganizationalUnit(self, name, basedn) + def site(self, name: str) -> SambaSites: + """ + Get site object. + + .. code-block:: python + :caption: Example usage + + @pytest.mark.topology(KnownTopology.Samba) + def test_example(client: Client, samba: Samba): + # Create New Site, this name cannot contain spaces + site = samba.site('New-Site').add() + + :param name: Site name. + :type name: str, cannot contain spaces + :return: New site object. + :rtype: SambaSites + """ + return SambaSites(self, name) + def sudorule(self, name: str, basedn: LDAPObject | str | None = "ou=sudoers") -> SambaSudoRule: """ Get sudo rule object. @@ -311,8 +392,15 @@ def __init__(self, role: Samba, command: str, name: str) -> None: self.name: str = name """Object name.""" + self.naming_context: str = role.ldap.naming_context + """Domain naming context.""" + self.__dn: str | None = None + self.__sid: str | None = None + + self.__cn: str | None = None + def _exec(self, op: str, args: list[str] | None = None, **kwargs) -> ProcessResult: """ Execute samba-tool command. @@ -332,6 +420,9 @@ def _exec(self, op: str, args: list[str] | None = None, **kwargs) -> ProcessResu if args is None: args = [] + if self.command == "gpo": + return self.role.host.conn.exec(["samba-tool", self.command, op, self.__cn, *args], **kwargs) + return self.role.host.conn.exec(["samba-tool", self.command, op, self.name, *args], **kwargs) def _add(self, attrs: CLIBuilderArgs) -> None: @@ -397,8 +488,34 @@ def get(self, attrs: list[str] | None = None) -> dict[str, list[str]]: :return: Dictionary with attribute name as a key. :rtype: dict[str, list[str]] """ - cmd = self._exec("show") - return attrs_parse(cmd.stdout_lines, attrs) + + # The samba-tool gpo show command returns a limited list of attributes, so we use LDAP instead + # The LDAP output is formatted to be like samba-tool + if self.command == "gpo": + result = self.role.host.ldap_conn.search_s( + f"cn=system,{self.naming_context}", + ldap.SCOPE_SUBTREE, + f"(&(objectClass=groupPolicyContainer)(displayName={self.name}))", + attrlist=attrs, + ) + + (_, result_attrs) = result[0] + out: list[str] = [] + for key, values in result_attrs.items(): + for value in values: + try: + decoded = value.decode("utf-8") + except UnicodeDecodeError: + decoded = base64.b64encode(value).decode("utf-8") + # The dn is missing from the output + if key == "distinguishedName": + out.insert(0, f"dn: {decoded}") + out.append(f"{key}: {decoded}") + cmd = out + else: + cmd = self._exec("show").stdout_lines + + return attrs_parse(cmd, attrs) @property def dn(self) -> str: @@ -412,6 +529,30 @@ def dn(self) -> str: self.__dn = obj.pop("dn")[0] return self.__dn + @property + def cn(self) -> str: + """ + Object's distinguished name. + """ + if self.__cn is not None: + return self.__cn + + obj = self.get(["cn"]) + self.__cn = obj.pop("cn")[0] + return self.__cn + + @property + def sid(self) -> str: + """ + Object's security identifier. + """ + if self.__sid is not None: + return self.__sid + + obj = self.get(["objectSid"]) + self.__sid = obj.pop("objectSid")[0] + return self.__sid + class SambaUser(SambaObject): """ @@ -679,6 +820,237 @@ def __get_member_args(self, members: list[SambaUser | SambaGroup]) -> list[str]: return [",".join([x.name for x in members])] +class SambaComputer(SambaObject): + """ + AD computer management. + """ + + def __init__(self, role: Samba, name: str) -> None: + """ + :param role: AD role object. + :type role: AD + :param name: Computer name. + :type name: str + """ + super().__init__(role, "computer", name) + + def move(self, target: str) -> SambaComputer: + """ + Move a computer object. + + :param target: Target path. + :type target: str + :return: Self. + :rtype: SambaComputer + """ + self._exec("move", [target]) + + return self + + +class SambaSites(SambaObject): + """ + AD Sites management. + """ + + def __init__(self, role: Samba, name: str) -> None: + """ + :param role: Samba role object. + :type role: Samba + :param name: Site name, cannot contain spaces. + :type name: str + """ + super().__init__(role, "sites", name) + + def add(self) -> SambaSites: + """ + Create new Samba site. + + :return: Self. + :rtype: SambaSites + """ + self._exec("create") + + return self + + +class SambaGPO(SambaObject): + """ + Group policy object management. + """ + + def __init__(self, role: Samba, name: str) -> None: + """ + :param name: GPO name, defaults to 'Domain Test Policy' + :type name: str, optional + """ + super().__init__(role, "gpo", name) + + self.target: str | None = None + """Group policy target.""" + + self.search_base: str = f"cn=policies,cn=system,{self.role.host.naming_context}" + """Group policy search base.""" + + # samba-tool gpo commands edit the database files directly and need to be authenticated. + self.credentials: str = f" --username={self.role.host.admin} --password={self.role.host.adminpw}" + """Credentials to manage GPOs.""" + + def add(self) -> SambaGPO: + """ + Add a group policy object. + + :return: Samba group policy object + :rtype: SambaGPO + """ + self.host.conn.run(f'samba-tool gpo create "{self.name}" {self.credentials}') + + return self + + def delete(self) -> None: + """ + Delete group policy object. + """ + self.role.host.conn.run(f'samba-tool gpo del "{self.cn}" {self.credentials}') + + def link( + self, + target: str | None = None, + enforced: bool | None = False, + disabled: bool | None = False, + ) -> SambaGPO: + """ + Link the group policy to the target object inside the directory, a site, domain or an ou. + + :param target: Group policy target, defaults to 'Default-First-Site-Name' + :type target: str, optional + :param enforced: Enforced the policy + :type enforced: bool, optional + :param disabled: Disable the policy + :type disabled: bool, optional + :return: Samba group policy object + :rtype: SambaGPO + """ + args: str = "" + + # samba-tool parameters do not have an inverse, i.e. --enabled does not exist + if enforced: + args = args + " --enforce" + if disabled: + args = args + " --disabled" + + if target is None and self.target is None: + self.target = f"CN=Default-First-Site-Name,CN=Sites,CN=Configuration,{self.role.host.naming_context}" + + if target is not None and self.target is None: + self.target = target + + self.host.conn.run(f'samba-tool gpo setlink "{self.target}" "{self.cn}" {args} {self.credentials}') + + return self + + def unlink(self) -> SambaGPO: + """ + Unlink the group policy from the target. + + :return: Samba group policy object + :rtype: SambaGPO + """ + self.host.conn.run(f'samba-tool gpo dellink "{self.name}" {self.credentials}') + + return self + + def policy(self, logon_rights: dict[str, list[SambaObject]], cfg: dict[str, Any] | None = None) -> SambaGPO: + """ + Group policy configuration. + + This method does the remaining configuration of the group policy. It updates + 'GptTmpl.inf' with security logon right keys with the SIDs of users and groups + objects. The *Remote* keys can be omitted, in which the interactive key's value + will then be used. + + To add users and groups to the policy, the SID must be used for the values. The + values need to be prefixed with an '*' and use a comma for a de-limiter, i.e. + `*SID1-2-3-4,*SID-5-6-7-8` + + Additionally, gPCMachineExtensionNames need to be updated in the directory so + the GPO is readable to the client. The value is a list of Client Side + Extensions (CSEs), that is an index of what part of the policy is pushed and + processed by the client. + + :param logon_rights: List of logon rights. + :type logon_rights: dict[str, list[SambaObject]] + :param cfg: Extra configuration for GptTmpl.inf file, defaults to None + :type cfg: dict[str, Any] | None, optional + :return: Samba Group policy object + :rtype: SambaGPO + """ + _path: str = ( + f"/var/lib/samba/sysvol/" + f"{self.role.domain}/" + f"Policies/{self.cn}" + f"/MACHINE/Microsoft/Windows " + f"NT/SecEdit/" + ) + _full_path: str = f"{_path}GptTmpl.inf" + + _keys: list[str] = [ + "SeInteractiveLogonRight", + "SeRemoteInteractiveLogonRight", + "SeDenyInteractiveLogonRight", + "SeDenyRemoteInteractiveLogonRight", + ] + + for i in _keys: + if i not in logon_rights.keys() and i == "SeRemoteInteractiveLogonRight": + logon_rights[i] = logon_rights["SeInteractiveLogonRight"] + if i not in logon_rights.keys() and i == "SeDenyRemoteInteractiveLogonRight": + logon_rights[i] = logon_rights["SeDenyInteractiveLogonRight"] + + for i in _keys: + if i not in logon_rights.keys(): + raise KeyError(f"Expected {i} but got {logon_rights.keys()}") + + _logon_rights: dict[str, Any] = {} + for k, v in logon_rights.items(): + sids: list[str] = [] + for j in v: + sids.append(f"*{j.sid}") + _logon_rights = {**_logon_rights, **{k: ",".join(sids)}} + + config = configparser.ConfigParser(interpolation=None) + config.optionxform = str + config["Unicode"] = {} + config["Unicode"]["Unicode"] = "yes" + config["Version"] = {} + config["Version"]["signature"] = '"$CHICAGO$"' + config["Version"]["Revision"] = "1" + config["Privilege Rights"] = {} + + for k, v in _logon_rights.items(): + config["Privilege Rights"][k] = v + + if cfg is not None: + for _k, _v in cfg.items(): + config[_k] = {} + for __k, __v in _v.items(): + config[_k][__k] = __v + + config.write(open("/tmp/GptTmpl.inf", "w")) + + # The enable the GPO the gPCMachineExtensionNames attributes needs to be updated with the proper CSEs + attrs: LDAPRecordAttributes = { + "gPCMachineExtensionNames": + "[{827D319E-6EAC-11D2-A4EA-00C04F79F83A}{803E14A0-B4FB-11D0-A0D0-00A0C90F574B}]" + } + self._modify(attrs) + + self.role.fs.mkdir_p(_path, mode="750", user="BUILTIN\\administrators", group="users") + self.role.fs.upload("/tmp/GptTmpl.inf", _full_path, mode="750", user="BUILTIN\\administrators", group="users") + + return self + + SambaOrganizationalUnit: TypeAlias = LDAPOrganizationalUnit[SambaHost, Samba] SambaAutomount: TypeAlias = LDAPAutomount[SambaHost, Samba] SambaSudoRule: TypeAlias = LDAPSudoRule[SambaHost, Samba, SambaUser, SambaGroup]