diff --git a/qgis_deployment_toolbelt/profiles/rules_context.py b/qgis_deployment_toolbelt/profiles/rules_context.py index e34d4ec6..0422df81 100644 --- a/qgis_deployment_toolbelt/profiles/rules_context.py +++ b/qgis_deployment_toolbelt/profiles/rules_context.py @@ -24,6 +24,10 @@ get_user_domain_groups, get_user_local_groups, ) +from qgis_deployment_toolbelt.utils.win32utils import ( + ExtendedNameFormat, + get_current_user_extended_data, +) # ############################################################################# # ########## Globals ############### @@ -79,6 +83,7 @@ def _context_environment(self) -> dict: return { "computer_network_name": platform.node(), "operating_system_code": opersys, + "operating_system_release": platform.release(), "processor_architecture": platform.machine(), # custom Linux "linux_distribution_name": linux_distribution_name, @@ -95,10 +100,18 @@ def _context_user(self) -> dict: Returns: dict: dict user information. """ + if opersys == "win32": + windows_extended = { + k.name: get_current_user_extended_data(k) for k in ExtendedNameFormat + } + else: + windows_extended = None + return { "name": getuser(), "groups_local": get_user_local_groups(), "groups_domain": get_user_domain_groups(), + "windows_extended": windows_extended, } # -- EXPORT diff --git a/qgis_deployment_toolbelt/utils/user_groups.py b/qgis_deployment_toolbelt/utils/user_groups.py index 252f7282..c85fa2db 100644 --- a/qgis_deployment_toolbelt/utils/user_groups.py +++ b/qgis_deployment_toolbelt/utils/user_groups.py @@ -23,15 +23,10 @@ """windows""" # 3rd party + import pyad import win32com import win32net - - # try to import pyad - try: - import pyad - except ImportError: - logging.info("'pyad' package is not available.") - pyad = None + from pywintypes import error as PyWinException else: import grp @@ -72,7 +67,19 @@ def get_user_local_groups(user_name: str | None = None) -> list[str]: return sorted({g.gr_name for g in grp.getgrall() if user_name in g.gr_mem}) elif opersys.lower() in ("win32", "windows"): server_host_name = uname()[1] - return sorted(set(win32net.NetUserGetLocalGroups(server_host_name, user_name))) + try: + local_groups = sorted( + set(win32net.NetUserGetLocalGroups(server_host_name, user_name)) + ) + except PyWinException as err: + logger.info( + f"Retrieving user ('{user_name}') local groups on {server_host_name} " + "failed. This usually means that it's not a local session, and that the " + "computer is linked to a domain and subscribed to a directory. " + f"Trace: {err}" + ) + local_groups = [] + return local_groups else: raise NotImplementedError(f"Unsupported operating system: {opersys}") @@ -110,10 +117,26 @@ def get_user_domain_groups(user_name: str | None = None) -> list[str]: # using pure ldap) return [] elif opersys.lower() in ("win32", "windows"): - if pyad is not None: - user_obj = pyad.aduser.ADUser.from_cn(getuser()) - return sorted(set(user_obj.get_attribute("memberOf"))) - return [] + user_obj = pyad.aduser.ADUser.from_cn(user_name) + user_groups: list[pyad.ADGroup] | None = user_obj.get_memberOfs() + if not user_groups or not len(user_groups): + logger.warning( + f"It looks like '{user_name}' does not belong to any domain group." + ) + return [] + else: + logger.info( + f"The current user '{user_name}' belongs to {len(user_groups)} Active " + "Directory groups." + ) + + return sorted( + { + grp.get_attribute("name")[0] + for grp in user_groups + if isinstance(grp, pyad.ADGroup) + } + ) else: raise NotImplementedError(f"Unsupported operating system: {opersys}") diff --git a/qgis_deployment_toolbelt/utils/win32utils.py b/qgis_deployment_toolbelt/utils/win32utils.py index 34b3ff94..1d744c14 100644 --- a/qgis_deployment_toolbelt/utils/win32utils.py +++ b/qgis_deployment_toolbelt/utils/win32utils.py @@ -12,8 +12,11 @@ # ########## Libraries ############# # ################################## + # Standard library +import ctypes import logging +from enum import Enum from os import sep # required since pathlib strips trailing whitespace from pathlib import Path from sys import platform as opersys @@ -42,6 +45,58 @@ ) user_hkey = (winreg.HKEY_CURRENT_USER, r"Environment") + +# ############################################################################# +# ########## Classes ############### +# ################################## + + +class ExtendedNameFormat(Enum): + """Possible values for user name in a Windows Active Directory context. + See references: + + - https://learn.microsoft.com/windows/win32/api/secext/ne-secext-extended_name_format + - https://mhammond.github.io/pywin32/win32api__GetUserNameEx_meth.html + - https://github.com/mhammond/pywin32/blob/dde12b8ef274a157aede18f46cae5b44f112be17/win32/Lib/win32con.py#L4963-L4973 + + """ + + # An unknown name type. + NameUnknown = 0 + # The fully qualified distinguished name (for example, CN=Jeff Smith,OU=Users,DC=Engineering,DC=Microsoft,DC=Com). + NameFullyQualifiedDN = 1 + # A legacy account name (for example, Engineering\JSmith). + # The domain-only version includes trailing backslashes (\). + NameSamCompatible = 2 + # A "friendly" display name (for example, Jeff Smith). + # The display name is not necessarily the defining relative distinguished name (RDN). + NameDisplay = 3 + # A GUID string that the IIDFromString function returns (for example, + # {4fa050f0-f561-11cf-bdd9-00aa003a77b6}). + NameUniqueId = 6 + # The complete canonical name (for example, + # engineering.microsoft.com/software/someone). + # The domain-only version includes a trailing forward slash (/). + NameCanonical = 7 + # The user principal name (for example, someone@example.com). + NameUserPrincipal = 8 + # The same as NameCanonical except that the rightmost forward slash (/) is replaced + # with a new line character (\n), even in a domain-only case (for example, + # engineering.microsoft.com/software\nJSmith). + NameCanonicalEx = 9 + # The generalized service principal name (for example, + # www/www.microsoft.com@microsoft.com). + NameServicePrincipal = 10 + # The DNS domain name followed by a backward-slash and the SAM user name. + NameDnsDomain = 12 + # The first name or given name of the user. Note: This type is only available for + # GetUserNameEx calls for an Active Directory user. + NameGivenName = 13 + # The last name or surname of the user. Note: This type is only available for + # GetUserNameEx calls for an Active Directory user. + NameSurname = 14 + + # ############################################################################# # ########## Functions ############# # ################################## @@ -111,6 +166,49 @@ def get_environment_variable(envvar_name: str, scope: str = "user") -> str | Non return None +def get_current_user_extended_data(extended_name_format: ExtendedNameFormat) -> str: + """Get current user full data extended with Active Directory informations. + + This method uses the ctypes module since the upper-level method implemented in + `pywin32.win32api` (`win32api.GetUserNameEx(extended_name_format.value)`) raises an + error when the specified format is not reachable. + + Inspired from: https://stackoverflow.com/a/70182936/2556577 + + Returns: + str: use data in the specified format + + Example: + + .. code-block:: python + + from qgis_deployment_toolbelt.utils.win32utils import ( + ExtendedNameFormat, + get_current_user_extended_data, + ) + + user_data = { + k.name: get_current_user_extended_data(k) + for k in ExtendedNameFormat + } + + print(user_data) + + """ + format_index = extended_name_format.value + + # use system DLL to call API + GetUserNameEx = ctypes.windll.secur32.GetUserNameExW + + size = ctypes.pointer(ctypes.c_ulong(0)) + GetUserNameEx(format_index, None, size) + + nameBuffer = ctypes.create_unicode_buffer(size.contents.value) + GetUserNameEx(format_index, nameBuffer, size) + + return nameBuffer.value + + def normalize_path(input_path: Path, add_trailing_slash_if_dir: bool = True) -> str: r"""Returns a path as normalized and fully escaped for Windows old-school file style.