messagefoundry 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,289 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Data-retention + store-maintenance worker (PHI.md §8, ASVS 14.2.x).
4
+
5
+ Without enforcement, PHI accumulates in the message store indefinitely — including dead-lettered
6
+ raw bodies. :class:`RetentionRunner` is the single background task that enforces the ``[retention]``
7
+ service settings: past the configured windows it **nulls message/dead-letter bodies while keeping
8
+ their metadata rows** (the Mirth Data-Pruner pattern — counts, disposition, and the audit trail stay
9
+ intact; nothing is deleted), checkpoints the WAL, and ``VACUUM``s on a daily off-peak schedule. Each
10
+ pass that does real work writes **one** ``audit_log`` entry recording the cutoffs + counts (never any
11
+ message content). When the store outgrows ``max_db_mb`` it raises an advisory ``storage_threshold``
12
+ alert.
13
+
14
+ It is owned by the :class:`~messagefoundry.pipeline.engine.Engine` (started in ``start``, cancelled in
15
+ ``stop``) rather than the per-graph runner, so it is independent of config reloads and runs once per
16
+ process. The clock is injected so the windows and the daily VACUUM time are deterministically testable
17
+ (``run_once`` performs a full pass for a given ``now``); the loop only governs cadence.
18
+
19
+ Engine-side and dependency-light (stdlib + the store/alert seams only), so it never pulls the API or
20
+ console into the engine.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import json
27
+ import logging
28
+ import time
29
+ from collections.abc import Callable
30
+ from dataclasses import dataclass
31
+
32
+ from messagefoundry.config.settings import RetentionSettings
33
+ from messagefoundry.pipeline.alerts import AlertSink, LoggingAlertSink
34
+ from messagefoundry.pipeline.cluster import ClusterCoordinator, NullCoordinator
35
+ from messagefoundry.store import Store
36
+
37
+ __all__ = ["RetentionRunner", "RetentionPass"]
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ _SECONDS_PER_DAY = 86_400
42
+ _BYTES_PER_MB = 1_000_000
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class RetentionPass:
47
+ """What one :meth:`RetentionRunner.run_once` pass did — returned for the audit entry + tests."""
48
+
49
+ messages_purged: int
50
+ dead_purged: int
51
+ state_purged: int
52
+ wal_checkpointed: bool
53
+ vacuumed: bool
54
+ size_bytes: int
55
+ over_limit: bool
56
+
57
+ @property
58
+ def did_work(self) -> bool:
59
+ """Whether the pass changed anything worth an audit row (a routine WAL checkpoint alone
60
+ isn't — it leaves no data trace and would otherwise spam the audit log every pass)."""
61
+ return (
62
+ self.messages_purged > 0
63
+ or self.dead_purged > 0
64
+ or self.state_purged > 0
65
+ or self.vacuumed
66
+ or self.over_limit
67
+ )
68
+
69
+
70
+ class RetentionRunner:
71
+ """Enforces ``[retention]`` on the message store: body-purge + WAL checkpoint + VACUUM + a size
72
+ alert, audited per pass. Construct with the store + settings; call :meth:`start`/:meth:`stop` for
73
+ the supervised loop, or :meth:`run_once` to perform a single deterministic pass (tests)."""
74
+
75
+ def __init__(
76
+ self,
77
+ store: Store,
78
+ settings: RetentionSettings,
79
+ *,
80
+ alert_sink: AlertSink | None = None,
81
+ clock: Callable[[], float] = time.time,
82
+ coordinator: ClusterCoordinator | None = None,
83
+ ) -> None:
84
+ self._store = store
85
+ self._settings = settings
86
+ # Default to the logging sink so an over-limit store is at least visible without a notifier.
87
+ self._alert_sink: AlertSink = alert_sink or LoggingAlertSink()
88
+ self._clock = clock
89
+ # Retention is a leader-only WRITE singleton (it purges PHI bodies + writes audit rows), so in
90
+ # a cluster it must run on exactly one node. Default NullCoordinator → always leader → always
91
+ # runs, so an existing caller/test that passes no coordinator is byte-identical (Track B Step 4).
92
+ self._coordinator: ClusterCoordinator = coordinator or NullCoordinator()
93
+ self._stop = asyncio.Event()
94
+ self._task: asyncio.Task[None] | None = None
95
+ # Maintenance cadence state (loop-driven; only read/written on the single task).
96
+ self._last_wal = 0.0
97
+ self._last_vacuum_day: str | None = None
98
+
99
+ @property
100
+ def enabled(self) -> bool:
101
+ """True when any window/threshold/maintenance knob is configured. When False, :meth:`start`
102
+ spawns no task — retention is entirely off by default."""
103
+ s = self._settings
104
+ return bool(
105
+ s.messages_days
106
+ or s.dead_letter_days
107
+ or s.state_max_age_days
108
+ or s.max_db_mb
109
+ or s.wal_checkpoint_seconds
110
+ or s.vacuum_time() is not None
111
+ )
112
+
113
+ # --- lifecycle -----------------------------------------------------------
114
+
115
+ def start(self) -> None:
116
+ """Spawn the supervised purge/maintenance loop (no-op when nothing is configured)."""
117
+ if self._task is not None:
118
+ return
119
+ if not self.enabled:
120
+ log.debug("retention disabled (no [retention] windows configured); not starting")
121
+ return
122
+ self._stop.clear()
123
+ self._task = asyncio.create_task(self._run())
124
+ log.info(
125
+ "retention enabled: messages_days=%d dead_letter_days=%d max_db_mb=%d "
126
+ "wal_checkpoint_seconds=%g vacuum_at=%r (every %gs)",
127
+ self._settings.messages_days,
128
+ self._settings.dead_letter_days,
129
+ self._settings.max_db_mb,
130
+ self._settings.wal_checkpoint_seconds,
131
+ self._settings.vacuum_at,
132
+ self._settings.purge_interval_seconds,
133
+ )
134
+
135
+ async def stop(self) -> None:
136
+ """Signal the loop and await its exit (idempotent)."""
137
+ self._stop.set()
138
+ task = self._task
139
+ self._task = None
140
+ if task is not None:
141
+ task.cancel()
142
+ try:
143
+ await task
144
+ except asyncio.CancelledError:
145
+ pass
146
+
147
+ async def _run(self) -> None:
148
+ # One isolated pass per interval; an error in a pass is logged and the loop continues (a
149
+ # retention hiccup must never take the engine down). Cooperatively cancellable via _stop.
150
+ while not self._stop.is_set():
151
+ try:
152
+ await self.run_once()
153
+ except asyncio.CancelledError:
154
+ raise
155
+ except Exception:
156
+ log.exception("retention pass failed; will retry next interval")
157
+ await self._sleep(self._settings.purge_interval_seconds)
158
+
159
+ async def _sleep(self, delay: float) -> None:
160
+ """Sleep up to ``delay``, waking immediately on stop (so shutdown isn't held by the interval)."""
161
+ try:
162
+ await asyncio.wait_for(self._stop.wait(), delay)
163
+ except asyncio.TimeoutError:
164
+ pass
165
+
166
+ # --- one pass ------------------------------------------------------------
167
+
168
+ async def run_once(self, now: float | None = None) -> RetentionPass:
169
+ """Run a full retention pass for ``now`` (default: the injected clock): purge bodies past the
170
+ configured windows, checkpoint the WAL / VACUUM if due, check the size threshold, and write a
171
+ single ``audit_log`` entry when the pass did real work. Returns a :class:`RetentionPass`.
172
+
173
+ Leader-gated (Track B Step 4): a non-leader node returns a did-nothing pass without touching the
174
+ store, so in a cluster exactly one node purges. The loop keeps ticking on followers
175
+ (reactive-by-polling), so when a follower becomes leader the very next pass acts. A follower
176
+ never advances its WAL/VACUUM cadence state (the gate returns before those timers update), so a
177
+ newly-promoted leader runs any due WAL checkpoint / daily VACUUM on its first acting pass — which
178
+ is the correct behavior (the new leader picks up the maintenance the cluster owes). Single-node
179
+ (the NullCoordinator default) is always leader, so this is byte-identical there."""
180
+ if not self._coordinator.is_leader():
181
+ return RetentionPass(
182
+ messages_purged=0,
183
+ dead_purged=0,
184
+ state_purged=0,
185
+ wal_checkpointed=False,
186
+ vacuumed=False,
187
+ size_bytes=0,
188
+ over_limit=False,
189
+ )
190
+ now = self._clock() if now is None else now
191
+ s = self._settings
192
+
193
+ messages_purged = 0
194
+ if s.messages_days > 0:
195
+ messages_purged = await self._store.purge_message_bodies(
196
+ older_than=now - s.messages_days * _SECONDS_PER_DAY, now=now
197
+ )
198
+ dead_purged = 0
199
+ if s.dead_letter_days > 0:
200
+ dead_purged = await self._store.purge_dead_letters(
201
+ older_than=now - s.dead_letter_days * _SECONDS_PER_DAY, now=now
202
+ )
203
+ state_purged = 0
204
+ if s.state_max_age_days > 0:
205
+ state_purged = await self._store.purge_state(
206
+ older_than=now - s.state_max_age_days * _SECONDS_PER_DAY, now=now
207
+ )
208
+
209
+ wal_checkpointed = False
210
+ if s.wal_checkpoint_seconds > 0 and now - self._last_wal >= s.wal_checkpoint_seconds:
211
+ await self._store.wal_checkpoint()
212
+ self._last_wal = now
213
+ wal_checkpointed = True
214
+
215
+ vacuumed = False
216
+ if self._vacuum_due(now):
217
+ await self._store.vacuum()
218
+ self._last_vacuum_day = self._day_key(now)
219
+ vacuumed = True
220
+
221
+ size_bytes, over_limit = await self._check_size()
222
+
223
+ result = RetentionPass(
224
+ messages_purged=messages_purged,
225
+ dead_purged=dead_purged,
226
+ state_purged=state_purged,
227
+ wal_checkpointed=wal_checkpointed,
228
+ vacuumed=vacuumed,
229
+ size_bytes=size_bytes,
230
+ over_limit=over_limit,
231
+ )
232
+ if result.did_work:
233
+ await self._audit(result)
234
+ return result
235
+
236
+ async def _check_size(self) -> tuple[int, bool]:
237
+ """Return ``(db_size_bytes, over_limit)``, emitting the advisory alert when over. Skips the
238
+ size query entirely when ``max_db_mb`` is off."""
239
+ if self._settings.max_db_mb <= 0:
240
+ return 0, False
241
+ size_bytes = (await self._store.db_status()).size_bytes
242
+ limit_bytes = self._settings.max_db_mb * _BYTES_PER_MB
243
+ over = size_bytes > limit_bytes
244
+ if over:
245
+ # The sink never raises (contract), but be defensive — an alert failure must not abort
246
+ # the purge pass that produced it.
247
+ try:
248
+ self._alert_sink.storage_threshold(
249
+ self._store.path, size_bytes=size_bytes, limit_bytes=limit_bytes
250
+ )
251
+ except Exception:
252
+ log.warning("storage_threshold alert sink failed", exc_info=True)
253
+ return size_bytes, over
254
+
255
+ async def _audit(self, result: RetentionPass) -> None:
256
+ """Append one audit row recording the cutoffs + counts (no message content — no PHI)."""
257
+ detail = json.dumps(
258
+ {
259
+ "messages_days": self._settings.messages_days,
260
+ "messages_purged": result.messages_purged,
261
+ "dead_letter_days": self._settings.dead_letter_days,
262
+ "dead_purged": result.dead_purged,
263
+ "state_max_age_days": self._settings.state_max_age_days,
264
+ "state_purged": result.state_purged,
265
+ "vacuumed": result.vacuumed,
266
+ "db_size_bytes": result.size_bytes,
267
+ "max_db_mb": self._settings.max_db_mb,
268
+ "over_limit": result.over_limit,
269
+ },
270
+ sort_keys=True,
271
+ )
272
+ await self._store.record_audit("retention_purge", actor="system", detail=detail)
273
+
274
+ # --- daily VACUUM schedule ----------------------------------------------
275
+
276
+ def _vacuum_due(self, now: float) -> bool:
277
+ """True when a daily VACUUM time is configured, the local clock has reached it, and we haven't
278
+ already vacuumed today. (At-most-once per local day; a late start that day still catches up.)"""
279
+ target = self._settings.vacuum_time()
280
+ if target is None:
281
+ return False
282
+ lt = time.localtime(now)
283
+ reached = (lt.tm_hour, lt.tm_min) >= target
284
+ return reached and self._last_vacuum_day != self._day_key(now)
285
+
286
+ @staticmethod
287
+ def _day_key(now: float) -> str:
288
+ lt = time.localtime(now)
289
+ return f"{lt.tm_year:04d}-{lt.tm_mon:02d}-{lt.tm_mday:02d}"
@@ -0,0 +1,168 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Per-user security-event email notifier (ASVS 6.3.5 / 6.3.7).
4
+
5
+ The concrete :class:`~messagefoundry.auth.notifications.SecurityNotifier` the API lifespan injects into
6
+ :class:`~messagefoundry.auth.service.AuthService`. It turns a
7
+ :class:`~messagefoundry.auth.notifications.SecurityEvent` into a short plain-text email to the
8
+ **affected user's own address** — distinct from the operator alert distribution list — over the
9
+ ``[alerts]`` SMTP transport. Dispatch runs on a bounded background queue so a notification never blocks
10
+ the login / admin path, and every send is best-effort (a failure is logged, never raised).
11
+
12
+ Lives in ``pipeline/`` (next to the operator alert plumbing it reuses) and imports the contract from
13
+ ``auth/`` — one-way ``pipeline → auth``, never the reverse (CLAUDE.md §4).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+
21
+ from messagefoundry.auth.notifications import (
22
+ ACCOUNT_DISABLED,
23
+ ACCOUNT_LOCKED,
24
+ ADMIN_NEW_IP,
25
+ EMAIL_CHANGED,
26
+ LOGIN_AFTER_FAILURES,
27
+ MFA_DISABLED,
28
+ MFA_ENABLED,
29
+ PASSWORD_CHANGED,
30
+ PASSWORD_RESET,
31
+ ROLES_CHANGED,
32
+ SecurityEvent,
33
+ )
34
+ from messagefoundry.config.settings import AlertsSettings
35
+ from messagefoundry.pipeline.alert_sinks import _BackgroundDispatcher, send_plain_email
36
+
37
+ log = logging.getLogger(__name__)
38
+
39
+ _SUBJECTS = {
40
+ ACCOUNT_LOCKED: "Your MessageFoundry account was locked",
41
+ LOGIN_AFTER_FAILURES: "New MessageFoundry sign-in after failed attempts",
42
+ PASSWORD_CHANGED: "Your MessageFoundry password was changed",
43
+ PASSWORD_RESET: "Your MessageFoundry password was reset",
44
+ EMAIL_CHANGED: "Your MessageFoundry account email was changed",
45
+ ROLES_CHANGED: "Your MessageFoundry account roles were changed",
46
+ ACCOUNT_DISABLED: "Your MessageFoundry account was disabled",
47
+ MFA_ENABLED: "Two-factor authentication was enabled on your MessageFoundry account",
48
+ MFA_DISABLED: "Two-factor authentication was disabled on your MessageFoundry account",
49
+ ADMIN_NEW_IP: "A sensitive action on your MessageFoundry account from a new location",
50
+ }
51
+
52
+ _DESCRIPTIONS = {
53
+ ACCOUNT_LOCKED: "Your account was locked after repeated failed sign-in attempts.",
54
+ LOGIN_AFTER_FAILURES: "A sign-in to your account succeeded after several failed attempts.",
55
+ PASSWORD_CHANGED: "Your account password was changed.",
56
+ PASSWORD_RESET: "Your account password was reset by an administrator.",
57
+ EMAIL_CHANGED: "Your account's email address was changed.",
58
+ ROLES_CHANGED: "Your account's roles were changed by an administrator.",
59
+ ACCOUNT_DISABLED: "Your account was disabled by an administrator.",
60
+ MFA_ENABLED: "A two-factor authenticator (TOTP) was enrolled on your account.",
61
+ MFA_DISABLED: "Two-factor authentication was removed from your account.",
62
+ ADMIN_NEW_IP: (
63
+ "A sensitive administrative action on your account was attempted from a client address that "
64
+ "differs from your session's last verified address. It was required to re-verify before "
65
+ "proceeding."
66
+ ),
67
+ }
68
+
69
+
70
+ def _build_body(event: SecurityEvent) -> str:
71
+ """A short, PHI-free notice. The recipient is the account owner, so naming their own account /
72
+ source IP / new email is appropriate; no message data or secrets ever appear here."""
73
+ lines = [
74
+ f"A security-relevant change occurred on your MessageFoundry account ({event.username}).",
75
+ "",
76
+ _DESCRIPTIONS.get(event.event_type, "A security event occurred on your account."),
77
+ ]
78
+ failed = event.detail.get("failed_attempts")
79
+ if event.event_type in (ACCOUNT_LOCKED, LOGIN_AFTER_FAILURES) and failed:
80
+ lines.append(f"Failed attempts: {failed}")
81
+ if event.event_type == EMAIL_CHANGED and event.detail.get("new_email"):
82
+ lines.append(f"New email on file: {event.detail['new_email']}")
83
+ if event.client_ip:
84
+ lines.append(f"Source IP: {event.client_ip}")
85
+ lines += [
86
+ "",
87
+ "If this was you, no action is needed. If not, contact your MessageFoundry administrator.",
88
+ ]
89
+ return "\n".join(lines)
90
+
91
+
92
+ class SecurityEventNotifier(_BackgroundDispatcher[SecurityEvent]):
93
+ """Emails the affected user about a security event, on a bounded background queue."""
94
+
95
+ def __init__(
96
+ self,
97
+ *,
98
+ host: str,
99
+ port: int,
100
+ sender: str,
101
+ use_tls: bool = True,
102
+ username: str | None = None,
103
+ password: str | None = None,
104
+ timeout: float = 30.0,
105
+ allowed_hosts: tuple[str, ...] = (),
106
+ ) -> None:
107
+ super().__init__()
108
+ self._host = host
109
+ self._port = port
110
+ self._sender = sender
111
+ self._use_tls = use_tls
112
+ self._username = username
113
+ self._password = password
114
+ self._timeout = timeout
115
+ self._allowed_hosts = allowed_hosts
116
+
117
+ async def notify(self, event: SecurityEvent) -> None:
118
+ # No deliverable address (common for local accounts / unset email) → nothing to email; the
119
+ # audited /me/security-events feed still records it. Non-blocking enqueue.
120
+ if not event.email:
121
+ return
122
+ self._enqueue(event, dropped=f"{event.event_type} for {event.username}")
123
+
124
+ async def _handle(self, event: SecurityEvent) -> None:
125
+ try:
126
+ await asyncio.to_thread(self._send, event)
127
+ except Exception:
128
+ # Best-effort: a failed send must never propagate. The event is also in the audit log.
129
+ log.warning(
130
+ "security-event email failed for %s (%s)",
131
+ event.username,
132
+ event.event_type,
133
+ exc_info=True,
134
+ )
135
+
136
+ def _send(self, event: SecurityEvent) -> None:
137
+ if not event.email: # narrowed for mypy; notify() already filtered these out
138
+ return
139
+ send_plain_email(
140
+ host=self._host,
141
+ port=self._port,
142
+ sender=self._sender,
143
+ recipients=[event.email],
144
+ subject=_SUBJECTS.get(event.event_type, "MessageFoundry security alert"),
145
+ body=_build_body(event),
146
+ use_tls=self._use_tls,
147
+ username=self._username,
148
+ password=self._password,
149
+ timeout=self._timeout,
150
+ allowed_hosts=self._allowed_hosts,
151
+ )
152
+
153
+
154
+ def security_notifier_from_settings(alerts: AlertsSettings) -> SecurityEventNotifier | None:
155
+ """Build the per-user security notifier from ``[alerts]`` SMTP settings, or ``None`` when no SMTP
156
+ server/sender is configured (then only the ``/me/security-events`` feed records events)."""
157
+ if not (alerts.email_smtp_host and alerts.email_from):
158
+ return None
159
+ return SecurityEventNotifier(
160
+ host=alerts.email_smtp_host,
161
+ port=alerts.email_smtp_port,
162
+ sender=alerts.email_from,
163
+ use_tls=alerts.email_use_tls,
164
+ username=alerts.email_username,
165
+ password=alerts.email_password,
166
+ timeout=alerts.email_timeout,
167
+ allowed_hosts=tuple(alerts.smtp_allowed_hosts),
168
+ )
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Cluster-wide transform-state read-through convergence (Track B Step 6b).
4
+
5
+ Transform state (ADR 0005) is written inside ``PostgresStore.transform_handoff`` and published into the
6
+ **writing node's** own ``_state_cache`` only. A sibling node never sees it, so ``state_get(ns, key)`` on a
7
+ follower returns stale/missing data for keys another node wrote. ``state_get`` is on the hot path of
8
+ **every** transform and must stay a pure synchronous dict lookup (no ``await``, no per-read DB), so
9
+ convergence is a background, off-path refresh of the local cache — exactly mirroring the reference-cache
10
+ convergence Step 6 ships (:class:`~messagefoundry.pipeline.reference_sync.ReferenceSyncRunner`).
11
+
12
+ :class:`StateConvergenceRunner` is the engine-owned background loop on every clustered node that, each
13
+ interval, calls a converge callable (``store.converge_state_cache``) which read-throughs any namespace
14
+ whose shared per-namespace version is newer than the one this node reflects, and logs the refreshed
15
+ namespace **names** (never keys/values — those may be PHI). The engine only spawns it in clustered mode
16
+ (``coordinator.is_clustered()``), so single-node / SQLite never pays for it.
17
+
18
+ It mirrors :class:`~messagefoundry.pipeline.config_convergence.ConfigConvergenceRunner`'s shape
19
+ (``start``/``stop``/``_run``/``_sleep``/``converge_once``, supervised + cooperatively cancellable) and
20
+ copies the **PHI/error discipline** of
21
+ :class:`~messagefoundry.pipeline.reference_sync.ReferenceSyncRunner`: on a convergence/decrypt failure log
22
+ the exception **class only** (``type(exc).__name__``), NEVER ``str(exc)`` and NEVER ``log.exception`` (a
23
+ decrypt failure can carry snapshot bytes / a PHI-bearing key), keep the last-good cache, retry next
24
+ interval, and alert via the AlertSink.
25
+
26
+ Decoupled from the store: it takes a plain converge callback (like
27
+ :class:`~messagefoundry.pipeline.config_convergence.ConfigConvergenceRunner` takes ``reload``) so the
28
+ dependency direction stays one-way (pipeline only — it never pulls the API or console into the engine).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import logging
35
+ from collections.abc import Awaitable, Callable
36
+
37
+ from messagefoundry.pipeline.alerts import AlertSink, LoggingAlertSink
38
+
39
+ __all__ = ["StateConvergenceRunner"]
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+
44
+ class StateConvergenceRunner:
45
+ """Polls the shared transform-state versions each tick and read-throughs newer namespaces locally.
46
+
47
+ Construct with ``converge`` (an awaitable that read-throughs any newer shared state into this node's
48
+ local cache and returns the refreshed namespace names, e.g. ``store.converge_state_cache``) and the
49
+ poll ``interval_seconds``. The runner never imports the store — it takes ``converge`` as a plain
50
+ callback so the dependency direction stays one-way (pipeline only).
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ converge: Callable[[], Awaitable[list[str]]],
57
+ interval_seconds: float,
58
+ alert_sink: AlertSink | None = None,
59
+ ) -> None:
60
+ self._converge = converge
61
+ self._interval_seconds = interval_seconds
62
+ self._alert_sink: AlertSink = alert_sink or LoggingAlertSink()
63
+ self._stop = asyncio.Event()
64
+ self._task: asyncio.Task[None] | None = None
65
+
66
+ # --- lifecycle -----------------------------------------------------------
67
+
68
+ def start(self) -> None:
69
+ """Spawn the supervised convergence loop (idempotent: a second call while running is a no-op)."""
70
+ if self._task is not None:
71
+ return
72
+ self._stop.clear()
73
+ self._task = asyncio.create_task(self._run())
74
+ log.info(
75
+ "cluster transform-state convergence enabled: read-through every %gs",
76
+ self._interval_seconds,
77
+ )
78
+
79
+ async def stop(self) -> None:
80
+ """Signal the loop and await its exit (idempotent)."""
81
+ self._stop.set()
82
+ task = self._task
83
+ self._task = None
84
+ if task is not None:
85
+ task.cancel()
86
+ try:
87
+ await task
88
+ except asyncio.CancelledError:
89
+ pass
90
+
91
+ async def _run(self) -> None:
92
+ # One isolated pass per interval; converge_once already swallows + alerts a pass error (NOT
93
+ # log.exception — a decrypt failure can carry PHI), so the loop just keeps going. Cooperatively
94
+ # cancellable via _stop.
95
+ while not self._stop.is_set():
96
+ await self.converge_once()
97
+ await self._sleep(self._interval_seconds)
98
+
99
+ async def _sleep(self, delay: float) -> None:
100
+ """Sleep up to ``delay``, waking immediately on stop (so shutdown isn't held by the interval)."""
101
+ try:
102
+ await asyncio.wait_for(self._stop.wait(), delay)
103
+ except asyncio.TimeoutError:
104
+ pass
105
+
106
+ # --- one pass ------------------------------------------------------------
107
+
108
+ async def converge_once(self) -> list[str]:
109
+ """Read-through any newer shared transform-state into this node's local cache. Returns the
110
+ refreshed namespace names (``[]`` when nothing advanced).
111
+
112
+ Errors are isolated: on any ``Exception`` (but ``CancelledError`` re-raises so shutdown isn't
113
+ swallowed) log the error CLASS only — never ``str(exc)`` and never ``log.exception`` (a decrypt
114
+ failure can carry snapshot bytes / a PHI-bearing key, CLAUDE.md §9) — alert, keep the last-good
115
+ cache, and return ``[]`` so the loop retries next interval rather than dying."""
116
+ try:
117
+ refreshed = await self._converge()
118
+ except asyncio.CancelledError:
119
+ raise
120
+ except Exception as exc:
121
+ kind = type(exc).__name__
122
+ log.warning(
123
+ "cluster: transform-state cache convergence failed (keeping current): %s", kind
124
+ )
125
+ self._alert(f"state cache convergence failed ({kind})")
126
+ return []
127
+ if refreshed:
128
+ # Names only (a namespace name is operator config, not PHI) at INFO so an operator can see a
129
+ # follower converge; never the keys/values (which may be PHI).
130
+ log.info(
131
+ "cluster: transform-state cache converged %d namespace(s): %s",
132
+ len(refreshed),
133
+ ", ".join(refreshed),
134
+ )
135
+ return refreshed
136
+
137
+ def _alert(self, detail: str) -> None:
138
+ # The AlertSink has no state-specific event; reuse connection_stopped as the generic "a named
139
+ # component degraded" signal (never raises — be defensive anyway).
140
+ try:
141
+ self._alert_sink.connection_stopped("transform-state", detail=detail)
142
+ except Exception:
143
+ log.warning("transform-state convergence alert sink failed", exc_info=True)