From d88767f05b0824caae9db0334eca17772f11806b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 9 Aug 2026 01:56:44 +0200 Subject: [PATCH] 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. --- .env.example | 7 +- WORK-RECORDS.md | 2 +- coulomb_social/apps/core/views.py | 1 + coulomb_social/apps/identity/services.py | 23 +++- coulomb_social/apps/identity/user_engine.py | 114 +++++++++++++++--- coulomb_social/apps/identity/views.py | 51 ++++++-- coulomb_social/settings/base.py | 12 +- coulomb_social/templates/core/app_home.html | 1 + .../templates/identity/dev_login.html | 3 + docs/dev.md | 8 +- docs/identity/user-engine-binding.md | 47 ++++++-- tests/test_user_engine.py | 96 +++++++++++++++ ...-netkingdom-user-management-reestablish.md | 8 +- 13 files changed, 325 insertions(+), 48 deletions(-) create mode 100644 tests/test_user_engine.py diff --git a/.env.example b/.env.example index ecf1d51..67f1def 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 5e07e2e..d41844b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/coulomb_social/apps/core/views.py b/coulomb_social/apps/core/views.py index 60fb1c8..52cda88 100644 --- a/coulomb_social/apps/core/views.py +++ b/coulomb_social/apps/core/views.py @@ -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", ""), }, }, ) diff --git a/coulomb_social/apps/identity/services.py b/coulomb_social/apps/identity/services.py index ad973f1..76c81d0 100644 --- a/coulomb_social/apps/identity/services.py +++ b/coulomb_social/apps/identity/services.py @@ -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 diff --git a/coulomb_social/apps/identity/user_engine.py b/coulomb_social/apps/identity/user_engine.py index 00c23e1..5dd23e4 100644 --- a/coulomb_social/apps/identity/user_engine.py +++ b/coulomb_social/apps/identity/user_engine.py @@ -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() diff --git a/coulomb_social/apps/identity/views.py b/coulomb_social/apps/identity/views.py index de89285..5e4c114 100644 --- a/coulomb_social/apps/identity/views.py +++ b/coulomb_social/apps/identity/views.py @@ -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"]) diff --git a/coulomb_social/settings/base.py b/coulomb_social/settings/base.py index 8b3e158..7716f35 100644 --- a/coulomb_social/settings/base.py +++ b/coulomb_social/settings/base.py @@ -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="") diff --git a/coulomb_social/templates/core/app_home.html b/coulomb_social/templates/core/app_home.html index ab4079f..f532719 100644 --- a/coulomb_social/templates/core/app_home.html +++ b/coulomb_social/templates/core/app_home.html @@ -12,6 +12,7 @@
Issuer
{{ principal.issuer }}
Subject
{{ principal.subject }}
user-engine id
{{ principal.user_engine_user_id }}
+
user-engine source
{{ principal.user_engine_source|default:"—" }}
{% endblock %} diff --git a/coulomb_social/templates/identity/dev_login.html b/coulomb_social/templates/identity/dev_login.html index 2bd7303..95f13e2 100644 --- a/coulomb_social/templates/identity/dev_login.html +++ b/coulomb_social/templates/identity/dev_login.html @@ -23,6 +23,9 @@ +

diff --git a/docs/dev.md b/docs/dev.md index 9601c8c..a5956f0 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -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) diff --git a/docs/identity/user-engine-binding.md b/docs/identity/user-engine-binding.md index cb63f47..a534b58 100644 --- a/docs/identity/user-engine-binding.md +++ b/docs/identity/user-engine-binding.md @@ -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: +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). diff --git a/tests/test_user_engine.py b/tests/test_user_engine.py new file mode 100644 index 0000000..2097d89 --- /dev/null +++ b/tests/test_user_engine.py @@ -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) diff --git a/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md b/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md index b4b1778..9911f62 100644 --- a/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md +++ b/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md @@ -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 repo’s 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