From 85423e8e09cf2c5f432dabd8b74bf7f37d9f8a2a Mon Sep 17 00:00:00 2001
From: tegwick
Date: Sat, 26 Sep 2026 20:46:38 +0200
Subject: [PATCH] Show login state, active sign-in, and allowed memberships
separately.
USER-WP-0036 keeps the token tenant off the membership list and leaves workload decisions unchecked until the catalogue reports them.
Assistant: grok
Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f
---
docs/account-journeys.md | 9 +-
src/user_engine/web.py | 184 ++++++++++++++----
tests/journey-coverage.json | 11 +-
tests/test_account_awareness.py | 133 +++++++++++++
tests/test_account_recovery.py | 7 +-
tests/test_portal_navigation.py | 2 +-
...R-WP-0036-account-situational-awareness.md | 27 ++-
7 files changed, 320 insertions(+), 53 deletions(-)
create mode 100644 tests/test_account_awareness.py
diff --git a/docs/account-journeys.md b/docs/account-journeys.md
index dd1c430..860bd3a 100644
--- a/docs/account-journeys.md
+++ b/docs/account-journeys.md
@@ -3,7 +3,8 @@
Owner: user-engine, with KeyCape/NetKingdom for sign-in and factors,
tenant-engine for tenant lifecycle, and applications for workload admission.
Acceptance work: USER-WP-0027; OTP dependency: KEY-WP-0035 and NK-WP-0033.
-Reviewed against the portal on 2026-09-13. This is the browser acceptance contract;
+Reviewed against the portal on 2026-09-13. U01, U09 and U10 were revised on
+2026-09-26. This is the browser acceptance contract;
headless capability alone does not mean a journey is usable or verified live.
## Common interaction rules
@@ -33,7 +34,7 @@ headless capability alone does not mean a journey is usable or verified live.
| ID / intent | Success | Failure and recovery | Current support / acceptance |
|---|---|---|---|
-| U01 — Know whether I am signed in | Header names my verified account; exactly the appropriate Sign in or Log out control | Expired/unknown cookie shows signed-out state; sign in again | Implemented; automated anonymous/expired/member/operator tests |
+| U01 — Know whether I am signed in | Header and the home page name the verified portal identity, or say the portal session is absent. An application may keep its own session | Expired/unknown cookie shows signed-out state; a query does not invent a session; sign in again | Implemented; automated anonymous/expired/member/operator tests, including the home login-state section |
| U02 — Sign in to my company application | Personal login lands in the intended tenant and application | Wrong credentials stay on provider; denied membership leads to account help with identity switching | Recovery deployed previously; actual fresh-user acceptance waiting on OTP |
| U03 — Accept an invitation | Confirm intended tenant/role, accept once, then see next setup step | Expired/used/wrong-person invitation explains next step; admin reissues without duplicates | Service/browser routes exist; live delivery and full browser acceptance pending |
| U04 — Set or recover my password | Use actual login name, complete single-use setup, return to sign-in | Missing mail or expired link offers admin-assisted new setup link | Password setup reported successful; login name and sign-in address now named at handoff and in the user entry (2026-09-23 run, USER-WP-0035-T01); email delivery unresolved (USER-WP-0035-T02) |
@@ -41,8 +42,8 @@ headless capability alone does not mean a journey is usable or verified live.
| U06 — Turn on authenticator codes voluntarily | My account → Sign-in security → provider; confirm identity, scan QR, verify current code, see activation confirmed, test fresh login | Bad code retries; cancellation does not report enabled; interruption can resume safely; support reachable without portal login | Help and configurable provider handoff implemented; provider activation/cancel semantics and live enrollment unverified |
| U07 — Sign in with an enrolled authenticator | Current code completes login; existing AAL1 session cannot skip OTP | Invalid code explains retry; lost device has a recovery route | Issuer policy tested; real-user enrolled/recovery acceptance pending |
| U08 — Replace or remove my authenticator | Provider reauthenticates; replacement verified before old factor removed; status and recovery instructions clear | Lost old factor triggers verified recovery, not a bypass link; policy-required MFA cannot be disabled | Required provider journey; not verified/available from portal yet |
-| U09 — See my tenants and usable applications | Account page lists allowed tenants and launchable applications with the current context | Empty membership explains request-access route; unavailable/stale access is labelled and refreshable | Personal membership display exists; authoritative application catalogue/request-access journey pending USER-WP-0026-T03 |
-| U10 — Change tenant or account | Explicit tenant switch confirms new authority; account switch ends relevant sessions and lets me choose identity | Denied tenant leaves a clear recovery route; stale shared identity can be cleared | Tenant reauthentication and shared sign-out implemented; real multi-identity acceptance pending |
+| U09 — See my tenants and usable applications | Account page and home show login state, the tenants and privileges active on this sign-in, and allowed memberships separately. An ordinary sign-in has one active tenant. An administrator, vendor, or multi-hire sign-in may show more than one active tenant when the verified token lists them. A recorded workload membership is an allowed record | An empty allowed list says nothing is recorded. A tenant account or the token tenant is not shown as a membership. A missing catalogue decision is not checked, which is neither access nor a denial | Login state, active sign-in, and allowed memberships are shown (USER-WP-0036). Allow, deny, and unavailable workload decisions remain USER-WP-0026-T03 and USER-WP-0028-T02 |
+| U10 — Change tenant or account | Explicit reauthentication confirms the new tenant. Other allowed tenants stay inactive until that sign-in. Switching does not claim to end sessions an application already has | Denied tenant leaves a clear recovery route; stale shared identity can be cleared. A portal control does not mark a second tenant active by itself | Reauthentication handoff is the only tenant switch; real multi-identity acceptance pending |
| U11 — Sign out | Confirmation states scope; portal session ends; correct signed-out controls appear | CSRF rejection retains session; shared-provider failure explains remaining scope and retry | Portal automated tests; prior shared logout browser checks; current real-user acceptance pending |
| U12 — Recover from denied access or service outage | Plain explanation, reference for support, and account/help navigation | No automatic login loop; no private claims/codes echoed; safe retry only | HTML browser denial/recovery implemented; JSON API semantics retained |
| U13 — Update my profile and finish onboarding | Saved values and required steps are confirmed; external steps reflect provider evidence | Validation keeps safe input; provider-owned steps cannot be manually faked complete | Existing routes; form-preservation and external completion UX acceptance pending |
diff --git a/src/user_engine/web.py b/src/user_engine/web.py
index 87b5f83..5e609a7 100644
--- a/src/user_engine/web.py
+++ b/src/user_engine/web.py
@@ -276,7 +276,12 @@ class PortalApplication:
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)
+ memberships: tuple[Any, ...] = ()
+ if actor is not None:
+ identity = self.service.store.find_identity(*actor.identity_key)
+ if identity is not None:
+ memberships = self.service.store.memberships_for_user(identity.user_id)
+ return self._html(start_response, self._home(actor, memberships), correlation_id)
if path == "/register" and method == "GET":
if self._optional_actor(environ) is not None:
@@ -389,8 +394,7 @@ class PortalApplication:
return self._html(
start_response,
self._onboarding(
- session, memberships, journeys, str(selected_tenant),
- self._csrf_token(environ),
+ session, memberships, journeys, self._csrf_token(environ),
),
correlation_id,
)
@@ -1894,25 +1898,26 @@ If the recovery message does not arrive, ask your tenant administrator for a new
Use the login name they provide; it may differ from your display name.
Back to sign-in help
""")
- def _home(self, actor: Any | None) -> str:
- identity = (
- f"Signed in as {escape(actor.preferred_username)}.
"
- 'View my account
'
- if actor is not None
- else (
- 'You are not signed in to this portal.
'
- + (
- 'New here? Create an account.
'
- if self.public_registration and self.registration_verification is not None
- else ""
- )
- )
+ def _home(self, actor: Any | None, memberships: tuple[Any, ...] = ()) -> str:
+ register = (
+ 'New here? Create an account.
'
+ if actor is None and self.public_registration and self.registration_verification is not None
+ else ""
)
+ account = (
+ 'View my account
'
+ if actor is not None else ""
+ )
+ situation = self._login_state(actor)
+ if actor is not None:
+ situation += self._active_now(actor, memberships)
+ situation += self._allowed_tenants(actor, memberships)
+ situation += self._allowed_workloads(memberships)
return self._page_html(
"Identity & access",
"Your account, on your terms.
"
"Join a tenant, complete onboarding, and manage access without exposing credentials to applications.
"
- + identity,
+ + situation + register + account,
)
def _registration_form(self, csrf_token: str, idempotency_key: str) -> str:
@@ -2168,44 +2173,147 @@ Use the login name they provide; it may differ from your display name. 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 empty_memberships
journey_items = "".join(
self._onboarding_journey_item(item, csrf_token) for item in journeys
) or "No additional onboarding steps are required."
- verification = "Verified by your identity provider" if session.actor.assurance else "Verification pending"
consent_checked = " checked" if session.user.consented_at else ""
+ actor = session.actor
return self._page_html(
"Onboarding",
- f"""Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}
-Current identity
Signed in as {escape(session.actor.preferred_username or session.actor.subject)}.
Sign-in tenant: {escape(session.actor.tenant)}.
Roles: {escape(", ".join(session.actor.roles) or "None")}.
{escape(verification)}
Password and two-step verification help
+ f"""Welcome, {escape(session.user.display_name or actor.preferred_username or session.user.user_id)}
+{self._login_state(actor)}
+{self._active_now(actor, memberships)}
-
-Workload access
{self._workload_memberships(memberships)}Each application checks access when you open it. Tenant membership alone does not grant access to every application.
+{self._allowed_tenants(actor, memberships)}
+{self._allowed_workloads(memberships)}
""",
)
@staticmethod
- def _workload_memberships(memberships: tuple[Any, ...]) -> str:
- items = "".join(
- f"{escape(item.scope_id)} — {escape(item.kind)} ({escape(item.tenant)})"
- for item in memberships if item.scope_type in {"application", "service", "workload", "asset"}
+ def _login_state(actor: Any | None) -> str:
+ if actor is None:
+ return (
+ 'Login state
'
+ "You are not signed in to this portal.
"
+ )
+ name = escape(actor.preferred_username or actor.subject)
+ verification = "Verified by your identity provider" if actor.assurance else "Verification pending"
+ return (
+ 'Login state
'
+ f"Signed in to this portal as {name}.
"
+ "This is the portal session. An application can keep its own session.
"
+ f"{escape(verification)}
"
+ 'Password and two-step verification help
'
+ )
+
+ @staticmethod
+ def _is_exception_account(actor: Any, memberships: tuple[Any, ...]) -> bool:
+ if {"tenant-admin", "platform-operator", "platform-root"}.intersection(actor.roles):
+ return True
+ return any(item.kind in {"vendor", "multi-hire"} for item in memberships)
+
+ @staticmethod
+ def _active_tenants(actor: Any, memberships: tuple[Any, ...]) -> tuple[str, ...]:
+ active = [str(actor.tenant)]
+ if not PortalApplication._is_exception_account(actor, memberships):
+ return tuple(active)
+ claimed = actor.claims.get("active_tenants", ())
+ if isinstance(claimed, str):
+ claimed = (claimed,)
+ for tenant in claimed:
+ if (
+ isinstance(tenant, str)
+ and tenant.startswith("tenant:")
+ and len(tenant) <= 200
+ and tenant not in active
+ ):
+ active.append(tenant)
+ if len(active) >= 8:
+ break
+ return tuple(active)
+
+ @staticmethod
+ def _active_now(actor: Any, memberships: tuple[Any, ...]) -> str:
+ active = PortalApplication._active_tenants(actor, memberships)
+ items = "".join(f"{escape(tenant)} - Active" for tenant in active)
+ if len(active) > 1:
+ note = "More than one tenant is active because this sign-in carries an administrator, vendor, or multi-hire role.
"
+ elif PortalApplication._is_exception_account(actor, memberships):
+ note = "This account may use more than one tenant when the sign-in says so. This sign-in has one active tenant.
"
+ else:
+ note = "An ordinary sign-in uses one tenant. Other allowed tenants stay inactive until you sign in to them.
"
+ roles = escape(", ".join(actor.roles) or "None")
+ groups = escape(", ".join(actor.groups) or "None")
+ return (
+ 'Active now
'
+ "This is the tenant and the privileges on this sign-in.
"
+ f''
+ f"Roles: {roles}.
"
+ f"Directory groups: {groups}.
"
+ f"{note}"
+ )
+
+ @staticmethod
+ def _allowed_tenants(actor: Any, memberships: tuple[Any, ...]) -> str:
+ active = set(PortalApplication._active_tenants(actor, memberships))
+ rows = [item for item in memberships if item.scope_type == "tenant"]
+ if not rows:
+ if "platform-operator" in actor.roles:
+ body = (
+ "No tenant memberships are recorded. You have no personal tenant memberships. "
+ "Your platform operator role lets you manage tenants through platform administration."
+ )
+ else:
+ body = "No tenant memberships are recorded."
+ else:
+ parts = []
+ for item in rows:
+ if item.tenant in active:
+ action = "Active"
+ else:
+ hint = escape(urlencode({"tenant_hint": item.tenant}))
+ action = (
+ 'Inactive. Sign in to use this tenant'
+ )
+ parts.append(
+ f"{escape(item.tenant)} - {escape(item.kind)} - {action}"
+ )
+ body = "".join(parts)
+ return (
+ 'Allowed tenants
'
+ "These are memberships recorded for this account. A tenant account created at first sign-in is not a membership.
"
+ f''
+ "Signing in to an inactive tenant replaces the active tenant for an ordinary account. "
+ "It does not end a session an application already has.
"
+ )
+
+ @staticmethod
+ def _allowed_workloads(memberships: tuple[Any, ...]) -> str:
+ rows = [
+ item for item in memberships
+ if item.scope_type in {"application", "service", "workload", "asset"}
+ ]
+ if rows:
+ items = "".join(
+ f"{escape(item.scope_id)} - {escape(item.kind)} ({escape(item.tenant)}) - Allowed, recorded here"
+ for item in rows
+ )
+ else:
+ items = "No workload access is recorded."
+ return (
+ 'Allowed workloads
'
+ f''
+ "Workload decisions are not checked.
"
+ "Each application checks access when you open it. Tenant membership alone does not grant access to every application.
"
)
- return "" if items else "No workload-specific access is recorded for this account.
"
@staticmethod
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
diff --git a/tests/journey-coverage.json b/tests/journey-coverage.json
index fc34901..66e499d 100644
--- a/tests/journey-coverage.json
+++ b/tests/journey-coverage.json
@@ -7,7 +7,8 @@
"implementation": "implemented",
"tests": [
"test_account_clarity.AccountClarityTests.test_anonymous_and_expired_sessions_have_login_without_logout",
- "test_account_clarity.AccountClarityTests.test_authenticated_roles_have_logout_without_login"
+ "test_account_clarity.AccountClarityTests.test_authenticated_roles_have_logout_without_login",
+ "test_account_awareness.AccountAwarenessTests.test_signed_out_home_states_only_the_portal_session"
],
"remaining": ""
},
@@ -81,9 +82,13 @@
"role": "user",
"implementation": "partial",
"tests": [
- "test_account_recovery.AccountRecoveryTests.test_account_workload_list_is_scoped_to_current_user"
+ "test_account_recovery.AccountRecoveryTests.test_account_workload_list_is_scoped_to_current_user",
+ "test_account_awareness.AccountAwarenessTests.test_token_tenant_without_membership_is_active_and_not_allowed",
+ "test_account_awareness.AccountAwarenessTests.test_allowed_tenant_that_is_not_active_uses_sign_in",
+ "test_account_awareness.AccountAwarenessTests.test_recorded_workload_stays_allowed_and_unchecked",
+ "test_account_awareness.AccountAwarenessTests.test_exception_role_shows_every_tenant_the_sign_in_lists"
],
- "remaining": "USER-WP-0028-T02/USER-WP-0026-T03: authoritative application catalogue and access requests not implemented."
+ "remaining": "USER-WP-0036 shows login state, the active sign-in, and allowed memberships. USER-WP-0028-T02/USER-WP-0026-T03 still own authoritative allow, deny, and unavailable workload decisions."
},
{
"id": "U10",
diff --git a/tests/test_account_awareness.py b/tests/test_account_awareness.py
new file mode 100644
index 0000000..1c53cac
--- /dev/null
+++ b/tests/test_account_awareness.py
@@ -0,0 +1,133 @@
+"""Login state, active sign-in, and allowed memberships stay separate."""
+import unittest
+
+from test_web import invoke
+from user_engine.domain import Membership
+from user_engine.oidc import BrowserSession
+from user_engine.testing.fixtures import human_actor_claims
+
+import test_portal_navigation
+
+
+def _section(body: bytes, element_id: str) -> bytes:
+ marker = f'id="{element_id}"'.encode()
+ start = body.index(marker)
+ end = body.index(b"", start)
+ return body[start:end]
+
+
+class AccountAwarenessTests(unittest.TestCase):
+ setUp = test_portal_navigation.PortalNavigationTests.setUp
+ get = test_portal_navigation.PortalNavigationTests.get
+
+ def test_signed_out_home_states_only_the_portal_session(self):
+ _, body = invoke(self.app, "/", query="username=forged&tenant=tenant:evil:one")
+ self.assertIn(b"Login state", body)
+ self.assertIn(b"You are not signed in to this portal.", body)
+ self.assertNotIn(b"Active now", body)
+ self.assertNotIn(b"Allowed tenants", body)
+ self.assertNotIn(b"forged", body)
+ self.assertNotIn(b"tenant:evil:one", body)
+
+ def test_token_tenant_without_membership_is_active_and_not_allowed(self):
+ session = self.app.service.me(self.oidc.claims("member"), correlation_id="synthetic")
+ self.assertIsNotNone(
+ self.app.service.store.tenant_account("tenant:trial:demo-company", session.user.user_id)
+ )
+ _, body = self.get("/onboarding", who="member")
+ active = _section(body, "active-tenant-list")
+ allowed = _section(body, "allowed-tenant-list")
+ self.assertIn(b"tenant:trial:demo-company - Active", active)
+ self.assertNotIn(b"tenant:two", active)
+ self.assertIn(b"No tenant memberships are recorded.", allowed)
+ self.assertNotIn(b"tenant:trial:demo-company", allowed)
+ self.assertIn(b"No workload access is recorded.", body)
+ self.assertIn(b"Workload decisions are not checked.", body)
+ self.assertNotIn(b"Viewing", body)
+ self.assertIn(b"An ordinary sign-in uses one tenant.", body)
+ self.assertIn(b"This is the portal session.", body)
+
+ def test_allowed_tenant_that_is_not_active_uses_sign_in(self):
+ session = self.app.service.me(self.oidc.claims("member"), correlation_id="synthetic")
+ self.app.service.store.save_membership(Membership(
+ membership_id="mem-other", user_id=session.user.user_id,
+ tenant="tenant:other:company", scope_type="tenant",
+ scope_id="tenant:other:company", kind="user",
+ ))
+ _, body = self.get("/onboarding", who="member")
+ allowed = _section(body, "allowed-tenant-list")
+ active = _section(body, "active-tenant-list")
+ self.assertIn(b"tenant:other:company - user - Inactive.", allowed)
+ self.assertIn(b'href="/login?tenant_hint=tenant%3Aother%3Acompany"', allowed)
+ self.assertNotIn(b"