"""NetKingdom's idempotent LLDAP lifecycle adapter.""" from __future__ import annotations from dataclasses import dataclass import json import hashlib import re import time from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from group_provenance import GroupProvenance, GroupProvenanceUnavailable @dataclass(frozen=True) class Result: provider: str external_subject: str status: str resumed: bool @dataclass(frozen=True) class GroupResult(Result): ensure_idempotency_key: str ensure_correlation_id: str created_groups: tuple[dict[str, Any], ...] created_memberships: tuple[dict[str, Any], ...] @dataclass(frozen=True) class DriftResult: provider: str external_subject: str status: str drift: tuple[str, ...] changed: tuple[str, ...] = () class DependencyFailure(RuntimeError): """Directory dependency failed. Messages never carry credentials or upstream bodies.""" def __init__(self, code: str, *, dependency: str = "directory") -> None: self.code = code self.dependency = dependency super().__init__("dependency_unavailable") def payload(self) -> dict[str, str]: return { "error": "dependency_unavailable", "dependency": self.dependency, "reason": self.code, } class LLDAPProvisioner: def __init__( self, *, base_url: str, admin_password: str, opener: Callable = urlopen, auth_rejected_cooldown: float = 30.0, clock: Callable[[], float] = time.monotonic, group_provenance: GroupProvenance | None = None, ) -> None: self.group_provenance = group_provenance self.base_url = base_url.rstrip("/") self.admin_password = admin_password self.opener = opener self.auth_rejected_cooldown = auth_rejected_cooldown self.clock = clock self._auth_rejected_until = 0.0 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 tenant_access(self, payload: dict[str, Any]) -> Result: """Change only the two role groups owned by the specified tenant.""" _required(payload, "external_subject", "tenant", "idempotency_key", "correlation_id") tenant = str(payload["tenant"]) if not re.fullmatch(r"tenant:[a-z0-9][a-z0-9._-]*(?::[a-z0-9][a-z0-9._-]*)*", tenant): raise ValueError("invalid tenant identifier") if tenant == "tenant:platform:root": raise ValueError("platform root is not a tenant access target") if not isinstance(payload.get("enabled"), bool): raise ValueError("enabled must be boolean") roles = payload.get("roles", []) if not isinstance(roles, (list, tuple)) or any(r not in {"user", "tenant-admin"} for r in roles): raise ValueError("unsupported tenant role") subject = _directory_username(str(payload["external_subject"])) if not re.fullmatch(r"[A-Za-z0-9._-]+", subject): raise ValueError("invalid directory subject") token = self._login() user = self._user(token, subject) if user is None: raise ValueError("login identity not found; create it before changing access") managed = {f"{tenant}:users", f"{tenant}:admins"} desired = {f"{tenant}:users"} if payload["enabled"] else set() if payload["enabled"] and "tenant-admin" in roles: desired.add(f"{tenant}:admins") current = {str(g["displayName"]): int(g["id"]) for g in user.get("groups", ())} groups = list(self._directory(token)[1]) for name in sorted(desired - current.keys()): self._add_group(token, subject, self._ensure_group(token, groups, name)) for name in sorted((managed & current.keys()) - desired): self._remove_group(token, subject, current[name]) checked = self._user(token, subject) if checked is None or ({g["displayName"] for g in checked.get("groups", ())} & managed) != desired: raise RuntimeError("tenant access readback did not confirm the requested state") return Result("netkingdom-lldap", _oidc_subject(subject), "tenant_active" if payload["enabled"] else "tenant_disabled", False) 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 preflight(self) -> dict[str, str]: """One login plus one directory read. Auth rejection is not retried during cooldown.""" now = self.clock() if now < self._auth_rejected_until: raise DependencyFailure("auth_rejected") try: token = self._login(timeout=3) self._gql(token, "query { groups { id } }", {}, timeout=3) except DependencyFailure as exc: if exc.code == "auth_rejected": self._auth_rejected_until = now + self.auth_rejected_cooldown raise if self.group_provenance is not None: self.group_provenance.check() return {"status": "ready", "dependency": "directory", "service_groups": "available" if self.group_provenance is not None else "disabled"} def _login(self, timeout: float = 10) -> str: return directory_login( base_url=self.base_url, admin_password=self.admin_password, opener=self.opener, timeout=timeout, ) def _group_store(self) -> GroupProvenance: store = self.group_provenance if store is None: raise GroupProvenanceUnavailable() store.check() return store @staticmethod def _group_binding(groups, members, correlation_id) -> str: # Original correlation is part of the exact ensure binding. canonical = {"ensure_correlation_id": correlation_id, "groups": sorted(groups), "members": {g: sorted(members.get(g, [])) for g in sorted(groups)}} return hashlib.sha256(json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode()).hexdigest() @staticmethod def _group_result(receipt, *, resumed=False) -> GroupResult: status = "groups_ensured" if receipt["state"] == "ensured" else ( "groups_revoked_and_deleted" if receipt.get("deleted") else "groups_revoked") return GroupResult("netkingdom-lldap", ",".join(receipt["requested_groups"]), status, resumed, receipt["key"], receipt["ensure_correlation_id"], tuple(receipt["created_groups"]), tuple(receipt["created_memberships"])) def ensure_service_groups(self, payload: dict[str, Any]) -> GroupResult: """Additive ensure with durable creation provenance, not new authority. Replays return the original result, never recreate removed state. Pending or ambiguous calls survive restart and require independent owner recovery. """ groups, members = _service_group_request(payload) store = self._group_store() receipt, fresh = store.reserve(payload["idempotency_key"], self._group_binding(groups, members, payload["correlation_id"])) if not fresh: if receipt["state"] != "ensured": raise RuntimeError("group provenance unresolved or already rolled back") return self._group_result(receipt, resumed=True) attempted = False try: token = self._login() users, directory_groups = self._directory(token) known = {str(u.get("id")) for u in users} if {u for us in members.values() for u in us} - known: raise ValueError("unknown directory users") ids = {str(g["displayName"]): int(g["id"]) for g in directory_groups} receipt["requested_groups"] = sorted(groups) receipt["ensure_correlation_id"] = payload["correlation_id"] # Complete prestate is persisted before the first directory mutation. for name in groups: gid = ids.get(name) before = sorted(self._group_members(token, gid)) if gid is not None else [] if len(before) + sum(len(g["before"]) for g in receipt["groups"].values()) > 4096: raise ValueError("group prestate exceeds bounded provenance capacity") receipt["groups"][name] = {"id": gid, "before": before, "after": sorted(set(before) | set(members.get(name, [])))} store.save(receipt) for name in groups: state = receipt["groups"][name] if state["id"] is None: receipt["intent"] = {"operation": "create_group", "name": name} store.save(receipt) attempted = True gid = self._ensure_group(token, directory_groups, name) state["id"] = gid # A successful creation response plus readback is needed. checked = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]} if checked.get(name) != gid or self._group_members(token, gid): raise RuntimeError("created group readback differs") receipt["created_groups"].append({"name": name, "id": gid}) receipt["intent"] = None store.save(receipt) for username in members.get(name, []): if username in state["before"]: continue receipt["intent"] = {"operation": "add_member", "name": name, "id": state["id"], "username": username} store.save(receipt) # Unlike the lifecycle helper, duplicate/racing additions are # ambiguous, never accepted as proof that this attempt created it. attempted = True result = self._gql(token, "mutation Add($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }", {"userId": username, "groupId": state["id"]}) if result.get("addUserToGroup", {}).get("ok") is not True or username not in self._group_members(token, state["id"]): raise RuntimeError("membership creation did not read back") receipt["created_memberships"].append({"name": name, "id": state["id"], "username": username}) receipt["intent"] = None store.save(receipt) self._check_group_postimage(token, receipt) receipt["state"] = "ensured" store.save(receipt) except Exception: receipt["state"] = "uncertain" if attempted else "refused" receipt["failure_phase"] = "mutation_attempted" if attempted else "before_mutation" store.save(receipt) raise return self._group_result(receipt) def _check_group_postimage(self, token, receipt) -> None: ids = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]} for name, state in receipt["groups"].items(): if ids.get(name) != state["id"] or sorted(self._group_members(token, state["id"])) != state["after"]: raise RuntimeError("group postimage changed; independent owner reconciliation required") def revoke_service_groups(self, payload: dict[str, Any]) -> GroupResult: """Reverse confirmed creations from the owner journal only. Caller-supplied creation inventories confer no rights. This protects preexisting state and refuses detected drift; native LLDAP has no CAS or membership-generation token, so external writer isolation is still an owner acceptance prerequisite (readback alone cannot exclude ABA). """ groups, members = _service_group_request(payload) _required(payload, "ensure_idempotency_key", "ensure_correlation_id") delete_if_empty = payload.get("delete_if_empty", False) if not isinstance(delete_if_empty, bool): raise ValueError("delete_if_empty must be true or false") store = self._group_store() receipt = store.claim_revoke(payload["ensure_idempotency_key"], self._group_binding(groups, members, payload["ensure_correlation_id"]), delete_if_empty) if receipt["state"] == "revoked": return self._group_result(receipt, resumed=True) try: token = self._login() # Validate the entire operation before the first destructive call. self._check_group_postimage(token, receipt) for edge in receipt["created_memberships"]: receipt["intent"] = {"operation": "remove_member", **edge} store.save(receipt) self._remove_group(token, edge["username"], edge["id"]) expected = receipt["groups"][edge["name"]]["after"] expected.remove(edge["username"]) self._check_group_postimage(token, receipt) receipt["intent"] = None store.save(receipt) receipt["deleted"] = [] if delete_if_empty: for group in receipt["created_groups"]: # Only this attempt's groups, never a preexisting empty group. self._check_group_postimage(token, receipt) if self._group_members(token, group["id"]): raise RuntimeError("created group is no longer empty") receipt["intent"] = {"operation": "delete_group", **group} store.save(receipt) self._gql(token, "mutation Del($id: Int!) { deleteGroup(groupId: $id) { ok } }", {"id": group["id"]}) if any(int(g["id"]) == group["id"] or g["displayName"] == group["name"] for g in self._directory(token)[1]): raise RuntimeError("group deletion did not read back") receipt["deleted"].append(group["name"]) del receipt["groups"][group["name"]] receipt["intent"] = None store.save(receipt) receipt["state"] = "revoked" store.save(receipt) except Exception: receipt["state"] = "uncertain" store.save(receipt) raise return self._group_result(receipt) def _group_members(self, token: str, group_id: int) -> list[str]: value = self._gql(token, "query G($id: Int!) { group(groupId: $id) { users { id } } }", {"id": group_id}) return [str(u["id"]) for u in (value.get("group") or {}).get("users", ())] 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], timeout: float = 15) -> 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", ) try: with self.opener(request, timeout=timeout) as response: payload = json.loads(response.read()) except HTTPError as exc: status = int(getattr(exc, "code", 0) or 0) _discard(exc) if status in {401, 403}: raise DependencyFailure("auth_rejected") from None raise DependencyFailure("protocol_error") from None except (TimeoutError, URLError, OSError): raise DependencyFailure("unreachable") from None except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError): raise DependencyFailure("protocol_error") from None if payload.get("errors"): raise ValueError(str(payload["errors"][0].get("message", "LLDAP GraphQL error"))) return dict(payload.get("data") or {}) def directory_login( *, base_url: str, admin_password: str, opener: Callable = urlopen, timeout: float = 10, ) -> str: request = Request( base_url.rstrip("/") + "/auth/simple/login", data=json.dumps({"username": "admin", "password": admin_password}).encode(), headers={"Content-Type": "application/json"}, method="POST", ) try: with opener(request, timeout=timeout) as response: payload = json.loads(response.read()) except HTTPError as exc: status = int(getattr(exc, "code", 0) or 0) _discard(exc) if status in {401, 403}: raise DependencyFailure("auth_rejected") from None raise DependencyFailure("protocol_error") from None except (TimeoutError, URLError, OSError): raise DependencyFailure("unreachable") from None except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError): raise DependencyFailure("protocol_error") from None if not isinstance(payload, dict): raise DependencyFailure("protocol_error") token = str(payload.get("token") or "") if not token: raise DependencyFailure("protocol_error") return token def _discard(exc: BaseException) -> None: read = getattr(exc, "read", None) if callable(read): try: read(65536) except Exception: pass SERVICE_GROUP = re.compile(r"[a-z][a-z0-9-]{1,62}") RESERVED_GROUP_PREFIXES = ("tenant", "netkingdom-", "net-kingdom-", "lldap_") DIRECTORY_USERNAME = re.compile(r"[a-z][a-z0-9._-]{0,31}") def _service_group_request(payload: dict[str, Any]) -> tuple[list[str], dict[str, list[str]]]: """Validate a service-access group request before anything is changed. Service-access groups gate sign-in to an internal service (SecurityCaseType oidc-browser-relying-party). Tenant, platform identity/administration, platform-suspension and LLDAP admin groups are reserved and can never be touched through this path. """ if not isinstance(payload, dict): raise ValueError("service group request must be an object") _required(payload, "groups", "idempotency_key", "correlation_id") allowed = {"groups", "members", "idempotency_key", "correlation_id", "delete_if_empty", "ensure_idempotency_key", "ensure_correlation_id"} if set(payload) - allowed: raise ValueError("unsupported service group request fields") if not isinstance(payload["idempotency_key"], str) or len(payload["idempotency_key"]) > 256: raise ValueError("invalid idempotency key") if "ensure_idempotency_key" in payload and (not isinstance(payload["ensure_idempotency_key"], str) or not 16 <= len(payload["ensure_idempotency_key"]) <= 256): raise ValueError("invalid ensure idempotency key") for key in ("correlation_id", "ensure_correlation_id"): if key in payload and (not isinstance(payload[key], str) or not 1 <= len(payload[key]) <= 256): raise ValueError("invalid correlation identifier") groups = payload["groups"] if not isinstance(groups, list) or not groups or len(groups) > 16: raise ValueError("groups must be a list of 1 to 16 names") for name in groups: if not isinstance(name, str) or not SERVICE_GROUP.fullmatch(name): raise ValueError(f"invalid service group name {name!r}") if name.startswith(RESERVED_GROUP_PREFIXES): raise ValueError(f"group {name!r} is reserved") members = payload.get("members", {}) if not isinstance(members, dict): raise ValueError("members must map group names to usernames") total_members = 0 for group, users in members.items(): if group not in groups: raise ValueError(f"members names group {group!r}, which is not in groups") if not isinstance(users, list) or not all(isinstance(u, str) and DIRECTORY_USERNAME.fullmatch(u) for u in users): raise ValueError(f"members of {group!r} must be directory usernames") total_members += len(users) if total_members > 256: raise ValueError("at most 256 service memberships per request") return list(dict.fromkeys(groups)), {g: list(dict.fromkeys(u)) for g, u in members.items()} def dispatch( provisioner: LLDAPProvisioner, path: str, payload: dict[str, Any] ) -> Result | DriftResult: if path == "/v1/groups/ensure": return provisioner.ensure_service_groups(payload) if path == "/v1/groups/revoke": return provisioner.revoke_service_groups(payload) if path == "/v1/identities/tenant-access": return provisioner.tenant_access(payload) 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"