Some checks failed
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa
674 lines
31 KiB
Python
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"
|