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,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
|
+
)
|