messagefoundry 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,507 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ r"""SOAP transport: a destination that POSTs a SOAP envelope to a web service.
4
+
5
+ The **destination** is a thin SOAP layer over the same stdlib HTTP client the REST connector uses
6
+ (ADR 0003) — it reuses REST's no-redirect, http(s)-only opener (so a 3xx can't divert a PHI-bearing
7
+ request; ASVS 15.3.2) and the fail-closed ``[egress].allowed_http`` host gate.
8
+
9
+ **Two modes (ADR 0015).**
10
+ - *Plain* (the default): the Handler produces the **full SOAP envelope** (XML); this adds the SOAP
11
+ ``Content-Type`` + action and POSTs it — byte-identical to the original connector.
12
+ - *WS-\** (opt-in, `ws_addressing`/`ws_security`): the Handler produces only the operation **`<Body>`
13
+ fragment**; the **transport** wraps it in the envelope and **stamps the non-deterministic
14
+ WS-Addressing / WS-Security headers** (``<wsa:MessageID>``, ``<wsu:Timestamp>``, optional
15
+ ``<wsse:UsernameToken>`` Nonce/Created) **in `send()`** — after the queue boundary, so a pure
16
+ transform never produces a per-call nonce/timestamp and re-run purity holds (ADR 0015 §1). Envelope
17
+ assembly is **stdlib string templating** (no XML parser); the attacker-influenceable ``<Body>``
18
+ fragment is checked once with a hardened, non-resolving, no-DTD well-formedness gate (XXE-negative).
19
+ WS-\* requires SOAP 1.2.
20
+
21
+ **Mutual TLS (ADR 0015).** ``client_cert_file``/``client_key_file`` present a client certificate via a
22
+ per-connection opener (server verification stays on); incompatible with ``verify_tls=False``.
23
+
24
+ **SOAP version.** ``1.1`` → ``Content-Type: text/xml; charset=utf-8`` + a ``SOAPAction`` header;
25
+ ``1.2`` → ``Content-Type: application/soap+xml; charset=utf-8; action="…"`` (no ``SOAPAction`` header).
26
+
27
+ **Fault mapping.** A response is inspected for a SOAP ``Fault`` (faults can arrive as HTTP 500 *or* an
28
+ HTTP 200 body). A WS-Security fault (FailedAuthentication / InvalidSecurityToken / MessageExpired) →
29
+ :class:`NegativeAckError` (``permanent=True``) → dead-letter (a credential/expiry reject won't fix on a
30
+ retry). A **Sender/Client** fault → permanent dead-letter; a **Receiver/Server** fault →
31
+ :class:`DeliveryError` (retry). An unrecognized fault is treated as permanent. With no fault, the HTTP
32
+ status decides: 2xx delivered, 5xx retry, other 4xx / refused 3xx dead-letter; a connection/timeout
33
+ error retries. Fault bodies are **not** echoed into errors/logs (they may carry PHI) — only the SOAP
34
+ fault role + HTTP status are.
35
+
36
+ **Idempotency.** Delivery is at-least-once, so a retry **re-sends** (minting a fresh ``<wsa:MessageID>``
37
+ in WS-\* mode — correct WS-\* retry semantics). The service operation **must be idempotent** and its
38
+ dedup must treat a re-send as a retry, not a duplicate. See docs/CONNECTIONS.md.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import asyncio
44
+ import base64
45
+ import hashlib
46
+ import io
47
+ import logging
48
+ import os
49
+ import re
50
+ import ssl
51
+ import time
52
+ import urllib.error
53
+ import urllib.parse
54
+ import urllib.request
55
+ import uuid
56
+ import xml.sax # nosec B406 — hardened, non-resolving, no-DTD well-formedness gate only (ADR 0015 §2a)
57
+ from collections.abc import Callable
58
+ from typing import Any
59
+ from xml.sax.handler import ( # nosec B406 — see _assert_well_formed_fragment (external entities OFF)
60
+ ContentHandler,
61
+ feature_external_ges,
62
+ feature_external_pes,
63
+ )
64
+ from xml.sax.saxutils import escape as _xml_escape # nosec B406 — pure string escaper, not a parser
65
+ from xml.sax.xmlreader import InputSource # nosec B406 — fed only the hardened, no-DTD parser
66
+
67
+ from messagefoundry.config.models import ConnectorType, Destination
68
+ from messagefoundry.config.settings import INSECURE_TLS_ESCAPE_ENV, insecure_tls_allowed
69
+ from messagefoundry.transports.base import (
70
+ DeliveryError,
71
+ DeliveryResponse,
72
+ DestinationConnector,
73
+ NegativeAckError,
74
+ register_destination,
75
+ )
76
+
77
+ # Reuse REST's hardened HTTP plumbing — same transports/ package, same no-redirect + TLS posture.
78
+ from messagefoundry.transports.rest import (
79
+ _NO_REDIRECT_OPENER,
80
+ _NoRedirectHandler,
81
+ _insecure_opener,
82
+ _redact_url,
83
+ enforce_outbound_length_limits,
84
+ refuse_cleartext_credentials,
85
+ )
86
+ from messagefoundry.transports.signing import MessageSigner, signer_from_destination
87
+
88
+ __all__ = ["SoapDestination"]
89
+
90
+ logger = logging.getLogger(__name__)
91
+
92
+ _FAULT_RE = re.compile(r"<(?:\w+:)?Fault[\s>]", re.IGNORECASE)
93
+ _SENDER_RE = re.compile(r"client|sender", re.IGNORECASE)
94
+ _RECEIVER_RE = re.compile(r"server|receiver", re.IGNORECASE)
95
+ # WS-Security fault codes that are PERMANENT (a retry won't fix a rejected/expired credential).
96
+ _WSSE_FAULT_RE = re.compile(
97
+ r"FailedAuthentication|InvalidSecurityToken|MessageExpired", re.IGNORECASE
98
+ )
99
+ # SOAP 1.1 <faultcode>…</faultcode>; SOAP 1.2 <Code><Value>…</Value></Code>.
100
+ _FAULTCODE_RE = re.compile(r"<(?:\w+:)?faultcode\b[^>]*>(.*?)</", re.IGNORECASE | re.DOTALL)
101
+ _CODEVALUE_RE = re.compile(r"<(?:\w+:)?Value\b[^>]*>(.*?)</", re.IGNORECASE | re.DOTALL)
102
+ # A <…:Header> element (any/no namespace prefix) smuggled into a <Body> fragment (purity-leak lint).
103
+ _HEADER_EL_RE = re.compile(r"<\s*(?:[\w.\-]+:)?Header[\s/>]", re.IGNORECASE)
104
+
105
+ # Namespace URIs (fixed; the transport's header is namespace-controlled, ADR 0015 §2a).
106
+ _NS_SOAP12 = "http://www.w3.org/2003/05/soap-envelope"
107
+ _NS_WSA = "http://www.w3.org/2005/08/addressing"
108
+ _NS_WSSE = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
109
+ _NS_WSU = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
110
+ _PW_TEXT = (
111
+ "http://docs.oasis-open.org/wss/2004/01/"
112
+ "oasis-200401-wss-username-token-profile-1.0#PasswordText"
113
+ )
114
+ _PW_DIGEST = (
115
+ "http://docs.oasis-open.org/wss/2004/01/"
116
+ "oasis-200401-wss-username-token-profile-1.0#PasswordDigest"
117
+ )
118
+ _NONCE_ENC = (
119
+ "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary"
120
+ )
121
+
122
+ # The envelope skeleton (SOAP 1.2 only; WS-* requires 1.2). Built by string concatenation, NOT
123
+ # str.format — an HL7-derived <Body> fragment may contain literal { } that would break .format.
124
+ _ENV_OPEN = (
125
+ '<?xml version="1.0" encoding="utf-8"?>'
126
+ f'<soap:Envelope xmlns:soap="{_NS_SOAP12}" xmlns:wsa="{_NS_WSA}" '
127
+ f'xmlns:wsse="{_NS_WSSE}" xmlns:wsu="{_NS_WSU}">'
128
+ )
129
+
130
+
131
+ def _fault_code(body: str) -> str:
132
+ """The fault code text (e.g. ``soap:Client`` / ``soap:Receiver``), SOAP 1.1 or 1.2, or ``""``."""
133
+ m = _FAULTCODE_RE.search(body) or _CODEVALUE_RE.search(body)
134
+ return m.group(1) if m else ""
135
+
136
+
137
+ def _classify_soap(status: int, body: str) -> DeliveryError | None:
138
+ """``None`` if delivered, else the classified failure. A WS-Security fault (auth/expiry) →
139
+ permanent; a SOAP fault is classified by its code (Sender/Client → permanent; Receiver/Server →
140
+ transient; unrecognized → permanent so a rejected request can't loop the lane); a fault-less
141
+ response falls back to the HTTP status."""
142
+ if _FAULT_RE.search(body):
143
+ code = _fault_code(body)
144
+ if _WSSE_FAULT_RE.search(code) or _WSSE_FAULT_RE.search(body):
145
+ return NegativeAckError(
146
+ f"WS-Security fault (HTTP {status})", code="wssecurity", permanent=True
147
+ )
148
+ if _SENDER_RE.search(code):
149
+ return NegativeAckError(
150
+ f"SOAP Sender fault (HTTP {status})", code="soap-sender", permanent=True
151
+ )
152
+ if _RECEIVER_RE.search(code):
153
+ return DeliveryError(f"SOAP Receiver fault (HTTP {status})")
154
+ return NegativeAckError(f"SOAP fault (HTTP {status})", code="soap-fault", permanent=True)
155
+ if 200 <= status < 300:
156
+ return None
157
+ if 500 <= status < 600:
158
+ return DeliveryError(f"SOAP endpoint returned HTTP {status}")
159
+ return NegativeAckError(
160
+ f"SOAP endpoint rejected with HTTP {status}", code=str(status), permanent=True
161
+ )
162
+
163
+
164
+ def _client_cert_opener(
165
+ certfile: str, keyfile: str, password: str | None
166
+ ) -> urllib.request.OpenerDirector:
167
+ """A no-redirect opener that presents a **client certificate** for mutual TLS (ADR 0015 §3).
168
+
169
+ Server verification stays on (``create_default_context`` verifies the peer + hostname); a client
170
+ cert against an unverified peer is incoherent and rejected at construction, so this never combines
171
+ with ``verify_tls=False``. TLS 1.2+ floor (ADR 0002), as in ``mllp.py``/``api/tls.py``. Per
172
+ connection — REST's shared module-level openers are left untouched."""
173
+ ctx = ssl.create_default_context()
174
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
175
+ ctx.load_cert_chain(certfile, keyfile, password)
176
+ return urllib.request.build_opener(_NoRedirectHandler, urllib.request.HTTPSHandler(context=ctx))
177
+
178
+
179
+ def _assert_well_formed_fragment(fragment: str) -> None:
180
+ """Reject a ``<Body>`` fragment that is not well-formed XML, **before** it reaches the wire
181
+ (ADR 0015 §2a). Uses a single hardened, **non-resolving, no-DTD** ``xml.sax`` parse of the fragment
182
+ *in isolation* (wrapped in a throwaway shell we discard) — external entity resolution is OFF, so it
183
+ is **not** an XXE vector; a DOCTYPE is rejected outright (no internal entity expansion). This is a
184
+ balanced-tags / no-smuggled-close gate on attacker-influenceable HL7-derived content, not a schema
185
+ check; no parsed tree is kept or trusted."""
186
+ if "<!doctype" in fragment.lower():
187
+ raise ValueError("SOAP <Body> fragment must not contain a DOCTYPE (ADR 0015)")
188
+ parser = xml.sax.make_parser() # noqa: S317 # nosec B317 — hardened below: external entities OFF
189
+ parser.setFeature(feature_external_ges, False)
190
+ parser.setFeature(feature_external_pes, False)
191
+ parser.setContentHandler(ContentHandler())
192
+ source = InputSource()
193
+ source.setByteStream(io.BytesIO(f"<_mf_frag>{fragment}</_mf_frag>".encode()))
194
+ try:
195
+ parser.parse(source)
196
+ except xml.sax.SAXException as exc:
197
+ raise ValueError(f"SOAP <Body> fragment is not well-formed XML: {exc}") from exc
198
+
199
+
200
+ def _reject_ws_leak(fragment: str) -> None:
201
+ """Best-effort lint (ADR 0015 §2b, NOT a structural guarantee): reject a ``<Body>`` fragment that
202
+ already carries a ``<Header>`` or a WS-\\* element **by namespace URI** — i.e. an author trying to
203
+ hand-build the non-deterministic header inside the (pure) transform. The real purity guarantee is
204
+ §1's value-placement; this only catches an accidental mistake early. Matches on URI (prefixes are
205
+ author-chosen) so it can be evaded (a URI in a comment/CDATA) and is intentionally conservative."""
206
+ if any(ns in fragment for ns in (_NS_WSA, _NS_WSSE, _NS_WSU)):
207
+ raise ValueError(
208
+ "SOAP <Body> fragment declares a WS-* namespace (wsa/wsse/wsu); the transport stamps those "
209
+ "headers in send() to preserve re-run purity — the Handler must return only the <Body> "
210
+ "operation fragment (ADR 0015)"
211
+ )
212
+ if _HEADER_EL_RE.search(fragment):
213
+ raise ValueError(
214
+ "SOAP <Body> fragment contains a <Header> element; in WS-* mode the Handler returns only "
215
+ "the operation <Body> fragment and the transport builds the <Header> (ADR 0015)"
216
+ )
217
+
218
+
219
+ def _iso(t: float) -> str:
220
+ """An XSD ``dateTime`` in UTC, e.g. ``2026-06-15T05:30:00Z`` (WS-Security Created/Expires)."""
221
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t))
222
+
223
+
224
+ def _default_message_id() -> str:
225
+ """A fresh WS-Addressing MessageID (a per-call URN); overridable for deterministic tests."""
226
+ return f"urn:uuid:{uuid.uuid4()}"
227
+
228
+
229
+ def _default_nonce() -> bytes:
230
+ """A fresh WS-Security nonce; overridable for deterministic tests."""
231
+ return os.urandom(16)
232
+
233
+
234
+ class SoapDestination(DestinationConnector):
235
+ """POST each SOAP envelope to a web-service endpoint (plain or WS-* mode; ADR 0003 + 0015)."""
236
+
237
+ def __init__(self, config: Destination) -> None:
238
+ s = config.settings
239
+ url = s.get("url")
240
+ if not isinstance(url, str) or not url:
241
+ raise ValueError("SOAP destination requires a 'url' setting")
242
+ scheme = urllib.parse.urlsplit(url).scheme.lower()
243
+ if scheme not in ("http", "https"):
244
+ raise ValueError(f"SOAP destination 'url' must be http or https, got scheme {scheme!r}")
245
+ self.url = url
246
+ self.timeout: float = float(s.get("timeout_seconds", 30.0))
247
+ self.encoding: str = s.get("encoding", "utf-8")
248
+ self.version: str = str(s.get("soap_version", "1.1"))
249
+ if self.version not in ("1.1", "1.2"):
250
+ raise ValueError(
251
+ f"SOAP destination soap_version must be 1.1 or 1.2, got {self.version!r}"
252
+ )
253
+ # The action is the single source of truth: the 1.2 Content-Type action param AND wsa:Action.
254
+ self.soap_action: str = str(s.get("soap_action") or "")
255
+ # ADR 0013: capture the SOAP response envelope. Default False → returns None, byte-identical.
256
+ self.capture_response: bool = bool(s.get("capture_response", False))
257
+
258
+ # --- ADR 0015: WS-* + mutual-TLS settings -----------------------------------------------
259
+ self.client_cert_file: str | None = s.get("client_cert_file") or None
260
+ self.client_key_file: str | None = s.get("client_key_file") or None
261
+ self.client_key_password: str | None = s.get("client_key_password") or None
262
+ self.ws_addressing: bool = bool(s.get("ws_addressing", False))
263
+ self.ws_security: bool = bool(s.get("ws_security", False))
264
+ self.ws_username: str | None = s.get("ws_username") or s.get("basic_user") or None
265
+ self.ws_password: str | None = s.get("ws_password") or s.get("basic_password") or None
266
+ self.ws_password_type: str = str(s.get("ws_password_type", "text"))
267
+ self.ws_timestamp_ttl_seconds: int = int(s.get("ws_timestamp_ttl_seconds", 300))
268
+ self._ws_mode: bool = self.ws_addressing or self.ws_security
269
+
270
+ # Non-deterministic generators — instance attributes so tests can inject a fixed clock / UUID /
271
+ # nonce and assert the values are minted in send() (ADR 0015 testing strategy).
272
+ self._now_fn: Callable[[], float] = time.time
273
+ self._uuid_fn: Callable[[], str] = _default_message_id
274
+ self._nonce_fn: Callable[[], bytes] = _default_nonce
275
+
276
+ self._validate_ws(scheme, s)
277
+
278
+ self._headers = self._build_headers(s)
279
+ enforce_outbound_length_limits(self.url, self._headers)
280
+ refuse_cleartext_credentials(scheme, self._headers, self.url)
281
+ # ASVS 4.1.5 (ADR 0018): opt-in detached-JWS signing of the outbound envelope. None = off
282
+ # (byte-identical). Built here so a bad key/algorithm fails loud at connector construction; the
283
+ # signature is minted in _post over the FINAL wire bytes (the WS-* wrapped envelope, ADR 0015).
284
+ self._signer: MessageSigner | None = signer_from_destination(config)
285
+
286
+ if self.client_cert_file and self.client_key_file: # NEW — mutual TLS, takes precedence
287
+ self._opener: urllib.request.OpenerDirector = _client_cert_opener(
288
+ self.client_cert_file, self.client_key_file, self.client_key_password
289
+ )
290
+ elif bool(s.get("verify_tls", True)):
291
+ self._opener = _NO_REDIRECT_OPENER
292
+ else:
293
+ if scheme == "https" and not insecure_tls_allowed():
294
+ raise ValueError(
295
+ "SOAP destination verify_tls=false disables TLS certificate verification; "
296
+ f"refused unless {INSECURE_TLS_ESCAPE_ENV} is set (dev/trusted-network only)"
297
+ )
298
+ logger.warning(
299
+ "SOAP destination %s has TLS verification DISABLED (verify_tls=false)",
300
+ _redact_url(self.url),
301
+ )
302
+ self._opener = _insecure_opener()
303
+
304
+ def _validate_ws(self, scheme: str, s: dict[str, Any]) -> None:
305
+ """Runtime validation of the WS-* / mTLS settings (also enforced at wiring time by
306
+ ``build_outbound_connection`` so ``check``/dry-run catches it without a store; the url-scheme
307
+ checks need the resolved url and live here). ADR 0015 §6."""
308
+ if self.client_cert_file and not self.client_key_file:
309
+ raise ValueError("SOAP client_cert_file requires client_key_file (ADR 0015)")
310
+ if self.client_key_file and not self.client_cert_file:
311
+ raise ValueError("SOAP client_key_file requires client_cert_file (ADR 0015)")
312
+ if self.client_cert_file:
313
+ if scheme != "https":
314
+ raise ValueError("SOAP client certificate requires an https url (ADR 0015)")
315
+ if not bool(s.get("verify_tls", True)):
316
+ raise ValueError(
317
+ "SOAP client cert is incompatible with verify_tls=false — the peer must be "
318
+ "verified (ADR 0015)"
319
+ )
320
+ if self.ws_password_type not in ("text", "digest"):
321
+ raise ValueError("SOAP ws_password_type must be 'text' or 'digest' (ADR 0015)")
322
+ if self._ws_mode and self.version != "1.2":
323
+ raise ValueError("SOAP ws_addressing/ws_security require soap_version='1.2' (ADR 0015)")
324
+ # A UsernameToken password over cleartext http is a credential on the wire — refuse like the
325
+ # Authorization-header path (ADR 0015 §6), unless the dev escape is set.
326
+ if self.ws_username and scheme == "http" and not insecure_tls_allowed():
327
+ raise ValueError(
328
+ "SOAP ws_username sends a UsernameToken credential over cleartext http; refused "
329
+ f"unless {INSECURE_TLS_ESCAPE_ENV} is set — use https (ADR 0015)"
330
+ )
331
+
332
+ def _build_headers(self, s: dict[str, Any]) -> dict[str, str]:
333
+ """SOAP Content-Type (+ SOAPAction for 1.1) + static ``headers`` + optional bearer/basic auth."""
334
+ action = str(s.get("soap_action") or "")
335
+ headers: dict[str, str] = {}
336
+ if self.version == "1.2":
337
+ ctype = "application/soap+xml; charset=utf-8"
338
+ if action:
339
+ ctype += f'; action="{action}"'
340
+ headers["Content-Type"] = ctype
341
+ else:
342
+ headers["Content-Type"] = "text/xml; charset=utf-8"
343
+ headers["SOAPAction"] = f'"{action}"' # quoted; an empty "" is the no-action convention
344
+ extra = s.get("headers") or {}
345
+ if isinstance(extra, dict):
346
+ headers.update({str(k): str(v) for k, v in extra.items()})
347
+ token = s.get("bearer_token")
348
+ if token:
349
+ headers["Authorization"] = f"Bearer {token}"
350
+ user, password = s.get("basic_user"), s.get("basic_password")
351
+ if user and password:
352
+ raw = f"{user}:{password}".encode()
353
+ headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii")
354
+ return headers
355
+
356
+ # --- WS-* envelope assembly (all non-deterministic values minted here, in send()) -------------
357
+
358
+ def _build_wsa_header(self) -> str:
359
+ # wsa:Action is sourced from soap_action — the single source of truth, never a divergent second
360
+ # value (ADR 0015 §6). wsa:To is the static endpoint url; wsa:MessageID is per-call.
361
+ action = _xml_escape(self.soap_action)
362
+ to = _xml_escape(self.url)
363
+ message_id = _xml_escape(self._uuid_fn())
364
+ return (
365
+ f"<wsa:Action>{action}</wsa:Action>"
366
+ f"<wsa:To>{to}</wsa:To>"
367
+ f"<wsa:MessageID>{message_id}</wsa:MessageID>"
368
+ )
369
+
370
+ def _build_wsse_header(self, now: float) -> str:
371
+ created = _iso(now)
372
+ expires = _iso(now + self.ws_timestamp_ttl_seconds)
373
+ timestamp = (
374
+ '<wsu:Timestamp wsu:Id="TS-1">'
375
+ f"<wsu:Created>{created}</wsu:Created><wsu:Expires>{expires}</wsu:Expires>"
376
+ "</wsu:Timestamp>"
377
+ )
378
+ token = self._build_username_token(created) if self.ws_username else ""
379
+ return f'<wsse:Security soap:mustUnderstand="true">{timestamp}{token}</wsse:Security>'
380
+
381
+ def _build_username_token(self, created: str) -> str:
382
+ username = _xml_escape(self.ws_username or "")
383
+ if self.ws_password_type == "digest": # nosec B105 — a WS-Security password *type*, not a secret
384
+ nonce = self._nonce_fn()
385
+ # Legacy WS-Security UsernameToken digest = Base64(SHA1(Nonce + Created + Password)). This
386
+ # is the spec's token construction, NOT a message-integrity signature (SHA1 here is the
387
+ # profile's defined hash; XML-DSig is deferred — ADR 0015 §4a).
388
+ digest = base64.b64encode(
389
+ hashlib.sha1( # noqa: S324 # nosec B324 — WS-Security UsernameToken profile, not integrity
390
+ nonce + created.encode() + (self.ws_password or "").encode()
391
+ ).digest()
392
+ ).decode("ascii")
393
+ nonce_b64 = base64.b64encode(nonce).decode("ascii")
394
+ return (
395
+ f"<wsse:UsernameToken><wsse:Username>{username}</wsse:Username>"
396
+ f'<wsse:Password Type="{_PW_DIGEST}">{_xml_escape(digest)}</wsse:Password>'
397
+ f'<wsse:Nonce EncodingType="{_NONCE_ENC}">{nonce_b64}</wsse:Nonce>'
398
+ f"<wsu:Created>{created}</wsu:Created></wsse:UsernameToken>"
399
+ )
400
+ password = _xml_escape(self.ws_password or "")
401
+ return (
402
+ f"<wsse:UsernameToken><wsse:Username>{username}</wsse:Username>"
403
+ f'<wsse:Password Type="{_PW_TEXT}">{password}</wsse:Password></wsse:UsernameToken>'
404
+ )
405
+
406
+ def _wrap_envelope(self, body_fragment: str) -> str:
407
+ """Wrap the Handler's operation ``<Body>`` fragment in a transport-built envelope + stamped
408
+ ``<Header>`` (ADR 0015 §2). The fragment is well-formedness-checked and lint-screened first."""
409
+ _assert_well_formed_fragment(body_fragment)
410
+ _reject_ws_leak(body_fragment)
411
+ now = self._now_fn()
412
+ header = ""
413
+ if self.ws_addressing:
414
+ header += self._build_wsa_header()
415
+ if self.ws_security:
416
+ header += self._build_wsse_header(now)
417
+ return (
418
+ _ENV_OPEN
419
+ + f"<soap:Header>{header}</soap:Header>"
420
+ + f"<soap:Body>{body_fragment}</soap:Body>"
421
+ + "</soap:Envelope>"
422
+ )
423
+
424
+ async def send(self, payload: str) -> DeliveryResponse | None:
425
+ # WS-* mode: the Handler returned only the <Body> fragment; wrap + stamp here (post-queue
426
+ # boundary), so the per-call MessageID/Timestamp/Nonce never live in a pure transform.
427
+ if self._ws_mode:
428
+ payload = self._wrap_envelope(payload)
429
+ body, status = await asyncio.to_thread(self._post, payload)
430
+ # A fault can arrive inside a 2xx body, so classify it. Transport-level faults (non-2xx,
431
+ # URL/timeout) already raised inside _post, for both modes.
432
+ failure = _classify_soap(status, body)
433
+ if not self.capture_response:
434
+ if failure is not None:
435
+ raise failure # byte-identical: a 2xx <Fault> still dead-letters/retries
436
+ return None
437
+ if failure is not None:
438
+ # Capturing: record the application <Fault> as a rejected reply rather than raising, so the
439
+ # row is delivered-with-a-rejection (operators reconcile from the captured response).
440
+ return DeliveryResponse(
441
+ body=body, outcome="rejected", detail=f"SOAP fault (HTTP {status})"
442
+ )
443
+ if not body:
444
+ return DeliveryResponse(body="", outcome="no_reply", detail=f"HTTP {status}")
445
+ return DeliveryResponse(body=body, outcome="accepted", detail=f"HTTP {status}")
446
+
447
+ async def test_connection(self) -> None:
448
+ await asyncio.to_thread(self._probe)
449
+
450
+ def _probe(self) -> None:
451
+ # Reachability only: a HEAD reaches the endpoint without POSTing an envelope. An HTTP response
452
+ # means the host answered (a 405 is still a pass), but a 401/403 means the configured
453
+ # credentials would be rejected — surface that as a failure. Connection/DNS/TLS/timeout fails.
454
+ req = urllib.request.Request( # noqa: S310 # nosec B310 — scheme constrained to http(s) in __init__
455
+ self.url, headers=self._headers, method="HEAD"
456
+ )
457
+ try:
458
+ with self._opener.open(req, timeout=self.timeout) as resp:
459
+ resp.read()
460
+ except urllib.error.HTTPError as exc:
461
+ if exc.code in (401, 403):
462
+ raise DeliveryError(
463
+ f"SOAP {_redact_url(self.url)} returned HTTP {exc.code} (check credentials)"
464
+ ) from exc
465
+ return # any other status (the host answered) → reachable
466
+ except urllib.error.URLError as exc:
467
+ raise DeliveryError(f"SOAP {_redact_url(self.url)} unreachable: {exc.reason}") from exc
468
+ except (TimeoutError, OSError) as exc:
469
+ raise DeliveryError(f"SOAP {_redact_url(self.url)} failed: {exc}") from exc
470
+
471
+ def _post(self, payload: str) -> tuple[str, int]:
472
+ # payload is the FINAL wire body (in WS-* mode send() already wrapped + stamped the envelope),
473
+ # so signing over these bytes covers exactly what the partner receives.
474
+ data = payload.encode(self.encoding)
475
+ headers = self._headers
476
+ if self._signer is not None:
477
+ # ASVS 4.1.5 (ADR 0018): detached JWS over the envelope, minted off-loop past the queue
478
+ # boundary so a retry re-mints it (re-run purity holds, like the WS-Security nonce).
479
+ headers = {**self._headers, **self._signer.signature_headers(data)}
480
+ req = urllib.request.Request( # noqa: S310 # nosec B310 — scheme constrained to http(s) in __init__
481
+ self.url,
482
+ data=data,
483
+ headers=headers,
484
+ method="POST",
485
+ )
486
+ try:
487
+ with self._opener.open(req, timeout=self.timeout) as resp:
488
+ body = resp.read().decode(self.encoding, errors="replace")
489
+ status = int(getattr(resp, "status", 200))
490
+ return body, status
491
+ except urllib.error.HTTPError as exc:
492
+ try:
493
+ body = exc.read().decode(self.encoding, errors="replace")
494
+ except Exception: # noqa: BLE001 - a body we can't read just becomes status-only
495
+ body = ""
496
+ # A non-2xx status: _classify_soap always returns a failure here (it returns None only on
497
+ # 2xx). This is a transport-level fault — raised for BOTH capturing and non-capturing.
498
+ raise (
499
+ _classify_soap(exc.code, body) or DeliveryError(f"SOAP HTTP {exc.code}")
500
+ ) from exc
501
+ except urllib.error.URLError as exc:
502
+ raise DeliveryError(f"SOAP {_redact_url(self.url)} unreachable: {exc.reason}") from exc
503
+ except (TimeoutError, OSError) as exc:
504
+ raise DeliveryError(f"SOAP {_redact_url(self.url)} failed: {exc}") from exc
505
+
506
+
507
+ register_destination(ConnectorType.SOAP, SoapDestination)