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