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

@ -4,7 +4,7 @@
SECRET_KEY=change-me
DEBUG=true
DATABASE_URL=sqlite:///db.sqlite3
DEFAULT_TENANT_ID=binky
DEFAULT_TENANT_ID=tenant:coulomb
# --- NetKingdom OIDC (KeyCape) ---
# 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
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=

View file

@ -17,7 +17,7 @@
| 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-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-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 |

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

View file

@ -89,8 +89,8 @@ LOGIN_URL = "identity:login"
LOGIN_REDIRECT_URL = "core:app_home"
LOGOUT_REDIRECT_URL = "core:landing"
# ── Tenant (Binky = client #1) ──────────────────────────────────────────────
DEFAULT_TENANT_ID = config("DEFAULT_TENANT_ID", default="binky")
# ── Tenant (KeyCape default platform tenant; Binky friendly slug later) ─────
DEFAULT_TENANT_ID = config("DEFAULT_TENANT_ID", default="tenant:coulomb")
# ── NetKingdom identity (see ADR-0001) ──────────────────────────────────────
# 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_SECRET = config("OIDC_CLIENT_SECRET", 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
# 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_PROXY_SECRET = config("USER_ENGINE_PROXY_SECRET", default="")
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_BASE_URL = config("FLEX_AUTH_BASE_URL", default="")

View file

@ -12,6 +12,7 @@
<dt>Issuer</dt><dd>{{ principal.issuer }}</dd>
<dt>Subject</dt><dd>{{ principal.subject }}</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>
</div>
{% endblock %}

View file

@ -23,6 +23,9 @@
<label>Preferred username
<input type="text" name="preferred_username" value="dev">
</label>
<label>Tenant claim
<input type="text" name="tenant" value="{{ default_tenant }}">
</label>
<p style="margin-top:1.25rem;">
<button class="btn" type="submit">Establish session</button>
</p>

View file

@ -33,14 +33,16 @@ See `.env.example`. Summary:
|----------|---------|---------|
| `SECRET_KEY` | insecure dev default | Django secret |
| `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_ISSUER` | | e.g. `https://kc.coulomb.social` |
| `OIDC_CLIENT_ID` | | `coulomb-social` |
| `OIDC_CLIENT_SECRET` | empty | **public client** — leave empty |
| `OIDC_REDIRECT_URI` | | `http://127.0.0.1:8008/auth/callback/` |
| `USER_ENGINE_BASE_URL` | empty (stub) | user-engine HTTP |
| `USER_ENGINE_APPLICATION_ID` | `coulomb-social` | App id in user-engine |
| `USER_ENGINE_BASE_URL` | empty (stub) | e.g. `https://users.92-205-62-239.nip.io` |
| `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 |
### Platform OIDC (KeyCape)

View file

@ -4,20 +4,53 @@
|-------|--------|
| application id | `coulomb-social` (`USER_ENGINE_APPLICATION_ID`) |
| display name | coulomb.social |
| tenant (v1) | `binky` (client #1) |
| identity link | `(issuer, sub)` `user_engine_user_id` on first login |
| tenant (v1) | `tenant:coulomb` (KeyCape default; Binky friendly slug later) |
| identity link | `(issuer, sub)` via user-engine `GET /api/v1/me` auto-provision |
| local row | `members.Member` (no passwords) |
| live base URL | `https://users.92-205-62-239.nip.io` (cluster ingress) |
## Runtime
| Mode | Behavior |
|------|----------|
| `USER_ENGINE_BASE_URL` empty | `StubUserEngineClient` — deterministic user id from issuer+sub hash |
| URL set | `HttpUserEngineClient` placeholder (T04: wire real link/projection API) |
| Mode | When | Behavior |
|------|------|----------|
| **stub** | `USER_ENGINE_BASE_URL` or `USER_ENGINE_PROXY_SECRET` empty | Deterministic `user_id` from sha256(iss\|sub) |
| **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)
- display name (from OIDC `name` / `preferred_username`)
- display name (OIDC `name` / user-engine user.display_name)
- email (optional)
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
View 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)

View file

@ -154,7 +154,7 @@ when running the app with `OIDC_ENABLED=true`.
```task
id: CSOC-WP-0002-T04
status: progress
status: done
priority: high
state_hub_task_id: "bbf8183c-2dd6-448b-a257-8d837d2d5ebf"
```
@ -176,6 +176,12 @@ steps are outside this repos authority (NK-WP-0023/0024, USER-WP-*).
**Done when:** login yields a resolvable user-engine projection usable by the
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)
```task