Show login state, active sign-in, and allowed memberships separately.
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Has been cancelled
Build and Publish Container Image / build-and-push (push) Successful in 41s
Account journey acceptance / journeys (push) Successful in 9s

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
This commit is contained in:
tegwick 2026-09-26 20:46:38 +02:00
parent 1c7634c7ce
commit 85423e8e09
7 changed files with 320 additions and 53 deletions

View file

@ -3,7 +3,8 @@
Owner: user-engine, with KeyCape/NetKingdom for sign-in and factors, Owner: user-engine, with KeyCape/NetKingdom for sign-in and factors,
tenant-engine for tenant lifecycle, and applications for workload admission. tenant-engine for tenant lifecycle, and applications for workload admission.
Acceptance work: USER-WP-0027; OTP dependency: KEY-WP-0035 and NK-WP-0033. 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. headless capability alone does not mean a journey is usable or verified live.
## Common interaction rules ## 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 | | 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 | | 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 | | 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) | | 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 | | 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 | | 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 | | 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 | | 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 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 | | 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 | | 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 | | 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 | | 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 |

View file

@ -276,7 +276,12 @@ class PortalApplication:
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) 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 path == "/register" and method == "GET":
if self._optional_actor(environ) is not None: if self._optional_actor(environ) is not None:
@ -389,8 +394,7 @@ class PortalApplication:
return self._html( return self._html(
start_response, start_response,
self._onboarding( self._onboarding(
session, memberships, journeys, str(selected_tenant), session, memberships, journeys, self._csrf_token(environ),
self._csrf_token(environ),
), ),
correlation_id, 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.</p></section> Use the login name they provide; it may differ from your display name.</p></section>
<p><a href="/access-recovery">Back to sign-in help</a></p>""") <p><a href="/access-recovery">Back to sign-in help</a></p>""")
def _home(self, actor: Any | None) -> str: def _home(self, actor: Any | None, memberships: tuple[Any, ...] = ()) -> str:
identity = ( register = (
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
'<p><a class="button" href="/onboarding">View my account</a></p>'
if actor is not None
else (
'<p>You are not signed in to this portal.</p>'
+ (
'<p>New here? <a href="/register">Create an account</a>.</p>' '<p>New here? <a href="/register">Create an account</a>.</p>'
if self.public_registration and self.registration_verification is not None if actor is None and self.public_registration and self.registration_verification is not None
else "" else ""
) )
account = (
'<p><a class="button" href="/onboarding">View my account</a></p>'
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( return self._page_html(
"Identity & access", "Identity & access",
"<h1>Your account, on your terms.</h1>" "<h1>Your account, on your terms.</h1>"
"<p>Join a tenant, complete onboarding, and manage access without exposing credentials to applications.</p>" "<p>Join a tenant, complete onboarding, and manage access without exposing credentials to applications.</p>"
+ identity, + situation + register + account,
) )
def _registration_form(self, csrf_token: str, idempotency_key: str) -> str: 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.</p></sect
def _onboarding( def _onboarding(
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...], self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
selected_tenant: str, csrf_token: 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(
f"<li><a href=\"/onboarding?{urlencode({'tenant': item.tenant})}\">{escape(item.tenant)}</a> — {escape(item.kind)}</li>"
for item in memberships
) 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>"
verification = "Verified by your identity provider" if session.actor.assurance else "Verification pending"
consent_checked = " checked" if session.user.consented_at else "" consent_checked = " checked" if session.user.consented_at else ""
actor = session.actor
return self._page_html( return self._page_html(
"Onboarding", "Onboarding",
f"""<h1>Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}</h1> f"""<h1>Welcome, {escape(session.user.display_name or actor.preferred_username or session.user.user_id)}</h1>
<section aria-labelledby="verification"><h2 id="verification">Current identity</h2><p>Signed in as <strong>{escape(session.actor.preferred_username or session.actor.subject)}</strong>.</p><p>Sign-in tenant: {escape(session.actor.tenant)}.</p><p>Roles: {escape(", ".join(session.actor.roles) or "None")}.</p><p>{escape(verification)}</p><p><a href="/security">Password and two-step verification help</a></p></section> {self._login_state(actor)}
{self._active_now(actor, memberships)}
<section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2> <section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2>
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}"> <form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label> <label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label>
<label><input name="consent_accepted" type="checkbox" value="yes"{consent_checked}> I accept portal terms version 1</label> <label><input name="consent_accepted" type="checkbox" value="yes"{consent_checked}> I accept portal terms version 1</label>
<button type="submit">Save profile</button></form></section> <button type="submit">Save profile</button></form></section>
<section aria-labelledby="tenants"><h2 id="tenants">Tenant access</h2><p>Viewing <strong>{escape(selected_tenant)}</strong>.</p><ul>{membership_items}</ul> {self._allowed_tenants(actor, memberships)}
<p><a href="/login?{urlencode({'tenant_hint': selected_tenant})}">Reauthenticate in this tenant</a> to change the authoritative login context.</p></section> {self._allowed_workloads(memberships)}
<section aria-labelledby="workloads"><h2 id="workloads">Workload access</h2>{self._workload_memberships(memberships)}<p>Each application checks access when you open it. Tenant membership alone does not grant access to every application.</p></section>
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""", <section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
) )
@staticmethod @staticmethod
def _workload_memberships(memberships: tuple[Any, ...]) -> str: def _login_state(actor: Any | None) -> str:
items = "".join( if actor is None:
f"<li>{escape(item.scope_id)} — {escape(item.kind)} ({escape(item.tenant)})</li>" return (
for item in memberships if item.scope_type in {"application", "service", "workload", "asset"} '<section aria-labelledby="login-state"><h2 id="login-state">Login state</h2>'
"<p>You are not signed in to this portal.</p></section>"
)
name = escape(actor.preferred_username or actor.subject)
verification = "Verified by your identity provider" if actor.assurance else "Verification pending"
return (
'<section aria-labelledby="login-state"><h2 id="login-state">Login state</h2>'
f"<p>Signed in to this portal as <strong>{name}</strong>.</p>"
"<p>This is the portal session. An application can keep its own session.</p>"
f"<p>{escape(verification)}</p>"
'<p><a href="/security">Password and two-step verification help</a></p></section>'
)
@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"<li><strong>{escape(tenant)}</strong> - Active</li>" for tenant in active)
if len(active) > 1:
note = "<p>More than one tenant is active because this sign-in carries an administrator, vendor, or multi-hire role.</p>"
elif PortalApplication._is_exception_account(actor, memberships):
note = "<p>This account may use more than one tenant when the sign-in says so. This sign-in has one active tenant.</p>"
else:
note = "<p>An ordinary sign-in uses one tenant. Other allowed tenants stay inactive until you sign in to them.</p>"
roles = escape(", ".join(actor.roles) or "None")
groups = escape(", ".join(actor.groups) or "None")
return (
'<section aria-labelledby="active-now"><h2 id="active-now">Active now</h2>'
"<p>This is the tenant and the privileges on this sign-in.</p>"
f'<ul id="active-tenant-list">{items}</ul>'
f"<p>Roles: {roles}.</p>"
f"<p>Directory groups: {groups}.</p>"
f"{note}</section>"
)
@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 = (
"<li>No tenant memberships are recorded. You have no personal tenant memberships. "
"Your platform operator role lets you manage tenants through platform administration.</li>"
)
else:
body = "<li>No tenant memberships are recorded.</li>"
else:
parts = []
for item in rows:
if item.tenant in active:
action = "Active"
else:
hint = escape(urlencode({"tenant_hint": item.tenant}))
action = (
'Inactive. <a href="/login?'
+ hint
+ '">Sign in to use this tenant</a>'
)
parts.append(
f"<li>{escape(item.tenant)} - {escape(item.kind)} - {action}</li>"
)
body = "".join(parts)
return (
'<section aria-labelledby="allowed-tenants"><h2 id="allowed-tenants">Allowed tenants</h2>'
"<p>These are memberships recorded for this account. A tenant account created at first sign-in is not a membership.</p>"
f'<ul id="allowed-tenant-list">{body}</ul>'
"<p>Signing in to an inactive tenant replaces the active tenant for an ordinary account. "
"It does not end a session an application already has.</p></section>"
)
@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"<li>{escape(item.scope_id)} - {escape(item.kind)} ({escape(item.tenant)}) - Allowed, recorded here</li>"
for item in rows
)
else:
items = "<li>No workload access is recorded.</li>"
return (
'<section aria-labelledby="allowed-workloads"><h2 id="allowed-workloads">Allowed workloads</h2>'
f'<ul id="allowed-workload-list">{items}</ul>'
"<p>Workload decisions are not checked.</p>"
"<p>Each application checks access when you open it. Tenant membership alone does not grant access to every application.</p></section>"
) )
return "<ul>" + items + "</ul>" if items else "<p>No workload-specific access is recorded for this account.</p>"
@staticmethod @staticmethod
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str: def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:

View file

@ -7,7 +7,8 @@
"implementation": "implemented", "implementation": "implemented",
"tests": [ "tests": [
"test_account_clarity.AccountClarityTests.test_anonymous_and_expired_sessions_have_login_without_logout", "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": "" "remaining": ""
}, },
@ -81,9 +82,13 @@
"role": "user", "role": "user",
"implementation": "partial", "implementation": "partial",
"tests": [ "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", "id": "U10",

View file

@ -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"</ul>", 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"<strong>tenant:trial:demo-company</strong> - 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"<form", allowed)
self.assertIn(b"tenant:trial:demo-company", active)
self.assertNotIn(b"tenant:other:company", active)
self.assertIn(b"does not end a session an application already has.", body)
def test_recorded_workload_stays_allowed_and_unchecked(self):
session = self.app.service.me(self.oidc.claims("member"), correlation_id="synthetic")
self.app.service.store.save_membership(Membership(
membership_id="mem-work", user_id=session.user.user_id,
tenant="tenant:trial:demo-company", scope_type="service",
scope_id="vergabe-demo-company", kind="user",
))
_, body = self.get("/onboarding", who="member")
workloads = _section(body, "allowed-workload-list")
allowed_tenants = _section(body, "allowed-tenant-list")
self.assertIn(b"vergabe-demo-company - user (tenant:trial:demo-company) - Allowed, recorded here", workloads)
self.assertIn(b"Workload decisions are not checked.", body)
self.assertIn(b"No tenant memberships are recorded.", allowed_tenants)
def test_ordinary_sign_in_ignores_a_second_active_tenant_claim(self):
self.oidc.sessions["member"].claims["active_tenants"] = [
"tenant:trial:demo-company", "tenant:two:beta",
]
_, body = self.get("/onboarding", who="member")
active = _section(body, "active-tenant-list")
self.assertIn(b"tenant:trial:demo-company", active)
self.assertNotIn(b"tenant:two:beta", active)
def test_exception_role_shows_every_tenant_the_sign_in_lists(self):
for role, kind in [("tenant-admin", "user"), ("platform-root", "user"), ("user", "vendor"), ("user", "multi-hire")]:
with self.subTest(role=role, kind=kind):
subject = f"{role}-{kind}"
claims = human_actor_claims(subject=subject, tenant="tenant:one:alpha")
claims["roles"] = [role]
claims["active_tenants"] = ["tenant:one:alpha", "tenant:two:beta"]
claims["preferred_username"] = subject
self.oidc.sessions[subject] = BrowserSession(claims, 9999999999, subject + "-csrf")
session = self.app.service.me(claims, correlation_id="synthetic")
if kind in {"vendor", "multi-hire"}:
self.app.service.store.save_membership(Membership(
membership_id="mem-" + subject, user_id=session.user.user_id,
tenant="tenant:one:alpha", scope_type="tenant",
scope_id="tenant:one:alpha", kind=kind,
))
_, body = self.get("/onboarding", who=subject)
active = _section(body, "active-tenant-list")
self.assertIn(b"<strong>tenant:one:alpha</strong> - Active", active)
self.assertIn(b"<strong>tenant:two:beta</strong> - Active", active)
self.assertNotIn(b'href="/login?tenant_hint=tenant%3Atwo', body)
def test_exception_without_a_second_tenant_claim_stays_on_one(self):
claims = human_actor_claims(subject="vendor-one", tenant="tenant:one:alpha")
claims["roles"] = ["user"]
claims["preferred_username"] = "vendor-one"
self.oidc.sessions["vendor-one"] = BrowserSession(claims, 9999999999, "vendor-one-csrf")
session = self.app.service.me(claims, correlation_id="synthetic")
self.app.service.store.save_membership(Membership(
membership_id="mem-vendor-one", user_id=session.user.user_id,
tenant="tenant:other:company", scope_type="tenant",
scope_id="tenant:other:company", kind="vendor",
))
_, body = self.get("/onboarding", who="vendor-one")
active = _section(body, "active-tenant-list")
allowed = _section(body, "allowed-tenant-list")
self.assertIn(b"tenant:one:alpha", active)
self.assertNotIn(b"tenant:other:company", active)
self.assertIn(b"tenant:other:company - vendor - Inactive.", allowed)
self.assertIn(b"This sign-in has one active tenant.", body)
if __name__ == "__main__":
unittest.main()

View file

@ -19,9 +19,10 @@ class AccountRecoveryTests(unittest.TestCase):
self.assertIn(b'/onboarding', body) self.assertIn(b'/onboarding', body)
self.assertIn(b'This portal is signed in as', body) self.assertIn(b'This portal is signed in as', body)
_, body = self.get('/onboarding') _, body = self.get('/onboarding')
self.assertIn(b'Current identity', body) self.assertIn(b'Login state', body)
self.assertIn(b'Workload access', body) self.assertIn(b'Allowed workloads', body)
self.assertIn(b'No workload-specific access is recorded', body) self.assertIn(b'No workload access is recorded.', body)
self.assertIn(b'Workload decisions are not checked.', body)
def test_shared_logout_clears_portal_then_uses_provider_confirmation(self): def test_shared_logout_clears_portal_then_uses_provider_confirmation(self):
response, _ = invoke(self.app, '/logout', method='POST', cookie='ue_session=operator', form={'csrf_token':'wrong','scope':'shared'}) response, _ = invoke(self.app, '/logout', method='POST', cookie='ue_session=operator', form={'csrf_token':'wrong','scope':'shared'})

View file

@ -94,7 +94,7 @@ class PortalNavigationTests(unittest.TestCase):
self.assertNotIn(b'href="/platform"',member) self.assertNotIn(b'href="/platform"',member)
self.assertNotIn(b'operator-csrf',member) self.assertNotIn(b'operator-csrf',member)
self.assertIn(b'value="member-csrf"',member) self.assertIn(b'value="member-csrf"',member)
self.assertIn(b'No tenant memberships yet.',member) self.assertIn(b'No tenant memberships are recorded.',member)
_, anonymous = invoke(self.app,'/') _, anonymous = invoke(self.app,'/')
self.assertNotIn(b'action="/logout"',anonymous) self.assertNotIn(b'action="/logout"',anonymous)
self.assertNotIn(b'href="/platform"',anonymous) self.assertNotIn(b'href="/platform"',anonymous)

View file

@ -4,7 +4,7 @@ type: workplan
title: "Show allowed and active access on the account page" title: "Show allowed and active access on the account page"
domain: communication domain: communication
repo: user-engine repo: user-engine
status: ready status: finished
flavor: extension flavor: extension
owner: grok owner: grok
topic_slug: user-engine topic_slug: user-engine
@ -41,7 +41,7 @@ and USER-WP-0028-T02. This plan does not infer those decisions.
```task ```task
id: USER-WP-0036-T01 id: USER-WP-0036-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "ffc8f42f-5ca3-56a2-8850-d2da9beba72c" state_hub_task_id: "ffc8f42f-5ca3-56a2-8850-d2da9beba72c"
``` ```
@ -76,11 +76,16 @@ The observed case renders as: signed in, one active tenant taken from the
token, no allowed tenants, no allowed workloads, workload decisions not token, no allowed tenants, no allowed workloads, workload decisions not
checked. Keep the layout readable on a phone. checked. Keep the layout readable on a phone.
2026-09-26: the home page and `/onboarding` now render Login state, Active now,
and Allowed tenants / Allowed workloads as separate sections. A tenant account
is not listed as a membership. Recorded workload rows stay labelled as records,
followed by "Workload decisions are not checked."
## Change the active tenant only through sign-in ## Change the active tenant only through sign-in
```task ```task
id: USER-WP-0036-T02 id: USER-WP-0036-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "136a27ff-fd3e-5f0a-8a3a-bb080f1965d5" state_hub_task_id: "136a27ff-fd3e-5f0a-8a3a-bb080f1965d5"
``` ```
@ -100,11 +105,18 @@ one is already recorded.
Switching tenant does not claim to end application sessions. The existing Switching tenant does not claim to end application sessions. The existing
sign-out copy remains the place that explains a remaining shared sign-in. sign-out copy remains the place that explains a remaining shared sign-in.
2026-09-26: an inactive tenant membership links to `/login?tenant_hint=`. The
page has no control that marks a second tenant active inside the portal
session. Extra active tenants are taken from the verified `active_tenants`
claim, and only for `tenant-admin`, `platform-operator`, `platform-root`, or a
recorded `vendor` or `multi-hire` membership. An ordinary sign-in shows the
token tenant alone.
## Record the journey and prove the three situations ## Record the journey and prove the three situations
```task ```task
id: USER-WP-0036-T03 id: USER-WP-0036-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "4c9b0600-5034-58ce-9b02-90a040f6f65f" state_hub_task_id: "4c9b0600-5034-58ce-9b02-90a040f6f65f"
``` ```
@ -121,3 +133,10 @@ catalogue decision renders as not checked.
Do not close USER-WP-0026-T03 or USER-WP-0028-T02 from this plan. They remain Do not close USER-WP-0026-T03 or USER-WP-0028-T02 from this plan. They remain
the owners of allow, deny, and unavailable workload decisions. the owners of allow, deny, and unavailable workload decisions.
2026-09-26: U01, U09 and U10 in `docs/account-journeys.md` match the page.
`tests/test_account_awareness.py` covers the signed-out home, a token tenant
with a tenant account and no membership, an inactive allowed tenant, a
recorded workload, an ordinary sign-in that ignores a second tenant claim, and
exception roles whose verified token lists two active tenants. 247 unit tests
passed. The live portal still serves the previous image.