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:
parent
a6a380b19f
commit
d88767f05b
13 changed files with 325 additions and 48 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue