Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""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
|
|
GUID_BATCH = 50 # objectGUID terms in one OR filter
|
|
SEARCH_GROUPS_MAX = 100
|
|
|
|
|
|
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]: ...
|
|
def list_groups_detail(self) -> list[tuple[str, str]]: ...
|
|
def members_of_dn(self, group_dn: str) -> list[AdUser]: ...
|
|
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]: ...
|
|
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]: ...
|
|
|
|
|
|
# ---------------------------------------------------------------- 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]:
|
|
"""Every account in the group under LDAP_GROUP_BASE_DN, nested included (IN_CHAIN, paged)."""
|
|
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 "")
|
|
return self._members(conn, gdn)
|
|
|
|
def _members(self, conn: Any, group_dn: str) -> list[AdUser]:
|
|
from ldap3.utils.conv import escape_filter_chars
|
|
|
|
flt = f"(&(objectClass=user)(objectCategory=person)(memberOf:{IN_CHAIN}:={escape_filter_chars(group_dn)}))"
|
|
found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS)
|
|
return [user_from_entry(e) for e in found]
|
|
|
|
def list_groups_detail(self) -> list[tuple[str, str]]:
|
|
"""(cn, dn) of every group in LDAP_GROUP_BASE_DN, sorted by cn."""
|
|
with self._service() as conn:
|
|
found = self._search(
|
|
conn, self.settings.ldap_group_base_dn, "(objectClass=group)", ["cn", "distinguishedName"]
|
|
)
|
|
out: dict[str, tuple[str, str]] = {}
|
|
for e in found:
|
|
attrs = e.get("attributes") or {}
|
|
cn = _text(attrs, "cn")
|
|
if cn:
|
|
out[cn.lower()] = (cn, _text(attrs, "distinguishedName") or str(e.get("dn") or ""))
|
|
return sorted(out.values(), key=lambda t: t[0].lower())
|
|
|
|
def members_of_dn(self, group_dn: str) -> list[AdUser]:
|
|
"""Every account in the group by DN, nested included, disabled accounts too. Paged at 500."""
|
|
with self._service() as conn:
|
|
return self._members(conn, group_dn)
|
|
|
|
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]:
|
|
"""Groups anywhere under LDAP_BASE_DN whose name or description contains q: [{dn, name, description}]."""
|
|
from ldap3.utils.conv import escape_filter_chars
|
|
|
|
q = q.strip()
|
|
if not q:
|
|
return []
|
|
limit = max(1, min(int(limit), SEARCH_GROUPS_MAX))
|
|
term = escape_filter_chars(q)
|
|
flt = f"(&(objectClass=group)(|(cn=*{term}*)(description=*{term}*)))"
|
|
with self._service() as conn:
|
|
found = self._search(conn, self.settings.ldap_base_dn, flt, ["cn", "distinguishedName", "description"])
|
|
out = []
|
|
for e in found:
|
|
attrs = e.get("attributes") or {}
|
|
name = _text(attrs, "cn")
|
|
if not name:
|
|
continue
|
|
out.append(
|
|
{
|
|
"dn": _text(attrs, "distinguishedName") or str(e.get("dn") or ""),
|
|
"name": name,
|
|
"description": _text(attrs, "description") or "",
|
|
}
|
|
)
|
|
out.sort(key=lambda g: (not g["name"].lower().startswith(q.lower()), g["name"].lower()))
|
|
return out[:limit]
|
|
|
|
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]:
|
|
"""The accounts for these objectGUIDs, read in batches. None for a GUID AD no longer has."""
|
|
wanted = list(dict.fromkeys(guids))
|
|
out: dict[uuid.UUID, AdUser | None] = dict.fromkeys(wanted)
|
|
if not wanted:
|
|
return out
|
|
with self._service() as conn:
|
|
for i in range(0, len(wanted), GUID_BATCH):
|
|
part = wanted[i : i + GUID_BATCH]
|
|
flt = "(|" + "".join(guid_filter(g) for g in part) + ")"
|
|
for e in self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS):
|
|
u = user_from_entry(e)
|
|
if u.guid in out:
|
|
out[u.guid] = u
|
|
return out
|