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

@ -32,6 +32,7 @@ def app_home(request: HttpRequest) -> HttpResponse:
str(member.user_engine_user_id) if member else ""
),
"tenant_id": member.tenant_id if member else "",
"user_engine_source": request.session.get("user_engine_source", ""),
},
},
)

View file

@ -14,20 +14,27 @@ from .user_engine import IdentityClaims, get_user_engine_client
def establish_session(request: HttpRequest, claims: IdentityClaims) -> Member:
"""Link platform identity → Member, log Django user in. No passwords."""
tenant_id = settings.DEFAULT_TENANT_ID
tenant_id = claims.tenant or settings.DEFAULT_TENANT_ID
application_id = settings.USER_ENGINE_APPLICATION_ID
link = get_user_engine_client().link_or_create(
claims, tenant_id=tenant_id, application_id=application_id
)
member = Member.objects.filter(issuer=claims.issuer, subject=claims.subject).first()
display = claims.name or claims.preferred_username or claims.email or claims.subject
display = (
link.display_name
or claims.name
or claims.preferred_username
or claims.email
or claims.subject
)
email = link.email or claims.email or ""
username = f"{claims.subject}@{_issuer_slug(claims.issuer)}"[:255]
if member is None:
user = User.objects.create_user(
username=username,
email=claims.email or "",
email=email,
full_name=display,
)
member = Member.objects.create(
@ -37,21 +44,25 @@ def establish_session(request: HttpRequest, claims: IdentityClaims) -> Member:
issuer=claims.issuer,
subject=claims.subject,
display_name=display,
email=claims.email or "",
email=email,
)
else:
user = member.user
user.email = claims.email or user.email
user.email = email or user.email
user.full_name = display or user.full_name
user.save(update_fields=["email", "full_name"])
member.display_name = display
member.email = claims.email or member.email
member.email = email or member.email
member.user_engine_user_id = link.user_id
member.tenant_id = tenant_id
member.last_login_at = timezone.now()
member.save()
# Stash provenance for debugging shell (no secrets)
request.session["user_engine_source"] = link.source
request.session["user_engine_user_id"] = link.user_id
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return member

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

View file

@ -77,13 +77,7 @@ def oidc_callback(request: HttpRequest) -> HttpResponse:
return HttpResponseBadRequest("Token missing subject")
issuer = raw.get("iss") or settings.OIDC_ISSUER
claims = IdentityClaims(
issuer=str(issuer),
subject=str(sub),
email=str(raw.get("email") or ""),
name=str(raw.get("name") or ""),
preferred_username=str(raw.get("preferred_username") or ""),
)
claims = _claims_from_oidc_payload(raw, issuer=str(issuer), subject=str(sub))
establish_session(request, claims)
return redirect(settings.LOGIN_REDIRECT_URL)
@ -105,11 +99,52 @@ def dev_login(request: HttpRequest) -> HttpResponse:
email=(request.POST.get("email") or "").strip(),
name=(request.POST.get("name") or "").strip(),
preferred_username=(request.POST.get("preferred_username") or "").strip(),
tenant=(request.POST.get("tenant") or settings.DEFAULT_TENANT_ID).strip(),
principal_type="human",
roles=("user",),
authorized_party=settings.USER_ENGINE_APPLICATION_ID,
)
establish_session(request, claims)
return redirect(settings.LOGIN_REDIRECT_URL)
return render(request, "identity/dev_login.html")
return render(
request,
"identity/dev_login.html",
{"default_tenant": settings.DEFAULT_TENANT_ID},
)
def _claims_from_oidc_payload(
raw: dict, *, issuer: str, subject: str
) -> IdentityClaims:
aud = raw.get("aud", ())
if isinstance(aud, str):
audience = (aud,)
else:
audience = tuple(str(a) for a in (aud or ()))
groups = raw.get("groups") or ()
if isinstance(groups, str):
groups = (groups,)
roles = raw.get("roles") or raw.get("tenant_roles") or ()
if isinstance(roles, str):
roles = (roles,)
assurance = raw.get("assurance") if isinstance(raw.get("assurance"), dict) else {}
return IdentityClaims(
issuer=issuer,
subject=subject,
email=str(raw.get("email") or ""),
name=str(raw.get("name") or ""),
preferred_username=str(raw.get("preferred_username") or ""),
tenant=str(raw.get("tenant") or settings.DEFAULT_TENANT_ID),
principal_type=str(raw.get("principal_type") or "human"),
groups=tuple(str(g) for g in groups),
roles=tuple(str(r) for r in roles) or ("user",),
assurance=dict(assurance),
audience=audience,
authorized_party=str(
raw.get("azp") or raw.get("client_id") or settings.OIDC_CLIENT_ID or ""
),
)
@require_http_methods(["GET", "POST"])