diff --git a/src/user_engine/adapters/__init__.py b/src/user_engine/adapters/__init__.py index c3b24ae..f8d4ff9 100644 --- a/src/user_engine/adapters/__init__.py +++ b/src/user_engine/adapters/__init__.py @@ -6,10 +6,12 @@ from user_engine.adapters.local import ( ) from user_engine.adapters.postgres import PostgresUserEngineStore from user_engine.adapters.claims import VerifiedIdentityClaimsAdapter +from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter __all__ = [ "InMemoryUserEngineStore", "LocalAuthorizationCheckPort", "PostgresUserEngineStore", "VerifiedIdentityClaimsAdapter", + "HTTPIdentityProvisioningAdapter", ] diff --git a/src/user_engine/adapters/provisioning.py b/src/user_engine/adapters/provisioning.py new file mode 100644 index 0000000..1df4225 --- /dev/null +++ b/src/user_engine/adapters/provisioning.py @@ -0,0 +1,67 @@ +"""HTTP adapter for the NetKingdom identity provisioning service.""" + +from __future__ import annotations + +import json +from typing import Any +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +from user_engine.ports import ProvisioningRequest, ProvisioningResult + + +class HTTPIdentityProvisioningAdapter: + def __init__(self, *, base_url: str, bearer_token: str, timeout: float = 10) -> None: + self.base_url = base_url.rstrip("/") + self.bearer_token = bearer_token + self.timeout = timeout + + 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) + + 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, + }) + + def _post(self, path: str, payload: dict[str, Any]) -> ProvisioningResult: + request = Request( + self.base_url + path, + data=json.dumps(payload).encode(), + headers={ + "Authorization": f"Bearer {self.bearer_token}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urlopen(request, timeout=self.timeout) as response: + result = json.loads(response.read()) + except HTTPError as exc: + message = exc.read(4096).decode("utf-8", "replace") + raise RuntimeError(f"identity provisioning failed ({exc.code}): {message}") from exc + return ProvisioningResult( + provider=str(result["provider"]), + external_subject=str(result["external_subject"]), + status=str(result["status"]), + resumed=bool(result.get("resumed", False)), + ) diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index e2978c2..2861f4d 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -54,6 +54,7 @@ class ProvisioningRequest: display_name: str | None idempotency_key: str correlation_id: str + roles: tuple[str, ...] = () @dataclass(frozen=True) diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index c296944..a60f99a 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -9,6 +9,7 @@ from user_engine.adapters import ( LocalAuthorizationCheckPort, PostgresUserEngineStore, VerifiedIdentityClaimsAdapter, + HTTPIdentityProvisioningAdapter, ) from user_engine.service import UserEngineService from user_engine.oidc import OIDCClient @@ -51,6 +52,10 @@ def create_application() -> PortalApplication: audience=_required("USER_ENGINE_OIDC_AUDIENCE"), backend_url=os.environ.get("USER_ENGINE_OIDC_BACKEND_URL"), ), + provisioning=HTTPIdentityProvisioningAdapter( + base_url=_required("USER_ENGINE_PROVISIONING_URL"), + bearer_token=_required("USER_ENGINE_PROVISIONING_TOKEN"), + ), ) diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 52223e9..964a9d6 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -20,6 +20,7 @@ from urllib.parse import parse_qs from user_engine.domain import AccountStatus from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError from user_engine.oidc import OIDCClient, cookie_value +from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest from user_engine.service import UserEngineService StartResponse = Callable[[str, list[tuple[str, str]]], Any] @@ -50,6 +51,7 @@ class PortalApplication: login_url: str, public_registration: bool = True, oidc_client: OIDCClient | None = None, + provisioning: IdentityProvisioningPort | None = None, ) -> None: if len(trusted_proxy_secret) < 24: raise ValueError("trusted proxy secret must contain at least 24 characters") @@ -58,6 +60,7 @@ class PortalApplication: self.login_url = login_url self.public_registration = public_registration self.oidc_client = oidc_client + self.provisioning = provisioning def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]: correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}" @@ -146,6 +149,69 @@ class PortalApplication: items = memberships[offset : offset + limit] payload = {"items": _jsonable(items), "offset": offset, "limit": limit, "total": len(memberships)} return self._json(start_response, "200 OK", payload, correlation_id) + if path.startswith("/api/v1/tenants/") and path.endswith("/users") and method == "POST": + tenant = path.split("/")[4] + self.service.resolve_tenant_context(actor, tenant) + body = self._body(environ) + user = self.service.create_user( + actor, + display_name=body.get("display_name"), + primary_email=body.get("primary_email"), + correlation_id=correlation_id, + ) + # Platform operators may create an identity for a tenant other than + # their own. Ensure the lifecycle record follows the requested + # tenant instead of only retaining the actor tenant created by the + # generic domain operation. + tenant_account = self.service.set_tenant_account_status( + actor, + user.user_id, + AccountStatus.ACTIVE, + tenant=tenant, + correlation_id=correlation_id, + ) + membership = self.service.add_membership( + actor, + user.user_id, + tenant=tenant, + scope_type="tenant", + scope_id=tenant, + kind=str(body.get("role", "user")), + correlation_id=correlation_id, + ) + return self._json(start_response, "201 Created", { + "user": _jsonable(user), + "tenant_account": _jsonable(tenant_account), + "membership": _jsonable(membership), + "provisioning_status": "pending", + }, correlation_id) + if path.startswith("/api/v1/tenants/") and path.endswith("/provision") and method == "POST": + if self.provisioning is None: + raise ValidationError("identity provisioning is unavailable") + parts = path.split("/") + tenant, user_id = parts[4], parts[6] + self.service.resolve_tenant_context(actor, tenant) + user = self.service.store.user(user_id) + if user is None: + raise NotFoundError("user not found") + idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", "")) + if len(idempotency_key) < 16: + raise ValidationError("Idempotency-Key must contain at least 16 characters") + result = self.provisioning.provision(ProvisioningRequest( + user_id=user.user_id, + tenant=tenant, + primary_email=user.primary_email, + display_name=user.display_name, + idempotency_key=idempotency_key, + correlation_id=correlation_id, + roles=tuple( + membership.kind + for membership in self.service.store.memberships_for_user( + user.user_id, tenant=tenant + ) + ), + )) + return self._json(start_response, "200 OK", _jsonable(result), correlation_id) if path.startswith("/api/v1/tenants/") and "/users/" in path and method == "PATCH": parts = path.split("/") tenant, user_id = parts[4], parts[6] diff --git a/tests/test_provisioning_adapter.py b/tests/test_provisioning_adapter.py new file mode 100644 index 0000000..12852ea --- /dev/null +++ b/tests/test_provisioning_adapter.py @@ -0,0 +1,38 @@ +import io +import json +import unittest +from unittest.mock import patch + +from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter +from user_engine.ports import ProvisioningRequest + + +class Response(io.BytesIO): + def __enter__(self): return self + def __exit__(self, *args): return None + + +class ProvisioningAdapterTests(unittest.TestCase): + @patch("user_engine.adapters.provisioning.urlopen") + def test_provision_transports_idempotency_without_provider_leakage(self, opener): + opener.return_value = Response(json.dumps({ + "provider": "netkingdom-lldap", + "external_subject": "person-1", + "status": "password_setup_required", + "resumed": True, + }).encode()) + adapter = HTTPIdentityProvisioningAdapter( + base_url="http://provisioner", bearer_token="secret" + ) + result = adapter.provision(ProvisioningRequest( + user_id="person-1", + tenant="tenant:friendly:binky", + primary_email="person@example.test", + display_name="Person", + idempotency_key="idem-1234567890123456", + correlation_id="corr-1", + )) + self.assertTrue(result.resumed) + request = opener.call_args.args[0] + self.assertEqual("Bearer secret", request.headers["Authorization"]) + self.assertIn(b"idem-1234567890123456", request.data)