net-kingdom/identity-provisioner/provisioner.py
tegwick 4f65e41ac5
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Identity provider journey acceptance / provider (push) Failing after 20s
Build and Publish identity-provisioner / build-and-push (push) Successful in 8s
fix(identity): retain group provenance and prepare sealed recovery custody
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa
2026-10-17 00:28:17 +02:00

674 lines
31 KiB
Python

"""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"