fix: expose operator navigation and protected portal logout
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 52s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-12 00:37:17 +02:00
parent 9657b72067
commit 655dce7165
4 changed files with 266 additions and 7 deletions

View file

@ -9,6 +9,7 @@ of user-engine.
from __future__ import annotations
from contextvars import ContextVar
from dataclasses import asdict, is_dataclass, replace
from enum import Enum
from html import escape
@ -44,6 +45,9 @@ from user_engine.service import PLATFORM_TENANT, UserEngineService
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
# Rendering state is scoped to one request, including concurrent WSGI requests.
_ACCOUNT_NAVIGATION: ContextVar[str] = ContextVar("account_navigation", default="")
def _jsonable(value: Any) -> Any:
if is_dataclass(value):
@ -107,6 +111,7 @@ class PortalApplication:
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
navigation_token = _ACCOUNT_NAVIGATION.set("")
try:
return self._dispatch(environ, start_response, str(correlation_id))
except ConflictError as exc:
@ -127,6 +132,8 @@ class PortalApplication:
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
except (json.JSONDecodeError, UnicodeDecodeError):
return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id)
finally:
_ACCOUNT_NAVIGATION.reset(navigation_token)
def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
@ -181,17 +188,43 @@ class PortalApplication:
]
start_response("303 See Other", headers)
return [b""]
if path == "/logged-out" and method == "GET":
if self._optional_actor(environ) is not None:
return self._redirect(start_response, "/", correlation_id)
return self._html(start_response, self._page_html(
"Logged out",
'<h1>You have logged out.</h1>'
'<p>Your portal session has ended. Your shared NetKingdom sign-in may still be active.</p>'
'<p>To sign in as another account while keeping that session, open this portal in a private browser window.</p>'
'<p><a class="button" href="/login">Sign in</a></p>',
), correlation_id)
if path == "/logout" and method == "GET":
actor = self._optional_actor(environ)
if actor is None:
return self._redirect(start_response, "/logged-out", correlation_id)
self._set_account_navigation(environ, actor)
token = self._csrf_token(environ)
return self._html(start_response, self._page_html(
"Log out", '<h1>Log out of this portal?</h1>'
'<p>This ends your portal session. Your shared NetKingdom sign-in stays active.</p>'
'<form method="post" action="/logout">'
f'<input type="hidden" name="csrf_token" value="{escape(token)}">'
'<button type="submit">Log out</button></form>',
), correlation_id)
if path == "/logout" and method == "POST":
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
if session_id and self.oidc_client:
if session_id and self.oidc_client and self.oidc_client.claims(session_id) is not None:
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
self.oidc_client.logout(session_id)
start_response(
"303 See Other",
[("Location", "/"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)],
[("Location", "/logged-out"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)],
)
return [b""]
if path == "/" and method == "GET":
actor = self._optional_actor(environ)
self._set_account_navigation(environ, actor)
return self._html(start_response, self._home(actor), correlation_id)
if path == "/register" and method == "GET":
@ -269,6 +302,7 @@ class PortalApplication:
)
actor = self._actor(environ)
self._set_account_navigation(environ, actor)
if path == "/api/v1/me" and method == "GET":
return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id)
if path == "/api/v1/me/profile" and method == "PATCH":
@ -703,12 +737,17 @@ class PortalApplication:
)
if path == "/platform/tenant" and method == "GET":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
lookup = parse_qs(str(environ.get("QUERY_STRING", ""))).get("tenant", [""])[0]
query = parse_qs(str(environ.get("QUERY_STRING", "")))
lookup = query.get("tenant", [""])[0]
view = query.get("view", ["lifecycle"])[0]
if view not in {"lifecycle", "users"}:
raise ValidationError("unknown tenant view")
if not lookup.startswith("tenant:") or lookup == PLATFORM_TENANT:
raise ValidationError("a non-platform tenant identifier is required")
return self._redirect(
start_response,
"/platform/tenants/" + quote(lookup, safe=""), correlation_id,
("/admin/" if view == "users" else "/platform/tenants/")
+ quote(lookup, safe=""), correlation_id,
)
if path.startswith("/platform/tenants/") and method in {"GET", "POST"}:
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
@ -1667,7 +1706,8 @@ class PortalApplication:
<section aria-labelledby="manage-tenant"><h2 id="manage-tenant">Manage an existing tenant</h2>
<form method="get" action="/platform/tenant">
<label>Tenant identifier <input name="tenant" required pattern="tenant:.+" placeholder="tenant:friendly:example"></label>
<button type="submit">Open tenant lifecycle</button></form>
<button type="submit" name="view" value="users">Manage users</button>
<button type="submit" name="view" value="lifecycle">Open tenant lifecycle</button></form>
<p>Tenant records, metadata, and retirement are owned by the tenant authority.</p></section>""",
)
@ -1694,6 +1734,7 @@ class PortalApplication:
f"Tenant {record.tenant}",
f"""<h1>{escape(record.tenant)}</h1>
<p>Lifecycle <strong>{escape(record.lifecycle)}</strong> at version {record.version}.</p>
<p><a class="button" href="/admin/{escape(quote(record.tenant, safe=''))}">Manage users</a></p>
<p>Grouping <strong>{escape(record.grouping or 'not reported')}</strong>, as reported by the tenant authority. The identifier's own segment is historical after a reclassification and is not the grouping.</p>
{replayed}
{metadata_form}
@ -1720,10 +1761,15 @@ class PortalApplication:
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
selected_tenant: str, csrf_token: str,
) -> str:
platform_operator = "platform-operator" in session.actor.roles
empty_memberships = (
"<li>You have no personal tenant memberships. Your platform operator role lets you manage tenants through platform administration.</li>"
if platform_operator else "<li>No tenant memberships yet.</li>"
)
membership_items = "".join(
f"<li><a href=\"/onboarding?{urlencode({'tenant': item.tenant})}\">{escape(item.tenant)}</a> — {escape(item.kind)}</li>"
for item in memberships
) or "<li>No tenant memberships yet.</li>"
) or empty_memberships
journey_items = "".join(
self._onboarding_journey_item(item, csrf_token) for item in journeys
) or "<li>No additional onboarding steps are required.</li>"
@ -1790,6 +1836,25 @@ class PortalApplication:
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
return [b""]
def _set_account_navigation(self, environ: Mapping[str, Any], actor: Any | None) -> None:
if actor is None:
_ACCOUNT_NAVIGATION.set("")
return
links = '<a href="/">Home</a><a href="/onboarding">My account</a>'
if "platform-operator" in actor.roles:
links += '<a href="/platform">Platform administration</a>'
elif "tenant-admin" in actor.roles:
links += f'<a href="/admin/{escape(quote(actor.tenant, safe=""))}">Manage users</a>'
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
csrf = self.oidc_client.csrf_token(session_id or "") if self.oidc_client else None
if csrf:
links += (
'<form method="post" action="/logout">'
f'<input type="hidden" name="csrf_token" value="{escape(csrf)}">'
'<button type="submit">Log out</button></form>'
)
_ACCOUNT_NAVIGATION.set('<nav aria-label="Account navigation">' + links + '</nav>')
@staticmethod
def _page_html(title: str, body: str) -> str:
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
@ -1798,13 +1863,14 @@ class PortalApplication:
:root{{--ink:#17201c;--paper:#f5f1e8;--accent:#195b47;--line:#c8c1b3}}
*{{box-sizing:border-box}}body{{margin:0;background:var(--paper);color:var(--ink);font:18px/1.55 system-ui,sans-serif}}
header,main{{max-width:68rem;margin:auto;padding:1.25rem}}header{{border-bottom:1px solid var(--line)}}
header nav{{display:flex;align-items:center;flex-wrap:wrap;gap:.75rem 1.25rem;margin-top:.75rem}}header nav form{{margin:0}}header nav button{{padding:.45rem .8rem}}
h1{{font:clamp(2.2rem,7vw,5.5rem)/.98 Georgia,serif;max-width:13ch}}a{{color:var(--accent)}}
.button{{display:inline-block;background:var(--accent);color:white;padding:.8rem 1.15rem;border-radius:.3rem;text-decoration:none}}
table{{width:100%;border-collapse:collapse;background:#fff}}th,td{{padding:.75rem;text-align:left;border-bottom:1px solid var(--line)}}
section{{margin:2rem 0}}form{{display:grid;gap:.8rem;max-width:42rem}}label{{display:grid;gap:.25rem}}
input,select,button{{font:inherit;padding:.65rem}}button{{background:var(--accent);color:white;border:0;border-radius:.3rem;cursor:pointer}}
a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}table{{display:block;overflow-x:auto}}}}
</style></head><body><header><strong>Railiance identity</strong></header><main>{body}</main></body></html>"""
</style></head><body><header><strong>Railiance identity</strong>{_ACCOUNT_NAVIGATION.get()}</header><main>{body}</main></body></html>"""
def _html(
self, start_response: StartResponse, body: str, correlation_id: str,