2026-07-28 00:57:04 +02:00
|
|
|
"""HTTP adapter for the NetKingdom identity provisioning service."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
2026-07-29 23:41:47 +02:00
|
|
|
import time
|
2026-07-28 00:57:04 +02:00
|
|
|
from typing import Any
|
2026-07-29 23:41:47 +02:00
|
|
|
from urllib.error import HTTPError, URLError
|
2026-07-28 00:57:04 +02:00
|
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
2026-07-29 23:41:47 +02:00
|
|
|
from user_engine.ports import IdentityDriftResult, ProvisioningRequest, ProvisioningResult
|
2026-07-28 00:57:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class HTTPIdentityProvisioningAdapter:
|
2026-07-29 23:41:47 +02:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
base_url: str,
|
|
|
|
|
bearer_token: str,
|
|
|
|
|
timeout: float = 10,
|
|
|
|
|
retry_delay: float = 0.1,
|
|
|
|
|
) -> None:
|
2026-07-28 00:57:04 +02:00
|
|
|
self.base_url = base_url.rstrip("/")
|
2026-07-28 01:04:05 +02:00
|
|
|
self.bearer_token = bearer_token.strip()
|
|
|
|
|
if not self.bearer_token:
|
|
|
|
|
raise ValueError("bearer token must not be empty")
|
2026-07-28 00:57:04 +02:00
|
|
|
self.timeout = timeout
|
2026-07-29 23:41:47 +02:00
|
|
|
self.retry_delay = retry_delay
|
2026-07-28 00:57:04 +02:00
|
|
|
|
|
|
|
|
def provision(self, request: ProvisioningRequest) -> ProvisioningResult:
|
|
|
|
|
return self._post("/v1/identities/provision", {
|
|
|
|
|
"user_id": request.user_id,
|
|
|
|
|
"tenant": request.tenant,
|
|
|
|
|
"primary_email": request.primary_email,
|
|
|
|
|
"display_name": request.display_name,
|
|
|
|
|
"idempotency_key": request.idempotency_key,
|
|
|
|
|
"correlation_id": request.correlation_id,
|
|
|
|
|
"roles": request.roles,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
|
|
|
|
|
return self._lifecycle("suspend", external_subject, idempotency_key, correlation_id)
|
|
|
|
|
|
|
|
|
|
def reactivate(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
|
|
|
|
|
return self._lifecycle("reactivate", external_subject, idempotency_key, correlation_id)
|
|
|
|
|
|
|
|
|
|
def deprovision(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
|
|
|
|
|
return self._lifecycle("deprovision", external_subject, idempotency_key, correlation_id)
|
|
|
|
|
|
2026-07-29 23:41:47 +02:00
|
|
|
def drift(
|
|
|
|
|
self,
|
|
|
|
|
request: ProvisioningRequest,
|
|
|
|
|
*,
|
|
|
|
|
external_subject: str,
|
|
|
|
|
desired_status: str = "active",
|
|
|
|
|
) -> IdentityDriftResult:
|
|
|
|
|
return self._desired("drift", request, external_subject, desired_status)
|
|
|
|
|
|
|
|
|
|
def reconcile(
|
|
|
|
|
self,
|
|
|
|
|
request: ProvisioningRequest,
|
|
|
|
|
*,
|
|
|
|
|
external_subject: str,
|
|
|
|
|
desired_status: str = "active",
|
|
|
|
|
) -> IdentityDriftResult:
|
|
|
|
|
return self._desired("reconcile", request, external_subject, desired_status)
|
|
|
|
|
|
2026-07-28 00:57:04 +02:00
|
|
|
def _lifecycle(self, action: str, subject: str, key: str, correlation_id: str) -> ProvisioningResult:
|
|
|
|
|
return self._post(f"/v1/identities/{action}", {
|
|
|
|
|
"external_subject": subject,
|
|
|
|
|
"idempotency_key": key,
|
|
|
|
|
"correlation_id": correlation_id,
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-29 23:41:47 +02:00
|
|
|
def _desired(
|
|
|
|
|
self,
|
|
|
|
|
action: str,
|
|
|
|
|
request: ProvisioningRequest,
|
|
|
|
|
external_subject: str,
|
|
|
|
|
desired_status: str,
|
|
|
|
|
) -> IdentityDriftResult:
|
|
|
|
|
result = self._request(f"/v1/identities/{action}", {
|
|
|
|
|
"external_subject": external_subject,
|
|
|
|
|
"user_id": request.user_id,
|
|
|
|
|
"tenant": request.tenant,
|
|
|
|
|
"primary_email": request.primary_email,
|
|
|
|
|
"display_name": request.display_name,
|
|
|
|
|
"idempotency_key": request.idempotency_key,
|
|
|
|
|
"correlation_id": request.correlation_id,
|
|
|
|
|
"roles": request.roles,
|
|
|
|
|
"desired_status": desired_status,
|
|
|
|
|
})
|
|
|
|
|
return IdentityDriftResult(
|
|
|
|
|
provider=str(result["provider"]),
|
|
|
|
|
external_subject=str(result["external_subject"]),
|
|
|
|
|
status=str(result["status"]),
|
|
|
|
|
drift=tuple(str(item) for item in result.get("drift", ())),
|
|
|
|
|
changed=tuple(str(item) for item in result.get("changed", ())),
|
2026-07-28 00:57:04 +02:00
|
|
|
)
|
2026-07-29 23:41:47 +02:00
|
|
|
|
|
|
|
|
def _post(self, path: str, payload: dict[str, Any]) -> ProvisioningResult:
|
|
|
|
|
result = self._request(path, payload)
|
2026-07-28 00:57:04 +02:00
|
|
|
return ProvisioningResult(
|
|
|
|
|
provider=str(result["provider"]),
|
|
|
|
|
external_subject=str(result["external_subject"]),
|
|
|
|
|
status=str(result["status"]),
|
|
|
|
|
resumed=bool(result.get("resumed", False)),
|
2026-07-28 17:30:17 +02:00
|
|
|
password_setup_url=(
|
|
|
|
|
str(result["password_setup_url"])
|
|
|
|
|
if result.get("password_setup_url")
|
|
|
|
|
else None
|
|
|
|
|
),
|
2026-07-28 00:57:04 +02:00
|
|
|
)
|
2026-07-29 23:41:47 +02:00
|
|
|
|
|
|
|
|
def _request(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
request = Request(
|
|
|
|
|
self.base_url + path,
|
|
|
|
|
data=json.dumps(payload).encode(),
|
|
|
|
|
headers={
|
|
|
|
|
"Authorization": f"Bearer {self.bearer_token}",
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
},
|
|
|
|
|
method="POST",
|
|
|
|
|
)
|
|
|
|
|
for attempt in range(2):
|
|
|
|
|
try:
|
|
|
|
|
with urlopen(request, timeout=self.timeout) as response:
|
|
|
|
|
result = json.loads(response.read())
|
|
|
|
|
break
|
|
|
|
|
except HTTPError as exc:
|
|
|
|
|
if exc.code not in {502, 503, 504} or attempt:
|
|
|
|
|
message = exc.read(4096).decode("utf-8", "replace")
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"identity provisioning failed ({exc.code}): {message}"
|
|
|
|
|
) from exc
|
|
|
|
|
except URLError as exc:
|
|
|
|
|
if attempt:
|
|
|
|
|
raise RuntimeError("identity provisioning dependency unavailable") from exc
|
|
|
|
|
time.sleep(self.retry_delay)
|
|
|
|
|
return dict(result)
|