Add scoped LLDAP identity provisioner
This commit is contained in:
parent
86eed20012
commit
ba07dd2acb
7 changed files with 377 additions and 0 deletions
155
identity-provisioner/provisioner.py
Normal file
155
identity-provisioner/provisioner.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue