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,247 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""FastAPI authentication + authorization dependencies (deny-by-default).
|
|
4
|
+
|
|
5
|
+
``require(*permissions)`` is a dependency factory applied to every protected route. Once an enabled
|
|
6
|
+
:class:`AuthService` is wired (the ``serve`` path) it enforces the bearer token plus the listed
|
|
7
|
+
permissions. When **no** AuthService is attached the behaviour is **fail-closed**: the route is
|
|
8
|
+
denied unless the app was explicitly built with ``allow_no_auth=True`` (the in-process embedding /
|
|
9
|
+
local-dev opt-in), in which case it returns a full-access *system* identity. This prevents an
|
|
10
|
+
``create_app(engine)`` that is accidentally served from silently granting unauthenticated full
|
|
11
|
+
access (SYS-1). ``authorize_ws`` is the WebSocket equivalent (it returns ``None`` instead of
|
|
12
|
+
raising, so the caller can close the socket cleanly).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
|
|
20
|
+
from fastapi import HTTPException, Request, WebSocket, status
|
|
21
|
+
|
|
22
|
+
from messagefoundry.auth import AuthProvider, Identity, Permission, Role
|
|
23
|
+
from messagefoundry.auth.service import AuthService
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Identity used when auth is explicitly disabled via allow_no_auth (embedding/dev): full access.
|
|
28
|
+
_SYSTEM_IDENTITY = Identity.build(
|
|
29
|
+
user_id="system", username="system", auth_provider=AuthProvider.LOCAL, roles=list(Role)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# While an account is flagged to rotate its password, only these self-service routes stay reachable.
|
|
33
|
+
_MUST_CHANGE_EXEMPT_PATHS = frozenset({"/auth/logout", "/auth/me", "/me/password"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_auth(request: Request) -> AuthService | None:
|
|
37
|
+
"""The attached :class:`AuthService`, or ``None`` when auth is not configured."""
|
|
38
|
+
auth: AuthService | None = getattr(request.app.state, "auth", None)
|
|
39
|
+
return auth
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _allow_no_auth(app_state: object) -> bool:
|
|
43
|
+
"""Whether this app explicitly opted out of auth (embedding/dev). Default: fail-closed."""
|
|
44
|
+
return bool(getattr(app_state, "allow_no_auth", False))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def bearer_token(request: Request) -> str | None:
|
|
48
|
+
"""Extract a ``Bearer`` token from the Authorization header, if present."""
|
|
49
|
+
header = request.headers.get("Authorization", "")
|
|
50
|
+
if header.startswith("Bearer "):
|
|
51
|
+
return header[len("Bearer ") :].strip() or None
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _client_ip(request: Request) -> str | None:
|
|
56
|
+
"""The caller's client address, matching how login records it on the session (``_client`` in
|
|
57
|
+
``auth_routes``). Used by the WP-L3-13 new-client-IP risk signal so the comparison is
|
|
58
|
+
apples-to-apples. (Forwarded-header resolution behind a trusted proxy is WP-15, not yet built.)"""
|
|
59
|
+
return request.client.host if request.client else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require(*permissions: Permission) -> Callable[[Request], Awaitable[Identity]]:
|
|
63
|
+
"""Build a dependency that authenticates the caller and asserts each of ``permissions``."""
|
|
64
|
+
|
|
65
|
+
async def dependency(request: Request) -> Identity:
|
|
66
|
+
auth = get_auth(request)
|
|
67
|
+
if auth is None or not auth.enabled:
|
|
68
|
+
if _allow_no_auth(request.app.state):
|
|
69
|
+
return _SYSTEM_IDENTITY
|
|
70
|
+
raise HTTPException(
|
|
71
|
+
status.HTTP_503_SERVICE_UNAVAILABLE, "authentication is not configured"
|
|
72
|
+
)
|
|
73
|
+
identity = await auth.identity_for_token(bearer_token(request))
|
|
74
|
+
if identity is None:
|
|
75
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
|
76
|
+
if identity.must_change_password and request.url.path not in _MUST_CHANGE_EXEMPT_PATHS:
|
|
77
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, "password change required")
|
|
78
|
+
for permission in permissions:
|
|
79
|
+
if not identity.has(permission):
|
|
80
|
+
await auth.audit_permission_denied(identity, permission, request.url.path)
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status.HTTP_403_FORBIDDEN, f"missing permission: {permission.value}"
|
|
83
|
+
)
|
|
84
|
+
return identity
|
|
85
|
+
|
|
86
|
+
return dependency
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def require_phi_read(*permissions: Permission) -> Callable[[Request], Awaitable[Identity]]:
|
|
90
|
+
"""Like :func:`require`, plus a **per-actor anti-automation throttle** for the PHI-read endpoints
|
|
91
|
+
(`/messages`, `/messages/{id}`, `/dead-letters`) — bounds scripted PHI harvesting beyond the
|
|
92
|
+
pagination + access-audit controls (ASVS 2.4.1). A throttled read is **logged** (not silent) and
|
|
93
|
+
returns 429. No throttle on the embedding/no-auth path (there's no per-actor identity to key on)."""
|
|
94
|
+
base = require(*permissions)
|
|
95
|
+
|
|
96
|
+
async def dependency(request: Request) -> Identity:
|
|
97
|
+
identity = await base(request)
|
|
98
|
+
auth = get_auth(request)
|
|
99
|
+
if auth is not None and not auth.allow_phi_read(identity.user_id):
|
|
100
|
+
log.warning(
|
|
101
|
+
"PHI-read throttled (anti-automation): actor=%s path=%s",
|
|
102
|
+
identity.username,
|
|
103
|
+
request.url.path,
|
|
104
|
+
)
|
|
105
|
+
raise HTTPException(
|
|
106
|
+
status.HTTP_429_TOO_MANY_REQUESTS,
|
|
107
|
+
"too many requests; please slow down",
|
|
108
|
+
headers={"Retry-After": "10"},
|
|
109
|
+
)
|
|
110
|
+
return identity
|
|
111
|
+
|
|
112
|
+
return dependency
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def require_step_up(*permissions: Permission) -> Callable[[Request], Awaitable[Identity]]:
|
|
116
|
+
"""Like :func:`require`, plus **step-up re-verification** (ASVS 7.5.3): the caller's session must
|
|
117
|
+
have re-proved its credential — at login or via ``POST /me/reauth`` — within
|
|
118
|
+
``[auth].step_up_max_age_seconds``. Gates the highly sensitive admin / replay / config flows; a
|
|
119
|
+
stale session is refused with 403 (the console then prompts to re-authenticate and retries). The
|
|
120
|
+
embedding/no-auth path is unaffected (there is no session to step up)."""
|
|
121
|
+
base = require(*permissions)
|
|
122
|
+
|
|
123
|
+
async def dependency(request: Request) -> Identity:
|
|
124
|
+
identity = await base(request)
|
|
125
|
+
auth = get_auth(request)
|
|
126
|
+
if auth is not None and auth.enabled:
|
|
127
|
+
token = bearer_token(request)
|
|
128
|
+
# Second factor first (WP-14, ASVS 6.3.3): an MFA-required session that has not verified
|
|
129
|
+
# its TOTP / recovery code cannot perform a sensitive op until it does. A distinct header
|
|
130
|
+
# tells the console to prompt for a code rather than a password reauth.
|
|
131
|
+
if not await auth.mfa_satisfied(token):
|
|
132
|
+
raise HTTPException(
|
|
133
|
+
status.HTTP_403_FORBIDDEN,
|
|
134
|
+
"multi-factor verification required; POST /auth/mfa-verify then retry",
|
|
135
|
+
headers={"X-MFA-Required": "1"},
|
|
136
|
+
)
|
|
137
|
+
# Contextual-risk layer (WP-L3-13, ASVS 8.4.2): a sensitive admin action from a client IP
|
|
138
|
+
# the session has not verified from forces a fresh step-up (and audits + notifies). A
|
|
139
|
+
# successful POST /me/reauth re-anchors the session to the new IP, so this then clears.
|
|
140
|
+
new_ip = await auth.flag_new_client_ip(
|
|
141
|
+
token, _client_ip(request), path=request.url.path
|
|
142
|
+
)
|
|
143
|
+
if new_ip or not await auth.has_recent_step_up(token):
|
|
144
|
+
raise HTTPException(
|
|
145
|
+
status.HTTP_403_FORBIDDEN,
|
|
146
|
+
"step-up re-verification required; POST /me/reauth then retry",
|
|
147
|
+
headers={"X-Step-Up-Required": "1"},
|
|
148
|
+
)
|
|
149
|
+
return identity
|
|
150
|
+
|
|
151
|
+
return dependency
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def require_reauth_only(*permissions: Permission) -> Callable[[Request], Awaitable[Identity]]:
|
|
155
|
+
"""Like :func:`require_step_up` but with **only** the password step-up — **not** the MFA gate.
|
|
156
|
+
|
|
157
|
+
Used by the MFA *enrollment* endpoints: a user enrolling their first second factor (or a
|
|
158
|
+
``require_mfa`` administrator who has not enrolled yet) cannot satisfy an MFA gate, so a
|
|
159
|
+
:func:`require_step_up` there would deadlock. Re-proving the password still defends a stolen
|
|
160
|
+
session from silently enrolling an attacker-controlled authenticator (WP-14)."""
|
|
161
|
+
base = require(*permissions)
|
|
162
|
+
|
|
163
|
+
async def dependency(request: Request) -> Identity:
|
|
164
|
+
identity = await base(request)
|
|
165
|
+
auth = get_auth(request)
|
|
166
|
+
if auth is not None and auth.enabled:
|
|
167
|
+
token = bearer_token(request)
|
|
168
|
+
# Same new-client-IP contextual-risk layer as require_step_up (WP-L3-13); the MFA gate is
|
|
169
|
+
# intentionally skipped here (enrollment would otherwise deadlock — see the docstring).
|
|
170
|
+
new_ip = await auth.flag_new_client_ip(
|
|
171
|
+
token, _client_ip(request), path=request.url.path
|
|
172
|
+
)
|
|
173
|
+
if new_ip or not await auth.has_recent_step_up(token):
|
|
174
|
+
raise HTTPException(
|
|
175
|
+
status.HTTP_403_FORBIDDEN,
|
|
176
|
+
"step-up re-verification required; POST /me/reauth then retry",
|
|
177
|
+
headers={"X-Step-Up-Required": "1"},
|
|
178
|
+
)
|
|
179
|
+
return identity
|
|
180
|
+
|
|
181
|
+
return dependency
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def optional_identity(request: Request) -> Identity | None:
|
|
185
|
+
"""Best-effort caller identity that **never raises** — for read-only, non-PHI endpoints (e.g.
|
|
186
|
+
``GET /ai/policy``) that must answer even to a tokenless client, while still reporting the
|
|
187
|
+
caller's RBAC when a valid token is present.
|
|
188
|
+
|
|
189
|
+
Returns the full-access system identity when auth is disabled-with-``allow_no_auth`` (embedding/
|
|
190
|
+
dev); ``None`` when auth is unconfigured/fail-closed or the token is missing/invalid. The
|
|
191
|
+
``must_change_password`` gate is intentionally *not* applied — this surfaces non-sensitive policy,
|
|
192
|
+
not PHI."""
|
|
193
|
+
auth = get_auth(request)
|
|
194
|
+
if auth is None or not auth.enabled:
|
|
195
|
+
return _SYSTEM_IDENTITY if _allow_no_auth(request.app.state) else None
|
|
196
|
+
return await auth.identity_for_token(bearer_token(request))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def ws_token(websocket: WebSocket) -> str | None:
|
|
200
|
+
"""Extract a WebSocket bearer token from the Authorization header.
|
|
201
|
+
|
|
202
|
+
Header-only: the legacy ``?token=`` query-string fallback was removed because a session token in
|
|
203
|
+
a URL leaks into proxy/access logs and the Referer header (ASVS Session Management; API-3). The
|
|
204
|
+
console already sends the token via the ``Authorization`` header."""
|
|
205
|
+
header = websocket.headers.get("Authorization", "")
|
|
206
|
+
if header.startswith("Bearer "):
|
|
207
|
+
return header[len("Bearer ") :].strip() or None
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ws_origin_allowed(websocket: WebSocket) -> bool:
|
|
212
|
+
"""Whether the WebSocket handshake's ``Origin`` is acceptable (ASVS 4.4.2).
|
|
213
|
+
|
|
214
|
+
A native (non-browser) client like the desktop console sends **no** ``Origin`` header — that is
|
|
215
|
+
allowed. A browser always sends one; it is allowed only if listed in ``[api].ws_allowed_origins``
|
|
216
|
+
(default empty → every browser Origin is rejected). This blocks cross-site WebSocket hijacking
|
|
217
|
+
at the handshake, before ``accept()``."""
|
|
218
|
+
origin = websocket.headers.get("origin")
|
|
219
|
+
if not origin:
|
|
220
|
+
return True # native client (no browser Origin) — the only shipped client
|
|
221
|
+
allowed = getattr(websocket.app.state, "ws_allowed_origins", ()) or ()
|
|
222
|
+
return origin in allowed
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def authorize_ws(websocket: WebSocket, *permissions: Permission) -> Identity | None:
|
|
226
|
+
"""Authorize a WebSocket upgrade: validate the ``Origin`` (4.4.2), then the bearer token from the
|
|
227
|
+
Authorization header and the listed permissions.
|
|
228
|
+
|
|
229
|
+
Returns the :class:`Identity` on success, or ``None`` if auth fails (caller should close).
|
|
230
|
+
"""
|
|
231
|
+
if not _ws_origin_allowed(websocket):
|
|
232
|
+
return None # cross-site / disallowed browser Origin — reject before accept()
|
|
233
|
+
auth: AuthService | None = getattr(websocket.app.state, "auth", None)
|
|
234
|
+
if auth is None or not auth.enabled:
|
|
235
|
+
return _SYSTEM_IDENTITY if _allow_no_auth(websocket.app.state) else None
|
|
236
|
+
identity = await auth.identity_for_token(ws_token(websocket))
|
|
237
|
+
if identity is None:
|
|
238
|
+
return None
|
|
239
|
+
if identity.must_change_password:
|
|
240
|
+
return None # a not-yet-rotated account is locked out of the WS too (mirrors require())
|
|
241
|
+
for permission in permissions:
|
|
242
|
+
if not identity.has(permission):
|
|
243
|
+
# Audit the denial like the HTTP require() path does, so a revoked/under-privileged
|
|
244
|
+
# user probing the stats feed leaves a trail too (review low-9).
|
|
245
|
+
await auth.audit_permission_denied(identity, permission, websocket.url.path)
|
|
246
|
+
return None
|
|
247
|
+
return identity
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""In-process API / WebSocket TLS context (WP-13a, ADR 0002).
|
|
4
|
+
|
|
5
|
+
Builds the ``ssl.SSLContext`` uvicorn terminates the engine API + ``/ws/stats`` WebSocket with, from the
|
|
6
|
+
``[api]`` ``tls_*`` settings. Pure stdlib ``ssl`` — no FastAPI/uvicorn import — so it is unit-testable in
|
|
7
|
+
isolation. The ``tls_min_version`` floor (NIST SP 800-52r2: 1.2+) is enforced via
|
|
8
|
+
``SSLContext.minimum_version``; an encrypted key's passphrase comes from ``MEFOR_API_TLS_KEY_PASSWORD``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ssl
|
|
14
|
+
|
|
15
|
+
from messagefoundry.config.settings import ApiSettings
|
|
16
|
+
from messagefoundry.config.tls_policy import harden_kex_groups, harden_verify_flags
|
|
17
|
+
|
|
18
|
+
__all__ = ["build_api_ssl_context"]
|
|
19
|
+
|
|
20
|
+
# Map the validated tls_min_version floor to the SSLContext minimum (TLS < 1.2 is never allowed).
|
|
21
|
+
_MIN_VERSION = {"1.2": ssl.TLSVersion.TLSv1_2, "1.3": ssl.TLSVersion.TLSv1_3}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_api_ssl_context(api: ApiSettings) -> ssl.SSLContext:
|
|
25
|
+
"""Build the server ``SSLContext`` for the API listener from ``[api].tls_*``.
|
|
26
|
+
|
|
27
|
+
Requires ``api.tls_cert_file`` (the caller checks ``api.tls_enabled`` first). The private key may be
|
|
28
|
+
embedded in the cert PEM (``tls_key_file`` optional). mTLS is **opt-in**: when ``tls_client_ca_file``
|
|
29
|
+
is set, a client cert is **required** and verified against it (console mutual auth); otherwise no
|
|
30
|
+
client auth (the default)."""
|
|
31
|
+
if not api.tls_cert_file:
|
|
32
|
+
raise ValueError("build_api_ssl_context requires [api].tls_cert_file")
|
|
33
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
34
|
+
ctx.minimum_version = _MIN_VERSION[api.tls_min_version]
|
|
35
|
+
ctx.load_cert_chain(
|
|
36
|
+
certfile=api.tls_cert_file,
|
|
37
|
+
keyfile=api.tls_key_file,
|
|
38
|
+
password=api.tls_key_password,
|
|
39
|
+
)
|
|
40
|
+
if api.tls_ciphers:
|
|
41
|
+
ctx.set_ciphers(api.tls_ciphers)
|
|
42
|
+
harden_kex_groups(ctx) # pin approved ECDHE groups where the runtime supports it (ASVS 11.6.2)
|
|
43
|
+
harden_verify_flags(ctx) # strict RFC 5280 cert validation (ASVS 12.1.4)
|
|
44
|
+
if api.tls_client_ca_file:
|
|
45
|
+
ctx.load_verify_locations(cafile=api.tls_client_ca_file)
|
|
46
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
47
|
+
return ctx
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Authentication & RBAC core — provider-agnostic, with no FastAPI/Qt imports.
|
|
4
|
+
|
|
5
|
+
Pure building blocks the API layer composes: the permission catalog and fixed built-in roles
|
|
6
|
+
(:mod:`~messagefoundry.auth.permissions`), the resolved :class:`~messagefoundry.auth.identity.Identity`,
|
|
7
|
+
argon2id password hashing, the password/lockout policy, and opaque session tokens. Like ``store``,
|
|
8
|
+
this package is importable by ``api`` but never imports it (one-way dependency direction).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from messagefoundry.auth.identity import AuthProvider, Identity
|
|
14
|
+
from messagefoundry.auth.passwords import hash_password, needs_rehash, verify_password
|
|
15
|
+
from messagefoundry.auth.permissions import (
|
|
16
|
+
BUILTIN_ROLE_PERMISSIONS,
|
|
17
|
+
ROLE_METADATA,
|
|
18
|
+
Permission,
|
|
19
|
+
Role,
|
|
20
|
+
permissions_for_roles,
|
|
21
|
+
)
|
|
22
|
+
from messagefoundry.auth.policy import PasswordPolicy
|
|
23
|
+
from messagefoundry.auth.tokens import hash_token, mint_token
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AuthProvider",
|
|
27
|
+
"Identity",
|
|
28
|
+
"Permission",
|
|
29
|
+
"Role",
|
|
30
|
+
"BUILTIN_ROLE_PERMISSIONS",
|
|
31
|
+
"ROLE_METADATA",
|
|
32
|
+
"permissions_for_roles",
|
|
33
|
+
"PasswordPolicy",
|
|
34
|
+
"hash_password",
|
|
35
|
+
"verify_password",
|
|
36
|
+
"needs_rehash",
|
|
37
|
+
"mint_token",
|
|
38
|
+
"hash_token",
|
|
39
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
common_passwords.txt — offline common/breached-password screening corpus
|
|
2
|
+
=========================================================================
|
|
3
|
+
|
|
4
|
+
Source : SecLists — Passwords/Common-Credentials/Pwdb_top-10000.txt
|
|
5
|
+
https://github.com/danielmiessler/SecLists
|
|
6
|
+
License: MIT (SecLists is MIT-licensed; the underlying lists aggregate publicly
|
|
7
|
+
disclosed breach corpora). Redistributed here under that license.
|
|
8
|
+
|
|
9
|
+
Contents: the 10,000 most common passwords, one per line, used by
|
|
10
|
+
messagefoundry.auth.policy as an offline membership check (ASVS 6.2.4/6.2.12) so
|
|
11
|
+
a local password that is a known-common/breached value is rejected. No network
|
|
12
|
+
call (no live HIBP lookup); a fuller k-anonymity (SHA-1 prefix) corpus is a
|
|
13
|
+
planned follow-up. The file is data, never executed.
|