diff --git a/docs/account-journeys.md b/docs/account-journeys.md new file mode 100644 index 0000000..6bb94b4 --- /dev/null +++ b/docs/account-journeys.md @@ -0,0 +1,112 @@ +# Account and access journeys + +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; +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 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 + reachable when a wrong shared identity prevents portal login. +- Log out opens a confirmation with the scope made explicit. GET never signs out. + Invalid confirmation leaves the current session intact. Do not claim global + sign-out when only the portal session ended. +- Name the person, selected tenant, and application separately. Platform + administration is not tenant membership and does not imply application access. +- Every action has a visible result and a useful next step. Failure is not success; + unknown provider state is not “disabled.” Keep recovery reachable before login. +- Use one primary action per step, familiar wording, labelled fields and errors, + keyboard-visible focus, and layouts usable on a phone. Preserve safe form input + after validation failure; never redisplay secrets or trust unverified returns. +- A status change requires authoritative confirmation. A sent message is not + delivered mail; an invitation is not a login; opening a setup link is not OTP + activation; recorded membership is not guaranteed workload admission. + +## User perspective + +| 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 | +| 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; email delivery unresolved; display-name/login-name clarity needs work | +| U05 — Use password-only access before optional OTP enrollment | Ordinary application permits login when provider confirms no activated factor | Provider unavailable gives recovery, never silently bypasses enrolled OTP | KEY-WP-0035 source tested; live credential/policy gate unresolved | +| 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 | +| 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 | + +## Tenant administrator perspective + +| ID / intent | Success | Failure and recovery | Current support / acceptance | +|---|---|---|---| +| T01 — Enter the right tenant administration | Header shows identity; managed tenant is explicit; only permitted admin navigation | Non-admin/cross-tenant request denied with account recovery | Existing authorization/navigation tests; broader browser matrix pending | +| T02 — Invite someone with the right role | Review name, email, tenant and role; show invitation state and next action | Duplicate, wrong address, expired invite: inspect, correct/reissue or expire without making a second account | Invitation routes and checks exist; delivery/preview usability pending | +| T03 — Prepare an account that can actually log in | Distinguish profile, directory login name, invitation, password setup, and tenant access; admin can give the correct login name | Partial provisioning shows what exists and retry reconciles it; never show display name as login implicitly | Create-login/setup-link routes exist; plain login-name presentation and lifecycle state view pending | +| T04 — Help someone who cannot sign in | Identify affected tenant/account; distinguish password, OTP, membership, and outage; give safe recovery step | No access to passwords, OTP seed or current codes; provider failure has support reference/escalation | Password setup and help page exist; verified lost-factor recovery/provider status pending | +| T05 — Grant/change/revoke application access | Review exact tenant/application/role; apply authorized change; confirm effective result | Policy denial or stale version explains reason and refresh; no silent broad grant | Service capabilities vary; consolidated browser application-access management pending | +| T06 — Suspend/reactivate/remove a tenant account | Confirm target and scope; show resulting state and whether access propagation is pending | Stale or failed operation leaves truthful state; retry after readback; shared identity in other tenants preserved | Existing lifecycle routes; confirmation/propagation UX and cross-tenant browser drills pending | +| T07 — Track incomplete onboarding | See invited, identity missing, password pending, OTP problem, and access denied as distinct actionable states | Stale/unknown provider state is labelled; administrator gets the correct owner/action | Headless diagnostics exist; consolidated browser status and retry workflow pending | +| T08 — Manage admin succession and audit | Grant the next admin only with authority; preserve an accountable admin; review who changed what | Prevent loss of last administrator or provide platform recovery; no self-escalation | Required journey; last-admin guard and browser audit acceptance need review | + +## Platform administrator perspective + +| ID / intent | Success | Failure and recovery | Current support / acceptance | +|---|---|---|---| +| P01 — Administer platform without joining every tenant | Platform console reachable with platform role; personal memberships remain truthful | Opening a workload as platform admin can be denied; account console remains reachable | Existing platform navigation/authority tests; full workload denial journey acceptance pending | +| P02 — Create tenant and first administrator | Confirm tenant identifier/type/name and first admin; show tenant created versus admin onboarding pending | Conflict/partial failure readback avoids duplicate tenant; resume only missing stages | Existing platform creation/result routes; live end-to-end drill pending | +| P03 — Find and inspect a tenant | Search exact identifier or disambiguate matching names; clearly state selected scope | Unknown/ambiguous name presents choices without guessing authority | Existing tests for unknown/ambiguous names and tenant selection | +| P04 — Recover a tenant administrator | Verify request and affected scope; audited, bounded account/factor recovery returns control | Insufficient proof denies; provider outage yields pending state and named owner | Identity-recovery route exists; lost-factor recovery and audited browser acceptance pending | +| P05 — Operate sign-in, factor and notification services | See degraded component and impacted journeys; renew scoped credentials; verify recovery | Expired factor-read credential blocks assurance decision; missing mail uses explicit assisted setup; no hidden MFA bypass | Current incident: rejected factor-read credential; custody/renewal and mail delivery unresolved | +| P06 — Set authentication policy | Distinguish optional-after-enrollment, mandatory, and application step-up; preview impact and rollback | Unsupported/ambiguous policy rejected; avoid locking out unenrolled users or weakening privileged actions | KeyCape optional policy source tested; scoped rollout and privileged-portal policy acceptance pending | +| P07 — Suspend/retire/reactivate tenant | Confirm blast radius and resulting tenant lifecycle; applications reflect effective policy | Stale version or partial propagation provides readback/retry; recovery retains audit | Existing tenant lifecycle APIs; browser confirmation/propagation drill pending | +| P08 — Review audit and service recovery | Follow correlation reference without exposing secrets; see actor, tenant, action and result; prove rollback | Missing telemetry or unknown state visible; no success until authoritative readback | Existing service events/references; unified browser audit/support workflow pending | + +## OTP activation state contract + +The provider owns credential material and proof of possession. The portal owns +explanation, navigation, and handoff; it never collects OTP seeds or codes. + +| State | What the user sees | Allowed next step | Required evidence | +|---|---|---|---| +| Unknown / unavailable | “We cannot confirm your authenticator status”; setup unavailable when no verified handoff is configured | Help; provider management only when operator-configured and verified | Do not infer off from lookup failure or AAL1 session | +| Not enrolled | Optional setup, plus continue without OTP where policy permits | Start enrollment | Authoritative factor absence and applicable client policy | +| Enrollment pending | QR/code confirmation at provider; no success badge yet | Confirm, retry, cancel or resume | Pending token cannot accidentally lock normal login | +| Enabled | Confirmation and recovery instructions | Test fresh sign-in; later manage device | Provider verifies possession and reports activated factor | +| Replacement pending | Old factor remains usable until replacement confirmed | Confirm replacement or cancel | No gap allowing MFA bypass | +| Recovery required | Clear lost-device recovery instructions | Verified recovery with audit | Provider proof/authorized recovery, never tenant membership alone | + +Deployment gate: verify the live provider's activation, cancellation, realm mapping, +scoped factor-read credential and renewal contract before enabling optional policy +or setting USER_ENGINE_MFA_MANAGEMENT_URL. Both successful and empty token queries +must be tested against controlled accounts; self-service credentials are not an +administrative lookup credential. The portal help page is reachable before login +so OTP-required login does not hide recovery. + +## Acceptance and remaining work + +USER-WP-0027 tracks the matrix and role-based usability gaps. KEY-WP-0035 tracks +OTP policy/provider rollout. USER-WP-0026 retains authoritative workload catalogue +work. Do not close these based solely on this document or a unit-test pass. + +Run every journey with an ordinary member, tenant admin, and platform operator as +applicable, plus an anonymous/expired session. For each capture: initial state, +visible controls, action, authoritative result, failure, recovery, and cross-tenant +isolation. Use synthetic identities in stored evidence. Include keyboard-only and +narrow-screen checks; never store passwords, QR images or OTP values in evidence. + +Automated portal coverage: test_account_clarity.py, test_account_recovery.py, +test_portal_navigation.py and existing test_web.py authorization/lifecycle tests. +Automated issuer coverage: KEY-WP-0035 optional MFA tests. Actual provider OTP, +notification delivery and multi-user workload acceptance remain separate evidence. diff --git a/docs/configuration.md b/docs/configuration.md index 73b1c8f..af5c32b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,3 +70,18 @@ Enabling it also requires the verification adapter and allow-lists: The service fails closed when those settings are absent. Do not add a placeholder or reused credential to turn the flag on. + + +## Authenticator management handoff + +`USER_ENGINE_MFA_MANAGEMENT_URL` is optional and empty by default. When set, the +public `/security` help page offers a fixed HTTPS provider-management link. No +credentials, query parameters or fragments are allowed in this URL. The portal +never collects an OTP, reads an OTP seed, or marks a factor enabled from a return +URL. It cannot currently display authoritative enrollment status. + +Enable only after verifying provider self-service identity checks, confirmation +before activation, cancellation/recovery, and enrolled-factor enforcement under +KEY-WP-0035. A reachable provider homepage alone is insufficient. Leave unset +while this dependency is unresolved; users get an explicit unavailable state and +help without needing to complete OTP login first. See [account journeys](account-journeys.md). diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index d95dffa..b0d8191 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -66,6 +66,7 @@ def create_application() -> PortalApplication: service, trusted_proxy_secret=_required("USER_ENGINE_PROXY_SECRET"), login_url=_required("USER_ENGINE_LOGIN_URL"), + mfa_management_url=os.environ.get("USER_ENGINE_MFA_MANAGEMENT_URL", ""), public_registration=os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower() == "true", oidc_client=OIDCClient( diff --git a/src/user_engine/web.py b/src/user_engine/web.py index b4c99c1..5cac56b 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -48,6 +48,7 @@ StartResponse = Callable[[str, list[tuple[str, str]]], Any] # Rendering state is scoped to one request, including concurrent WSGI requests. _ACCOUNT_NAVIGATION: ContextVar[str] = ContextVar("account_navigation", default="") +_BROWSER_REQUEST: ContextVar[bool] = ContextVar("browser_request", default=False) def _jsonable(value: Any) -> Any: @@ -74,6 +75,7 @@ class PortalApplication: trusted_proxy_secret: str, login_url: str, public_registration: bool = True, + mfa_management_url: str = "", oidc_client: OIDCClient | None = None, provisioning: IdentityProvisioningPort | None = None, tenant_management: TenantManagementPort | None = None, @@ -92,6 +94,14 @@ class PortalApplication: self.trusted_proxy_secret = trusted_proxy_secret self.login_url = login_url self.public_registration = public_registration + if mfa_management_url: + destination = urlsplit(mfa_management_url) + if (destination.scheme != "https" or not destination.hostname + or destination.username or destination.password + or destination.query or destination.fragment + or any(c.isspace() for c in mfa_management_url)): + raise ValueError("MFA management URL must be a fixed HTTPS destination without credentials or query") + self.mfa_management_url = mfa_management_url self.oidc_client = oidc_client self.provisioning = provisioning self.tenant_management = tenant_management @@ -113,7 +123,15 @@ class PortalApplication: def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]: correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}" navigation_token = _ACCOUNT_NAVIGATION.set("") + browser_token = _BROWSER_REQUEST.set( + "text/html" in str(environ.get("HTTP_ACCEPT", "")) + and not str(environ.get("PATH_INFO", "/")).startswith("/api/") + and str(environ.get("PATH_INFO", "/")) not in {"/healthz", "/readyz", "/metrics"} + ) try: + if (not str(environ.get("PATH_INFO", "/")).startswith("/api/") + and str(environ.get("PATH_INFO", "/")) not in {"/healthz", "/readyz", "/metrics"}): + self._set_account_navigation(environ, self._optional_actor(environ)) return self._dispatch(environ, start_response, str(correlation_id)) except ConflictError as exc: return self._error(start_response, "409 Conflict", "conflict", str(exc), correlation_id) @@ -135,6 +153,7 @@ class PortalApplication: return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id) finally: _ACCOUNT_NAVIGATION.reset(navigation_token) + _BROWSER_REQUEST.reset(browser_token) def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]: method = str(environ.get("REQUEST_METHOD", "GET")).upper() @@ -202,24 +221,25 @@ class PortalApplication: identity = ( f'
This portal is signed in as {escape(actor.preferred_username or actor.subject)}.
' '' - if actor else 'Your identity has not been verified in this portal.
' + if actor else 'You are not signed in to this portal. Sign in to verify your identity and access.
' ) return self._html(start_response, self._page_html( "Sign-in help", 'The application may not allow this account, or the sign-in service may have failed.
' + 'Your account may not have access to the application, or sign-in may have been interrupted.
' + identity - + '' - 'Log out or use another account
', + + 'Help with passwords and verification codes
' + + (self._identity_switch_help() if actor is None else ""), ), correlation_id) + if path == "/security" and method == "GET": + return self._html(start_response, self._security_page(), correlation_id) if path == "/logged-out" and method == "GET": if self._optional_actor(environ) is not None: return self._redirect(start_response, "/", correlation_id) return self._html(start_response, self._page_html( - "Logged out", - 'Your portal session has ended. Your shared NetKingdom sign-in may still be active.
' - + self._shared_logout_link() - + '', + "Not signed in", + 'Your shared NetKingdom sign-in may still be active. Signing in may reuse that account.
' + + self._identity_switch_help(), ), correlation_id) if path == "/logout" and method == "GET": actor = self._optional_actor(environ) @@ -252,6 +272,8 @@ class PortalApplication: return self._html(start_response, self._home(actor), correlation_id) if path == "/register" and method == "GET": + if self._optional_actor(environ) is not None: + return self._redirect(start_response, "/onboarding", correlation_id) if not self.public_registration or self.registration_verification is None: raise NotFoundError("public registration is unavailable") token = secrets.token_urlsafe(32) @@ -1544,21 +1566,56 @@ class PortalApplication: raise ValidationError("Idempotency-Key must contain at least 16 characters") return value - def _shared_logout_link(self) -> str: + def _identity_switch_help(self) -> str: if not self.oidc_client: return "" return ( - f'' - 'Sign out of NetKingdom to use another account
' + 'You can clear the shared sign-in before choosing another account. ' + 'Other applications may keep their own sessions.
' + f'The sign-in service will ask you to verify your identity. Check the account name there before making changes.
' + if self.mfa_management_url else + 'Authenticator setup is temporarily unavailable. ' + 'If a code is requested before you have set up an authenticator, contact your tenant administrator.
' + ) + return self._page_html("Sign-in security", """ +Use this page for help with your password and authenticator app.
+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.
+""" + handoff + """ +Opening the setup page does not activate two-step verification. If you cancel, check the status in authenticator management before leaving.
Use the newest code for the correct account and check that your device sets its time automatically. +If you cannot use your authenticator, follow the sign-in service's recovery instructions or ask your tenant administrator for account recovery. +Never send your password, QR code, or verification codes to an administrator.
Use password recovery on the sign-in page. +If the recovery message does not arrive, ask your tenant administrator for a new password setup link. +Use the login name they provide; it may differ from your display name.
Signed in as {escape(actor.preferred_username)}.
" - '' + '' if actor is not None else ( - f'' + 'You are not signed in to this portal.
' + ( 'New here? Create an account.
' if self.public_registration and self.registration_verification is not None @@ -1842,7 +1899,7 @@ class PortalApplication: return self._page_html( "Onboarding", f"""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)}
Passwords and MFA are managed by your identity provider.
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)}
Signed in to this portal as {identity}
') @staticmethod def _page_html(title: str, body: str) -> str: @@ -1993,6 +2047,20 @@ a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{o return [data] def _error(self, start_response: StartResponse, status: str, code: str, message: str, correlation_id: str) -> list[bytes]: + if _BROWSER_REQUEST.get(): + title = "This action could not be completed" + guidance = "Check your account and access, then try again." + if code == "access_denied": + title = "Access is not available" + guidance = "You may need to sign in again, or your account may not have permission for this action." + elif code == "provisioning_unavailable": + guidance = "Account services are temporarily unavailable. Check the account status before retrying." + page = self._page_html(title, f'{guidance}
' + '' + f'If you need help, give your administrator this reference: {escape(str(correlation_id))}.