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", + '

You have 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.

' + '

Sign in

', + ), 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", '

Log out of this portal?

' + '

This ends your portal session. Your shared NetKingdom sign-in stays active.

' + '
' + f'' + '
', + ), 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:

Manage an existing tenant

-
+ +

Tenant records, metadata, and retirement are owned by the tenant authority.

""", ) @@ -1694,6 +1734,7 @@ class PortalApplication: f"Tenant {record.tenant}", f"""

{escape(record.tenant)}

Lifecycle {escape(record.lifecycle)} at version {record.version}.

+

Manage users

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 = ( + "
  • You have no personal tenant memberships. Your platform operator role lets you manage tenants through platform administration.
  • " + if platform_operator else "
  • No tenant memberships yet.
  • " + ) membership_items = "".join( f"
  • {escape(item.tenant)} — {escape(item.kind)}
  • " for item in memberships - ) or "
  • No tenant memberships yet.
  • " + ) or empty_memberships journey_items = "".join( self._onboarding_journey_item(item, csrf_token) for item in journeys ) or "
  • No additional onboarding steps are required.
  • " @@ -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 = 'HomeMy account' + if "platform-operator" in actor.roles: + links += 'Platform administration' + elif "tenant-admin" in actor.roles: + links += f'Manage users' + 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 += ( + '
    ' + f'' + '
    ' + ) + _ACCOUNT_NAVIGATION.set('') + @staticmethod def _page_html(title: str, body: str) -> str: return f""" @@ -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}}}} -
    Railiance identity
    {body}
    """ +
    Railiance identity{_ACCOUNT_NAVIGATION.get()}
    {body}
    """ def _html( self, start_response: StartResponse, body: str, correlation_id: str, diff --git a/tests/test_portal_navigation.py b/tests/test_portal_navigation.py new file mode 100644 index 0000000..52f5081 --- /dev/null +++ b/tests/test_portal_navigation.py @@ -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() diff --git a/workplans/USER-WP-0025-operator-navigation-and-logout.md b/workplans/USER-WP-0025-operator-navigation-and-logout.md new file mode 100644 index 0000000..f6ab994 --- /dev/null +++ b/workplans/USER-WP-0025-operator-navigation-and-logout.md @@ -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.