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
messagefoundry/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""PHI redaction for the exception/logging path (WP-6c; ASVS 16.2.5, PHI.md P1-3).
|
|
4
|
+
|
|
5
|
+
Inbound HL7 is attacker-/PHI-bearing, and a Router/Handler is user code that can do
|
|
6
|
+
``raise ValueError(f"bad value in {raw}")`` — which would otherwise carry the full message body into
|
|
7
|
+
the stored ``last_error``/``message_events.detail`` and any log line built from it. :func:`safe_exc`
|
|
8
|
+
is the **chokepoint**: every exception rendered into a stored disposition or a log is routed through
|
|
9
|
+
it, so HL7-structured content is scrubbed while the exception **type** (the useful, non-PHI part) is
|
|
10
|
+
kept.
|
|
11
|
+
|
|
12
|
+
This is a conservative *redaction* of HL7-shaped content — **not** de-identification (that is a
|
|
13
|
+
separate, centralized framework; see PHI.md §9). It errs toward over-redaction; the residual control
|
|
14
|
+
for free-text PHI a user script invents (e.g. a bare ``"DOE^JANE"``) remains the "never put PHI in an
|
|
15
|
+
exception message" convention. Pure stdlib (``re`` only), so it can be used from any engine package.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
__all__ = ["redact", "safe_exc", "safe_text"]
|
|
23
|
+
|
|
24
|
+
_REDACTED = "[redacted]"
|
|
25
|
+
#: Max characters of a (redacted) exception message to keep — a raw HL7 body is long, so bound what
|
|
26
|
+
#: reaches a stored ``last_error`` or a log line even after redaction.
|
|
27
|
+
_DEFAULT_LIMIT = 200
|
|
28
|
+
|
|
29
|
+
#: An HL7 **segment** span: a 3-char segment ID (``MSH``/``PID``/``OBX``/…) immediately followed by the
|
|
30
|
+
#: field separator and field data to end-of-line. Catches a raw message (or fragment) embedded in an
|
|
31
|
+
#: exception — the realistic vector. The segment ID is kept (not PHI, useful); the field data is cut.
|
|
32
|
+
_HL7_SEGMENT = re.compile(r"\b([A-Z][A-Z0-9]{2})\|[^\r\n]*")
|
|
33
|
+
#: A run carrying **≥2 HL7 delimiters** (``| ^ ~ &``) — a field/component dump like ``100^^^H^MR`` or
|
|
34
|
+
#: ``DOE^JANE^M`` that may be PHI even without a segment header. The non-delimiter runs use **possessive**
|
|
35
|
+
#: quantifiers (``*+``, Python 3.11+): the char classes are disjoint from the delimiters, so possessive
|
|
36
|
+
#: matching can't change *what* matches, but it makes the scan **linear** — a long delimiter-free run
|
|
37
|
+
#: (e.g. ``"a"*5000`` in a hostile exception string) can't trigger quadratic backtracking.
|
|
38
|
+
_HL7_FIELD_RUN = re.compile(r"[^\s|^~&]*+[|^~&][^\s|^~&]*+(?:[|^~&][^\s|^~&]*+)+")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def redact(text: str) -> str:
|
|
42
|
+
"""Scrub HL7 segment/field content (potential PHI) from free text, keeping segment IDs. Conservative
|
|
43
|
+
(errs toward over-redaction); the goal is that a raw HL7 body embedded in an exception message can't
|
|
44
|
+
reach a log or the stored ``last_error``/``detail``. NOT de-identification (PHI.md §9)."""
|
|
45
|
+
if not text:
|
|
46
|
+
return text
|
|
47
|
+
scrubbed = _HL7_SEGMENT.sub(lambda m: f"{m.group(1)}|{_REDACTED}", text)
|
|
48
|
+
return _HL7_FIELD_RUN.sub(_REDACTED, scrubbed)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def safe_text(text: str, *, limit: int = _DEFAULT_LIMIT) -> str:
|
|
52
|
+
"""A PHI-redacted, length-bounded rendering of a free-text diagnostic string — the string analog of
|
|
53
|
+
:func:`safe_exc`, for error/detail text that isn't an exception object (joined strict-validation
|
|
54
|
+
errors, a ``last_error`` built at the store layer, a connector's reply-parse note). HL7-shaped content
|
|
55
|
+
is scrubbed (:func:`redact`) and the result truncated. Idempotent on already-:func:`safe_text`'d
|
|
56
|
+
input (``redact`` is a fixed point once delimiter runs are gone), so it is safe to re-apply as a
|
|
57
|
+
store-layer chokepoint over values a caller may already have scrubbed."""
|
|
58
|
+
message = redact(text).strip()
|
|
59
|
+
if len(message) > limit:
|
|
60
|
+
message = f"{message[:limit]}…(+{len(message) - limit} chars)"
|
|
61
|
+
return message
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def safe_exc(exc: BaseException, *, limit: int = _DEFAULT_LIMIT) -> str:
|
|
65
|
+
"""A PHI-redacted, length-bounded rendering of ``exc`` for a stored ``last_error``/``detail`` or a
|
|
66
|
+
log line. Always keeps the exception **type** (safe + most useful); the message is redacted
|
|
67
|
+
(:func:`redact`) and truncated — so a Router/Handler that did ``raise ValueError(f"...{raw}")``
|
|
68
|
+
can't leak the HL7 body into the store or logs."""
|
|
69
|
+
name = type(exc).__name__
|
|
70
|
+
message = safe_text(str(exc), limit=limit)
|
|
71
|
+
return f"{name}: {message}" if message else name
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Scaffold a standalone **config repo** for the ``messagefoundry init`` command.
|
|
4
|
+
|
|
5
|
+
A deploying organization keeps its *configuration* (Connections/Routers/Handlers, code sets, and
|
|
6
|
+
per-environment values) in its **own** repo, with the engine as a **read-only, version-pinned
|
|
7
|
+
dependency** it never edits (ADR 0017). This module lays down that repo's skeleton: a runnable starter
|
|
8
|
+
feed, ``environments/<env>.toml`` value stubs, a synthetic fixture, an instance ``messagefoundry.toml``,
|
|
9
|
+
a pinned ``requirements.txt``, a CI ``check`` workflow, ``.vscode`` settings the extension reads, and a
|
|
10
|
+
README — everything an analyst needs to author + validate + deploy config without touching engine source.
|
|
11
|
+
|
|
12
|
+
The templates are plain strings (they ship in the wheel as part of this module — no package-data
|
|
13
|
+
config). ``scaffold()`` writes them and never overwrites an existing file.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from messagefoundry import __version__
|
|
21
|
+
|
|
22
|
+
# A runnable starter feed: receive ADT over MLLP, archive admit/register/update events to a file. Uses
|
|
23
|
+
# literals (not env()) so `messagefoundry check` is green on the first commit; the docstring shows the
|
|
24
|
+
# env() upgrade. The router/handler defs carry `# type: ignore[no-untyped-def]` (the engine calls them
|
|
25
|
+
# with a Message; user config isn't type-checked against that signature).
|
|
26
|
+
_STARTER_FEED = '''\
|
|
27
|
+
"""Starter feed — receive ADT over MLLP, archive admit/register/update events to a file.
|
|
28
|
+
|
|
29
|
+
Replace this with your own Connections, Routers, and Handlers. The authoring surface is the top-level
|
|
30
|
+
``messagefoundry`` package (inbound / outbound / @router / @handler / Send / Message / MLLP / File /
|
|
31
|
+
env / code_set / current_environment / ...). See docs/CONNECTIONS.md in the engine.
|
|
32
|
+
|
|
33
|
+
Connection names follow ``[TYPE]_[PARTNER]_[MESSAGE]``: ``IB_EXAMPLE_ADT`` is an inbound MLLP listener;
|
|
34
|
+
``FILE-OUT_EXAMPLE_ADT`` is an outbound file writer.
|
|
35
|
+
|
|
36
|
+
Per-environment values: replace a literal like ``port=2575`` with ``port=env("example_adt_port")`` and
|
|
37
|
+
add ``example_adt_port = 2575`` to ``environments/<env>.toml`` (those value files resolve against the
|
|
38
|
+
project root — launch ``serve`` from the repo root, or pin it via ``serve --project-root`` /
|
|
39
|
+
``[environments].base_dir``; see the README). Secrets come from ``MEFOR_VALUE_<KEY>`` env vars, never
|
|
40
|
+
the value files.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from messagefoundry import File, MLLP, Send, handler, inbound, outbound, router
|
|
44
|
+
|
|
45
|
+
inbound("IB_EXAMPLE_ADT", MLLP(port=2575), router="example_adt_router")
|
|
46
|
+
outbound("FILE-OUT_EXAMPLE_ADT", File(directory="./out/example", filename="{MSH-10}.hl7"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router("example_adt_router")
|
|
50
|
+
def route(msg): # type: ignore[no-untyped-def]
|
|
51
|
+
# The router sees EVERY received message and returns the handler(s) to run ([] = UNROUTED).
|
|
52
|
+
if msg["MSH-9.1"] != "ADT":
|
|
53
|
+
return []
|
|
54
|
+
return ["example_adt_archive"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@handler("example_adt_archive")
|
|
58
|
+
def archive(msg): # type: ignore[no-untyped-def]
|
|
59
|
+
# Filter -> (transform) -> Send. Only admit/register/update events are archived; others FILTERED.
|
|
60
|
+
if msg["MSH-9.2"] not in ("A01", "A04", "A08"):
|
|
61
|
+
return None
|
|
62
|
+
return Send("FILE-OUT_EXAMPLE_ADT", msg)
|
|
63
|
+
'''
|
|
64
|
+
|
|
65
|
+
# A synthetic ADT^A01 (NO real PHI) that routes + archives, so `check`'s dryrun delivers one message.
|
|
66
|
+
# HL7 segments are CR-separated.
|
|
67
|
+
_FIXTURE_ADT = (
|
|
68
|
+
"MSH|^~\\&|EXAMPLE|FAC|DEST|DEST|20260101120000||ADT^A01|MSG00001|P|2.5.1\r"
|
|
69
|
+
"EVN|A01|20260101120000\r"
|
|
70
|
+
"PID|1||100^^^HOSP^MR||DOE^JANE||19800101|F\r"
|
|
71
|
+
"PV1|1|I|WARD^101^A\r"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_ENV_DEV = """\
|
|
75
|
+
# DEV environment values — resolved by env("key") in your config graph (see the engine's
|
|
76
|
+
# docs/CONFIGURATION.md). NON-SECRET values only, versioned here so they're diffable/reviewable.
|
|
77
|
+
# Secrets come from MEFOR_VALUE_<KEY> environment variables, never this file. Keys are lower_snake_case.
|
|
78
|
+
#
|
|
79
|
+
# Selected by [ai].environment = "dev" (or `serve --env dev`). The starter feed uses none; add keys as
|
|
80
|
+
# you switch literals to env(), e.g.:
|
|
81
|
+
# example_adt_port = 2575
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
_ENV_PROD = """\
|
|
85
|
+
# PROD environment values — same shape as dev.toml, with this instance's real (non-secret) endpoints.
|
|
86
|
+
# Secrets come from MEFOR_VALUE_<KEY> environment variables, never this file.
|
|
87
|
+
#
|
|
88
|
+
# Selected by [ai].environment = "prod" (or `serve --env prod`).
|
|
89
|
+
# example_adt_port = 2575
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
# Service settings for ONE instance. Copy per deployed instance (Test/Prod/...) and set its environment
|
|
93
|
+
# + posture + store + egress. Precedence: CLI > MEFOR_<SECTION>_<KEY> env > this file > default.
|
|
94
|
+
_SERVICE_TOML = """\
|
|
95
|
+
# MessageFoundry service settings for THIS instance. Keep one per deployed instance (dev/test/prod/...).
|
|
96
|
+
# Precedence: CLI flag > MEFOR_<SECTION>_<KEY> env > this file > built-in default. Secrets go in env
|
|
97
|
+
# (MEFOR_*), never here. See the engine's docs/CONFIGURATION.md.
|
|
98
|
+
|
|
99
|
+
[store]
|
|
100
|
+
# path = "messagefoundry.db" # SQLite (default). Use a server DB for Test/Prod — see docs/DEPLOY-SERVER-DB.md.
|
|
101
|
+
|
|
102
|
+
[api]
|
|
103
|
+
host = "127.0.0.1" # loopback only; an off-loopback bind requires TLS — see docs/DEPLOYMENT.md.
|
|
104
|
+
port = 8765
|
|
105
|
+
|
|
106
|
+
[ai]
|
|
107
|
+
# The active-environment NAME — REQUIRED (also passable as `serve --env <name>`). Free-form: name
|
|
108
|
+
# instances dev/staging/test/prod/poc/... Built-in names dev/staging/prod carry a default posture; a
|
|
109
|
+
# CUSTOM name MUST also set data_class + production below (posture is never inferred from the name).
|
|
110
|
+
environment = "dev"
|
|
111
|
+
# Security posture, decoupled from the name (ADR 0017). Derived for dev/staging/prod when omitted:
|
|
112
|
+
# data_class = "phi" # synthetic | phi — does this instance carry REAL PHI? (drives at-rest + egress advisories)
|
|
113
|
+
# production = true # production tier? (drives the prod-DEBUG refusal + the AI data-scope ceiling)
|
|
114
|
+
|
|
115
|
+
[environments]
|
|
116
|
+
# Where environments/<env>.toml value files resolve FROM. Default (unset) = the process working
|
|
117
|
+
# directory, so `serve` must be launched from the repo root. Set base_dir to this repo's ABSOLUTE root
|
|
118
|
+
# — or pass `serve --project-root <repo>` — so values resolve no matter the launch CWD (REQUIRED under a
|
|
119
|
+
# service like NSSM, where the working directory isn't the repo root). See docs/CONFIGURATION.md.
|
|
120
|
+
# base_dir = "C:/srv/mefor/this-config-repo"
|
|
121
|
+
|
|
122
|
+
[egress]
|
|
123
|
+
# Lock down outbound destinations on any PHI-carrying instance (recommended for Test/Prod):
|
|
124
|
+
# deny_by_default = true
|
|
125
|
+
# allowed_mllp = ["receiver.test.example:2601"]
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
_VSCODE_SETTINGS = """\
|
|
129
|
+
{
|
|
130
|
+
"messagefoundry.configDir": "config",
|
|
131
|
+
"messagefoundry.messageSetsDir": "messages/sets"
|
|
132
|
+
}
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
# CI gate: verify the pinned engine wheel's build provenance, then install it and run `messagefoundry
|
|
136
|
+
# check` (validate + dryrun + advisory lint) on every PR. `pip install -r requirements.txt` resolves the
|
|
137
|
+
# engine from your configured index — public PyPI (the published releases carry SLSA + PEP 740
|
|
138
|
+
# attestations), the engine's GitHub Release wheel, or a private index.
|
|
139
|
+
_CI_WORKFLOW = """\
|
|
140
|
+
name: check
|
|
141
|
+
on:
|
|
142
|
+
pull_request:
|
|
143
|
+
push:
|
|
144
|
+
branches: [main]
|
|
145
|
+
|
|
146
|
+
jobs:
|
|
147
|
+
# Supply-chain gate (MessageFoundry WP-BL3-07): verify the pinned engine wheel's SLSA build provenance
|
|
148
|
+
# BEFORE installing it, so a registry/mirror substitution of the engine fails the build instead of
|
|
149
|
+
# shipping silently. Fail-closed by default. If your package index strips attestations (some private
|
|
150
|
+
# mirrors do), set the repository variable MEFOR_VERIFY_ENGINE=off to skip this job (see README).
|
|
151
|
+
verify-engine:
|
|
152
|
+
runs-on: ubuntu-latest
|
|
153
|
+
if: ${{ vars.MEFOR_VERIFY_ENGINE != 'off' }}
|
|
154
|
+
steps:
|
|
155
|
+
- uses: actions/checkout@v4
|
|
156
|
+
- uses: actions/setup-python@v5
|
|
157
|
+
with:
|
|
158
|
+
python-version: "3.11"
|
|
159
|
+
- name: Download the pinned engine wheel (no install)
|
|
160
|
+
run: |
|
|
161
|
+
pip download -r requirements.txt --no-deps --only-binary=:all: -d dist-verify
|
|
162
|
+
- name: Verify SLSA build provenance before install
|
|
163
|
+
env:
|
|
164
|
+
GH_TOKEN: ${{ github.token }}
|
|
165
|
+
run: gh attestation verify dist-verify/messagefoundry-*.whl --repo wshallwshall/MessageFoundry
|
|
166
|
+
|
|
167
|
+
check:
|
|
168
|
+
needs: verify-engine
|
|
169
|
+
# Run when verify passed OR was intentionally skipped (MEFOR_VERIFY_ENGINE=off); never when it failed
|
|
170
|
+
# — a failed/cancelled verify-engine fails the gate (fail-closed). `always()` lets this evaluate even
|
|
171
|
+
# though the dependency may have been skipped.
|
|
172
|
+
if: ${{ always() && needs.verify-engine.result != 'failure' && needs.verify-engine.result != 'cancelled' }}
|
|
173
|
+
runs-on: ubuntu-latest
|
|
174
|
+
steps:
|
|
175
|
+
- uses: actions/checkout@v4
|
|
176
|
+
- uses: actions/setup-python@v5
|
|
177
|
+
with:
|
|
178
|
+
python-version: "3.11"
|
|
179
|
+
- name: Install the pinned MessageFoundry engine
|
|
180
|
+
run: pip install -r requirements.txt
|
|
181
|
+
# `check` runs validate + dryrun (the real gate). --no-lint skips the advisory ruff/mypy pass
|
|
182
|
+
# (those tools aren't in requirements.txt); add them and drop --no-lint to lint your config too.
|
|
183
|
+
- name: Validate config (validate + dryrun)
|
|
184
|
+
run: messagefoundry check --config config --messages messages/sets --no-lint
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
_GITIGNORE = """\
|
|
188
|
+
# MessageFoundry config repo — never commit local stores, secrets, captures, or build cruft.
|
|
189
|
+
*.db
|
|
190
|
+
*.db-shm
|
|
191
|
+
*.db-wal
|
|
192
|
+
*.log
|
|
193
|
+
.env
|
|
194
|
+
.env.*
|
|
195
|
+
/out/
|
|
196
|
+
captures/
|
|
197
|
+
__pycache__/
|
|
198
|
+
.venv/
|
|
199
|
+
.mypy_cache/
|
|
200
|
+
.ruff_cache/
|
|
201
|
+
.pytest_cache/
|
|
202
|
+
.DS_Store
|
|
203
|
+
Thumbs.db
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
_GITATTRIBUTES = "# Keep the generated pre-commit hook LF so its shebang works on Windows.\n.mefor-hooks/** text eol=lf\n"
|
|
207
|
+
|
|
208
|
+
_README = """\
|
|
209
|
+
# MessageFoundry configuration
|
|
210
|
+
|
|
211
|
+
This repository is a **MessageFoundry config repo**: it holds *your* integration configuration
|
|
212
|
+
(Connections, Routers, Handlers, code sets, and per-environment values) and drives one or more engine
|
|
213
|
+
instances (e.g. Test, Production). The **engine is a read-only, version-pinned dependency** — you never
|
|
214
|
+
edit it here; you author config against its public surface (ADR 0017).
|
|
215
|
+
|
|
216
|
+
## Layout
|
|
217
|
+
- `config/` — the `--config` directory: your Connection/Router/Handler modules (and `codesets/`).
|
|
218
|
+
- `environments/<env>.toml` — NON-secret per-environment values for `env("key")` lookups (versioned).
|
|
219
|
+
Secrets come from `MEFOR_VALUE_<KEY>` environment variables, never these files.
|
|
220
|
+
- `messages/sets/` — synthetic HL7 fixtures that gate `messagefoundry check` (no real PHI).
|
|
221
|
+
- `messagefoundry.toml` — this instance's service settings (active environment + posture, store, API, egress).
|
|
222
|
+
- `requirements.txt` — pins the engine version this config targets.
|
|
223
|
+
- `.github/workflows/check.yml` — CI: install the pinned engine + run `messagefoundry check` on every PR.
|
|
224
|
+
|
|
225
|
+
## Use it
|
|
226
|
+
```bash
|
|
227
|
+
# 1. Install the pinned engine into a venv (the engine is a read-only dependency):
|
|
228
|
+
python -m venv .venv && . .venv/bin/activate # Windows: .\\.venv\\Scripts\\Activate.ps1
|
|
229
|
+
pip install -r requirements.txt
|
|
230
|
+
|
|
231
|
+
# 2. Validate your config (also runs in CI on every PR):
|
|
232
|
+
messagefoundry check --config config --messages messages/sets
|
|
233
|
+
|
|
234
|
+
# 3. Run an instance. environments/<env>.toml resolves against the project root — by default the
|
|
235
|
+
# process working directory, so launch from the repo root:
|
|
236
|
+
messagefoundry serve --config config --env dev
|
|
237
|
+
# Or pin the root explicitly (an ABSOLUTE path) so it resolves no matter the launch directory —
|
|
238
|
+
# REQUIRED under a service like NSSM, where the CWD isn't the repo root:
|
|
239
|
+
# messagefoundry serve --config config --env dev --project-root /srv/mefor/this-config-repo
|
|
240
|
+
# (Equivalently, set [environments].base_dir to that absolute path in messagefoundry.toml.)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
> The engine version in `requirements.txt` is resolved from your configured package index — **PyPI**
|
|
244
|
+
> (`pip install messagefoundry==<version>`; the published releases carry SLSA + PEP 740 attestations),
|
|
245
|
+
> the engine's **GitHub Release wheel**, or a **private index**.
|
|
246
|
+
|
|
247
|
+
## Engine integrity (supply-chain gate)
|
|
248
|
+
`.github/workflows/check.yml` **verifies the pinned engine wheel's build provenance before installing it**
|
|
249
|
+
(`gh attestation verify` against the MessageFoundry release attestation), so a registry/mirror swap of the
|
|
250
|
+
engine fails CI instead of shipping silently — pinning a version proves *which bytes*, not *who built
|
|
251
|
+
them*. The gate is **fail-closed by default**. If your package index strips attestations (some private
|
|
252
|
+
mirrors do), set the repository variable **`MEFOR_VERIFY_ENGINE=off`** (Settings → Secrets and variables →
|
|
253
|
+
Actions → Variables) to skip it; the `check` job still runs. See the engine's INSTALL-GUIDE for the
|
|
254
|
+
matching manual verify-before-install recipe.
|
|
255
|
+
|
|
256
|
+
## Environments & posture
|
|
257
|
+
The active environment is **required** and **free-form** — name instances `dev`/`staging`/`test`/`prod`/`poc`/…
|
|
258
|
+
Built-in names `dev`/`staging`/`prod` carry a default security posture; a **custom** name must set
|
|
259
|
+
`[ai].data_class` (`synthetic`|`phi`) and `[ai].production` in `messagefoundry.toml`. One reviewed config
|
|
260
|
+
commit is deployed to every instance; each instance picks its environment at runtime (`--env` or
|
|
261
|
+
`[ai].environment`), so a Test instance never resolves Prod values.
|
|
262
|
+
|
|
263
|
+
The selected `environments/<env>.toml` resolves against the **project root**: by default the process
|
|
264
|
+
working directory (launch from the repo root), or pin it with `serve --project-root <abs-path>` /
|
|
265
|
+
`[environments].base_dir` so it resolves regardless of the launch directory — required under a service
|
|
266
|
+
(e.g. NSSM) where the working directory isn't the repo root.
|
|
267
|
+
|
|
268
|
+
## Secrets
|
|
269
|
+
Never commit secrets. Per-environment endpoints (non-secret) live in `environments/<env>.toml`; secrets
|
|
270
|
+
(passwords, keys, WS-Security credentials) are injected per instance via `MEFOR_VALUE_*` (graph) and
|
|
271
|
+
`MEFOR_*` (service) environment variables.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
Generated by `messagefoundry init`.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _templates(version: str) -> dict[str, str]:
|
|
279
|
+
"""The relative-path -> file-content map for a fresh config repo, pinning ``version``."""
|
|
280
|
+
return {
|
|
281
|
+
"README.md": _README,
|
|
282
|
+
"requirements.txt": f"messagefoundry=={version}\n",
|
|
283
|
+
".gitignore": _GITIGNORE,
|
|
284
|
+
".gitattributes": _GITATTRIBUTES,
|
|
285
|
+
".vscode/settings.json": _VSCODE_SETTINGS,
|
|
286
|
+
".github/workflows/check.yml": _CI_WORKFLOW,
|
|
287
|
+
"messagefoundry.toml": _SERVICE_TOML,
|
|
288
|
+
"config/IB_EXAMPLE_ADT.py": _STARTER_FEED,
|
|
289
|
+
"environments/dev.toml": _ENV_DEV,
|
|
290
|
+
"environments/prod.toml": _ENV_PROD,
|
|
291
|
+
"messages/sets/example_adt.hl7": _FIXTURE_ADT,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def scaffold(target: str | Path, *, force: bool = False, version: str = __version__) -> list[Path]:
|
|
296
|
+
"""Write a starter config-repo skeleton into ``target``; return the files written (sorted).
|
|
297
|
+
|
|
298
|
+
Refuses a **non-empty** ``target`` unless ``force`` is set. An existing file is **never**
|
|
299
|
+
overwritten (even with ``force``) — ``force`` only permits scaffolding the missing files into a
|
|
300
|
+
directory that already has content. ``version`` pins the engine in ``requirements.txt`` (defaults
|
|
301
|
+
to the running engine's version).
|
|
302
|
+
"""
|
|
303
|
+
root = Path(target)
|
|
304
|
+
if root.exists() and root.is_dir() and any(root.iterdir()) and not force:
|
|
305
|
+
raise FileExistsError(
|
|
306
|
+
f"{root} is not empty — pass force=True to scaffold the missing files into it "
|
|
307
|
+
"(existing files are left untouched)"
|
|
308
|
+
)
|
|
309
|
+
if root.exists() and not root.is_dir():
|
|
310
|
+
raise NotADirectoryError(f"{root} exists and is not a directory")
|
|
311
|
+
|
|
312
|
+
written: list[Path] = []
|
|
313
|
+
for rel, content in _templates(version).items():
|
|
314
|
+
path = root / rel
|
|
315
|
+
if path.exists():
|
|
316
|
+
continue # never clobber an existing file
|
|
317
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
# newline="" writes the content verbatim — LF for text, the CR-separated HL7 fixture intact.
|
|
319
|
+
path.write_text(content, encoding="utf-8", newline="")
|
|
320
|
+
written.append(path)
|
|
321
|
+
return sorted(written)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Windows DPAPI secret-at-rest helper (WP-11d; ASVS 13.3.1/13.3.2).
|
|
4
|
+
|
|
5
|
+
The store encryption key is normally supplied as base64 via ``MEFOR_STORE_ENCRYPTION_KEY`` (the
|
|
6
|
+
cross-platform default). On Windows an operator may instead keep it in a **DPAPI-protected key file**
|
|
7
|
+
(``[store].encryption_key_file``): ``CryptProtectData`` binds the ciphertext to this machine
|
|
8
|
+
(``LOCAL_MACHINE`` scope), so a copied file is useless off the protecting host and the plaintext key
|
|
9
|
+
never sits in the service's environment block (readable by any local admin). At startup the service
|
|
10
|
+
account ``CryptUnprotectData``s the file back to the base64 key.
|
|
11
|
+
|
|
12
|
+
DPAPI is **Windows-only**. Every entry point raises :class:`DpapiUnavailable` elsewhere so callers
|
|
13
|
+
degrade gracefully to the env-var key — this module never imports anything Windows-specific at module
|
|
14
|
+
load, so it imports cleanly on Linux/macOS (CI lint leg) too. The ``ctypes.windll`` calls live behind
|
|
15
|
+
``sys.platform != "win32"`` guards; mypy treats the code after the guard as unreachable off Windows
|
|
16
|
+
(mirrors :mod:`messagefoundry.console.service_control`), so it type-checks on the Linux CI leg.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ctypes
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# CryptProtectData flags. LOCAL_MACHINE: any principal on THIS machine can unprotect — required so the
|
|
26
|
+
# low-privilege *service account* (not just the installing admin) can read the key. UI_FORBIDDEN: never
|
|
27
|
+
# raise a prompt (the engine runs headless under a service).
|
|
28
|
+
_CRYPTPROTECT_UI_FORBIDDEN = 0x01
|
|
29
|
+
_CRYPTPROTECT_LOCAL_MACHINE = 0x04
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DpapiUnavailable(RuntimeError):
|
|
33
|
+
"""DPAPI was requested off Windows — there is no ``CryptProtectData`` to call."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DpapiError(RuntimeError):
|
|
37
|
+
"""A DPAPI operation (protect/unprotect or the backing file I/O) failed."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dpapi_available() -> bool:
|
|
41
|
+
"""Whether DPAPI can be used here (Windows only)."""
|
|
42
|
+
return sys.platform == "win32"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _DataBlob(ctypes.Structure):
|
|
46
|
+
"""The Win32 ``DATA_BLOB`` (cbData + pbData) passed to/from the CryptProtectData API."""
|
|
47
|
+
|
|
48
|
+
_fields_ = (("cbData", ctypes.c_uint32), ("pbData", ctypes.POINTER(ctypes.c_char)))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _to_blob(data: bytes) -> tuple[_DataBlob, ctypes.Array[ctypes.c_char]]:
|
|
52
|
+
# Return the buffer alongside the blob so the caller keeps it referenced for the call's duration
|
|
53
|
+
# (the blob only borrows the pointer; if the buffer is GC'd mid-call the read is use-after-free).
|
|
54
|
+
buf = ctypes.create_string_buffer(data, len(data))
|
|
55
|
+
blob = _DataBlob(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
|
|
56
|
+
return blob, buf
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def dpapi_protect(secret: bytes, *, machine_scope: bool = True) -> bytes:
|
|
60
|
+
"""DPAPI-encrypt ``secret``. ``machine_scope`` (default) ties it to the machine so the service
|
|
61
|
+
account can decrypt; False ties it to the current user only. Raises :class:`DpapiUnavailable`
|
|
62
|
+
off Windows, :class:`DpapiError` on a Win32 failure."""
|
|
63
|
+
if sys.platform != "win32":
|
|
64
|
+
raise DpapiUnavailable("DPAPI (CryptProtectData) is only available on Windows")
|
|
65
|
+
crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
|
|
66
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
67
|
+
in_blob, _keep = _to_blob(secret)
|
|
68
|
+
out_blob = _DataBlob()
|
|
69
|
+
flags = _CRYPTPROTECT_UI_FORBIDDEN | (_CRYPTPROTECT_LOCAL_MACHINE if machine_scope else 0)
|
|
70
|
+
ok = crypt32.CryptProtectData(
|
|
71
|
+
ctypes.byref(in_blob), None, None, None, None, flags, ctypes.byref(out_blob)
|
|
72
|
+
)
|
|
73
|
+
if not ok:
|
|
74
|
+
raise DpapiError(f"CryptProtectData failed (Win32 error {ctypes.get_last_error()})")
|
|
75
|
+
try:
|
|
76
|
+
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
|
|
77
|
+
finally:
|
|
78
|
+
kernel32.LocalFree(out_blob.pbData)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dpapi_unprotect(blob: bytes) -> bytes:
|
|
82
|
+
"""DPAPI-decrypt a ciphertext produced by :func:`dpapi_protect` (on this machine/account).
|
|
83
|
+
Raises :class:`DpapiUnavailable` off Windows, :class:`DpapiError` on a Win32 failure (wrong
|
|
84
|
+
machine/account, or a corrupt/foreign blob)."""
|
|
85
|
+
if sys.platform != "win32":
|
|
86
|
+
raise DpapiUnavailable("DPAPI (CryptUnprotectData) is only available on Windows")
|
|
87
|
+
crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
|
|
88
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
89
|
+
in_blob, _keep = _to_blob(blob)
|
|
90
|
+
out_blob = _DataBlob()
|
|
91
|
+
ok = crypt32.CryptUnprotectData(
|
|
92
|
+
ctypes.byref(in_blob),
|
|
93
|
+
None,
|
|
94
|
+
None,
|
|
95
|
+
None,
|
|
96
|
+
None,
|
|
97
|
+
_CRYPTPROTECT_UI_FORBIDDEN,
|
|
98
|
+
ctypes.byref(out_blob),
|
|
99
|
+
)
|
|
100
|
+
if not ok:
|
|
101
|
+
raise DpapiError(
|
|
102
|
+
f"CryptUnprotectData failed (Win32 error {ctypes.get_last_error()}); the key file must be "
|
|
103
|
+
"unprotected on the same machine that protected it"
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
|
|
107
|
+
finally:
|
|
108
|
+
kernel32.LocalFree(out_blob.pbData)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def protect_key_to_file(key_b64: str, path: Path, *, machine_scope: bool = True) -> None:
|
|
112
|
+
"""DPAPI-protect a base64 store key and write the ciphertext to ``path`` (raises on non-Windows
|
|
113
|
+
or a write failure). The caller should restrict the file's ACL afterwards (``_secure_file``)."""
|
|
114
|
+
blob = dpapi_protect(key_b64.strip().encode("ascii"), machine_scope=machine_scope)
|
|
115
|
+
try:
|
|
116
|
+
path.write_bytes(blob)
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
raise DpapiError(f"cannot write protected key file {path}: {exc}") from exc
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_protected_key(path: str | Path) -> str:
|
|
122
|
+
"""Read and DPAPI-decrypt a key file into its base64 store key. Raises :class:`DpapiUnavailable`
|
|
123
|
+
off Windows or :class:`DpapiError` if the file is missing/unreadable/not decryptable here."""
|
|
124
|
+
p = Path(path)
|
|
125
|
+
try:
|
|
126
|
+
blob = p.read_bytes()
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
raise DpapiError(f"cannot read encryption_key_file {p}: {exc}") from exc
|
|
129
|
+
return dpapi_unprotect(blob).decode("ascii").strip()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Durable message store + queue (SQLite WAL, transactional inbox/outbox).
|
|
4
|
+
|
|
5
|
+
The store *is* the queue: persisting an inbound message and its per-destination outbox
|
|
6
|
+
rows in one transaction buys at-least-once delivery, retries, and replay without a
|
|
7
|
+
separate broker. See :mod:`messagefoundry.store.store` and docs/ARCHITECTURE.md.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from messagefoundry.store.base import (
|
|
13
|
+
AdminStore,
|
|
14
|
+
AuditStore,
|
|
15
|
+
AuthStore,
|
|
16
|
+
QueueStore,
|
|
17
|
+
Row,
|
|
18
|
+
Store,
|
|
19
|
+
StoreLifecycle,
|
|
20
|
+
open_store,
|
|
21
|
+
sqlite_settings,
|
|
22
|
+
)
|
|
23
|
+
from messagefoundry.store.store import (
|
|
24
|
+
MessageStatus,
|
|
25
|
+
MessageStore,
|
|
26
|
+
OutboxItem,
|
|
27
|
+
OutboxStatus,
|
|
28
|
+
Stage,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AdminStore",
|
|
33
|
+
"AuditStore",
|
|
34
|
+
"AuthStore",
|
|
35
|
+
"MessageStatus",
|
|
36
|
+
"MessageStore",
|
|
37
|
+
"OutboxItem",
|
|
38
|
+
"OutboxStatus",
|
|
39
|
+
"QueueStore",
|
|
40
|
+
"Row",
|
|
41
|
+
"Stage",
|
|
42
|
+
"Store",
|
|
43
|
+
"StoreLifecycle",
|
|
44
|
+
"open_store",
|
|
45
|
+
"sqlite_settings",
|
|
46
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Off-box audit tee — the single PHI-redaction path shared by every store backend (sec-offbox-log).
|
|
4
|
+
|
|
5
|
+
Each store backend (SQLite, Postgres, SQL Server) calls :func:`emit_audit_tee` immediately after a
|
|
6
|
+
``record_audit`` row is durably committed, so a **PHI-safe metadata** copy of the audit record is
|
|
7
|
+
shipped off-box via the ``messagefoundry.audit`` logger — which propagates to the root stdout +
|
|
8
|
+
optional syslog/SIEM forwarder configured by :mod:`messagefoundry.logging_setup`. So the audit trail
|
|
9
|
+
survives a host/DB compromise (ASVS 16.x).
|
|
10
|
+
|
|
11
|
+
One helper means there is exactly **one** place the off-box PHI-redaction guarantee lives, identical
|
|
12
|
+
across all three backends — not three copies that could drift.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
from messagefoundry.redaction import safe_text
|
|
21
|
+
|
|
22
|
+
__all__ = ["audit_logger", "emit_audit_tee"]
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Pinned to INFO so audit evidence forwards regardless of the deployment's general log level: the
|
|
27
|
+
# logging_setup root handlers are NOTSET, so a record's only level gate is this logger's own level.
|
|
28
|
+
# It propagates to those root handlers, so no logging_setup change is needed to reach the forwarder.
|
|
29
|
+
audit_logger = logging.getLogger("messagefoundry.audit")
|
|
30
|
+
audit_logger.setLevel(logging.INFO)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def emit_audit_tee(
|
|
34
|
+
*,
|
|
35
|
+
action: str,
|
|
36
|
+
actor: str | None,
|
|
37
|
+
channel_id: str | None,
|
|
38
|
+
detail: str | None,
|
|
39
|
+
ts: float,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Tee a just-persisted ``audit_log`` record off-box as PHI-safe metadata (sec-offbox-log, ASVS
|
|
42
|
+
16.x). Emits actor / action / channel / timestamp plus a **redacted** ``detail`` to the
|
|
43
|
+
``messagefoundry.audit`` logger.
|
|
44
|
+
|
|
45
|
+
**Never emits a raw message body.** ``detail`` can embed raw HL7 fragments from an exception
|
|
46
|
+
message (it is a cipher column at rest for exactly that reason), so it is run through
|
|
47
|
+
:func:`~messagefoundry.redaction.safe_text` — which scrubs HL7-shaped spans and bounds length —
|
|
48
|
+
before it leaves the process. The handler-level ``RedactionFilter`` re-scrubs as a backstop, but
|
|
49
|
+
redacting here keeps the off-box guarantee independent of handler config and **identical across
|
|
50
|
+
every backend**.
|
|
51
|
+
|
|
52
|
+
Best-effort: a logging failure must never fail the audit write (already committed), so it is
|
|
53
|
+
caught and logged, not raised. Callers invoke this **after commit** and **outside any write
|
|
54
|
+
lock/transaction**, so a synchronous syslog send can't block the event loop under a lock."""
|
|
55
|
+
record = {
|
|
56
|
+
"event": "audit",
|
|
57
|
+
"ts": ts,
|
|
58
|
+
"action": action,
|
|
59
|
+
"actor": actor,
|
|
60
|
+
"channel_id": channel_id,
|
|
61
|
+
# PHI chokepoint: redact HL7-shaped content + bound length before it ships off-box.
|
|
62
|
+
"detail": safe_text(detail) if detail else None,
|
|
63
|
+
}
|
|
64
|
+
try:
|
|
65
|
+
audit_logger.info(json.dumps(record, ensure_ascii=False))
|
|
66
|
+
except Exception: # noqa: BLE001 — the audit row is durable; the off-box tee is best-effort
|
|
67
|
+
log.warning("off-box audit tee failed for action=%s", action, exc_info=True)
|