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.
Files changed (40) hide show
  1. messagefoundry_webconsole/__init__.py +128 -0
  2. messagefoundry_webconsole/_auth.py +841 -0
  3. messagefoundry_webconsole/_html.py +539 -0
  4. messagefoundry_webconsole/_security.py +253 -0
  5. messagefoundry_webconsole/_service.py +22 -0
  6. messagefoundry_webconsole/_static.py +78 -0
  7. messagefoundry_webconsole/mount.py +103 -0
  8. messagefoundry_webconsole/pages/__init__.py +25 -0
  9. messagefoundry_webconsole/pages/_common.py +21 -0
  10. messagefoundry_webconsole/pages/account.py +771 -0
  11. messagefoundry_webconsole/pages/admin.py +480 -0
  12. messagefoundry_webconsole/pages/audit.py +66 -0
  13. messagefoundry_webconsole/pages/config.py +113 -0
  14. messagefoundry_webconsole/pages/connections.py +519 -0
  15. messagefoundry_webconsole/pages/messages.py +689 -0
  16. messagefoundry_webconsole/pages/monitoring.py +853 -0
  17. messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
  18. messagefoundry_webconsole/routes/__init__.py +6 -0
  19. messagefoundry_webconsole/routes/_common.py +45 -0
  20. messagefoundry_webconsole/routes/account.py +451 -0
  21. messagefoundry_webconsole/routes/admin.py +505 -0
  22. messagefoundry_webconsole/routes/audit.py +39 -0
  23. messagefoundry_webconsole/routes/config.py +67 -0
  24. messagefoundry_webconsole/routes/connection_writes.py +248 -0
  25. messagefoundry_webconsole/routes/core.py +1077 -0
  26. messagefoundry_webconsole/routes/monitoring.py +94 -0
  27. messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
  28. messagefoundry_webconsole/routes/oidc.py +172 -0
  29. messagefoundry_webconsole/routes/search.py +204 -0
  30. messagefoundry_webconsole/routes/sso.py +88 -0
  31. messagefoundry_webconsole/routes/status.py +178 -0
  32. messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
  33. messagefoundry_webconsole/static/app.css +345 -0
  34. messagefoundry_webconsole/static/app.js +1506 -0
  35. messagefoundry_webconsole/static/csp-probe.js +9 -0
  36. messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
  37. messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
  38. messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
  39. messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
  40. 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)