Add opt-in tenant-to-client bindings for invitation mail
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 35s
Account journey acceptance / journeys (push) Failing after 7s

User-engine side of EMAIL-WP-0008: USER_ENGINE_MAIL_CLIENT_BINDINGS maps
tenant globs to a single client_id carried on invitation mail envelopes
only. Unmatched or conflicting tenants fail as mail_binding_denied before
either lane is contacted, and the outbox now records that reason instead
of collapsing it to delivery_unavailable. Unset keeps legacy envelopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 445231@bnt-lap001
Assistant-Session: 024a807e-0cc5-4f2c-bc8a-b83322d1395d
This commit is contained in:
tegwick 2026-10-09 11:21:17 +02:00
parent a9360842f8
commit adf6e992a6
9 changed files with 199 additions and 2 deletions

View file

@ -71,6 +71,17 @@ Enabling it also requires the verification adapter and allow-lists:
The service fails closed when those settings are absent. Do not add a
placeholder or reused credential to turn the flag on.
`USER_ENGINE_MAIL_CLIENT_BINDINGS` is optional and unset by default. When
unset, invitation mail envelopes keep the legacy shape (no `client_id`). When
set, it is a JSON list of `[tenant_glob, client_id]` pairs (1–128 rows, unique
globs, case-sensitive whole-string `*`/`?` matching), for example
`[["tenant:*-family", "coulomb-social"]]`. Each invitation mail carries the
single matching `client_id`, so email-connect can admit it as a scoped caller
(EMAIL-WP-0008). A tenant with no match, or with matches naming different
clients, is denied as `mail_binding_denied` before either delivery lane is
contacted; the platform event never carries `client_id`. An invalid value stops
startup — it never falls back to the legacy shape.
## Authenticator management handoff

View file

@ -19,6 +19,7 @@ class NoRedirect(HTTPRedirectHandler):
from user_engine.domain import OutboxEvent
from user_engine.mail_bindings import load_mail_client_bindings, mail_client_for
_MAIL_EVENTS = {"family_member.invited", "family_invitation.resent"}
@ -34,12 +35,14 @@ class HTTPOutboxDeliveryAdapter:
mail_url: str | None = None,
mail_bearer_token: str | None = None,
timeout_seconds: float = 5.0,
mail_client_bindings: str | None = None,
) -> None:
self.event_url = event_url
self.mail_url = mail_url
self.event_bearer_token = event_bearer_token
self.mail_bearer_token = mail_bearer_token
self.timeout_seconds = timeout_seconds
self.mail_client_bindings = load_mail_client_bindings(mail_client_bindings)
def __call__(self, event: OutboxEvent) -> None:
envelope = {
@ -57,7 +60,15 @@ class HTTPOutboxDeliveryAdapter:
if self.mail_url and event.event_type in _MAIL_EVENTS:
if not self.mail_bearer_token:
raise RuntimeError("mail delivery token is required")
self._post(self.mail_url, envelope, self.mail_bearer_token)
mail_envelope = dict(envelope)
if self.mail_client_bindings is not None:
try:
mail_envelope["client_id"] = mail_client_for(
event.tenant, self.mail_client_bindings
)
except ValueError:
raise DeliveryError("mail_binding_denied") from None
self._post(self.mail_url, mail_envelope, self.mail_bearer_token)
event_envelope = dict(envelope)
event_data = dict(envelope["data"])
if "primary_email" in event_data:

View file

@ -0,0 +1,40 @@
"""Explicit tenant globs for the scoped invitation mail transport."""
from __future__ import annotations
import json
import re
from fnmatch import fnmatchcase
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z")
PATTERN = re.compile(r"[A-Za-z0-9*?][A-Za-z0-9._:*?-]{0,127}\Z")
def load_mail_client_bindings(raw):
if raw is None:
return None
try:
rows = json.loads(raw)
if not isinstance(rows, list) or not 1 <= len(rows) <= 128:
raise ValueError
result = []
for row in rows:
if (not isinstance(row, list) or len(row) != 2
or not isinstance(row[0], str) or not PATTERN.fullmatch(row[0])
or not isinstance(row[1], str) or not IDENTIFIER.fullmatch(row[1])):
raise ValueError
result.append(tuple(row))
if len({row[0] for row in result}) != len(result):
raise ValueError
return tuple(result)
except (ValueError, TypeError):
raise ValueError("mail_client_bindings_invalid") from None
def mail_client_for(tenant, bindings):
if not isinstance(tenant, str) or not IDENTIFIER.fullmatch(tenant):
raise ValueError("mail_binding_denied")
matches = {client for pattern, client in bindings if fnmatchcase(tenant, pattern)}
if len(matches) != 1:
raise ValueError("mail_binding_denied")
return matches.pop()

View file

@ -65,6 +65,7 @@ def create_application() -> PortalApplication:
else None
),
timeout_seconds=float(os.environ.get("USER_ENGINE_DELIVERY_TIMEOUT", "5")),
mail_client_bindings=os.environ.get("USER_ENGINE_MAIL_CLIENT_BINDINGS"),
)
app = PortalApplication(
service,

View file

@ -2964,7 +2964,7 @@ class UserEngineService:
deliver(claimed)
except Exception as exc:
code = getattr(exc, "code", "delivery_unavailable")
if code not in {"mail_unconfigured", "delivery_outcome_unknown", "temporary_deferral", "permanent_rejection", "recipient_suppressed", "provider_timeout", "provider_unavailable"}:
if code not in {"mail_unconfigured", "mail_binding_denied", "delivery_outcome_unknown", "temporary_deferral", "permanent_rejection", "recipient_suppressed", "provider_timeout", "provider_unavailable"}:
code = "delivery_unavailable"
outcome = replace(claimed, claimed_by=None, claimed_at=None,
failed_at=utc_now(), failure_reason=code,

View file

@ -1299,6 +1299,7 @@ class PortalApplication:
if event.delivered_at: return "Accepted by delivery adapter; receipt by the person is unverified"
if event.failure_reason == "delivery_outcome_unknown": return "Mail outcome unknown — check provider evidence before sending another message"
if event.failure_reason == "mail_unconfigured": return "Mail is not configured — use assisted setup or restore the mail lane"
if event.failure_reason == "mail_binding_denied": return "Mail is not admitted for this tenant — fix the mail client binding or use assisted setup"
if event.dead_lettered_at: return "Delivery stopped after repeated failures"
if event.failed_at: return "Delivery failed; retry pending"
if event.claimed_by: return "Being processed"

View file

@ -0,0 +1,73 @@
"""Scoped mail migration uses synthetic identities and a mocked HTTP boundary."""
import json
import unittest
from unittest.mock import patch
from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter, DeliveryError
from user_engine.domain import OutboxEvent
from user_engine.mail_bindings import load_mail_client_bindings, mail_client_for
class MailBindingsTests(unittest.TestCase):
def test_whole_case_sensitive_globs_and_literal_clients(self):
bindings = load_mail_client_bindings('[ ["tenant:*-family", "coulomb-social"] ]')
self.assertEqual(mail_client_for("tenant:synthetic-family", bindings), "coulomb-social")
for tenant in ("tenant:platform", "tenant:Synthetic-Family", "tenant:x-family/path"):
with self.assertRaisesRegex(ValueError, "mail_binding_denied"):
mail_client_for(tenant, bindings)
def test_invalid_configuration_never_falls_back(self):
for raw in ('', '[]', '{}', '[["*", "*"]]', '[["[a]", "client"]]',
'[["*", "one"], ["*", "two"]]', '[[true, "client"]]'):
with self.assertRaisesRegex(ValueError, "mail_client_bindings_invalid"):
load_mail_client_bindings(raw)
self.assertIsNone(load_mail_client_bindings(None))
def test_conflicting_overlaps_are_denied(self):
bindings = load_mail_client_bindings('[["tenant:*", "one"], ["tenant:x", "two"]]')
with self.assertRaisesRegex(ValueError, "mail_binding_denied"):
mail_client_for("tenant:x", bindings)
def adapter(self, bindings=None):
return HTTPOutboxDeliveryAdapter(event_url="http://events.example.test",
event_bearer_token="synthetic-event-token", mail_url="http://mail.example.test",
mail_bearer_token="synthetic-mail-token", mail_client_bindings=bindings)
def event(self, kind="family_member.invited", tenant="tenant:synthetic-family"):
return OutboxEvent(event_id="evt-synthetic", event_type=kind, aggregate_id="inv-synthetic",
payload={"primary_email": "member@example.test", "invitation_id": "inv-synthetic"},
tenant=tenant, correlation_id="trace-synthetic")
def test_binding_only_changes_mail_transport_not_event_or_outbox(self):
event = self.event()
adapter = self.adapter('[["tenant:*-family", "coulomb-social"]]')
with patch.object(adapter, "_post") as post:
adapter(event)
mail, audit = [call.args[1] for call in post.call_args_list]
self.assertEqual(mail["client_id"], "coulomb-social")
self.assertNotIn("client_id", audit)
self.assertNotIn("primary_email", audit["data"])
self.assertNotIn("client_id", event.payload)
self.assertEqual(mail["id"], event.event_id)
def test_legacy_mode_and_non_mail_events_preserve_envelopes(self):
adapter = self.adapter()
with patch.object(adapter, "_post") as post:
adapter(self.event())
self.assertNotIn("client_id", post.call_args_list[0].args[1])
adapter = self.adapter('[["tenant:*-family", "coulomb-social"]]')
with patch.object(adapter, "_post") as post:
adapter(self.event("membership.added", "tenant:platform"))
self.assertEqual(post.call_count, 1)
def test_unadmitted_mail_tenant_contacts_neither_lane(self):
adapter = self.adapter('[["tenant:*-family", "coulomb-social"]]')
with patch.object(adapter, "_post") as post:
with self.assertRaisesRegex(DeliveryError, "mail_binding_denied"):
adapter(self.event(tenant="tenant:platform"))
post.assert_not_called()
if __name__ == "__main__":
unittest.main()

View file

@ -106,6 +106,23 @@ class ServiceOperations(JourneyFixture):
self.app.service.store.outbox_event(event.event_id).delivered_at
)
def test_mail_binding_denial_is_recorded_not_collapsed(self):
event = self.seed()
def deny(event):
raise DeliveryError("mail_binding_denied")
self.app.outbox_delivery = deny
self.post(
"/platform/operations/deliver",
who="operator",
event_id=event.event_id,
confirmed=True,
)
failed = self.app.service.store.outbox_event(event.event_id)
self.assertEqual("mail_binding_denied", failed.failure_reason)
self.assertIsNone(failed.delivered_at)
def test_crashed_claim_reconciles_under_event_guard(self):
event = self.seed()
self.app.service.store.save_outbox(

View file

@ -0,0 +1,43 @@
---
id: USER-WP-ADHOC-2026-10-09
type: workplan
title: "Scoped invitation mail client bindings"
domain: communication
repo: user-engine
status: finished
owner: claude
created: "2026-10-09"
updated: "2026-10-09"
---
## Send an admitted tenant/client pair on invitation mail
```task
id: USER-WP-ADHOC-2026-10-09-T01
status: done
priority: medium
```
User-engine side of email-connect EMAIL-WP-0008 (caller policy draft
2026-10-08): current invitation envelopes omit `client_id`, so a scoped
user-engine caller cannot be admitted. Opt-in
`USER_ENGINE_MAIL_CLIENT_BINDINGS` maps tenant globs to one literal client id.
Files:
- `src/user_engine/mail_bindings.py` — strict loader and single-match resolver
- `src/user_engine/adapters/delivery.py` — adds `client_id` to the mail
envelope only; denial contacts neither lane
- `src/user_engine/runtime.py` — reads the env var
- `src/user_engine/service.py`, `src/user_engine/web.py` — keep
`mail_binding_denied` as the recorded failure reason with an operator status
line instead of collapsing it to `delivery_unavailable`
- `docs/configuration.md`, `tests/test_mail_bindings.py`,
`tests/test_service_operations.py`
Acceptance: unset keeps legacy envelopes; invalid config stops startup;
unmatched or conflicting tenants are denied; platform events and outbox payloads
never carry `client_id`; `make test` and `make check-layer` pass.
Not done here: activation. Deploy config, email-connect caller admission
(EMAIL-WP-0008-T03) and token custody stay with their owners and the founder.