"""Dependency-free WSGI transport for the user-engine portal. Authentication is deliberately delegated to KeyCape (or another OIDC-aware edge). The application accepts claims only when the edge presents a shared authentication marker configured at process start. This keeps passwords, MFA material, provider administration credentials, and browser sessions out of user-engine. """ from __future__ import annotations from dataclasses import asdict, is_dataclass from enum import Enum from html import escape import json import re import secrets from typing import Any, Callable, Iterable, Mapping from urllib.parse import parse_qs, urlencode, urlsplit from user_engine.domain import ( AccountStatus, Actor, FactorVerification, FamilyMemberSpec, IdentityFactorType, PrincipalType, ) from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError from user_engine.oidc import OIDCClient, cookie_value from user_engine.ports import ( IdentityProvisioningPort, ProvisioningRequest, RegistrationVerificationPort, RegistrationVerificationRequest, TenantManagementPort, ) from user_engine.service import PLATFORM_TENANT, UserEngineService StartResponse = Callable[[str, list[tuple[str, str]]], Any] def _jsonable(value: Any) -> Any: if is_dataclass(value): return {key: _jsonable(item) for key, item in asdict(value).items()} if isinstance(value, Enum): return value.value if isinstance(value, Mapping): return {str(key): _jsonable(item) for key, item in value.items()} if isinstance(value, (tuple, list)): return [_jsonable(item) for item in value] if hasattr(value, "isoformat"): return value.isoformat() return value class PortalApplication: """Small, auditable HTTP adapter over :class:`UserEngineService`.""" def __init__( self, service: UserEngineService, *, trusted_proxy_secret: str, login_url: str, public_registration: bool = True, oidc_client: OIDCClient | None = None, provisioning: IdentityProvisioningPort | None = None, tenant_management: TenantManagementPort | None = None, outbox_delivery: Callable[[Any], None] | None = None, registration_verification: RegistrationVerificationPort | None = None, registration_clients: tuple[str, ...] = (), registration_tenants: tuple[str, ...] = (), registration_oidc_issuer: str = "", registration_password_setup_origins: tuple[str, ...] = (), ) -> None: if len(trusted_proxy_secret) < 24: raise ValueError("trusted proxy secret must contain at least 24 characters") self.service = service self.trusted_proxy_secret = trusted_proxy_secret self.login_url = login_url self.public_registration = public_registration self.oidc_client = oidc_client self.provisioning = provisioning self.tenant_management = tenant_management self.outbox_delivery = outbox_delivery self.registration_verification = registration_verification self.registration_clients = frozenset(registration_clients) self.registration_tenants = frozenset(registration_tenants) self.registration_oidc_issuer = registration_oidc_issuer.rstrip("/") self.registration_password_setup_origins = frozenset( origin.rstrip("/") for origin in registration_password_setup_origins ) 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)}" try: return self._dispatch(environ, start_response, str(correlation_id)) except ConflictError as exc: return self._error(start_response, "409 Conflict", "conflict", str(exc), correlation_id) except (ValidationError, ValueError) as exc: return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id) except RuntimeError: return self._error( start_response, "502 Bad Gateway", "provisioning_unavailable", "Identity provisioning is temporarily unavailable.", correlation_id, ) except AuthorizationDenied: return self._error(start_response, "403 Forbidden", "access_denied", "Access denied.", correlation_id) except NotFoundError: return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id) except (json.JSONDecodeError, UnicodeDecodeError): return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id) def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]: method = str(environ.get("REQUEST_METHOD", "GET")).upper() path = str(environ.get("PATH_INFO", "/")).rstrip("/") or "/" if path == "/healthz": return self._json(start_response, "200 OK", _jsonable(self.service.health()), correlation_id) if path == "/readyz": report = self.service.readiness() return self._json(start_response, "200 OK" if report.ready else "503 Service Unavailable", _jsonable(report), correlation_id) if path == "/metrics": supplied = str(environ.get("HTTP_X_USER_ENGINE_PROXY_SECRET", "")) if not secrets.compare_digest(supplied, self.trusted_proxy_secret): raise AuthorizationDenied("metrics require the trusted workload marker") return self._metrics(start_response, correlation_id) if path in {"/login", "/oidc/start"}: query = parse_qs(str(environ.get("QUERY_STRING", ""))) tenant_hint = query.get("tenant_hint", [None])[0] if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"): raise ValidationError("tenant_hint must be a tenant identifier") location = ( self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None) if self.oidc_client else self.login_url ) start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)]) return [b""] if path == "/oidc/callback": if self.oidc_client is None: raise NotFoundError("OIDC login is not configured") query = parse_qs(str(environ.get("QUERY_STRING", ""))) if query.get("error"): raise AuthorizationDenied("OIDC login failed") session_id = self.oidc_client.complete( code=query.get("code", [""])[0], state=query.get("state", [""])[0], ) headers = [ ("Location", "/"), ("Set-Cookie", f"ue_session={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600"), *self._security_headers(correlation_id), ] start_response("303 See Other", headers) return [b""] if path == "/logout" and method == "POST": session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") if session_id and self.oidc_client: self.oidc_client.logout(session_id) start_response( "303 See Other", [("Location", "/"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)], ) return [b""] if path == "/" and method == "GET": actor = self._optional_actor(environ) return self._html(start_response, self._home(actor), correlation_id) if path == "/api/v1/public/registrations" and method == "POST": return self._start_public_registration( environ, start_response, correlation_id ) if path == "/api/v1/public/registrations/verify" and method == "POST": return self._verify_public_registration( environ, start_response, correlation_id ) actor = self._actor(environ) if path == "/api/v1/me" and method == "GET": return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id) if path == "/api/v1/me/profile" and method == "PATCH": self._idempotency_key(environ) self.service.me(self._claims(environ), correlation_id=correlation_id) body = self._body(environ) updated = self.service.update_self_service_profile( actor, display_name=str(body.get("display_name", "")), consent_accepted=bool(body.get("consent_accepted", False)), consent_version=str(body.get("consent_version") or "portal-terms-v1"), correlation_id=correlation_id, ) return self._json(start_response, "200 OK", _jsonable(updated), correlation_id) if path == "/onboarding" and method == "GET": session = self.service.me(self._claims(environ), correlation_id=correlation_id) memberships = self.service.store.memberships_for_user(session.user.user_id) journeys = self.service.store.onboarding_journeys_for_user(session.user.user_id) query = parse_qs(str(environ.get("QUERY_STRING", ""))) selected_tenant = query.get("tenant", [session.actor.tenant])[0] allowed_tenants = {item.tenant for item in memberships} | {session.actor.tenant} if selected_tenant not in allowed_tenants: raise AuthorizationDenied("tenant selection is not a membership") return self._html( start_response, self._onboarding( session, memberships, journeys, str(selected_tenant), self._csrf_token(environ), ), correlation_id, ) if path == "/onboarding/profile" and method == "POST": body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) self.service.me(self._claims(environ), correlation_id=correlation_id) self.service.update_self_service_profile( actor, display_name=str(body.get("display_name", "")), consent_accepted=body.get("consent_accepted") == "yes", consent_version="portal-terms-v1", correlation_id=correlation_id, ) return self._redirect(start_response, "/onboarding", correlation_id) if path.startswith("/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST": body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) parts = path.split("/") journey_id, step_key = parts[2], parts[4] session = self.service.me(self._claims(environ), correlation_id=correlation_id) journey = self.service.store.onboarding_journey(journey_id) if journey is None or journey.user_id != session.user.user_id: raise NotFoundError("onboarding journey not found") step = next((item for item in journey.steps if item.step_key == step_key), None) if step is None: raise NotFoundError("onboarding step not found") if step.subsystem != "user-engine" or step.handoff is not None: raise AuthorizationDenied("subsystem-owned steps require their handoff") self.service.complete_onboarding_step( actor, journey_id, step_key, correlation_id=correlation_id ) return self._redirect(start_response, "/onboarding", correlation_id) if path.startswith("/invitations/") and method == "GET": invitation = self.service.store.family_invitation(path.split("/")[2]) if invitation is None: raise NotFoundError("invitation not found") self.service.resolve_tenant_context(actor, invitation.tenant) return self._html( start_response, self._invitation_acceptance(invitation, self._csrf_token(environ)), correlation_id, ) if path.startswith("/invitations/") and method == "POST": body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) self.service.accept_family_invitation( self._claims(environ), path.split("/")[2], correlation_id=correlation_id ) return self._redirect(start_response, "/onboarding", correlation_id) if path == "/api/v1/platform/tenants" and method == "POST": self.service.resolve_tenant_context(actor, PLATFORM_TENANT) if self.tenant_management is None: raise ValidationError("tenant management is unavailable") idempotency_key = self._idempotency_key(environ) body = self._body(environ) tenant = str(body.get("tenant") or "") if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT: raise ValidationError("a non-platform tenant identifier is required") result = self.tenant_management.create_tenant( tenant=tenant, display_name=str(body.get("display_name") or tenant), idempotency_key=idempotency_key, correlation_id=correlation_id, ) admin = body.get("first_admin") bootstrap = None if admin is not None: if not isinstance(admin, Mapping): raise ValidationError("first_admin must be an object") user = self.service.create_user( actor, display_name=admin.get("display_name"), primary_email=admin.get("primary_email"), correlation_id=correlation_id, ) account = self.service.set_tenant_account_status( actor, user.user_id, AccountStatus.INVITED, tenant=tenant, correlation_id=correlation_id, ) membership = self.service.add_membership( actor, user.user_id, tenant=tenant, scope_type="tenant", scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id, ) bootstrap = {"user": user, "tenant_account": account, "membership": membership} return self._json(start_response, "201 Created", { "tenant": _jsonable(result), "first_admin": _jsonable(bootstrap), }, correlation_id) if path == "/api/v1/platform/outbox/deliver" and method == "POST": self.service.resolve_tenant_context(actor, PLATFORM_TENANT) if self.outbox_delivery is None: raise ValidationError("outbox delivery is unavailable") body = self._body(environ) events = self.service.deliver_outbox( actor, self.outbox_delivery, worker_id=str(body.get("worker_id") or "portal-operator"), max_attempts=int(body.get("max_attempts") or 3), correlation_id=correlation_id, ) return self._json(start_response, "200 OK", {"items": _jsonable(events)}, correlation_id) if path.startswith("/api/v1/platform/outbox/") and path.endswith("/replay") and method == "POST": self.service.resolve_tenant_context(actor, PLATFORM_TENANT) event = self.service.replay_outbox( actor, path.split("/")[5], correlation_id=correlation_id ) return self._json(start_response, "200 OK", _jsonable(event), correlation_id) if path.startswith("/api/v1/platform/tenants/") and path.endswith("/recover") and method == "POST": self.service.resolve_tenant_context(actor, PLATFORM_TENANT) if self.provisioning is None: raise ValidationError("identity provisioning is unavailable") parts = path.split("/") tenant, user_id = parts[5], parts[7] idempotency_key = self._idempotency_key(environ) user = self.service.store.user(user_id) if user is None: raise NotFoundError("user not found") request = ProvisioningRequest( user_id=user_id, tenant=tenant, primary_email=user.primary_email, display_name=user.display_name, idempotency_key=idempotency_key, correlation_id=correlation_id, roles=tuple(item.kind for item in self.service.store.memberships_for_user(user_id, tenant=tenant)), ) identity = next(iter(self.service.store.identities_for_user(user_id)), None) if identity is None: provisioned = self.provisioning.provision(request) self.service.link_identity( actor, user_id, issuer="urn:netkingdom:directory", subject=provisioned.external_subject, provider=provisioned.provider, correlation_id=correlation_id, ) recovery = {"status": provisioned.status, "changed": ("identity",)} else: reconciled = self.provisioning.reconcile( request, external_subject=identity.subject, desired_status="active" ) recovery = {"status": reconciled.status, "drift": reconciled.drift, "changed": reconciled.changed} account = self.service.set_tenant_account_status( actor, user_id, AccountStatus.ACTIVE, tenant=tenant, correlation_id=correlation_id, ) return self._json(start_response, "200 OK", { "recovery": _jsonable(recovery), "tenant_account": _jsonable(account), }, correlation_id) if path.startswith("/api/v1/invitations/") and path.endswith("/claim") and method == "POST": invitation_id = path.split("/")[4] accepted = self.service.accept_family_invitation( self._claims(environ), invitation_id, correlation_id=correlation_id ) return self._json(start_response, "200 OK", _jsonable(accepted), correlation_id) if path.startswith("/api/v1/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST": self._idempotency_key(environ) parts = path.split("/") journey_id, step_key = parts[4], parts[6] session = self.service.me(self._claims(environ), correlation_id=correlation_id) journey = self.service.store.onboarding_journey(journey_id) if journey is None or journey.user_id != session.user.user_id: raise NotFoundError("onboarding journey not found") step = next((item for item in journey.steps if item.step_key == step_key), None) if step is None: raise NotFoundError("onboarding step not found") if step.subsystem != "user-engine" or step.handoff is not None: raise AuthorizationDenied("subsystem-owned steps require their handoff") updated = self.service.complete_onboarding_step( actor, journey_id, step_key, correlation_id=correlation_id ) return self._json(start_response, "200 OK", _jsonable(updated), correlation_id) if path == "/api/v1/registrations" and method == "POST": if not self.public_registration: raise AuthorizationDenied("public registration disabled") body = self._body(environ) session = self.service.start_registration( actor, tenant=body.get("tenant"), correlation_id=correlation_id, ) return self._json(start_response, "201 Created", _jsonable(session), correlation_id) if path.startswith("/api/v1/registrations/") and path.endswith("/complete") and method == "POST": registration_id = path.split("/")[4] body = self._body(environ) result = self.service.complete_registration( actor, registration_id, display_name=body.get("display_name"), primary_email=body.get("primary_email"), correlation_id=correlation_id, ) return self._json(start_response, "200 OK", _jsonable(result), correlation_id) if path.startswith("/api/v1/tenants/") and path.endswith("/users") and method == "GET": tenant = path.split("/")[4] self.service.resolve_tenant_context(actor, tenant) memberships = self.service.store.memberships_for_tenant(tenant) offset, limit = self._page(environ) 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("/invitations"): tenant = path.split("/")[4] self.service.resolve_tenant_context(actor, tenant) if method == "GET": items = self.service.store.family_invitations_for_tenant(tenant) return self._json(start_response, "200 OK", {"items": _jsonable(items)}, correlation_id) if method == "POST": body = self._body(environ) invited = self.service.invite_family_member( actor, tenant=tenant, family_scope_id=str(body.get("scope_id") or tenant), application_id=str(body.get("application_id") or "app.user-portal"), member=FamilyMemberSpec( primary_email=str(body.get("primary_email") or ""), display_name=body.get("display_name"), role=str(body.get("role") or "user"), ), correlation_id=correlation_id, ) return self._json(start_response, "201 Created", _jsonable(invited), correlation_id) if path.startswith("/api/v1/tenants/") and "/invitations/" in path and method == "POST": parts = path.split("/") tenant, invitation_id, action = parts[4], parts[6], parts[7] self.service.resolve_tenant_context(actor, tenant) expected = self._expected_version(environ) if action == "resend": value = self.service.resend_family_invitation( actor, invitation_id, correlation_id=correlation_id, expected_version=expected, ) elif action == "expire": value = self.service.revoke_family_invitation( actor, invitation_id, correlation_id=correlation_id, expected_version=expected, ) else: raise NotFoundError("invitation action not found") return self._json(start_response, "200 OK", _jsonable(value), 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 ) ), )) identity = self.service.link_identity( actor, user.user_id, issuer="urn:netkingdom:directory", subject=result.external_subject, provider=result.provider, correlation_id=correlation_id, ) return self._json(start_response, "200 OK", { "provisioning": _jsonable(result), "identity": _jsonable(identity), }, 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] body = self._body(environ) status = AccountStatus(str(body["status"])) 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._change_status( actor, tenant, user_id, status, idempotency_key=idempotency_key, correlation_id=correlation_id, ) return self._json(start_response, "200 OK", _jsonable(result), correlation_id) if path.startswith("/api/v1/tenants/") and "/users/" in path and method == "DELETE": 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) idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", "")) if len(idempotency_key) < 16: raise ValidationError("Idempotency-Key must contain at least 16 characters") identity = next(iter(self.service.store.identities_for_user(user_id)), None) if identity is not None: self.provisioning.deprovision( external_subject=identity.subject, idempotency_key=idempotency_key, correlation_id=correlation_id, ) account = self.service.set_tenant_account_status( actor, user_id, AccountStatus.DISABLED, tenant=tenant, correlation_id=correlation_id, ) return self._json(start_response, "200 OK", { "status": "removed", "tenant_account": _jsonable(account), "provider_identity_removed": identity is not None, }, correlation_id) if path == "/platform" and method == "GET": self.service.resolve_tenant_context(actor, PLATFORM_TENANT) return self._html( start_response, self._platform(self._csrf_token(environ)), correlation_id ) if path == "/platform/tenants" and method == "POST": self.service.resolve_tenant_context(actor, PLATFORM_TENANT) if self.tenant_management is None: raise ValidationError("tenant management is unavailable") body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) tenant = str(body.get("tenant", "")) if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT: raise ValidationError("a non-platform tenant identifier is required") result = self.tenant_management.create_tenant( tenant=tenant, display_name=str(body.get("display_name") or tenant), idempotency_key=f"portal-tenant-{tenant}", correlation_id=correlation_id, ) email = str(body.get("admin_email", "")) if email: user = self.service.create_user( actor, display_name=body.get("admin_display_name"), primary_email=email, correlation_id=correlation_id, ) self.service.set_tenant_account_status( actor, user.user_id, AccountStatus.INVITED, tenant=tenant, correlation_id=correlation_id, ) self.service.add_membership( actor, user.user_id, tenant=tenant, scope_type="tenant", scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id, ) return self._html( start_response, self._platform_result(result, tenant, bool(email)), correlation_id, ) if path.startswith("/admin/") and method == "GET": tenant = path.split("/")[2] self.service.resolve_tenant_context(actor, tenant) memberships = self.service.store.memberships_for_tenant(tenant) invitations = self.service.store.family_invitations_for_tenant(tenant) diagnostics = self.service.tenant_diagnostics( actor, tenant=tenant, correlation_id=correlation_id ) return self._html( start_response, self._admin( tenant, memberships, invitations, diagnostics, "platform-operator" in actor.roles, self._csrf_token(environ), ), correlation_id, ) if path.startswith("/admin/") and method == "POST": parts = path.split("/") tenant = parts[2] self.service.resolve_tenant_context(actor, tenant) body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) if len(parts) == 4 and parts[3] == "users": user = self.service.create_user( actor, display_name=body.get("display_name"), primary_email=body.get("primary_email"), correlation_id=correlation_id, ) self.service.set_tenant_account_status( actor, user.user_id, AccountStatus.ACTIVE, tenant=tenant, correlation_id=correlation_id, ) 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._redirect(start_response, f"/admin/{tenant}", correlation_id) if len(parts) == 4 and parts[3] == "invitations": self.service.invite_family_member( actor, tenant=tenant, family_scope_id=tenant, application_id="app.user-portal", member=FamilyMemberSpec( primary_email=str(body.get("primary_email", "")), display_name=body.get("display_name"), role=str(body.get("role", "user")), ), correlation_id=correlation_id, ) return self._redirect(start_response, f"/admin/{tenant}", correlation_id) if len(parts) == 6 and parts[3] == "invitations": invitation_id, action = parts[4], parts[5] version = int(body.get("version", "0")) if action == "resend": self.service.resend_family_invitation( actor, invitation_id, expected_version=version, correlation_id=correlation_id, ) elif action == "expire": self.service.revoke_family_invitation( actor, invitation_id, expected_version=version, correlation_id=correlation_id, ) else: raise NotFoundError("invitation action not found") return self._redirect(start_response, f"/admin/{tenant}", correlation_id) if len(parts) == 6 and parts[3] == "users" and parts[5] == "provision": if self.provisioning is None: raise ValidationError("identity provisioning is unavailable") user_id = parts[4] user = self.service.store.user(user_id) if user is None: raise NotFoundError("user not found") result = self.provisioning.provision(ProvisioningRequest( user_id=user.user_id, tenant=tenant, primary_email=user.primary_email, display_name=user.display_name, idempotency_key=f"portal-{user.user_id}-{tenant}", correlation_id=correlation_id, roles=tuple( item.kind for item in self.service.store.memberships_for_user( user.user_id, tenant=tenant ) ), )) self.service.link_identity( actor, user.user_id, issuer="urn:netkingdom:directory", subject=result.external_subject, provider=result.provider, correlation_id=correlation_id, ) if result.password_setup_url: return self._html( start_response, self._password_setup_handoff( result.password_setup_url, tenant ), correlation_id, ) query = urlencode({"provisioned": user.user_id, "status": result.status}) return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id) if len(parts) == 6 and parts[3] == "users" and parts[5] == "status": status = AccountStatus(str(body.get("status", ""))) if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}: raise ValidationError("browser lifecycle supports active or suspended") self._change_status( actor, tenant, parts[4], status, idempotency_key=f"portal-status-{parts[4]}-{status.value}", correlation_id=correlation_id, ) return self._redirect(start_response, f"/admin/{tenant}", correlation_id) if len(parts) == 6 and parts[3] == "users" and parts[5] == "remove": if self.provisioning is None: raise ValidationError("identity provisioning is unavailable") user_id = parts[4] identity = next(iter(self.service.store.identities_for_user(user_id)), None) if identity is not None: self.provisioning.deprovision( external_subject=identity.subject, idempotency_key=f"portal-remove-{tenant}-{user_id}", correlation_id=correlation_id, ) self.service.set_tenant_account_status( actor, user_id, AccountStatus.DISABLED, tenant=tenant, correlation_id=correlation_id, ) return self._redirect(start_response, f"/admin/{tenant}", correlation_id) if len(parts) == 6 and parts[3] == "users" and parts[5] == "recover": self.service.resolve_tenant_context(actor, PLATFORM_TENANT) if self.provisioning is None: raise ValidationError("identity provisioning is unavailable") user_id = parts[4] user = self.service.store.user(user_id) if user is None: raise NotFoundError("user not found") request = ProvisioningRequest( user_id=user_id, tenant=tenant, primary_email=user.primary_email, display_name=user.display_name, idempotency_key=f"portal-recover-{tenant}-{user_id}", correlation_id=correlation_id, roles=tuple(item.kind for item in self.service.store.memberships_for_user(user_id, tenant=tenant)), ) identity = next(iter(self.service.store.identities_for_user(user_id)), None) if identity is None: result = self.provisioning.provision(request) self.service.link_identity( actor, user_id, issuer="urn:netkingdom:directory", subject=result.external_subject, provider=result.provider, correlation_id=correlation_id, ) else: self.provisioning.reconcile( request, external_subject=identity.subject, desired_status="active" ) self.service.set_tenant_account_status( actor, user_id, AccountStatus.ACTIVE, tenant=tenant, correlation_id=correlation_id, ) return self._redirect(start_response, f"/admin/{tenant}?recovered={user_id}", correlation_id) return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id) def _change_status( self, actor: Any, tenant: str, user_id: str, status: AccountStatus, *, idempotency_key: str, correlation_id: str, ) -> Any: if self.provisioning is None: raise ValidationError("identity provisioning is unavailable") self.service.resolve_tenant_context(actor, tenant) identity = next( ( item for item in self.service.store.identities_for_user(user_id) if item.provider == "netkingdom-lldap" ), None, ) if identity is None: raise ValidationError("user has no managed login identity") if status == AccountStatus.SUSPENDED: self.provisioning.suspend( external_subject=identity.subject, idempotency_key=idempotency_key, correlation_id=correlation_id, ) elif status == AccountStatus.ACTIVE: self.provisioning.reactivate( external_subject=identity.subject, idempotency_key=idempotency_key, correlation_id=correlation_id, ) else: raise ValidationError("provider lifecycle supports active or suspended") return self.service.set_tenant_account_status( actor, user_id, status, tenant=tenant, correlation_id=correlation_id ) def _claims(self, environ: Mapping[str, Any]) -> Mapping[str, Any]: if self.oidc_client is not None: session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") if session_id: claims = self.oidc_client.claims(session_id) if claims is not None: return claims marker = str(environ.get("HTTP_X_USER_ENGINE_PROXY_SECRET", "")) if not secrets.compare_digest(marker, self.trusted_proxy_secret): raise AuthorizationDenied("untrusted identity source") raw = environ.get("HTTP_X_VERIFIED_OIDC_CLAIMS") if not raw: raise AuthorizationDenied("verified claims required") claims = json.loads(str(raw)) if not isinstance(claims, dict): raise AuthorizationDenied("verified claims must be an object") return claims def _actor(self, environ: Mapping[str, Any]) -> Any: return self.service.identity_adapter.normalize(self._claims(environ)) def _start_public_registration( self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str, ) -> Iterable[bytes]: if not self.public_registration or self.registration_verification is None: raise NotFoundError("public registration is unavailable") body = self._body(environ) username = self._registration_username(body.get("username")) email = self._registration_email(body.get("email")) display_name = str(body.get("display_name") or "").strip() or None if display_name is not None and len(display_name) > 200: raise ValidationError("display_name is too long") client_id = str(body.get("client_id") or "") tenant = str(body.get("tenant") or "") if client_id not in self.registration_clients: raise ValidationError("client_id is not eligible for registration") if tenant not in self.registration_tenants: raise ValidationError("tenant is not eligible for registration") applicant_subject = f"applicant_{secrets.token_hex(16)}" actor = Actor( issuer="urn:netkingdom:public-registration", subject=applicant_subject, tenant=tenant, principal_type=PrincipalType.HUMAN, audience=("user-engine",), roles=("registration-applicant",), authorized_party=client_id, preferred_username=username, ) session = self.service.start_registration( actor, tenant=tenant, correlation_id=correlation_id, applicant_username=username, client_id=client_id, ) self.registration_verification.request( RegistrationVerificationRequest( registration_id=session.registration_id, normalized_email=email, preferred_username=username, client_id=client_id, tenant=tenant, correlation_id=correlation_id, display_name=display_name, ) ) return self._json( start_response, "202 Accepted", {"status": "verification_requested"}, correlation_id, ) def _verify_public_registration( self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str, ) -> Iterable[bytes]: if not self.public_registration or self.registration_verification is None: raise NotFoundError("public registration is unavailable") body = self._body(environ) evidence = self.registration_verification.consume(str(body.get("handle") or "")) session = self.service.store.registration_session(evidence.registration_id) if session is None: raise NotFoundError("registration not found") if ( evidence.client_id != session.client_id or evidence.tenant != session.tenant or evidence.preferred_username != session.applicant_username ): raise AuthorizationDenied("verification evidence does not match registration") actor = Actor( issuer="urn:netkingdom:public-registration", subject=str(session.started_by_subject), tenant=session.tenant, principal_type=PrincipalType.HUMAN, audience=("user-engine",), roles=("registration-applicant",), authorized_party=session.client_id, preferred_username=session.applicant_username, ) updated = self.service.attach_registration_factor( actor, session.registration_id, FactorVerification( factor_type=IdentityFactorType.EMAIL, normalized_value=evidence.normalized_email, verification_id=evidence.verification_id, source_system=evidence.source_system, assurance=dict(evidence.assurance), ), correlation_id=correlation_id, ) if self.provisioning is None or not self.registration_oidc_issuer: raise RuntimeError("public registration provisioning is unavailable") completion = self.service.complete_registration( actor, updated.registration_id, display_name=evidence.display_name, primary_email=evidence.normalized_email, correlation_id=correlation_id, ) provisioned = self.provisioning.provision( ProvisioningRequest( user_id=completion.user.user_id, tenant=updated.tenant, primary_email=evidence.normalized_email, display_name=evidence.display_name, preferred_username=evidence.preferred_username, idempotency_key=f"public-registration-{updated.registration_id}", correlation_id=correlation_id, roles=("user",), ) ) self.service.link_identity( actor, completion.user.user_id, issuer=self.registration_oidc_issuer, subject=provisioned.external_subject, provider=provisioned.provider, correlation_id=correlation_id, ) if provisioned.password_setup_url: self._validate_registration_handoff(provisioned.password_setup_url) return self._redirect( start_response, provisioned.password_setup_url, correlation_id ) return self._json( start_response, "200 OK", {"status": "identity_ready", "login_required": True}, correlation_id, ) def _validate_registration_handoff(self, setup_url: str) -> None: parsed = urlsplit(setup_url) origin = f"{parsed.scheme}://{parsed.netloc}" if ( parsed.scheme != "https" or not parsed.netloc or origin not in self.registration_password_setup_origins ): raise ValidationError("password setup handoff is not allow-listed") @staticmethod def _registration_username(value: Any) -> str: username = str(value or "").strip().lower() if not re.fullmatch(r"[a-z][a-z0-9._-]{2,31}", username): raise ValidationError("username is invalid") if username in {"admin", "administrator", "platform-root", "root", "system"}: raise ValidationError("username is reserved") return username @staticmethod def _registration_email(value: Any) -> str: email = str(value or "").strip().lower() if len(email) > 254 or not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): raise ValidationError("email is invalid") return email def _optional_actor(self, environ: Mapping[str, Any]) -> Any | None: try: return self._actor(environ) except (AuthorizationDenied, json.JSONDecodeError, ValidationError): return None @staticmethod def _body(environ: Mapping[str, Any]) -> Mapping[str, Any]: length = min(int(environ.get("CONTENT_LENGTH") or 0), 65536) payload = environ["wsgi.input"].read(length) if length else b"{}" value = json.loads(payload.decode("utf-8")) if not isinstance(value, dict): raise ValidationError("request body must be an object") return value @staticmethod def _form_body(environ: Mapping[str, Any]) -> Mapping[str, str]: content_type = str(environ.get("CONTENT_TYPE", "")).partition(";")[0] if content_type != "application/x-www-form-urlencoded": raise ValidationError("form content type is required") length = min(int(environ.get("CONTENT_LENGTH") or 0), 65536) payload = environ["wsgi.input"].read(length).decode("utf-8") return {key: values[0] for key, values in parse_qs(payload).items()} def _csrf_token(self, environ: Mapping[str, Any]) -> str: if self.oidc_client is None: raise AuthorizationDenied("browser session required") session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") token = self.oidc_client.csrf_token(session_id or "") if token is None: raise AuthorizationDenied("browser session required") return token def _require_csrf(self, environ: Mapping[str, Any], supplied: str) -> None: expected = self._csrf_token(environ) if not supplied or not secrets.compare_digest(supplied, expected): raise AuthorizationDenied("invalid CSRF token") @staticmethod def _page(environ: Mapping[str, Any]) -> tuple[int, int]: query = parse_qs(str(environ.get("QUERY_STRING", ""))) offset = max(0, int(query.get("offset", ["0"])[0])) limit = max(1, min(100, int(query.get("limit", ["25"])[0]))) return offset, limit @staticmethod def _expected_version(environ: Mapping[str, Any]) -> int: value = str(environ.get("HTTP_IF_MATCH", "")).strip().strip('"') if not value.isdigit(): raise ValidationError("If-Match invitation version is required") return int(value) @staticmethod def _idempotency_key(environ: Mapping[str, Any]) -> str: value = str(environ.get("HTTP_IDEMPOTENCY_KEY", "")) if len(value) < 16: raise ValidationError("Idempotency-Key must contain at least 16 characters") return value def _home(self, actor: Any | None) -> str: identity = ( f"
Signed in as {escape(actor.preferred_username)}.
" '' if actor is not None else f'' ) return self._page_html( "Identity & access", "Join a tenant, complete onboarding, and manage access without exposing credentials to applications.
" + identity, ) def _admin( self, tenant: str, memberships: tuple[Any, ...], invitations: tuple[Any, ...], diagnostics: Any, platform_operator: bool, csrf_token: str, ) -> str: rows = "".join( self._admin_row(tenant, item, platform_operator, csrf_token) for item in memberships ) or '| Role | Status | Expires | Action |
|---|
Diagnostics contain machine-readable gap categories only; credentials and factor evidence are never displayed.
| User | Role | Status | Directory | Action |
|---|
You were invited as {escape(invitation.role)}. The invitation expires at {escape(invitation.expires_at.isoformat() if invitation.expires_at else 'the tenant policy deadline')}.
Your password and MFA remain on the identity-provider surface.
""", ) def _platform(self, csrf_token: str) -> str: return self._page_html( "Platform administration", f"""{escape(tenant)} was processed by the tenant authority.
{'The first administrator is prepared and awaiting onboarding.' if admin_prepared else 'No first administrator was requested.'}
Return to platform administration
""", ) def _onboarding( self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...], selected_tenant: str, csrf_token: str, ) -> str: membership_items = "".join( f"{escape(verification)}
Passwords and MFA are managed by your identity provider.
Viewing {escape(selected_tenant)}.
Reauthenticate in this tenant to change the authoritative login context.
The password is handled only by the NetKingdom identity " "surface. This short-lived link is single use.
" f'" f'' "Return to tenant administration
", ) def _redirect(self, start_response: StartResponse, location: str, correlation_id: str) -> list[bytes]: start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)]) return [b""] @staticmethod def _page_html(title: str, body: str) -> str: return f"""