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,219 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """TLS-certificate expiry monitor (Q5c / ADR 0002).
4
+
5
+ Now that native off-loopback TLS is the supported posture, a **silently expired** API or MLLP
6
+ certificate is a hard PHI-feed outage at renewal time with no in-engine alarm. :class:`CertExpiryRunner`
7
+ is a small background task that periodically reads the certificate PEM files the engine actually serves
8
+ with — the ``[api]`` TLS cert and every connection's ``tls_cert_file`` (MLLP server/client identity) —
9
+ and raises a ``cert_expiry`` alert when one is expired or within ``[cert_monitor].warn_days`` of expiry.
10
+ It reads only the **public certificate** (``notAfter``), never any private key, and never message
11
+ content (no PHI).
12
+
13
+ Engine-owned (started in :meth:`Engine.start`, stopped in :meth:`Engine.stop`) and modelled on the
14
+ :class:`~messagefoundry.pipeline.retention.RetentionRunner`: an injected clock + a pure :meth:`run_once`
15
+ make it deterministically testable; the loop only governs cadence. The set of certs to watch is supplied
16
+ by an injected **callable** so it is recomputed each pass — a config reload that adds or removes a TLS
17
+ connection is picked up automatically — and so tests can drive it with a literal list.
18
+
19
+ Engine-side and dependency-light (stdlib + ``cryptography`` — already a core dep for PHI-at-rest), so it
20
+ never pulls the API or console into the engine.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import logging
27
+ import time
28
+ from collections.abc import Callable, Sequence
29
+ from dataclasses import dataclass
30
+ from typing import TYPE_CHECKING
31
+
32
+ from cryptography import x509
33
+
34
+ from messagefoundry.config.settings import CertMonitorSettings
35
+ from messagefoundry.pipeline.alerts import AlertSink, LoggingAlertSink
36
+
37
+ if TYPE_CHECKING:
38
+ from messagefoundry.config.wiring import Registry
39
+
40
+ __all__ = ["MonitoredCert", "CertCheck", "CertExpiryRunner", "certs_from_registry"]
41
+
42
+ log = logging.getLogger(__name__)
43
+
44
+ _SECONDS_PER_DAY = 86_400
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class MonitoredCert:
49
+ """A certificate file the engine serves with: ``label`` identifies it in the alert (``"api"`` or a
50
+ connection name); ``path`` is the PEM file to read (public cert only — never its key)."""
51
+
52
+ label: str
53
+ path: str
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class CertCheck:
58
+ """The outcome of inspecting one :class:`MonitoredCert` — returned from :meth:`CertExpiryRunner.run_once`
59
+ for the audit/test surface. ``days_remaining`` is negative once the cert is expired."""
60
+
61
+ label: str
62
+ path: str
63
+ not_after_iso: str
64
+ days_remaining: int
65
+
66
+ @property
67
+ def expired(self) -> bool:
68
+ return self.days_remaining < 0
69
+
70
+
71
+ def certs_from_registry(
72
+ registry: Registry | None, api_tls_cert_file: str | None
73
+ ) -> list[MonitoredCert]:
74
+ """Enumerate the certs the engine serves with: the ``[api]`` TLS cert plus every wired connection
75
+ carrying a ``tls_cert_file`` (MLLP inbound server identity / outbound mTLS client cert). A cert path
76
+ supplied as a deferred ``env()`` reference (not yet a literal ``str``) is skipped — it is resolved
77
+ per-environment elsewhere and is not a readable path here."""
78
+ certs: list[MonitoredCert] = []
79
+ if isinstance(api_tls_cert_file, str) and api_tls_cert_file:
80
+ certs.append(MonitoredCert("api", api_tls_cert_file))
81
+ if registry is not None:
82
+ # Separate loops (not a merged tuple) so each connection keeps its concrete type — mypy widens a
83
+ # star-unpacked ``(*inbound, *outbound)`` of two different value views to ``object``.
84
+ for ib in registry.inbound.values():
85
+ ib_path = ib.spec.settings.get("tls_cert_file")
86
+ if isinstance(ib_path, str) and ib_path:
87
+ certs.append(MonitoredCert(ib.name, ib_path))
88
+ for ob in registry.outbound.values():
89
+ ob_path = ob.spec.settings.get("tls_cert_file")
90
+ if isinstance(ob_path, str) and ob_path:
91
+ certs.append(MonitoredCert(ob.name, ob_path))
92
+ return certs
93
+
94
+
95
+ class CertExpiryRunner:
96
+ """Periodically scans the served certs and raises ``cert_expiry`` alerts for any expired or
97
+ within-window. Construct with a ``cert_source`` callable (recomputed each pass) + the
98
+ ``[cert_monitor]`` settings; call :meth:`start`/:meth:`stop` for the supervised loop, or
99
+ :meth:`run_once` for a single deterministic pass (tests)."""
100
+
101
+ def __init__(
102
+ self,
103
+ cert_source: Callable[[], Sequence[MonitoredCert]],
104
+ settings: CertMonitorSettings,
105
+ *,
106
+ alert_sink: AlertSink | None = None,
107
+ clock: Callable[[], float] = time.time,
108
+ ) -> None:
109
+ self._cert_source = cert_source
110
+ self._settings = settings
111
+ # Default to the logging sink so an expiring cert is at least visible without a notifier.
112
+ self._alert_sink: AlertSink = alert_sink or LoggingAlertSink()
113
+ self._clock = clock
114
+ self._stop = asyncio.Event()
115
+ self._task: asyncio.Task[None] | None = None
116
+
117
+ @property
118
+ def enabled(self) -> bool:
119
+ """True when ``warn_days > 0``. When False, :meth:`start` spawns no task (the monitor is off)."""
120
+ return self._settings.warn_days > 0
121
+
122
+ # --- lifecycle -----------------------------------------------------------
123
+
124
+ def start(self) -> None:
125
+ """Spawn the supervised scan loop (no-op when ``warn_days`` is 0)."""
126
+ if self._task is not None:
127
+ return
128
+ if not self.enabled:
129
+ log.debug("cert monitor disabled (cert_monitor.warn_days=0); not starting")
130
+ return
131
+ self._stop.clear()
132
+ self._task = asyncio.create_task(self._run())
133
+ log.info(
134
+ "cert monitor enabled: warn within %d days (every %gs)",
135
+ self._settings.warn_days,
136
+ self._settings.check_interval_seconds,
137
+ )
138
+
139
+ async def stop(self) -> None:
140
+ """Signal the loop and await its exit (idempotent)."""
141
+ self._stop.set()
142
+ task = self._task
143
+ self._task = None
144
+ if task is not None:
145
+ task.cancel()
146
+ try:
147
+ await task
148
+ except asyncio.CancelledError:
149
+ pass
150
+
151
+ async def _run(self) -> None:
152
+ # One isolated scan per interval; an error in a pass is logged and the loop continues (a cert
153
+ # check must never take the engine down). Cooperatively cancellable via _stop.
154
+ while not self._stop.is_set():
155
+ try:
156
+ self.run_once()
157
+ except Exception:
158
+ log.exception("cert expiry scan failed; will retry next interval")
159
+ await self._sleep(self._settings.check_interval_seconds)
160
+
161
+ async def _sleep(self, delay: float) -> None:
162
+ """Sleep up to ``delay``, waking immediately on stop (so shutdown isn't held by the interval)."""
163
+ try:
164
+ await asyncio.wait_for(self._stop.wait(), delay)
165
+ except asyncio.TimeoutError:
166
+ pass
167
+
168
+ # --- one pass ------------------------------------------------------------
169
+
170
+ def run_once(self, now: float | None = None) -> list[CertCheck]:
171
+ """Inspect every served cert for ``now`` (default: the injected clock), emitting a
172
+ ``cert_expiry`` alert for each expired or within-window cert. Synchronous (a few small file
173
+ reads); returns one :class:`CertCheck` per readable cert. Unreadable/missing certs are logged
174
+ and skipped — a typo'd path must not silence the monitor for the others."""
175
+ now = self._clock() if now is None else now
176
+ checks: list[CertCheck] = []
177
+ for cert in self._cert_source():
178
+ check = self._inspect(cert, now)
179
+ if check is None:
180
+ continue
181
+ checks.append(check)
182
+ if check.days_remaining <= self._settings.warn_days:
183
+ # The sink never raises (contract), but be defensive — one bad sink call must not
184
+ # abort the scan of the remaining certs.
185
+ try:
186
+ self._alert_sink.cert_expiry(
187
+ check.label,
188
+ path=check.path,
189
+ not_after=check.not_after_iso,
190
+ days_remaining=check.days_remaining,
191
+ )
192
+ except Exception:
193
+ log.warning("cert_expiry alert sink failed for %r", check.label, exc_info=True)
194
+ return checks
195
+
196
+ def _inspect(self, cert: MonitoredCert, now: float) -> CertCheck | None:
197
+ try:
198
+ with open(cert.path, "rb") as fh:
199
+ pem = fh.read()
200
+ certificate = x509.load_pem_x509_certificate(pem)
201
+ not_after = certificate.not_valid_after_utc # tz-aware UTC (cryptography >= 42)
202
+ except FileNotFoundError:
203
+ log.warning("cert_expiry: certificate for %r not found: %s", cert.label, cert.path)
204
+ return None
205
+ except Exception:
206
+ log.warning(
207
+ "cert_expiry: could not read/parse certificate for %r (%s)",
208
+ cert.label,
209
+ cert.path,
210
+ exc_info=True,
211
+ )
212
+ return None
213
+ days_remaining = int((not_after.timestamp() - now) // _SECONDS_PER_DAY)
214
+ return CertCheck(
215
+ label=cert.label,
216
+ path=cert.path,
217
+ not_after_iso=not_after.isoformat(),
218
+ days_remaining=days_remaining,
219
+ )