diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 1542544..09189b5 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -33,6 +33,7 @@ | workplan | USER-WP-0022 | finished | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | | workplan | USER-WP-0023 | finished | — | workplans/USER-WP-0023-flex-auth-caller-identity.md | | workplan | USER-WP-0024 | finished | — | workplans/USER-WP-0024-security-layer-conformance.md | +| workplan | USER-WP-0025 | active | — | workplans/USER-WP-0025-operator-navigation-and-logout.md | | task | USER-WP-ADHOC-2026-09-06-T01 | done | — | workplans/ADHOC-2026-09-06.md | | task | USER-WP-0001-T1 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md | | task | USER-WP-0001-T2 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md | @@ -178,4 +179,7 @@ | task | USER-WP-0024-T04 | done | — | workplans/USER-WP-0024-security-layer-conformance.md | | task | USER-WP-0024-T05 | done | — | workplans/USER-WP-0024-security-layer-conformance.md | | task | USER-WP-0024-T06 | done | — | workplans/USER-WP-0024-security-layer-conformance.md | +| task | USER-WP-0025-T01 | done | — | workplans/USER-WP-0025-operator-navigation-and-logout.md | +| task | USER-WP-0025-T02 | progress | — | workplans/USER-WP-0025-operator-navigation-and-logout.md | +| task | USER-WP-0025-T03 | todo | — | workplans/USER-WP-0025-operator-navigation-and-logout.md | | intake | USER-IN-0001 | answered | — | intakes/intakes.md | diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 464eb9d..f0c114e 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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", + '
Your portal session has ended. Your shared NetKingdom sign-in may still be active.
' + 'To sign in as another account while keeping that session, open this portal in a private browser window.
' + '', + ), 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", 'This ends your portal session. Your shared NetKingdom sign-in stays active.
' + '', + ), 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:Tenant records, metadata, and retirement are owned by the tenant authority.
Lifecycle {escape(record.lifecycle)} at version {record.version}.
+Grouping {escape(record.grouping or 'not reported')}, as reported by the tenant authority. The identifier's own segment is historical after a reclassification and is not the grouping.
{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 = ( + "