Name the account site NetKingdom Identity.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 40s
Account journey acceptance / journeys (push) Successful in 11s

Say "Signed in as" the identity, and describe a one-time code as a
higher security level of the NetKingdom sign-in rather than another
sign-in. The account site keeps its own session.

Assistant: grok
Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f
This commit is contained in:
tegwick 2026-09-26 23:48:00 +02:00
parent b5c60ab2bd
commit b987a3de9e
9 changed files with 50 additions and 39 deletions

View file

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

View file

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

View file

@ -226,9 +226,9 @@ class PortalApplication:
actor = None
self._set_account_navigation(environ, actor)
identity = (
f'<p>This portal is signed in as <strong>{escape(actor.preferred_username or actor.subject)}</strong>.</p>'
f'<p>Signed in as <strong>{escape(actor.preferred_username or actor.subject)}</strong>.</p>'
'<p><a class="button" href="/onboarding">View my account and access</a></p>'
if actor else '<p>You are not signed in to this portal. Sign in to verify your identity and access.</p>'
if actor else '<p>You are not signed in. Open the account site with your NetKingdom identity.</p>'
)
return self._html(start_response, self._page_html(
"Sign-in help", '<h1>Sign-in could not be completed</h1>'
@ -244,8 +244,8 @@ class PortalApplication:
return self._redirect(start_response, "/", correlation_id)
return self._html(start_response, self._page_html(
"Not signed in",
'<h1>You are not signed in to this portal.</h1>'
'<p>Your shared NetKingdom sign-in may still be active. Signing in may reuse that account.</p>'
'<h1>You are not signed in.</h1>'
'<p>Your shared NetKingdom sign-in may still be active. Opening the account site again may reuse that identity.</p>'
+ 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", '<h1>Log out of this portal?</h1>'
'<p>This ends your portal session. Your shared NetKingdom sign-in stays active.</p>'
"Log out", '<h1>End this account-site session?</h1>'
'<p>This ends the session on the account site. Your 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 of this portal</button>'
'<button type="submit">End this session</button>'
'<button type="submit" name="scope" value="shared">Continue to NetKingdom sign-out</button></form>',
), 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'<tr><td>{escape(name)}</td><td>{"Configured; live health unverified" if configured else "Unavailable in this portal"}</td><td>{escape(help_text)}</td></tr>'
rows = "".join(f'<tr><td>{escape(name)}</td><td>{"Configured; live health unverified" if configured else "Unavailable on this account site"}</td><td>{escape(help_text)}</td></tr>'
for name, configured, help_text in capabilities)
return ('<section><h2>Service capabilities</h2><table><thead><tr><th>Service</th><th>Known state</th><th>Recovery step</th></tr></thead><tbody>'
+ rows + '</tbody></table>' + ('<p><a href="/platform/factor-recovery">Recover a lost authenticator</a></p>' if self.factor_recovery else '<p>Authenticator recovery is unavailable in this portal.</p>') + '<p><a href="/platform/authentication-policy">Review authentication policy</a>. A configured adapter is not a health check.</p></section>')
+ rows + '</tbody></table>' + ('<p><a href="/platform/factor-recovery">Recover a lost authenticator</a></p>' if self.factor_recovery else '<p>Authenticator recovery is unavailable on this account site.</p>') + '<p><a href="/platform/authentication-policy">Review authentication policy</a>. A configured adapter is not a health check.</p></section>')
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:
<h1>Sign-in security</h1>
<p>Use this page for help with your password and authenticator app.</p>
<section><h2>Two-step verification</h2>
<p>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.</p>
<p>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.</p>
""" + handoff + """
<details><summary>How to set up an authenticator when setup is available</summary>
<ol><li>Open authenticator management and check that it shows your account.</li>
<li>Choose Enroll Token, select TOTP, and scan its QR code with your authenticator app.</li>
<li>Enter a current code to confirm setup. Wait for the sign-in service to confirm activation.</li>
<li>Follow the recovery instructions shown there, then test a new sign-in before closing your current session.</li></ol>
<li>Follow the recovery instructions shown there, then confirm the new security level before closing your current session.</li></ol>
<p>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.</p></details>
<details><summary>A code is rejected, or I have lost my authenticator</summary>
<p>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.</p></sect
@staticmethod
def _login_state(actor: Any | None) -> str:
level = (
"<p>A one-time code raises the security level of a NetKingdom sign-in. "
"It is not another sign-in.</p>"
)
if actor is None:
return (
'<section aria-labelledby="login-state"><h2 id="login-state">Login state</h2>'
"<p>You are not signed in to this portal.</p></section>"
'<section aria-labelledby="login-state"><h2 id="login-state">Identity</h2>'
"<p>You are not signed in.</p>"
f"{level}</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>"
'<section aria-labelledby="login-state"><h2 id="login-state">Identity</h2>'
f"<p>Signed in as <strong>{name}</strong>.</p>"
"<p>This session is for the account site. An application can keep its own session.</p>"
f"{level}"
f"<p>{escape(verification)}</p>"
'<p><a href="/security">Password and two-step verification help</a></p></section>'
'<p><a href="/security">Password and one-time code help</a></p></section>'
)
@staticmethod
@ -2361,7 +2367,7 @@ Use the login name they provide; it may differ from your display name.</p></sect
f'<p><a class="button" rel="noreferrer" href="{escape(setup_url)}">'
"Continue to password setup</a></p>"
+ f'<p>After the password is set, sign in at <a href="{escape(self.login_url)}">{escape(self.login_url)}</a>. '
"This portal does not deliver the setup link; pass it and the login name on yourself.</p>"
"This account site does not deliver the setup link; pass it and the login name on yourself.</p>"
f'<p><a href="/admin/{escape(tenant)}">'
"Return to tenant administration</a></p>",
)
@ -2372,7 +2378,7 @@ Use the login name they provide; it may differ from your display name.</p></sect
def _set_account_navigation(self, environ: Mapping[str, Any], actor: Any | None) -> None:
if actor is None:
_ACCOUNT_NAVIGATION.set('<p>Not signed in to this portal</p><nav aria-label="Account navigation"><a href="/">Home</a><a href="/security">Sign-in help</a><a class="button" href="/login">Sign in</a></nav>')
_ACCOUNT_NAVIGATION.set('<p>Not signed in</p><nav aria-label="Account navigation"><a href="/">Home</a><a href="/security">Sign-in help</a><a class="button" href="/login">Sign in</a></nav>')
return
links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'
if "platform-operator" in actor.roles:
@ -2384,13 +2390,13 @@ Use the login name they provide; it may differ from your display name.</p></sect
if csrf:
links += '<a href="/logout">Log out</a>'
identity = escape(actor.preferred_username or actor.subject)
_ACCOUNT_NAVIGATION.set(f'<p>Signed in to this portal as <strong>{identity}</strong></p><nav aria-label="Account navigation">' + links + '</nav>')
_ACCOUNT_NAVIGATION.set(f'<p>Signed in as <strong>{identity}</strong></p><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">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{escape(title)} · Railiance</title><style>
<title>{escape(title)} · NetKingdom Identity</title><style>
: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)}}
@ -2401,7 +2407,7 @@ table{{width:100%;border-collapse:collapse;background:#fff}}th,td{{padding:.75re
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>{_ACCOUNT_NAVIGATION.get()}</header><main>{body}</main></body></html>"""
</style></head><body><header><strong>NetKingdom Identity</strong>{_ACCOUNT_NAVIGATION.get()}</header><main>{body}</main></body></html>"""
def _html(
self, start_response: StartResponse, body: str, correlation_id: str,

View file

@ -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")

View file

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

View file

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

View file

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

View file

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

View file

@ -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})