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

@ -0,0 +1,128 @@
"""access-engine application access readback HTTP adapter.
Calls the dedicated readback pin with its own caller token. The token file is
separate from the ``/v1/check`` caller token and never authorizes a decision.
Any failure raises ``ReadbackUnavailable``; callers render "could not be checked".
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from datetime import datetime
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from user_engine.application_access import (
MAX_SYSTEMS_PER_REQUEST,
ReadbackResult,
ReadbackSystem,
ReadbackUnavailable,
)
from user_engine.domain import Actor
_EFFECTS = frozenset({"allow", "deny", "not_applicable"})
class ApplicationAccessReadbackHTTPAdapter:
"""Read ``/v1/readback/systems`` and ``/v1/readback/application_access``."""
def __init__(
self,
*,
base_url: str,
bearer_token_file: str,
timeout_seconds: float = 3.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.bearer_token_file = bearer_token_file
self.timeout_seconds = timeout_seconds
def systems(self) -> tuple[ReadbackSystem, ...]:
body = self._call("GET", "/v1/readback/systems")
try:
systems = []
for item in body["systems"]:
binding = item.get("client_binding") or {}
systems.append(ReadbackSystem(
system_id=str(item["id"]),
name=str(item.get("name") or item["id"]),
readback=bool(item["readback"]),
client_issuer=str(binding["issuer"]) if binding else None,
client_id=str(binding["client_id"]) if binding else None,
))
return tuple(systems)
except (KeyError, TypeError, AttributeError) as error:
raise ReadbackUnavailable("malformed readback systems response") from error
def read(self, actor: Actor, systems: Sequence[str]) -> tuple[ReadbackResult, ...]:
requested = list(dict.fromkeys(systems))
if not 1 <= len(requested) <= MAX_SYSTEMS_PER_REQUEST:
raise ValueError("readback requires 1 to 64 unique systems")
payload = {
"tenant": actor.tenant,
# Same subject shape as FlexAuthHTTPAdapter's /v1/check request.
"subject": {
"id": actor.subject,
"type": actor.principal_type.value,
"tenant": actor.tenant,
"attributes": {
"issuer": actor.issuer,
"roles": list(actor.roles),
"tenant_roles": list(actor.tenant_roles),
"groups": list(actor.groups),
"scopes": list(actor.scopes),
"assurance": dict(actor.assurance),
},
},
"systems": requested,
}
body = self._call("POST", "/v1/readback/application_access", payload)
try:
results = []
for item in body["results"]:
effect = str(item["effect"])
if effect not in _EFFECTS:
raise ValueError(f"unknown readback effect {effect!r}")
lifetime = item.get("lifetime") or {}
results.append(ReadbackResult(
system_id=str(item["system"]),
effect=effect,
expires_at=_timestamp(lifetime.get("expires_at")),
decision_id=item.get("decision_id"),
))
except (KeyError, TypeError, ValueError, AttributeError) as error:
raise ReadbackUnavailable("malformed readback response") from error
if [result.system_id for result in results] != requested:
raise ReadbackUnavailable("readback results do not match the request")
return tuple(results)
def _call(self, method: str, path: str, payload: Any = None) -> Any:
try:
# Read per call so projected ServiceAccount-token rotation needs no restart.
with open(self.bearer_token_file, encoding="utf-8") as token_file:
token = token_file.read().strip()
if not token:
raise ValueError("empty readback caller token")
headers = {"Authorization": f"Bearer {token}"}
data = None
if payload is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(payload).encode()
response = urlopen(
Request(self.base_url + path, data=data, headers=headers, method=method),
timeout=self.timeout_seconds,
)
return json.load(response)
except (HTTPError, URLError, TimeoutError, OSError, ValueError) as error:
raise ReadbackUnavailable("application access readback unavailable") from error
def _timestamp(value: Any) -> datetime | None:
if not value:
return None
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("readback expires_at must carry a timezone")
return parsed

View file

@ -158,6 +158,9 @@ class InMemoryUserEngineStore:
def binding(self, application_id: str) -> ApplicationBinding | None:
return self.bindings.get(application_id)
def all_bindings(self) -> tuple[ApplicationBinding, ...]:
return tuple(self.bindings.values())
def save_catalog(self, catalog: Catalog) -> None:
self.catalogs[catalog.catalog_id] = catalog

View file

@ -233,6 +233,9 @@ class PostgresUserEngineStore:
def catalog(self, catalog_id: str) -> Catalog | None:
return cast(Catalog | None, self._get_record("catalogs", catalog_id))
def all_bindings(self) -> tuple[ApplicationBinding, ...]:
return cast(tuple[ApplicationBinding, ...], self._query_records("application_bindings"))
def all_catalogs(self) -> tuple[Catalog, ...]:
return cast(tuple[Catalog, ...], self._query_records("catalogs"))

View file

@ -0,0 +1,181 @@
"""Application access readback: which bound workloads a signed-in person may use.
access-engine is the only decision point. This module joins its readback
answers to the workload catalogue and renders them. It never infers an allow:
a missing, failed, expired, denied or not-applicable answer is "not available".
Contract: access-engine ``docs/application-access-readback.md``
(FLEX-DEC-2026-019).
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta
from user_engine.domain import Actor, Application, ApplicationBinding, utc_now
MAX_SYSTEMS_PER_REQUEST = 64
DEFAULT_CACHE_TTL = timedelta(seconds=60)
class ReadbackUnavailable(Exception):
"""access-engine returned no usable readback answer."""
@dataclass(frozen=True)
class ReadbackSystem:
system_id: str
name: str
readback: bool
client_issuer: str | None = None
client_id: str | None = None
@dataclass(frozen=True)
class ReadbackResult:
system_id: str
effect: str
expires_at: datetime | None = None
decision_id: str | None = None
@dataclass(frozen=True)
class Workload:
"""A workload bound in all three records: user-engine, key-cape, access-engine."""
application_id: str
display_name: str
system_id: str
@dataclass(frozen=True)
class ApplicationAccessRow:
workload: Workload
# available | denied | not_offered | unchecked
status: str
expires_at: datetime | None = None
@property
def available(self) -> bool:
return self.status == "available"
def workload_catalogue(
applications: Sequence[tuple[Application, ApplicationBinding]],
systems: Sequence[ReadbackSystem],
*,
issuer: str,
) -> tuple[Workload, ...]:
"""List only workloads whose three records exist and agree."""
by_id = {system.system_id: system for system in systems}
expected_issuer = issuer.rstrip("/")
workloads = []
for application, binding in applications:
system = by_id.get(binding.protected_system_id or "")
if (
system is None
or not binding.oidc_client_id
or system.client_id != binding.oidc_client_id
or (system.client_issuer or "").rstrip("/") != expected_issuer
):
continue
workloads.append(Workload(
application_id=application.application_id,
display_name=application.display_name or system.name or system.system_id,
system_id=system.system_id,
))
return tuple(sorted(workloads, key=lambda item: (item.display_name, item.application_id)))
def render_rows(
workloads: Sequence[Workload],
results: Sequence[ReadbackResult] | None,
*,
now: datetime,
) -> tuple[ApplicationAccessRow, ...]:
by_system = {result.system_id: result for result in results or ()}
rows = []
for workload in workloads:
result = by_system.get(workload.system_id)
if result is None:
status, expires_at = "unchecked", None
elif result.effect == "allow":
live = result.expires_at is not None and result.expires_at > now
status, expires_at = ("available", result.expires_at) if live else ("unchecked", None)
elif result.effect == "deny":
status, expires_at = "denied", None
elif result.effect == "not_applicable":
status, expires_at = "not_offered", None
else:
status, expires_at = "unchecked", None
rows.append(ApplicationAccessRow(workload, status, expires_at))
return tuple(rows)
@dataclass
class _CacheEntry:
workloads: tuple[Workload, ...]
results: tuple[ReadbackResult, ...] | None
reuse_until: datetime
class ApplicationAccessReader:
"""Read and reuse answers per session and subject, never past expiry."""
def __init__(
self,
readback: object,
*,
issuer: str,
bound_applications: Callable[[], Sequence[tuple[Application, ApplicationBinding]]],
cache_ttl: timedelta = DEFAULT_CACHE_TTL,
clock: Callable[[], datetime] = utc_now,
) -> None:
self.readback = readback
self.issuer = issuer
self.bound_applications = bound_applications
self.cache_ttl = cache_ttl
self.clock = clock
self._cache: dict[tuple[str, str, str], _CacheEntry] = {}
def rows(self, actor: Actor, session_key: str) -> tuple[ApplicationAccessRow, ...] | None:
"""Return rendered rows, or None when the catalogue itself is unreadable."""
now = self.clock()
key = (session_key, actor.issuer, actor.subject)
entry = self._cache.get(key)
if entry is None or entry.reuse_until <= now:
entry = self._read(actor, now)
if entry is None:
self._cache.pop(key, None)
return None
self._cache[key] = entry
return render_rows(entry.workloads, entry.results, now=now)
def forget(self, session_key: str) -> None:
for key in [key for key in self._cache if key[0] == session_key]:
del self._cache[key]
def _read(self, actor: Actor, now: datetime) -> _CacheEntry | None:
try:
systems = self.readback.systems()
except ReadbackUnavailable:
return None
workloads = workload_catalogue(
self.bound_applications(), systems, issuer=self.issuer
)[:MAX_SYSTEMS_PER_REQUEST]
results: tuple[ReadbackResult, ...] | None = ()
if workloads:
try:
results = tuple(self.readback.read(
actor, [workload.system_id for workload in workloads]
))
except ReadbackUnavailable:
results = None
reuse_until = now + self.cache_ttl
for result in results or ():
if result.effect == "allow" and result.expires_at is not None:
reuse_until = min(reuse_until, result.expires_at)
if results is None:
reuse_until = now
return _CacheEntry(workloads, results, reuse_until)

View file

@ -9,7 +9,7 @@ from __future__ import annotations
from contextlib import AbstractContextManager
from dataclasses import dataclass
from typing import Any, Iterable, Mapping, Protocol
from typing import Any, Iterable, Mapping, Protocol, Sequence
from user_engine.domain import (
Account,
@ -303,6 +303,9 @@ class UserEngineStore(Protocol):
def binding(self, application_id: str) -> ApplicationBinding | None:
"""Return an application binding by application id."""
def all_bindings(self) -> tuple[ApplicationBinding, ...]:
"""Return all application bindings."""
def save_catalog(self, catalog: Catalog) -> None:
"""Create or replace a catalog."""
@ -591,3 +594,16 @@ class SecretProvider(Protocol):
def get(self, name: str) -> str:
"""Return a secret value by logical name."""
class ApplicationAccessReadbackPort(Protocol):
"""Read access-engine application access answers; never decide locally.
Both calls raise ``ReadbackUnavailable`` when no usable answer exists.
"""
def systems(self) -> tuple[Any, ...]:
"""Return the readback pin's registry projection (``ReadbackSystem``)."""
def read(self, actor: Actor, systems: Sequence[str]) -> tuple[Any, ...]:
"""Return one ``ReadbackResult`` per requested system."""

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import os
from typing import Any
from wsgiref.simple_server import make_server
from user_engine.adapters import (
@ -145,6 +146,29 @@ def create_application() -> PortalApplication:
hub_authority_token=os.environ.get("USER_ENGINE_HUB_AUTHORITY_TOKEN", "").strip(),
)
if os.environ.get("USER_ENGINE_READBACK_URL"):
from user_engine.adapters.application_access import (
ApplicationAccessReadbackHTTPAdapter,
)
from user_engine.application_access import ApplicationAccessReader
def bound_applications() -> list[tuple[Any, Any]]:
return [
(application, binding)
for binding in store.all_bindings()
if (application := store.application(binding.application_id)) is not None
]
app.application_access = ApplicationAccessReader(
ApplicationAccessReadbackHTTPAdapter(
base_url=_required("USER_ENGINE_READBACK_URL"),
bearer_token_file=_required("USER_ENGINE_READBACK_TOKEN_FILE"),
timeout_seconds=float(os.environ.get("USER_ENGINE_READBACK_TIMEOUT", "3")),
),
issuer=_required("USER_ENGINE_OIDC_ISSUER"),
bound_applications=bound_applications,
)
from user_engine.operations_status import check_services
app.operations_probe = lambda: check_services(app.oidc_client, app.outbox_delivery)
from user_engine.authentication_policy import PolicyClient

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(