"""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 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) 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 if existing is None: 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") for name in group_names: group_id = self._ensure_group(token, groups, name) self._add_group(token, username, group_id) return Result("netkingdom-lldap", username, "password_setup_required", resumed) def suspend(self, subject: str) -> Result: 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: 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: token = self._login() self._gql(token, "mutation Delete($id: String!) { deleteUser(userId: $id) { ok } }", {"id": subject}) return Result("netkingdom-lldap", subject, "deprovisioned", False) 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 _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 _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: if path == "/v1/identities/provision": return provisioner.provision(payload) _required(payload, "external_subject", "idempotency_key", "correlation_id") subject = 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) -> str: 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")