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,248 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""L3b: outbound queue purge (step-up + dual-control) + the bulk connection-control surface."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Literal, cast
|
|
9
|
+
from urllib.parse import parse_qsl
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
|
12
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
13
|
+
|
|
14
|
+
from messagefoundry.api._ui_seam import UiDeps
|
|
15
|
+
from messagefoundry.api.models import (
|
|
16
|
+
ConnectionFlagRequest,
|
|
17
|
+
PendingApprovalResponse,
|
|
18
|
+
)
|
|
19
|
+
from messagefoundry.api.security import client_ip
|
|
20
|
+
from messagefoundry.auth import Identity, Permission
|
|
21
|
+
|
|
22
|
+
from .. import pages
|
|
23
|
+
from .._auth import (
|
|
24
|
+
assert_same_origin,
|
|
25
|
+
register_ui_action,
|
|
26
|
+
require_ui,
|
|
27
|
+
require_ui_step_up,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# L3b: the queue purge is step-up-gated, so register it in the write-action allow-list — this is
|
|
33
|
+
# the first extension of the registry L0b introduced (the step-up re-auth may auto-retry the
|
|
34
|
+
# body-less POST because both params, name + scope, live in the PATH).
|
|
35
|
+
register_ui_action(r"^/ui/connections/[^/?#]+/purge/(top|all)$", Permission.MESSAGES_PURGE)
|
|
36
|
+
# The bulk purge CONFIRM page is a step-up-UNLOCK GET form (like content-search / create-user): a
|
|
37
|
+
# stale step-up 303s to /ui/reauth and, after re-verification, 303-GET-redirects BACK to it (the
|
|
38
|
+
# ?dest/?scope query is deliberately NOT carried across — the operator re-selects on the fresh
|
|
39
|
+
# page; fail-safe for a destructive op). auto_retry=False so it is never re-POSTed body-less.
|
|
40
|
+
register_ui_action(
|
|
41
|
+
r"^/ui/connections/purge-confirm$",
|
|
42
|
+
Permission.MESSAGES_PURGE,
|
|
43
|
+
auto_retry=False,
|
|
44
|
+
unlock=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register(app: FastAPI, deps: UiDeps) -> None:
|
|
49
|
+
"""L3b: outbound queue purge (soft-cancel an outbound's queued deliveries) + the bulk
|
|
50
|
+
connection-control surface. Purge is step-up-gated (require_ui_step_up → /ui/reauth) +
|
|
51
|
+
dual-control (may hold for a second approver) and acts on an OUTBOUND, so a channel-scoped user
|
|
52
|
+
is refused; bulk-control mirrors the per-name control primitive (CONNECTIONS_CONTROL, no
|
|
53
|
+
step-up). The literal bulk paths are registered BEFORE the ``{name}/purge/{scope}`` route so a
|
|
54
|
+
literal always wins over the path-param route."""
|
|
55
|
+
core = deps.core
|
|
56
|
+
|
|
57
|
+
@app.post("/ui/connections/bulk-control")
|
|
58
|
+
async def ui_bulk_control(
|
|
59
|
+
request: Request,
|
|
60
|
+
engine: Any = Depends(deps.get_engine),
|
|
61
|
+
identity: Identity = Depends(require_ui(Permission.CONNECTIONS_CONTROL)),
|
|
62
|
+
) -> HTMLResponse:
|
|
63
|
+
# Bulk Start/Stop/Restart over selected rows (both roles). require_ui re-asserts
|
|
64
|
+
# CONNECTIONS_CONTROL (the dual-role handler's own Depends is skipped on a direct call).
|
|
65
|
+
# Each `sel` is an encoded _row_key; dispatch per unique control target (a multi-edge
|
|
66
|
+
# outbound → one destination name → fires once), capturing each per-target failure so one
|
|
67
|
+
# bad target never aborts the batch. Undecodable sels render the fixed label, never bytes.
|
|
68
|
+
assert_same_origin(request)
|
|
69
|
+
pairs = parse_qsl((await request.body()).decode("utf-8", "replace"))
|
|
70
|
+
action = next((v for k, v in pairs if k == "action"), "")
|
|
71
|
+
if action not in ("start", "stop", "restart"):
|
|
72
|
+
raise HTTPException(404, "unknown control action")
|
|
73
|
+
outcomes: list[tuple[str | None, str]] = []
|
|
74
|
+
seen: set[tuple[str, str]] = set()
|
|
75
|
+
for key, value in pairs:
|
|
76
|
+
if key != "sel":
|
|
77
|
+
continue
|
|
78
|
+
decoded = pages.decode_row_key(value)
|
|
79
|
+
if decoded is None:
|
|
80
|
+
outcomes.append((None, "not applied"))
|
|
81
|
+
continue
|
|
82
|
+
role, channel_id, destination = decoded
|
|
83
|
+
name = channel_id if role == "source" else destination
|
|
84
|
+
if not name: # a destination row with no destination name is malformed
|
|
85
|
+
outcomes.append((None, "not applied"))
|
|
86
|
+
continue
|
|
87
|
+
# Dedupe per (role, name): a multi-edge outbound is controlled once, but a same-named
|
|
88
|
+
# source and destination are two distinct control targets — never collapsed to one.
|
|
89
|
+
if (role, name) in seen:
|
|
90
|
+
continue
|
|
91
|
+
seen.add((role, name))
|
|
92
|
+
try:
|
|
93
|
+
result = await core.dual_role_control(engine, identity, name, action, role=role)
|
|
94
|
+
outcomes.append((name, f"applied (running={result['running']})"))
|
|
95
|
+
except HTTPException as exc:
|
|
96
|
+
outcomes.append((name, f"{exc.status_code}: {exc.detail}"))
|
|
97
|
+
except Exception: # noqa: BLE001 - capture-and-continue; one bad target never aborts the batch
|
|
98
|
+
_log.exception("bulk control %s failed for %r", action, name)
|
|
99
|
+
outcomes.append((name, "error"))
|
|
100
|
+
return HTMLResponse(pages.bulk_control_result(action, outcomes))
|
|
101
|
+
|
|
102
|
+
@app.post("/ui/connections/{name}/flag")
|
|
103
|
+
async def ui_set_connection_flag(
|
|
104
|
+
name: str,
|
|
105
|
+
request: Request,
|
|
106
|
+
engine: Any = Depends(deps.get_engine),
|
|
107
|
+
identity: Identity = Depends(require_ui(Permission.CONFIG_DEPLOY)),
|
|
108
|
+
) -> Response:
|
|
109
|
+
# #131 (ADR 0007 amendment): the FIRST console→connections.toml write path — the object-flag
|
|
110
|
+
# toggle. config:deploy + same-origin (like bulk-control); NO step-up (a cosmetic annotation), so
|
|
111
|
+
# it registers no write-action. The reused JSON handler persists via connections_edit
|
|
112
|
+
# (validate-before-persist, comment-preserving) + reflects it on the live registry, and REFUSES a
|
|
113
|
+
# code-first connection (no TOML home) with 409 — which we render as a friendly notice.
|
|
114
|
+
assert_same_origin(request)
|
|
115
|
+
form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
|
|
116
|
+
direction = form.get("direction", "")
|
|
117
|
+
if direction not in ("inbound", "outbound"):
|
|
118
|
+
raise HTTPException(404, "unknown connection direction")
|
|
119
|
+
req = ConnectionFlagRequest(
|
|
120
|
+
flagged=form.get("flagged") == "true",
|
|
121
|
+
direction=cast(Literal["inbound", "outbound"], direction),
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
await core.set_connection_flag(
|
|
125
|
+
name, req, engine=engine, identity=identity, request=request
|
|
126
|
+
)
|
|
127
|
+
except HTTPException as exc:
|
|
128
|
+
if (
|
|
129
|
+
exc.status_code == 409
|
|
130
|
+
): # scope fork (code-first) or a validate-before-persist failure
|
|
131
|
+
return HTMLResponse(pages.flag_rejected(name, str(exc.detail)))
|
|
132
|
+
raise
|
|
133
|
+
return RedirectResponse("/ui", status_code=303)
|
|
134
|
+
|
|
135
|
+
@app.get("/ui/connections/purge-confirm", response_class=HTMLResponse)
|
|
136
|
+
async def ui_purge_confirm(
|
|
137
|
+
request: Request,
|
|
138
|
+
engine: Any = Depends(deps.get_engine),
|
|
139
|
+
identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_PURGE)),
|
|
140
|
+
dest: list[str] | None = Query(None),
|
|
141
|
+
scope: str = Query("all", max_length=8),
|
|
142
|
+
) -> HTMLResponse:
|
|
143
|
+
# Step-up-unlock confirm page for the bulk purge. A channel-scoped user can't purge a
|
|
144
|
+
# shared outbound (mirrors purge_connection); validate the scope (404, not 422, matching
|
|
145
|
+
# the per-name route); then RE-DERIVE eligibility from LIVE quiescence — intersect the
|
|
146
|
+
# requested ?dest with rr.outbound_quiesced(d), dropping anything since-started / not-yet-
|
|
147
|
+
# quiesced. On a stale step-up, require_ui_step_up already 303'd to /ui/reauth and the
|
|
148
|
+
# unlock flow GET-redirects back here WITHOUT the query, so dest is empty → nothing pre-
|
|
149
|
+
# armed (the operator re-selects). The confirm form POSTs to /ui/connections/purge-bulk.
|
|
150
|
+
if identity.allowed_channels is not None:
|
|
151
|
+
# ADR 0150: a console RBAC denial is a security event from a specific host, so it
|
|
152
|
+
# carries the browser's address exactly as the JSON API's denials do. `request` here is
|
|
153
|
+
# the browser's — the console mounts in-process on the engine's own app.
|
|
154
|
+
await core.audit_channel_denied(engine, identity, None, client_ip(request))
|
|
155
|
+
raise HTTPException(
|
|
156
|
+
403, "channel-scoped users cannot purge a shared outbound connection"
|
|
157
|
+
)
|
|
158
|
+
if scope not in ("top", "all"):
|
|
159
|
+
raise HTTPException(404, "unknown purge scope")
|
|
160
|
+
rr = engine.registry_runner
|
|
161
|
+
eligible: list[str] = []
|
|
162
|
+
seen_dest: set[str] = set()
|
|
163
|
+
for d in dest or []:
|
|
164
|
+
if d in seen_dest:
|
|
165
|
+
continue
|
|
166
|
+
seen_dest.add(d)
|
|
167
|
+
if rr is not None and rr.outbound_quiesced(d):
|
|
168
|
+
eligible.append(d)
|
|
169
|
+
return HTMLResponse(pages.purge_confirm(eligible, scope))
|
|
170
|
+
|
|
171
|
+
@app.post("/ui/connections/purge-bulk")
|
|
172
|
+
async def ui_purge_bulk(
|
|
173
|
+
request: Request,
|
|
174
|
+
engine: Any = Depends(deps.get_engine),
|
|
175
|
+
identity: Identity = Depends(
|
|
176
|
+
require_ui_step_up(
|
|
177
|
+
Permission.MESSAGES_PURGE,
|
|
178
|
+
reauth_next=lambda _r: "/ui/connections/purge-confirm",
|
|
179
|
+
)
|
|
180
|
+
),
|
|
181
|
+
gate: Any = Depends(deps.get_gate),
|
|
182
|
+
) -> HTMLResponse:
|
|
183
|
+
# The body-carrying bulk purge. require_ui_step_up re-asserts MESSAGES_PURGE + the step-up
|
|
184
|
+
# window (mapping a stale window's re-auth to the confirm UNLOCK page, never a body-less
|
|
185
|
+
# re-POST). Validate the scope BEFORE fan-out (a directly-called purge_connection skips its
|
|
186
|
+
# own Query pattern, so an unvalidated scope would silently become purge-all). Then, per
|
|
187
|
+
# UNIQUE dest, call purge_connection DIRECTLY (its require_step_up Depends is skipped, so
|
|
188
|
+
# the 409 require-quiesced guard + the ApprovalGate dual-control run per dest), capturing
|
|
189
|
+
# PurgeResult / PendingApprovalResponse / HTTPException(403/404/409) — one bad dest never
|
|
190
|
+
# aborts the batch.
|
|
191
|
+
assert_same_origin(request)
|
|
192
|
+
pairs = parse_qsl((await request.body()).decode("utf-8", "replace"))
|
|
193
|
+
scope = next((v for k, v in pairs if k == "scope"), "all")
|
|
194
|
+
if scope not in ("top", "all"):
|
|
195
|
+
raise HTTPException(404, "unknown purge scope")
|
|
196
|
+
outcomes: list[tuple[str | None, str]] = []
|
|
197
|
+
seen_dest: set[str] = set()
|
|
198
|
+
for key, value in pairs:
|
|
199
|
+
if key != "dest":
|
|
200
|
+
continue
|
|
201
|
+
if value in seen_dest:
|
|
202
|
+
continue
|
|
203
|
+
seen_dest.add(value)
|
|
204
|
+
try:
|
|
205
|
+
result = await core.purge_connection(
|
|
206
|
+
value,
|
|
207
|
+
Response(),
|
|
208
|
+
engine=engine,
|
|
209
|
+
scope=scope,
|
|
210
|
+
identity=identity,
|
|
211
|
+
gate=gate,
|
|
212
|
+
request=request,
|
|
213
|
+
)
|
|
214
|
+
if isinstance(result, PendingApprovalResponse):
|
|
215
|
+
outcomes.append((value, f"held for approval ({result.approval_id})"))
|
|
216
|
+
else:
|
|
217
|
+
outcomes.append((value, f"purged {result.cancelled}"))
|
|
218
|
+
except HTTPException as exc:
|
|
219
|
+
outcomes.append((value, f"{exc.status_code}: {exc.detail}"))
|
|
220
|
+
except Exception: # noqa: BLE001 - capture-and-continue; one bad dest never aborts the batch
|
|
221
|
+
_log.exception("bulk purge failed for %r", value)
|
|
222
|
+
outcomes.append((value, "error"))
|
|
223
|
+
return HTMLResponse(pages.purge_result(scope, outcomes))
|
|
224
|
+
|
|
225
|
+
@app.post("/ui/connections/{name}/purge/{scope}")
|
|
226
|
+
async def ui_purge_connection(
|
|
227
|
+
name: str,
|
|
228
|
+
scope: str,
|
|
229
|
+
request: Request,
|
|
230
|
+
engine: Any = Depends(deps.get_engine),
|
|
231
|
+
identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_PURGE)),
|
|
232
|
+
gate: Any = Depends(deps.get_gate),
|
|
233
|
+
) -> Response:
|
|
234
|
+
assert_same_origin(request)
|
|
235
|
+
if scope not in ("top", "all"):
|
|
236
|
+
raise HTTPException(404, "unknown purge scope")
|
|
237
|
+
result = await core.purge_connection(
|
|
238
|
+
name,
|
|
239
|
+
Response(),
|
|
240
|
+
engine=engine,
|
|
241
|
+
scope=scope,
|
|
242
|
+
identity=identity,
|
|
243
|
+
gate=gate,
|
|
244
|
+
request=request,
|
|
245
|
+
)
|
|
246
|
+
if isinstance(result, PendingApprovalResponse):
|
|
247
|
+
return HTMLResponse(pages.purge_pending(result))
|
|
248
|
+
return RedirectResponse("/ui", status_code=303)
|