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,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)
|