diff --git a/docs/account-journeys.md b/docs/account-journeys.md index 860bd3a..d7d3950 100644 --- a/docs/account-journeys.md +++ b/docs/account-journeys.md @@ -9,9 +9,11 @@ headless capability alone does not mean a journey is usable or verified live. ## Common interaction rules -- The header states the verified portal identity, or “Not signed in to this - portal.” A valid portal session shows Log out; an absent/expired session shows - Sign in. Never infer identity from URL parameters or an existing provider tab. +- The header is titled NetKingdom Identity. It states “Signed in as” the + verified identity, or “Not signed in.” A valid account-site session shows + Log out; an absent or expired session shows Sign in. A one-time code raises + the security level of the NetKingdom sign-in and is not another sign-in. + Never infer identity from URL parameters or an existing provider tab. - The portal cannot observe every application or shared-provider session. Explain this once in sign-out confirmation or expandable identity-switch help, not as competing login/logout actions everywhere. “Use another account” remains @@ -34,7 +36,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 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 | +| U01 — Know whether I am signed in | Header and the home page say “Signed in as” the verified identity, or that no account-site session exists. An application may keep its own session. A one-time code is a higher security level, not another sign-in | 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 identity 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) | diff --git a/src/user_engine/authentication_policy.py b/src/user_engine/authentication_policy.py index 078654b..1f2318e 100644 --- a/src/user_engine/authentication_policy.py +++ b/src/user_engine/authentication_policy.py @@ -19,7 +19,7 @@ def page(csrf, result=None): failure = result.get('failure') if failure: messages = { - 'fresh_platform_mfa_required': 'Verify your identity with a fresh MFA sign-in before viewing or changing policy.', + 'fresh_platform_mfa_required': 'Raise the security level with a one-time code before viewing or changing policy.', 'preview_expired_or_changed': 'The review expired, changed or belongs to another session. Check the current policy and review again.', 'policy_changed_review_again': 'Policy changed after this review. Check the current policy and review again.', 'reference_already_used': 'This reference is already recorded. Check the history; use a new reference for a new change.', diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 5e609a7..2fe5ede 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -226,9 +226,9 @@ class PortalApplication: actor = None self._set_account_navigation(environ, actor) identity = ( - f'

This portal is signed in as {escape(actor.preferred_username or actor.subject)}.

' + f'

Signed in as {escape(actor.preferred_username or actor.subject)}.

' '

View my account and access

' - if actor else '

You are not signed in to this portal. Sign in to verify your identity and access.

' + if actor else '

You are not signed in. Open the account site with your NetKingdom identity.

' ) return self._html(start_response, self._page_html( "Sign-in help", '

Sign-in could not be completed

' @@ -244,8 +244,8 @@ class PortalApplication: return self._redirect(start_response, "/", correlation_id) return self._html(start_response, self._page_html( "Not signed in", - '

You are not signed in to this portal.

' - '

Your shared NetKingdom sign-in may still be active. Signing in may reuse that account.

' + '

You are not signed in.

' + '

Your shared NetKingdom sign-in may still be active. Opening the account site again may reuse that identity.

' + self._identity_switch_help(), ), correlation_id) if path == "/logout" and method == "GET": @@ -255,11 +255,11 @@ class PortalApplication: self._set_account_navigation(environ, actor) token = self._csrf_token(environ) return self._html(start_response, self._page_html( - "Log out", '

Log out of this portal?

' - '

This ends your portal session. Your shared NetKingdom sign-in stays active.

' + "Log out", '

End this account-site session?

' + '

This ends the session on the account site. Your NetKingdom sign-in stays active.

' '
' f'' - '' + '' '
', ), correlation_id) if path == "/logout" and method == "POST": @@ -1317,15 +1317,15 @@ class PortalApplication: def _operation_capabilities(self) -> str: capabilities = ( - ("Portal sign-in", self.oidc_client is not None, "An existing portal session does not prove a fresh provider sign-in works."), + ("Account-site session", self.oidc_client is not None, "An existing account-site session does not prove a fresh NetKingdom sign-in works."), ("Tenant identity management", self.provisioning is not None, "Use tenant administration for login setup or tenant access recovery. Shared identity and factor recovery belong to the sign-in service."), ("Tenant lifecycle", self.tenant_management is not None, "Review the authority's returned version after a change."), ("Notification delivery", self.outbox_delivery is not None, "Inspect the delivery record below. If email cannot be received, use the tenant's assisted password setup process."), ) - rows = "".join(f'{escape(name)}{"Configured; live health unverified" if configured else "Unavailable in this portal"}{escape(help_text)}' + rows = "".join(f'{escape(name)}{"Configured; live health unverified" if configured else "Unavailable on this account site"}{escape(help_text)}' for name, configured, help_text in capabilities) return ('

Service capabilities

' - + rows + '
ServiceKnown stateRecovery step
' + ('

Recover a lost authenticator

' if self.factor_recovery else '

Authenticator recovery is unavailable in this portal.

') + '

Review authentication policy. A configured adapter is not a health check.

') + + rows + '' + ('

Recover a lost authenticator

' if self.factor_recovery else '

Authenticator recovery is unavailable on this account site.

') + '

Review authentication policy. A configured adapter is not a health check.

') def _require_setup_access(self, tenant: str, user_id: str) -> None: account = self.service.store.tenant_account(tenant, user_id) @@ -1880,14 +1880,14 @@ class PortalApplication:

Sign-in security

Use this page for help with your password and authenticator app.

Two-step verification

-

An authenticator app generates a short-lived code to enter after your password. -This portal cannot currently confirm whether an authenticator is enabled for your account.

+

An authenticator app generates a short-lived code. Entering that code raises the security level of your NetKingdom sign-in. It is not another sign-in. +This account site cannot currently confirm whether an authenticator is enabled for your account.

""" + handoff + """
How to set up an authenticator when setup is available
  1. Open authenticator management and check that it shows your account.
  2. Choose Enroll Token, select TOTP, and scan its QR code with your authenticator app.
  3. Enter a current code to confirm setup. Wait for the sign-in service to confirm activation.
  4. -
  5. Follow the recovery instructions shown there, then test a new sign-in before closing your current session.
+
  • Follow the recovery instructions shown there, then confirm the new security level before closing your current session.
  • Opening the setup page does not activate two-step verification. To cancel unfinished setup, open All Tokens, select the pending token, and choose Delete. A confirmed authenticator cannot be removed or replaced from this password-only management session; ask your administrator to use audited authenticator recovery.

    A code is rejected, or I have lost my authenticator

    Use the newest code for the correct account and check that your device sets its time automatically. @@ -2197,19 +2197,25 @@ Use the login name they provide; it may differ from your display name.

    str: + level = ( + "

    A one-time code raises the security level of a NetKingdom sign-in. " + "It is not another sign-in.

    " + ) if actor is None: return ( - '

    Login state

    ' - "

    You are not signed in to this portal.

    " + '

    Identity

    ' + "

    You are not signed in.

    " + f"{level}
    " ) 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.

    " + '

    Identity

    ' + f"

    Signed in as {name}.

    " + "

    This session is for the account site. An application can keep its own session.

    " + f"{level}" f"

    {escape(verification)}

    " - '

    Password and two-step verification help

    ' + '

    Password and one-time code help

    ' ) @staticmethod @@ -2361,7 +2367,7 @@ Use the login name they provide; it may differ from your display name.

    ' "Continue to password setup

    " + f'

    After the password is set, sign in at {escape(self.login_url)}. ' - "This portal does not deliver the setup link; pass it and the login name on yourself.

    " + "This account site does not deliver the setup link; pass it and the login name on yourself.

    " f'

    ' "Return to tenant administration

    ", ) @@ -2372,7 +2378,7 @@ Use the login name they provide; it may differ from your display name.

    None: if actor is None: - _ACCOUNT_NAVIGATION.set('

    Not signed in to this portal

    ') + _ACCOUNT_NAVIGATION.set('

    Not signed in

    ') return links = 'HomeMy accountSign-in security' if "platform-operator" in actor.roles: @@ -2384,13 +2390,13 @@ Use the login name they provide; it may differ from your display name.

    Signed in to this portal as {identity}

    ') + _ACCOUNT_NAVIGATION.set(f'

    Signed in as {identity}

    ') @staticmethod def _page_html(title: str, body: str) -> str: return f""" -{escape(title)} · Railiance
    Railiance identity{_ACCOUNT_NAVIGATION.get()}
    {body}
    """ +
    NetKingdom Identity{_ACCOUNT_NAVIGATION.get()}
    {body}
    """ def _html( self, start_response: StartResponse, body: str, correlation_id: str, diff --git a/tests/test_account_awareness.py b/tests/test_account_awareness.py index 1c53cac..6eea5fb 100644 --- a/tests/test_account_awareness.py +++ b/tests/test_account_awareness.py @@ -22,8 +22,10 @@ class AccountAwarenessTests(unittest.TestCase): 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.assertIn(b"NetKingdom Identity", body) + self.assertIn(b"Identity", body) + self.assertIn(b"You are not signed in.", body) + self.assertIn(b"It is not another sign-in.", body) self.assertNotIn(b"Active now", body) self.assertNotIn(b"Allowed tenants", body) self.assertNotIn(b"forged", body) @@ -45,7 +47,8 @@ class AccountAwarenessTests(unittest.TestCase): 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) + self.assertIn(b"This session is for the account site.", body) + self.assertIn(b"Signed in as", 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") diff --git a/tests/test_account_clarity.py b/tests/test_account_clarity.py index 8af3604..04c207e 100644 --- a/tests/test_account_clarity.py +++ b/tests/test_account_clarity.py @@ -17,7 +17,7 @@ class AccountClarityTests(unittest.TestCase): self.assertIn(b'href="/login">Sign in', body) self.assertNotIn(b'href="/logout"', body) self.assertNotIn(b'action="/logout"', body) - self.assertNotIn(b'Signed in to this portal as', body) + self.assertNotIn(b'Signed in as', body) self.assertNotIn(b'You have logged out', body) self.assertEqual('no-store', response['headers']['Cache-Control']) @@ -27,7 +27,7 @@ class AccountClarityTests(unittest.TestCase): with self.subTest(path=path, who=who): response, body = invoke(self.app, path, cookie='ue_session='+who) self.assertEqual('200 OK', response['status']) - self.assertIn(b'Signed in to this portal as', body) + self.assertIn(b'Signed in as', body) self.assertIn(b'href="/logout"', body) self.assertNotIn(b'href="/login"', body) self.assertNotIn(b'Verify my current identity', body) diff --git a/tests/test_account_recovery.py b/tests/test_account_recovery.py index 7961c5a..cd1c30e 100644 --- a/tests/test_account_recovery.py +++ b/tests/test_account_recovery.py @@ -17,9 +17,9 @@ class AccountRecoveryTests(unittest.TestCase): response, body = self.get('/access-recovery') self.assertEqual('200 OK', response['status']) self.assertIn(b'/onboarding', body) - self.assertIn(b'This portal is signed in as', body) + self.assertIn(b'Signed in as', body) _, body = self.get('/onboarding') - self.assertIn(b'Login state', body) + self.assertIn(b'Identity', body) self.assertIn(b'Allowed workloads', body) self.assertIn(b'No workload access is recorded.', body) self.assertIn(b'Workload decisions are not checked.', body) diff --git a/tests/test_authentication_policy.py b/tests/test_authentication_policy.py index 1f267a6..80f158f 100644 --- a/tests/test_authentication_policy.py +++ b/tests/test_authentication_policy.py @@ -42,7 +42,7 @@ class AuthenticationPolicyJourney(JourneyFixture): self.assertEqual('403 Forbidden',response['status']) self.oidc.sessions['operator'].claims['assurance']['at']=time.time()-301 _,body=invoke(self.app,'/platform/authentication-policy',cookie='ue_session=operator') - self.assertIn(b'fresh MFA sign-in',body);self.assertEqual([],self.provider.calls) + self.assertIn(b'one-time code before viewing',body);self.assertEqual([],self.provider.calls) def test_review_explains_lockout_scope_rollback_and_receipts(self): _,body=self.post('/platform/authentication-policy',who='operator',action='preview',client='vergabe-demo-company',mode='mandatory',reference='p06-case') self.assertIn(b'Review policy change',body);self.assertIn(b'unable to complete sign-in',body) diff --git a/tests/test_platform_support.py b/tests/test_platform_support.py index d663a7c..2ef9236 100644 --- a/tests/test_platform_support.py +++ b/tests/test_platform_support.py @@ -74,6 +74,6 @@ class PlatformSupportJourneys(JourneyFixture): response,body=invoke(self.app,"/platform/operations",cookie="ue_session=operator") self.assertEqual("200 OK",response["status"]) self.assertIn(b"Configured; live health unverified",body) - self.assertIn(b"Unavailable in this portal",body) + self.assertIn(b"Unavailable on this account site",body) self.assertIn(b"assisted password setup",body) self.assertIn(b"Review authentication policy",body) diff --git a/tests/test_portal_navigation.py b/tests/test_portal_navigation.py index 9a700e1..068f2bb 100644 --- a/tests/test_portal_navigation.py +++ b/tests/test_portal_navigation.py @@ -103,7 +103,7 @@ class PortalNavigationTests(unittest.TestCase): 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.assertIn(b'End this account-site session?',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})