From b8506ef2e336f15eb85b3fd66072ddc7ade341c4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 13 Sep 2026 14:14:10 +0200 Subject: [PATCH] Add platform support investigation and clarify bounded account recovery Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- scripts/browser_journeys.mjs | 9 +++ src/user_engine/web.py | 68 +++++++++++++++- tests/journey-coverage.json | 12 ++- tests/test_platform_support.py | 79 +++++++++++++++++++ tests/test_web.py | 2 +- .../USER-WP-0030-platform-admin-journeys.md | 12 +++ 6 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 tests/test_platform_support.py 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 += "" + "".join(f"{escape(value)}" for value in + (stamp.isoformat(), kind, scope, action, actor_name, ref, status)) + f"{detail}" + empty = 'No matching records. This does not prove that no action occurred; check the reference and provider records.' + return self._page_html("Platform activity", f"""

Platform activity

+

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.

+{rows or empty}
TimeKindTenantActionActorSupport referenceKnown resultNext 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'{escape(event.event_id)}{escape(event.tenant)}{escape(event.event_type)}{escape(self._delivery_status(event))}{escape(event.correlation_id)}{action}' - return self._page_html("Service recovery", '

Service recovery

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", '

Service recovery

' + configuration + '

Investigate a support reference

This view shows local delivery records. Live sign-in, email receipt and authenticator health are not verified here.

' '
' '' + (rows or '') + '
DeliveryTenantKindStatusSupport referenceRecovery
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'{escape(name)}{"Configured; live health unverified" if configured else "Unavailable in this portal"}{escape(help_text)}' + for name, configured, help_text in capabilities) + return ('

Service capabilities

' + + rows + '
ServiceKnown stateRecovery 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.

') + def _require_setup_access(self, tenant: str, user_id: str) -> None: account = self.service.store.tenant_account(tenant, user_id) if account is not None and account.status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}: @@ -1913,7 +1973,7 @@ Use the login name they provide; it may differ from your display name.

{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}' f'{escape(user.primary_email or "") if user else ""}{escape(membership.kind)}' f'{escape(status.value)} for this tenant{login}{actions}') @@ -2113,7 +2173,7 @@ Use the login name they provide; it may differ from your display name.

Manage users' session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") diff --git a/tests/journey-coverage.json b/tests/journey-coverage.json index 6adb02d..403093f 100644 --- a/tests/journey-coverage.json +++ b/tests/journey-coverage.json @@ -245,7 +245,8 @@ "role": "platform_admin", "implementation": "external-blocked", "tests": [ - "test_journey_roles.PlatformAdminJourneys.test_recovery_uses_tenant_access_and_keeps_global_identity_operations_unused" + "test_journey_roles.PlatformAdminJourneys.test_recovery_uses_tenant_access_and_keeps_global_identity_operations_unused", + "test_platform_support.PlatformSupportJourneys.test_recovery_denied_before_preview_and_operator_sees_factor_boundary" ], "remaining": "Tenant identity recovery is scoped; verified OTP/account-ownership recovery remains provider-owned." }, @@ -254,7 +255,8 @@ "role": "platform_admin", "implementation": "external-blocked", "tests": [ - "test_journey_roles.PlatformAdminJourneys.test_delivery_denial_redaction_retry_and_completed_guard" + "test_journey_roles.PlatformAdminJourneys.test_delivery_denial_redaction_retry_and_completed_guard", + "test_platform_support.PlatformSupportJourneys.test_service_capabilities_distinguish_configuration_from_health" ], "remaining": "Local delivery record operations work; approved factor credential renewal and mail receipt remain external dependencies." }, @@ -283,7 +285,11 @@ "implementation": "implemented", "tests": [ "test_journey_roles.PlatformAdminJourneys.test_delivery_denial_redaction_retry_and_completed_guard", - "test_journey_roles.TenantAdminJourneys.test_audit_is_tenant_scoped_and_never_dumps_payload" + "test_journey_roles.TenantAdminJourneys.test_audit_is_tenant_scoped_and_never_dumps_payload", + "test_platform_support.PlatformSupportJourneys.test_operator_correlates_actions_and_delivery_without_raw_content", + "test_platform_support.PlatformSupportJourneys.test_activity_denies_nonoperators_and_mutations", + "test_platform_support.PlatformSupportJourneys.test_exact_filters_apply_before_display_limit_and_missing_is_explicit", + "test_platform_support.PlatformSupportJourneys.test_filter_values_are_escaped_and_bounded" ], "remaining": "" } diff --git a/tests/test_platform_support.py b/tests/test_platform_support.py new file mode 100644 index 0000000..46f5b23 --- /dev/null +++ b/tests/test_platform_support.py @@ -0,0 +1,79 @@ +"""Platform support journeys with synthetic records and provider failures.""" +from dataclasses import replace +from urllib.parse import urlencode +from user_engine.domain import AuditRecord, OutboxEvent, utc_now +from test_journey_roles import JourneyFixture, TENANT, OTHER +from test_web import invoke + +class PlatformSupportJourneys(JourneyFixture): + def seed(self): + self.app.service.store.append_audit(AuditRecord(audit_id="audit-support", actor=self.actor, + action="tenant.account.update", subject="person", tenant=TENANT, + correlation_id="support-123", summary="private-audit-summary")) + self.app.service.store.append_outbox(OutboxEvent(event_id="support-delivery", event_type="notification.requested", + aggregate_id="person", tenant=TENANT, correlation_id="support-123", + payload={"private":"private-payload"}, failed_at=utc_now(), failure_reason="private-provider-error")) + + def test_operator_correlates_actions_and_delivery_without_raw_content(self): + self.seed() + self.app.service.store.append_outbox(OutboxEvent(event_id="unrelated", event_type="private-unrelated", + aggregate_id="other", tenant=OTHER, correlation_id="different", payload={})) + response,body=invoke(self.app,"/platform/activity",cookie="ue_session=operator",query="reference=support-123") + self.assertEqual("200 OK",response["status"]) + for value in [b"tenant.account.update",b"notification.requested",b"Inspect delivery",b"support-delivery",b"Delivery failed"]: + self.assertIn(value,body) + for value in [b"private-audit-summary",b"private-payload",b"private-provider-error",b"private-unrelated"]: + self.assertNotIn(value,body) + self.assertIn(b"external outcome is unverified",body) + + def test_activity_denies_nonoperators_and_mutations(self): + self.seed() + for who in ["member","admin"]: + response,body=invoke(self.app,"/platform/activity",cookie="ue_session="+who) + self.assertEqual("403 Forbidden",response["status"]) + self.assertNotIn(b"support-delivery",body) + response,_=self.post("/platform/activity",who="operator") + self.assertEqual("404 Not Found",response["status"]) + + def test_exact_filters_apply_before_display_limit_and_missing_is_explicit(self): + self.seed() + for i in range(105): + self.app.service.store.append_outbox(OutboxEvent(event_id="noise-"+str(i),event_type="noise", + aggregate_id="other",tenant=OTHER,correlation_id="noise",payload={})) + response,body=invoke(self.app,"/platform/activity",cookie="ue_session=operator", + query=urlencode({"reference":"support-123","tenant":TENANT})) + self.assertEqual("200 OK",response["status"]) + self.assertIn(b"support-delivery",body) + self.assertIn(b"Showing 2 of 2",body) + for query in ["reference=support",urlencode({"reference":"support-123","tenant":OTHER})]: + _,body=invoke(self.app,"/platform/activity",cookie="ue_session=operator",query=query) + self.assertIn(b"No matching records",body) + self.assertNotIn(b"support-delivery",body) + + def test_filter_values_are_escaped_and_bounded(self): + response,body=invoke(self.app,"/platform/activity",cookie="ue_session=operator",query=urlencode({"reference":""})) + self.assertEqual("200 OK",response["status"]) + self.assertIn(b"<script>",body);self.assertNotIn(b"