fix: expose operator navigation and protected portal logout
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
9657b72067
commit
655dce7165
4 changed files with 266 additions and 7 deletions
|
|
@ -33,6 +33,7 @@
|
||||||
| workplan | USER-WP-0022 | finished | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
|
| 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-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-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-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-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 |
|
| 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-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-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-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 |
|
| intake | USER-IN-0001 | answered | — | intakes/intakes.md |
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ of user-engine.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
from dataclasses import asdict, is_dataclass, replace
|
from dataclasses import asdict, is_dataclass, replace
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
@ -44,6 +45,9 @@ from user_engine.service import PLATFORM_TENANT, UserEngineService
|
||||||
|
|
||||||
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
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:
|
def _jsonable(value: Any) -> Any:
|
||||||
if is_dataclass(value):
|
if is_dataclass(value):
|
||||||
|
|
@ -107,6 +111,7 @@ class PortalApplication:
|
||||||
|
|
||||||
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
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)}"
|
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
|
||||||
|
navigation_token = _ACCOUNT_NAVIGATION.set("")
|
||||||
try:
|
try:
|
||||||
return self._dispatch(environ, start_response, str(correlation_id))
|
return self._dispatch(environ, start_response, str(correlation_id))
|
||||||
except ConflictError as exc:
|
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)
|
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id)
|
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]:
|
def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
|
||||||
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
|
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
|
||||||
|
|
@ -181,17 +188,43 @@ class PortalApplication:
|
||||||
]
|
]
|
||||||
start_response("303 See Other", headers)
|
start_response("303 See Other", headers)
|
||||||
return [b""]
|
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":
|
if path == "/logout" and method == "POST":
|
||||||
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
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)
|
self.oidc_client.logout(session_id)
|
||||||
start_response(
|
start_response(
|
||||||
"303 See Other",
|
"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""]
|
return [b""]
|
||||||
if path == "/" and method == "GET":
|
if path == "/" and method == "GET":
|
||||||
actor = self._optional_actor(environ)
|
actor = self._optional_actor(environ)
|
||||||
|
self._set_account_navigation(environ, actor)
|
||||||
return self._html(start_response, self._home(actor), correlation_id)
|
return self._html(start_response, self._home(actor), correlation_id)
|
||||||
|
|
||||||
if path == "/register" and method == "GET":
|
if path == "/register" and method == "GET":
|
||||||
|
|
@ -269,6 +302,7 @@ class PortalApplication:
|
||||||
)
|
)
|
||||||
|
|
||||||
actor = self._actor(environ)
|
actor = self._actor(environ)
|
||||||
|
self._set_account_navigation(environ, actor)
|
||||||
if path == "/api/v1/me" and method == "GET":
|
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)
|
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":
|
if path == "/api/v1/me/profile" and method == "PATCH":
|
||||||
|
|
@ -703,12 +737,17 @@ class PortalApplication:
|
||||||
)
|
)
|
||||||
if path == "/platform/tenant" and method == "GET":
|
if path == "/platform/tenant" and method == "GET":
|
||||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
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:
|
if not lookup.startswith("tenant:") or lookup == PLATFORM_TENANT:
|
||||||
raise ValidationError("a non-platform tenant identifier is required")
|
raise ValidationError("a non-platform tenant identifier is required")
|
||||||
return self._redirect(
|
return self._redirect(
|
||||||
start_response,
|
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"}:
|
if path.startswith("/platform/tenants/") and method in {"GET", "POST"}:
|
||||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
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>
|
<section aria-labelledby="manage-tenant"><h2 id="manage-tenant">Manage an existing tenant</h2>
|
||||||
<form method="get" action="/platform/tenant">
|
<form method="get" action="/platform/tenant">
|
||||||
<label>Tenant identifier <input name="tenant" required pattern="tenant:.+" placeholder="tenant:friendly:example"></label>
|
<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>""",
|
<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"Tenant {record.tenant}",
|
||||||
f"""<h1>{escape(record.tenant)}</h1>
|
f"""<h1>{escape(record.tenant)}</h1>
|
||||||
<p>Lifecycle <strong>{escape(record.lifecycle)}</strong> at version {record.version}.</p>
|
<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>
|
<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}
|
{replayed}
|
||||||
{metadata_form}
|
{metadata_form}
|
||||||
|
|
@ -1720,10 +1761,15 @@ class PortalApplication:
|
||||||
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
|
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
|
||||||
selected_tenant: str, csrf_token: str,
|
selected_tenant: str, csrf_token: str,
|
||||||
) -> 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(
|
membership_items = "".join(
|
||||||
f"<li><a href=\"/onboarding?{urlencode({'tenant': item.tenant})}\">{escape(item.tenant)}</a> — {escape(item.kind)}</li>"
|
f"<li><a href=\"/onboarding?{urlencode({'tenant': item.tenant})}\">{escape(item.tenant)}</a> — {escape(item.kind)}</li>"
|
||||||
for item in memberships
|
for item in memberships
|
||||||
) or "<li>No tenant memberships yet.</li>"
|
) or empty_memberships
|
||||||
journey_items = "".join(
|
journey_items = "".join(
|
||||||
self._onboarding_journey_item(item, csrf_token) for item in journeys
|
self._onboarding_journey_item(item, csrf_token) for item in journeys
|
||||||
) or "<li>No additional onboarding steps are required.</li>"
|
) 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)])
|
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||||
return [b""]
|
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
|
@staticmethod
|
||||||
def _page_html(title: str, body: str) -> str:
|
def _page_html(title: str, body: str) -> str:
|
||||||
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
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}}
|
: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}}
|
*{{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,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)}}
|
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}}
|
.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)}}
|
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}}
|
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}}
|
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}}}}
|
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(
|
def _html(
|
||||||
self, start_response: StartResponse, body: str, correlation_id: str,
|
self, start_response: StartResponse, body: str, correlation_id: str,
|
||||||
|
|
|
||||||
104
tests/test_portal_navigation.py
Normal file
104
tests/test_portal_navigation.py
Normal 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()
|
||||||
85
workplans/USER-WP-0025-operator-navigation-and-logout.md
Normal file
85
workplans/USER-WP-0025-operator-navigation-and-logout.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue