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