From a0f39f58bd31682c4ac8b7b8df668caf6b2a1919 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 13 Aug 2026 14:56:24 +0200 Subject: [PATCH] Separate event and mail delivery credentials --- src/user_engine/adapters/delivery.py | 18 +++++++++++------ src/user_engine/runtime.py | 7 ++++++- tests/test_platform_adapters.py | 20 +++++++++++++++++-- .../USER-WP-0021-portal-product-expansion.md | 9 +++++++++ 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/user_engine/adapters/delivery.py b/src/user_engine/adapters/delivery.py index 2968b4b..a532a38 100644 --- a/src/user_engine/adapters/delivery.py +++ b/src/user_engine/adapters/delivery.py @@ -17,13 +17,15 @@ class HTTPOutboxDeliveryAdapter: self, *, event_url: str, - bearer_token: str, + event_bearer_token: str, mail_url: str | None = None, + mail_bearer_token: str | None = None, timeout_seconds: float = 5.0, ) -> None: self.event_url = event_url self.mail_url = mail_url - self.bearer_token = bearer_token + self.event_bearer_token = event_bearer_token + self.mail_bearer_token = mail_bearer_token self.timeout_seconds = timeout_seconds def __call__(self, event: OutboxEvent) -> None: @@ -38,22 +40,26 @@ class HTTPOutboxDeliveryAdapter: "data": dict(event.payload), } if self.mail_url and event.event_type in _MAIL_EVENTS: - self._post(self.mail_url, envelope) + if not self.mail_bearer_token: + raise RuntimeError("mail delivery token is required") + self._post(self.mail_url, envelope, self.mail_bearer_token) event_envelope = dict(envelope) event_data = dict(envelope["data"]) if "primary_email" in event_data: event_data["recipient_present"] = True del event_data["primary_email"] event_envelope["data"] = event_data - self._post(self.event_url, event_envelope) + self._post(self.event_url, event_envelope, self.event_bearer_token) - def _post(self, url: str, envelope: dict[str, object]) -> None: + def _post( + self, url: str, envelope: dict[str, object], bearer_token: str + ) -> None: with urlopen( Request( url, data=json.dumps(envelope).encode(), headers={ - "Authorization": f"Bearer {self.bearer_token}", + "Authorization": f"Bearer {bearer_token}", "Content-Type": "application/json", "Idempotency-Key": str(envelope["id"]), }, diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index 42c7bfc..2069eaf 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -52,8 +52,13 @@ def create_application() -> PortalApplication: ) outbox_delivery = HTTPOutboxDeliveryAdapter( event_url=_required("USER_ENGINE_EVENT_URL"), + event_bearer_token=_required("USER_ENGINE_EVENT_TOKEN"), mail_url=os.environ.get("USER_ENGINE_MAIL_URL"), - bearer_token=_required("USER_ENGINE_DELIVERY_TOKEN"), + mail_bearer_token=( + _required("USER_ENGINE_MAIL_TOKEN") + if os.environ.get("USER_ENGINE_MAIL_URL") + else None + ), timeout_seconds=float(os.environ.get("USER_ENGINE_DELIVERY_TIMEOUT", "5")), ) return PortalApplication( diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 6b6710b..4b5eaed 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -45,7 +45,8 @@ class PlatformAdapterTests(unittest.TestCase): def test_invitation_delivery_calls_mail_and_event_with_idempotency(self): adapter = HTTPOutboxDeliveryAdapter( - event_url="http://events", mail_url="http://mail", bearer_token="opaque" + 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")) @@ -53,6 +54,10 @@ class PlatformAdapterTests(unittest.TestCase): ["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") @@ -61,12 +66,23 @@ class PlatformAdapterTests(unittest.TestCase): def test_non_mail_event_only_calls_event_lane(self): adapter = HTTPOutboxDeliveryAdapter( - event_url="http://events", mail_url="http://mail", bearer_token="opaque" + 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( diff --git a/workplans/USER-WP-0021-portal-product-expansion.md b/workplans/USER-WP-0021-portal-product-expansion.md index cb4a9f2..8e77172 100644 --- a/workplans/USER-WP-0021-portal-product-expansion.md +++ b/workplans/USER-WP-0021-portal-product-expansion.md @@ -212,3 +212,12 @@ metadata update, retirement, and reactivation contract. This task is waiting on that workplan and on the OpenBao-backed event/mail delivery credentials. The non-secret flex-auth runtime URL is live and verified, removing that item from the rollout gate. + +2026-08-13 delivery hardening: audit-core and email-connect are now live as +separately scoped receivers. `HTTPOutboxDeliveryAdapter` therefore accepts +distinct event and mail bearer tokens; runtime configuration uses +`USER_ENGINE_EVENT_TOKEN` and `USER_ENGINE_MAIL_TOKEN`, and mail delivery +fails closed when its dedicated token is absent. Tests assert that the two +Authorization headers differ. The full suite passes 132 tests with three +external-provider skips. Production rollout still waits on governed delivery +of both scoped credentials.