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
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:
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue