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,153 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Password-strength and account-lockout policy (ASVS 6.2.x).
4
+
5
+ Modernized per ASVS 5.0 (WP-3): length-first (15+), **no mandatory character-class composition**
6
+ (the class rules are kept as *opt-in* knobs, default off), plus **offline breached/common-password
7
+ screening**, a small **context-word deny-list** (app/vendor/HL7 terms), and **username-in-password
8
+ rejection** (6.2.11). Defaults remain a direct improvement on Mirth, whose password requirements
9
+ default to zero. Operators tune these via the ``[auth]`` settings section.
10
+
11
+ The breach corpus is a bundled offline top-10k common-password list (see ``data/common_passwords.txt``
12
+ + its ``.NOTICE``); the check is a case-insensitive set membership — no network/live-HIBP call.
13
+ Operators can widen it with an offline ``breach_corpus_file`` (6.2.12) — a plaintext list **or** an
14
+ HIBP-style SHA-1-hash export (``HASH[:count]`` lines, auto-detected), still fully offline. (True HIBP
15
+ k-anonymity needs a live range query, which this on-prem engine deliberately doesn't make.)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import functools
21
+ import hashlib
22
+ import re
23
+ from dataclasses import dataclass
24
+ from importlib.resources import files
25
+
26
+ #: Shortest username we'll substring-match inside a password — below this the false-positive risk on a
27
+ #: legitimate long passphrase outweighs the value (a 2-3 char username fragment is too common).
28
+ _MIN_USERNAME_MATCH = 4
29
+
30
+ #: A line in an HIBP-style SHA-1 export: 40 hex chars, optionally ``:<count>``. Used to auto-detect a
31
+ #: hashed corpus from its first entry.
32
+ _HASH_LINE = re.compile(r"[0-9A-Fa-f]{40}(:\d+)?")
33
+
34
+ #: App/vendor/protocol terms a local password must not *contain* (case-insensitive) — so an obvious
35
+ #: in-context credential like ``messagefoundry2026`` or ``Mefor-Admin!`` is rejected (ASVS 6.2.5).
36
+ #: Deliberately app-specific (not a generic dictionary) to keep false-positives rare; the broader
37
+ #: "common word" coverage comes from the breach corpus.
38
+ CONTEXT_WORDS: frozenset[str] = frozenset(
39
+ {
40
+ "messagefoundry",
41
+ "mefor",
42
+ "mllp",
43
+ "hl7",
44
+ "corepoint",
45
+ "mirth",
46
+ "rhapsody",
47
+ "changeme",
48
+ "bootstrap",
49
+ "admin",
50
+ "administrator",
51
+ "password",
52
+ }
53
+ )
54
+
55
+
56
+ @functools.lru_cache(maxsize=1)
57
+ def _common_passwords() -> frozenset[str]:
58
+ """The bundled offline common/breached-password set (lower-cased), loaded once and cached."""
59
+ data = (files("messagefoundry.auth") / "data" / "common_passwords.txt").read_bytes()
60
+ text = data.decode("utf-8", "ignore")
61
+ return frozenset(line.strip().lower() for line in text.splitlines() if line.strip())
62
+
63
+
64
+ @functools.lru_cache(maxsize=4)
65
+ def _operator_corpus(path: str) -> tuple[frozenset[str], bool]:
66
+ """Load an operator-supplied offline breach corpus, returning ``(entries, hashed)``. Format is
67
+ auto-detected from the first non-empty line: an HIBP-style SHA-1 export (``HASH[:count]``) is
68
+ stored as upper-hex hashes (``hashed=True``); anything else is a plaintext list stored lower-cased.
69
+ Loaded once per path and cached. Raises ``OSError`` if the file can't be read (the caller degrades
70
+ gracefully; a configured-but-unreadable corpus is warned about at startup)."""
71
+ entries: set[str] = set()
72
+ hashed: bool | None = None
73
+ with open(path, encoding="utf-8", errors="ignore") as handle:
74
+ for raw in handle:
75
+ line = raw.strip()
76
+ if not line:
77
+ continue
78
+ if hashed is None: # detect format from the first real entry
79
+ hashed = _HASH_LINE.fullmatch(line) is not None
80
+ entries.add(line.split(":", 1)[0].upper() if hashed else line.lower())
81
+ return frozenset(entries), bool(hashed)
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class PasswordPolicy:
86
+ """Rules applied to *local* passwords (AD passwords are governed by the directory).
87
+
88
+ ASVS-aligned defaults: a 15-char minimum, **no** mandatory character classes (the ``require_*``
89
+ flags are opt-in, default off), and breach + context screening on. ``violations`` is the single
90
+ enforcement point — used on both create-user and change-password.
91
+ """
92
+
93
+ min_length: int = 15
94
+ require_uppercase: bool = False
95
+ require_lowercase: bool = False
96
+ require_digit: bool = False
97
+ require_symbol: bool = False
98
+ check_breached: bool = True # reject known common/breached passwords (offline corpus)
99
+ check_context: bool = True # reject passwords containing app/vendor/HL7 terms
100
+ check_username: bool = True # reject passwords containing the user's own username (6.2.11)
101
+ breach_corpus_file: str | None = None # optional operator-supplied offline corpus (6.2.12)
102
+ lockout_threshold: int = 5 # consecutive failed logins before the account locks
103
+ lockout_minutes: int = 15 # how long a locked account stays locked
104
+
105
+ def violations(self, password: str, *, username: str | None = None) -> list[str]:
106
+ """Return clauses completing *"password must …"*; an empty list means the password is
107
+ acceptable. Order: length → opt-in character classes → breach → username → context.
108
+
109
+ ``username`` enables the 6.2.11 own-username check (omit it where there is no user context,
110
+ e.g. generating the bootstrap password)."""
111
+ problems: list[str] = []
112
+ if len(password) < self.min_length:
113
+ problems.append(f"be at least {self.min_length} characters")
114
+ if self.require_uppercase and not any(c.isupper() for c in password):
115
+ problems.append("contain an uppercase letter")
116
+ if self.require_lowercase and not any(c.islower() for c in password):
117
+ problems.append("contain a lowercase letter")
118
+ if self.require_digit and not any(c.isdigit() for c in password):
119
+ problems.append("contain a digit")
120
+ if self.require_symbol and all(c.isalnum() for c in password):
121
+ problems.append("contain a symbol")
122
+ lowered = password.lower()
123
+ if self.check_breached and (
124
+ lowered in _common_passwords() or self._in_operator_corpus(password)
125
+ ):
126
+ problems.append("not be a common or breached password")
127
+ if (
128
+ self.check_username
129
+ and username
130
+ and len(username) >= _MIN_USERNAME_MATCH
131
+ and username.lower() in lowered
132
+ ):
133
+ problems.append("not contain your username")
134
+ if self.check_context and any(word in lowered for word in CONTEXT_WORDS):
135
+ problems.append("not contain application or vendor terms")
136
+ return problems
137
+
138
+ def _in_operator_corpus(self, password: str) -> bool:
139
+ """Whether ``password`` is in the operator-supplied corpus (if one is configured). Best-effort:
140
+ a missing/unreadable corpus file returns ``False`` rather than breaking a password change — the
141
+ misconfiguration is warned about once at startup (see ``AuthService``)."""
142
+ if not self.breach_corpus_file:
143
+ return False
144
+ try:
145
+ entries, hashed = _operator_corpus(self.breach_corpus_file)
146
+ except OSError:
147
+ return False
148
+ if hashed:
149
+ digest = (
150
+ hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
151
+ )
152
+ return digest in entries
153
+ return password.lower() in entries
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """In-process sliding-window rate limiter for the unauthenticated auth surface (AUTH-RATE).
4
+
5
+ Bounds brute-force / password-spray and argon2 CPU-burn on ``/auth/login`` and friends *ahead*
6
+ of the per-account lockout (which a spray across many usernames never trips). It is in-process and
7
+ per-app, **not** distributed — an exposed or multi-host deployment must additionally front the API
8
+ with a proxy/WAF limiter. Decisions use ``time.monotonic()`` so a wall-clock step can't widen the
9
+ window. Calls are synchronous and complete without ``await``, so they're atomic on the event loop.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from collections import deque
16
+
17
+ __all__ = ["SlidingWindowRateLimiter"]
18
+
19
+
20
+ class SlidingWindowRateLimiter:
21
+ """Allow up to ``per_key`` hits per key and ``glob`` hits overall within ``window_seconds``.
22
+
23
+ A falsy ``per_key``/``glob`` disables that dimension. Empty per-key buckets are dropped as they
24
+ age out, so memory is bounded by the number of *active* keys in the window.
25
+ """
26
+
27
+ def __init__(self, *, per_key: int, glob: int, window_seconds: float = 60.0) -> None:
28
+ self._per_key = per_key
29
+ self._global = glob
30
+ self._window = window_seconds
31
+ self._hits: dict[str, deque[float]] = {}
32
+ self._global_hits: deque[float] = deque()
33
+
34
+ def _prune(self, dq: deque[float], now: float) -> None:
35
+ cutoff = now - self._window
36
+ while dq and dq[0] <= cutoff:
37
+ dq.popleft()
38
+
39
+ def allow(self, key: str) -> bool:
40
+ """Record and allow an attempt for ``key``, or return ``False`` if it would exceed a limit."""
41
+ now = time.monotonic()
42
+ self._prune(self._global_hits, now)
43
+ bucket = self._hits.get(key)
44
+ if bucket is not None:
45
+ self._prune(bucket, now)
46
+ if not bucket:
47
+ del self._hits[key]
48
+ bucket = None
49
+ global_full = bool(self._global) and len(self._global_hits) >= self._global
50
+ key_full = bucket is not None and bool(self._per_key) and len(bucket) >= self._per_key
51
+ if global_full or key_full:
52
+ return False # a rejected attempt does not count toward the window
53
+ self._global_hits.append(now)
54
+ self._hits.setdefault(key, deque()).append(now)
55
+ return True