"""user-engine integration port. Production calls GET /api/v1/me with the trusted-proxy envelope: X-User-Engine-Proxy-Secret + X-Verified-Oidc-Claims (JSON) That path auto-creates/links the platform user from (iss, sub) and returns a stable user_id. Offline/dev uses a deterministic stub when base URL or proxy secret is unset. """ from __future__ import annotations import hashlib import json import uuid from dataclasses import dataclass, field from typing import Any, Protocol import httpx from django.conf import settings @dataclass(frozen=True) class IdentityClaims: issuer: str subject: str email: str = "" name: str = "" preferred_username: str = "" tenant: str = "" principal_type: str = "human" groups: tuple[str, ...] = () roles: tuple[str, ...] = () assurance: dict[str, Any] = field(default_factory=dict) audience: tuple[str, ...] = () authorized_party: str = "" @dataclass(frozen=True) class UserEngineLink: user_id: str application_id: str tenant_id: str created: bool display_name: str = "" email: str = "" source: str = "stub" # stub | http class UserEngineClient(Protocol): def link_or_create( self, claims: IdentityClaims, *, tenant_id: str, application_id: str, ) -> UserEngineLink: ... class StubUserEngineClient: """Deterministic offline stand-in (not for production identity).""" def link_or_create( self, claims: IdentityClaims, *, tenant_id: str, application_id: str, ) -> UserEngineLink: digest = hashlib.sha256( f"{claims.issuer}|{claims.subject}".encode() ).hexdigest()[:32] user_id = str(uuid.UUID(digest)) return UserEngineLink( user_id=user_id, application_id=application_id, tenant_id=tenant_id, created=True, display_name=claims.name or claims.preferred_username or claims.subject, email=claims.email, source="stub", ) class HttpUserEngineClient: """Trusted-proxy client for live user-engine portal HTTP API.""" def __init__(self, base_url: str, proxy_secret: str) -> None: self.base_url = base_url.rstrip("/") self.proxy_secret = proxy_secret def link_or_create( self, claims: IdentityClaims, *, tenant_id: str, application_id: str, ) -> UserEngineLink: verified = _claims_envelope(claims, tenant_id=tenant_id, application_id=application_id) headers = { "X-User-Engine-Proxy-Secret": self.proxy_secret, "X-Verified-Oidc-Claims": json.dumps(verified, separators=(",", ":")), "Accept": "application/json", } url = f"{self.base_url}/api/v1/me" with httpx.Client(timeout=20.0, verify=True) as client: resp = client.get(url, headers=headers) resp.raise_for_status() data = resp.json() user = data.get("user") or {} user_id = str(user.get("user_id") or "") if not user_id: raise RuntimeError("user-engine /me response missing user.user_id") actor = data.get("actor") or {} return UserEngineLink( user_id=user_id, application_id=application_id, tenant_id=str(actor.get("tenant") or tenant_id), created=True, display_name=str( user.get("display_name") or claims.name or claims.preferred_username or claims.subject ), email=str(user.get("primary_email") or claims.email or ""), source="http", ) def _claims_envelope( claims: IdentityClaims, *, tenant_id: str, application_id: str, ) -> dict[str, Any]: """Build VerifiedIdentityClaimsAdapter-compatible claims for the proxy path. user-engine is deployed with audience user-engine-portal; trusted apps may present that audience in the envelope because the proxy secret is the trust boundary (claims are already verified at the app edge via KeyCape OIDC). """ portal_aud = getattr(settings, "USER_ENGINE_EXPECTED_AUDIENCE", "user-engine-portal") aud = list(claims.audience) if claims.audience else [] if portal_aud not in aud: aud.append(portal_aud) if application_id and application_id not in aud: aud.append(application_id) tenant = claims.tenant or tenant_id or settings.DEFAULT_TENANT_ID return { "iss": claims.issuer, "sub": claims.subject, "tenant": tenant, "principal_type": claims.principal_type or "human", "aud": aud, "email": claims.email, "name": claims.name, "preferred_username": claims.preferred_username or claims.name or claims.subject, "groups": list(claims.groups), "roles": list(claims.roles) or ["user"], "assurance": claims.assurance or {"aal": "aal1", "methods": ["pwd"], "mfa": False}, "azp": claims.authorized_party or application_id, "client_id": claims.authorized_party or application_id, } def get_user_engine_client() -> UserEngineClient: base = (settings.USER_ENGINE_BASE_URL or "").strip() secret = (getattr(settings, "USER_ENGINE_PROXY_SECRET", "") or "").strip() if base and secret: return HttpUserEngineClient(base, secret) return StubUserEngineClient()