Separate event and mail delivery credentials
This commit is contained in:
parent
9480463b24
commit
a0f39f58bd
4 changed files with 45 additions and 9 deletions
|
|
@ -17,13 +17,15 @@ class HTTPOutboxDeliveryAdapter:
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
event_url: str,
|
event_url: str,
|
||||||
bearer_token: str,
|
event_bearer_token: str,
|
||||||
mail_url: str | None = None,
|
mail_url: str | None = None,
|
||||||
|
mail_bearer_token: str | None = None,
|
||||||
timeout_seconds: float = 5.0,
|
timeout_seconds: float = 5.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.event_url = event_url
|
self.event_url = event_url
|
||||||
self.mail_url = mail_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
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
def __call__(self, event: OutboxEvent) -> None:
|
def __call__(self, event: OutboxEvent) -> None:
|
||||||
|
|
@ -38,22 +40,26 @@ class HTTPOutboxDeliveryAdapter:
|
||||||
"data": dict(event.payload),
|
"data": dict(event.payload),
|
||||||
}
|
}
|
||||||
if self.mail_url and event.event_type in _MAIL_EVENTS:
|
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_envelope = dict(envelope)
|
||||||
event_data = dict(envelope["data"])
|
event_data = dict(envelope["data"])
|
||||||
if "primary_email" in event_data:
|
if "primary_email" in event_data:
|
||||||
event_data["recipient_present"] = True
|
event_data["recipient_present"] = True
|
||||||
del event_data["primary_email"]
|
del event_data["primary_email"]
|
||||||
event_envelope["data"] = event_data
|
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(
|
with urlopen(
|
||||||
Request(
|
Request(
|
||||||
url,
|
url,
|
||||||
data=json.dumps(envelope).encode(),
|
data=json.dumps(envelope).encode(),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {self.bearer_token}",
|
"Authorization": f"Bearer {bearer_token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Idempotency-Key": str(envelope["id"]),
|
"Idempotency-Key": str(envelope["id"]),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,13 @@ def create_application() -> PortalApplication:
|
||||||
)
|
)
|
||||||
outbox_delivery = HTTPOutboxDeliveryAdapter(
|
outbox_delivery = HTTPOutboxDeliveryAdapter(
|
||||||
event_url=_required("USER_ENGINE_EVENT_URL"),
|
event_url=_required("USER_ENGINE_EVENT_URL"),
|
||||||
|
event_bearer_token=_required("USER_ENGINE_EVENT_TOKEN"),
|
||||||
mail_url=os.environ.get("USER_ENGINE_MAIL_URL"),
|
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")),
|
timeout_seconds=float(os.environ.get("USER_ENGINE_DELIVERY_TIMEOUT", "5")),
|
||||||
)
|
)
|
||||||
return PortalApplication(
|
return PortalApplication(
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ class PlatformAdapterTests(unittest.TestCase):
|
||||||
|
|
||||||
def test_invitation_delivery_calls_mail_and_event_with_idempotency(self):
|
def test_invitation_delivery_calls_mail_and_event_with_idempotency(self):
|
||||||
adapter = HTTPOutboxDeliveryAdapter(
|
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:
|
with patch("user_engine.adapters.delivery.urlopen", return_value=_Response()) as call:
|
||||||
adapter(_event("family_member.invited"))
|
adapter(_event("family_member.invited"))
|
||||||
|
|
@ -53,6 +54,10 @@ class PlatformAdapterTests(unittest.TestCase):
|
||||||
["http://mail", "http://events"])
|
["http://mail", "http://events"])
|
||||||
for item in call.call_args_list:
|
for item in call.call_args_list:
|
||||||
self.assertEqual(item.args[0].get_header("Idempotency-key"), "evt-1")
|
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)
|
mail_payload = json.loads(call.call_args_list[0].args[0].data)
|
||||||
event_payload = json.loads(call.call_args_list[1].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.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):
|
def test_non_mail_event_only_calls_event_lane(self):
|
||||||
adapter = HTTPOutboxDeliveryAdapter(
|
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:
|
with patch("user_engine.adapters.delivery.urlopen", return_value=_Response()) as call:
|
||||||
adapter(_event("membership.added"))
|
adapter(_event("membership.added"))
|
||||||
self.assertEqual(call.call_count, 1)
|
self.assertEqual(call.call_count, 1)
|
||||||
self.assertEqual(call.call_args.args[0].full_url, "http://events")
|
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():
|
def _request():
|
||||||
actor = Actor(
|
actor = Actor(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
The non-secret flex-auth runtime URL is live and verified, removing that item
|
||||||
from the rollout gate.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue