Files
govoplan-addresses/src/govoplan_addresses/backend/ldap.py
T

197 lines
6.9 KiB
Python

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",
]