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

@ -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 |

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,

View file

@ -0,0 +1,104 @@
"""Browser navigation and logout must preserve the authenticated authority boundary."""
import unittest
from urllib.parse import urlencode
from test_web import FakeTenantManagement, invoke
from user_engine.adapters import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.oidc import BrowserSession, OIDCClient
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.web import PortalApplication
class PortalNavigationTests(unittest.TestCase):
def setUp(self):
store = InMemoryUserEngineStore()
store.migrate()
self.app = PortalApplication(
UserEngineService(store=store, identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort()),
trusted_proxy_secret='synthetic-marker-with-adequate-length', login_url='https://kc.example/login',
tenant_management=FakeTenantManagement(),
)
self.oidc = OIDCClient(issuer='https://kc.example', client_id='portal',
redirect_uri='https://users.example/oidc/callback', audience='portal')
self.app.oidc_client = self.oidc
for key, tenant, roles in [
('operator', 'tenant:platform:root', ['platform-operator']),
('member', 'tenant:trial:demo-company', ['user']),
]:
claims = human_actor_claims(subject=key, tenant=tenant)
claims['roles'] = roles
self.oidc.sessions[key] = BrowserSession(claims, 9999999999, key+'-csrf')
def get(self, path, who='operator', query=''):
return invoke(self.app, path, cookie='ue_session='+who, query=query)
def test_operator_can_reach_administration_without_personal_membership(self):
for path in ['/', '/onboarding', '/platform', '/admin/tenant:trial:demo-company']:
response, body = self.get(path)
self.assertEqual('200 OK', response['status'], path)
self.assertIn(b'href="/platform"', body)
self.assertIn(b'action="/logout"', body)
self.assertIn(b'value="operator-csrf"', body)
_, body = self.get('/onboarding')
self.assertIn(b'no personal tenant memberships', body)
self.assertIn(b'platform operator role', body)
self.assertFalse(self.app.service.store.memberships_for_tenant('tenant:trial:demo-company'))
def test_existing_tenant_user_navigation_preserves_authority(self):
query=urlencode({'tenant':'tenant:trial:demo-company','view':'users'})
response, _ = self.get('/platform/tenant', query=query)
self.assertEqual('/admin/tenant%3Atrial%3Ademo-company',response['headers']['Location'])
denied, _ = self.get('/platform/tenant',who='member',query=query)
self.assertEqual('403 Forbidden',denied['status'])
invalid, _ = self.get('/platform/tenant',query=urlencode({'tenant':'tenant:trial:demo-company','view':'https://evil.test'}))
self.assertEqual('400 Bad Request',invalid['status'])
def test_navigation_does_not_leak_between_operator_member_and_anonymous(self):
self.get('/platform')
_, member = self.get('/onboarding',who='member')
self.assertNotIn(b'href="/platform"',member)
self.assertNotIn(b'operator-csrf',member)
self.assertIn(b'value="member-csrf"',member)
self.assertIn(b'No tenant memberships yet.',member)
_, anonymous = invoke(self.app,'/')
self.assertNotIn(b'action="/logout"',anonymous)
self.assertNotIn(b'href="/platform"',anonymous)
self.assertNotIn(b'member-csrf',anonymous)
def test_get_logout_only_confirms_and_bad_csrf_does_not_end_session(self):
response, body = self.get('/logout')
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Log out of this portal?',body)
self.assertIsNotNone(self.oidc.claims('operator'))
for token in ['', 'wrong', 'member-csrf']:
response,_=invoke(self.app,'/logout',method='POST',cookie='ue_session=operator',form={'csrf_token':token})
self.assertEqual('403 Forbidden',response['status'])
self.assertIsNotNone(self.oidc.claims('operator'))
def test_logout_invalidates_only_the_current_session_and_deletes_cookie(self):
response,_=invoke(self.app,'/logout',method='POST',cookie='ue_session=operator',form={'csrf_token':'operator-csrf'})
self.assertEqual('303 See Other',response['status'])
self.assertEqual('/logged-out',response['headers']['Location'])
cookie=response['headers']['Set-Cookie']
for attribute in ['ue_session=;', 'Path=/', 'Secure', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']:
self.assertIn(attribute,cookie)
self.assertIsNone(self.oidc.claims('operator'))
self.assertIsNotNone(self.oidc.claims('member'))
denied,_=self.get('/platform')
self.assertEqual('403 Forbidden',denied['status'])
page,body=invoke(self.app,'/logged-out')
self.assertEqual('200 OK',page['status'])
self.assertIn(b'shared NetKingdom sign-in may still be active',body)
self.assertNotIn(b'action="/logout"',body)
def test_expired_session_logout_clears_stale_cookie(self):
self.oidc.sessions['operator'].expires_at=0
response,_=invoke(self.app,'/logout',method='POST',cookie='ue_session=operator',form={})
self.assertEqual('303 See Other',response['status'])
self.assertIn('Max-Age=0',response['headers']['Set-Cookie'])
self.assertNotIn('operator',self.oidc.sessions)
if __name__=='__main__':
unittest.main()

View file

@ -0,0 +1,85 @@
---
id: USER-WP-0025
type: workplan
title: "Make operator navigation and portal logout usable during demo onboarding"
domain: communication
repo: user-engine
status: active
owner: the-custodian
topic_slug: user-engine
created: "2026-09-11"
updated: "2026-09-12"
related: [RAPPS-WP-0014, USER-WP-0020, KEY-WP-0025]
state_hub_workstream_id: "85391398-f5be-551b-b90d-c8e987a00098"
---
During demo-company onboarding the operator signs in as platform-root, sees
“No tenant memberships yet”, cannot find the existing tenant, and requests a
logout control. Source confirms that personal memberships are distinct from the
platform-operator role. Read-only live consumer verification confirms the tenant
active with two memberships (one administrator and one ordinary user), neither
linked to a directory identity yet. Do not add a customer membership to the
platform operator to work around missing navigation.
## Expose authorized navigation and a protected portal logout
```task
id: USER-WP-0025-T01
status: done
priority: high
state_hub_task_id: "2f40807f-5442-5bca-8c38-c2d084f8e1ef"
```
Render shared navigation on authenticated pages, with platform administration
only for verified platform operators, tenant user management for tenant admins,
and an accessible POST logout form using the current browser session's CSRF
value. Keep navigation request-scoped and clear it even after failures. Explain
personal memberships to operators and add a Manage users route from the existing
platform tenant lookup/lifecycle page. Keep authority checks unchanged.
GET /logout only displays confirmation. POST verifies CSRF for an active session,
invalidates that session server-side, expires its cookie, and leads to a logged-out
page explaining the remaining shared sign-in session. Expired sessions may clear
their stale cookie without affecting another session. This is portal logout;
provider-wide sign-out/account switching remains the explicit next task below.
Validation: make test passes (175 tests, three optional integration skips) and
layer conformance passes. Six regressions cover operator access without membership,
ordinary-user denial, navigation isolation, logout CSRF, session invalidation and
expired-session cleanup. Eight local Chromium checks pass for operator guidance, navigation to tenant
users, mobile navigation, visible logout, session cookie removal and denied
access after logout. Immutable image promotion and native verification are T02.
## Publish and verify the visible change on the native portal
```task
id: USER-WP-0025-T02
status: progress
priority: high
state_hub_task_id: "67e2feca-ffcd-55a0-b0ce-2bab43c862e4"
```
Check the browser flow on a local synthetic fixture, publish the exact source
through existing Forgejo image CI, pin the resulting digest in rapp-user-engine,
and promote only the current User Engine Deployment's image. Preserve requests,
service identity, database, policies and secrets. Verify health/readiness and
native operator navigation/logout when the operator is available. Deployment
restarts invalidate the portal's in-memory sessions, so provide the sign-in URL.
Actual demo-user Create login and password setup remain RAPPS-WP-0014-T02.
## Coordinate provider-wide sign-out and account switching
```task
id: USER-WP-0025-T03
status: todo
priority: medium
state_hub_task_id: "f5ee70b9-f169-5316-af3e-5dbbdd394d57"
```
Coordinate the relying-party, KeyCape and Authelia session boundaries so one
explicit user action can end all relevant browser sessions and allow another
account to authenticate. KeyCape currently documents local-only logout in
KEY-WP-0025 and docs/operations.md. Do not claim global logout or JWT revocation
from User Engine's cookie deletion. Use registered return locations and verify
same-account login cannot silently reappear after complete sign-out. Preserve
session-only logout for users who intend to keep their other applications open.