197 lines
6.9 KiB
Python
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",
|
|
]
|