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,184 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Dual-control (maker-checker) approval workflow for high-value actions (ASVS 2.3.5).
4
+
5
+ Optional and **deny-by-default** (``[approvals]``, off unless enabled). When a gated operation is
6
+ invoked it is **not executed inline**: a pending request (operation key + JSON params + requester) is
7
+ persisted, and a **distinct** second user holding ``approvals:approve`` must release it — the requester
8
+ can never approve their own (enforced server-side). On approval the captured operation is re-executed
9
+ and **both identities** land in the hash-chained audit log. A request older than
10
+ ``[approvals].expiry_hours`` can no longer be approved.
11
+
12
+ The registry (op key -> executor) is populated by the API wiring, where the engine is in scope; this
13
+ module owns only the generic hold/approve/reject mechanics over the ``pending_approvals`` store table.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import time
20
+ from collections.abc import Awaitable, Callable, Mapping
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+ from uuid import uuid4
24
+
25
+ from messagefoundry.config.settings import ApprovalsSettings
26
+ from messagefoundry.store.base import Store
27
+
28
+ #: An executor re-runs a captured operation on approval, returning a small JSON-able result summary.
29
+ Executor = Callable[[Mapping[str, Any]], Awaitable[dict[str, Any]]]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class _Operation:
34
+ key: str
35
+ label: str # human description, surfaced in the pending list + audit
36
+ execute: Executor
37
+
38
+
39
+ class ApprovalError(Exception):
40
+ """A pending-approval decision could not be made. ``status`` is the HTTP code the API should map
41
+ to (404 unknown, 409 already-decided/expired, 403 self-approval)."""
42
+
43
+ def __init__(self, status: int, detail: str) -> None:
44
+ super().__init__(detail)
45
+ self.status = status
46
+ self.detail = detail
47
+
48
+
49
+ class ApprovalGate:
50
+ """Holds the registry of approvable operations and the hold/approve/reject mechanics. One instance
51
+ per app; created with the live store + the resolved ``[approvals]`` settings."""
52
+
53
+ def __init__(self, store: Store, settings: ApprovalsSettings) -> None:
54
+ self._store = store
55
+ self._settings = settings
56
+ self._ops: dict[str, _Operation] = {}
57
+
58
+ def register(self, key: str, label: str, execute: Executor) -> None:
59
+ self._ops[key] = _Operation(key=key, label=label, execute=execute)
60
+
61
+ def _gated(self, operation: str) -> bool:
62
+ return self._settings.enabled and operation in self._settings.operations
63
+
64
+ async def guard(
65
+ self, operation: str, params: Mapping[str, Any], *, requester: str
66
+ ) -> str | None:
67
+ """Call at the start of a gated endpoint, **after** the requester's own permission/scope checks
68
+ pass. If dual-control is active for ``operation``, persist a pending request, audit
69
+ ``approval.requested``, and return its **id** (the endpoint should respond 202). Otherwise
70
+ return ``None`` — the endpoint executes inline exactly as before."""
71
+ if not self._gated(operation):
72
+ return None
73
+ now = time.time()
74
+ approval_id = uuid4().hex
75
+ expires_at = (
76
+ None if self._settings.expiry_hours == 0 else now + self._settings.expiry_hours * 3600.0
77
+ )
78
+ await self._store.create_pending_approval(
79
+ approval_id=approval_id,
80
+ operation=operation,
81
+ params=json.dumps(dict(params), sort_keys=True),
82
+ requester=requester,
83
+ requested_at=now,
84
+ expires_at=expires_at,
85
+ )
86
+ await self._store.record_audit(
87
+ "approval.requested",
88
+ actor=requester,
89
+ detail=json.dumps({"approval_id": approval_id, "operation": operation}),
90
+ )
91
+ return approval_id
92
+
93
+ async def list_pending(self) -> list[dict[str, Any]]:
94
+ rows = await self._store.list_pending_approvals(now=time.time())
95
+ return [
96
+ {
97
+ "id": str(r["id"]),
98
+ "operation": str(r["operation"]),
99
+ "label": self._label(str(r["operation"])),
100
+ "requester": str(r["requester"]),
101
+ "requested_at": float(r["requested_at"]),
102
+ "expires_at": (None if r["expires_at"] is None else float(r["expires_at"])),
103
+ }
104
+ for r in rows
105
+ ]
106
+
107
+ async def approve(self, approval_id: str, *, approver: str) -> dict[str, Any]:
108
+ """Release a pending request: the captured operation is re-executed and both identities are
109
+ audited. Refuses self-approval (the requester is not a valid second approver)."""
110
+ row = await self._require_pending(approval_id)
111
+ if str(row["requester"]) == approver:
112
+ raise ApprovalError(403, "you cannot approve your own request")
113
+ operation = str(row["operation"])
114
+ op = self._ops.get(operation)
115
+ if (
116
+ op is None
117
+ ): # registered op was removed between request and approval — refuse, stay pending
118
+ raise ApprovalError(409, f"operation '{operation}' is no longer available")
119
+ # Transition to 'approved' FIRST (atomic, guards a double-approve race); only then execute.
120
+ if not await self._store.decide_pending_approval(
121
+ approval_id, status="approved", approver=approver, decided_at=time.time()
122
+ ):
123
+ raise ApprovalError(409, "request was already decided")
124
+ params = json.loads(str(row["params"]))
125
+ result = await op.execute(params)
126
+ await self._store.record_audit(
127
+ "approval.approved",
128
+ actor=approver,
129
+ detail=json.dumps(
130
+ {
131
+ "approval_id": approval_id,
132
+ "operation": operation,
133
+ "requester": str(row["requester"]),
134
+ "result": result,
135
+ }
136
+ ),
137
+ )
138
+ return {
139
+ "operation": operation,
140
+ "requested_by": str(row["requester"]),
141
+ "approved_by": approver,
142
+ "result": result,
143
+ }
144
+
145
+ async def reject(self, approval_id: str, *, approver: str) -> dict[str, Any]:
146
+ """Decline a pending request without executing it (audited). Any ``approvals:approve`` holder
147
+ may reject — including the requester cancelling their own."""
148
+ row = await self._require_pending(approval_id)
149
+ if not await self._store.decide_pending_approval(
150
+ approval_id, status="rejected", approver=approver, decided_at=time.time()
151
+ ):
152
+ raise ApprovalError(409, "request was already decided")
153
+ operation = str(row["operation"])
154
+ await self._store.record_audit(
155
+ "approval.rejected",
156
+ actor=approver,
157
+ detail=json.dumps(
158
+ {
159
+ "approval_id": approval_id,
160
+ "operation": operation,
161
+ "requester": str(row["requester"]),
162
+ }
163
+ ),
164
+ )
165
+ return {
166
+ "operation": operation,
167
+ "requested_by": str(row["requester"]),
168
+ "rejected_by": approver,
169
+ }
170
+
171
+ async def _require_pending(self, approval_id: str) -> Any:
172
+ row = await self._store.get_pending_approval(approval_id)
173
+ if row is None:
174
+ raise ApprovalError(404, "no such approval request")
175
+ if str(row["status"]) != "pending":
176
+ raise ApprovalError(409, f"request is already {row['status']}")
177
+ expires_at = row["expires_at"]
178
+ if expires_at is not None and float(expires_at) <= time.time():
179
+ raise ApprovalError(409, "request has expired")
180
+ return row
181
+
182
+ def _label(self, operation: str) -> str:
183
+ op = self._ops.get(operation)
184
+ return op.label if op is not None else operation
@@ -0,0 +1,211 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Pydantic request/response models for the auth + user-administration endpoints."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ # Upper bounds on free-text request fields (API-INPUT): reject absurd inputs before they reach the
10
+ # store or argon2. Generous vs any legitimate value; the password cap also bounds argon2 work.
11
+ _NAME_MAX = 256
12
+ _PASSWORD_MAX = 1024
13
+ _GROUP_MAX = 512
14
+
15
+
16
+ class LoginRequest(BaseModel):
17
+ username: str = Field(max_length=_NAME_MAX)
18
+ password: str = Field(max_length=_PASSWORD_MAX)
19
+ provider: str = Field("local", max_length=16) # 'local' | 'ad'
20
+
21
+
22
+ class CurrentUser(BaseModel):
23
+ user_id: str
24
+ username: str
25
+ auth_provider: str
26
+ roles: list[str]
27
+ permissions: list[str]
28
+
29
+
30
+ class LoginResponse(BaseModel):
31
+ token: str
32
+ token_type: str = "bearer"
33
+ must_change_password: bool = False
34
+ # The password was accepted but a second factor is still required before sensitive operations
35
+ # (WP-14): the client should prompt for a TOTP / recovery code and POST /auth/mfa-verify.
36
+ mfa_required: bool = False
37
+ user: CurrentUser
38
+
39
+
40
+ class ProvidersInfo(BaseModel):
41
+ """What the login screen should offer."""
42
+
43
+ local: bool = True
44
+ ad: bool = False
45
+ kerberos: bool = False
46
+
47
+
48
+ class UserSummary(BaseModel):
49
+ id: str
50
+ username: str
51
+ auth_provider: str
52
+ display_name: str | None = None
53
+ email: str | None = None
54
+ disabled: bool
55
+ roles: list[str]
56
+ channel_scope: list[str] | None = None # per-channel RBAC: allowed connections; None = all
57
+
58
+
59
+ class ChannelScope(BaseModel):
60
+ """A user's per-channel RBAC scope. ``None`` = all channels; a list = exactly those connections."""
61
+
62
+ channels: list[str] | None = Field(default=None, max_length=512)
63
+
64
+
65
+ class UserCreateRequest(BaseModel):
66
+ username: str = Field(max_length=_NAME_MAX)
67
+ password: str = Field(max_length=_PASSWORD_MAX)
68
+ display_name: str | None = Field(default=None, max_length=_NAME_MAX)
69
+ email: str | None = Field(default=None, max_length=_NAME_MAX)
70
+ roles: list[str] = Field(default=[], max_length=64)
71
+
72
+
73
+ class UserUpdateRequest(BaseModel):
74
+ display_name: str | None = Field(default=None, max_length=_NAME_MAX)
75
+ email: str | None = Field(default=None, max_length=_NAME_MAX)
76
+ disabled: bool | None = None
77
+
78
+
79
+ class RolesUpdateRequest(BaseModel):
80
+ roles: list[str] = Field(max_length=64)
81
+
82
+
83
+ class PasswordChangeRequest(BaseModel):
84
+ current_password: str = Field(max_length=_PASSWORD_MAX)
85
+ new_password: str = Field(max_length=_PASSWORD_MAX)
86
+
87
+
88
+ class ReauthRequest(BaseModel):
89
+ """Step-up re-verification (ASVS 7.5.3): the caller re-supplies their current credential to refresh
90
+ the session's step-up window before a highly sensitive operation."""
91
+
92
+ password: str = Field(max_length=_PASSWORD_MAX)
93
+
94
+
95
+ class PasswordResetResponse(BaseModel):
96
+ """The result of an admin password reset (ASVS 6.4.6): a one-time credential returned **once** for
97
+ the administrator to convey out-of-band. The user must change it on first login."""
98
+
99
+ temp_password: str
100
+ must_change_password: bool = True
101
+
102
+
103
+ # --- MFA: native TOTP second factor (WP-14, ASVS 6.3.3) ----------------------
104
+
105
+
106
+ class MfaVerifyRequest(BaseModel):
107
+ """Satisfy a session's second factor with a TOTP code **or** a single-use recovery code."""
108
+
109
+ code: str = Field(max_length=64)
110
+
111
+
112
+ class MfaEnrollResponse(BaseModel):
113
+ """A staged (not-yet-active) TOTP enrollment: the base32 secret + the ``otpauth://`` URI the
114
+ console renders as a QR. Returned once; the secret is not active until confirmed."""
115
+
116
+ secret: str
117
+ otpauth_uri: str
118
+
119
+
120
+ class MfaConfirmRequest(BaseModel):
121
+ """Confirm a staged enrollment by proving a live TOTP code from the authenticator app."""
122
+
123
+ code: str = Field(max_length=16)
124
+
125
+
126
+ class MfaConfirmResponse(BaseModel):
127
+ """The one-time single-use recovery codes minted on enrollment — shown **once** for the user to
128
+ save (lost-authenticator escape hatch)."""
129
+
130
+ recovery_codes: list[str]
131
+
132
+
133
+ class MfaStatusResponse(BaseModel):
134
+ """The caller's current MFA posture for ``GET /me/mfa``."""
135
+
136
+ enabled: bool
137
+ enrolled_at: float | None = None
138
+ recovery_codes_remaining: int = 0
139
+ required: bool = False
140
+
141
+
142
+ class RoleInfo(BaseModel):
143
+ id: str
144
+ display_name: str
145
+ description: str | None = None
146
+ permissions: list[str]
147
+
148
+
149
+ class AdGroupMapEntry(BaseModel):
150
+ ad_group: str = Field(max_length=_GROUP_MAX)
151
+ role: str = Field(max_length=64)
152
+
153
+
154
+ class AdGroupMap(BaseModel):
155
+ entries: list[AdGroupMapEntry]
156
+
157
+
158
+ class AdGroupScopeEntry(BaseModel):
159
+ """Maps an AD group to one allowed channel; channel ``*`` = all channels (per-channel RBAC C3)."""
160
+
161
+ ad_group: str = Field(max_length=_GROUP_MAX)
162
+ channel: str = Field(max_length=_NAME_MAX)
163
+
164
+
165
+ class AdGroupScopeMap(BaseModel):
166
+ entries: list[AdGroupScopeEntry]
167
+
168
+
169
+ class AuditEntry(BaseModel):
170
+ ts: float
171
+ actor: str | None = None
172
+ action: str
173
+ channel_id: str | None = None
174
+ detail: str | None = None
175
+
176
+
177
+ class AuditList(BaseModel):
178
+ entries: list[AuditEntry]
179
+
180
+
181
+ class SimpleMessage(BaseModel):
182
+ detail: str
183
+
184
+
185
+ class SessionInfo(BaseModel):
186
+ """One active session in the self-service inventory (WP-10). ``id`` is the session's ``token_hash``
187
+ (a one-way hash of the opaque token, safe to expose) — pass it to ``DELETE /me/sessions/{id}``."""
188
+
189
+ id: str
190
+ created_at: float
191
+ last_used_at: float
192
+ expires_at: float
193
+ client: str | None = None
194
+ current: bool = False
195
+
196
+
197
+ class SessionList(BaseModel):
198
+ sessions: list[SessionInfo]
199
+
200
+
201
+ class SecurityEventInfo(BaseModel):
202
+ """One entry in the caller's security-event history (WP-L3-05, ASVS 6.3.5/6.3.7) — a view over the
203
+ audited ``auth.*`` actions. ``detail`` is the audit log's JSON metadata (PHI-free)."""
204
+
205
+ ts: float
206
+ action: str # e.g. auth.login_success / auth.login_locked / auth.password_changed
207
+ detail: str | None = None
208
+
209
+
210
+ class SecurityEventsList(BaseModel):
211
+ events: list[SecurityEventInfo]