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,94 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """L1a: read-only monitoring pages (alerts + event log)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from fastapi import Depends, FastAPI, Query, Request
10
+ from fastapi.responses import HTMLResponse
11
+
12
+ from messagefoundry.api._ui_seam import UiDeps
13
+ from messagefoundry.auth import Identity, Permission
14
+
15
+ from .. import pages
16
+ from .._auth import (
17
+ require_ui,
18
+ )
19
+
20
+
21
+ def register(app: FastAPI, deps: UiDeps) -> None:
22
+ """L1a: read-only monitoring pages (alerts + event log). Reuses the metadata-only JSON
23
+ handlers (no PHI, no step-up) — ADR 0065, BACKLOG #75 phase 1."""
24
+ core = deps.core
25
+
26
+ @app.get("/ui/alerts", response_class=HTMLResponse)
27
+ async def ui_alerts(
28
+ request: Request,
29
+ engine: Any = Depends(deps.get_engine),
30
+ identity: Identity = Depends(
31
+ require_ui(Permission.MONITORING_READ, Permission.MONITORING_DIAGNOSE)
32
+ ),
33
+ ) -> HTMLResponse:
34
+ # Active instances need monitoring:diagnose, rules need monitoring:read — the page
35
+ # requires BOTH (fail-closed), then calls the handlers directly (their own gates are
36
+ # skipped, so require_ui re-asserts the permissions the same way the other /ui routes do).
37
+ # Pass every param explicitly: calling the handler directly (not via Depends) leaves
38
+ # its Query(...) defaults unresolved, so limit must be a real int here.
39
+ instances = await core.list_active_alerts(engine=engine, identity=identity, limit=200)
40
+ config = await core.alerts_rules(request, _user=identity)
41
+ return HTMLResponse(pages.alerts(instances, config))
42
+
43
+ @app.get("/ui/events", response_class=HTMLResponse)
44
+ async def ui_events(
45
+ request: Request,
46
+ engine: Any = Depends(deps.get_engine),
47
+ identity: Identity = Depends(require_ui(Permission.MONITORING_READ)),
48
+ connection: str | None = Query(None, max_length=256),
49
+ kind: str | None = Query(None, max_length=64),
50
+ ) -> HTMLResponse:
51
+ # L6b (#75 parity): expose the JSON handler's event-kind filter (a single kind from
52
+ # the fixed dropdown → a one-element kinds list; blank/unknown = no filter).
53
+ kinds = [kind] if kind else None
54
+ rows = await core.list_connection_events(
55
+ engine=engine,
56
+ identity=identity,
57
+ connection=connection,
58
+ kind=kinds,
59
+ since=None,
60
+ limit=100,
61
+ request=request,
62
+ )
63
+ return HTMLResponse(pages.events(rows, connection=connection or "", kind=kind or ""))
64
+
65
+ async def _flow_data(request: Request, engine: Any, identity: Identity) -> tuple[Any, Any]:
66
+ """Fetch the two read-only monitoring:read sources for the Flow & trends page (BACKLOG #76):
67
+ the status-colored graph (from the Registry edges) + the metrics-history ring. Their own
68
+ ``Depends`` gates are skipped on a direct call, so ``require_ui`` re-asserted the permission."""
69
+ graph = await core.graph_edges(engine=engine, identity=identity)
70
+ history = await core.metrics_history(request, _user=identity)
71
+ return graph, history
72
+
73
+ @app.get("/ui/monitoring", response_class=HTMLResponse)
74
+ async def ui_monitoring(
75
+ request: Request,
76
+ engine: Any = Depends(deps.get_engine),
77
+ identity: Identity = Depends(require_ui(Permission.MONITORING_READ)),
78
+ ) -> HTMLResponse:
79
+ # #76: the status-colored by-name data-flow graph + the historical queue-trend chart, both
80
+ # inline SVG (CSP script-src 'self'). Read-only, metadata only — no message body.
81
+ graph, history = await _flow_data(request, engine, identity)
82
+ return HTMLResponse(pages.flow_and_trends(graph, history))
83
+
84
+ @app.get("/ui/monitoring/live", response_class=HTMLResponse)
85
+ async def ui_monitoring_live(
86
+ request: Request,
87
+ engine: Any = Depends(deps.get_engine),
88
+ # activity=False (ASVS 14.3.1): the Flow page's auto-refresh is timer-driven, not user activity.
89
+ identity: Identity = Depends(require_ui(Permission.MONITORING_READ, activity=False)),
90
+ ) -> HTMLResponse:
91
+ # The poll target app.js swaps into the page's [data-mf-fragment] container (server-rendered,
92
+ # already-escaped) so the graph's live status colours + the trend refresh without a WebSocket.
93
+ graph, history = await _flow_data(request, engine, identity)
94
+ return HTMLResponse(pages.flow_and_trends_fragment(graph, history))
@@ -0,0 +1,205 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """L3a: monitoring write actions (alert ack/resolve, statistics reset, integrity check, DR activate/release)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Literal
8
+ from urllib.parse import parse_qsl
9
+
10
+ from fastapi import Depends, FastAPI, Request, Response
11
+ from fastapi.responses import HTMLResponse, RedirectResponse
12
+ from pydantic import ValidationError
13
+
14
+ from messagefoundry.api._ui_seam import UiDeps
15
+ from messagefoundry.api.models import (
16
+ AlertSuspendRequest,
17
+ StatsResetRequest,
18
+ StatsResetTarget,
19
+ )
20
+ from messagefoundry.auth import Identity, Permission
21
+
22
+ from .. import pages
23
+ from .._auth import (
24
+ assert_same_origin,
25
+ require_ui,
26
+ )
27
+
28
+
29
+ def register(app: FastAPI, deps: UiDeps) -> None:
30
+ """L3a: monitoring write actions (alert ack/resolve, statistics reset, DB integrity check,
31
+ DR activate/release). Permission-gated to MATCH the JSON handlers (no step-up — they are not
32
+ require_step_up), CSRF-guarded by assert_same_origin, each redirecting back to its page.
33
+
34
+ These deliberately do NOT call register_ui_action(): that registry only gates the
35
+ step-up re-auth AUTO-RETRY allow-list (is_safe_ui_action), and these use plain require_ui —
36
+ they never route through /ui/reauth, so they have nothing to register."""
37
+ core = deps.core
38
+
39
+ @app.post("/ui/alerts/{alert_id}/ack")
40
+ async def ui_ack_alert(
41
+ alert_id: int,
42
+ request: Request,
43
+ engine: Any = Depends(deps.get_engine),
44
+ identity: Identity = Depends(require_ui(Permission.MONITORING_DIAGNOSE)),
45
+ ) -> Response:
46
+ assert_same_origin(request)
47
+ await core.ack_alert(alert_id, engine=engine, identity=identity, request=request)
48
+ return RedirectResponse("/ui/alerts", status_code=303)
49
+
50
+ @app.post("/ui/alerts/{alert_id}/resolve")
51
+ async def ui_resolve_alert(
52
+ alert_id: int,
53
+ request: Request,
54
+ engine: Any = Depends(deps.get_engine),
55
+ identity: Identity = Depends(require_ui(Permission.MONITORING_DIAGNOSE)),
56
+ ) -> Response:
57
+ assert_same_origin(request)
58
+ await core.resolve_alert(alert_id, request=request, engine=engine, identity=identity)
59
+ return RedirectResponse("/ui/alerts", status_code=303)
60
+
61
+ @app.post("/ui/alerts/{alert_id}/suspend")
62
+ async def ui_suspend_alert(
63
+ alert_id: int,
64
+ request: Request,
65
+ engine: Any = Depends(deps.get_engine),
66
+ identity: Identity = Depends(require_ui(Permission.MONITORING_DIAGNOSE)),
67
+ ) -> Response:
68
+ # #143 windowed suspend: the mute duration arrives as a `minutes` hidden/select form field; build
69
+ # the typed request and reuse the single audited JSON handler (scope check + store + notifier cache
70
+ # + audit). An out-of-range/undecodable value falls back to 60 minutes; core 404s an unknown id.
71
+ assert_same_origin(request)
72
+ form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
73
+ try:
74
+ body = AlertSuspendRequest(minutes=float(form.get("minutes") or "60"))
75
+ except (ValueError, ValidationError):
76
+ body = AlertSuspendRequest(minutes=60.0)
77
+ await core.suspend_alert(
78
+ alert_id, body=body, request=request, engine=engine, identity=identity
79
+ )
80
+ return RedirectResponse("/ui/alerts", status_code=303)
81
+
82
+ @app.post("/ui/alerts/{alert_id}/resume")
83
+ async def ui_resume_alert(
84
+ alert_id: int,
85
+ request: Request,
86
+ engine: Any = Depends(deps.get_engine),
87
+ identity: Identity = Depends(require_ui(Permission.MONITORING_DIAGNOSE)),
88
+ ) -> Response:
89
+ assert_same_origin(request)
90
+ await core.resume_alert(alert_id, request=request, engine=engine, identity=identity)
91
+ return RedirectResponse("/ui/alerts", status_code=303)
92
+
93
+ @app.post("/ui/statistics/reset")
94
+ async def ui_reset_statistics(
95
+ request: Request,
96
+ engine: Any = Depends(deps.get_engine),
97
+ identity: Identity = Depends(require_ui(Permission.MONITORING_DIAGNOSE)),
98
+ ) -> Response:
99
+ assert_same_origin(request)
100
+ # The status-page "Reset statistics" button zeroes ALL cumulative counters.
101
+ await core.reset_statistics(
102
+ StatsResetRequest(all=True), engine=engine, identity=identity, request=request
103
+ )
104
+ return RedirectResponse("/ui/status", status_code=303)
105
+
106
+ @app.post("/ui/statistics/reset-one")
107
+ async def ui_reset_statistics_one(
108
+ request: Request,
109
+ engine: Any = Depends(deps.get_engine),
110
+ identity: Identity = Depends(require_ui(Permission.MONITORING_DIAGNOSE)),
111
+ ) -> Response:
112
+ # L6b (#75 parity): reset ONE connection's counters from its dashboard row (the
113
+ # desktop's per-row/selected reset). role/channel_id/destination arrive as hidden
114
+ # form fields (names aren't path-safe); build a single-target request. The finer
115
+ # per-channel scope check runs inside core.reset_statistics (403 for an out-of-scope
116
+ # user), exactly like the reset-all path.
117
+ assert_same_origin(request)
118
+ form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
119
+ role: Literal["source", "destination"]
120
+ if form.get("role") == "source":
121
+ role = "source"
122
+ elif form.get("role") == "destination":
123
+ role = "destination"
124
+ else:
125
+ return RedirectResponse("/ui", status_code=303)
126
+ channel_id = form.get("channel_id", "")
127
+ destination = form.get("destination") or None
128
+ if not channel_id:
129
+ return RedirectResponse("/ui", status_code=303)
130
+ try:
131
+ target = StatsResetTarget(role=role, channel_id=channel_id, destination=destination)
132
+ except ValidationError:
133
+ return RedirectResponse("/ui", status_code=303)
134
+ await core.reset_statistics(
135
+ StatsResetRequest(targets=[target]), engine=engine, identity=identity, request=request
136
+ )
137
+ return RedirectResponse("/ui", status_code=303)
138
+
139
+ @app.post("/ui/statistics/reset-many")
140
+ async def ui_reset_statistics_many(
141
+ request: Request,
142
+ engine: Any = Depends(deps.get_engine),
143
+ identity: Identity = Depends(require_ui(Permission.MONITORING_DIAGNOSE)),
144
+ ) -> Response:
145
+ # Bulk counter reset over a selection of dashboard rows (both roles). Each `sel` is an
146
+ # encoded _row_key (role|b64url(channel_id)|b64url(destination)); build ONE
147
+ # StatsResetRequest and call reset_statistics directly — its per-channel scope check runs
148
+ # per target (a single out-of-scope target 403s the batch, matching reset-one). Undecodable
149
+ # sels are dropped (never reflected). require_ui already re-asserted MONITORING_DIAGNOSE.
150
+ assert_same_origin(request)
151
+ pairs = parse_qsl((await request.body()).decode("utf-8", "replace"))
152
+ targets: list[StatsResetTarget] = []
153
+ seen: set[tuple[str, str, str]] = set()
154
+ for key, value in pairs:
155
+ if key != "sel":
156
+ continue
157
+ decoded = pages.decode_row_key(value)
158
+ if decoded is None or decoded in seen:
159
+ continue
160
+ seen.add(decoded)
161
+ role, channel_id, destination = decoded
162
+ try:
163
+ targets.append(
164
+ StatsResetTarget(
165
+ role=role,
166
+ channel_id=channel_id,
167
+ destination=destination or None,
168
+ )
169
+ )
170
+ except ValidationError:
171
+ continue
172
+ await core.reset_statistics(
173
+ StatsResetRequest(targets=targets), engine=engine, identity=identity, request=request
174
+ )
175
+ return RedirectResponse("/ui", status_code=303)
176
+
177
+ @app.post("/ui/status/integrity-check")
178
+ async def ui_integrity_check(
179
+ request: Request,
180
+ engine: Any = Depends(deps.get_engine),
181
+ identity: Identity = Depends(require_ui(Permission.MONITORING_DIAGNOSE)),
182
+ ) -> HTMLResponse:
183
+ assert_same_origin(request)
184
+ result = await core.integrity_check(engine=engine, _user=identity)
185
+ return HTMLResponse(pages.integrity_result(result))
186
+
187
+ @app.post("/ui/dr/activate")
188
+ async def ui_dr_activate(
189
+ request: Request,
190
+ engine: Any = Depends(deps.get_engine),
191
+ identity: Identity = Depends(require_ui(Permission.DR_OPERATE)),
192
+ ) -> Response:
193
+ assert_same_origin(request)
194
+ await core.dr_activate(engine=engine, identity=identity, body=None)
195
+ return RedirectResponse("/ui/status", status_code=303)
196
+
197
+ @app.post("/ui/dr/release")
198
+ async def ui_dr_release(
199
+ request: Request,
200
+ engine: Any = Depends(deps.get_engine),
201
+ identity: Identity = Depends(require_ui(Permission.DR_OPERATE)),
202
+ ) -> Response:
203
+ assert_same_origin(request)
204
+ await core.dr_release(engine=engine, identity=identity)
205
+ return RedirectResponse("/ui/status", status_code=303)
@@ -0,0 +1,172 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """W4-5 (ADR 0142): the browser federated-login legs — OIDC authorization-code + PKCE, default-OFF.
4
+
5
+ Two GET routes, both unauthenticated, modelled closely on ``routes/sso.py``:
6
+
7
+ * ``/ui/oidc/start`` mints a server-side flow, drops an opaque flow id in a short-lived ``__Host-``
8
+ cookie, and 303s the browser to the IdP.
9
+ * ``/ui/oidc/callback`` re-binds cookie + ``state``, redeems the code, and lands the session.
10
+
11
+ **Registration is self-gating.** ``register`` returns before declaring either route unless
12
+ ``[auth].oidc_enabled`` is set, so with federation off the two paths are not in the route table at all
13
+ and ``tests/golden/ui_routes.txt`` is unchanged — that unchanged golden IS the AC-1 proof.
14
+
15
+ Two deliberate departures a reviewer will want to check rather than "fix":
16
+
17
+ * **No same-origin assertion on either leg.** ``assert_same_origin`` rejects any request whose
18
+ ``Sec-Fetch-Site`` is ``cross-site``, and the IdP's redirect back here is *legitimately* a top-level
19
+ cross-site navigation. Adding it would 403 every real federated login while every hermetic test
20
+ still passed (test clients send no ``Sec-Fetch`` headers). ``routes/sso.py`` does not call it either.
21
+ * **The callback returns 200 + a meta refresh, never a 303.** See :func:`pages.oidc_landing`.
22
+
23
+ Ordering rule inherited from ``sso.py``: **every audit-writing branch sits behind the rate limiter.**
24
+ Both legs are unauthenticated, so an attacker looping them would otherwise be an unbounded
25
+ audit_log-write amplifier. The availability and rate-limit rejects therefore write no audit row.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+
32
+ from fastapi import FastAPI, Query, Request, Response
33
+ from fastapi.responses import HTMLResponse, RedirectResponse
34
+
35
+ from messagefoundry.api._ui_seam import UiDeps
36
+ from messagefoundry.api.security import get_auth
37
+ from messagefoundry.auth.oidc import FlowCacheFullError, FlowError
38
+
39
+ from .. import pages
40
+ from .._auth import (
41
+ clear_oidc_flow_cookie,
42
+ oidc_flow_cookie_name,
43
+ set_oidc_flow_cookie,
44
+ set_session_cookie,
45
+ )
46
+
47
+ _log = logging.getLogger(__name__)
48
+
49
+ #: Service-side reject reasons mapped to the login page's allow-listed short codes. Anything not in
50
+ #: here collapses to ``oidc_failed`` — an unrecognised slug must never become a reflected error code.
51
+ _REASON_TO_CODE = {
52
+ "state_unknown": "flow_binding_missing",
53
+ "state_mismatch": "flow_binding_missing",
54
+ "mfa_claim_missing": "sso_mfa_required",
55
+ }
56
+
57
+
58
+ def _fail(request: Request, code: str) -> Response:
59
+ """A terminal callback failure: 303 to the login page AND clear the single-use flow cookie."""
60
+ resp = RedirectResponse(f"/ui/login?e={code}", status_code=303)
61
+ clear_oidc_flow_cookie(resp, request)
62
+ return resp
63
+
64
+
65
+ def register(app: FastAPI, deps: UiDeps) -> None:
66
+ """Register the federated-login legs — ONLY when ``[auth].oidc_enabled`` is set.
67
+
68
+ The gate reads ``deps.oidc_enabled`` (static CONFIG), not ``oidc_available`` (the advisory,
69
+ non-sticky runtime health flag): the route table is fixed at app construction, and gating it on a
70
+ value that changes when an IdP blips would make the surface appear and disappear across restarts.
71
+
72
+ It also must NOT read ``app.state.auth``. Under ``messagefoundry serve`` the app is built by
73
+ ``create_managed_app``, which attaches the AuthService inside the **lifespan** — long after
74
+ ``mount_ui`` has fixed the route table — so an ``app.state.auth`` gate registers nothing in
75
+ production while passing every test that constructs the app with ``auth=`` directly.
76
+ """
77
+ if not deps.oidc_enabled:
78
+ return
79
+
80
+ @app.get("/ui/oidc/start")
81
+ async def ui_oidc_start(request: Request) -> Response:
82
+ auth = get_auth(request)
83
+ if auth is None or not auth.oidc_enabled:
84
+ # Disabled: redirect WITHOUT auditing — the sso.py anti-flood carve-out. Note this reads
85
+ # oidc_enabled, not oidc_available: AC-8 requires recovery without an engine restart, so
86
+ # the start leg ALWAYS attempts and a degraded IdP is discovered per-request.
87
+ return RedirectResponse("/ui/login?e=oidc_unavailable", status_code=303)
88
+ client = request.client.host if request.client else None
89
+ if not auth.allow_login_attempt(client):
90
+ # A _log.warning, never an audit — parity with sso.py, so exhaustion writes zero DB rows.
91
+ _log.warning("federated sign-in rate limit exceeded for %s", client or "<unknown>")
92
+ return RedirectResponse("/ui/login?e=rate_limited", status_code=303)
93
+ mode = request.headers.get("Sec-Fetch-Mode")
94
+ if mode is not None and mode != "navigate":
95
+ # A non-navigation fetch of a login leg is drive-by probing. Audited (behind the limiter).
96
+ await auth.audit_oidc_reject("non_navigation_fetch")
97
+ return RedirectResponse("/ui/login?e=oidc_failed", status_code=303)
98
+ public_origin = getattr(request.app.state, "public_origin", None)
99
+ if not public_origin:
100
+ # The redirect_uri is derived from public_origin, never from the Host header — a
101
+ # client-forwardable Host would let an attacker steer where the IdP sends the code.
102
+ _log.warning("federated sign-in unavailable: [api].public_origin is not set")
103
+ return RedirectResponse("/ui/login?e=oidc_unavailable", status_code=303)
104
+ try:
105
+ flow_id, authorization_url = await auth.begin_oidc_login(
106
+ client=client, public_origin=public_origin
107
+ )
108
+ except FlowCacheFullError:
109
+ # The bounded flow cache REJECTS rather than evicts (evict-oldest would make a start-leg
110
+ # flood a login DoS). That is a flood signal, so it is logged, not audited per request.
111
+ _log.warning("federated flow cache is full; refusing new sign-in for %s", client or "?")
112
+ return RedirectResponse("/ui/login?e=rate_limited", status_code=303)
113
+ except FlowError:
114
+ await auth.audit_oidc_reject("start_failed")
115
+ return RedirectResponse("/ui/login?e=oidc_failed", status_code=303)
116
+ resp = RedirectResponse(authorization_url, status_code=303)
117
+ set_oidc_flow_cookie(resp, flow_id, request=request, max_age=auth.oidc_flow_ttl_seconds)
118
+ return resp
119
+
120
+ @app.get("/ui/oidc/callback")
121
+ async def ui_oidc_callback(
122
+ request: Request,
123
+ code: str | None = Query(None, max_length=4096),
124
+ state: str | None = Query(None, max_length=512),
125
+ error: str | None = Query(None, max_length=64),
126
+ ) -> Response:
127
+ # `error_description` is deliberately NOT accepted: it is free-form IdP/attacker text, and the
128
+ # bounded lengths above let FastAPI reject an oversized query string before any handler runs.
129
+ auth = get_auth(request)
130
+ if auth is None or not auth.oidc_enabled:
131
+ return RedirectResponse("/ui/login?e=oidc_unavailable", status_code=303)
132
+ client = request.client.host if request.client else None
133
+ if not auth.allow_login_attempt(client):
134
+ # The limiter runs on BOTH legs (ADR 0142): the callback is equally unauthenticated.
135
+ _log.warning("federated callback rate limit exceeded for %s", client or "<unknown>")
136
+ return RedirectResponse("/ui/login?e=rate_limited", status_code=303)
137
+ mode = request.headers.get("Sec-Fetch-Mode")
138
+ if mode is not None and mode != "navigate":
139
+ await auth.audit_oidc_reject("non_navigation_fetch")
140
+ return _fail(request, "oidc_failed")
141
+ # NOTE: no assert_same_origin here. Sec-Fetch-Site on this leg is legitimately "cross-site".
142
+
143
+ flow_id = request.cookies.get(oidc_flow_cookie_name(request))
144
+ if not flow_id:
145
+ # AC-7: without the browser-binding cookie the request is refused even when state and
146
+ # code are otherwise valid — server-side `state` alone would let whoever presents a valid
147
+ # (state, code) pair have the session minted into THEIR browser.
148
+ await auth.audit_oidc_reject("flow_binding_missing")
149
+ return _fail(request, "flow_binding_missing")
150
+ if error is not None:
151
+ # The IdP reported a failure. Audit a fixed slug; never the IdP's own string.
152
+ await auth.audit_oidc_reject("idp_error")
153
+ return _fail(request, "oidc_failed")
154
+ if not code or not state:
155
+ await auth.audit_oidc_reject("malformed_callback")
156
+ return _fail(request, "oidc_failed")
157
+
158
+ outcome = await auth.complete_oidc_login(
159
+ flow_id=flow_id,
160
+ state=state,
161
+ code=code,
162
+ client=client,
163
+ public_origin=getattr(request.app.state, "public_origin", "") or "",
164
+ )
165
+ if not outcome.ok or outcome.token is None:
166
+ # complete_oidc_login already audited the closed-set reason; map it to an allow-listed
167
+ # short code, defaulting to the generic one so an unrecognised slug is never reflected.
168
+ return _fail(request, _REASON_TO_CODE.get(outcome.reason or "", "oidc_failed"))
169
+ resp = HTMLResponse(pages.oidc_landing(), status_code=200)
170
+ set_session_cookie(resp, outcome.token, request=request)
171
+ clear_oidc_flow_cookie(resp, request)
172
+ return resp
@@ -0,0 +1,204 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """content-search: a step-up-unlock GET page over the JSON search_messages handler (ADR 0046 #51),
4
+ plus saved / layered filter presets (BACKLOG #151, ADR 0136)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ from typing import Any
10
+
11
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request
12
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
13
+
14
+ from messagefoundry.api._ui_seam import UiDeps
15
+ from messagefoundry.api.models import SearchPresetCreateRequest, SearchPresetCriteria
16
+ from messagefoundry.auth import Identity, Permission
17
+
18
+ from .. import pages
19
+ from .._auth import (
20
+ assert_same_origin,
21
+ register_ui_action,
22
+ require_ui,
23
+ require_ui_step_up,
24
+ )
25
+ from ._common import _form_pairs
26
+
27
+ # content-search (ADR 0046 #51): the search PAGE is step-up-gated (bulk-PHI decrypt), so register
28
+ # it as an UNLOCK form — a stale step-up 303s to /ui/reauth and GET-redirects back to the fresh
29
+ # search form (the L0c step-up-to-unlock primitive; the PHI-shaped search term is a GET query, so
30
+ # it is deliberately NOT carried across the redirect — the operator re-enters it in the window).
31
+ register_ui_action(
32
+ r"^/ui/messages/search$", Permission.MESSAGES_READ, auto_retry=False, unlock=True
33
+ )
34
+ # The layered run (ADR 0136) is likewise a step-up-gated GET that composes + decrypts; register it as
35
+ # an UNLOCK form too. Its query carries only preset IDS (never the PHI needle — that's server-composed
36
+ # from the encrypted column), so the deliberate-drop posture is preserved.
37
+ register_ui_action(
38
+ r"^/ui/messages/search/layered$", Permission.MESSAGES_READ, auto_retry=False, unlock=True
39
+ )
40
+
41
+
42
+ def register(app: FastAPI, deps: UiDeps) -> None:
43
+ """content-search + saved/layered presets over the JSON handlers (ADR 0046 / 0136)."""
44
+ core = deps.core
45
+
46
+ async def _presets(engine: Any, identity: Identity, request: Request) -> list[Any]:
47
+ return list(
48
+ (
49
+ await core.list_search_presets(engine=engine, identity=identity, request=request)
50
+ ).presets
51
+ )
52
+
53
+ @app.get("/ui/messages/search", response_class=HTMLResponse)
54
+ async def ui_message_search(
55
+ request: Request,
56
+ engine: Any = Depends(deps.get_engine),
57
+ identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_READ)),
58
+ content: str | None = Query(None, max_length=512),
59
+ field_path: str | None = Query(None, max_length=32),
60
+ field_value: str | None = Query(None, max_length=512),
61
+ target: str = Query("both", pattern="^(raw|summary|both)$"),
62
+ channel_id: str | None = Query(None, max_length=256),
63
+ status_filter: str | None = Query(None, alias="status", max_length=64),
64
+ message_type: str | None = Query(None, max_length=64),
65
+ control_id: str | None = Query(None, max_length=256),
66
+ limit: int = Query(50, ge=1, le=500),
67
+ ) -> HTMLResponse:
68
+ # A criterion is required to search; with none, render the bare form (no decrypt/audit).
69
+ # A field_path alone is a valid presence-test search (matches make_spec/row_matches),
70
+ # so it counts as a criterion too — keeping /ui at parity with the JSON API.
71
+ has_criteria = bool(content) or bool(field_value) or bool(field_path)
72
+ preset_list = await _presets(engine, identity, request)
73
+ shared = dict( # noqa: C408
74
+ content=content or "",
75
+ field_path=field_path or "",
76
+ field_value=field_value or "",
77
+ target=target,
78
+ channel_id=channel_id or "",
79
+ status=status_filter or "",
80
+ message_type=message_type or "",
81
+ control_id=control_id or "",
82
+ )
83
+ if not has_criteria:
84
+ return HTMLResponse(pages.message_search(None, presets=preset_list, **shared))
85
+ try:
86
+ # Call the JSON handler directly (its require_step_up Depends is skipped —
87
+ # require_ui_step_up above re-asserted it); pass every param explicitly.
88
+ results = await core.search_messages(
89
+ request,
90
+ engine=engine,
91
+ identity=identity,
92
+ content=content,
93
+ field_path=field_path,
94
+ field_value=field_value,
95
+ target=target,
96
+ channel_id=channel_id,
97
+ status=status_filter,
98
+ message_type=message_type,
99
+ control_id=control_id,
100
+ limit=limit,
101
+ scan_limit=deps.default_scan_limit,
102
+ )
103
+ except HTTPException as exc:
104
+ if exc.status_code == 400: # make_spec rejected the criteria — re-render the form
105
+ return HTMLResponse(
106
+ pages.message_search(
107
+ None, error=str(exc.detail), presets=preset_list, **shared
108
+ ),
109
+ status_code=400,
110
+ )
111
+ raise
112
+ return HTMLResponse(pages.message_search(results, presets=preset_list, **shared))
113
+
114
+ @app.post("/ui/messages/search/presets")
115
+ async def ui_save_preset(
116
+ request: Request,
117
+ engine: Any = Depends(deps.get_engine),
118
+ identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_READ)),
119
+ ) -> Response:
120
+ # Same-origin; step-up (persists a possibly-PHI criteria). Body-carrying, so it re-opens via the
121
+ # search page's unlock form on a stale step-up rather than being auto-retried.
122
+ assert_same_origin(request)
123
+ form = dict(await _form_pairs(request))
124
+ criteria = SearchPresetCriteria(
125
+ content=form.get("content") or None,
126
+ field_path=form.get("field_path") or None,
127
+ field_value=form.get("field_value") or None,
128
+ target=form.get("target")
129
+ if form.get("target") in ("raw", "summary", "both")
130
+ else "both", # type: ignore[arg-type]
131
+ channel_id=form.get("channel_id") or None,
132
+ status=form.get("status") or None,
133
+ message_type=form.get("message_type") or None,
134
+ control_id=form.get("control_id") or None,
135
+ limit=50,
136
+ )
137
+ try:
138
+ body = SearchPresetCreateRequest(name=form.get("name", ""), criteria=criteria)
139
+ await core.create_search_preset(
140
+ body=body, engine=engine, identity=identity, request=request
141
+ )
142
+ except HTTPException as exc:
143
+ preset_list = await _presets(engine, identity, request)
144
+ return HTMLResponse(
145
+ pages.message_search(None, error=str(exc.detail), presets=preset_list),
146
+ status_code=exc.status_code,
147
+ )
148
+ except ValueError: # pydantic validation (e.g. empty name)
149
+ preset_list = await _presets(engine, identity, request)
150
+ return HTMLResponse(
151
+ pages.message_search(None, error="a preset name is required", presets=preset_list),
152
+ status_code=400,
153
+ )
154
+ return RedirectResponse("/ui/messages/search", status_code=303)
155
+
156
+ @app.post("/ui/messages/search/presets/{preset_id}/delete")
157
+ async def ui_delete_preset(
158
+ preset_id: str,
159
+ request: Request,
160
+ engine: Any = Depends(deps.get_engine),
161
+ identity: Identity = Depends(require_ui(Permission.MESSAGES_READ)),
162
+ ) -> Response:
163
+ # Deleting your own preset is low-risk metadata (no step-up); same-origin CSRF guard.
164
+ assert_same_origin(request)
165
+ # A missing preset (already deleted) → the redirect just re-renders the list.
166
+ with contextlib.suppress(HTTPException):
167
+ await core.delete_search_preset(
168
+ preset_id, engine=engine, identity=identity, request=request
169
+ )
170
+ return RedirectResponse("/ui/messages/search", status_code=303)
171
+
172
+ @app.get("/ui/messages/search/layered", response_class=HTMLResponse)
173
+ async def ui_layered_search(
174
+ request: Request,
175
+ engine: Any = Depends(deps.get_engine),
176
+ identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_READ)),
177
+ presets: list[str] | None = Query(None),
178
+ ) -> HTMLResponse:
179
+ preset_list = await _presets(engine, identity, request)
180
+ ids = ",".join(p for p in (presets or []) if p)
181
+ if not ids:
182
+ return HTMLResponse(
183
+ pages.message_search(
184
+ None, error="select at least one preset to layer", presets=preset_list
185
+ ),
186
+ status_code=400,
187
+ )
188
+ try:
189
+ results = await core.layered_search(
190
+ request,
191
+ engine=engine,
192
+ identity=identity,
193
+ presets=ids,
194
+ limit=50,
195
+ scan_limit=deps.default_scan_limit,
196
+ )
197
+ except HTTPException as exc:
198
+ if exc.status_code in (400, 404):
199
+ return HTMLResponse(
200
+ pages.message_search(None, error=str(exc.detail), presets=preset_list),
201
+ status_code=exc.status_code,
202
+ )
203
+ raise
204
+ return HTMLResponse(pages.message_search(results, presets=preset_list))