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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- 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)
|