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,71 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """The resolved identity of an authenticated caller, plus the auth-provider enum.
4
+
5
+ An :class:`Identity` is built once per request from the session's user and carries the roles already
6
+ flattened to a permission set, so the API authorization dependencies can answer ``has(permission)``
7
+ without touching the store.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+
16
+ from messagefoundry.auth.permissions import Permission, Role, permissions_for_roles
17
+
18
+
19
+ class AuthProvider(str, Enum):
20
+ """How a user authenticates. ``local`` users carry a password hash; ``ad`` users bind to AD."""
21
+
22
+ LOCAL = "local"
23
+ AD = "ad"
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Identity:
28
+ """An authenticated user with roles resolved to a flat, deny-by-default permission set."""
29
+
30
+ user_id: str
31
+ username: str
32
+ auth_provider: AuthProvider
33
+ roles: frozenset[Role]
34
+ permissions: frozenset[Permission]
35
+ must_change_password: bool = False
36
+ #: Per-channel RBAC scope: connections this user's *operational* permissions apply to. ``None``
37
+ #: = all channels (the default / Administrators). See docs/security/PHASE-8C-RBAC.md.
38
+ allowed_channels: frozenset[str] | None = None
39
+
40
+ @classmethod
41
+ def build(
42
+ cls,
43
+ *,
44
+ user_id: str,
45
+ username: str,
46
+ auth_provider: AuthProvider,
47
+ roles: Iterable[Role],
48
+ must_change_password: bool = False,
49
+ allowed_channels: frozenset[str] | None = None,
50
+ ) -> Identity:
51
+ """Construct an identity, resolving ``roles`` to their union of permissions."""
52
+ role_set = frozenset(roles)
53
+ return cls(
54
+ user_id=user_id,
55
+ username=username,
56
+ auth_provider=auth_provider,
57
+ roles=role_set,
58
+ permissions=permissions_for_roles(role_set),
59
+ must_change_password=must_change_password,
60
+ allowed_channels=allowed_channels,
61
+ )
62
+
63
+ def has(self, permission: Permission) -> bool:
64
+ """True iff one of this identity's roles grants ``permission``."""
65
+ return permission in self.permissions
66
+
67
+ def can_access_channel(self, channel_id: str | None) -> bool:
68
+ """True iff the user's per-channel scope permits ``channel_id`` (``None`` scope = all)."""
69
+ if self.allowed_channels is None:
70
+ return True
71
+ return channel_id is not None and channel_id in self.allowed_channels
@@ -0,0 +1,264 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Active Directory authentication: LDAP simple-bind + nested-group resolution, and Kerberos SSO.
4
+
5
+ Pure (no FastAPI). ``ldap3`` does a service-account bind to locate the user, then a bind *as the
6
+ user* to verify the password, then resolves group membership (optionally nested via the AD matching
7
+ rule ``LDAP_MATCHING_RULE_IN_CHAIN``). Windows SSO is handled by a SPNEGO server step (``pyspnego``)
8
+ that yields the authenticated principal, whose groups are resolved the same way.
9
+
10
+ All calls here are **synchronous**; :class:`~messagefoundry.auth.service.AuthService` runs them via
11
+ ``asyncio.to_thread`` so the event loop never blocks. ``ldap3``/``spnego`` are imported lazily so a
12
+ local-only deployment never touches them.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import ssl
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ from messagefoundry.config.settings import (
23
+ INSECURE_TLS_ESCAPE_ENV,
24
+ AuthSettings,
25
+ insecure_tls_allowed,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941" # AD nested-group ("member in chain")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AdPrincipal:
35
+ """An authenticated AD user: identity attributes + the set of groups governing role mapping.
36
+
37
+ ``groups`` holds **lower-cased** identifiers — both each group's DN and its ``sAMAccountName`` —
38
+ so the admin can map roles by whichever form they configured in ``ad_group_role_map``.
39
+ """
40
+
41
+ username: str
42
+ display_name: str | None
43
+ email: str | None
44
+ dn: str
45
+ groups: frozenset[str]
46
+
47
+
48
+ class LdapError(RuntimeError):
49
+ """LDAP/Kerberos configuration or connectivity failure (distinct from rejected credentials)."""
50
+
51
+
52
+ def _escape_filter(value: str) -> str:
53
+ """RFC 4515 escaping for values interpolated into an LDAP search filter."""
54
+ out: list[str] = []
55
+ for ch in value:
56
+ if ch in "\\*()\x00":
57
+ out.append("\\%02x" % ord(ch))
58
+ else:
59
+ out.append(ch)
60
+ return "".join(out)
61
+
62
+
63
+ def _attr(entry: Any, name: str) -> str | None:
64
+ if name not in entry:
65
+ return None
66
+ value = entry[name].value
67
+ return str(value) if value else None
68
+
69
+
70
+ def _multi(entry: Any, name: str) -> list[str]:
71
+ if name not in entry:
72
+ return []
73
+ return [str(v) for v in entry[name].values]
74
+
75
+
76
+ def _cn_of(dn: str) -> str | None:
77
+ head = dn.split(",", 1)[0]
78
+ return head[3:] if head[:3].upper() == "CN=" else None
79
+
80
+
81
+ class LdapAuthenticator:
82
+ """Binds against Active Directory over LDAPS and resolves a user's (nested) group membership."""
83
+
84
+ def __init__(self, settings: AuthSettings) -> None:
85
+ if not settings.ad_server or not settings.ad_user_search_base:
86
+ raise LdapError("AD is enabled but ad_server / ad_user_search_base are not configured")
87
+ if not settings.ad_bind_dn or not settings.ad_bind_password:
88
+ raise LdapError("AD is enabled but the service-account bind is not configured")
89
+ self._s = settings
90
+ # A disabled-cert-verification posture (ad_tls_verify=false over LDAPS) makes the service-
91
+ # account and user binds MITM-able, so it now REFUSES at startup unless the operator sets the
92
+ # explicit MEFOR_ALLOW_INSECURE_TLS dev escape — it can no longer be silently turned on in
93
+ # production (ASVS 12.3.2). With the escape set, we still warn loudly once at startup.
94
+ if str(settings.ad_server).lower().startswith("ldaps") and not settings.ad_tls_verify:
95
+ if not insecure_tls_allowed():
96
+ raise LdapError(
97
+ "ad_tls_verify=false disables LDAPS certificate verification (MITM risk). Use a "
98
+ f"trusted CA via ad_tls_ca_cert_file, or set {INSECURE_TLS_ESCAPE_ENV}=1 to "
99
+ "explicitly allow it for a trusted-network dev/test bind."
100
+ )
101
+ logger.warning(
102
+ "AD LDAPS certificate verification is DISABLED (ad_tls_verify=false, permitted by "
103
+ "%s) — the service-account and user binds are exposed to MITM; do not use in "
104
+ "production.",
105
+ INSECURE_TLS_ESCAPE_ENV,
106
+ )
107
+
108
+ def _server(self) -> Any:
109
+ import ldap3
110
+
111
+ tls = None
112
+ if str(self._s.ad_server).lower().startswith("ldaps"):
113
+ validate = ssl.CERT_REQUIRED if self._s.ad_tls_verify else ssl.CERT_NONE
114
+ tls = ldap3.Tls(validate=validate, ca_certs_file=self._s.ad_tls_ca_cert_file)
115
+ return ldap3.Server(self._s.ad_server, tls=tls, get_info=ldap3.NONE)
116
+
117
+ def _service_conn(self) -> Any:
118
+ import ldap3
119
+
120
+ return ldap3.Connection(
121
+ self._server(),
122
+ user=self._s.ad_bind_dn,
123
+ password=self._s.ad_bind_password,
124
+ authentication=ldap3.SIMPLE,
125
+ auto_bind=True,
126
+ )
127
+
128
+ def _find_user(self, conn: Any, username: str) -> dict[str, Any] | None:
129
+ import ldap3
130
+
131
+ upn = f"{username}@{self._s.ad_domain}" if self._s.ad_domain else username
132
+ conn.search(
133
+ search_base=self._s.ad_user_search_base,
134
+ search_filter=(
135
+ f"(|(sAMAccountName={_escape_filter(username)})"
136
+ f"(userPrincipalName={_escape_filter(upn)}))"
137
+ ),
138
+ search_scope=ldap3.SUBTREE,
139
+ attributes=[
140
+ "distinguishedName",
141
+ "sAMAccountName",
142
+ "displayName",
143
+ "mail",
144
+ "memberOf",
145
+ "userAccountControl",
146
+ ],
147
+ )
148
+ if not conn.entries:
149
+ return None
150
+ e = conn.entries[0]
151
+ # ACCOUNTDISABLE (0x2): a disabled AD account must not authenticate. The local-user path
152
+ # checks `disabled` up front; the AD password + Kerberos paths both go through here, so
153
+ # rejecting a disabled account at the lookup covers both (review M-18).
154
+ uac = _attr(e, "userAccountControl")
155
+ if uac and uac.isdigit() and (int(uac) & 0x2):
156
+ return None
157
+ return {
158
+ "dn": str(e.entry_dn),
159
+ "username": _attr(e, "sAMAccountName") or username,
160
+ "display_name": _attr(e, "displayName"),
161
+ "email": _attr(e, "mail"),
162
+ "memberOf": _multi(e, "memberOf"),
163
+ }
164
+
165
+ def _resolve_groups(self, conn: Any, user_dn: str, member_of: list[str]) -> frozenset[str]:
166
+ import ldap3
167
+
168
+ groups: set[str] = set()
169
+ for dn in member_of: # direct membership from the user's memberOf attribute
170
+ groups.add(dn.lower())
171
+ cn = _cn_of(dn)
172
+ if cn:
173
+ groups.add(cn.lower())
174
+ if self._s.ad_use_nested_groups and self._s.ad_group_search_base:
175
+ conn.search(
176
+ search_base=self._s.ad_group_search_base,
177
+ search_filter=f"(member:{_MATCHING_RULE_IN_CHAIN}:={_escape_filter(user_dn)})",
178
+ search_scope=ldap3.SUBTREE,
179
+ attributes=["distinguishedName", "sAMAccountName"],
180
+ )
181
+ for e in conn.entries:
182
+ groups.add(str(e.entry_dn).lower())
183
+ sam = _attr(e, "sAMAccountName")
184
+ if sam:
185
+ groups.add(sam.lower())
186
+ return frozenset(groups)
187
+
188
+ def authenticate(self, username: str, password: str) -> AdPrincipal | None:
189
+ """Verify ``username``/``password`` against AD and return the principal, or ``None`` if the
190
+ credentials are rejected. Raises :class:`LdapError` on a connectivity/config failure."""
191
+ import ldap3
192
+
193
+ if not password: # never allow an empty password (it triggers an anonymous bind)
194
+ return None
195
+ try:
196
+ with self._service_conn() as svc:
197
+ info = self._find_user(svc, username)
198
+ if info is None:
199
+ return None
200
+ user_dn = str(info["dn"])
201
+ user_conn = ldap3.Connection(
202
+ self._server(), user=user_dn, password=password, authentication=ldap3.SIMPLE
203
+ )
204
+ if not user_conn.bind():
205
+ return None
206
+ user_conn.unbind()
207
+ groups = self._resolve_groups(svc, user_dn, info["memberOf"])
208
+ except ldap3.core.exceptions.LDAPException as exc: # pragma: no cover - needs real AD
209
+ raise LdapError(str(exc)) from exc
210
+ return AdPrincipal(
211
+ username=str(info["username"]),
212
+ display_name=info["display_name"],
213
+ email=info["email"],
214
+ dn=user_dn,
215
+ groups=groups,
216
+ )
217
+
218
+ def resolve_principal(self, username: str) -> AdPrincipal | None:
219
+ """Look a user up + resolve groups *without* a password — for Kerberos, where SSO already
220
+ proved the identity. Uses the service-account bind only."""
221
+ import ldap3
222
+
223
+ try:
224
+ with self._service_conn() as svc:
225
+ info = self._find_user(svc, username)
226
+ if info is None:
227
+ return None
228
+ user_dn = str(info["dn"])
229
+ groups = self._resolve_groups(svc, user_dn, info["memberOf"])
230
+ except ldap3.core.exceptions.LDAPException as exc: # pragma: no cover - needs real AD
231
+ raise LdapError(str(exc)) from exc
232
+ return AdPrincipal(
233
+ username=str(info["username"]),
234
+ display_name=info["display_name"],
235
+ email=info["email"],
236
+ dn=user_dn,
237
+ groups=groups,
238
+ )
239
+
240
+
241
+ def kerberos_principal(token: bytes, settings: AuthSettings) -> str | None:
242
+ """Complete one SPNEGO server step and return the authenticated sAMAccountName, or ``None``.
243
+
244
+ Experimental — **not a supported v0.1 feature**: off by default (``kerberos_enabled=False``),
245
+ production hardening (CI coverage, keytab/SPN preflight) targeted for 0.2. Single-leg only: no
246
+ NTLM fallback, no mutual-auth response token, no multi-leg challenge handshake. The server must
247
+ have a usable keytab/credential for ``kerberos_spn`` in its environment; the realm suffix
248
+ (``user@REALM``) is stripped to yield the account name.
249
+ """
250
+ import spnego
251
+
252
+ try: # pragma: no cover - requires a domain-joined server + keytab
253
+ server = (
254
+ spnego.server(service=settings.kerberos_spn)
255
+ if settings.kerberos_spn
256
+ else spnego.server()
257
+ )
258
+ server.step(token)
259
+ principal = server.client_principal
260
+ except spnego.exceptions.SpnegoError as exc: # pragma: no cover
261
+ raise LdapError(str(exc)) from exc
262
+ if not principal:
263
+ return None
264
+ return str(principal).split("@", 1)[0]
@@ -0,0 +1,68 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Security-event notification seam (ASVS 6.3.5 / 6.3.7).
4
+
5
+ A tiny, dependency-free contract the auth layer uses to push an **out-of-band** notice to the affected
6
+ user when something security-relevant happens to their account — a suspicious login (lockout, or a
7
+ success after repeated failures) or a credential change (password / email / roles / disable).
8
+
9
+ The contract lives here, in ``auth/``, so :class:`~messagefoundry.auth.service.AuthService` can emit
10
+ events **without importing** ``pipeline/`` (the one-way dependency rule, CLAUDE.md §4). The concrete
11
+ sender — which turns an event into a per-user email over the ``[alerts]`` SMTP transport — lives in
12
+ ``pipeline/`` and is injected into ``AuthService``. Emission is always **best-effort**: a notifier
13
+ failure is logged and never breaks authentication or an admin action.
14
+
15
+ The persistent, pull-based companion (``GET /me/security-events``) is a user-scoped view over the
16
+ existing tamper-evident audit log — these same events are already audited — so a user with no
17
+ deliverable mailbox can still review their security history.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Protocol, runtime_checkable
24
+
25
+ # Event types (the values double as the audit-action suffix / feed category). PHI-free by construction.
26
+ ACCOUNT_LOCKED = "account_locked" # 6.3.5 — repeated failures crossed the lockout threshold
27
+ LOGIN_AFTER_FAILURES = (
28
+ "login_after_failures" # 6.3.5 — first success following >= N failed attempts
29
+ )
30
+ PASSWORD_CHANGED = "password_changed" # nosec B105 — event-type label, not a credential (6.3.7)
31
+ PASSWORD_RESET = "password_reset" # nosec B105 — event label, not a credential; admin-initiated (6.3.7/6.4.6)
32
+ EMAIL_CHANGED = "email_changed" # 6.3.7 — the account's email address was changed
33
+ ROLES_CHANGED = "roles_changed" # 6.3.7 — an admin changed the account's roles
34
+ ACCOUNT_DISABLED = "account_disabled" # 6.3.7 — an admin disabled the account
35
+ MFA_ENABLED = "mfa_enabled" # 6.3.7 — a second factor (TOTP) was enrolled on the account
36
+ MFA_DISABLED = (
37
+ "mfa_disabled" # 6.3.7 — the account's second factor was removed (self-service or admin reset)
38
+ )
39
+ ADMIN_NEW_IP = (
40
+ "admin_action_new_ip" # 8.4.2 — a sensitive admin action from a new/unexpected client IP
41
+ )
42
+
43
+ # First success after this many prior failed attempts is flagged as suspicious (6.3.5). Kept modest and
44
+ # fixed (not an operator knob) so a single fat-fingered password does not generate a notice.
45
+ SUSPICIOUS_LOGIN_FAILURE_THRESHOLD = 3
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SecurityEvent:
50
+ """One notifiable security event. Carries only the affected user's own identifiers + non-PHI
51
+ metadata; the body sent to the user is built from these by the concrete notifier."""
52
+
53
+ event_type: str
54
+ username: str
55
+ email: str | None = None # the affected user's address (push target); None = no mailbox on file
56
+ client_ip: str | None = None # source IP of the triggering request, when known
57
+ detail: dict[str, Any] = field(
58
+ default_factory=dict
59
+ ) # PHI-free extras (e.g. role from/to counts)
60
+
61
+
62
+ @runtime_checkable
63
+ class SecurityNotifier(Protocol):
64
+ """Push a security event to the affected user out-of-band. Implemented in ``pipeline/`` (email over
65
+ the ``[alerts]`` SMTP transport) and injected into :class:`AuthService`. Must be best-effort and
66
+ must not raise into the auth path (the caller still guards it)."""
67
+
68
+ async def notify(self, event: SecurityEvent) -> None: ...
@@ -0,0 +1,53 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """argon2id password hashing (argon2-cffi) for *local* users.
4
+
5
+ AD users never reach here — they authenticate by binding to the directory. The hash string embeds
6
+ its own salt and cost parameters, so it is self-contained and safe to store in the ``users`` table.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from argon2 import PasswordHasher
12
+ from argon2.exceptions import InvalidHashError, VerificationError
13
+
14
+ # argon2id cost parameters, pinned EXPLICITLY rather than relying on argon2-cffi's library defaults
15
+ # (ASVS 11.4.2). Pinning means a library upgrade can't silently weaken (or change) the work factor,
16
+ # and ``needs_rehash`` transparently upgrades any stored hash that predates a future bump. These
17
+ # values meet/exceed OWASP guidance for argon2id (memory ≥ 19 MiB; here 64 MiB, t=3, p=4) and a unit
18
+ # test asserts them so a drift is caught in CI.
19
+ _ARGON2_TIME_COST = 3
20
+ _ARGON2_MEMORY_COST = 65536 # KiB = 64 MiB
21
+ _ARGON2_PARALLELISM = 4
22
+ _ARGON2_HASH_LEN = 32
23
+ _ARGON2_SALT_LEN = 16
24
+
25
+ # One shared, stateless hasher with the pinned argon2id parameters above.
26
+ _hasher = PasswordHasher(
27
+ time_cost=_ARGON2_TIME_COST,
28
+ memory_cost=_ARGON2_MEMORY_COST,
29
+ parallelism=_ARGON2_PARALLELISM,
30
+ hash_len=_ARGON2_HASH_LEN,
31
+ salt_len=_ARGON2_SALT_LEN,
32
+ )
33
+
34
+
35
+ def hash_password(password: str) -> str:
36
+ """Return an argon2id hash (salt + parameters included) to store for a local user."""
37
+ return _hasher.hash(password)
38
+
39
+
40
+ def verify_password(stored_hash: str, password: str) -> bool:
41
+ """True iff ``password`` matches ``stored_hash``. Never raises on a bad password or malformed hash."""
42
+ try:
43
+ return _hasher.verify(stored_hash, password)
44
+ except (VerificationError, InvalidHashError):
45
+ return False
46
+
47
+
48
+ def needs_rehash(stored_hash: str) -> bool:
49
+ """True if ``stored_hash`` uses weaker-than-current parameters and should be upgraded on login."""
50
+ try:
51
+ return _hasher.check_needs_rehash(stored_hash)
52
+ except InvalidHashError:
53
+ return False
@@ -0,0 +1,120 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Permission catalog and the fixed built-in roles for RBAC.
4
+
5
+ Authorization is **deny-by-default**: an action is allowed only when one of the caller's roles grants
6
+ the matching :class:`Permission`. This version ships a *fixed* set of built-in :class:`Role` s (no
7
+ custom-role builder yet), so ``BUILTIN_ROLE_PERMISSIONS`` below is the single source of truth — it is
8
+ seeded into the ``roles`` table on store open and consulted when resolving an
9
+ :class:`~messagefoundry.auth.identity.Identity`.
10
+
11
+ A few permissions (``config:deploy``, ``config:validate``, ``code:edit``, ``service:configure``) have
12
+ no API endpoint yet; they are defined now so the Deployment/Coding roles are complete and those
13
+ endpoints can be gated the moment they land, without a roles migration.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Iterable
19
+ from enum import Enum
20
+
21
+
22
+ class Permission(str, Enum):
23
+ """A single capability a role may grant. The value is the wire/storage string."""
24
+
25
+ MONITORING_READ = "monitoring:read"
26
+ MONITORING_DIAGNOSE = "monitoring:diagnose"
27
+ MESSAGES_READ = "messages:read"
28
+ MESSAGES_VIEW_SUMMARY = "messages:view_summary" # PHI: patient identifiers in list summaries
29
+ MESSAGES_VIEW_RAW = "messages:view_raw" # PHI: the full message body
30
+ MESSAGES_REPLAY = "messages:replay"
31
+ MESSAGES_PURGE = "messages:purge"
32
+ CONNECTIONS_CONTROL = "connections:control"
33
+ CONNECTIONS_TEST = (
34
+ "connections:test" # probe a connection's reachability (POST /connections/{name}/test)
35
+ )
36
+ CONFIG_DEPLOY = "config:deploy" # endpoint lands in a later effort
37
+ CONFIG_VALIDATE = "config:validate" # endpoint lands in a later effort
38
+ CODE_EDIT = "code:edit" # endpoint lands in a later effort
39
+ AI_ASSIST = "ai:assist" # use AI coding assistance (IDE), gated by /ai/policy
40
+ SERVICE_CONFIGURE = "service:configure" # endpoint lands in a later effort
41
+ USERS_READ = "users:read"
42
+ USERS_MANAGE = "users:manage"
43
+ AUDIT_READ = "audit:read"
44
+ APPROVALS_APPROVE = (
45
+ "approvals:approve" # release a pending high-value action (dual-control, 2.3.5)
46
+ )
47
+
48
+
49
+ class Role(str, Enum):
50
+ """A fixed built-in role. The role->permission policy lives in ``BUILTIN_ROLE_PERMISSIONS``."""
51
+
52
+ ADMINISTRATOR = "administrator"
53
+ OPERATOR = "operator"
54
+ DEPLOYMENT = "deployment"
55
+ CODING = "coding"
56
+ VIEWER = "viewer"
57
+ AUDITOR = "auditor"
58
+
59
+
60
+ #: Human-facing (label, description) per role — seeded into the ``roles`` table for listing/admin UI.
61
+ ROLE_METADATA: dict[Role, tuple[str, str]] = {
62
+ Role.ADMINISTRATOR: (
63
+ "Administrator",
64
+ "Full control, including user and service administration.",
65
+ ),
66
+ Role.OPERATOR: (
67
+ "Operator",
68
+ "Day-to-day monitoring and message operations, including PHI viewing.",
69
+ ),
70
+ Role.DEPLOYMENT: ("Deployment", "Deploy and validate the connection/router/handler graph."),
71
+ Role.CODING: ("Coding", "Author and validate Router/Handler code."),
72
+ Role.VIEWER: ("Viewer", "Read-only dashboards and non-PHI message metadata."),
73
+ Role.AUDITOR: ("Auditor", "Read the audit trail (separation of duties)."),
74
+ }
75
+
76
+ _OPERATOR_PERMISSIONS: frozenset[Permission] = frozenset(
77
+ {
78
+ Permission.MONITORING_READ,
79
+ Permission.MONITORING_DIAGNOSE,
80
+ Permission.MESSAGES_READ,
81
+ Permission.MESSAGES_VIEW_SUMMARY,
82
+ Permission.MESSAGES_VIEW_RAW,
83
+ Permission.MESSAGES_REPLAY,
84
+ Permission.MESSAGES_PURGE,
85
+ Permission.CONNECTIONS_CONTROL,
86
+ Permission.CONNECTIONS_TEST,
87
+ }
88
+ )
89
+
90
+ #: The fixed built-in RBAC policy — which permissions each role grants. Holding multiple roles unions.
91
+ BUILTIN_ROLE_PERMISSIONS: dict[Role, frozenset[Permission]] = {
92
+ Role.ADMINISTRATOR: frozenset(Permission), # every permission
93
+ Role.OPERATOR: _OPERATOR_PERMISSIONS,
94
+ Role.DEPLOYMENT: frozenset(
95
+ {
96
+ Permission.MONITORING_READ,
97
+ Permission.CONFIG_DEPLOY,
98
+ Permission.CONFIG_VALIDATE,
99
+ Permission.CONNECTIONS_TEST,
100
+ }
101
+ ),
102
+ Role.CODING: frozenset(
103
+ {
104
+ Permission.MONITORING_READ,
105
+ Permission.CODE_EDIT,
106
+ Permission.CONFIG_VALIDATE,
107
+ Permission.AI_ASSIST,
108
+ }
109
+ ),
110
+ Role.VIEWER: frozenset({Permission.MONITORING_READ, Permission.MESSAGES_READ}),
111
+ Role.AUDITOR: frozenset({Permission.MONITORING_READ, Permission.AUDIT_READ}),
112
+ }
113
+
114
+
115
+ def permissions_for_roles(roles: Iterable[Role]) -> frozenset[Permission]:
116
+ """Union of the permissions granted by ``roles`` (deny-by-default: unknown roles grant nothing)."""
117
+ granted: set[Permission] = set()
118
+ for role in roles:
119
+ granted |= BUILTIN_ROLE_PERMISSIONS.get(role, frozenset())
120
+ return frozenset(granted)