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,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))
|