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,99 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Synchronous **read side** for transform-accessible state (cross-message correlation, ADR 0005).
|
|
4
|
+
|
|
5
|
+
A Handler **declares** writes by returning :class:`~messagefoundry.config.wiring.SetState` (applied
|
|
6
|
+
exactly-once inside the routed→outbound handoff transaction); it **reads** them back synchronously via
|
|
7
|
+
:func:`state_get`. Handlers are pure synchronous functions and a store read is async, so a read can't
|
|
8
|
+
await the DB: instead the engine keeps an in-memory **read-through cache** of the state table (loaded
|
|
9
|
+
at startup, updated as writes commit), and publishes a read-only **view** of it as the active state
|
|
10
|
+
for the duration of each router/transform run — exactly how :mod:`messagefoundry.config.code_sets`
|
|
11
|
+
publishes the active code sets. :func:`state_get` resolves against that view synchronously.
|
|
12
|
+
|
|
13
|
+
**Layering (information hiding).** This config-layer module owns only the active-view *holder* + the
|
|
14
|
+
accessor + the publish helpers (:func:`activated`/:func:`set_active`/:func:`reset`). It does **not**
|
|
15
|
+
import the store: the store owns the cache and exposes it as a ``StateView`` (the read-only mapping
|
|
16
|
+
``{(namespace, key): decrypted_value}``); the runner *bridges* the two by publishing
|
|
17
|
+
``store.state_view()`` here around each run. So the config layer stays free of any store dependency,
|
|
18
|
+
matching the one-way dependency direction (CLAUDE.md §4).
|
|
19
|
+
|
|
20
|
+
**Consistency caveat (ADR 0005).** The view is the live cache, so a read reflects every committed
|
|
21
|
+
write as of the call. Reads are *not* linearized with concurrent sibling-handler writes — fine for
|
|
22
|
+
read-mostly correlation (patient-id mapping); a race-sensitive read-modify-write within one namespace
|
|
23
|
+
needs author care. See docs/CONFIGURATION.md.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Iterator, Mapping
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from contextvars import ContextVar
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from messagefoundry.config.wiring import StateValue
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"StateView",
|
|
37
|
+
"state_get",
|
|
38
|
+
"set_active",
|
|
39
|
+
"reset",
|
|
40
|
+
"activated",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
#: The engine-published read view: a read-only mapping ``{(namespace, key): decrypted_value}``. The
|
|
44
|
+
#: store builds it (decrypting at load), so the config layer needs no store import — it only reads it.
|
|
45
|
+
StateView = Mapping[tuple[str, str], StateValue]
|
|
46
|
+
|
|
47
|
+
# Active view as a ContextVar (mirrors code_sets._active): the runner re-publishes the live cache view
|
|
48
|
+
# around each router/transform run so a call-time state_get(...) inside a Handler resolves, a clean
|
|
49
|
+
# reset restores the prior view (no leak across runs / overlapping reloads), and dry-run can publish a
|
|
50
|
+
# view too. Defaults to None = "no active view" so state_get distinguishes "not running" from "empty".
|
|
51
|
+
_active: ContextVar[StateView | None] = ContextVar("mefor_active_state", default=None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set_active(view: StateView | None) -> Any:
|
|
55
|
+
"""Publish ``view`` as the active state view and return a reset token (pass it to :func:`reset`).
|
|
56
|
+
|
|
57
|
+
For callers that can't bracket the active span with a ``with`` (e.g. an async worker publishing
|
|
58
|
+
around a single transform call). Prefer :func:`activated` where a ``with`` block fits."""
|
|
59
|
+
return _active.set(view)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def reset(token: Any) -> None:
|
|
63
|
+
"""Restore the active state view to what it was before the matching :func:`set_active`."""
|
|
64
|
+
_active.reset(token)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def activated(view: StateView | None) -> Iterator[None]:
|
|
69
|
+
"""Make ``view`` the active state view for the duration of the ``with`` block, then restore.
|
|
70
|
+
|
|
71
|
+
The runner brackets each router/transform run with it (and dry-run mirrors that), so
|
|
72
|
+
:func:`state_get` resolves at call time and the prior view is always restored — no leak."""
|
|
73
|
+
token = _active.set(view)
|
|
74
|
+
try:
|
|
75
|
+
yield
|
|
76
|
+
finally:
|
|
77
|
+
_active.reset(token)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def state_get(namespace: str, key: str, default: StateValue = None) -> StateValue:
|
|
81
|
+
"""Read ``namespace``/``key`` from the active state view synchronously; ``default`` on a miss.
|
|
82
|
+
|
|
83
|
+
Call it inside a Handler at run time::
|
|
84
|
+
|
|
85
|
+
anon = state_get("patient_anon", mrn)
|
|
86
|
+
if anon is None:
|
|
87
|
+
anon = derive_anon_id(mrn)
|
|
88
|
+
return [Send("OB_DOWNSTREAM", msg), SetState("patient_anon", mrn, anon)]
|
|
89
|
+
|
|
90
|
+
Resolves against the engine-maintained read-through cache view the runner publishes around the run
|
|
91
|
+
(it reflects every committed write as of this call — see the module note on non-linearization).
|
|
92
|
+
Unlike :func:`messagefoundry.config.wiring.code_set`, a missing **key** is **not** an error (it
|
|
93
|
+
returns ``default``): state is sparse correlation data, not a referenced config table. Calling it
|
|
94
|
+
with **no active view** (outside a run/dry-run) returns ``default`` as well — there is no state to
|
|
95
|
+
read there, and a transform must stay usable in isolation."""
|
|
96
|
+
view = _active.get()
|
|
97
|
+
if view is None:
|
|
98
|
+
return default
|
|
99
|
+
return view.get((namespace, key), default)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Shared TLS hardening policy (ASVS 11.6.2 key exchange + 12.1.4 strict X.509, WP-L3-10 code half).
|
|
4
|
+
|
|
5
|
+
Pure stdlib ``ssl`` helpers, importable by ``api/`` and ``transports/`` (and the ``config`` settings
|
|
6
|
+
validator) without crossing the engine's one-way dependency boundaries. Three controls:
|
|
7
|
+
|
|
8
|
+
* :func:`validate_tls_ciphers` — reject an operator ``tls_ciphers`` string that would admit a
|
|
9
|
+
non-forward-secret (non-ECDHE/DHE) key exchange, so a misconfiguration cannot widen the suite below
|
|
10
|
+
policy. Run from the ``[api].tls_ciphers`` settings validator, so a bad value fails loud at load.
|
|
11
|
+
* :func:`harden_kex_groups` — pin the approved ECDHE groups on a built context where the runtime
|
|
12
|
+
supports it (``SSLContext.set_groups``, Python 3.13+); on older interpreters OpenSSL already leads
|
|
13
|
+
with these groups, so it is a best-effort no-op rather than a downgrade.
|
|
14
|
+
* :func:`harden_verify_flags` — OR ``ssl.VERIFY_X509_STRICT`` into a verifying context's
|
|
15
|
+
``verify_flags`` so a presented chain must be RFC 5280-conformant (ASVS 12.1.4 strict path
|
|
16
|
+
validation). Revocation itself is delegated to the org PKI / OCSP-must-staple proxy + OS trust store
|
|
17
|
+
(ADR 0002); the engine attempts no stdlib OCSP. Guarded like ``harden_kex_groups`` for old runtimes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import ssl
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"APPROVED_KEX_GROUPS",
|
|
30
|
+
"harden_kex_groups",
|
|
31
|
+
"harden_verify_flags",
|
|
32
|
+
"validate_tls_ciphers",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
#: Approved forward-secret key-exchange groups in preference order (X25519 first). These are the modern
|
|
36
|
+
#: NIST/FIPS-permitted ECDHE curves; the string is the OpenSSL group list passed to ``set_groups``.
|
|
37
|
+
APPROVED_KEX_GROUPS = "X25519:secp384r1:secp256r1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def harden_kex_groups(ctx: ssl.SSLContext) -> None:
|
|
41
|
+
"""Best-effort pin ``ctx`` to :data:`APPROVED_KEX_GROUPS`.
|
|
42
|
+
|
|
43
|
+
Uses ``SSLContext.set_groups`` where available (Python 3.13+). On older interpreters there is no
|
|
44
|
+
public API to pin groups and OpenSSL's defaults already lead with X25519/P-256/P-384, so this is a
|
|
45
|
+
deliberate no-op rather than a weakening. A runtime that rejects the group list (an unusual OpenSSL
|
|
46
|
+
build) is logged and left at its secure defaults."""
|
|
47
|
+
set_groups = getattr(ctx, "set_groups", None)
|
|
48
|
+
if set_groups is None:
|
|
49
|
+
return
|
|
50
|
+
try:
|
|
51
|
+
set_groups(APPROVED_KEX_GROUPS)
|
|
52
|
+
except (
|
|
53
|
+
ssl.SSLError,
|
|
54
|
+
ValueError,
|
|
55
|
+
) as exc: # pragma: no cover - depends on the linked OpenSSL build
|
|
56
|
+
logger.warning("Could not pin TLS key-exchange groups %r: %s", APPROVED_KEX_GROUPS, exc)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def harden_verify_flags(ctx: ssl.SSLContext) -> None:
|
|
60
|
+
"""Best-effort enable strict X.509 path validation on a *verifying* ``ctx`` (ASVS 12.1.4).
|
|
61
|
+
|
|
62
|
+
ORs ``ssl.VERIFY_X509_STRICT`` into ``ctx.verify_flags`` so a presented certificate chain must be
|
|
63
|
+
RFC 5280-conformant — no malformed/ambiguous fields from which revocation metadata (AIA / CRL-DP)
|
|
64
|
+
would otherwise be read. This is **strict validation, not revocation checking**: live revocation is
|
|
65
|
+
delegated to the deploying org's PKI — OCSP-must-staple at the WP-15 proxy plus the OS trust store —
|
|
66
|
+
because stdlib ``ssl`` exposes no OCSP/CRL fetch and the engine deliberately attempts none (ADR 0002).
|
|
67
|
+
|
|
68
|
+
Guarded like :func:`harden_kex_groups`: a runtime/OpenSSL build without ``VERIFY_X509_STRICT`` is a
|
|
69
|
+
deliberate no-op, not an error. Call it **only** on a context that actually verifies the peer (skip
|
|
70
|
+
the MLLP ``tls_verify=false`` / ``CERT_NONE`` path, where there is nothing to validate)."""
|
|
71
|
+
strict = getattr(ssl, "VERIFY_X509_STRICT", None)
|
|
72
|
+
if strict is None: # pragma: no cover - depends on the linked OpenSSL build
|
|
73
|
+
return
|
|
74
|
+
ctx.verify_flags |= strict
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_tls_ciphers(value: str) -> str:
|
|
78
|
+
"""Validate an operator OpenSSL cipher string, rejecting non-forward-secret key exchange.
|
|
79
|
+
|
|
80
|
+
Returns ``value`` unchanged when it parses and every resolved TLS 1.2 suite uses (EC)DHE (TLS 1.3
|
|
81
|
+
suites are inherently ECDHE + AEAD). Raises ``ValueError`` — surfaced as a config-load error — for
|
|
82
|
+
an unparseable string or one that would admit a static-RSA/DH key exchange, closing the 11.6.2 gap
|
|
83
|
+
that a misconfigured ``tls_ciphers`` could widen the key exchange below policy."""
|
|
84
|
+
probe = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
85
|
+
try:
|
|
86
|
+
probe.set_ciphers(value)
|
|
87
|
+
except ssl.SSLError as exc:
|
|
88
|
+
raise ValueError(f"tls_ciphers is not a valid OpenSSL cipher string: {exc}") from exc
|
|
89
|
+
non_fs = sorted(
|
|
90
|
+
{str(c.get("name", "?")) for c in probe.get_ciphers() if not _is_forward_secret(c)}
|
|
91
|
+
)
|
|
92
|
+
if non_fs:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"tls_ciphers must resolve to forward-secret (EC)DHE suites only (ASVS 11.6.2); "
|
|
95
|
+
f"these admit a non-forward-secret key exchange: {', '.join(non_fs)}"
|
|
96
|
+
)
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_forward_secret(cipher: Mapping[str, object]) -> bool:
|
|
101
|
+
"""Whether a ``SSLContext.get_ciphers()`` entry uses an (EC)DHE — forward-secret — key exchange."""
|
|
102
|
+
name = str(cipher.get("name", ""))
|
|
103
|
+
# TLS 1.3 suite names (TLS_AES_*, TLS_CHACHA20_*) are always ECDHE and cannot be configured down.
|
|
104
|
+
if name.startswith("TLS_") or cipher.get("protocol") == "TLSv1.3":
|
|
105
|
+
return True
|
|
106
|
+
if name.startswith(("ECDHE", "DHE")):
|
|
107
|
+
return True
|
|
108
|
+
# Fall back to the human description's Kx token (stable across CPython versions).
|
|
109
|
+
desc = str(cipher.get("description", ""))
|
|
110
|
+
return "Kx=ECDH" in desc or "Kx=DH" in desc
|