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,552 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Synchronous API client for the console.
|
|
4
|
+
|
|
5
|
+
A small typed wrapper over the localhost REST API. It is deliberately **synchronous** —
|
|
6
|
+
Qt has its own event loop and localhost calls are sub-millisecond, so blocking briefly on
|
|
7
|
+
the GUI thread is simpler and safe enough for Phase 1 (a short timeout guards against a
|
|
8
|
+
hung/dead server). It returns the API's pydantic models so callers get typed, validated
|
|
9
|
+
data rather than raw dicts.
|
|
10
|
+
|
|
11
|
+
Kept free of any Qt import so it can be unit-tested on its own against a real server.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from json import JSONDecodeError
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import TypeVar
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from pydantic import BaseModel, ValidationError
|
|
25
|
+
|
|
26
|
+
from messagefoundry.api.auth_models import (
|
|
27
|
+
AdGroupMap,
|
|
28
|
+
AdGroupMapEntry,
|
|
29
|
+
AuditList,
|
|
30
|
+
ChannelScope,
|
|
31
|
+
CurrentUser,
|
|
32
|
+
LoginResponse,
|
|
33
|
+
MfaConfirmResponse,
|
|
34
|
+
MfaEnrollResponse,
|
|
35
|
+
MfaStatusResponse,
|
|
36
|
+
ProvidersInfo,
|
|
37
|
+
RoleInfo,
|
|
38
|
+
SessionInfo,
|
|
39
|
+
SessionList,
|
|
40
|
+
SimpleMessage,
|
|
41
|
+
UserSummary,
|
|
42
|
+
)
|
|
43
|
+
from messagefoundry.api.models import (
|
|
44
|
+
ChannelInfo,
|
|
45
|
+
ClusterNodeList,
|
|
46
|
+
ClusterStatus,
|
|
47
|
+
ConnectionRow,
|
|
48
|
+
DeadLetterList,
|
|
49
|
+
DeadLetterReplayResult,
|
|
50
|
+
Health,
|
|
51
|
+
IntegrityResult,
|
|
52
|
+
MessageDetail,
|
|
53
|
+
MessageList,
|
|
54
|
+
PurgeResult,
|
|
55
|
+
ReloadResult,
|
|
56
|
+
ReplayResult,
|
|
57
|
+
StatsResponse,
|
|
58
|
+
SystemStatus,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__all__ = ["EngineClient", "ApiError"]
|
|
62
|
+
|
|
63
|
+
_log = logging.getLogger(__name__)
|
|
64
|
+
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ApiError(RuntimeError):
|
|
68
|
+
"""An API call failed (transport error, a non-2xx response, or an undecodable 2xx body)."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, message: str, *, status: int | None = None) -> None:
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.status = status
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_Model = TypeVar("_Model", bound=BaseModel)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _decode(response: httpx.Response, model: type[_Model]) -> _Model:
|
|
79
|
+
"""Validate a 2xx JSON body into ``model``, mapping schema/JSON errors to :class:`ApiError`.
|
|
80
|
+
|
|
81
|
+
Preserves the client's contract that every call raises only ``ApiError``: a malformed or
|
|
82
|
+
schema-mismatched success body (e.g. an engine version skew) would otherwise raise pydantic's
|
|
83
|
+
``ValidationError`` straight out of a Qt slot into the event loop (H2/L2)."""
|
|
84
|
+
try:
|
|
85
|
+
return model.model_validate(response.json())
|
|
86
|
+
except (ValidationError, JSONDecodeError) as exc:
|
|
87
|
+
raise ApiError(f"invalid response from engine: {exc}") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _decode_list(response: httpx.Response, model: type[_Model]) -> list[_Model]:
|
|
91
|
+
"""List form of :func:`_decode` (the body must be a JSON array of ``model`` objects)."""
|
|
92
|
+
try:
|
|
93
|
+
return [model.model_validate(item) for item in response.json()]
|
|
94
|
+
except (ValidationError, JSONDecodeError, TypeError) as exc:
|
|
95
|
+
raise ApiError(f"invalid response from engine: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _assert_safe_transport(base_url: str, *, allow_insecure: bool) -> None:
|
|
99
|
+
"""Refuse plaintext ``http`` to a non-loopback host (CONSOLE-3).
|
|
100
|
+
|
|
101
|
+
There is no transport TLS yet, so a remote ``http://`` URL would put the bearer token and PHI on
|
|
102
|
+
the wire in cleartext. Loopback http and any https are fine; a non-loopback http URL requires an
|
|
103
|
+
explicit ``allow_insecure`` opt-in (trusted-network dev only), which is then loudly warned."""
|
|
104
|
+
parts = urlsplit(base_url)
|
|
105
|
+
if parts.scheme == "https":
|
|
106
|
+
return
|
|
107
|
+
host = (parts.hostname or "").lower()
|
|
108
|
+
if host in _LOOPBACK_HOSTS or host == "":
|
|
109
|
+
return
|
|
110
|
+
if allow_insecure:
|
|
111
|
+
_log.warning(
|
|
112
|
+
"sending credentials over plaintext http to non-loopback host %r (allow_insecure)", host
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
raise ApiError(
|
|
116
|
+
f"refusing to use plaintext http to non-loopback host {host!r}: the bearer token and PHI "
|
|
117
|
+
"would cross the network in cleartext. Use an https URL, or pass --insecure for a "
|
|
118
|
+
"trusted-network dev setup."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class EngineClient:
|
|
123
|
+
"""Blocking client for the MessageFoundry localhost API.
|
|
124
|
+
|
|
125
|
+
Use as a context manager (or call :meth:`close`) to release the connection pool.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
base_url: str = "http://127.0.0.1:8765",
|
|
131
|
+
*,
|
|
132
|
+
timeout: float = 5.0,
|
|
133
|
+
allow_insecure: bool = False,
|
|
134
|
+
tls_client_cert: str | None = None,
|
|
135
|
+
tls_client_key: str | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
self.base_url = base_url.rstrip("/")
|
|
138
|
+
self._timeout = timeout
|
|
139
|
+
self._allow_insecure = allow_insecure
|
|
140
|
+
_assert_safe_transport(self.base_url, allow_insecure=allow_insecure)
|
|
141
|
+
# Optional client certificate for mutual TLS (ASVS 12.3.5): when the engine API requires a
|
|
142
|
+
# client cert (api.tls_client_ca_file → CERT_REQUIRED), the console presents this PEM cert
|
|
143
|
+
# (plus a separate key file when the key isn't bundled in the cert PEM) so it authenticates to
|
|
144
|
+
# the API by certificate, not only the bearer token. Off by default; meaningful only over https
|
|
145
|
+
# (the server, not the console, decides whether the cert is required).
|
|
146
|
+
cert: str | tuple[str, str] | None = None
|
|
147
|
+
if tls_client_cert is not None:
|
|
148
|
+
cert = (tls_client_cert, tls_client_key) if tls_client_key else tls_client_cert
|
|
149
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=timeout, cert=cert)
|
|
150
|
+
self._token: str | None = None
|
|
151
|
+
self._user: CurrentUser | None = None
|
|
152
|
+
#: Invoked when the engine demands step-up re-verification (403 + X-Step-Up-Required); the GUI
|
|
153
|
+
#: prompts, calls reauth(), and returns True iff re-verified — then the request is retried.
|
|
154
|
+
self._step_up_handler: Callable[[], bool] | None = None
|
|
155
|
+
#: Invoked when the engine demands a second factor (403 + X-MFA-Required, WP-14); the GUI
|
|
156
|
+
#: prompts for a TOTP / recovery code, calls verify_mfa(), and returns True iff verified.
|
|
157
|
+
self._mfa_handler: Callable[[], bool] | None = None
|
|
158
|
+
|
|
159
|
+
def __enter__(self) -> "EngineClient":
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def __exit__(
|
|
163
|
+
self,
|
|
164
|
+
exc_type: type[BaseException] | None,
|
|
165
|
+
exc: BaseException | None,
|
|
166
|
+
tb: TracebackType | None,
|
|
167
|
+
) -> None:
|
|
168
|
+
self.close()
|
|
169
|
+
|
|
170
|
+
def close(self) -> None:
|
|
171
|
+
self._http.close()
|
|
172
|
+
|
|
173
|
+
def for_polling(self) -> "EngineClient":
|
|
174
|
+
"""A second client dedicated to **background (off-thread) reads** — the nav health poll, the
|
|
175
|
+
Engine Status refresh, and the per-page auto-refresh.
|
|
176
|
+
|
|
177
|
+
It shares this client's bearer token but has its **own** ``httpx.Client`` connection pool and
|
|
178
|
+
**no step-up/MFA handlers**, so background reader threads never contend on the main-thread
|
|
179
|
+
client's pool or its mutable auth state. That separation is what makes the console
|
|
180
|
+
concurrency-safe: the handler-bearing, token-mutating primary client stays **main-thread
|
|
181
|
+
only** (it serves the modal sign-in/step-up/MFA flows and user actions), while this read-only
|
|
182
|
+
client is the only one shared across worker threads — and sharing *it* is safe because its
|
|
183
|
+
token is never mutated, its 403→prompt retry branches are inert (no handlers), and
|
|
184
|
+
``httpx.Client`` is itself thread-safe for concurrent requests.
|
|
185
|
+
|
|
186
|
+
The token is copied at creation. A mid-session credential change relaunches the console
|
|
187
|
+
(sign-out/expiry quits the app), so this snapshot can't drift out from under a live window.
|
|
188
|
+
"""
|
|
189
|
+
poll = EngineClient(
|
|
190
|
+
self.base_url, timeout=self._timeout, allow_insecure=self._allow_insecure
|
|
191
|
+
)
|
|
192
|
+
poll._token = self._token
|
|
193
|
+
poll._user = self._user
|
|
194
|
+
return poll
|
|
195
|
+
|
|
196
|
+
# --- requests ------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def _get(self, path: str, **params: object) -> httpx.Response:
|
|
199
|
+
return self._request("GET", path, params={k: v for k, v in params.items() if v is not None})
|
|
200
|
+
|
|
201
|
+
def _request(
|
|
202
|
+
self,
|
|
203
|
+
method: str,
|
|
204
|
+
path: str,
|
|
205
|
+
*,
|
|
206
|
+
_allow_step_up: bool = True,
|
|
207
|
+
_allow_mfa: bool = True,
|
|
208
|
+
**kw: object,
|
|
209
|
+
) -> httpx.Response:
|
|
210
|
+
headers = {"Authorization": f"Bearer {self._token}"} if self._token else None
|
|
211
|
+
try:
|
|
212
|
+
response = self._http.request(method, path, headers=headers, **kw) # type: ignore[arg-type]
|
|
213
|
+
except httpx.HTTPError as exc:
|
|
214
|
+
raise ApiError(f"could not reach engine at {self.base_url}: {exc}") from exc
|
|
215
|
+
# Second factor (WP-14, ASVS 6.3.3): the engine refuses a sensitive op with 403 +
|
|
216
|
+
# X-MFA-Required when this session hasn't satisfied MFA. Prompt for a code (the handler calls
|
|
217
|
+
# verify_mfa()) and retry once — transparently, like step-up. Checked first because the engine
|
|
218
|
+
# gates MFA before step-up.
|
|
219
|
+
if (
|
|
220
|
+
_allow_mfa
|
|
221
|
+
and response.status_code == 403
|
|
222
|
+
and response.headers.get("X-MFA-Required")
|
|
223
|
+
and self._mfa_handler is not None
|
|
224
|
+
and self._mfa_handler()
|
|
225
|
+
):
|
|
226
|
+
return self._request(
|
|
227
|
+
method, path, _allow_step_up=_allow_step_up, _allow_mfa=False, **kw
|
|
228
|
+
)
|
|
229
|
+
# Step-up re-verification (ASVS 7.5.3): the engine refuses a sensitive op with 403 +
|
|
230
|
+
# X-Step-Up-Required when this session hasn't re-proved its credential recently. Prompt the
|
|
231
|
+
# user (the handler re-authenticates via reauth()) and retry once, so the action goes through
|
|
232
|
+
# transparently instead of surfacing a raw 403.
|
|
233
|
+
if (
|
|
234
|
+
_allow_step_up
|
|
235
|
+
and response.status_code == 403
|
|
236
|
+
and response.headers.get("X-Step-Up-Required")
|
|
237
|
+
and self._step_up_handler is not None
|
|
238
|
+
and self._step_up_handler()
|
|
239
|
+
):
|
|
240
|
+
return self._request(method, path, _allow_step_up=False, _allow_mfa=_allow_mfa, **kw)
|
|
241
|
+
if response.status_code >= 400:
|
|
242
|
+
raise ApiError(_error_detail(response), status=response.status_code)
|
|
243
|
+
return response
|
|
244
|
+
|
|
245
|
+
def set_step_up_handler(self, handler: Callable[[], bool] | None) -> None:
|
|
246
|
+
"""Register the callback invoked when the engine demands step-up re-verification (403 +
|
|
247
|
+
``X-Step-Up-Required``). It must prompt the user, call :meth:`reauth`, and return ``True`` iff
|
|
248
|
+
re-verified — the original request is then retried once. Runs on the calling thread (the
|
|
249
|
+
console's sensitive actions run on the Qt main thread, so a modal dialog is safe)."""
|
|
250
|
+
self._step_up_handler = handler
|
|
251
|
+
|
|
252
|
+
def reauth(self, password: str) -> None:
|
|
253
|
+
"""Step-up re-verification (ASVS 7.5.3): re-prove the current credential to refresh this
|
|
254
|
+
session's step-up window. Raises :class:`ApiError` (status 403) on a wrong password. Does not
|
|
255
|
+
itself trigger the step-up handler (``/me/reauth`` is not a step-up-gated route)."""
|
|
256
|
+
self._request("POST", "/me/reauth", json={"password": password}, _allow_step_up=False)
|
|
257
|
+
|
|
258
|
+
def set_mfa_handler(self, handler: Callable[[], bool] | None) -> None:
|
|
259
|
+
"""Register the callback invoked when the engine demands a second factor (403 +
|
|
260
|
+
``X-MFA-Required``, WP-14). It must prompt for a TOTP / recovery code, call :meth:`verify_mfa`,
|
|
261
|
+
and return ``True`` iff verified — the original request is then retried once."""
|
|
262
|
+
self._mfa_handler = handler
|
|
263
|
+
|
|
264
|
+
# --- MFA (WP-14, ASVS 6.3.3) ---------------------------------------------
|
|
265
|
+
|
|
266
|
+
def mfa_status(self) -> MfaStatusResponse:
|
|
267
|
+
"""The signed-in user's MFA posture (enabled, enrolled-at, recovery codes left, required)."""
|
|
268
|
+
return _decode(self._get("/me/mfa"), MfaStatusResponse)
|
|
269
|
+
|
|
270
|
+
def enroll_mfa(self) -> MfaEnrollResponse:
|
|
271
|
+
"""Begin TOTP enrollment: stage a secret and return it + the ``otpauth://`` URI. Step-up gated,
|
|
272
|
+
so the step-up handler may prompt for the password before this returns."""
|
|
273
|
+
return _decode(self._request("POST", "/me/mfa/enroll"), MfaEnrollResponse)
|
|
274
|
+
|
|
275
|
+
def confirm_mfa(self, code: str) -> list[str]:
|
|
276
|
+
"""Confirm enrollment with a live TOTP code; activates MFA and returns the one-time recovery
|
|
277
|
+
codes (shown **once**). Raises :class:`ApiError` (400) on a wrong code."""
|
|
278
|
+
return _decode(
|
|
279
|
+
self._request("POST", "/me/mfa/confirm", json={"code": code}), MfaConfirmResponse
|
|
280
|
+
).recovery_codes
|
|
281
|
+
|
|
282
|
+
def verify_mfa(self, code: str) -> None:
|
|
283
|
+
"""Satisfy the current session's second factor with a TOTP or single-use recovery code. Raises
|
|
284
|
+
:class:`ApiError` (401) on a wrong code. Does not itself trigger the MFA handler."""
|
|
285
|
+
self._request("POST", "/auth/mfa-verify", json={"code": code}, _allow_mfa=False)
|
|
286
|
+
|
|
287
|
+
def disable_mfa(self) -> None:
|
|
288
|
+
"""Turn off the signed-in user's TOTP MFA (step-up gated)."""
|
|
289
|
+
self._request("DELETE", "/me/mfa")
|
|
290
|
+
|
|
291
|
+
def reset_user_mfa(self, user_id: str) -> None:
|
|
292
|
+
"""Admin: clear a user's MFA enrollment and revoke their sessions (step-up gated)."""
|
|
293
|
+
self._request("POST", f"/users/{user_id}/reset-mfa")
|
|
294
|
+
|
|
295
|
+
# --- endpoints -----------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def health(self) -> Health:
|
|
298
|
+
return _decode(self._get("/health"), Health)
|
|
299
|
+
|
|
300
|
+
def list_channels(self) -> list[ChannelInfo]:
|
|
301
|
+
"""Inbound connections (id = connection name) — used by the Log Search filter."""
|
|
302
|
+
return _decode_list(self._get("/channels"), ChannelInfo)
|
|
303
|
+
|
|
304
|
+
def connections(self) -> list[ConnectionRow]:
|
|
305
|
+
return _decode_list(self._get("/connections"), ConnectionRow)
|
|
306
|
+
|
|
307
|
+
# --- code-first connection operations ------------------------------------
|
|
308
|
+
|
|
309
|
+
def start_connection(self, name: str) -> None:
|
|
310
|
+
self._request("POST", f"/connections/{name}/start")
|
|
311
|
+
|
|
312
|
+
def stop_connection(self, name: str) -> None:
|
|
313
|
+
self._request("POST", f"/connections/{name}/stop")
|
|
314
|
+
|
|
315
|
+
def restart_connection(self, name: str) -> None:
|
|
316
|
+
self._request("POST", f"/connections/{name}/restart")
|
|
317
|
+
|
|
318
|
+
def purge_connection(self, name: str, scope: str = "all") -> PurgeResult:
|
|
319
|
+
return _decode(
|
|
320
|
+
self._request("POST", f"/connections/{name}/purge", params={"scope": scope}),
|
|
321
|
+
PurgeResult,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def list_messages(
|
|
325
|
+
self,
|
|
326
|
+
*,
|
|
327
|
+
channel_id: str | None = None,
|
|
328
|
+
status: str | None = None,
|
|
329
|
+
message_type: str | None = None,
|
|
330
|
+
control_id: str | None = None,
|
|
331
|
+
limit: int = 50,
|
|
332
|
+
offset: int = 0,
|
|
333
|
+
audit_summary: bool = False,
|
|
334
|
+
) -> MessageList:
|
|
335
|
+
response = self._get(
|
|
336
|
+
"/messages",
|
|
337
|
+
channel_id=channel_id,
|
|
338
|
+
status=status,
|
|
339
|
+
message_type=message_type,
|
|
340
|
+
control_id=control_id,
|
|
341
|
+
limit=limit,
|
|
342
|
+
offset=offset,
|
|
343
|
+
audit_summary=audit_summary or None,
|
|
344
|
+
)
|
|
345
|
+
return _decode(response, MessageList)
|
|
346
|
+
|
|
347
|
+
def get_message(self, message_id: str) -> MessageDetail:
|
|
348
|
+
return _decode(self._get(f"/messages/{message_id}"), MessageDetail)
|
|
349
|
+
|
|
350
|
+
def replay(self, message_id: str) -> ReplayResult:
|
|
351
|
+
return _decode(self._request("POST", f"/messages/{message_id}/replay"), ReplayResult)
|
|
352
|
+
|
|
353
|
+
# --- dead letters --------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
def list_dead_letters(
|
|
356
|
+
self,
|
|
357
|
+
*,
|
|
358
|
+
channel_id: str | None = None,
|
|
359
|
+
destination_name: str | None = None,
|
|
360
|
+
limit: int = 50,
|
|
361
|
+
offset: int = 0,
|
|
362
|
+
audit_summary: bool = False,
|
|
363
|
+
) -> DeadLetterList:
|
|
364
|
+
"""Dead-lettered deliveries (newest first), optionally scoped to an inbound/outbound."""
|
|
365
|
+
response = self._get(
|
|
366
|
+
"/dead-letters",
|
|
367
|
+
channel_id=channel_id,
|
|
368
|
+
destination_name=destination_name,
|
|
369
|
+
limit=limit,
|
|
370
|
+
offset=offset,
|
|
371
|
+
audit_summary=audit_summary or None,
|
|
372
|
+
)
|
|
373
|
+
return DeadLetterList.model_validate(response.json())
|
|
374
|
+
|
|
375
|
+
def replay_dead_letters(
|
|
376
|
+
self, *, channel_id: str | None = None, destination_name: str | None = None
|
|
377
|
+
) -> DeadLetterReplayResult:
|
|
378
|
+
"""Re-queue dead-lettered deliveries (``None`` scope = all; a channel-scoped user must
|
|
379
|
+
name their channel — an unscoped replay-all is denied server-side)."""
|
|
380
|
+
return DeadLetterReplayResult.model_validate(
|
|
381
|
+
self._request(
|
|
382
|
+
"POST",
|
|
383
|
+
"/dead-letters/replay",
|
|
384
|
+
json={"channel_id": channel_id, "destination_name": destination_name},
|
|
385
|
+
).json()
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# --- config --------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def reload_config(self, config_dir: str | None = None) -> ReloadResult:
|
|
391
|
+
"""Apply code-first config atomically (``None`` = the server's startup --config dir)."""
|
|
392
|
+
return ReloadResult.model_validate(
|
|
393
|
+
self._request("POST", "/config/reload", json={"config_dir": config_dir}).json()
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def stats(self) -> StatsResponse:
|
|
397
|
+
return _decode(self._get("/stats"), StatsResponse)
|
|
398
|
+
|
|
399
|
+
def status(self) -> SystemStatus:
|
|
400
|
+
return _decode(self._get("/status"), SystemStatus)
|
|
401
|
+
|
|
402
|
+
def cluster_status(self) -> ClusterStatus:
|
|
403
|
+
"""This node's active-passive role / leadership (cheap in-memory read; MONITORING_READ)."""
|
|
404
|
+
return _decode(self._get("/cluster/status"), ClusterStatus)
|
|
405
|
+
|
|
406
|
+
def cluster_nodes(self) -> ClusterNodeList:
|
|
407
|
+
"""Cluster membership + the derived live leader + lease state (MONITORING_READ)."""
|
|
408
|
+
return _decode(self._get("/cluster/nodes"), ClusterNodeList)
|
|
409
|
+
|
|
410
|
+
def integrity_check(self) -> IntegrityResult:
|
|
411
|
+
# The DB integrity scan (PRAGMA quick_check) is exactly the call that runs long on a large
|
|
412
|
+
# store — the blanket short timeout would spuriously report it as "could not reach engine".
|
|
413
|
+
# Give this one request a generous timeout (review M-27).
|
|
414
|
+
return _decode(
|
|
415
|
+
self._request("POST", "/status/integrity-check", timeout=httpx.Timeout(300.0)),
|
|
416
|
+
IntegrityResult,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# --- authentication ------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def token(self) -> str | None:
|
|
423
|
+
return self._token
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def current_user(self) -> CurrentUser | None:
|
|
427
|
+
return self._user
|
|
428
|
+
|
|
429
|
+
def can(self, permission: str) -> bool:
|
|
430
|
+
"""True if the signed-in user holds ``permission`` (False when not signed in)."""
|
|
431
|
+
return self._user is not None and permission in self._user.permissions
|
|
432
|
+
|
|
433
|
+
def set_token(self, token: str) -> None:
|
|
434
|
+
"""Adopt an existing token (e.g. from the OS keyring) and refresh the cached user."""
|
|
435
|
+
self._token = token
|
|
436
|
+
self._user = self.me()
|
|
437
|
+
|
|
438
|
+
def clear_auth(self) -> None:
|
|
439
|
+
self._token = None
|
|
440
|
+
self._user = None
|
|
441
|
+
|
|
442
|
+
def providers(self) -> ProvidersInfo:
|
|
443
|
+
"""Which login methods the engine offers (callable before authenticating)."""
|
|
444
|
+
return _decode(self._get("/auth/providers"), ProvidersInfo)
|
|
445
|
+
|
|
446
|
+
def login(self, username: str, password: str, *, provider: str = "local") -> LoginResponse:
|
|
447
|
+
result = _decode(
|
|
448
|
+
self._request(
|
|
449
|
+
"POST",
|
|
450
|
+
"/auth/login",
|
|
451
|
+
json={"username": username, "password": password, "provider": provider},
|
|
452
|
+
),
|
|
453
|
+
LoginResponse,
|
|
454
|
+
)
|
|
455
|
+
self._token = result.token
|
|
456
|
+
self._user = result.user
|
|
457
|
+
return result
|
|
458
|
+
|
|
459
|
+
def me(self) -> CurrentUser:
|
|
460
|
+
return _decode(self._get("/auth/me"), CurrentUser)
|
|
461
|
+
|
|
462
|
+
def logout(self) -> None:
|
|
463
|
+
try:
|
|
464
|
+
self._request("POST", "/auth/logout")
|
|
465
|
+
finally:
|
|
466
|
+
self.clear_auth()
|
|
467
|
+
|
|
468
|
+
def change_password(self, current_password: str, new_password: str) -> None:
|
|
469
|
+
self._request(
|
|
470
|
+
"POST",
|
|
471
|
+
"/me/password",
|
|
472
|
+
json={"current_password": current_password, "new_password": new_password},
|
|
473
|
+
)
|
|
474
|
+
self.clear_auth() # the server revokes sessions on change; sign in again
|
|
475
|
+
|
|
476
|
+
# --- sessions (WP-10) ----------------------------------------------------
|
|
477
|
+
|
|
478
|
+
def list_sessions(self) -> list[SessionInfo]:
|
|
479
|
+
"""The signed-in user's active sessions; the entry with ``current=True`` is this one."""
|
|
480
|
+
return _decode(self._get("/me/sessions"), SessionList).sessions
|
|
481
|
+
|
|
482
|
+
def revoke_session(self, session_id: str) -> None:
|
|
483
|
+
"""Revoke one of the user's own sessions by its ``id`` (the session's ``token_hash``)."""
|
|
484
|
+
self._request("DELETE", f"/me/sessions/{session_id}")
|
|
485
|
+
|
|
486
|
+
def revoke_other_sessions(self) -> str:
|
|
487
|
+
"""Revoke every session except this one ("sign out everywhere else"); returns the summary."""
|
|
488
|
+
return _decode(self._request("DELETE", "/me/sessions"), SimpleMessage).detail
|
|
489
|
+
|
|
490
|
+
def revoke_user_sessions(self, user_id: str) -> str:
|
|
491
|
+
"""Admin force-sign-out: revoke all of ``user_id``'s sessions; returns the summary."""
|
|
492
|
+
return _decode(self._request("DELETE", f"/users/{user_id}/sessions"), SimpleMessage).detail
|
|
493
|
+
|
|
494
|
+
# --- user administration -------------------------------------------------
|
|
495
|
+
|
|
496
|
+
def list_roles(self) -> list[RoleInfo]:
|
|
497
|
+
return _decode_list(self._get("/roles"), RoleInfo)
|
|
498
|
+
|
|
499
|
+
def list_users(self) -> list[UserSummary]:
|
|
500
|
+
return _decode_list(self._get("/users"), UserSummary)
|
|
501
|
+
|
|
502
|
+
def create_user(
|
|
503
|
+
self,
|
|
504
|
+
username: str,
|
|
505
|
+
password: str,
|
|
506
|
+
*,
|
|
507
|
+
display_name: str | None = None,
|
|
508
|
+
email: str | None = None,
|
|
509
|
+
roles: list[str] | None = None,
|
|
510
|
+
) -> UserSummary:
|
|
511
|
+
body = {
|
|
512
|
+
"username": username,
|
|
513
|
+
"password": password,
|
|
514
|
+
"display_name": display_name,
|
|
515
|
+
"email": email,
|
|
516
|
+
"roles": roles or [],
|
|
517
|
+
}
|
|
518
|
+
return _decode(self._request("POST", "/users", json=body), UserSummary)
|
|
519
|
+
|
|
520
|
+
def set_user_roles(self, user_id: str, roles: list[str]) -> None:
|
|
521
|
+
self._request("PUT", f"/users/{user_id}/roles", json={"roles": roles})
|
|
522
|
+
|
|
523
|
+
def get_channel_scope(self, user_id: str) -> list[str] | None:
|
|
524
|
+
"""A user's per-channel RBAC scope (``None`` = all channels)."""
|
|
525
|
+
return _decode(self._get(f"/users/{user_id}/channel-scope"), ChannelScope).channels
|
|
526
|
+
|
|
527
|
+
def set_channel_scope(self, user_id: str, channels: list[str] | None) -> None:
|
|
528
|
+
"""Set a user's per-channel RBAC scope (``None`` = all channels)."""
|
|
529
|
+
self._request("PUT", f"/users/{user_id}/channel-scope", json={"channels": channels})
|
|
530
|
+
|
|
531
|
+
def delete_user(self, user_id: str) -> None:
|
|
532
|
+
self._request("DELETE", f"/users/{user_id}")
|
|
533
|
+
|
|
534
|
+
def audit(self, *, limit: int = 100) -> AuditList:
|
|
535
|
+
return _decode(self._get("/audit", limit=limit), AuditList)
|
|
536
|
+
|
|
537
|
+
def ad_group_map(self) -> AdGroupMap:
|
|
538
|
+
return _decode(self._get("/ad-group-map"), AdGroupMap)
|
|
539
|
+
|
|
540
|
+
def set_ad_group_map(self, entries: list[tuple[str, str]]) -> None:
|
|
541
|
+
payload = {
|
|
542
|
+
"entries": [AdGroupMapEntry(ad_group=g, role=r).model_dump() for g, r in entries]
|
|
543
|
+
}
|
|
544
|
+
self._request("PUT", "/ad-group-map", json=payload)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _error_detail(response: httpx.Response) -> str:
|
|
548
|
+
try:
|
|
549
|
+
detail = response.json().get("detail")
|
|
550
|
+
except Exception:
|
|
551
|
+
detail = None
|
|
552
|
+
return f"{response.status_code}: {detail or response.text or response.reason_phrase}"
|