diff --git a/scripts/browser_journeys.mjs b/scripts/browser_journeys.mjs index fa6847b..5d95c76 100644 --- a/scripts/browser_journeys.mjs +++ b/scripts/browser_journeys.mjs @@ -42,6 +42,15 @@ try{ await check(`!!document.querySelector('a[href="/platform/operations"]')`,'P01 platform recovery navigation'); await navigate('/platform/operations'); await check(`document.body.innerText.includes("Live sign-in, email receipt and authenticator health are not verified here")`,'P05 unknown provider health remains explicit'); + await navigate('/platform/activity'); + await check(`!!document.querySelector('input[name="reference"]') && !!document.querySelector('input[name="tenant"]')`,'P08 platform investigation filters'); + await evaluate(`document.querySelector('input[name="reference"]').value='synthetic-missing';document.querySelector('form[action="/platform/activity"] button').click()`); + await waitFor('location.search.includes("synthetic-missing") && document.body.innerText.includes("No matching records")'); + await check(`document.body.innerText.includes("does not prove that no action occurred")`,'P08 missing evidence is explicit'); + await navigate('/admin/tenant:trial:demo-company'); + await evaluate(`Array.from(document.forms).find(f=>f.action.endsWith("/recover")).querySelector("button").click()`); + await waitFor('document.body.innerText.includes("Confirm change")'); + await check(`document.body.innerText.includes("does not reset a password") && document.body.innerText.includes("cannot bypass")`,'P04 restoration explains factor boundary'); await navigate('/logout'); await check(`document.body.innerText.includes("Log out of this portal?")`,'U11 logout requires confirmation'); await evaluate(`document.querySelector('form[action="/logout"] button').click()`); diff --git a/src/user_engine/web.py b/src/user_engine/web.py index fdde662..d2862f2 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -753,6 +753,14 @@ class PortalApplication: "status": "removed", "tenant_account": _jsonable(account), "provider_identity_removed": False, }, correlation_id) + if path == "/platform/activity": + self.service.resolve_tenant_context(actor, PLATFORM_TENANT) + if method != "GET": + raise NotFoundError("activity route not found") + self.service.tenant_diagnostics(actor, tenant=PLATFORM_TENANT, correlation_id=correlation_id) + query = parse_qs(str(environ.get("QUERY_STRING", ""))) + return self._html(start_response, self._platform_activity( + query.get("reference", [""])[0], query.get("tenant", [""])[0]), correlation_id) if path in {"/platform/operations", "/platform/operations/replay"}: self.service.resolve_tenant_context(actor, PLATFORM_TENANT) if method == "POST" and path.endswith("/replay"): @@ -921,12 +929,15 @@ class PortalApplication: body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) if len(parts) == 6 and parts[3] == "users" and parts[5] in {"status", "remove", "recover", "role"}: + if parts[5] == "recover": + self.service.resolve_tenant_context(actor, PLATFORM_TENANT) user = self.service.store.user(parts[4]) state = self.service.store.tenant_account(tenant, parts[4]) snapshot = repr((state, self.service.store.memberships_for_user(parts[4], tenant=tenant))) preview = self._confirm_change(environ, start_response, body, snapshot, f"{parts[5].capitalize()} account in {tenant}", - f"Account: {user.display_name or user.user_id}. This action applies to this tenant. Other tenant access and the shared login are retained.", correlation_id) + f"Account: {user.display_name or user.user_id}. This action applies to this tenant. Other tenant access and the shared login are retained." + + (" Recovery restores this tenant account and prepares a missing directory login. Verify the person's request through your established support process first. It does not reset a password, remove an authenticator, lift a global suspension, or prove account ownership. For a lost authenticator, use provider recovery; this action cannot bypass it." if parts[5] == "recover" else ""), correlation_id) if preview is not None: return preview if len(parts) == 4 and parts[3] in {"users", "invitations"} and body.get("role", "user") not in {"user", "tenant-admin"}: @@ -1155,6 +1166,42 @@ class PortalApplication: if event.claimed_by: return "Being processed" return "Queued for delivery" + def _platform_activity(self, reference: str, tenant: str) -> str: + reference, tenant = reference.strip(), tenant.strip() + if len(reference) > 200 or len(tenant) > 200: + raise ValidationError("Support reference and tenant must each be at most 200 characters.") + entries = [] + for record in self.service.audit_records(): + if (reference and record.correlation_id != reference) or (tenant and record.tenant != tenant): + continue + entries.append((record.recorded_at, "Audit", record.tenant, record.action, + record.actor.preferred_username or record.actor.subject, record.correlation_id, + "Recorded action; external outcome is unverified", "")) + for event in self.service.store.outbox_history(): + if (reference and event.correlation_id != reference) or (tenant and event.tenant != tenant): + continue + link = "/platform/operations?" + urlencode({"event_id": event.event_id}) + entries.append((event.occurred_at, "Delivery", event.tenant, event.event_type, + "—", event.correlation_id, self._delivery_status(event), link)) + entries.sort(key=lambda row: row[0], reverse=True) + count = len(entries) + rows = "" + for stamp, kind, scope, action, actor_name, ref, status, link in entries[:100]: + detail = f'Inspect delivery' if link else "" + rows += "
Search an exact support reference across recorded tenant actions and delivery attempts. Add a full tenant identifier to narrow the scope.
+ +Showing {min(count, 100)} of {count} matching records, newest first. Filters apply before the 100-record display limit.
+| Time | Kind | Tenant | Action | Actor | Support reference | Known result | Next step |
|---|
Audit records describe recorded actions. Delivery acceptance does not prove receipt, and neither proves a provider change or rollback. Check the relevant provider before closing an incident.
+Service recovery · Platform administration
""") + def _operations_page(self, actor: Any, csrf: str, event_id: str, correlation_id: str) -> str: self.service.tenant_diagnostics(actor, tenant=PLATFORM_TENANT, correlation_id=correlation_id) events = list(self.service.store.outbox_history())[-100:] @@ -1168,12 +1215,25 @@ class PortalApplication: if event.delivered_at is None and not event.claimed_by and (event.failed_at or event.dead_lettered_at): action = f'' rows += f'This view shows local delivery records. Live sign-in, email receipt and authenticator health are not verified here.
' + configuration = self._operation_capabilities() + return self._page_html("Service recovery", 'Investigate a support reference
This view shows local delivery records. Live sign-in, email receipt and authenticator health are not verified here.
' '' '| Delivery | Tenant | Kind | Status | Support reference | Recovery |
|---|---|---|---|---|---|
| No delivery records. This does not prove mail was received. | |||||
Queued retries are processed by the delivery worker. Check the record again for the result.
Return to platform administration
') + 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."), + ("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'| Service | Known state | Recovery step |
|---|
Authenticator recovery and authentication policy changes are unavailable in this portal. The sign-in service owner must verify factor lookup, recovery and policy enforcement. A configured adapter is not a health check.