IAM Profile v0.3 and Tenancy Posture v0.1 are now live on policy.coulomb.social. Close the portal expansion workplan against those documents, keep the flex-auth live A2 probe waiting, and forward optional tenant_roles to flex-auth without authorizing from them locally.
177 lines
8 KiB
Python
177 lines
8 KiB
Python
import io
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import patch
|
|
from urllib.error import URLError
|
|
|
|
from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter
|
|
from user_engine.adapters.flex_auth import FlexAuthHTTPAdapter
|
|
from user_engine.domain import (
|
|
Actor,
|
|
AuthorizationEffect,
|
|
AuthorizationRequest,
|
|
OutboxEvent,
|
|
PrincipalType,
|
|
)
|
|
|
|
|
|
class _Response(io.BytesIO):
|
|
status = 200
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
self.close()
|
|
|
|
|
|
class PlatformAdapterTests(unittest.TestCase):
|
|
def test_flex_auth_maps_allow_and_decision_id(self):
|
|
body = _Response(json.dumps({"id": "decision:1", "effect": "allow"}).encode())
|
|
with patch("user_engine.adapters.flex_auth.urlopen", return_value=body) as call:
|
|
decision = FlexAuthHTTPAdapter(base_url="http://flex-auth").check(_request())
|
|
self.assertEqual(decision.effect, AuthorizationEffect.ALLOW)
|
|
self.assertEqual(decision.decision_id, "decision:1")
|
|
request = json.loads(call.call_args.args[0].data)
|
|
self.assertEqual(request["resource"]["system"], "user-engine")
|
|
self.assertEqual(request["context"]["self"], True)
|
|
self.assertEqual(request["subject"]["attributes"]["tenant_roles"], ["CUS"])
|
|
|
|
def test_flex_auth_fails_closed_when_unavailable(self):
|
|
with patch("user_engine.adapters.flex_auth.urlopen", side_effect=URLError("down")):
|
|
decision = FlexAuthHTTPAdapter(base_url="http://flex-auth").check(_request())
|
|
self.assertEqual(decision.effect, AuthorizationEffect.DENY)
|
|
self.assertEqual(decision.reason, "authorization service unavailable")
|
|
|
|
def test_flex_auth_reads_rotating_caller_token_for_each_decision(self):
|
|
with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") as token_file:
|
|
token_file.write("projected-token-1\n")
|
|
token_file.flush()
|
|
body = _Response(json.dumps({"id": "decision:1", "effect": "allow"}).encode())
|
|
adapter = FlexAuthHTTPAdapter(
|
|
base_url="http://flex-auth", bearer_token_file=token_file.name
|
|
)
|
|
with patch("user_engine.adapters.flex_auth.urlopen", return_value=body) as call:
|
|
adapter.check(_request())
|
|
self.assertEqual(
|
|
call.call_args.args[0].get_header("Authorization"),
|
|
"Bearer projected-token-1",
|
|
)
|
|
|
|
token_file.seek(0)
|
|
token_file.truncate()
|
|
token_file.write("projected-token-2\n")
|
|
token_file.flush()
|
|
body = _Response(json.dumps({"id": "decision:2", "effect": "allow"}).encode())
|
|
with patch("user_engine.adapters.flex_auth.urlopen", return_value=body) as call:
|
|
adapter.check(_request())
|
|
self.assertEqual(
|
|
call.call_args.args[0].get_header("Authorization"),
|
|
"Bearer projected-token-2",
|
|
)
|
|
|
|
def test_flex_auth_fails_closed_on_unusable_caller_token(self):
|
|
"""A caller that cannot prove its identity must never reach the service."""
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
empty = f"{directory}/empty-token"
|
|
with open(empty, "w", encoding="utf-8") as handle:
|
|
handle.write(" \n")
|
|
unusable = {
|
|
"missing": f"{directory}/absent-token",
|
|
"empty": empty,
|
|
"unreadable": directory,
|
|
}
|
|
for label, path in unusable.items():
|
|
with self.subTest(token=label):
|
|
adapter = FlexAuthHTTPAdapter(
|
|
base_url="http://flex-auth", bearer_token_file=path
|
|
)
|
|
with patch("user_engine.adapters.flex_auth.urlopen") as call:
|
|
decision = adapter.check(_request())
|
|
call.assert_not_called()
|
|
self.assertEqual(decision.effect, AuthorizationEffect.DENY)
|
|
self.assertEqual(
|
|
decision.reason, "authorization service unavailable"
|
|
)
|
|
|
|
def test_flex_auth_deny_reason_never_carries_the_caller_token(self):
|
|
with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") as token_file:
|
|
token_file.write("super-secret-projected-token\n")
|
|
token_file.flush()
|
|
adapter = FlexAuthHTTPAdapter(
|
|
base_url="http://flex-auth", bearer_token_file=token_file.name
|
|
)
|
|
with patch(
|
|
"user_engine.adapters.flex_auth.urlopen", side_effect=URLError("down")
|
|
):
|
|
decision = adapter.check(_request())
|
|
self.assertEqual(decision.effect, AuthorizationEffect.DENY)
|
|
self.assertNotIn("super-secret-projected-token", str(decision.reason))
|
|
self.assertNotIn("super-secret-projected-token", repr(decision))
|
|
|
|
def test_invitation_delivery_calls_mail_and_event_with_idempotency(self):
|
|
adapter = HTTPOutboxDeliveryAdapter(
|
|
event_url="http://events", mail_url="http://mail",
|
|
event_bearer_token="event-opaque", mail_bearer_token="mail-opaque",
|
|
)
|
|
with patch("user_engine.adapters.delivery.urlopen", return_value=_Response()) as call:
|
|
adapter(_event("family_member.invited"))
|
|
self.assertEqual([item.args[0].full_url for item in call.call_args_list],
|
|
["http://mail", "http://events"])
|
|
for item in call.call_args_list:
|
|
self.assertEqual(item.args[0].get_header("Idempotency-key"), "evt-1")
|
|
self.assertEqual(call.call_args_list[0].args[0].get_header("Authorization"),
|
|
"Bearer mail-opaque")
|
|
self.assertEqual(call.call_args_list[1].args[0].get_header("Authorization"),
|
|
"Bearer event-opaque")
|
|
mail_payload = json.loads(call.call_args_list[0].args[0].data)
|
|
event_payload = json.loads(call.call_args_list[1].args[0].data)
|
|
self.assertEqual(mail_payload["data"]["primary_email"], "person@example.test")
|
|
self.assertNotIn("primary_email", event_payload["data"])
|
|
self.assertTrue(event_payload["data"]["recipient_present"])
|
|
|
|
def test_non_mail_event_only_calls_event_lane(self):
|
|
adapter = HTTPOutboxDeliveryAdapter(
|
|
event_url="http://events", mail_url="http://mail",
|
|
event_bearer_token="event-opaque", mail_bearer_token="mail-opaque",
|
|
)
|
|
with patch("user_engine.adapters.delivery.urlopen", return_value=_Response()) as call:
|
|
adapter(_event("membership.added"))
|
|
self.assertEqual(call.call_count, 1)
|
|
self.assertEqual(call.call_args.args[0].full_url, "http://events")
|
|
self.assertEqual(call.call_args.args[0].get_header("Authorization"),
|
|
"Bearer event-opaque")
|
|
|
|
def test_mail_event_fails_closed_without_mail_token(self):
|
|
adapter = HTTPOutboxDeliveryAdapter(
|
|
event_url="http://events", mail_url="http://mail",
|
|
event_bearer_token="event-opaque",
|
|
)
|
|
with self.assertRaisesRegex(RuntimeError, "mail delivery token is required"):
|
|
adapter(_event("family_member.invited"))
|
|
|
|
def _request():
|
|
actor = Actor(
|
|
issuer="https://issuer", subject="subject-1", tenant="tenant-a",
|
|
principal_type=PrincipalType.HUMAN, audience=("user-engine",),
|
|
roles=("tenant-admin",), tenant_roles=("CUS",),
|
|
)
|
|
return AuthorizationRequest(
|
|
actor=actor, resource_type="user-engine:user", resource_id="user-1",
|
|
action="user.update", tenant="tenant-a", correlation_id="corr-1",
|
|
target_user_id="user-1", context={"self": True},
|
|
)
|
|
|
|
|
|
def _event(event_type):
|
|
return OutboxEvent(
|
|
event_id="evt-1", event_type=event_type, aggregate_id="inv-1",
|
|
payload={"primary_email": "person@example.test"}, tenant="tenant-a",
|
|
correlation_id="corr-1", occurred_at=datetime(2026, 8, 8, tzinfo=UTC),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|