diff --git a/docs/configuration.md b/docs/configuration.md index 2aecaef..a2be3cd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,6 +83,41 @@ contacted; the platform event never carries `client_id`. An invalid value stops startup — it never falls back to the legacy shape. +## Application access readback + +Off unless `USER_ENGINE_READBACK_URL` is set. When it is set, these are also +needed: + +- `USER_ENGINE_READBACK_URL`: base URL of access-engine's dedicated + `flex-auth-application-readback` pin; +- `USER_ENGINE_READBACK_TOKEN_FILE`: the readback caller token, read on every + call. This is a separate file from `USER_ENGINE_FLEX_AUTH_TOKEN_FILE`. The pin + admits it only under the `application-readback` binding, so it can never act + on `/v1/check`; +- `USER_ENGINE_READBACK_TIMEOUT`: optional, in seconds (default `3`). + +The account page (`/onboarding`) then lists each workload bound in all three +records: + +- the user-engine application record; +- its binding's `protected_system_id`; +- the readback system's `client_binding`, whose issuer and client id must equal + our OIDC issuer and the binding's `oidc_client_id`. + +The rows are rendered as follows. No row is ever inferred as allow: + +| access-engine answer | Shown | +| --- | --- | +| `allow` before `expires_at` | Available | +| `deny` | Not available | +| `not_applicable` | Not available: not offered here | +| failure, missing row, or `allow` past or without `expires_at` | Not available: could not be checked | + +Answers are read only on account-page views. They are reused per session and +subject for at most 60 seconds, and never past the earliest allow's +`expires_at`. Logout drops them. A failed read is not reused. Contract: +access-engine `docs/application-access-readback.md` (FLEX-DEC-2026-019). + ## Authenticator management handoff `USER_ENGINE_MFA_MANAGEMENT_URL` is optional and empty by default. When set, the diff --git a/layer.yaml b/layer.yaml index f652b8e..5da25ee 100644 --- a/layer.yaml +++ b/layer.yaml @@ -64,6 +64,14 @@ non_tooling_clients: write: false note: Engine API. The shape §5 prescribes for a PEP-shaped consumer. + - id: access-engine-application-readback + target: access-engine + layer: engine + module: src/user_engine/adapters/application_access.py + operation: "HTTP GET /v1/readback/systems and POST /v1/readback/application_access" + write: false + note: "Engine API, dedicated readback pin (FLEX-DEC-2026-019). Display only; readback decisions authorize nothing and no row is inferred as allow." + - id: oidc-claim-input target: key-cape layer: tooling-as-claim-input diff --git a/src/user_engine/adapters/application_access.py b/src/user_engine/adapters/application_access.py new file mode 100644 index 0000000..d5fecb7 --- /dev/null +++ b/src/user_engine/adapters/application_access.py @@ -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 diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py index c5290ba..5724675 100644 --- a/src/user_engine/adapters/local.py +++ b/src/user_engine/adapters/local.py @@ -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 diff --git a/src/user_engine/adapters/postgres.py b/src/user_engine/adapters/postgres.py index 5325d09..10df95b 100644 --- a/src/user_engine/adapters/postgres.py +++ b/src/user_engine/adapters/postgres.py @@ -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")) diff --git a/src/user_engine/application_access.py b/src/user_engine/application_access.py new file mode 100644 index 0000000..8d1efda --- /dev/null +++ b/src/user_engine/application_access.py @@ -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) diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index bb8d783..38597d9 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -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.""" diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index dc65648..38f2bda 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -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 diff --git a/src/user_engine/web.py b/src/user_engine/web.py index f6c0ad3..d12ef55 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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.
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.Save profile {self._allowed_tenants(actor, memberships)} {self._allowed_workloads(memberships)} +{application_access}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.