from __future__ import annotations import ssl from dataclasses import dataclass from typing import Any from urllib.parse import unquote, urlsplit from govoplan_core.core.connector_runtime import ConnectorContractError, ConnectorEndpoint class AddressLdapError(RuntimeError): pass @dataclass(frozen=True, slots=True) class AddressLdapEntry: dn: str attributes: dict[str, Any] @dataclass(frozen=True, slots=True) class AddressLdapSearchResult: base_dn: str entries: tuple[AddressLdapEntry, ...] complete: bool page_size: int class AddressLdapClient: def __init__( self, *, url: str, bind_dn: str | None = None, password: str | None = None, start_tls: bool = True, connect_timeout: int = 10, receive_timeout: int = 30, ) -> None: try: endpoint = ConnectorEndpoint( url=url, tls_mode="start_tls" if start_tls else "required", ) except ConnectorContractError as exc: raise AddressLdapError(str(exc)) from exc parsed = urlsplit(endpoint.url) if parsed.scheme not in {"ldap", "ldaps"}: raise AddressLdapError("LDAP endpoints must use ldap:// or ldaps://.") if parsed.scheme == "ldap" and not start_tls: raise AddressLdapError("ldap:// endpoints require StartTLS.") if parsed.path not in {"", "/"}: self.default_base_dn = unquote(parsed.path.lstrip("/")) else: self.default_base_dn = None self.url = endpoint.url self.host = parsed.hostname or "" self.port = parsed.port or (636 if parsed.scheme == "ldaps" else 389) self.use_ssl = parsed.scheme == "ldaps" self.start_tls = parsed.scheme == "ldap" and start_tls self.bind_dn = bind_dn self.password = password self.connect_timeout = max(1, min(connect_timeout, 30)) self.receive_timeout = max(1, min(receive_timeout, 120)) def discover_base_dns(self) -> tuple[str, ...]: connection = self._connection() try: from ldap3 import BASE if not connection.search( search_base="", search_filter="(objectClass=*)", search_scope=BASE, attributes=["namingContexts", "defaultNamingContext", "rootDomainNamingContext"], ): raise AddressLdapError(_ldap_result_message(connection.result, "LDAP root DSE discovery failed.")) values: list[str] = [] for entry in connection.entries: data = entry.entry_attributes_as_dict for key in ("defaultNamingContext", "rootDomainNamingContext", "namingContexts"): for value in _as_values(data.get(key)): normalized = str(value).strip() if normalized and normalized not in values: values.append(normalized) if self.default_base_dn and self.default_base_dn not in values: values.insert(0, self.default_base_dn) return tuple(values) finally: connection.unbind() def search( self, *, base_dn: str, search_filter: str, attributes: tuple[str, ...], page_size: int = 500, max_entries: int = 10_000, ) -> AddressLdapSearchResult: normalized_base = base_dn.strip() or self.default_base_dn if not normalized_base: raise AddressLdapError("LDAP base DN is required.") page_size = max(1, min(page_size, 1_000)) max_entries = max(1, min(max_entries, 10_000)) connection = self._connection() entries: list[AddressLdapEntry] = [] complete = True try: try: stream = connection.extend.standard.paged_search( search_base=normalized_base, search_filter=search_filter, attributes=list(attributes), paged_size=page_size, generator=True, ) for response in stream: response_type = response.get("type") if response_type != "searchResEntry": continue if len(entries) >= max_entries: complete = False break entries.append( AddressLdapEntry( dn=str(response.get("dn") or ""), attributes=dict(response.get("attributes") or {}), ) ) except Exception as exc: raise AddressLdapError(f"LDAP paged search failed: {exc}.") from exc if connection.result and int(connection.result.get("result", 0) or 0) != 0: raise AddressLdapError(_ldap_result_message(connection.result, "LDAP paged search failed.")) return AddressLdapSearchResult( base_dn=normalized_base, entries=tuple(entries), complete=complete, page_size=page_size, ) finally: connection.unbind() def _connection(self): try: from ldap3 import Connection, Server, Tls except ImportError as exc: # pragma: no cover - package failure raise AddressLdapError("LDAP connector support is not installed.") from exc tls = Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLS_CLIENT) server = Server( self.host, port=self.port, use_ssl=self.use_ssl, tls=tls, connect_timeout=self.connect_timeout, ) try: connection = Connection( server, user=self.bind_dn, password=self.password, receive_timeout=self.receive_timeout, raise_exceptions=True, ) connection.open() if self.start_tls: connection.start_tls() connection.bind() return connection except Exception as exc: raise AddressLdapError(f"LDAP connection or bind failed: {exc}.") from exc def _as_values(value: Any) -> tuple[Any, ...]: if value is None: return () if isinstance(value, (list, tuple, set)): return tuple(value) return (value,) def _ldap_result_message(result: dict[str, Any] | None, fallback: str) -> str: if not result: return fallback description = str(result.get("description") or "").strip() message = str(result.get("message") or "").strip() detail = ": ".join(part for part in (description, message) if part) return f"{fallback} {detail}".strip() __all__ = [ "AddressLdapClient", "AddressLdapEntry", "AddressLdapError", "AddressLdapSearchResult", ]