USER-WP-0026-T03: access-engine application access readback client

Read FLEX-DEC-2026-019 readback answers for workloads bound in all three
records (user-engine application, protected system, key-cape client) and
show them on the account page. No row is inferred as allow; failures,
missing rows and expired allows render 'could not be checked'. Off unless
USER_ENGINE_READBACK_URL and its own token file are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 629164@bnt-lap001
Assistant-Session: 22dee475-6a23-47f3-8588-00a7f62d8720
This commit is contained in:
tegwick 2026-10-10 00:45:03 +02:00
parent 119910640f
commit 869de4080b
11 changed files with 780 additions and 2 deletions

View file

@ -94,6 +94,7 @@ class PortalApplication:
identity_lookup: Any | None = None,
hub_authority_caller: Actor | None = None,
hub_authority_token: str = "",
application_access: Any | None = None,
) -> None:
if len(trusted_proxy_secret) < 24:
raise ValueError("trusted proxy secret must contain at least 24 characters")
@ -136,6 +137,7 @@ class PortalApplication:
raise ValueError("hub authority lookup needs both a caller and a token, or neither")
self.hub_authority_caller = hub_authority_caller
self.hub_authority_token = hub_authority_token
self.application_access = application_access
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)}"
@ -311,6 +313,8 @@ class PortalApplication:
if path == "/logout" and method == "POST":
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
body = self._form_body(environ)
if session_id and self.application_access is not None:
self.application_access.forget(session_id)
if session_id and self.oidc_client and self.oidc_client.claims(session_id) is not None:
self._require_csrf(environ, str(body.get("csrf_token", "")))
self.oidc_client.logout(session_id)
@ -444,6 +448,10 @@ class PortalApplication:
start_response,
self._onboarding(
session, memberships, journeys, self._csrf_token(environ),
application_access=self._application_access_section(
session.actor,
cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") or "",
),
),
correlation_id,
)
@ -2273,7 +2281,7 @@ Use the login name they provide; it may differ from your display name.</p></sect
def _onboarding(
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
csrf_token: str,
csrf_token: str, application_access: str = "",
) -> str:
journey_items = "".join(
self._onboarding_journey_item(item, csrf_token) for item in journeys
@ -2292,6 +2300,7 @@ Use the login name they provide; it may differ from your display name.</p></sect
<button type="submit">Save profile</button></form></section>
{self._allowed_tenants(actor, memberships)}
{self._allowed_workloads(memberships)}
{application_access}
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
)
@ -2426,6 +2435,35 @@ Use the login name they provide; it may differ from your display name.</p></sect
"<p>Each application checks access when you open it. Tenant membership alone does not grant access to every application.</p></section>"
)
_APPLICATION_ACCESS_TEXT = {
"available": "Available",
"denied": "Not available",
"not_offered": "Not available: not offered here",
"unchecked": "Not available: could not be checked",
}
def _application_access_section(self, actor: Any, session_key: str) -> str:
"""Render access-engine readback answers; never infer an allow."""
if self.application_access is None:
return ""
rows = self.application_access.rows(actor, session_key)
if rows is None:
items = "<li>Application access could not be checked. Try again later.</li>"
elif not rows:
items = "<li>No application is registered for an access check.</li>"
else:
items = "".join(
f'<li data-access="{escape(row.status)}">{escape(row.workload.display_name)} - '
f"{escape(self._APPLICATION_ACCESS_TEXT[row.status])}</li>"
for row in rows
)
return (
'<section aria-labelledby="application-access"><h2 id="application-access">Application access</h2>'
f'<ul id="application-access-list">{items}</ul>'
"<p>Checked with the access service for your current sign-in. Each application checks again when you open it. "
"This list covers registered applications only and is not a complete inventory.</p></section>"
)
@staticmethod
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
steps = "".join(