Add reusable identity provisioning adapter
This commit is contained in:
parent
268b3156f9
commit
e7e8709ca8
6 changed files with 179 additions and 0 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue