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
This commit is contained in:
parent
1c7634c7ce
commit
85423e8e09
7 changed files with 320 additions and 53 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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>New here? <a href="/register">Create an account</a>.</p>'
|
||||||
'<p><a class="button" href="/onboarding">View my account</a></p>'
|
if actor is None and self.public_registration and self.registration_verification is not None
|
||||||
if actor is not None
|
else ""
|
||||||
else (
|
|
||||||
'<p>You are not signed in to this portal.</p>'
|
|
||||||
+ (
|
|
||||||
'<p>New here? <a href="/register">Create an account</a>.</p>'
|
|
||||||
if self.public_registration and self.registration_verification is not None
|
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
133
tests/test_account_awareness.py
Normal file
133
tests/test_account_awareness.py
Normal 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()
|
||||||
|
|
@ -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'})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue