"""Read-only access to Active Directory with ldap3: search with the service account, bind to verify a password. Every operation opens its own connection and unbinds when done. A user's password is used for one bind and never stored or logged. """ from __future__ import annotations import contextlib import logging import ssl import uuid from collections.abc import Iterator from dataclasses import dataclass, field from typing import Any, Protocol from ..settings import SecretFileError, Settings log = logging.getLogger(__name__) IN_CHAIN = "1.2.840.113556.1.4.1941" USER_ATTRS = ( "objectGUID", "sAMAccountName", "userPrincipalName", "displayName", "employeeID", "mail", "userAccountControl", "distinguishedName", ) ACCOUNTDISABLE = 2 INVALID_CREDENTIALS = 49 PAGE_SIZE = 500 class LdapUnavailable(Exception): """No domain controller answered, TLS failed, or the service bind was refused. Never holds a password.""" @dataclass class AdUser: guid: uuid.UUID dn: str sam_account: str upn: str | None display_name: str emp_no: str | None email: str | None disabled: bool groups: list[str] = field(default_factory=list) class DirectoryProtocol(Protocol): """What the app needs from AD. `Directory` talks to a domain controller; tests use a fake.""" def find_user(self, login: str) -> AdUser | None: ... def get_user_by_guid(self, guid: uuid.UUID) -> AdUser | None: ... def verify_password(self, dn: str, password: str) -> bool: ... def groups_of(self, dn: str) -> list[str]: ... def list_groups(self) -> list[str]: ... def members_of(self, group_cn: str) -> list[AdUser]: ... # ---------------------------------------------------------------- attribute helpers def parse_guid(value: Any) -> uuid.UUID: """objectGUID as ldap3 returns it: 16 raw bytes (little-endian fields) or a "{...}" string.""" if isinstance(value, list | tuple): if not value: raise ValueError("empty objectGUID") value = value[0] if isinstance(value, bytes | bytearray): if len(value) != 16: raise ValueError("objectGUID must be 16 bytes") return uuid.UUID(bytes_le=bytes(value)) if isinstance(value, uuid.UUID): return value return uuid.UUID(str(value).strip().strip("{}")) def guid_filter(guid: uuid.UUID) -> str: """`(objectGUID=\\xx...)` with the bytes escaped for a search filter.""" from ldap3.utils.conv import escape_bytes return f"(objectGUID={escape_bytes(guid.bytes_le)})" def _first(attrs: dict[str, Any], name: str) -> Any: v = attrs.get(name) if isinstance(v, list | tuple): return v[0] if v else None return v def _text(attrs: dict[str, Any], name: str) -> str | None: v = _first(attrs, name) if v is None: return None if isinstance(v, bytes | bytearray): v = bytes(v).decode("utf-8", "replace") text = str(v).strip() return text or None def user_from_entry(entry: dict[str, Any]) -> AdUser: """An AdUser from one ldap3 search response entry (`attributes` plus `raw_attributes`).""" attrs = entry.get("attributes") or {} raw = entry.get("raw_attributes") or {} guid_raw = _first(raw, "objectGUID") or _first(attrs, "objectGUID") if guid_raw is None: raise ValueError("entry has no objectGUID") uac_text = _text(attrs, "userAccountControl") or "0" try: uac = int(uac_text) except ValueError: uac = 0 sam = _text(attrs, "sAMAccountName") or "" return AdUser( guid=parse_guid(guid_raw), dn=_text(attrs, "distinguishedName") or str(entry.get("dn") or ""), sam_account=sam, upn=_text(attrs, "userPrincipalName"), display_name=_text(attrs, "displayName") or sam, emp_no=_text(attrs, "employeeID"), email=_text(attrs, "mail"), disabled=bool(uac & ACCOUNTDISABLE), ) def _entries(response: Any) -> list[dict[str, Any]]: return [e for e in (response or []) if e.get("type") == "searchResEntry"] # ---------------------------------------------------------------- the real directory class Directory: """AD over ldaps:// (or ldap:// with STARTTLS). Built from Settings; every method is synchronous.""" def __init__(self, settings: Settings): self.settings = settings self._pool: Any = None # ------------------------------------------------ connections def _servers(self) -> Any: import ldap3 if self._pool is not None: return self._pool s = self.settings urls = s.ldap_urls if not urls: raise LdapUnavailable("LDAP_URL is not set") servers = [] for url in urls: ldaps = url.lower().startswith("ldaps://") if not ldaps and not s.ldap_starttls and not s.ldap_allow_insecure: raise LdapUnavailable(f"{url} is plain LDAP without STARTTLS; refused") tls = ldap3.Tls( validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLS_CLIENT, ca_certs_file=str(s.ldap_ca_bundle) if s.ldap_ca_bundle else None, ) servers.append(ldap3.Server(url, use_ssl=ldaps, tls=tls, get_info=ldap3.NONE, connect_timeout=10)) # FIRST: the first healthy controller is used. active=1 tries the list once (True would loop # forever when every controller is down); exhaust=60 skips a dead controller for a minute. self._pool = ldap3.ServerPool(servers, ldap3.FIRST, active=1, exhaust=60) return self._pool def _open(self, user: str, password: str) -> Any: """An open, TLS-protected connection, not yet bound.""" import ldap3 conn = ldap3.Connection( self._servers(), user=user, password=password, authentication=ldap3.SIMPLE, receive_timeout=15, raise_exceptions=False, auto_referrals=False, read_only=True, ) conn.open() url_is_ldaps = bool(getattr(conn.server, "ssl", False)) if self.settings.ldap_starttls and not url_is_ldaps and not conn.start_tls(): with contextlib.suppress(Exception): conn.unbind() raise LdapUnavailable(f"STARTTLS failed: {conn.result.get('description')}") return conn @contextlib.contextmanager def _service(self) -> Iterator[Any]: """A connection bound as the service account; always unbound afterwards.""" from ldap3.core.exceptions import LDAPException s = self.settings if not s.ldap_bind_dn: raise LdapUnavailable("LDAP_BIND_DN is not set") try: password = s.ldap_password() except SecretFileError as e: raise LdapUnavailable(str(e)) from None conn = None try: conn = self._open(s.ldap_bind_dn, password) if not conn.bind(): raise LdapUnavailable(f"service bind failed: {conn.result.get('description')}") yield conn except LDAPException as e: raise LdapUnavailable(f"{type(e).__name__}: {e}") from None finally: if conn is not None: with contextlib.suppress(Exception): conn.unbind() def _search(self, conn: Any, base: str, flt: str, attrs: tuple[str, ...] | list[str]) -> list[dict[str, Any]]: import ldap3 results = conn.extend.standard.paged_search( base, flt, ldap3.SUBTREE, attributes=list(attrs), paged_size=PAGE_SIZE, generator=False ) desc = conn.result.get("description") if conn.result else "success" if desc not in ("success", None, "noSuchObject"): raise LdapUnavailable(f"search failed: {desc}") return _entries(results) # ------------------------------------------------ protocol def find_user(self, login: str) -> AdUser | None: from ldap3.utils.conv import escape_filter_chars login = login.strip() if not login: return None flt = self.settings.ldap_user_filter.replace("{0}", escape_filter_chars(login)) with self._service() as conn: found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS) users = [user_from_entry(e) for e in found] if len(users) != 1: if len(users) > 1: log.warning("LDAP_USER_FILTER matched %d entries for one login; refusing", len(users)) return None return users[0] def get_user_by_guid(self, guid: uuid.UUID) -> AdUser | None: with self._service() as conn: found = self._search(conn, self.settings.ldap_base_dn, guid_filter(guid), USER_ATTRS) return user_from_entry(found[0]) if found else None def verify_password(self, dn: str, password: str) -> bool: """Simple bind as the user. An empty password is refused here: AD treats it as an anonymous bind.""" from ldap3.core.exceptions import LDAPException if not password or not dn: return False conn = None try: conn = self._open(dn, password) if conn.bind(): return True code = conn.result.get("result") if conn.result else None if code == INVALID_CREDENTIALS: return False raise LdapUnavailable(f"user bind failed: {conn.result.get('description')}") except LDAPException as e: raise LdapUnavailable(f"{type(e).__name__}") from None finally: if conn is not None: with contextlib.suppress(Exception): conn.unbind() def groups_of(self, dn: str) -> list[str]: from ldap3.utils.conv import escape_filter_chars flt = f"(&(objectClass=group)(member:{IN_CHAIN}:={escape_filter_chars(dn)}))" with self._service() as conn: found = self._search(conn, self.settings.ldap_group_base_dn, flt, ["cn"]) return sorted({cn for e in found if (cn := _text(e.get("attributes") or {}, "cn"))}) def list_groups(self) -> list[str]: with self._service() as conn: found = self._search(conn, self.settings.ldap_group_base_dn, "(objectClass=group)", ["cn"]) return sorted({cn for e in found if (cn := _text(e.get("attributes") or {}, "cn"))}) def members_of(self, group_cn: str) -> list[AdUser]: from ldap3.utils.conv import escape_filter_chars with self._service() as conn: groups = self._search( conn, self.settings.ldap_group_base_dn, f"(&(objectClass=group)(cn={escape_filter_chars(group_cn)}))", ["cn", "distinguishedName"], ) if not groups: return [] gdn = _text(groups[0].get("attributes") or {}, "distinguishedName") or str(groups[0].get("dn") or "") flt = f"(&(objectClass=user)(objectCategory=person)(memberOf:{IN_CHAIN}:={escape_filter_chars(gdn)}))" found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS) return [user_from_entry(e) for e in found]