Add reusable identity provisioning adapter
This commit is contained in:
parent
268b3156f9
commit
e7e8709ca8
6 changed files with 179 additions and 0 deletions
|
|
@ -6,10 +6,12 @@ from user_engine.adapters.local import (
|
||||||
)
|
)
|
||||||
from user_engine.adapters.postgres import PostgresUserEngineStore
|
from user_engine.adapters.postgres import PostgresUserEngineStore
|
||||||
from user_engine.adapters.claims import VerifiedIdentityClaimsAdapter
|
from user_engine.adapters.claims import VerifiedIdentityClaimsAdapter
|
||||||
|
from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"InMemoryUserEngineStore",
|
"InMemoryUserEngineStore",
|
||||||
"LocalAuthorizationCheckPort",
|
"LocalAuthorizationCheckPort",
|
||||||
"PostgresUserEngineStore",
|
"PostgresUserEngineStore",
|
||||||
"VerifiedIdentityClaimsAdapter",
|
"VerifiedIdentityClaimsAdapter",
|
||||||
|
"HTTPIdentityProvisioningAdapter",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
67
src/user_engine/adapters/provisioning.py
Normal file
67
src/user_engine/adapters/provisioning.py
Normal file
|
|
@ -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)),
|
||||||
|
)
|
||||||
|
|
@ -54,6 +54,7 @@ class ProvisioningRequest:
|
||||||
display_name: str | None
|
display_name: str | None
|
||||||
idempotency_key: str
|
idempotency_key: str
|
||||||
correlation_id: str
|
correlation_id: str
|
||||||
|
roles: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from user_engine.adapters import (
|
||||||
LocalAuthorizationCheckPort,
|
LocalAuthorizationCheckPort,
|
||||||
PostgresUserEngineStore,
|
PostgresUserEngineStore,
|
||||||
VerifiedIdentityClaimsAdapter,
|
VerifiedIdentityClaimsAdapter,
|
||||||
|
HTTPIdentityProvisioningAdapter,
|
||||||
)
|
)
|
||||||
from user_engine.service import UserEngineService
|
from user_engine.service import UserEngineService
|
||||||
from user_engine.oidc import OIDCClient
|
from user_engine.oidc import OIDCClient
|
||||||
|
|
@ -51,6 +52,10 @@ def create_application() -> PortalApplication:
|
||||||
audience=_required("USER_ENGINE_OIDC_AUDIENCE"),
|
audience=_required("USER_ENGINE_OIDC_AUDIENCE"),
|
||||||
backend_url=os.environ.get("USER_ENGINE_OIDC_BACKEND_URL"),
|
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"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from urllib.parse import parse_qs
|
||||||
from user_engine.domain import AccountStatus
|
from user_engine.domain import AccountStatus
|
||||||
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
||||||
from user_engine.oidc import OIDCClient, cookie_value
|
from user_engine.oidc import OIDCClient, cookie_value
|
||||||
|
from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest
|
||||||
from user_engine.service import UserEngineService
|
from user_engine.service import UserEngineService
|
||||||
|
|
||||||
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
||||||
|
|
@ -50,6 +51,7 @@ class PortalApplication:
|
||||||
login_url: str,
|
login_url: str,
|
||||||
public_registration: bool = True,
|
public_registration: bool = True,
|
||||||
oidc_client: OIDCClient | None = None,
|
oidc_client: OIDCClient | None = None,
|
||||||
|
provisioning: IdentityProvisioningPort | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if len(trusted_proxy_secret) < 24:
|
if len(trusted_proxy_secret) < 24:
|
||||||
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
||||||
|
|
@ -58,6 +60,7 @@ class PortalApplication:
|
||||||
self.login_url = login_url
|
self.login_url = login_url
|
||||||
self.public_registration = public_registration
|
self.public_registration = public_registration
|
||||||
self.oidc_client = oidc_client
|
self.oidc_client = oidc_client
|
||||||
|
self.provisioning = provisioning
|
||||||
|
|
||||||
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
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)}"
|
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]
|
items = memberships[offset : offset + limit]
|
||||||
payload = {"items": _jsonable(items), "offset": offset, "limit": limit, "total": len(memberships)}
|
payload = {"items": _jsonable(items), "offset": offset, "limit": limit, "total": len(memberships)}
|
||||||
return self._json(start_response, "200 OK", payload, correlation_id)
|
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":
|
if path.startswith("/api/v1/tenants/") and "/users/" in path and method == "PATCH":
|
||||||
parts = path.split("/")
|
parts = path.split("/")
|
||||||
tenant, user_id = parts[4], parts[6]
|
tenant, user_id = parts[4], parts[6]
|
||||||
|
|
|
||||||
38
tests/test_provisioning_adapter.py
Normal file
38
tests/test_provisioning_adapter.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue