"""NetKingdom's idempotent LLDAP lifecycle adapter.""" from __future__ import annotations from dataclasses import dataclass import json import re import secrets from typing import Any, Callable from urllib.request import Request, urlopen @dataclass(frozen=True) class Result: provider: str external_subject: str status: str resumed: bool @dataclass(frozen=True) class DriftResult: provider: str external_subject: str status: str drift: tuple[str, ...] changed: tuple[str, ...] = () class LLDAPProvisioner: def __init__(self, *, base_url: str, admin_password: str, opener: Callable = urlopen) -> None: self.base_url = base_url.rstrip("/") self.admin_password = admin_password self.opener = opener def provision(self, payload: dict[str, Any]) -> Result: _required(payload, "user_id", "tenant", "primary_email", "idempotency_key", "correlation_id") email = str(payload["primary_email"]).strip().lower() username = _username(email, payload.get("preferred_username")) token = self._login() users, groups = self._directory(token) existing = next((user for user in users if user.get("id") == username), None) resumed = existing is not None created = existing is None if created: self._gql(token, """ mutation CreateUser($id: String!, $email: String!, $display: String!) { createUser(user: {id: $id, email: $email, displayName: $display}) { id } }""", { "id": username, "email": email, "display": str(payload.get("display_name") or username), }) elif str(existing.get("email", "")).lower() != email: raise ValueError("directory username collision") roles = {str(role) for role in payload.get("roles", ())} group_names = [f"{payload['tenant']}:users"] if "tenant-admin" in roles: group_names.append(f"{payload['tenant']}:admins") try: for name in group_names: group_id = self._ensure_group(token, groups, name) self._add_group(token, username, group_id) except Exception: if created: try: self._delete(token, username) except Exception: pass raise return Result("netkingdom-lldap", _oidc_subject(username), "password_setup_required", resumed) def suspend(self, subject: str) -> Result: subject = _directory_username(subject) token = self._login() _, groups = self._directory(token) group_id = self._ensure_group(token, groups, "netkingdom-suspended") self._add_group(token, subject, group_id) return Result("netkingdom-lldap", subject, "suspended", False) def reactivate(self, subject: str) -> Result: subject = _directory_username(subject) token = self._login() _, groups = self._directory(token) group = next((item for item in groups if item.get("displayName") == "netkingdom-suspended"), None) if group: self._gql(token, """ mutation Remove($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }""", {"userId": subject, "groupId": int(group["id"])}) return Result("netkingdom-lldap", subject, "active", False) def deprovision(self, subject: str) -> Result: subject = _directory_username(subject) token = self._login() if self._user(token, subject) is None: return Result("netkingdom-lldap", subject, "deprovisioned", True) self._delete(token, subject) return Result("netkingdom-lldap", subject, "deprovisioned", False) def drift(self, payload: dict[str, Any]) -> DriftResult: subject, email, desired_groups, desired_status = _desired(payload) token = self._login() user = self._user(token, subject) drift = self._drift(user, email, desired_groups, desired_status, str(payload["tenant"])) status = "in_sync" if not drift else "drifted" return DriftResult("netkingdom-lldap", subject, status, tuple(drift)) def reconcile(self, payload: dict[str, Any]) -> DriftResult: subject, email, desired_groups, desired_status = _desired(payload) token = self._login() user = self._user(token, subject) changed: list[str] = [] if user is None: result = self.provision(payload) changed.append("user:created") subject = result.external_subject token = self._login() user = self._user(token, subject) if user is None: raise RuntimeError("directory reconciliation did not create the identity") if str(user.get("email", "")).lower() != email: raise ValueError("directory email drift requires explicit identity repair") groups = list(self._directory(token)[1]) current = {str(item["displayName"]): int(item["id"]) for item in user.get("groups", ())} tenant = str(payload["tenant"]) managed = { name for name in current if name in {f"{tenant}:users", f"{tenant}:admins", "netkingdom-suspended"} } for name in sorted(desired_groups - managed): self._add_group(token, subject, self._ensure_group(token, groups, name)) changed.append(f"group:added:{name}") for name in sorted(managed - desired_groups): self._remove_group(token, subject, current[name]) changed.append(f"group:removed:{name}") user = self._user(token, subject) remaining = self._drift(user, email, desired_groups, desired_status, tenant) status = "reconciled" if not remaining else "drifted" return DriftResult("netkingdom-lldap", subject, status, tuple(remaining), tuple(changed)) def _login(self) -> str: request = Request( self.base_url + "/auth/simple/login", data=json.dumps({"username": "admin", "password": self.admin_password}).encode(), headers={"Content-Type": "application/json"}, method="POST", ) with self.opener(request, timeout=10) as response: return str(json.loads(response.read())["token"]) def _directory(self, token: str) -> tuple[list[dict], list[dict]]: value = self._gql(token, "query { users { id email displayName } groups { id displayName } }", {}) return list(value["users"]), list(value["groups"]) def _user(self, token: str, subject: str) -> dict[str, Any] | None: try: value = self._gql(token, """ query User($id: String!) { user(userId: $id) { id email displayName groups { id displayName } } }""", {"id": subject}) except ValueError as exc: if "not found" in str(exc).lower(): return None raise user = value.get("user") return dict(user) if user else None def _ensure_group(self, token: str, groups: list[dict], name: str) -> int: existing = next((group for group in groups if group.get("displayName") == name), None) if existing: return int(existing["id"]) created = self._gql( token, "mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }", {"name": name}, )["createGroup"] groups.append(created) return int(created["id"]) def _add_group(self, token: str, username: str, group_id: int) -> None: try: self._gql(token, """ mutation Add($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }""", {"userId": username, "groupId": group_id}) except ValueError as exc: if "already" not in str(exc).lower() and "unique" not in str(exc).lower(): raise def _remove_group(self, token: str, username: str, group_id: int) -> None: self._gql(token, """ mutation Remove($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }""", {"userId": username, "groupId": group_id}) def _delete(self, token: str, subject: str) -> None: self._gql( token, "mutation Delete($id: String!) { deleteUser(userId: $id) { ok } }", {"id": subject}, ) @staticmethod def _drift( user: dict[str, Any] | None, email: str, desired_groups: set[str], desired_status: str, tenant: str, ) -> list[str]: if user is None: return ["user:missing"] drift: list[str] = [] if str(user.get("email", "")).lower() != email: drift.append("email:mismatch") current = {str(item["displayName"]) for item in user.get("groups", ())} managed = { name for name in current if name in {f"{tenant}:users", f"{tenant}:admins", "netkingdom-suspended"} } drift.extend(f"group:missing:{name}" for name in sorted(desired_groups - managed)) drift.extend(f"group:unexpected:{name}" for name in sorted(managed - desired_groups)) suspended = "netkingdom-suspended" in current if suspended != (desired_status == "suspended"): drift.append("status:mismatch") return drift def _gql(self, token: str, query: str, variables: dict[str, Any]) -> dict: request = Request( self.base_url + "/api/graphql", data=json.dumps({"query": query, "variables": variables}).encode(), headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, method="POST", ) with self.opener(request, timeout=15) as response: payload = json.loads(response.read()) if payload.get("errors"): raise ValueError(str(payload["errors"][0].get("message", "LLDAP GraphQL error"))) return dict(payload.get("data") or {}) def dispatch( provisioner: LLDAPProvisioner, path: str, payload: dict[str, Any] ) -> Result | DriftResult: if path == "/v1/identities/provision": return provisioner.provision(payload) if path == "/v1/identities/drift": return provisioner.drift(payload) if path == "/v1/identities/reconcile": return provisioner.reconcile(payload) _required(payload, "external_subject", "idempotency_key", "correlation_id") subject = _directory_username(str(payload["external_subject"])) if path == "/v1/identities/suspend": return provisioner.suspend(subject) if path == "/v1/identities/reactivate": return provisioner.reactivate(subject) if path == "/v1/identities/deprovision": return provisioner.deprovision(subject) raise KeyError(path) def _username(email: str, preferred: object = None) -> str: if preferred is not None: value = str(preferred).strip().lower() if not re.fullmatch(r"[a-z][a-z0-9._-]{2,31}", value): raise ValueError("preferred_username is invalid") if value in {"admin", "administrator", "platform-root", "root", "system"}: raise ValueError("preferred_username is reserved") return value local = email.partition("@")[0].lower() value = re.sub(r"[^a-z0-9._-]+", "-", local).strip("-") if not value or "@" not in email: raise ValueError("valid primary_email is required") return value[:64] def _required(payload: dict[str, Any], *fields: str) -> None: missing = [field for field in fields if not payload.get(field)] if missing: raise ValueError("missing required fields: " + ", ".join(missing)) if len(str(payload.get("idempotency_key", ""))) < 16: raise ValueError("idempotency_key must contain at least 16 characters") def _desired(payload: dict[str, Any]) -> tuple[str, str, set[str], str]: _required( payload, "external_subject", "tenant", "primary_email", "idempotency_key", "correlation_id", ) subject = str(payload["external_subject"]) email = str(payload["primary_email"]).strip().lower() if _username(email) != subject: raise ValueError("external_subject does not match canonical email username") desired_status = str(payload.get("desired_status", "active")) if desired_status not in {"active", "suspended"}: raise ValueError("desired_status must be active or suspended") tenant = str(payload["tenant"]) groups = {f"{tenant}:users"} if "tenant-admin" in {str(role) for role in payload.get("roles", ())}: groups.add(f"{tenant}:admins") if desired_status == "suspended": groups.add("netkingdom-suspended") return subject, email, groups, desired_status def _directory_username(subject: str) -> str: return subject[4:].split(",", 1)[0] if subject.startswith("uid=") else subject def _oidc_subject(username: str) -> str: return f"uid={username},ou=people,dc=netkingdom,dc=local"