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
|
|
@ -4,7 +4,7 @@
|
||||||
SECRET_KEY=change-me
|
SECRET_KEY=change-me
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
DATABASE_URL=sqlite:///db.sqlite3
|
DATABASE_URL=sqlite:///db.sqlite3
|
||||||
DEFAULT_TENANT_ID=binky
|
DEFAULT_TENANT_ID=tenant:coulomb
|
||||||
|
|
||||||
# --- NetKingdom OIDC (KeyCape) ---
|
# --- NetKingdom OIDC (KeyCape) ---
|
||||||
# Offline shell: leave OIDC_ENABLED=false and use /auth/dev-login/
|
# Offline shell: leave OIDC_ENABLED=false and use /auth/dev-login/
|
||||||
|
|
@ -17,5 +17,8 @@ OIDC_REDIRECT_URI=http://127.0.0.1:8008/auth/callback/
|
||||||
OIDC_SCOPES=openid profile email groups
|
OIDC_SCOPES=openid profile email groups
|
||||||
|
|
||||||
USER_ENGINE_APPLICATION_ID=coulomb-social
|
USER_ENGINE_APPLICATION_ID=coulomb-social
|
||||||
# USER_ENGINE_BASE_URL=
|
USER_ENGINE_EXPECTED_AUDIENCE=user-engine-portal
|
||||||
|
# Live user-engine (both required for HTTP mode):
|
||||||
|
# USER_ENGINE_BASE_URL=https://users.92-205-62-239.nip.io
|
||||||
|
# USER_ENGINE_PROXY_SECRET= # from OpenBao / kubectl user-engine-runtime — never commit
|
||||||
# FLEX_AUTH_BASE_URL=
|
# FLEX_AUTH_BASE_URL=
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
| task | CSOC-WP-0002-T01 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
| task | CSOC-WP-0002-T01 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
||||||
| task | CSOC-WP-0002-T02 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
| task | CSOC-WP-0002-T02 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
||||||
| task | CSOC-WP-0002-T03 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
| task | CSOC-WP-0002-T03 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
||||||
| task | CSOC-WP-0002-T04 | progress | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
| task | CSOC-WP-0002-T04 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
||||||
| task | CSOC-WP-0002-T05 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
| task | CSOC-WP-0002-T05 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
||||||
| task | CSOC-WP-0002-T06 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
| task | CSOC-WP-0002-T06 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
||||||
| task | CSOC-WP-0002-T07 | progress | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
| task | CSOC-WP-0002-T07 | progress | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ def app_home(request: HttpRequest) -> HttpResponse:
|
||||||
str(member.user_engine_user_id) if member else ""
|
str(member.user_engine_user_id) if member else ""
|
||||||
),
|
),
|
||||||
"tenant_id": member.tenant_id if member else "",
|
"tenant_id": member.tenant_id if member else "",
|
||||||
|
"user_engine_source": request.session.get("user_engine_source", ""),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,27 @@ from .user_engine import IdentityClaims, get_user_engine_client
|
||||||
|
|
||||||
def establish_session(request: HttpRequest, claims: IdentityClaims) -> Member:
|
def establish_session(request: HttpRequest, claims: IdentityClaims) -> Member:
|
||||||
"""Link platform identity → Member, log Django user in. No passwords."""
|
"""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
|
application_id = settings.USER_ENGINE_APPLICATION_ID
|
||||||
link = get_user_engine_client().link_or_create(
|
link = get_user_engine_client().link_or_create(
|
||||||
claims, tenant_id=tenant_id, application_id=application_id
|
claims, tenant_id=tenant_id, application_id=application_id
|
||||||
)
|
)
|
||||||
|
|
||||||
member = Member.objects.filter(issuer=claims.issuer, subject=claims.subject).first()
|
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]
|
username = f"{claims.subject}@{_issuer_slug(claims.issuer)}"[:255]
|
||||||
|
|
||||||
if member is None:
|
if member is None:
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
username=username,
|
username=username,
|
||||||
email=claims.email or "",
|
email=email,
|
||||||
full_name=display,
|
full_name=display,
|
||||||
)
|
)
|
||||||
member = Member.objects.create(
|
member = Member.objects.create(
|
||||||
|
|
@ -37,21 +44,25 @@ def establish_session(request: HttpRequest, claims: IdentityClaims) -> Member:
|
||||||
issuer=claims.issuer,
|
issuer=claims.issuer,
|
||||||
subject=claims.subject,
|
subject=claims.subject,
|
||||||
display_name=display,
|
display_name=display,
|
||||||
email=claims.email or "",
|
email=email,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
user = member.user
|
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.full_name = display or user.full_name
|
||||||
user.save(update_fields=["email", "full_name"])
|
user.save(update_fields=["email", "full_name"])
|
||||||
member.display_name = display
|
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.user_engine_user_id = link.user_id
|
||||||
member.tenant_id = tenant_id
|
member.tenant_id = tenant_id
|
||||||
|
|
||||||
member.last_login_at = timezone.now()
|
member.last_login_at = timezone.now()
|
||||||
member.save()
|
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")
|
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||||
return member
|
return member
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
"""user-engine integration port.
|
"""user-engine integration port.
|
||||||
|
|
||||||
Production will call the user-engine HTTP API. Until that service is wired
|
Production calls GET /api/v1/me with the trusted-proxy envelope:
|
||||||
for this app, an in-process stub provisions stable user ids from claims so
|
X-User-Engine-Proxy-Secret + X-Verified-Oidc-Claims (JSON)
|
||||||
the shell and tests can run offline.
|
|
||||||
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
import httpx
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,6 +27,13 @@ class IdentityClaims:
|
||||||
email: str = ""
|
email: str = ""
|
||||||
name: str = ""
|
name: str = ""
|
||||||
preferred_username: 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)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -30,6 +42,9 @@ class UserEngineLink:
|
||||||
application_id: str
|
application_id: str
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
created: bool
|
created: bool
|
||||||
|
display_name: str = ""
|
||||||
|
email: str = ""
|
||||||
|
source: str = "stub" # stub | http
|
||||||
|
|
||||||
|
|
||||||
class UserEngineClient(Protocol):
|
class UserEngineClient(Protocol):
|
||||||
|
|
@ -43,7 +58,7 @@ class UserEngineClient(Protocol):
|
||||||
|
|
||||||
|
|
||||||
class StubUserEngineClient:
|
class StubUserEngineClient:
|
||||||
"""Deterministic offline user-engine stand-in (not for production identity)."""
|
"""Deterministic offline stand-in (not for production identity)."""
|
||||||
|
|
||||||
def link_or_create(
|
def link_or_create(
|
||||||
self,
|
self,
|
||||||
|
|
@ -55,21 +70,24 @@ class StubUserEngineClient:
|
||||||
digest = hashlib.sha256(
|
digest = hashlib.sha256(
|
||||||
f"{claims.issuer}|{claims.subject}".encode()
|
f"{claims.issuer}|{claims.subject}".encode()
|
||||||
).hexdigest()[:32]
|
).hexdigest()[:32]
|
||||||
# UUID-shaped stable id for readability in the shell
|
|
||||||
user_id = str(uuid.UUID(digest))
|
user_id = str(uuid.UUID(digest))
|
||||||
return UserEngineLink(
|
return UserEngineLink(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
application_id=application_id,
|
application_id=application_id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
created=True,
|
created=True,
|
||||||
|
display_name=claims.name or claims.preferred_username or claims.subject,
|
||||||
|
email=claims.email,
|
||||||
|
source="stub",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class HttpUserEngineClient:
|
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.base_url = base_url.rstrip("/")
|
||||||
|
self.proxy_secret = proxy_secret
|
||||||
|
|
||||||
def link_or_create(
|
def link_or_create(
|
||||||
self,
|
self,
|
||||||
|
|
@ -78,16 +96,80 @@ class HttpUserEngineClient:
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
application_id: str,
|
application_id: str,
|
||||||
) -> UserEngineLink:
|
) -> UserEngineLink:
|
||||||
# Until the production contract endpoint is confirmed, fall back to stub
|
verified = _claims_envelope(claims, tenant_id=tenant_id, application_id=application_id)
|
||||||
# semantics while recording that HTTP mode was requested.
|
headers = {
|
||||||
# TODO(CSOC-WP-0002-T04): replace with real projection/link API.
|
"X-User-Engine-Proxy-Secret": self.proxy_secret,
|
||||||
return StubUserEngineClient().link_or_create(
|
"X-Verified-Oidc-Claims": json.dumps(verified, separators=(",", ":")),
|
||||||
claims, tenant_id=tenant_id, application_id=application_id
|
"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:
|
def get_user_engine_client() -> UserEngineClient:
|
||||||
base = (settings.USER_ENGINE_BASE_URL or "").strip()
|
base = (settings.USER_ENGINE_BASE_URL or "").strip()
|
||||||
if base:
|
secret = (getattr(settings, "USER_ENGINE_PROXY_SECRET", "") or "").strip()
|
||||||
return HttpUserEngineClient(base)
|
if base and secret:
|
||||||
|
return HttpUserEngineClient(base, secret)
|
||||||
return StubUserEngineClient()
|
return StubUserEngineClient()
|
||||||
|
|
|
||||||
|
|
@ -77,13 +77,7 @@ def oidc_callback(request: HttpRequest) -> HttpResponse:
|
||||||
return HttpResponseBadRequest("Token missing subject")
|
return HttpResponseBadRequest("Token missing subject")
|
||||||
|
|
||||||
issuer = raw.get("iss") or settings.OIDC_ISSUER
|
issuer = raw.get("iss") or settings.OIDC_ISSUER
|
||||||
claims = IdentityClaims(
|
claims = _claims_from_oidc_payload(raw, issuer=str(issuer), subject=str(sub))
|
||||||
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 ""),
|
|
||||||
)
|
|
||||||
establish_session(request, claims)
|
establish_session(request, claims)
|
||||||
return redirect(settings.LOGIN_REDIRECT_URL)
|
return redirect(settings.LOGIN_REDIRECT_URL)
|
||||||
|
|
||||||
|
|
@ -105,11 +99,52 @@ def dev_login(request: HttpRequest) -> HttpResponse:
|
||||||
email=(request.POST.get("email") or "").strip(),
|
email=(request.POST.get("email") or "").strip(),
|
||||||
name=(request.POST.get("name") or "").strip(),
|
name=(request.POST.get("name") or "").strip(),
|
||||||
preferred_username=(request.POST.get("preferred_username") 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)
|
establish_session(request, claims)
|
||||||
return redirect(settings.LOGIN_REDIRECT_URL)
|
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"])
|
@require_http_methods(["GET", "POST"])
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,8 @@ LOGIN_URL = "identity:login"
|
||||||
LOGIN_REDIRECT_URL = "core:app_home"
|
LOGIN_REDIRECT_URL = "core:app_home"
|
||||||
LOGOUT_REDIRECT_URL = "core:landing"
|
LOGOUT_REDIRECT_URL = "core:landing"
|
||||||
|
|
||||||
# ── Tenant (Binky = client #1) ──────────────────────────────────────────────
|
# ── Tenant (KeyCape default platform tenant; Binky friendly slug later) ─────
|
||||||
DEFAULT_TENANT_ID = config("DEFAULT_TENANT_ID", default="binky")
|
DEFAULT_TENANT_ID = config("DEFAULT_TENANT_ID", default="tenant:coulomb")
|
||||||
|
|
||||||
# ── NetKingdom identity (see ADR-0001) ──────────────────────────────────────
|
# ── NetKingdom identity (see ADR-0001) ──────────────────────────────────────
|
||||||
# When OIDC_ENABLED is false, only the DEBUG dev-login path is available.
|
# When OIDC_ENABLED is false, only the DEBUG dev-login path is available.
|
||||||
|
|
@ -99,12 +99,16 @@ OIDC_ISSUER = config("OIDC_ISSUER", default="")
|
||||||
OIDC_CLIENT_ID = config("OIDC_CLIENT_ID", default="")
|
OIDC_CLIENT_ID = config("OIDC_CLIENT_ID", default="")
|
||||||
OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", default="")
|
OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", default="")
|
||||||
OIDC_REDIRECT_URI = config("OIDC_REDIRECT_URI", default="")
|
OIDC_REDIRECT_URI = config("OIDC_REDIRECT_URI", default="")
|
||||||
OIDC_SCOPES = config("OIDC_SCOPES", default="openid profile email")
|
OIDC_SCOPES = config("OIDC_SCOPES", default="openid profile email groups")
|
||||||
OIDC_DISCOVERY_URL = config("OIDC_DISCOVERY_URL", default="") # optional override
|
OIDC_DISCOVERY_URL = config("OIDC_DISCOVERY_URL", default="") # optional override
|
||||||
|
|
||||||
# user-engine HTTP base (empty → in-process stub)
|
# user-engine HTTP (empty base or secret → offline stub)
|
||||||
USER_ENGINE_BASE_URL = config("USER_ENGINE_BASE_URL", default="")
|
USER_ENGINE_BASE_URL = config("USER_ENGINE_BASE_URL", default="")
|
||||||
|
USER_ENGINE_PROXY_SECRET = config("USER_ENGINE_PROXY_SECRET", default="")
|
||||||
USER_ENGINE_APPLICATION_ID = config("USER_ENGINE_APPLICATION_ID", default="coulomb-social")
|
USER_ENGINE_APPLICATION_ID = config("USER_ENGINE_APPLICATION_ID", default="coulomb-social")
|
||||||
|
USER_ENGINE_EXPECTED_AUDIENCE = config(
|
||||||
|
"USER_ENGINE_EXPECTED_AUDIENCE", default="user-engine-portal"
|
||||||
|
)
|
||||||
|
|
||||||
# flex-auth (empty → local fail-closed stub for sensitive checks; shell view allowed)
|
# flex-auth (empty → local fail-closed stub for sensitive checks; shell view allowed)
|
||||||
FLEX_AUTH_BASE_URL = config("FLEX_AUTH_BASE_URL", default="")
|
FLEX_AUTH_BASE_URL = config("FLEX_AUTH_BASE_URL", default="")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
<dt>Issuer</dt><dd>{{ principal.issuer }}</dd>
|
<dt>Issuer</dt><dd>{{ principal.issuer }}</dd>
|
||||||
<dt>Subject</dt><dd>{{ principal.subject }}</dd>
|
<dt>Subject</dt><dd>{{ principal.subject }}</dd>
|
||||||
<dt>user-engine id</dt><dd>{{ principal.user_engine_user_id }}</dd>
|
<dt>user-engine id</dt><dd>{{ principal.user_engine_user_id }}</dd>
|
||||||
|
<dt>user-engine source</dt><dd>{{ principal.user_engine_source|default:"—" }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@
|
||||||
<label>Preferred username
|
<label>Preferred username
|
||||||
<input type="text" name="preferred_username" value="dev">
|
<input type="text" name="preferred_username" value="dev">
|
||||||
</label>
|
</label>
|
||||||
|
<label>Tenant claim
|
||||||
|
<input type="text" name="tenant" value="{{ default_tenant }}">
|
||||||
|
</label>
|
||||||
<p style="margin-top:1.25rem;">
|
<p style="margin-top:1.25rem;">
|
||||||
<button class="btn" type="submit">Establish session</button>
|
<button class="btn" type="submit">Establish session</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,16 @@ See `.env.example`. Summary:
|
||||||
|----------|---------|---------|
|
|----------|---------|---------|
|
||||||
| `SECRET_KEY` | insecure dev default | Django secret |
|
| `SECRET_KEY` | insecure dev default | Django secret |
|
||||||
| `DATABASE_URL` | sqlite `./db.sqlite3` | DB |
|
| `DATABASE_URL` | sqlite `./db.sqlite3` | DB |
|
||||||
| `DEFAULT_TENANT_ID` | `binky` | Client #1 tenant key |
|
| `DEFAULT_TENANT_ID` | `tenant:coulomb` | Platform tenant claim (KeyCape default) |
|
||||||
| `OIDC_ENABLED` | `false` | Use KeyCape / real issuer |
|
| `OIDC_ENABLED` | `false` | Use KeyCape / real issuer |
|
||||||
| `OIDC_ISSUER` | | e.g. `https://kc.coulomb.social` |
|
| `OIDC_ISSUER` | | e.g. `https://kc.coulomb.social` |
|
||||||
| `OIDC_CLIENT_ID` | | `coulomb-social` |
|
| `OIDC_CLIENT_ID` | | `coulomb-social` |
|
||||||
| `OIDC_CLIENT_SECRET` | empty | **public client** — leave empty |
|
| `OIDC_CLIENT_SECRET` | empty | **public client** — leave empty |
|
||||||
| `OIDC_REDIRECT_URI` | | `http://127.0.0.1:8008/auth/callback/` |
|
| `OIDC_REDIRECT_URI` | | `http://127.0.0.1:8008/auth/callback/` |
|
||||||
| `USER_ENGINE_BASE_URL` | empty (stub) | user-engine HTTP |
|
| `USER_ENGINE_BASE_URL` | empty (stub) | e.g. `https://users.92-205-62-239.nip.io` |
|
||||||
| `USER_ENGINE_APPLICATION_ID` | `coulomb-social` | App id in user-engine |
|
| `USER_ENGINE_PROXY_SECRET` | empty | trusted proxy secret (with base URL → HTTP) |
|
||||||
|
| `USER_ENGINE_APPLICATION_ID` | `coulomb-social` | App id |
|
||||||
|
| `USER_ENGINE_EXPECTED_AUDIENCE` | `user-engine-portal` | required aud for /me |
|
||||||
| `FLEX_AUTH_BASE_URL` | empty (fail-closed except shell:view) | PDP |
|
| `FLEX_AUTH_BASE_URL` | empty (fail-closed except shell:view) | PDP |
|
||||||
|
|
||||||
### Platform OIDC (KeyCape)
|
### Platform OIDC (KeyCape)
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,53 @@
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| application id | `coulomb-social` (`USER_ENGINE_APPLICATION_ID`) |
|
| application id | `coulomb-social` (`USER_ENGINE_APPLICATION_ID`) |
|
||||||
| display name | coulomb.social |
|
| display name | coulomb.social |
|
||||||
| tenant (v1) | `binky` (client #1) |
|
| tenant (v1) | `tenant:coulomb` (KeyCape default; Binky friendly slug later) |
|
||||||
| identity link | `(issuer, sub)` → `user_engine_user_id` on first login |
|
| identity link | `(issuer, sub)` via user-engine `GET /api/v1/me` auto-provision |
|
||||||
| local row | `members.Member` (no passwords) |
|
| local row | `members.Member` (no passwords) |
|
||||||
|
| live base URL | `https://users.92-205-62-239.nip.io` (cluster ingress) |
|
||||||
|
|
||||||
## Runtime
|
## Runtime
|
||||||
|
|
||||||
| Mode | Behavior |
|
| Mode | When | Behavior |
|
||||||
|------|----------|
|
|------|------|----------|
|
||||||
| `USER_ENGINE_BASE_URL` empty | `StubUserEngineClient` — deterministic user id from issuer+sub hash |
|
| **stub** | `USER_ENGINE_BASE_URL` or `USER_ENGINE_PROXY_SECRET` empty | Deterministic `user_id` from sha256(iss\|sub) |
|
||||||
| URL set | `HttpUserEngineClient` placeholder (T04: wire real link/projection API) |
|
| **http** | both set | Trusted-proxy `GET /api/v1/me` with IAM-shaped claims envelope |
|
||||||
|
|
||||||
|
### Trusted proxy envelope
|
||||||
|
|
||||||
|
user-engine accepts claims only when:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-User-Engine-Proxy-Secret: <secret from user-engine-runtime>
|
||||||
|
X-Verified-Oidc-Claims: {"iss","sub","tenant","principal_type","aud",...}
|
||||||
|
```
|
||||||
|
|
||||||
|
`aud` must include the portal audience `user-engine-portal` (deployed expectation)
|
||||||
|
plus `coulomb-social`. Trust is the proxy secret; claims were verified by
|
||||||
|
KeyCape OIDC at the app edge.
|
||||||
|
|
||||||
|
### Operator: enable HTTP mode locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# secret never printed to shell history if you use process substitution carefully
|
||||||
|
export USER_ENGINE_BASE_URL=https://users.92-205-62-239.nip.io
|
||||||
|
export USER_ENGINE_PROXY_SECRET="$(kubectl -n user-engine get secret user-engine-runtime \
|
||||||
|
-o jsonpath='{.data.proxy-secret}' | base64 -d)"
|
||||||
|
export USER_ENGINE_EXPECTED_AUDIENCE=user-engine-portal
|
||||||
|
# then OIDC or dev-login
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenBao lane for the proxy secret (future): document under railiance-platform
|
||||||
|
workload KV; until then kubectl-sourced secret is operator-only on the workstation.
|
||||||
|
|
||||||
## Profile attributes (shell only)
|
## Profile attributes (shell only)
|
||||||
|
|
||||||
- display name (from OIDC `name` / `preferred_username`)
|
- display name (OIDC `name` / user-engine user.display_name)
|
||||||
- email (optional)
|
- email (optional)
|
||||||
|
|
||||||
Bubble content fields are **not** part of this binding.
|
Bubble content fields are **not** part of this binding.
|
||||||
|
|
||||||
|
## Live probe (2026-08-09)
|
||||||
|
|
||||||
|
`GET /api/v1/me` with probe claims returned `user_id` `usr_…` and
|
||||||
|
`actor.tenant=tenant:coulomb` (HTTP 200).
|
||||||
|
|
|
||||||
96
tests/test_user_engine.py
Normal file
96
tests/test_user_engine.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
from coulomb_social.apps.identity.user_engine import (
|
||||||
|
HttpUserEngineClient,
|
||||||
|
IdentityClaims,
|
||||||
|
StubUserEngineClient,
|
||||||
|
get_user_engine_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stub_is_deterministic():
|
||||||
|
c = StubUserEngineClient()
|
||||||
|
claims = IdentityClaims(issuer="https://iss", subject="sub-1", name="A")
|
||||||
|
a = c.link_or_create(claims, tenant_id="tenant:coulomb", application_id="coulomb-social")
|
||||||
|
b = c.link_or_create(claims, tenant_id="tenant:coulomb", application_id="coulomb-social")
|
||||||
|
assert a.user_id == b.user_id
|
||||||
|
assert a.source == "stub"
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_client_calls_me(httpx_mock=None):
|
||||||
|
"""Manual transport mock without pytest-httpx plugin."""
|
||||||
|
claims = IdentityClaims(
|
||||||
|
issuer="https://kc.coulomb.social",
|
||||||
|
subject="sub-http",
|
||||||
|
name="Http User",
|
||||||
|
email="h@example.com",
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
principal_type="human",
|
||||||
|
roles=("user",),
|
||||||
|
authorized_party="coulomb-social",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path == "/api/v1/me"
|
||||||
|
assert request.headers["X-User-Engine-Proxy-Secret"] == "secret-value-at-least-24-chars"
|
||||||
|
raw = json.loads(request.headers["X-Verified-Oidc-Claims"])
|
||||||
|
assert raw["iss"] == claims.issuer
|
||||||
|
assert raw["sub"] == claims.subject
|
||||||
|
assert raw["tenant"] == "tenant:coulomb"
|
||||||
|
assert "user-engine-portal" in raw["aud"]
|
||||||
|
assert "coulomb-social" in raw["aud"]
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"user": {
|
||||||
|
"user_id": "usr_test_123",
|
||||||
|
"display_name": "Http User",
|
||||||
|
"primary_email": "h@example.com",
|
||||||
|
},
|
||||||
|
"actor": {"tenant": "tenant:coulomb", "subject": claims.subject},
|
||||||
|
"account": {},
|
||||||
|
"identities": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client = HttpUserEngineClient("https://users.example.test", "secret-value-at-least-24-chars")
|
||||||
|
|
||||||
|
# inject transport by patching Client used inside method
|
||||||
|
import coulomb_social.apps.identity.user_engine as ue
|
||||||
|
|
||||||
|
real_client = httpx.Client
|
||||||
|
|
||||||
|
def client_factory(*args, **kwargs):
|
||||||
|
kwargs["transport"] = transport
|
||||||
|
return real_client(*args, **kwargs)
|
||||||
|
|
||||||
|
original = ue.httpx.Client
|
||||||
|
ue.httpx.Client = client_factory # type: ignore[misc]
|
||||||
|
try:
|
||||||
|
link = client.link_or_create(
|
||||||
|
claims, tenant_id="tenant:coulomb", application_id="coulomb-social"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
ue.httpx.Client = original # type: ignore[misc]
|
||||||
|
|
||||||
|
assert link.user_id == "usr_test_123"
|
||||||
|
assert link.source == "http"
|
||||||
|
assert link.display_name == "Http User"
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(USER_ENGINE_BASE_URL="", USER_ENGINE_PROXY_SECRET="")
|
||||||
|
def test_get_client_stub_when_unconfigured():
|
||||||
|
assert isinstance(get_user_engine_client(), StubUserEngineClient)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
USER_ENGINE_BASE_URL="https://users.example.test",
|
||||||
|
USER_ENGINE_PROXY_SECRET="secret-value-at-least-24-chars",
|
||||||
|
)
|
||||||
|
def test_get_client_http_when_configured():
|
||||||
|
assert isinstance(get_user_engine_client(), HttpUserEngineClient)
|
||||||
|
|
@ -154,7 +154,7 @@ when running the app with `OIDC_ENABLED=true`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CSOC-WP-0002-T04
|
id: CSOC-WP-0002-T04
|
||||||
status: progress
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "bbf8183c-2dd6-448b-a257-8d837d2d5ebf"
|
state_hub_task_id: "bbf8183c-2dd6-448b-a257-8d837d2d5ebf"
|
||||||
```
|
```
|
||||||
|
|
@ -176,6 +176,12 @@ steps are outside this repo’s authority (NK-WP-0023/0024, USER-WP-*).
|
||||||
**Done when:** login yields a resolvable user-engine projection usable by the
|
**Done when:** login yields a resolvable user-engine projection usable by the
|
||||||
app; second login is idempotent (same user_id).
|
app; second login is idempotent (same user_id).
|
||||||
|
|
||||||
|
2026-08-09: `HttpUserEngineClient` calls live user-engine
|
||||||
|
`GET /api/v1/me` with `X-User-Engine-Proxy-Secret` + verified claims
|
||||||
|
envelope (iss/sub/tenant/principal_type/aud including user-engine-portal).
|
||||||
|
Offline stub remains when URL/secret unset. Live probe created
|
||||||
|
`usr_…` under `tenant:coulomb`.
|
||||||
|
|
||||||
## T05 — App auth module (OIDC session boundary)
|
## T05 — App auth module (OIDC session boundary)
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue