coulomb-social/coulomb_social/apps/identity/user_engine.py
tegwick d88767f05b Wire user-engine HTTP /me for member provisioning (CSOC-WP-0002-T04)
HttpUserEngineClient uses trusted-proxy claims against live user-engine.
Offline stub when URL/secret unset. Align default tenant with KeyCape
tenant:coulomb; map OIDC tenant/principal_type/groups into the envelope.
2026-08-09 01:56:44 +02:00

175 lines
5.4 KiB
Python

"""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()