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,1311 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Operational **service settings** — deployment config, distinct from the code-first message graph.
|
|
4
|
+
|
|
5
|
+
The message graph (Connections/Routers/Handlers) is authored in Python and loaded from ``--config``;
|
|
6
|
+
this module covers the *operational* knobs an admin sets to run the service: where the store lives,
|
|
7
|
+
the API bind address, logging. They load from a TOML file + environment + CLI, with precedence::
|
|
8
|
+
|
|
9
|
+
CLI flag > environment variable > messagefoundry.toml > built-in default
|
|
10
|
+
|
|
11
|
+
Secrets (e.g. a future DB password) belong in **env** (``MEFOR_<SECTION>_<KEY>``), never in the file.
|
|
12
|
+
This is the first cut (build-order step 1 of docs/CONFIGURATION.md): ``[store]`` (backend/path/
|
|
13
|
+
synchronous), ``[api]`` (host/port), and ``[logging]`` (level + structured-JSON ``format`` + off-box
|
|
14
|
+
``forward_*`` syslog shipping — sec-offbox-log). ``[retention]`` is now enforced (the
|
|
15
|
+
``RetentionRunner``), except its ``audit_days`` key, which is reserved/keep-forever by design.
|
|
16
|
+
Remaining planned keys (some server-DB ``[store]`` keys) are accepted-but-ignored for now so a
|
|
17
|
+
forward-looking config file still loads.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import tomllib
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Mapping
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
31
|
+
|
|
32
|
+
from messagefoundry.config.ai_policy import AiDataScope, AiMode, DataClass
|
|
33
|
+
from messagefoundry.config.models import (
|
|
34
|
+
AckAfter,
|
|
35
|
+
BuildupThreshold,
|
|
36
|
+
InternalErrorPolicy,
|
|
37
|
+
OrderingMode,
|
|
38
|
+
RetryPolicy,
|
|
39
|
+
)
|
|
40
|
+
from messagefoundry.config.tls_policy import validate_tls_ciphers
|
|
41
|
+
from messagefoundry.logging_setup import LOG_LEVELS
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"StoreBackend",
|
|
45
|
+
"SqliteSync",
|
|
46
|
+
"SqlAuth",
|
|
47
|
+
"StoreSettings",
|
|
48
|
+
"ApiSettings",
|
|
49
|
+
"InboundSettings",
|
|
50
|
+
"DeliverySettings",
|
|
51
|
+
"PipelineSettings",
|
|
52
|
+
"EnvironmentsSettings",
|
|
53
|
+
"LoggingSettings",
|
|
54
|
+
"LogFormat",
|
|
55
|
+
"SyslogProtocol",
|
|
56
|
+
"ReferenceSettings",
|
|
57
|
+
"RetentionSettings",
|
|
58
|
+
"AuthSettings",
|
|
59
|
+
"AiSettings",
|
|
60
|
+
"AiMode",
|
|
61
|
+
"AiDataScope",
|
|
62
|
+
"DataClass",
|
|
63
|
+
"EgressSettings",
|
|
64
|
+
"ShadowSettings",
|
|
65
|
+
"AlertsSettings",
|
|
66
|
+
"ClusterSettings",
|
|
67
|
+
"ApprovalsSettings",
|
|
68
|
+
"ServiceSettings",
|
|
69
|
+
"load_settings",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
#: Known config sections (used to parse ``MEFOR_<SECTION>_<KEY>`` env vars).
|
|
73
|
+
_SECTIONS = (
|
|
74
|
+
"store",
|
|
75
|
+
"api",
|
|
76
|
+
"inbound",
|
|
77
|
+
"delivery",
|
|
78
|
+
"environments",
|
|
79
|
+
"logging",
|
|
80
|
+
"reference",
|
|
81
|
+
"retention",
|
|
82
|
+
"auth",
|
|
83
|
+
"ai",
|
|
84
|
+
"egress",
|
|
85
|
+
"shadow",
|
|
86
|
+
"alerts",
|
|
87
|
+
"cluster",
|
|
88
|
+
"approvals",
|
|
89
|
+
)
|
|
90
|
+
_ENV_PREFIX = "MEFOR_"
|
|
91
|
+
_DEFAULT_FILE = "messagefoundry.toml"
|
|
92
|
+
|
|
93
|
+
_log = logging.getLogger(__name__)
|
|
94
|
+
|
|
95
|
+
#: (section, key) secrets that belong in env, never the config file (see _warn_file_secrets).
|
|
96
|
+
_FILE_SECRET_KEYS = (
|
|
97
|
+
("store", "password"),
|
|
98
|
+
("store", "encryption_key"),
|
|
99
|
+
("store", "encryption_keys_retired"),
|
|
100
|
+
("auth", "ad_bind_password"),
|
|
101
|
+
("alerts", "email_password"),
|
|
102
|
+
("api", "tls_key_password"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class StoreBackend(str, Enum):
|
|
107
|
+
SQLITE = "sqlite"
|
|
108
|
+
SQLSERVER = (
|
|
109
|
+
"sqlserver" # production server-DB backend; full staged pipeline (see store/sqlserver.py)
|
|
110
|
+
)
|
|
111
|
+
POSTGRES = (
|
|
112
|
+
"postgres" # production server-DB backend with single-node parity (see store/postgres.py)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SqliteSync(str, Enum):
|
|
117
|
+
NORMAL = "normal" # crash-safe under WAL, no per-commit fsync (default)
|
|
118
|
+
FULL = "full"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SqlAuth(str, Enum):
|
|
122
|
+
SQL = "sql" # SQL login (username + password)
|
|
123
|
+
INTEGRATED = "integrated" # Windows Integrated auth
|
|
124
|
+
ENTRA = "entra" # Microsoft Entra ID (Azure AD)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _Section(BaseModel):
|
|
128
|
+
# Ignore unknown keys so a forward-looking file (planned retention/delivery keys) still loads.
|
|
129
|
+
model_config = ConfigDict(extra="ignore")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
#: Env var that explicitly permits MITM-able TLS overrides for a trusted-network dev/test bind.
|
|
133
|
+
INSECURE_TLS_ESCAPE_ENV = "MEFOR_ALLOW_INSECURE_TLS"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def insecure_tls_allowed() -> bool:
|
|
137
|
+
"""Whether the explicit dev escape to permit insecure TLS overrides is set (ASVS 12.3.2).
|
|
138
|
+
|
|
139
|
+
Certificate-validation overrides (``ad_tls_verify=false`` for LDAPS, ``trust_server_certificate
|
|
140
|
+
=true`` for SQL Server) are MITM-able, so they now **refuse** at startup unless
|
|
141
|
+
``MEFOR_ALLOW_INSECURE_TLS`` is truthy. This means a production deployment can't silently disable
|
|
142
|
+
server-cert validation; an operator must opt in loudly for a trusted-network dev/test bind."""
|
|
143
|
+
return os.environ.get(INSECURE_TLS_ESCAPE_ENV, "").strip().lower() in ("1", "true", "yes", "on")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class StoreSettings(_Section):
|
|
147
|
+
backend: StoreBackend = StoreBackend.SQLITE
|
|
148
|
+
|
|
149
|
+
# --- SQLite (default backend) -------------------------------------------
|
|
150
|
+
path: str = "messagefoundry.db"
|
|
151
|
+
synchronous: SqliteSync = SqliteSync.NORMAL
|
|
152
|
+
|
|
153
|
+
# --- PHI-at-rest encryption (both backends; STORE-1 / WP-5) -------------
|
|
154
|
+
# Base64 32-byte ACTIVE key; when set, PHI columns (raw bodies + error/last_error/detail) are
|
|
155
|
+
# AES-256-GCM-encrypted at rest. Secret — supply via MEFOR_STORE_ENCRYPTION_KEY, never the file.
|
|
156
|
+
# Empty = off (values stored as-is).
|
|
157
|
+
encryption_key: str | None = None
|
|
158
|
+
# Comma-separated base64 RETIRED keys, kept available for *decrypt only* during a key rotation
|
|
159
|
+
# (ASVS 11.2.2) until `messagefoundry rotate-key` finishes re-encrypting under the active key.
|
|
160
|
+
# Secret — env-only (MEFOR_STORE_ENCRYPTION_KEYS_RETIRED). Empty = none.
|
|
161
|
+
encryption_keys_retired: str = ""
|
|
162
|
+
# When true, `serve` refuses to start without an encryption key (any environment). Off by default;
|
|
163
|
+
# with it off, a 'prod' environment still gets a loud startup warning. See docs/PHI.md §3.
|
|
164
|
+
require_encryption: bool = False
|
|
165
|
+
# Windows DPAPI-protected key file (WP-11d, ASVS 13.3.1): a path produced by
|
|
166
|
+
# `messagefoundry protect-key`. When `encryption_key` is unset and this is set, the active key is
|
|
167
|
+
# CryptUnprotectData'd from this file at open — so the plaintext key never sits in the service
|
|
168
|
+
# environment. This is a *path*, not a secret, so it may live in the config file. Windows-only;
|
|
169
|
+
# the env key takes precedence. Empty = use `encryption_key` (the cross-platform default).
|
|
170
|
+
encryption_key_file: str | None = None
|
|
171
|
+
# KeyProvider seam (ADR 0019, ASVS 13.3.3): selects HOW the active/retired DEK bytes are *sourced* —
|
|
172
|
+
# never how they are used (the cipher, keyring, and `mfenc:v1` format are unchanged). `auto` (the
|
|
173
|
+
# default) is the env-then-DPAPI ladder, BYTE-IDENTICAL to the pre-seam behavior; `env`/`dpapi` pin a
|
|
174
|
+
# single built-in source; `aws_kms`|`azure_kv`|`gcp_kms`|`vault`|`pkcs11` are external HSM/KMS/Vault
|
|
175
|
+
# envelope-decrypt providers (lazy, optional extras — not built yet, fail closed if selected). This
|
|
176
|
+
# names a *provider*, not key material, so it is NOT a secret — it must never be added to
|
|
177
|
+
# `_FILE_SECRET_KEYS`. Unknown/unresolvable values fail closed at `open_store` (store/keyprovider.py).
|
|
178
|
+
key_provider: str = "auto"
|
|
179
|
+
|
|
180
|
+
# --- Server-DB backends (backend = "sqlserver" | "postgres") ------------
|
|
181
|
+
# These connection fields are shared by every server-database backend. SQL Server consumes them
|
|
182
|
+
# via an ODBC DSN (store/sqlserver.py); Postgres maps them onto asyncpg connection params
|
|
183
|
+
# (store/postgres.py). trust_server_certificate/encrypt drive the TLS posture identically.
|
|
184
|
+
server: str | None = None
|
|
185
|
+
# Default is SQL Server's port (1433); for the Postgres backend a left-at-default 1433 is treated
|
|
186
|
+
# as "use Postgres's conventional 5432" by the model_validator below, so a Postgres deployment that
|
|
187
|
+
# omits `port` still connects (set MEFOR_STORE_PORT explicitly to override either default).
|
|
188
|
+
port: int = 1433
|
|
189
|
+
database: str | None = None
|
|
190
|
+
auth: SqlAuth = SqlAuth.SQL
|
|
191
|
+
username: str | None = None
|
|
192
|
+
password: str | None = None # secret — supply via MEFOR_STORE_PASSWORD, never the file
|
|
193
|
+
encrypt: bool = True
|
|
194
|
+
trust_server_certificate: bool = False
|
|
195
|
+
pool_size: int = 5
|
|
196
|
+
connect_timeout: int = 15 # seconds
|
|
197
|
+
command_timeout: int = 30 # seconds
|
|
198
|
+
db_schema: str | None = (
|
|
199
|
+
None # 'db_schema' avoids shadowing BaseModel.schema; env: MEFOR_STORE_DB_SCHEMA
|
|
200
|
+
)
|
|
201
|
+
application_name: str = "messagefoundry"
|
|
202
|
+
# Inflight-row lease TTL (seconds) for the multi-node server-DB backends (Track B Step 2). When a
|
|
203
|
+
# worker claims a row it stamps owner + a lease_expires_at = now + this; a renew timer extends it
|
|
204
|
+
# while processing, and a leader sweep reclaims only rows whose lease has expired (so a crashed
|
|
205
|
+
# node's work is recovered without stealing a live sibling's in-flight rows). A shared server-DB
|
|
206
|
+
# field — harmless to SQL Server / SQLite, which don't lease and ignore it. The lease is wall-clock
|
|
207
|
+
# across nodes, so the no-theft guarantee assumes clocks are NTP-synced to well within this TTL;
|
|
208
|
+
# set it comfortably larger than expected clock skew + the renew interval.
|
|
209
|
+
lease_ttl_seconds: float = 60.0
|
|
210
|
+
|
|
211
|
+
@field_validator("lease_ttl_seconds")
|
|
212
|
+
@classmethod
|
|
213
|
+
def _positive_lease_ttl(cls, value: float) -> float:
|
|
214
|
+
if value <= 0:
|
|
215
|
+
raise ValueError("lease_ttl_seconds must be > 0")
|
|
216
|
+
return value
|
|
217
|
+
|
|
218
|
+
@field_validator("server", "database", "username", "application_name")
|
|
219
|
+
@classmethod
|
|
220
|
+
def _no_odbc_injection(cls, value: str | None) -> str | None:
|
|
221
|
+
"""Reject ODBC connection-string metacharacters in identity fields (STORE-5).
|
|
222
|
+
|
|
223
|
+
These go into the DSN; a ``;``/``{``/``}``/``=`` or newline could smuggle extra keywords
|
|
224
|
+
(e.g. downgrade TLS or redirect the server). Passwords legitimately contain these, so they
|
|
225
|
+
are brace-escaped at build time instead (see ``sqlserver.connection_string``)."""
|
|
226
|
+
if value is not None and any(ch in value for ch in ";{}=\r\n"):
|
|
227
|
+
raise ValueError(
|
|
228
|
+
"must not contain ';', '{', '}', '=', or newlines (ODBC injection risk)"
|
|
229
|
+
)
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
@model_validator(mode="after")
|
|
233
|
+
def _require_server_db_fields(self) -> "StoreSettings":
|
|
234
|
+
"""When a server-database backend (SQL Server or Postgres) is selected, its connection
|
|
235
|
+
essentials must be present. Both backends share the ``server``/``database`` (+ ``username``
|
|
236
|
+
for SQL auth) connection fields; Postgres additionally only supports SQL (username/password)
|
|
237
|
+
auth in this phase — INTEGRATED/ENTRA are SQL-Server-only until a Postgres equivalent
|
|
238
|
+
(Kerberos/IAM) is built."""
|
|
239
|
+
if self.backend in (StoreBackend.SQLSERVER, StoreBackend.POSTGRES):
|
|
240
|
+
label = self.backend.value
|
|
241
|
+
if self.backend is StoreBackend.POSTGRES:
|
|
242
|
+
if self.auth is not SqlAuth.SQL:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"postgres backend supports only auth='sql' (username + MEFOR_STORE_PASSWORD) "
|
|
245
|
+
f"in this phase, not auth={self.auth.value!r}"
|
|
246
|
+
)
|
|
247
|
+
if self.port == 1433:
|
|
248
|
+
# Left at the SQL-Server default → fall back to Postgres's conventional port so a
|
|
249
|
+
# Postgres deployment that omits `port` doesn't silently dial 1433 and fail.
|
|
250
|
+
self.port = 5432
|
|
251
|
+
missing = [name for name in ("server", "database") if getattr(self, name) is None]
|
|
252
|
+
if self.auth is SqlAuth.SQL and self.username is None:
|
|
253
|
+
missing.append("username") # SQL login needs a user (+ MEFOR_STORE_PASSWORD)
|
|
254
|
+
if missing:
|
|
255
|
+
raise ValueError(f"{label} backend requires: " + ", ".join(missing))
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class ApiSettings(_Section):
|
|
260
|
+
host: str = "127.0.0.1" # Phase 1 = localhost only
|
|
261
|
+
port: int = 8765
|
|
262
|
+
expose_docs: bool = False # serve /docs, /redoc, /openapi.json (off by default; widens surface)
|
|
263
|
+
# Extra directories /config/reload may load from, besides the startup --config dir. The loader
|
|
264
|
+
# EXECUTES Python from these, so list only admin-owned, trusted roots (e.g. an IDE staging dir).
|
|
265
|
+
config_reload_roots: list[str] = []
|
|
266
|
+
|
|
267
|
+
# Browser Origins allowed to open the /ws/stats WebSocket (ASVS 4.4.2). The only shipped client
|
|
268
|
+
# is the PySide6 desktop console, which sends NO Origin header, so the secure default is empty:
|
|
269
|
+
# a request that carries an Origin (i.e. a browser) is rejected unless its Origin is listed here.
|
|
270
|
+
ws_allowed_origins: list[str] = []
|
|
271
|
+
|
|
272
|
+
# --- In-process API/WebSocket TLS (WP-13a, ADR 0002) --------------------
|
|
273
|
+
# When tls_cert_file is set the engine terminates TLS in uvicorn, so the API serves https/wss and
|
|
274
|
+
# HSTS (already emitted on https) engages — the first-class way to bind off-loopback safely. PEM
|
|
275
|
+
# paths (not secrets); the key may be in the cert PEM (tls_key_file optional).
|
|
276
|
+
tls_cert_file: str | None = None
|
|
277
|
+
tls_key_file: str | None = None
|
|
278
|
+
# Passphrase for an encrypted private key. Secret — supply via MEFOR_API_TLS_KEY_PASSWORD, never
|
|
279
|
+
# the file.
|
|
280
|
+
tls_key_password: str | None = None
|
|
281
|
+
# Minimum negotiated TLS version floor (NIST SP 800-52r2: 1.2+). "1.2" or "1.3".
|
|
282
|
+
tls_min_version: str = "1.2"
|
|
283
|
+
# Optional OpenSSL cipher string (default = the interpreter's secure defaults).
|
|
284
|
+
tls_ciphers: str | None = None
|
|
285
|
+
# Optional CA bundle to verify CLIENT certs (mTLS for the console; opt-in, future).
|
|
286
|
+
tls_client_ca_file: str | None = None
|
|
287
|
+
|
|
288
|
+
# --- Reverse-proxy / upstream TLS termination (WP-15, ADR 0002) --------
|
|
289
|
+
# Proxy IPs whose X-Forwarded-For/-Proto headers are trusted (uvicorn forwarded_allow_ips). Empty =
|
|
290
|
+
# trust nothing (the audit/rate-limit source IP is then the direct TCP peer). Set this ONLY to the
|
|
291
|
+
# reverse proxy's address(es), or XFF spoofing returns.
|
|
292
|
+
trusted_proxies: list[str] = []
|
|
293
|
+
# Declare that a reverse proxy / load balancer terminates TLS in front of the engine. Lets a
|
|
294
|
+
# non-loopback bind satisfy the exposed-gate WITHOUT in-process TLS — but only when trusted_proxies
|
|
295
|
+
# is set (so the engine knows a terminator is really in front).
|
|
296
|
+
tls_terminated_upstream: bool = False
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def tls_enabled(self) -> bool:
|
|
300
|
+
"""Whether in-process API TLS is configured (a server cert is present)."""
|
|
301
|
+
return bool(self.tls_cert_file)
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def exposure_protected(self) -> bool:
|
|
305
|
+
"""Whether an off-loopback bind is safe: in-process TLS (WP-13a) OR a declared upstream TLS
|
|
306
|
+
terminator behind trusted proxies (WP-15)."""
|
|
307
|
+
return self.tls_enabled or (self.tls_terminated_upstream and bool(self.trusted_proxies))
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def is_loopback(self) -> bool:
|
|
311
|
+
"""Whether the API binds a loopback host — i.e. is **not** exposed off-box, so the exposed-bind
|
|
312
|
+
TLS gate and the MFA-at-exposure advisory (``serve``) don't apply. Treats ``127.0.0.1``,
|
|
313
|
+
``localhost`` and ``::1`` as loopback (a dual-stack box never spuriously counts as exposed)."""
|
|
314
|
+
return self.host in ("127.0.0.1", "localhost", "::1")
|
|
315
|
+
|
|
316
|
+
@field_validator("config_reload_roots", "ws_allowed_origins", "trusted_proxies", mode="before")
|
|
317
|
+
@classmethod
|
|
318
|
+
def _split_roots(cls, v: object) -> object:
|
|
319
|
+
# The env layer delivers list settings (MEFOR_API_CONFIG_RELOAD_ROOTS,
|
|
320
|
+
# MEFOR_API_WS_ALLOWED_ORIGINS, MEFOR_API_TRUSTED_PROXIES) as one string; split it on the
|
|
321
|
+
# platform path separator so these list-typed settings can be set via env (review low-12).
|
|
322
|
+
if isinstance(v, str):
|
|
323
|
+
return [p for p in v.split(os.pathsep) if p]
|
|
324
|
+
return v
|
|
325
|
+
|
|
326
|
+
@field_validator("tls_min_version")
|
|
327
|
+
@classmethod
|
|
328
|
+
def _check_tls_min_version(cls, v: str) -> str:
|
|
329
|
+
if v not in ("1.2", "1.3"):
|
|
330
|
+
raise ValueError(f"tls_min_version must be '1.2' or '1.3' (NIST 800-52r2), got {v!r}")
|
|
331
|
+
return v
|
|
332
|
+
|
|
333
|
+
@field_validator("tls_ciphers")
|
|
334
|
+
@classmethod
|
|
335
|
+
def _check_tls_ciphers(cls, v: str | None) -> str | None:
|
|
336
|
+
# Reject a cipher string that would admit a non-forward-secret key exchange (ASVS 11.6.2), so a
|
|
337
|
+
# misconfiguration can't widen the suite below the ECDHE policy. Fails loud at load, not bind.
|
|
338
|
+
return v if v is None else validate_tls_ciphers(v)
|
|
339
|
+
|
|
340
|
+
@model_validator(mode="after")
|
|
341
|
+
def _check_tls_cert_dependency(self) -> "ApiSettings":
|
|
342
|
+
# A key (or its passphrase / a client-CA) is meaningless without a server cert; require it so a
|
|
343
|
+
# half-configured TLS block fails loud at load, not at bind.
|
|
344
|
+
if (
|
|
345
|
+
self.tls_key_file or self.tls_key_password or self.tls_client_ca_file
|
|
346
|
+
) and not self.tls_cert_file:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
"tls_key_file / tls_key_password / tls_client_ca_file require [api].tls_cert_file"
|
|
349
|
+
)
|
|
350
|
+
# An upstream TLS terminator only satisfies the exposed-gate when the engine knows (and trusts)
|
|
351
|
+
# the proxy in front — otherwise it's an unverifiable claim that XFF could spoof.
|
|
352
|
+
if self.tls_terminated_upstream and not self.trusted_proxies:
|
|
353
|
+
raise ValueError("[api].tls_terminated_upstream requires [api].trusted_proxies")
|
|
354
|
+
return self
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class InboundSettings(_Section):
|
|
358
|
+
"""Inbound-connection defaults that are an operational, per-environment decision rather than
|
|
359
|
+
something authored in the message graph."""
|
|
360
|
+
|
|
361
|
+
# The network interface EVERY inbound MLLP/TCP listener binds to. Loopback by default; binding
|
|
362
|
+
# 0.0.0.0 exposes unauthenticated MLLP to the network, so it's a deliberate per-instance admin
|
|
363
|
+
# choice (DEV typically loopback, PROD a specific NIC or 0.0.0.0) — not a developer default.
|
|
364
|
+
# Connections never carry a host; they inherit this. See docs/CONNECTIONS.md.
|
|
365
|
+
bind_host: str = "127.0.0.1"
|
|
366
|
+
|
|
367
|
+
# Default ACK timing for every inbound (staged pipeline, ADR 0001): INGEST = ACK-on-receipt
|
|
368
|
+
# (the message is ACKed once durably committed to the ingress stage). A connection's own
|
|
369
|
+
# ack_after= overrides this. Step A supports only INGEST; 'delivered' (defer the ACK until
|
|
370
|
+
# delivery) is not yet implemented and is rejected at engine start.
|
|
371
|
+
ack_after: AckAfter = AckAfter.INGEST
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class DeliverySettings(_Section):
|
|
375
|
+
"""Global outbound-delivery defaults. An outbound connection that declares no ``retry=``/
|
|
376
|
+
``ordering=`` of its own inherits these; an explicit per-connection value overrides them
|
|
377
|
+
(resolution order: per-connection override > ``[delivery]`` global default > built-in). The
|
|
378
|
+
retry fields mirror :class:`~messagefoundry.config.models.RetryPolicy`; a test guards the sync.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
# Key names match docs/CONFIGURATION.md's [delivery] catalog (retry_-prefixed so the section can
|
|
382
|
+
# also grow non-retry keys like outbox_workers/dead_letter later). max_attempts unset (None) =
|
|
383
|
+
# retry forever (the conservative default — see RetryPolicy); set a finite value to dead-letter.
|
|
384
|
+
retry_max_attempts: int | None = None
|
|
385
|
+
retry_backoff_seconds: float = 5.0
|
|
386
|
+
retry_backoff_multiplier: float = 2.0
|
|
387
|
+
retry_max_backoff_seconds: float = 300.0
|
|
388
|
+
# Default queue ordering for every outbound (FIFO = strict in-order per connection).
|
|
389
|
+
ordering: OrderingMode = OrderingMode.FIFO
|
|
390
|
+
# What a delivery worker does on an internal/code error: continue (dead-letter + advance, default)
|
|
391
|
+
# or stop the connection and alert. Per-connection internal_error= overrides this.
|
|
392
|
+
internal_error: InternalErrorPolicy = InternalErrorPolicy.CONTINUE
|
|
393
|
+
# queue_buildup alert thresholds for every outbound. Mirror BuildupThreshold (a test guards the
|
|
394
|
+
# sync); buildup_max_depth unset = depth dimension off; per-connection buildup= overrides.
|
|
395
|
+
buildup_max_depth: int | None = None
|
|
396
|
+
buildup_max_oldest_seconds: float | None = 300.0
|
|
397
|
+
|
|
398
|
+
def retry_policy(self) -> RetryPolicy:
|
|
399
|
+
"""The global default :class:`RetryPolicy` an outbound inherits when it sets none."""
|
|
400
|
+
return RetryPolicy(
|
|
401
|
+
max_attempts=self.retry_max_attempts,
|
|
402
|
+
backoff_seconds=self.retry_backoff_seconds,
|
|
403
|
+
backoff_multiplier=self.retry_backoff_multiplier,
|
|
404
|
+
max_backoff_seconds=self.retry_max_backoff_seconds,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def buildup_threshold(self) -> BuildupThreshold:
|
|
408
|
+
"""The global default :class:`BuildupThreshold` an outbound inherits when it sets none."""
|
|
409
|
+
return BuildupThreshold(
|
|
410
|
+
max_depth=self.buildup_max_depth,
|
|
411
|
+
max_oldest_seconds=self.buildup_max_oldest_seconds,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class PipelineSettings(_Section):
|
|
416
|
+
"""Staged-pipeline tunables (ADR 0013 Increment 2). ``max_correlation_depth`` bounds re-ingress
|
|
417
|
+
loops: a re-ingressed message at this correlation depth still routes, but the next hop (depth+1)
|
|
418
|
+
dead-letters its work-row and the origin is marked ``ERROR``. Coarse by design (it bounds total work,
|
|
419
|
+
not topology) — a chain that legitimately bounces A→B→A a few times needs headroom; the default 8 is
|
|
420
|
+
safe for typical request→response→route feeds. Floor of 1 (a value of 0 would dead-letter every
|
|
421
|
+
re-ingress)."""
|
|
422
|
+
|
|
423
|
+
max_correlation_depth: int = Field(default=8, ge=1)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class EnvironmentsSettings(_Section):
|
|
427
|
+
"""Where the per-environment **values** (``env()`` lookups in the message graph) live.
|
|
428
|
+
|
|
429
|
+
The ACTIVE environment is the single cross-cutting selector ``[ai].environment`` (a free-form
|
|
430
|
+
name, ADR 0017); this section only locates the value files. Each environment has a ``<env>.toml``
|
|
431
|
+
flat table under ``dir`` for non-secret values (versioned), overlaid by ``MEFOR_VALUE_<KEY>`` env
|
|
432
|
+
vars for secrets. See docs/CONFIGURATION.md."""
|
|
433
|
+
|
|
434
|
+
dir: str = "environments" # directory of <env>.toml value files, relative to base_dir (below)
|
|
435
|
+
# Anchor that ``dir`` (and thus ``environments/<env>.toml``) resolves against. Empty (default) =
|
|
436
|
+
# the process working directory — the original behavior, so an existing deployment is unchanged.
|
|
437
|
+
# Set it to the config-repo root (a standalone config repo keeps environments/ at its root, a
|
|
438
|
+
# sibling of the --config dir) so env-value resolution no longer depends on where serve was
|
|
439
|
+
# launched — important under NSSM, whose working dir is rarely the repo. A relative value is taken
|
|
440
|
+
# against the working dir; an absolute value is used as-is (on Windows it must be drive-qualified,
|
|
441
|
+
# e.g. C:/repo — a leading-slash "/repo" is drive-relative and still inherits the launch drive).
|
|
442
|
+
# Overridable per run via ``serve --project-root``. See resolve_values_base_dir + docs/CONFIGURATION.md.
|
|
443
|
+
base_dir: str = ""
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class LogFormat(str, Enum):
|
|
447
|
+
TEXT = "text" # human-readable (the default; stdout unchanged)
|
|
448
|
+
JSON = "json" # one JSON object per line — structured for a log shipper / SIEM
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class SyslogProtocol(str, Enum):
|
|
452
|
+
UDP = "udp" # RFC 5426; fire-and-forget, never blocks the engine (the default)
|
|
453
|
+
TCP = (
|
|
454
|
+
"tcp" # RFC 6587; connection-oriented (down-at-startup skipped; runtime stall bounded by a
|
|
455
|
+
)
|
|
456
|
+
# socket timeout so a wedged collector can't block the event loop — synchronous send)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class LoggingSettings(_Section):
|
|
460
|
+
"""``[logging]`` — log level, stdout rendering, and optional off-box forwarding (sec-offbox-log).
|
|
461
|
+
|
|
462
|
+
PHI redaction + control-char scrubbing are applied to **every** sink (stdout and the forwarder) by
|
|
463
|
+
``logging_setup.configure_logging``, so structured output and off-box shipping never weaken the
|
|
464
|
+
"never log full PHI bodies" guarantee (docs/PHI.md §7)."""
|
|
465
|
+
|
|
466
|
+
level: str = "INFO"
|
|
467
|
+
# stdout rendering: "text" (default, unchanged) or "json" (one JSON object per line, friendlier to
|
|
468
|
+
# a log shipper tailing NSSM's captured stdout).
|
|
469
|
+
format: LogFormat = LogFormat.TEXT
|
|
470
|
+
|
|
471
|
+
# --- Off-box forwarding to a syslog/SIEM collector (ASVS 16.x) ----------
|
|
472
|
+
# Ship a copy of every log record to a remote syslog collector so log evidence survives a host
|
|
473
|
+
# compromise (the local audit_log is tamper-evident, but lives on the same host). Off by default.
|
|
474
|
+
# PHI redaction applies to the forwarded stream exactly as to stdout, but the syslog transport
|
|
475
|
+
# itself is plaintext — terminate it at a local TLS-forwarding agent or keep it on a trusted
|
|
476
|
+
# management network (see docs/SECURITY.md / docs/PHI.md). The forwarder never blocks the engine
|
|
477
|
+
# indefinitely: UDP is fire-and-forget; a TCP collector unreachable at startup is skipped (warns),
|
|
478
|
+
# and a runtime stall is bounded by a socket timeout (record dropped). Synchronous send — for a
|
|
479
|
+
# high-volume feed prefer UDP or a local agent.
|
|
480
|
+
forward_enabled: bool = False
|
|
481
|
+
forward_host: str | None = None
|
|
482
|
+
forward_port: int = 514
|
|
483
|
+
forward_protocol: SyslogProtocol = SyslogProtocol.UDP
|
|
484
|
+
# Wire format sent off-box, independent of the stdout `format`. JSON is the SIEM-friendly default and
|
|
485
|
+
# guarantees one record per line; "text" framing is best-effort (a multi-line traceback spans lines).
|
|
486
|
+
forward_format: LogFormat = LogFormat.JSON
|
|
487
|
+
|
|
488
|
+
@field_validator("level")
|
|
489
|
+
@classmethod
|
|
490
|
+
def _normalize_level(cls, value: str) -> str:
|
|
491
|
+
upper = value.upper()
|
|
492
|
+
if upper not in LOG_LEVELS:
|
|
493
|
+
raise ValueError(
|
|
494
|
+
f"invalid log level {value!r}; expected one of {', '.join(LOG_LEVELS)}"
|
|
495
|
+
)
|
|
496
|
+
return upper
|
|
497
|
+
|
|
498
|
+
@field_validator("forward_port")
|
|
499
|
+
@classmethod
|
|
500
|
+
def _check_forward_port(cls, value: int) -> int:
|
|
501
|
+
if not 1 <= value <= 65535:
|
|
502
|
+
raise ValueError("[logging].forward_port must be between 1 and 65535")
|
|
503
|
+
return value
|
|
504
|
+
|
|
505
|
+
@model_validator(mode="after")
|
|
506
|
+
def _forward_needs_host(self) -> "LoggingSettings":
|
|
507
|
+
if self.forward_enabled and not self.forward_host:
|
|
508
|
+
raise ValueError(
|
|
509
|
+
"[logging].forward_enabled requires [logging].forward_host (the syslog/SIEM collector)"
|
|
510
|
+
)
|
|
511
|
+
return self
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class ReferenceSettings(_Section):
|
|
515
|
+
"""``[reference]`` — managed, versioned, read-only lookup snapshots (ADR 0006 Tier 1).
|
|
516
|
+
|
|
517
|
+
Enforced by the engine's :class:`~messagefoundry.pipeline.reference_sync.ReferenceSyncRunner`.
|
|
518
|
+
Reference sets are declared in wiring modules with ``Reference(name, source=…)`` and materialized
|
|
519
|
+
OFF the message path; a transform reads them purely via ``reference("name").get(key)``. The runner
|
|
520
|
+
is a no-op when no sets are declared, so these defaults are safe for an existing deployment."""
|
|
521
|
+
|
|
522
|
+
# Base cadence (seconds) the sync loop ticks at; each set re-materializes when its own
|
|
523
|
+
# refresh_seconds is due. Must be > 0.
|
|
524
|
+
refresh_interval_seconds: float = 3600.0
|
|
525
|
+
# Sync every declared set once at startup, before inbound listeners begin serving, so a transform's
|
|
526
|
+
# reference(...) resolves on the very first message. Strongly recommended on.
|
|
527
|
+
sync_on_startup: bool = True
|
|
528
|
+
# Reserved freshness guard (seconds; 0 = off): alert/refuse when the active snapshot is older than
|
|
529
|
+
# this. Not enforced in Tier 1 — accepted so a forward-looking file still loads.
|
|
530
|
+
max_staleness_seconds: float = 0.0
|
|
531
|
+
|
|
532
|
+
@field_validator("refresh_interval_seconds")
|
|
533
|
+
@classmethod
|
|
534
|
+
def _positive_interval(cls, value: float) -> float:
|
|
535
|
+
if value <= 0:
|
|
536
|
+
raise ValueError("refresh_interval_seconds must be > 0")
|
|
537
|
+
return value
|
|
538
|
+
|
|
539
|
+
@field_validator("max_staleness_seconds")
|
|
540
|
+
@classmethod
|
|
541
|
+
def _non_negative_staleness(cls, value: float) -> float:
|
|
542
|
+
if value < 0:
|
|
543
|
+
raise ValueError("max_staleness_seconds must be >= 0 (0 = off)")
|
|
544
|
+
return value
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
class RetentionSettings(_Section):
|
|
548
|
+
"""``[retention]`` — data-retention + SQLite maintenance (PHI.md §8, ASVS 14.2.x).
|
|
549
|
+
|
|
550
|
+
Enforced by the engine's :class:`~messagefoundry.pipeline.retention.RetentionRunner`. Every window
|
|
551
|
+
defaults to ``0``/``""`` = keep/off, so an existing deployment is unchanged until an operator opts
|
|
552
|
+
in. A purge **NULLs the PHI *body*** of a message/dead-letter while **keeping its metadata row**
|
|
553
|
+
(counts + disposition + audit stay intact — the Mirth Data-Pruner pattern); it never deletes a
|
|
554
|
+
``messages`` row, and never touches a body still in flight (at-least-once is preserved).
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
# Past N days, null inbound bodies (raw/summary/error) of fully-resolved messages, keeping the
|
|
558
|
+
# metadata row. 0 = keep forever.
|
|
559
|
+
messages_days: int = 0
|
|
560
|
+
# Past N days, null the bodies of DEAD (dead-lettered) outbound rows — their own window because a
|
|
561
|
+
# dead row stays replayable until its body is purged. 0 = keep forever.
|
|
562
|
+
dead_letter_days: int = 0
|
|
563
|
+
# Past N days, DELETE transform-state entries (ADR 0005) last written before the cutoff — keeps the
|
|
564
|
+
# in-memory state cache + table bounded. A simple global age purge; per-namespace policy is a
|
|
565
|
+
# follow-up. 0 = keep forever (the default — state correlation data is opt-in to purge).
|
|
566
|
+
state_max_age_days: int = 0
|
|
567
|
+
# Audit-log retention. RESERVED / not enforced: the audit_log is a tamper-evident hash chain and
|
|
568
|
+
# HIPAA expects ~6-year retention, so audit is keep-forever by design here; archive-first pruning
|
|
569
|
+
# is a tracked follow-up. Accepted (not rejected) so a forward-looking file still loads.
|
|
570
|
+
audit_days: int = 0
|
|
571
|
+
# Warn (WARNING log + AlertSink storage_threshold) when the DB file (+ -wal/-shm) exceeds this
|
|
572
|
+
# many MB. 0 = off. Advisory only — never auto-deletes.
|
|
573
|
+
max_db_mb: int = 0
|
|
574
|
+
# How often the purge/maintenance loop runs a pass (seconds).
|
|
575
|
+
purge_interval_seconds: float = 3600.0
|
|
576
|
+
# PRAGMA wal_checkpoint(TRUNCATE) cadence in seconds (SQLite). 0 = off — rely on SQLite's
|
|
577
|
+
# auto-checkpoint. Evaluated once per purge pass, so a value below purge_interval_seconds is
|
|
578
|
+
# effectively rounded up to it.
|
|
579
|
+
wal_checkpoint_seconds: float = 0.0
|
|
580
|
+
# Daily local clock time "HH:MM" at which to run VACUUM (SQLite; reclaims space freed by purges).
|
|
581
|
+
# "" = off. A daily off-peak time, not a cron expression, to avoid a new dependency — VACUUM holds
|
|
582
|
+
# a write lock on the whole DB while it runs, so it is off by default and meant for a quiet window.
|
|
583
|
+
vacuum_at: str = ""
|
|
584
|
+
|
|
585
|
+
@field_validator(
|
|
586
|
+
"messages_days", "dead_letter_days", "audit_days", "max_db_mb", "state_max_age_days"
|
|
587
|
+
)
|
|
588
|
+
@classmethod
|
|
589
|
+
def _non_negative_days(cls, value: int) -> int:
|
|
590
|
+
if value < 0:
|
|
591
|
+
raise ValueError("retention windows/thresholds must be >= 0 (0 = keep/off)")
|
|
592
|
+
return value
|
|
593
|
+
|
|
594
|
+
@field_validator("purge_interval_seconds")
|
|
595
|
+
@classmethod
|
|
596
|
+
def _positive_interval(cls, value: float) -> float:
|
|
597
|
+
if value <= 0:
|
|
598
|
+
raise ValueError("purge_interval_seconds must be > 0")
|
|
599
|
+
return value
|
|
600
|
+
|
|
601
|
+
@field_validator("wal_checkpoint_seconds")
|
|
602
|
+
@classmethod
|
|
603
|
+
def _non_negative_wal(cls, value: float) -> float:
|
|
604
|
+
if value < 0:
|
|
605
|
+
raise ValueError("wal_checkpoint_seconds must be >= 0 (0 = off)")
|
|
606
|
+
return value
|
|
607
|
+
|
|
608
|
+
@field_validator("vacuum_at")
|
|
609
|
+
@classmethod
|
|
610
|
+
def _valid_clock_time(cls, value: str) -> str:
|
|
611
|
+
value = value.strip()
|
|
612
|
+
if value and cls._parse_clock(value) is None:
|
|
613
|
+
raise ValueError(f"vacuum_at must be empty or 'HH:MM' (24h), got {value!r}")
|
|
614
|
+
return value
|
|
615
|
+
|
|
616
|
+
@staticmethod
|
|
617
|
+
def _parse_clock(value: str) -> tuple[int, int] | None:
|
|
618
|
+
m = re.fullmatch(r"(\d{1,2}):(\d{2})", value.strip())
|
|
619
|
+
if not m:
|
|
620
|
+
return None
|
|
621
|
+
hour, minute = int(m.group(1)), int(m.group(2))
|
|
622
|
+
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
|
623
|
+
return hour, minute
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
def vacuum_time(self) -> tuple[int, int] | None:
|
|
627
|
+
"""The configured daily VACUUM time as ``(hour, minute)`` local, or ``None`` when disabled."""
|
|
628
|
+
return self._parse_clock(self.vacuum_at) if self.vacuum_at else None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class AuthSettings(_Section):
|
|
632
|
+
"""Authentication + RBAC knobs. Secrets (the AD bind password) come from env, never the file."""
|
|
633
|
+
|
|
634
|
+
# Authentication is required by default; this flag exists only for the embedding/test path.
|
|
635
|
+
enabled: bool = True
|
|
636
|
+
session_idle_timeout_minutes: int = 30
|
|
637
|
+
session_absolute_hours: int = 12
|
|
638
|
+
# Cap concurrent sessions per user (ASVS 7.1.2); a login beyond the cap revokes the user's oldest
|
|
639
|
+
# active session. 0 = unlimited. Default 5 (WP-10): generous for a few devices/console instances.
|
|
640
|
+
max_sessions_per_user: int = 5
|
|
641
|
+
# Step-up re-verification (ASVS 7.5.3): a highly sensitive operation requires the session to have
|
|
642
|
+
# re-verified its credential — at login or via POST /me/reauth — within this many seconds. The
|
|
643
|
+
# initial login counts as the first verification (sudo-timestamp model). Default 5 minutes.
|
|
644
|
+
step_up_max_age_seconds: int = 300
|
|
645
|
+
|
|
646
|
+
# Multi-factor authentication (WP-14, ADR 0002 §3; ASVS 6.3.3) — a native RFC 6238 TOTP second
|
|
647
|
+
# factor for LOCAL accounts. AD/Kerberos MFA is delegated to the directory (Entra Conditional
|
|
648
|
+
# Access / an MFA proxy), so a directory login is never prompted for an engine TOTP. When
|
|
649
|
+
# require_mfa is on, a user holding the Administrator role MUST enroll TOTP and satisfy it before
|
|
650
|
+
# any step-up (sensitive) operation; non-admins may opt in voluntarily. Default OFF preserves
|
|
651
|
+
# today's loopback behavior byte-for-byte (on the 127.0.0.1 bind 6.3.3 is deferred-by-design with
|
|
652
|
+
# the single-trusted-host compensating control). An off-loopback bind that serves local accounts
|
|
653
|
+
# SHOULD turn this on; ``serve`` now makes that posture explicit (sec-mfa-on) — on an exposed
|
|
654
|
+
# (non-loopback) PHI bind with this off it **refuses to start** on a production instance and
|
|
655
|
+
# **warns** on a non-production one, mirroring the keyless-store / open-egress startup gates (see
|
|
656
|
+
# __main__._serve), so MFA can't be silently skipped at exposure. Scope: it gates **step-up
|
|
657
|
+
# (sensitive) operations** for the Administrator role — it is NOT a gate on every authenticated PHI
|
|
658
|
+
# read (those stay behind RBAC + the PHI-read throttle).
|
|
659
|
+
require_mfa: bool = False
|
|
660
|
+
# How many single-use recovery codes are minted at enrollment (the lost-authenticator escape
|
|
661
|
+
# hatch). 0 disables recovery codes (an admin reset is then the only recovery path).
|
|
662
|
+
mfa_recovery_code_count: int = 10
|
|
663
|
+
# Admin-interface defense-in-depth contextual-risk signal (WP-L3-13, ADR 0002; ASVS 8.4.2). When
|
|
664
|
+
# on, a step-up (sensitive admin) request arriving from a client IP that differs from the one the
|
|
665
|
+
# session last verified from is treated as higher-risk: it emits an audit + out-of-band notice and
|
|
666
|
+
# FORCES a fresh step-up (a successful re-verify re-anchors the session to the new IP). It is
|
|
667
|
+
# advisory + step-up-forcing only — it NEVER changes an RBAC allow/deny and never blocks the
|
|
668
|
+
# non-admin request path. Default OFF preserves today's behavior byte-for-byte; and even on, a
|
|
669
|
+
# single-host loopback deployment never trips it (loopback addresses 127.0.0.1 and ::1 are treated
|
|
670
|
+
# as the same host, so a dual-stack box doesn't spuriously fire).
|
|
671
|
+
# An off-loopback bind serving admins SHOULD turn this on (operator/runbook responsibility).
|
|
672
|
+
admin_new_ip_step_up: bool = False
|
|
673
|
+
|
|
674
|
+
# Local-password policy — ASVS 5.0-aligned (WP-3): length-first, no mandatory composition.
|
|
675
|
+
password_min_length: int = 15
|
|
676
|
+
# Character-class requirements are OFF by default (ASVS forbids mandatory composition) but kept
|
|
677
|
+
# as opt-in knobs for deployments with a legacy standard that still mandates them.
|
|
678
|
+
password_require_uppercase: bool = False
|
|
679
|
+
password_require_lowercase: bool = False
|
|
680
|
+
password_require_digit: bool = False
|
|
681
|
+
password_require_symbol: bool = False
|
|
682
|
+
password_check_breached: bool = True # reject known common/breached passwords (offline corpus)
|
|
683
|
+
password_check_context: bool = True # reject passwords containing app/vendor/HL7 terms
|
|
684
|
+
password_check_username: bool = (
|
|
685
|
+
True # reject passwords containing the user's own username (6.2.11)
|
|
686
|
+
)
|
|
687
|
+
# Optional path to a larger offline breach corpus that augments the bundled top-10k list (6.2.12):
|
|
688
|
+
# a plaintext list OR an HIBP-style SHA-1-hash export (HASH[:count] lines, auto-detected). Fully
|
|
689
|
+
# offline — no live HIBP call. Use a curated subset, not the full ~40 GB HIBP set (loaded into memory).
|
|
690
|
+
password_breach_corpus_file: str | None = None
|
|
691
|
+
lockout_threshold: int = 5 # consecutive failed logins before the account locks
|
|
692
|
+
lockout_minutes: int = 15
|
|
693
|
+
# First-run bootstrap admin: auto-disabled once a second administrator exists, and (if still
|
|
694
|
+
# unclaimed — never password-changed) disabled this many hours after creation. 0 = no time expiry.
|
|
695
|
+
bootstrap_expiry_hours: int = 72
|
|
696
|
+
|
|
697
|
+
# Active Directory / LDAP. The bind password is a secret: MEFOR_AUTH_AD_BIND_PASSWORD.
|
|
698
|
+
ad_enabled: bool = False
|
|
699
|
+
ad_server: str | None = None # e.g. ldaps://dc1.example.com:636
|
|
700
|
+
ad_domain: str | None = None # e.g. example.com (UPN suffix)
|
|
701
|
+
ad_user_search_base: str | None = None
|
|
702
|
+
ad_group_search_base: str | None = None
|
|
703
|
+
ad_bind_dn: str | None = None # service-account DN used to look users up
|
|
704
|
+
ad_bind_password: str | None = None # secret — supply via env only
|
|
705
|
+
ad_use_nested_groups: bool = True # resolve nested groups via LDAP_MATCHING_RULE_IN_CHAIN
|
|
706
|
+
ad_tls_verify: bool = True
|
|
707
|
+
ad_tls_ca_cert_file: str | None = None # trust an internal CA without disabling verification
|
|
708
|
+
ad_allow_insecure_ldap: bool = False # explicit opt-in to a non-ldaps:// bind (trusted-net dev)
|
|
709
|
+
|
|
710
|
+
# Windows SSO (Kerberos/SPNEGO) — passwordless login from a domain-joined client.
|
|
711
|
+
# Experimental; off by default. Not a supported v0.1 feature — hardening targeted for 0.2.
|
|
712
|
+
kerberos_enabled: bool = False
|
|
713
|
+
kerberos_spn: str | None = None # e.g. HTTP/host.example.com
|
|
714
|
+
|
|
715
|
+
# Login rate limiting (AUTH-RATE) — in-process sliding window in front of the per-account
|
|
716
|
+
# lockout: bounds password-spray + argon2 CPU-burn. In-process only; an exposed/multi-host
|
|
717
|
+
# deployment must also front the API with a proxy/WAF limiter. None/0 disables a limit.
|
|
718
|
+
login_rate_limit_enabled: bool = True
|
|
719
|
+
login_rate_limit_per_ip: int = 10 # max attempts per client IP per window
|
|
720
|
+
login_rate_limit_global: int = 60 # max attempts across all clients per window
|
|
721
|
+
login_rate_limit_window_seconds: float = 60.0
|
|
722
|
+
|
|
723
|
+
# Anti-automation on the authenticated PHI-read endpoints (WP-8, ASVS 2.4.1): a per-actor sliding
|
|
724
|
+
# window over /messages, /messages/{id}, /dead-letters — bounds scripted PHI harvesting on top of
|
|
725
|
+
# pagination + access auditing. Generous by default (clears console/human use); in-process only,
|
|
726
|
+
# so an exposed deployment must also front a proxy/WAF limiter. 0 disables that dimension.
|
|
727
|
+
phi_read_rate_limit_enabled: bool = True
|
|
728
|
+
phi_read_rate_limit_per_actor: int = 120 # max PHI reads per user per window
|
|
729
|
+
phi_read_rate_limit_global: int = 0 # max PHI reads across all users per window (0 = off)
|
|
730
|
+
phi_read_rate_limit_window_seconds: float = 60.0
|
|
731
|
+
|
|
732
|
+
# Out-of-band user notification of security events (ASVS 6.3.5/6.3.7): email the affected user on
|
|
733
|
+
# lockout / first-success-after-failures / password/email/role/disable changes. Email requires the
|
|
734
|
+
# [alerts] SMTP transport to be configured (no SMTP → email is skipped); the audited
|
|
735
|
+
# /me/security-events feed records these regardless of this toggle.
|
|
736
|
+
notify_security_events: bool = True
|
|
737
|
+
|
|
738
|
+
@field_validator("mfa_recovery_code_count")
|
|
739
|
+
@classmethod
|
|
740
|
+
def _check_recovery_count(cls, value: int) -> int:
|
|
741
|
+
if not 0 <= value <= 50:
|
|
742
|
+
raise ValueError("mfa_recovery_code_count must be between 0 and 50 (0 = disabled)")
|
|
743
|
+
return value
|
|
744
|
+
|
|
745
|
+
@model_validator(mode="after")
|
|
746
|
+
def _require_ad_fields(self) -> "AuthSettings":
|
|
747
|
+
"""AD/SSO need their connection essentials present when enabled."""
|
|
748
|
+
if self.ad_enabled and (self.ad_server is None or self.ad_user_search_base is None):
|
|
749
|
+
raise ValueError("ad_enabled requires: ad_server, ad_user_search_base")
|
|
750
|
+
if (
|
|
751
|
+
self.ad_enabled
|
|
752
|
+
and self.ad_server is not None
|
|
753
|
+
and not self.ad_server.lower().startswith("ldaps://")
|
|
754
|
+
and not self.ad_allow_insecure_ldap
|
|
755
|
+
):
|
|
756
|
+
raise ValueError(
|
|
757
|
+
"ad_enabled requires an ldaps:// ad_server (credentials go over a SIMPLE bind); "
|
|
758
|
+
"set ad_allow_insecure_ldap=true only for a trusted-network dev override"
|
|
759
|
+
)
|
|
760
|
+
if self.ad_enabled and (self.ad_bind_dn is None or self.ad_bind_password is None):
|
|
761
|
+
raise ValueError(
|
|
762
|
+
"ad_enabled requires a service account: ad_bind_dn and ad_bind_password "
|
|
763
|
+
"(supply the password via MEFOR_AUTH_AD_BIND_PASSWORD)"
|
|
764
|
+
)
|
|
765
|
+
if self.kerberos_enabled and not self.ad_enabled:
|
|
766
|
+
raise ValueError("kerberos_enabled requires ad_enabled (SSO resolves roles via AD)")
|
|
767
|
+
return self
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
#: Characters permitted in a free-form environment NAME (it selects ``environments/<name>.toml``, so
|
|
771
|
+
#: it must be a safe single path segment).
|
|
772
|
+
_ENV_NAME_ALLOWED = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
|
|
773
|
+
|
|
774
|
+
#: Built-in environment names whose security posture (data_class, production) is derived when
|
|
775
|
+
#: ``[ai].data_class`` / ``[ai].production`` are left unset — back-compat with the original
|
|
776
|
+
#: dev/staging/prod tiers. A CUSTOM name must set posture explicitly (it is never inferred from a
|
|
777
|
+
#: free-form string), so a 'test'/'poc' instance can never default permissive (ADR 0017).
|
|
778
|
+
_KNOWN_ENV_POSTURE: dict[str, tuple[DataClass, bool]] = {
|
|
779
|
+
"dev": (DataClass.SYNTHETIC, False),
|
|
780
|
+
"staging": (DataClass.PHI, False),
|
|
781
|
+
"prod": (DataClass.PHI, True),
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
class AiSettings(_Section):
|
|
786
|
+
"""Central AI-assistance policy plus the instance's active **environment name** and security
|
|
787
|
+
**posture**. The two AI axes (mode + data scope) are bounded by the production-posture ceiling
|
|
788
|
+
computed by :func:`~messagefoundry.config.ai_policy.resolve_effective_policy` (the API endpoint
|
|
789
|
+
and the ``ai-policy`` CLI both clamp these before serving them). See docs/AI.md.
|
|
790
|
+
|
|
791
|
+
``environment`` is the **free-form** active-environment name (ADR 0017): it selects
|
|
792
|
+
``environments/<name>.toml`` and is what ``current_environment()`` returns. It has **no default** —
|
|
793
|
+
``serve`` requires it, so a missing env can never silently resolve another environment's
|
|
794
|
+
values/secrets. ``data_class`` / ``production`` are the explicit security posture, **decoupled from
|
|
795
|
+
the name**: for the built-in names dev/staging/prod they are derived when unset, but a custom name
|
|
796
|
+
must set them (see :meth:`require_posture`)."""
|
|
797
|
+
|
|
798
|
+
mode: AiMode = AiMode.BYO
|
|
799
|
+
data_scope: AiDataScope = AiDataScope.CODE_ONLY
|
|
800
|
+
# Free-form active-environment NAME (ADR 0017): selects environments/<name>.toml + what
|
|
801
|
+
# current_environment() returns. No default — serve requires it (a missing env must never silently
|
|
802
|
+
# resolve another env's values/secrets).
|
|
803
|
+
environment: str | None = None
|
|
804
|
+
# Explicit security POSTURE, decoupled from the name. Unset is derived from a built-in name
|
|
805
|
+
# (dev->synthetic/non-prod, staging->phi/non-prod, prod->phi/prod); a custom name must set them.
|
|
806
|
+
data_class: DataClass | None = None
|
|
807
|
+
production: bool | None = None
|
|
808
|
+
|
|
809
|
+
# --- forward-compat (accepted-but-UNUSED in this MVP; for the P1 engine broker) ----------
|
|
810
|
+
# These describe a managed provider connection. They parse so a forward-looking config loads,
|
|
811
|
+
# but nothing in this build reads them — managed modes are not yet implemented.
|
|
812
|
+
provider: str = "claude"
|
|
813
|
+
model: str = "claude-opus-4-8"
|
|
814
|
+
baa_attested: bool = False
|
|
815
|
+
endpoint: str | None = None
|
|
816
|
+
|
|
817
|
+
@field_validator("environment")
|
|
818
|
+
@classmethod
|
|
819
|
+
def _valid_environment_name(cls, v: str | None) -> str | None:
|
|
820
|
+
# The name becomes a filename segment (environments/<name>.toml), so keep it a simple token.
|
|
821
|
+
if v is not None and (not v or not set(v) <= _ENV_NAME_ALLOWED):
|
|
822
|
+
raise ValueError(
|
|
823
|
+
"[ai].environment must be a non-empty name of letters, digits, '.', '_' or '-' "
|
|
824
|
+
"(it selects environments/<name>.toml)"
|
|
825
|
+
)
|
|
826
|
+
return v
|
|
827
|
+
|
|
828
|
+
def derived_posture(self) -> tuple[DataClass | None, bool | None]:
|
|
829
|
+
"""``(data_class, production)`` with built-in-name derivation applied where each is unset.
|
|
830
|
+
|
|
831
|
+
Either element may still be ``None`` when a *custom* environment name leaves it unset — callers
|
|
832
|
+
that need a definite posture use :meth:`require_posture` (fail-closed) or default the missing
|
|
833
|
+
``production`` to ``True`` (strictest ceiling) for an advisory read."""
|
|
834
|
+
dc, prod = self.data_class, self.production
|
|
835
|
+
known = _KNOWN_ENV_POSTURE.get(self.environment or "")
|
|
836
|
+
if known is not None:
|
|
837
|
+
if dc is None:
|
|
838
|
+
dc = known[0]
|
|
839
|
+
if prod is None:
|
|
840
|
+
prod = known[1]
|
|
841
|
+
return dc, prod
|
|
842
|
+
|
|
843
|
+
def require_posture(self) -> tuple[DataClass, bool]:
|
|
844
|
+
"""The fail-closed ``(data_class, production)`` posture; raises ``ValueError`` when a custom or
|
|
845
|
+
unset environment name has no explicit posture. Used at ``serve`` so a custom env never defaults
|
|
846
|
+
permissive (ADR 0017)."""
|
|
847
|
+
dc, prod = self.derived_posture()
|
|
848
|
+
if dc is None or prod is None:
|
|
849
|
+
raise ValueError(
|
|
850
|
+
f"environment {self.environment!r} has no built-in security posture (not one of "
|
|
851
|
+
"dev/staging/prod); set [ai].data_class (synthetic|phi) and [ai].production "
|
|
852
|
+
"(true|false) explicitly"
|
|
853
|
+
)
|
|
854
|
+
return dc, prod
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
class EgressSettings(_Section):
|
|
858
|
+
"""``[egress]`` — fail-closed outbound destination allowlist (WP-11c; ASVS 13.2.4/13.2.5/14.2.3).
|
|
859
|
+
|
|
860
|
+
Bounds where the engine may **send** PHI, so a fat-fingered or hostile outbound destination can't
|
|
861
|
+
exfiltrate it. Each list is **opt-in**: empty = unrestricted (today's behavior); once a transport's
|
|
862
|
+
list is set, a destination of that transport not on it is **refused at config load/reload**
|
|
863
|
+
(fail-closed), checked against the resolved (``env()``-substituted) destination. The webhook/SMTP
|
|
864
|
+
*alert* sinks carry no PHI bodies and keep their own ``[alerts]`` host allowlists.
|
|
865
|
+
|
|
866
|
+
Set ``deny_by_default = true`` to flip the whole posture fail-closed: a transport with an **empty**
|
|
867
|
+
allowlist then refuses *every* destination of that type (so each permitted destination must be
|
|
868
|
+
listed). Default false keeps the per-list opt-in behavior.
|
|
869
|
+
"""
|
|
870
|
+
|
|
871
|
+
# Allowed MLLP outbound destinations: each entry is "host" (any port) or "host:port".
|
|
872
|
+
allowed_mllp: list[str] = []
|
|
873
|
+
# Allowed raw-TCP outbound destinations: each entry is "host" (any port) or "host:port".
|
|
874
|
+
allowed_tcp: list[str] = []
|
|
875
|
+
# Allowed File outbound directories: a destination's directory must resolve at/under one of these.
|
|
876
|
+
allowed_file_dirs: list[str] = []
|
|
877
|
+
# Allowed REST/SOAP (HTTP) outbound hosts: each entry is "host" (any port) or "host:port".
|
|
878
|
+
allowed_http: list[str] = []
|
|
879
|
+
# Allowed DATABASE outbound servers: each entry is "host" (any port) or "host:port".
|
|
880
|
+
allowed_db: list[str] = []
|
|
881
|
+
# Allowed REMOTEFILE (SFTP/FTP/FTPS) hosts — gates the connector in BOTH directions (the source
|
|
882
|
+
# dials out to poll, the destination dials out to upload). Each entry is "host" or "host:port".
|
|
883
|
+
allowed_remote: list[str] = []
|
|
884
|
+
|
|
885
|
+
# Opt-in deny-by-default (Q5b): when true, a transport with an EMPTY allowlist refuses every
|
|
886
|
+
# destination of that type instead of allowing any. A global on-ramp to fail-closed egress without
|
|
887
|
+
# having to enumerate one list just to flip the posture; pairs with the prod/staging open-egress
|
|
888
|
+
# startup advisory. Default false = the per-list opt-in behavior above (empty = unrestricted).
|
|
889
|
+
deny_by_default: bool = False
|
|
890
|
+
|
|
891
|
+
@field_validator(
|
|
892
|
+
"allowed_mllp",
|
|
893
|
+
"allowed_tcp",
|
|
894
|
+
"allowed_file_dirs",
|
|
895
|
+
"allowed_http",
|
|
896
|
+
"allowed_db",
|
|
897
|
+
"allowed_remote",
|
|
898
|
+
mode="before",
|
|
899
|
+
)
|
|
900
|
+
@classmethod
|
|
901
|
+
def _split_list(cls, v: object) -> object:
|
|
902
|
+
# Allow setting via env (MEFOR_EGRESS_ALLOWED_MLLP=...) as one comma-separated string.
|
|
903
|
+
if isinstance(v, str):
|
|
904
|
+
return [item.strip() for item in v.split(",") if item.strip()]
|
|
905
|
+
return v
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
class ShadowSettings(_Section):
|
|
909
|
+
"""``[shadow]`` — parallel-run / shadow-instance egress suppression (#15).
|
|
910
|
+
|
|
911
|
+
A *shadow* MessageFoundry instance processes real (teed) traffic to validate it against a legacy
|
|
912
|
+
engine, but must **not** deliver to live partners (the legacy engine is still the real sender).
|
|
913
|
+
Set ``simulate_all_egress = true`` to force **every** outbound into ``simulate`` mode regardless of
|
|
914
|
+
its per-connection ``simulate=`` flag — the deployment-wide safety switch so a shadow stand-up
|
|
915
|
+
can't accidentally leave one outbound live. Default false = each outbound's own ``simulate=`` flag
|
|
916
|
+
applies. (Per-outbound is the precise control; this is the blunt instance-wide override.)
|
|
917
|
+
"""
|
|
918
|
+
|
|
919
|
+
simulate_all_egress: bool = False
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
class AlertSeverity(str, Enum):
|
|
923
|
+
"""Severity a matching rule tags a fired alert with (ADR 0014) — carried in the payload so a
|
|
924
|
+
webhook target (PagerDuty/Slack/Teams) or the email subject can triage by it."""
|
|
925
|
+
|
|
926
|
+
INFO = "info"
|
|
927
|
+
WARNING = "warning"
|
|
928
|
+
CRITICAL = "critical"
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
#: The alert event types a rule may match (plus ``"any"``); mirror the AlertSink methods.
|
|
932
|
+
_ALERT_EVENT_TYPES = frozenset(
|
|
933
|
+
{"connection_stopped", "queue_buildup", "storage_threshold", "cert_expiry"}
|
|
934
|
+
)
|
|
935
|
+
#: The transport names a rule may route to; mirror ``AlertTransport.name``.
|
|
936
|
+
_ALERT_TRANSPORTS = frozenset({"webhook", "email"})
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
class AlertRule(BaseModel):
|
|
940
|
+
"""One operator-authored alerting rule (ADR 0014). The **first** rule that matches an event decides
|
|
941
|
+
its severity, which transports fire, and the re-alert cooldown; an event matching no rule keeps the
|
|
942
|
+
default (notify every configured transport at ``warning`` with the global ``realert_seconds``).
|
|
943
|
+
Rules are pure data — there is no embedded code/expression."""
|
|
944
|
+
|
|
945
|
+
model_config = ConfigDict(extra="forbid")
|
|
946
|
+
|
|
947
|
+
# --- match (all conditions must hold) ---
|
|
948
|
+
event_type: str = (
|
|
949
|
+
"any" # "any" | connection_stopped | queue_buildup | storage_threshold | cert_expiry
|
|
950
|
+
)
|
|
951
|
+
connection: str = "*" # fnmatch glob over the connection name; "*" = all
|
|
952
|
+
min_depth: int | None = Field(None, ge=1) # queue_buildup: match only at/over this lane depth
|
|
953
|
+
min_oldest_seconds: float | None = Field(
|
|
954
|
+
None, ge=0
|
|
955
|
+
) # queue_buildup: …or oldest-message age (s)
|
|
956
|
+
# --- outcome ---
|
|
957
|
+
severity: AlertSeverity = AlertSeverity.WARNING
|
|
958
|
+
transports: list[str] | None = (
|
|
959
|
+
None # None = every configured transport; [] = suppress entirely (event dropped, never sent)
|
|
960
|
+
)
|
|
961
|
+
cooldown_seconds: float | None = Field(
|
|
962
|
+
None, gt=0
|
|
963
|
+
) # override realert_seconds for matching events
|
|
964
|
+
|
|
965
|
+
@field_validator("event_type")
|
|
966
|
+
@classmethod
|
|
967
|
+
def _check_event_type(cls, v: str) -> str:
|
|
968
|
+
if v != "any" and v not in _ALERT_EVENT_TYPES:
|
|
969
|
+
allowed = ", ".join(sorted({"any", *_ALERT_EVENT_TYPES}))
|
|
970
|
+
raise ValueError(f"event_type must be one of {allowed}; got {v!r}")
|
|
971
|
+
return v
|
|
972
|
+
|
|
973
|
+
@field_validator("transports")
|
|
974
|
+
@classmethod
|
|
975
|
+
def _check_transports(cls, v: list[str] | None) -> list[str] | None:
|
|
976
|
+
if v is not None:
|
|
977
|
+
bad = [t for t in v if t not in _ALERT_TRANSPORTS]
|
|
978
|
+
if bad:
|
|
979
|
+
allowed = ", ".join(sorted(_ALERT_TRANSPORTS))
|
|
980
|
+
raise ValueError(f"transports must be a subset of [{allowed}]; unknown: {bad}")
|
|
981
|
+
return v
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
class AlertsSettings(_Section):
|
|
985
|
+
"""Where operational alerts (``connection_stopped`` / ``queue_buildup`` from the delivery
|
|
986
|
+
pipeline) are delivered. Both transports are **off by default** — with neither configured the
|
|
987
|
+
engine falls back to logging the events at ``WARNING`` (``LoggingAlertSink``).
|
|
988
|
+
|
|
989
|
+
A transport is *enabled* when its essentials are present: ``webhook_url`` for the webhook;
|
|
990
|
+
``email_smtp_host`` + ``email_from`` + at least one ``email_to`` for email. The SMTP password is a
|
|
991
|
+
secret — supply it via ``MEFOR_ALERTS_EMAIL_PASSWORD``, never the file. Payloads carry only the
|
|
992
|
+
connection name + queue shape (no PHI)."""
|
|
993
|
+
|
|
994
|
+
# --- webhook (generic HTTP POST; fronts Slack/Teams/PagerDuty/custom) ----
|
|
995
|
+
webhook_url: str | None = None
|
|
996
|
+
webhook_timeout: float = 10.0 # seconds per POST
|
|
997
|
+
# Optional egress allowlist for the webhook host (ASVS 1.3.6, SSRF defense-in-depth). Empty =
|
|
998
|
+
# any host (the URL is operator-configured, not request-derived). When set, the webhook_url host
|
|
999
|
+
# must be listed or the transport refuses to send. Comma- or os.pathsep-separated via env.
|
|
1000
|
+
webhook_allowed_hosts: list[str] = []
|
|
1001
|
+
|
|
1002
|
+
# --- email / SMTP -------------------------------------------------------
|
|
1003
|
+
email_smtp_host: str | None = None
|
|
1004
|
+
email_smtp_port: int = 587
|
|
1005
|
+
email_from: str | None = None
|
|
1006
|
+
email_to: list[str] = []
|
|
1007
|
+
email_use_tls: bool = True # STARTTLS
|
|
1008
|
+
email_username: str | None = None
|
|
1009
|
+
email_password: str | None = None # secret — supply via MEFOR_ALERTS_EMAIL_PASSWORD
|
|
1010
|
+
email_timeout: float = 30.0 # seconds per send
|
|
1011
|
+
# Egress allowlist for the SMTP host (WP-11c, parity with webhook_allowed_hosts). Empty = any.
|
|
1012
|
+
smtp_allowed_hosts: list[str] = []
|
|
1013
|
+
|
|
1014
|
+
# Re-alert throttle: the same (event, connection) won't re-notify more often than this, so a
|
|
1015
|
+
# flapping lane can't spam the channel.
|
|
1016
|
+
realert_seconds: float = 300.0
|
|
1017
|
+
|
|
1018
|
+
# Operator alert rules (ADR 0014): refine severity / which transports fire / cooldown / suppression
|
|
1019
|
+
# per event + connection. Empty = today's behaviour (every event → every transport, global throttle).
|
|
1020
|
+
# Authored as ``[[alerts.rules]]`` tables in the config file. First match wins.
|
|
1021
|
+
rules: list[AlertRule] = []
|
|
1022
|
+
|
|
1023
|
+
@field_validator("email_to", "webhook_allowed_hosts", "smtp_allowed_hosts", mode="before")
|
|
1024
|
+
@classmethod
|
|
1025
|
+
def _split_recipients(cls, v: object) -> object:
|
|
1026
|
+
# The env layer delivers list-typed alerts settings (MEFOR_ALERTS_EMAIL_TO,
|
|
1027
|
+
# MEFOR_ALERTS_WEBHOOK_ALLOWED_HOSTS) as one string; split on commas so they can be set via
|
|
1028
|
+
# env (mirrors api.config_reload_roots).
|
|
1029
|
+
if isinstance(v, str):
|
|
1030
|
+
return [addr.strip() for addr in v.split(",") if addr.strip()]
|
|
1031
|
+
return v
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
class ClusterSettings(_Section):
|
|
1035
|
+
"""``[cluster]`` — horizontal scale-out coordination (Track B Steps 3-7).
|
|
1036
|
+
|
|
1037
|
+
The multi-node coordination seam (a ``nodes`` table + per-node heartbeat + leader election) without
|
|
1038
|
+
changing single-node behavior: with ``enabled = false`` (the default) the engine uses the no-op
|
|
1039
|
+
:class:`~messagefoundry.pipeline.cluster.NullCoordinator` and runs byte-identically to before.
|
|
1040
|
+
With ``enabled = true`` on Postgres, the scale-out feature set is COMPLETE: leader election (Step 4),
|
|
1041
|
+
leader-gated poll-source intake (Step 4b), per-lane FIFO ownership (Step 5), cross-node reference +
|
|
1042
|
+
config-reload + transform-state convergence (Steps 6/6b), and the read-only observability API
|
|
1043
|
+
(Step 7 — ``/cluster/status`` + ``/cluster/nodes``). Exactly one node runs the leader-only WRITE
|
|
1044
|
+
singletons (retention, the lease-reclaim sweep) and re-reads each reference source while followers
|
|
1045
|
+
read-through the shared snapshot; an operator config reload propagates cluster-wide via a version
|
|
1046
|
+
token; and operators can see membership + leadership over the API. Operators must keep node clocks
|
|
1047
|
+
synced (NTP — leases are wall-clock), run identical config dirs on every node, and apply config
|
|
1048
|
+
changes via a coordinated (not rolling) restart — see ``docs/CLUSTERING.md``. Leadership itself is a
|
|
1049
|
+
**self-fencing lease** (Workstream A2): the leader renews a ``leader_lease`` row every
|
|
1050
|
+
``heartbeat_seconds`` to ``DB_now + leader_lease_ttl_seconds``, a standby acquires only once that
|
|
1051
|
+
lease has expired, and a leader that cannot renew within ``leader_fence_timeout_seconds`` self-fences
|
|
1052
|
+
before the lease can expire (the split-brain guard). The cross-section validator below requires
|
|
1053
|
+
``[store].backend = postgres`` and ``[store].pool_size >= 2`` when this is enabled (a clustered node
|
|
1054
|
+
drives concurrent background work against the pool)."""
|
|
1055
|
+
|
|
1056
|
+
enabled: bool = False
|
|
1057
|
+
# Override the auto-generated node id (host:pid:hex). Pin it for a stable identity across restarts
|
|
1058
|
+
# or in tests; left unset, the factory reuses the store's lease owner-id so node-id == owner-id.
|
|
1059
|
+
node_id: str | None = None
|
|
1060
|
+
# How often a node refreshes its `last_seen` heartbeat. The same cadence drives leadership-lease
|
|
1061
|
+
# renewal (Track B Step 4 / Workstream A2) — no separate leader-check knob. Must be > 0.
|
|
1062
|
+
heartbeat_seconds: float = 10.0
|
|
1063
|
+
# A node is considered dead when its last_seen is older than this. Consulted by DbCoordinator's
|
|
1064
|
+
# cluster_members() (Step 7) as the freshness filter for the /cluster/nodes observability endpoint —
|
|
1065
|
+
# it discards a crashed ex-leader's stale is_leader flag and bounds the failover window in which a
|
|
1066
|
+
# just-beaten node still counts toward the derived leader. It is NOT what transfers leadership: the
|
|
1067
|
+
# self-fencing leadership lease is (a standby acquires only once the lease has expired). Must be > 0.
|
|
1068
|
+
node_timeout_seconds: float = 30.0
|
|
1069
|
+
# How often the LEADER runs the lease-reclaim sweep (reclaim_expired_leases) that recovers crashed
|
|
1070
|
+
# nodes' in-flight rows (Track B Step 4). Only the current leader acts; followers no-op. Must be > 0.
|
|
1071
|
+
reclaim_interval_seconds: float = 30.0
|
|
1072
|
+
# The leadership LEASE TTL (Workstream A2 active-passive self-fencing). The current leader renews the
|
|
1073
|
+
# lease every heartbeat_seconds, extending its expiry to DB_now + this; a standby may acquire leadership
|
|
1074
|
+
# ONLY once the lease has expired, so it always waits out the full TTL. Measured on the DB's own clock
|
|
1075
|
+
# (clock_timestamp()), so inter-node clock skew is irrelevant to leadership correctness. Must be > 0.
|
|
1076
|
+
leader_lease_ttl_seconds: float = 30.0
|
|
1077
|
+
# The SELF-FENCE timeout: a leader that has not renewed its lease within this many seconds (its own
|
|
1078
|
+
# monotonic clock, with NO DB I/O so a hung/partitioned DB can't block it) halts its leader work.
|
|
1079
|
+
# MUST be < leader_lease_ttl_seconds so the old leader stops BEFORE the lease can expire and a standby
|
|
1080
|
+
# acquire — the split-brain guard. MUST be > heartbeat_seconds so a single missed renew doesn't fence.
|
|
1081
|
+
leader_fence_timeout_seconds: float = 20.0
|
|
1082
|
+
|
|
1083
|
+
@field_validator(
|
|
1084
|
+
"heartbeat_seconds",
|
|
1085
|
+
"node_timeout_seconds",
|
|
1086
|
+
"reclaim_interval_seconds",
|
|
1087
|
+
"leader_lease_ttl_seconds",
|
|
1088
|
+
"leader_fence_timeout_seconds",
|
|
1089
|
+
)
|
|
1090
|
+
@classmethod
|
|
1091
|
+
def _positive(cls, value: float) -> float:
|
|
1092
|
+
if value <= 0:
|
|
1093
|
+
raise ValueError("must be > 0")
|
|
1094
|
+
return value
|
|
1095
|
+
|
|
1096
|
+
@model_validator(mode="after")
|
|
1097
|
+
def _timeout_exceeds_heartbeat(self) -> "ClusterSettings":
|
|
1098
|
+
"""A node must beat at least once within its dead-timeout, or Step-4 election would mark a
|
|
1099
|
+
live node dead between beats. node_timeout_seconds is reserved for that election, but lock the
|
|
1100
|
+
invariant in now so a misconfiguration is caught at config load, not at election bring-up."""
|
|
1101
|
+
if self.node_timeout_seconds <= self.heartbeat_seconds:
|
|
1102
|
+
raise ValueError(
|
|
1103
|
+
"node_timeout_seconds must be > heartbeat_seconds "
|
|
1104
|
+
f"(got node_timeout_seconds={self.node_timeout_seconds}, "
|
|
1105
|
+
f"heartbeat_seconds={self.heartbeat_seconds}) — a node must beat at least once before "
|
|
1106
|
+
"it is considered dead"
|
|
1107
|
+
)
|
|
1108
|
+
return self
|
|
1109
|
+
|
|
1110
|
+
@model_validator(mode="after")
|
|
1111
|
+
def _fence_ordering(self) -> "ClusterSettings":
|
|
1112
|
+
"""The split-brain guard's timing invariant (Workstream A2): heartbeat < fence < lease TTL. The
|
|
1113
|
+
leader must renew faster than it fences (so one missed beat doesn't demote it) and must fence
|
|
1114
|
+
before the lease can expire (so a partitioned old leader stops before a standby acquires).
|
|
1115
|
+
Caught at config load, not at failover."""
|
|
1116
|
+
if not (
|
|
1117
|
+
self.heartbeat_seconds
|
|
1118
|
+
< self.leader_fence_timeout_seconds
|
|
1119
|
+
< self.leader_lease_ttl_seconds
|
|
1120
|
+
):
|
|
1121
|
+
raise ValueError(
|
|
1122
|
+
"cluster lease timing must satisfy heartbeat_seconds < leader_fence_timeout_seconds "
|
|
1123
|
+
"< leader_lease_ttl_seconds "
|
|
1124
|
+
f"(got heartbeat_seconds={self.heartbeat_seconds}, "
|
|
1125
|
+
f"leader_fence_timeout_seconds={self.leader_fence_timeout_seconds}, "
|
|
1126
|
+
f"leader_lease_ttl_seconds={self.leader_lease_ttl_seconds}) — the leader must renew "
|
|
1127
|
+
"faster than it fences, and fence before the lease can expire and a standby acquire it"
|
|
1128
|
+
)
|
|
1129
|
+
return self
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class CertMonitorSettings(_Section):
|
|
1133
|
+
"""Periodic TLS-certificate expiry monitor (``[cert_monitor]``). The engine scans the certificate
|
|
1134
|
+
PEM files it actually serves with — the ``[api]`` TLS cert and every connection's ``tls_cert_file``
|
|
1135
|
+
(MLLP server/client identity) — and raises a ``cert_expiry`` alert when one is expired or within
|
|
1136
|
+
``warn_days`` of expiry. Now that native off-loopback TLS is the supported posture, this catches a
|
|
1137
|
+
silently expiring cert (a hard PHI-feed outage at renewal time) ahead of time. Only the public
|
|
1138
|
+
certificate is read, never any private key. Set ``warn_days`` to 0 to disable the monitor."""
|
|
1139
|
+
|
|
1140
|
+
warn_days: int = 30 # alert this many days before expiry (0 = monitor off)
|
|
1141
|
+
check_interval_seconds: float = 43_200.0 # rescan cadence (default 12h)
|
|
1142
|
+
|
|
1143
|
+
@field_validator("warn_days")
|
|
1144
|
+
@classmethod
|
|
1145
|
+
def _check_warn_days(cls, v: int) -> int:
|
|
1146
|
+
if v < 0:
|
|
1147
|
+
raise ValueError("cert_monitor.warn_days must be >= 0 (0 disables the monitor)")
|
|
1148
|
+
return v
|
|
1149
|
+
|
|
1150
|
+
@field_validator("check_interval_seconds")
|
|
1151
|
+
@classmethod
|
|
1152
|
+
def _check_interval(cls, v: float) -> float:
|
|
1153
|
+
if v <= 0:
|
|
1154
|
+
raise ValueError("cert_monitor.check_interval_seconds must be > 0")
|
|
1155
|
+
return v
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
#: The high-value operations dual-control can gate (registry keys). Confining ``[approvals].operations``
|
|
1159
|
+
#: to this set catches a typo'd op name at startup rather than silently never gating it.
|
|
1160
|
+
APPROVABLE_OPERATIONS: frozenset[str] = frozenset({"dead_letter_replay", "connection_purge"})
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
class ApprovalsSettings(_Section):
|
|
1164
|
+
"""Optional dual-control (maker-checker) approval for high-value actions (``[approvals]``, ASVS
|
|
1165
|
+
2.3.5). **Off by default** so a single-operator deployment is never blocked. When ``enabled``, an
|
|
1166
|
+
action in ``operations`` is held as a pending request and must be released by a *distinct* second
|
|
1167
|
+
user holding ``approvals:approve`` — the requester can never approve their own. A request older than
|
|
1168
|
+
``expiry_hours`` can no longer be approved."""
|
|
1169
|
+
|
|
1170
|
+
enabled: bool = False
|
|
1171
|
+
operations: list[str] = Field(default_factory=lambda: sorted(APPROVABLE_OPERATIONS))
|
|
1172
|
+
expiry_hours: float = (
|
|
1173
|
+
72.0 # a pending request expires this many hours after it's made (0 = never)
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
@field_validator("operations")
|
|
1177
|
+
@classmethod
|
|
1178
|
+
def _known_operations(cls, v: list[str]) -> list[str]:
|
|
1179
|
+
unknown = sorted(set(v) - APPROVABLE_OPERATIONS)
|
|
1180
|
+
if unknown:
|
|
1181
|
+
raise ValueError(
|
|
1182
|
+
f"[approvals].operations has unknown operation(s) {unknown}; "
|
|
1183
|
+
f"valid: {sorted(APPROVABLE_OPERATIONS)}"
|
|
1184
|
+
)
|
|
1185
|
+
return v
|
|
1186
|
+
|
|
1187
|
+
@field_validator("expiry_hours")
|
|
1188
|
+
@classmethod
|
|
1189
|
+
def _check_expiry(cls, v: float) -> float:
|
|
1190
|
+
if v < 0:
|
|
1191
|
+
raise ValueError("approvals.expiry_hours must be >= 0 (0 = never expires)")
|
|
1192
|
+
return v
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
class ServiceSettings(BaseModel):
|
|
1196
|
+
model_config = ConfigDict(extra="ignore") # tolerate forward-looking/unknown sections
|
|
1197
|
+
|
|
1198
|
+
store: StoreSettings = Field(default_factory=StoreSettings)
|
|
1199
|
+
api: ApiSettings = Field(default_factory=ApiSettings)
|
|
1200
|
+
inbound: InboundSettings = Field(default_factory=InboundSettings)
|
|
1201
|
+
delivery: DeliverySettings = Field(default_factory=DeliverySettings)
|
|
1202
|
+
pipeline: PipelineSettings = Field(default_factory=PipelineSettings)
|
|
1203
|
+
environments: EnvironmentsSettings = Field(default_factory=EnvironmentsSettings)
|
|
1204
|
+
logging: LoggingSettings = Field(default_factory=LoggingSettings)
|
|
1205
|
+
reference: ReferenceSettings = Field(default_factory=ReferenceSettings)
|
|
1206
|
+
retention: RetentionSettings = Field(default_factory=RetentionSettings)
|
|
1207
|
+
auth: AuthSettings = Field(default_factory=AuthSettings)
|
|
1208
|
+
ai: AiSettings = Field(default_factory=AiSettings)
|
|
1209
|
+
egress: EgressSettings = Field(default_factory=EgressSettings)
|
|
1210
|
+
shadow: ShadowSettings = Field(default_factory=ShadowSettings)
|
|
1211
|
+
alerts: AlertsSettings = Field(default_factory=AlertsSettings)
|
|
1212
|
+
cert_monitor: CertMonitorSettings = Field(default_factory=CertMonitorSettings)
|
|
1213
|
+
cluster: ClusterSettings = Field(default_factory=ClusterSettings)
|
|
1214
|
+
approvals: ApprovalsSettings = Field(default_factory=ApprovalsSettings)
|
|
1215
|
+
|
|
1216
|
+
@model_validator(mode="after")
|
|
1217
|
+
def _cluster_requires_server_db(self) -> "ServiceSettings":
|
|
1218
|
+
"""Cluster coordination needs a shared **server-DB** store to back the ``nodes`` + leadership-
|
|
1219
|
+
lease tables. SQLite is single-file/single-node, so it cannot. **Postgres** and **SQL Server**
|
|
1220
|
+
both can: each runs the active-passive leadership lease (one leader drains the graph; a standby
|
|
1221
|
+
takes over on failure). Postgres additionally backs the active-active per-lane row leases (0.2
|
|
1222
|
+
horizontal scale-out); SQL Server is **active-passive only** (no row leases — the leader-gate +
|
|
1223
|
+
self-fence keep a single active processor at a time). This spans two sections, so it lives here
|
|
1224
|
+
(not on :class:`ClusterSettings`, which can't see ``[store]``)."""
|
|
1225
|
+
if self.cluster.enabled:
|
|
1226
|
+
if self.store.backend not in (StoreBackend.POSTGRES, StoreBackend.SQLSERVER):
|
|
1227
|
+
raise ValueError(
|
|
1228
|
+
"[cluster].enabled requires [store].backend in {'postgres', 'sqlserver'} "
|
|
1229
|
+
f"(got {self.store.backend.value!r}); SQLite is single-node — cluster coordination "
|
|
1230
|
+
"needs a shared server-DB store (Postgres active-active/active-passive, or SQL "
|
|
1231
|
+
"Server active-passive)"
|
|
1232
|
+
)
|
|
1233
|
+
if self.store.pool_size < 2:
|
|
1234
|
+
# A clustered node runs concurrent background work against the pool — the maintenance
|
|
1235
|
+
# loop (heartbeat + lease renew + lane/config refresh), the leader-gated reclaim sweep,
|
|
1236
|
+
# and the per-stage workers — alongside request traffic. A pool of 1 would serialize all
|
|
1237
|
+
# of it behind a single connection, so require headroom.
|
|
1238
|
+
raise ValueError(
|
|
1239
|
+
"[cluster].enabled requires [store].pool_size >= 2 "
|
|
1240
|
+
f"(got {self.store.pool_size}); a clustered node drives concurrent background work "
|
|
1241
|
+
"(the membership/lease maintenance loop + the leader reclaim sweep + the per-stage "
|
|
1242
|
+
"workers) against the pool, so a pool of 1 would serialize everything — prefer "
|
|
1243
|
+
"pool_size >= 3 for clustered Postgres"
|
|
1244
|
+
)
|
|
1245
|
+
return self
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _merge(dst: dict[str, dict[str, Any]], src: Mapping[str, Any]) -> None:
|
|
1249
|
+
"""Shallow-merge per-section dicts from ``src`` into ``dst`` (later layers win)."""
|
|
1250
|
+
for section, values in src.items():
|
|
1251
|
+
if isinstance(values, dict):
|
|
1252
|
+
dst.setdefault(section, {}).update(values)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _env_overrides(environ: Mapping[str, str]) -> dict[str, dict[str, Any]]:
|
|
1256
|
+
"""Parse ``MEFOR_<SECTION>_<KEY>`` vars into ``{section: {key: value}}`` (strings; pydantic coerces)."""
|
|
1257
|
+
out: dict[str, dict[str, Any]] = {}
|
|
1258
|
+
for name, value in environ.items():
|
|
1259
|
+
if not name.startswith(_ENV_PREFIX):
|
|
1260
|
+
continue
|
|
1261
|
+
section, _, key = name[len(_ENV_PREFIX) :].lower().partition("_")
|
|
1262
|
+
if section in _SECTIONS and key:
|
|
1263
|
+
out.setdefault(section, {})[key] = value
|
|
1264
|
+
return out
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _warn_file_secrets(file_data: Mapping[str, Any], path: Path) -> None:
|
|
1268
|
+
"""Warn when a secret is supplied via the config file instead of the environment."""
|
|
1269
|
+
for section, key in _FILE_SECRET_KEYS:
|
|
1270
|
+
sect = file_data.get(section)
|
|
1271
|
+
if isinstance(sect, dict) and sect.get(key) is not None:
|
|
1272
|
+
_log.warning(
|
|
1273
|
+
"secret [%s].%s is set in %s; move it to env (MEFOR_%s_%s) — the config file is "
|
|
1274
|
+
"not a safe place for secrets",
|
|
1275
|
+
section,
|
|
1276
|
+
key,
|
|
1277
|
+
path,
|
|
1278
|
+
section.upper(),
|
|
1279
|
+
key.upper(),
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def load_settings(
|
|
1284
|
+
*,
|
|
1285
|
+
config_path: str | Path | None = None,
|
|
1286
|
+
cli: Mapping[str, Mapping[str, Any]] | None = None,
|
|
1287
|
+
environ: Mapping[str, str] | None = None,
|
|
1288
|
+
) -> ServiceSettings:
|
|
1289
|
+
"""Resolve settings with CLI > env > file > default precedence.
|
|
1290
|
+
|
|
1291
|
+
``config_path`` reads that TOML file (error if it's missing); when ``None``, ``./messagefoundry.toml``
|
|
1292
|
+
is used **only if it exists**. ``cli`` is a nested ``{section: {key: value}}`` of explicitly-provided
|
|
1293
|
+
CLI overrides (omit a key to fall through). ``environ`` defaults to ``os.environ``.
|
|
1294
|
+
"""
|
|
1295
|
+
environ = os.environ if environ is None else environ
|
|
1296
|
+
data: dict[str, dict[str, Any]] = {}
|
|
1297
|
+
|
|
1298
|
+
path = Path(config_path) if config_path is not None else Path(_DEFAULT_FILE)
|
|
1299
|
+
if config_path is not None and not path.exists():
|
|
1300
|
+
raise FileNotFoundError(f"service config not found: {path}")
|
|
1301
|
+
if path.exists():
|
|
1302
|
+
with path.open("rb") as fh:
|
|
1303
|
+
file_data = tomllib.load(fh)
|
|
1304
|
+
_warn_file_secrets(file_data, path)
|
|
1305
|
+
_merge(data, file_data)
|
|
1306
|
+
|
|
1307
|
+
_merge(data, _env_overrides(environ))
|
|
1308
|
+
if cli:
|
|
1309
|
+
_merge(data, cli)
|
|
1310
|
+
|
|
1311
|
+
return ServiceSettings.model_validate(data)
|