messagefoundry-webconsole 0.2.15__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_webconsole/__init__.py +128 -0
- messagefoundry_webconsole/_auth.py +841 -0
- messagefoundry_webconsole/_html.py +539 -0
- messagefoundry_webconsole/_security.py +253 -0
- messagefoundry_webconsole/_service.py +22 -0
- messagefoundry_webconsole/_static.py +78 -0
- messagefoundry_webconsole/mount.py +103 -0
- messagefoundry_webconsole/pages/__init__.py +25 -0
- messagefoundry_webconsole/pages/_common.py +21 -0
- messagefoundry_webconsole/pages/account.py +771 -0
- messagefoundry_webconsole/pages/admin.py +480 -0
- messagefoundry_webconsole/pages/audit.py +66 -0
- messagefoundry_webconsole/pages/config.py +113 -0
- messagefoundry_webconsole/pages/connections.py +519 -0
- messagefoundry_webconsole/pages/messages.py +689 -0
- messagefoundry_webconsole/pages/monitoring.py +853 -0
- messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
- messagefoundry_webconsole/routes/__init__.py +6 -0
- messagefoundry_webconsole/routes/_common.py +45 -0
- messagefoundry_webconsole/routes/account.py +451 -0
- messagefoundry_webconsole/routes/admin.py +505 -0
- messagefoundry_webconsole/routes/audit.py +39 -0
- messagefoundry_webconsole/routes/config.py +67 -0
- messagefoundry_webconsole/routes/connection_writes.py +248 -0
- messagefoundry_webconsole/routes/core.py +1077 -0
- messagefoundry_webconsole/routes/monitoring.py +94 -0
- messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
- messagefoundry_webconsole/routes/oidc.py +172 -0
- messagefoundry_webconsole/routes/search.py +204 -0
- messagefoundry_webconsole/routes/sso.py +88 -0
- messagefoundry_webconsole/routes/status.py +178 -0
- messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
- messagefoundry_webconsole/static/app.css +345 -0
- messagefoundry_webconsole/static/app.js +1506 -0
- messagefoundry_webconsole/static/csp-probe.js +9 -0
- messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
- messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
- messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
- messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
- messagefoundry_webconsole-0.2.15.dist-info/licenses/NOTICE +31 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Offline uploaded-logs page builders (BACKLOG #125/#126, ADR 0134).
|
|
4
|
+
|
|
5
|
+
The Uploaded Logs page lets an operator import a partner-supplied `.hl7`/`.txt`/`.xml` file and browse
|
|
6
|
+
it as a filterable/searchable log, decoupled from any live connection, with per-message resend into a
|
|
7
|
+
chosen inbound and a guarded delete. Every dynamic value — including attacker-influenced HL7 content and
|
|
8
|
+
the operator-supplied filename — is placed through the escaping ``_html`` builders, so it can never
|
|
9
|
+
inject markup. No decrypted message body is ever rendered here (metadata only)."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from messagefoundry.api.models import UploadedFileList, UploadedMessagesResult
|
|
14
|
+
|
|
15
|
+
from .._html import Markup, el, page, register_nav, rows_table, text
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"uploaded_log_delete_confirm",
|
|
19
|
+
"uploaded_log_detail",
|
|
20
|
+
"uploaded_logs",
|
|
21
|
+
"uploaded_logs_upload",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _human_size(n: int) -> str:
|
|
26
|
+
"""A compact human-readable byte size (e.g. ``1.2 MiB``). Pure display."""
|
|
27
|
+
size = float(max(n, 0))
|
|
28
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
29
|
+
if size < 1024 or unit == "TiB":
|
|
30
|
+
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
31
|
+
size /= 1024
|
|
32
|
+
return f"{n} B" # unreachable
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def uploaded_logs(data: UploadedFileList) -> Markup:
|
|
36
|
+
"""The uploaded-files list: an upload link + a table of files (metadata only), each linking to the
|
|
37
|
+
offline browse view and a guarded delete."""
|
|
38
|
+
rows = [
|
|
39
|
+
[
|
|
40
|
+
el("a", f.filename, href=f"/ui/uploaded-logs/file/{f.file_id}"),
|
|
41
|
+
f.uploader,
|
|
42
|
+
f.content_type,
|
|
43
|
+
_human_size(f.size),
|
|
44
|
+
f.message_count,
|
|
45
|
+
f.uploaded_at,
|
|
46
|
+
el(
|
|
47
|
+
"a",
|
|
48
|
+
"Delete",
|
|
49
|
+
href=f"/ui/uploaded-logs/file/{f.file_id}/delete-confirm",
|
|
50
|
+
class_="btn-link",
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
for f in data.files
|
|
54
|
+
]
|
|
55
|
+
return page(
|
|
56
|
+
"Uploaded logs",
|
|
57
|
+
el("h1", "Uploaded logs"),
|
|
58
|
+
el(
|
|
59
|
+
"p",
|
|
60
|
+
"Import a partner-supplied message file and browse it offline — decoupled from any live "
|
|
61
|
+
"connection. Uploaded files are PHI at rest; access is audited.",
|
|
62
|
+
class_="muted",
|
|
63
|
+
),
|
|
64
|
+
el("p", el("a", "Upload a file →", href="/ui/uploaded-logs/upload", class_="btn-link")),
|
|
65
|
+
rows_table(
|
|
66
|
+
["File", "Uploaded by", "Format", "Size", "Messages", "When", ""],
|
|
67
|
+
rows,
|
|
68
|
+
),
|
|
69
|
+
el("p", text(f"{data.total} file(s)"), class_="pager"),
|
|
70
|
+
active="uploaded-logs",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def uploaded_logs_upload(*, error: str = "") -> Markup:
|
|
75
|
+
"""The upload form (multipart/form-data — hand-parsed server-side, ADR 0134)."""
|
|
76
|
+
parts: list[object] = [
|
|
77
|
+
el("p", el("a", "← Uploaded logs", href="/ui/uploaded-logs")),
|
|
78
|
+
el("h1", "Upload a log file"),
|
|
79
|
+
el(
|
|
80
|
+
"p",
|
|
81
|
+
"Upload a .hl7 / .txt / .xml message file to inspect offline. It is stored encrypted at rest "
|
|
82
|
+
"(when a store key is set) and never ingested into a live connection until you resend a "
|
|
83
|
+
"message. Use synthetic / de-identified data where possible.",
|
|
84
|
+
class_="muted",
|
|
85
|
+
),
|
|
86
|
+
]
|
|
87
|
+
if error:
|
|
88
|
+
parts.append(el("p", text(error), class_="banner"))
|
|
89
|
+
parts.append(
|
|
90
|
+
el(
|
|
91
|
+
"form",
|
|
92
|
+
el("input", type="file", name="file", required=True),
|
|
93
|
+
# Consent affordance (ASVS 14.2.8): state, above the submit button, what non-body metadata is
|
|
94
|
+
# retained and who sees it — submitting the form IS the consent (no separate stored flag).
|
|
95
|
+
el(
|
|
96
|
+
"p",
|
|
97
|
+
"The original filename and your username are stored with this upload and shown to "
|
|
98
|
+
"authorized operators, and recorded in the audit log. Submitting this form is your "
|
|
99
|
+
"consent. Only plain-text .hl7 / .txt / .xml logs are accepted.",
|
|
100
|
+
class_="muted",
|
|
101
|
+
),
|
|
102
|
+
el("button", "Upload", type="submit", class_="primary"),
|
|
103
|
+
method="post",
|
|
104
|
+
action="/ui/uploaded-logs/upload",
|
|
105
|
+
enctype="multipart/form-data",
|
|
106
|
+
class_="ctl",
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return page("Upload a log file", *parts, active="uploaded-logs")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _browse_filters(
|
|
113
|
+
file_id: str,
|
|
114
|
+
*,
|
|
115
|
+
content: str,
|
|
116
|
+
field_path: str,
|
|
117
|
+
field_value: str,
|
|
118
|
+
message_type: str,
|
|
119
|
+
control_id: str,
|
|
120
|
+
) -> Markup:
|
|
121
|
+
"""A GET filter form over an uploaded file's split messages (metadata + optional content needle)."""
|
|
122
|
+
return el(
|
|
123
|
+
"form",
|
|
124
|
+
el(
|
|
125
|
+
"label",
|
|
126
|
+
"Field path",
|
|
127
|
+
el("input", name="field_path", value=field_path or None, placeholder="PID-3"),
|
|
128
|
+
),
|
|
129
|
+
el("label", "Field value", el("input", name="field_value", value=field_value or None)),
|
|
130
|
+
el("label", "Substring", el("input", name="content", value=content or None)),
|
|
131
|
+
el("input", name="message_type", value=message_type or None, placeholder="type (optional)"),
|
|
132
|
+
el(
|
|
133
|
+
"input",
|
|
134
|
+
name="control_id",
|
|
135
|
+
value=control_id or None,
|
|
136
|
+
placeholder="control id (optional)",
|
|
137
|
+
),
|
|
138
|
+
el("button", "Filter", type="submit"),
|
|
139
|
+
method="get",
|
|
140
|
+
action=f"/ui/uploaded-logs/file/{file_id}",
|
|
141
|
+
class_="filters",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def uploaded_log_detail(
|
|
146
|
+
result: UploadedMessagesResult,
|
|
147
|
+
*,
|
|
148
|
+
content: str = "",
|
|
149
|
+
field_path: str = "",
|
|
150
|
+
field_value: str = "",
|
|
151
|
+
message_type: str = "",
|
|
152
|
+
control_id: str = "",
|
|
153
|
+
error: str = "",
|
|
154
|
+
) -> Markup:
|
|
155
|
+
"""Browse one uploaded file's split messages (metadata only), with a per-file resend form (inject a
|
|
156
|
+
message into a chosen inbound) and a link to the guarded delete."""
|
|
157
|
+
file_id = result.file_id
|
|
158
|
+
rows = [[m.index, m.message_type, m.control_id, _human_size(m.size)] for m in result.messages]
|
|
159
|
+
note = f"{result.matched} of {result.total_messages} message(s)" + (
|
|
160
|
+
" — result capped; narrow the filter" if result.truncated else ""
|
|
161
|
+
)
|
|
162
|
+
resend = el(
|
|
163
|
+
"form",
|
|
164
|
+
el(
|
|
165
|
+
"label",
|
|
166
|
+
"Message #",
|
|
167
|
+
el("input", type="number", name="index", min="0", value="0", required=True),
|
|
168
|
+
),
|
|
169
|
+
el(
|
|
170
|
+
"label",
|
|
171
|
+
"To inbound",
|
|
172
|
+
el(
|
|
173
|
+
"input",
|
|
174
|
+
type="text",
|
|
175
|
+
name="to",
|
|
176
|
+
maxlength="256",
|
|
177
|
+
placeholder="IB_PARTNER_ADT",
|
|
178
|
+
required=True,
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
el("button", "Resend into inbound", type="submit"),
|
|
182
|
+
method="post",
|
|
183
|
+
action=f"/ui/uploaded-logs/file/{file_id}/resend",
|
|
184
|
+
class_="ctl",
|
|
185
|
+
)
|
|
186
|
+
parts: list[object] = [
|
|
187
|
+
el("p", el("a", "← Uploaded logs", href="/ui/uploaded-logs")),
|
|
188
|
+
el("h1", text(f"Browse: {result.filename}")),
|
|
189
|
+
el(
|
|
190
|
+
"p",
|
|
191
|
+
"Metadata only — no decrypted body is shown. Resend injects the chosen message into a "
|
|
192
|
+
"running inbound connection as a fresh receipt (audited).",
|
|
193
|
+
class_="muted",
|
|
194
|
+
),
|
|
195
|
+
]
|
|
196
|
+
if error:
|
|
197
|
+
parts.append(el("p", text(error), class_="banner"))
|
|
198
|
+
parts.extend(
|
|
199
|
+
[
|
|
200
|
+
_browse_filters(
|
|
201
|
+
file_id,
|
|
202
|
+
content=content,
|
|
203
|
+
field_path=field_path,
|
|
204
|
+
field_value=field_value,
|
|
205
|
+
message_type=message_type,
|
|
206
|
+
control_id=control_id,
|
|
207
|
+
),
|
|
208
|
+
el("p", text(note), class_="pager"),
|
|
209
|
+
rows_table(["#", "Type", "Control ID", "Size"], rows),
|
|
210
|
+
el("h2", "Resend a message"),
|
|
211
|
+
resend,
|
|
212
|
+
el(
|
|
213
|
+
"p",
|
|
214
|
+
el(
|
|
215
|
+
"a",
|
|
216
|
+
"Delete this file",
|
|
217
|
+
href=f"/ui/uploaded-logs/file/{file_id}/delete-confirm",
|
|
218
|
+
class_="btn-link",
|
|
219
|
+
),
|
|
220
|
+
),
|
|
221
|
+
]
|
|
222
|
+
)
|
|
223
|
+
return page("Uploaded log", *parts, active="uploaded-logs")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def uploaded_log_delete_confirm(file_id: str, filename: str) -> Markup:
|
|
227
|
+
"""The delete confirm step (BACKLOG #126) — an explicit two-step so a stray click never destroys a
|
|
228
|
+
file. The POST is step-up-gated + audited server-side."""
|
|
229
|
+
return page(
|
|
230
|
+
"Delete uploaded file",
|
|
231
|
+
el("p", el("a", "← Uploaded logs", href="/ui/uploaded-logs")),
|
|
232
|
+
el("h1", "Delete uploaded file?"),
|
|
233
|
+
el(
|
|
234
|
+
"p",
|
|
235
|
+
text(
|
|
236
|
+
f"This permanently deletes the uploaded file “{filename}”. This cannot be undone."
|
|
237
|
+
),
|
|
238
|
+
class_="muted",
|
|
239
|
+
),
|
|
240
|
+
el(
|
|
241
|
+
"form",
|
|
242
|
+
el("button", "Delete permanently", type="submit", class_="danger"),
|
|
243
|
+
el("a", "Cancel", href=f"/ui/uploaded-logs/file/{file_id}", class_="btn-link"),
|
|
244
|
+
method="post",
|
|
245
|
+
action=f"/ui/uploaded-logs/file/{file_id}/delete",
|
|
246
|
+
class_="ctl",
|
|
247
|
+
),
|
|
248
|
+
active="uploaded-logs",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
register_nav("uploaded-logs", "/ui/uploaded-logs", "Uploaded logs")
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""The /ui route modules (moved from ``api.app``'s ``_register_*`` blocks + ``api.auth_routes``'s
|
|
4
|
+
admin/account/audit /ui routes). Each exposes ``register(app, deps)``; :func:`..mount.mount_ui`
|
|
5
|
+
imports them all eagerly (firing every module-level ``register_ui_action``) and calls each in a fixed,
|
|
6
|
+
test-pinned order (search before core so a literal path never loses to a path-param route)."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Small route helpers shared by the admin/account modules (moved from ``api.auth_routes``).
|
|
4
|
+
|
|
5
|
+
``_form_pairs`` is the stdlib urlencoded-form parser (no python-multipart dep) shared by every
|
|
6
|
+
body-carrying /ui admin/account POST; ``_client`` / ``_rate_limited`` are the account-lifecycle
|
|
7
|
+
throttle helpers re-implemented package-side (so the package never imports ``auth_routes``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from urllib.parse import parse_qsl
|
|
14
|
+
|
|
15
|
+
from fastapi import HTTPException, Request, status
|
|
16
|
+
|
|
17
|
+
_log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _form_pairs(request: Request) -> list[tuple[str, str]]:
|
|
21
|
+
# stdlib urlencoded-form parsing (no python-multipart dep), like /ui/login. Pair order is
|
|
22
|
+
# preserved so repeated fields (checkboxes, map rows) can be collected positionally.
|
|
23
|
+
# keep_blank_values=True is LOAD-BEARING for the paired-row AD-map forms: a browser posts
|
|
24
|
+
# blank fields (ad_group=, role=) for empty/half-filled rows, and dropping them (the
|
|
25
|
+
# parse_qsl default) shifts the positional pairing so a role from one row silently binds
|
|
26
|
+
# to a group from another — an RBAC mis-grant. With blanks kept, every row contributes
|
|
27
|
+
# exactly one value per field and the row-wise "if g and r" filters drop incomplete rows
|
|
28
|
+
# as intended. Scalar readers are unaffected (dict(pairs).get(k, "") yields "" either way;
|
|
29
|
+
# checkbox values are never blank).
|
|
30
|
+
return parse_qsl((await request.body()).decode("utf-8", "replace"), keep_blank_values=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _client(request: Request) -> str | None:
|
|
34
|
+
# Already proxy-aware: uvicorn runs with forwarded_allow_ips = settings.api.trusted_proxies
|
|
35
|
+
# (defaults to [] = trust nothing), so behind a declared trusted proxy this resolves to the
|
|
36
|
+
# real client. Matches how ``api.auth_routes._client`` records it on the session.
|
|
37
|
+
return request.client.host if request.client else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _rate_limited(request: Request, label: str) -> HTTPException:
|
|
41
|
+
"""Log a throttled (HTTP 429) attempt so password-spraying is no longer silent (ASVS 16.3.3),
|
|
42
|
+
then return the exception to raise. We log (the rotating general log) rather than write an
|
|
43
|
+
audit_log row per rejection so a sustained flood can't amplify into unbounded DB growth."""
|
|
44
|
+
_log.warning("rate-limited %s attempt from client=%s", label, _client(request))
|
|
45
|
+
return HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many attempts; please retry later")
|