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.
This commit is contained in:
tegwick 2026-08-09 01:56:44 +02:00
parent a6a380b19f
commit d88767f05b
13 changed files with 325 additions and 48 deletions

View file

@ -1,17 +1,22 @@
"""user-engine integration port.
Production will call the user-engine HTTP API. Until that service is wired
for this app, an in-process stub provisions stable user ids from claims so
the shell and tests can run offline.
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
from typing import Protocol
from dataclasses import dataclass, field
from typing import Any, Protocol
import httpx
from django.conf import settings
@ -22,6 +27,13 @@ class IdentityClaims:
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)
@ -30,6 +42,9 @@ class UserEngineLink:
application_id: str
tenant_id: str
created: bool
display_name: str = ""
email: str = ""
source: str = "stub" # stub | http
class UserEngineClient(Protocol):
@ -43,7 +58,7 @@ class UserEngineClient(Protocol):
class StubUserEngineClient:
"""Deterministic offline user-engine stand-in (not for production identity)."""
"""Deterministic offline stand-in (not for production identity)."""
def link_or_create(
self,
@ -55,21 +70,24 @@ class StubUserEngineClient:
digest = hashlib.sha256(
f"{claims.issuer}|{claims.subject}".encode()
).hexdigest()[:32]
# UUID-shaped stable id for readability in the shell
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:
"""Minimal HTTP client placeholder — expand when USER_ENGINE_BASE_URL is live."""
"""Trusted-proxy client for live user-engine portal HTTP API."""
def __init__(self, base_url: str) -> None:
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,
@ -78,16 +96,80 @@ class HttpUserEngineClient:
tenant_id: str,
application_id: str,
) -> UserEngineLink:
# Until the production contract endpoint is confirmed, fall back to stub
# semantics while recording that HTTP mode was requested.
# TODO(CSOC-WP-0002-T04): replace with real projection/link API.
return StubUserEngineClient().link_or_create(
claims, tenant_id=tenant_id, application_id=application_id
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()
if base:
return HttpUserEngineClient(base)
secret = (getattr(settings, "USER_ENGINE_PROXY_SECRET", "") or "").strip()
if base and secret:
return HttpUserEngineClient(base, secret)
return StubUserEngineClient()