Add governed tabular and LDAP address sources
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user