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:
parent
119910640f
commit
869de4080b
11 changed files with 780 additions and 2 deletions
|
|
@ -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.
|
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
|
## Authenticator management handoff
|
||||||
|
|
||||||
`USER_ENGINE_MFA_MANAGEMENT_URL` is optional and empty by default. When set, the
|
`USER_ENGINE_MFA_MANAGEMENT_URL` is optional and empty by default. When set, the
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,14 @@ non_tooling_clients:
|
||||||
write: false
|
write: false
|
||||||
note: Engine API. The shape §5 prescribes for a PEP-shaped consumer.
|
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
|
- id: oidc-claim-input
|
||||||
target: key-cape
|
target: key-cape
|
||||||
layer: tooling-as-claim-input
|
layer: tooling-as-claim-input
|
||||||
|
|
|
||||||
128
src/user_engine/adapters/application_access.py
Normal file
128
src/user_engine/adapters/application_access.py
Normal 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
|
||||||
|
|
@ -158,6 +158,9 @@ class InMemoryUserEngineStore:
|
||||||
def binding(self, application_id: str) -> ApplicationBinding | None:
|
def binding(self, application_id: str) -> ApplicationBinding | None:
|
||||||
return self.bindings.get(application_id)
|
return self.bindings.get(application_id)
|
||||||
|
|
||||||
|
def all_bindings(self) -> tuple[ApplicationBinding, ...]:
|
||||||
|
return tuple(self.bindings.values())
|
||||||
|
|
||||||
def save_catalog(self, catalog: Catalog) -> None:
|
def save_catalog(self, catalog: Catalog) -> None:
|
||||||
self.catalogs[catalog.catalog_id] = catalog
|
self.catalogs[catalog.catalog_id] = catalog
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,9 @@ class PostgresUserEngineStore:
|
||||||
def catalog(self, catalog_id: str) -> Catalog | None:
|
def catalog(self, catalog_id: str) -> Catalog | None:
|
||||||
return cast(Catalog | None, self._get_record("catalogs", catalog_id))
|
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, ...]:
|
def all_catalogs(self) -> tuple[Catalog, ...]:
|
||||||
return cast(tuple[Catalog, ...], self._query_records("catalogs"))
|
return cast(tuple[Catalog, ...], self._query_records("catalogs"))
|
||||||
|
|
||||||
|
|
|
||||||
181
src/user_engine/application_access.py
Normal file
181
src/user_engine/application_access.py
Normal 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)
|
||||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Iterable, Mapping, Protocol
|
from typing import Any, Iterable, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
from user_engine.domain import (
|
from user_engine.domain import (
|
||||||
Account,
|
Account,
|
||||||
|
|
@ -303,6 +303,9 @@ class UserEngineStore(Protocol):
|
||||||
def binding(self, application_id: str) -> ApplicationBinding | None:
|
def binding(self, application_id: str) -> ApplicationBinding | None:
|
||||||
"""Return an application binding by application id."""
|
"""Return an application binding by application id."""
|
||||||
|
|
||||||
|
def all_bindings(self) -> tuple[ApplicationBinding, ...]:
|
||||||
|
"""Return all application bindings."""
|
||||||
|
|
||||||
def save_catalog(self, catalog: Catalog) -> None:
|
def save_catalog(self, catalog: Catalog) -> None:
|
||||||
"""Create or replace a catalog."""
|
"""Create or replace a catalog."""
|
||||||
|
|
||||||
|
|
@ -591,3 +594,16 @@ class SecretProvider(Protocol):
|
||||||
|
|
||||||
def get(self, name: str) -> str:
|
def get(self, name: str) -> str:
|
||||||
"""Return a secret value by logical name."""
|
"""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."""
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from typing import Any
|
||||||
from wsgiref.simple_server import make_server
|
from wsgiref.simple_server import make_server
|
||||||
|
|
||||||
from user_engine.adapters import (
|
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(),
|
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
|
from user_engine.operations_status import check_services
|
||||||
app.operations_probe = lambda: check_services(app.oidc_client, app.outbox_delivery)
|
app.operations_probe = lambda: check_services(app.oidc_client, app.outbox_delivery)
|
||||||
from user_engine.authentication_policy import PolicyClient
|
from user_engine.authentication_policy import PolicyClient
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ class PortalApplication:
|
||||||
identity_lookup: Any | None = None,
|
identity_lookup: Any | None = None,
|
||||||
hub_authority_caller: Actor | None = None,
|
hub_authority_caller: Actor | None = None,
|
||||||
hub_authority_token: str = "",
|
hub_authority_token: str = "",
|
||||||
|
application_access: Any | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if len(trusted_proxy_secret) < 24:
|
if len(trusted_proxy_secret) < 24:
|
||||||
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
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")
|
raise ValueError("hub authority lookup needs both a caller and a token, or neither")
|
||||||
self.hub_authority_caller = hub_authority_caller
|
self.hub_authority_caller = hub_authority_caller
|
||||||
self.hub_authority_token = hub_authority_token
|
self.hub_authority_token = hub_authority_token
|
||||||
|
self.application_access = application_access
|
||||||
|
|
||||||
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
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)}"
|
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":
|
if path == "/logout" and method == "POST":
|
||||||
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
||||||
body = self._form_body(environ)
|
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:
|
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._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||||
self.oidc_client.logout(session_id)
|
self.oidc_client.logout(session_id)
|
||||||
|
|
@ -444,6 +448,10 @@ class PortalApplication:
|
||||||
start_response,
|
start_response,
|
||||||
self._onboarding(
|
self._onboarding(
|
||||||
session, memberships, journeys, self._csrf_token(environ),
|
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,
|
correlation_id,
|
||||||
)
|
)
|
||||||
|
|
@ -2273,7 +2281,7 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
||||||
|
|
||||||
def _onboarding(
|
def _onboarding(
|
||||||
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
|
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
|
||||||
csrf_token: str,
|
csrf_token: str, application_access: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
journey_items = "".join(
|
journey_items = "".join(
|
||||||
self._onboarding_journey_item(item, csrf_token) for item in journeys
|
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>
|
<button type="submit">Save profile</button></form></section>
|
||||||
{self._allowed_tenants(actor, memberships)}
|
{self._allowed_tenants(actor, memberships)}
|
||||||
{self._allowed_workloads(memberships)}
|
{self._allowed_workloads(memberships)}
|
||||||
|
{application_access}
|
||||||
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
|
<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>"
|
"<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
|
@staticmethod
|
||||||
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
|
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
|
||||||
steps = "".join(
|
steps = "".join(
|
||||||
|
|
|
||||||
330
tests/test_application_access.py
Normal file
330
tests/test_application_access.py
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
from user_engine.adapters.application_access import ApplicationAccessReadbackHTTPAdapter
|
||||||
|
from user_engine.adapters.flex_auth import FlexAuthHTTPAdapter
|
||||||
|
from user_engine.application_access import (
|
||||||
|
ApplicationAccessReader,
|
||||||
|
ReadbackResult,
|
||||||
|
ReadbackSystem,
|
||||||
|
ReadbackUnavailable,
|
||||||
|
Workload,
|
||||||
|
render_rows,
|
||||||
|
workload_catalogue,
|
||||||
|
)
|
||||||
|
from user_engine.domain import (
|
||||||
|
Actor,
|
||||||
|
Application,
|
||||||
|
ApplicationBinding,
|
||||||
|
AuthorizationRequest,
|
||||||
|
PrincipalType,
|
||||||
|
)
|
||||||
|
|
||||||
|
ISSUER = "https://key-cape.example/realms/coulomb"
|
||||||
|
NOW = datetime(2026, 10, 10, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class _Response(io.BytesIO):
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(subject="subject-1"):
|
||||||
|
return Actor(
|
||||||
|
issuer=ISSUER, subject=subject, tenant="tenant-a",
|
||||||
|
principal_type=PrincipalType.HUMAN, audience=("user-engine",),
|
||||||
|
roles=("member",), tenant_roles=("CUS",), groups=("tenant:a:users",),
|
||||||
|
scopes=("openid",), assurance={"level": "aal2", "mfa": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bound(app_id, system_id, client_id):
|
||||||
|
return (
|
||||||
|
Application(application_id=app_id, display_name=app_id.title(), owner="owner"),
|
||||||
|
ApplicationBinding(application_id=app_id, oidc_client_id=client_id,
|
||||||
|
protected_system_id=system_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _system(system_id, client_id, issuer=ISSUER):
|
||||||
|
return ReadbackSystem(system_id=system_id, name=system_id, readback=True,
|
||||||
|
client_issuer=issuer, client_id=client_id)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadCatalogueTests(unittest.TestCase):
|
||||||
|
def test_lists_only_workloads_bound_in_all_three_records(self):
|
||||||
|
applications = [
|
||||||
|
_bound("informed", "informed-decision", "informed-client"),
|
||||||
|
_bound("unbound", None, "unbound-client"),
|
||||||
|
_bound("wrongclient", "wrong-client", "client-a"),
|
||||||
|
_bound("wrongissuer", "other-issuer", "client-b"),
|
||||||
|
_bound("nobinding", "no-client-binding", "client-c"),
|
||||||
|
_bound("unknown", "not-in-registry", "client-d"),
|
||||||
|
]
|
||||||
|
systems = [
|
||||||
|
_system("informed-decision", "informed-client"),
|
||||||
|
_system("wrong-client", "client-z"),
|
||||||
|
_system("other-issuer", "client-b", issuer="https://elsewhere"),
|
||||||
|
ReadbackSystem("no-client-binding", "x", readback=True),
|
||||||
|
]
|
||||||
|
workloads = workload_catalogue(applications, systems, issuer=ISSUER + "/")
|
||||||
|
self.assertEqual([item.system_id for item in workloads], ["informed-decision"])
|
||||||
|
|
||||||
|
|
||||||
|
class RenderTests(unittest.TestCase):
|
||||||
|
workloads = tuple(Workload(f"app-{n}", f"App {n}", f"sys-{n}") for n in range(5))
|
||||||
|
|
||||||
|
def test_no_row_is_inferred_as_allow(self):
|
||||||
|
rows = render_rows(self.workloads, [
|
||||||
|
ReadbackResult("sys-0", "allow", NOW + timedelta(seconds=30)),
|
||||||
|
ReadbackResult("sys-1", "allow", NOW - timedelta(seconds=1)),
|
||||||
|
ReadbackResult("sys-2", "deny"),
|
||||||
|
ReadbackResult("sys-3", "not_applicable"),
|
||||||
|
], now=NOW)
|
||||||
|
self.assertEqual(
|
||||||
|
[row.status for row in rows],
|
||||||
|
["available", "unchecked", "denied", "not_offered", "unchecked"],
|
||||||
|
)
|
||||||
|
self.assertEqual([row.available for row in rows], [True, False, False, False, False])
|
||||||
|
|
||||||
|
def test_allow_without_expiry_is_not_available(self):
|
||||||
|
rows = render_rows(self.workloads[:1], [ReadbackResult("sys-0", "allow")], now=NOW)
|
||||||
|
self.assertEqual(rows[0].status, "unchecked")
|
||||||
|
|
||||||
|
def test_failed_read_renders_every_row_unchecked(self):
|
||||||
|
rows = render_rows(self.workloads, None, now=NOW)
|
||||||
|
self.assertEqual({row.status for row in rows}, {"unchecked"})
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeReadback:
|
||||||
|
def __init__(self, results, systems=None, fail_read=False, fail_systems=False):
|
||||||
|
self.results = results
|
||||||
|
self._systems = systems or [_system("informed-decision", "informed-client")]
|
||||||
|
self.fail_read = fail_read
|
||||||
|
self.fail_systems = fail_systems
|
||||||
|
self.reads = []
|
||||||
|
|
||||||
|
def systems(self):
|
||||||
|
if self.fail_systems:
|
||||||
|
raise ReadbackUnavailable("down")
|
||||||
|
return tuple(self._systems)
|
||||||
|
|
||||||
|
def read(self, actor, systems):
|
||||||
|
self.reads.append((actor.subject, tuple(systems)))
|
||||||
|
if self.fail_read:
|
||||||
|
raise ReadbackUnavailable("down")
|
||||||
|
return tuple(self.results)
|
||||||
|
|
||||||
|
|
||||||
|
class ReaderTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now = NOW
|
||||||
|
self.applications = [_bound("informed", "informed-decision", "informed-client")]
|
||||||
|
|
||||||
|
def _reader(self, readback):
|
||||||
|
return ApplicationAccessReader(
|
||||||
|
readback, issuer=ISSUER, bound_applications=lambda: self.applications,
|
||||||
|
clock=lambda: self.now,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reuses_rows_until_allow_expiry_and_never_past_it(self):
|
||||||
|
readback = _FakeReadback([
|
||||||
|
ReadbackResult("informed-decision", "allow", NOW + timedelta(seconds=30), "decision:1"),
|
||||||
|
])
|
||||||
|
reader = self._reader(readback)
|
||||||
|
self.assertEqual(reader.rows(_actor(), "s1")[0].status, "available")
|
||||||
|
self.now = NOW + timedelta(seconds=20)
|
||||||
|
self.assertEqual(reader.rows(_actor(), "s1")[0].status, "available")
|
||||||
|
self.assertEqual(len(readback.reads), 1)
|
||||||
|
self.now = NOW + timedelta(seconds=30)
|
||||||
|
reader.rows(_actor(), "s1")
|
||||||
|
self.assertEqual(len(readback.reads), 2)
|
||||||
|
|
||||||
|
def test_cache_is_per_subject_and_dropped_on_forget(self):
|
||||||
|
readback = _FakeReadback([ReadbackResult("informed-decision", "deny")])
|
||||||
|
reader = self._reader(readback)
|
||||||
|
reader.rows(_actor("alice"), "s1")
|
||||||
|
reader.rows(_actor("bob"), "s1")
|
||||||
|
self.assertEqual([item[0] for item in readback.reads], ["alice", "bob"])
|
||||||
|
reader.forget("s1")
|
||||||
|
reader.rows(_actor("alice"), "s1")
|
||||||
|
self.assertEqual(len(readback.reads), 3)
|
||||||
|
|
||||||
|
def test_read_failure_is_unchecked_and_not_reused(self):
|
||||||
|
readback = _FakeReadback([], fail_read=True)
|
||||||
|
reader = self._reader(readback)
|
||||||
|
self.assertEqual(reader.rows(_actor(), "s1")[0].status, "unchecked")
|
||||||
|
reader.rows(_actor(), "s1")
|
||||||
|
self.assertEqual(len(readback.reads), 2)
|
||||||
|
|
||||||
|
def test_unreadable_registry_returns_none(self):
|
||||||
|
reader = self._reader(_FakeReadback([], fail_systems=True))
|
||||||
|
self.assertIsNone(reader.rows(_actor(), "s1"))
|
||||||
|
|
||||||
|
def test_no_bound_workload_makes_no_decision_request(self):
|
||||||
|
self.applications = []
|
||||||
|
readback = _FakeReadback([])
|
||||||
|
self.assertEqual(self._reader(readback).rows(_actor(), "s1"), ())
|
||||||
|
self.assertEqual(readback.reads, [])
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPAdapterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
handle, self.token_file = tempfile.mkstemp()
|
||||||
|
os.write(handle, b"readback-token\n")
|
||||||
|
os.close(handle)
|
||||||
|
self.addCleanup(os.unlink, self.token_file)
|
||||||
|
self.adapter = ApplicationAccessReadbackHTTPAdapter(
|
||||||
|
base_url="http://readback/", bearer_token_file=self.token_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _patch(self, **kwargs):
|
||||||
|
return patch("user_engine.adapters.application_access.urlopen", **kwargs)
|
||||||
|
|
||||||
|
def test_read_sends_check_subject_shape_and_maps_results(self):
|
||||||
|
body = _Response(json.dumps({
|
||||||
|
"registry_snapshot_digest": "sha256:" + "0" * 64,
|
||||||
|
"results": [
|
||||||
|
{"system": "informed-decision", "effect": "allow", "decision_id": "decision:1",
|
||||||
|
"lifetime": {"kind": "ttl", "expires_at": "2026-10-10T12:00:30Z"}},
|
||||||
|
{"system": "markitect", "effect": "not_applicable", "reason": "no rule"},
|
||||||
|
],
|
||||||
|
}).encode())
|
||||||
|
with self._patch(return_value=body) as call:
|
||||||
|
results = self.adapter.read(_actor(), ["informed-decision", "markitect"])
|
||||||
|
request = call.call_args.args[0]
|
||||||
|
self.assertEqual(request.full_url, "http://readback/v1/readback/application_access")
|
||||||
|
self.assertEqual(request.get_header("Authorization"), "Bearer readback-token")
|
||||||
|
payload = json.loads(request.data)
|
||||||
|
self.assertEqual(set(payload), {"tenant", "subject", "systems"})
|
||||||
|
self.assertEqual(payload["subject"]["tenant"], "tenant-a")
|
||||||
|
check = _check_payload(_actor())
|
||||||
|
self.assertEqual(payload["subject"], check["subject"])
|
||||||
|
self.assertEqual(results[0].effect, "allow")
|
||||||
|
self.assertEqual(results[0].expires_at, NOW + timedelta(seconds=30))
|
||||||
|
self.assertEqual(results[1].effect, "not_applicable")
|
||||||
|
|
||||||
|
def test_mismatched_or_unknown_results_are_unavailable(self):
|
||||||
|
bodies = [
|
||||||
|
{"registry_snapshot_digest": "x", "results": [{"system": "other", "effect": "allow"}]},
|
||||||
|
{"registry_snapshot_digest": "x", "results": [{"system": "a", "effect": "permit"}]},
|
||||||
|
{"registry_snapshot_digest": "x", "results": []},
|
||||||
|
]
|
||||||
|
for body in bodies:
|
||||||
|
with self.subTest(body=body), self._patch(return_value=_Response(json.dumps(body).encode())):
|
||||||
|
with self.assertRaises(ReadbackUnavailable):
|
||||||
|
self.adapter.read(_actor(), ["a"])
|
||||||
|
|
||||||
|
def test_transport_failures_are_unavailable(self):
|
||||||
|
for error in (URLError("down"), HTTPError("http://readback", 503, "x", {}, None), TimeoutError()):
|
||||||
|
with self.subTest(error=error), self._patch(side_effect=error):
|
||||||
|
with self.assertRaises(ReadbackUnavailable):
|
||||||
|
self.adapter.systems()
|
||||||
|
|
||||||
|
def test_empty_token_never_calls(self):
|
||||||
|
with open(self.token_file, "w", encoding="utf-8"):
|
||||||
|
pass
|
||||||
|
with self._patch() as call, self.assertRaises(ReadbackUnavailable):
|
||||||
|
self.adapter.systems()
|
||||||
|
call.assert_not_called()
|
||||||
|
|
||||||
|
def test_systems_maps_client_binding(self):
|
||||||
|
body = _Response(json.dumps({
|
||||||
|
"registry_snapshot_digest": "sha256:" + "0" * 64,
|
||||||
|
"systems": [
|
||||||
|
{"id": "informed-decision", "name": "Informed Decision", "readback": True,
|
||||||
|
"client_binding": {"issuer": ISSUER, "client_id": "informed-client"}},
|
||||||
|
{"id": "markitect", "readback": False},
|
||||||
|
],
|
||||||
|
}).encode())
|
||||||
|
with self._patch(return_value=body):
|
||||||
|
systems = self.adapter.systems()
|
||||||
|
self.assertEqual(systems[0].client_id, "informed-client")
|
||||||
|
self.assertIsNone(systems[1].client_id)
|
||||||
|
|
||||||
|
def test_request_size_is_bounded(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.adapter.read(_actor(), [f"s{n}" for n in range(65)])
|
||||||
|
|
||||||
|
|
||||||
|
def _check_payload(actor):
|
||||||
|
body = _Response(json.dumps({"id": "decision:1", "effect": "deny"}).encode())
|
||||||
|
with patch("user_engine.adapters.flex_auth.urlopen", return_value=body) as call:
|
||||||
|
FlexAuthHTTPAdapter(base_url="http://flex-auth").check(AuthorizationRequest(
|
||||||
|
actor=actor, resource_type="user-engine:user", resource_id="u",
|
||||||
|
action="user.read", tenant=actor.tenant, correlation_id="c",
|
||||||
|
))
|
||||||
|
return json.loads(call.call_args.args[0].data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class PortalApplicationAccessTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
import test_portal_navigation
|
||||||
|
|
||||||
|
test_portal_navigation.PortalNavigationTests.setUp(self)
|
||||||
|
store = self.app.service.store
|
||||||
|
for app_id, system_id, client_id in [
|
||||||
|
("informed", "informed-decision", "informed-client"),
|
||||||
|
("markitect", "markitect", "markitect-client"),
|
||||||
|
]:
|
||||||
|
application, binding = _bound(app_id, system_id, client_id)
|
||||||
|
store.save_application(application)
|
||||||
|
store.save_binding(binding)
|
||||||
|
self.readback = _FakeReadback(
|
||||||
|
[
|
||||||
|
ReadbackResult("informed-decision", "allow",
|
||||||
|
datetime.now(UTC) + timedelta(seconds=30), "decision:1"),
|
||||||
|
ReadbackResult("markitect", "not_applicable"),
|
||||||
|
],
|
||||||
|
systems=[_system("informed-decision", "informed-client"),
|
||||||
|
_system("markitect", "markitect-client")],
|
||||||
|
)
|
||||||
|
self.app.application_access = ApplicationAccessReader(
|
||||||
|
self.readback, issuer=ISSUER,
|
||||||
|
bound_applications=lambda: [
|
||||||
|
(store.application(binding.application_id), binding)
|
||||||
|
for binding in store.all_bindings()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _onboarding(self):
|
||||||
|
from test_web import invoke
|
||||||
|
|
||||||
|
return invoke(self.app, "/onboarding", cookie="ue_session=member")[1]
|
||||||
|
|
||||||
|
def test_account_page_renders_readback_rows(self):
|
||||||
|
body = self._onboarding()
|
||||||
|
self.assertIn(b'<li data-access="available">Informed - Available</li>', body)
|
||||||
|
self.assertIn(b'<li data-access="not_offered">Markitect - Not available: not offered here</li>', body)
|
||||||
|
self.assertIn(b"is not a complete inventory", body)
|
||||||
|
|
||||||
|
def test_unreadable_registry_says_could_not_be_checked(self):
|
||||||
|
self.readback.fail_systems = True
|
||||||
|
self.assertIn(b"Application access could not be checked.", self._onboarding())
|
||||||
|
|
||||||
|
def test_logout_drops_the_cached_rows(self):
|
||||||
|
from test_web import invoke
|
||||||
|
|
||||||
|
self._onboarding()
|
||||||
|
self._onboarding()
|
||||||
|
self.assertEqual(len(self.readback.reads), 1)
|
||||||
|
invoke(self.app, "/logout", method="POST", cookie="ue_session=member", form={})
|
||||||
|
self._onboarding()
|
||||||
|
self.assertEqual(len(self.readback.reads), 2)
|
||||||
|
|
||||||
|
def test_section_absent_without_readback(self):
|
||||||
|
self.app.application_access = None
|
||||||
|
self.assertNotIn(b"application-access-list", self._onboarding())
|
||||||
|
|
@ -101,6 +101,18 @@ deny, not_applicable, failure and expired as not available, never as allow.
|
||||||
Use a separate `USER_ENGINE_READBACK_TOKEN_FILE`. Wait stays on access-engine
|
Use a separate `USER_ENGINE_READBACK_TOKEN_FILE`. Wait stays on access-engine
|
||||||
T02/T03 code and its T04 founder go.
|
T02/T03 code and its T04 founder go.
|
||||||
|
|
||||||
|
2026-10-10: access-engine folded amendments (a)-(c) into the contract,
|
||||||
|
accepted FLEX-DEC-2026-019 and shipped T02/T03 (8f2ba75). T05 (b5da745) gives
|
||||||
|
informed-decision a separate `application.access` rule. user-engine's client is
|
||||||
|
now in the repo, off by default: `application_access.py`,
|
||||||
|
`adapters/application_access.py`, the account-page section, and
|
||||||
|
`USER_ENGINE_READBACK_URL` / `USER_ENGINE_READBACK_TOKEN_FILE` (see
|
||||||
|
docs/configuration.md). A workload is listed only when all three records are
|
||||||
|
bound. Rows follow the agreed rendering table, and none is inferred as allow.
|
||||||
|
Remaining before this task is done: access-engine T04 (readback pin values,
|
||||||
|
digest-equality test, founder go), operator-installed readback URL and token
|
||||||
|
file, and a live check against the pin.
|
||||||
|
|
||||||
### Live recovery rollout — 2026-09-12
|
### Live recovery rollout — 2026-09-12
|
||||||
|
|
||||||
Recovery is deployed in KeyCape 4d8b8fe, User Engine e54b6ee and Vergabe c067993
|
Recovery is deployed in KeyCape 4d8b8fe, User Engine e54b6ee and Vergabe c067993
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue