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.
""") - def _home(self, actor: Any | None) -> str: - identity = ( - f"Signed in as {escape(actor.preferred_username)}.
" - '' - 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 = ( + '' + 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", "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 = ( - "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)}
Viewing {escape(selected_tenant)}.
Reauthenticate in this tenant to change the authoritative login context.
Each application checks access when you open it. Tenant membership alone does not grant access to every application.
You are not signed in to this portal.
Signed in to this portal as {name}.
" + "This is the portal session. An application can keep its own session.
" + f"{escape(verification)}
" + '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 ( + 'This is the tenant and the privileges on this sign-in.
" + f'Roles: {roles}.
" + f"Directory groups: {groups}.
" + f"{note}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.
Workload decisions are not checked.
" + "Each application checks access when you open it. Tenant membership alone does not grant access to every application.
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"