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,505 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """L4a admin surface (ADR 0065; #75 phase 4): user, role, and AD-group-mapping /ui pages + actions. Clients of the injected JSON handlers (called directly, re-asserting each gate via require_ui*)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from fastapi import Depends, FastAPI, HTTPException, Request, status
8
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
9
+ from pydantic import ValidationError
10
+
11
+ from messagefoundry.api._ui_seam import UiDeps
12
+ from messagefoundry.api.auth_models import (
13
+ AdGroupMap,
14
+ AdGroupMapEntry,
15
+ AdGroupScopeEntry,
16
+ AdGroupScopeMap,
17
+ ChannelScope,
18
+ CustomRoleInfo,
19
+ CustomRoleRequest,
20
+ RolesUpdateRequest,
21
+ UserCreateRequest,
22
+ UserUpdateRequest,
23
+ )
24
+ from messagefoundry.auth import Identity, Permission
25
+ from messagefoundry.auth.permissions import CUSTOM_ROLE_FORBIDDEN_PERMISSIONS
26
+ from messagefoundry.auth.service import AuthService
27
+
28
+ from .. import pages
29
+ from .._auth import (
30
+ assert_same_origin,
31
+ register_ui_action,
32
+ require_ui,
33
+ require_ui_step_up,
34
+ )
35
+ from .._service import _service
36
+ from ._common import _form_pairs
37
+
38
+ register_ui_action(r"^/ui/users/new$", Permission.USERS_MANAGE, auto_retry=False, unlock=True)
39
+ register_ui_action(r"^/ui/users/[^/?#]+$", Permission.USERS_MANAGE, auto_retry=False, unlock=True)
40
+ register_ui_action(
41
+ r"^/ui/users/[^/?#]+/(reset-password|reset-mfa|revoke-sessions|delete)$",
42
+ Permission.USERS_MANAGE,
43
+ )
44
+ register_ui_action(r"^/ui/roles/new$", Permission.USERS_MANAGE, auto_retry=False, unlock=True)
45
+ register_ui_action(
46
+ r"^/ui/roles/[^/?#]+/edit$", Permission.USERS_MANAGE, auto_retry=False, unlock=True
47
+ )
48
+ register_ui_action(r"^/ui/roles/custom/[^/?#]+/delete$", Permission.USERS_MANAGE)
49
+ register_ui_action(r"^/ui/ad-groups$", Permission.USERS_MANAGE, auto_retry=False, unlock=True)
50
+
51
+
52
+ def register(app: FastAPI, deps: UiDeps) -> None:
53
+ """L4a admin surface (ADR 0065; #75 phase 4): user, role, and AD-group-mapping /ui pages + actions. Clients of the injected JSON handlers (called directly, re-asserting each gate via require_ui*)."""
54
+ admin = deps.admin
55
+
56
+ # Custom roles may grant any catalog permission EXCEPT the carved-out escalation primitives
57
+ # (ADR 0045 D1) — don't offer what the service will refuse.
58
+ _role_catalog = sorted(
59
+ p.value for p in Permission if p not in CUSTOM_ROLE_FORBIDDEN_PERMISSIONS
60
+ )
61
+
62
+ async def _user_detail(
63
+ user_id: str,
64
+ service: AuthService,
65
+ identity: Identity,
66
+ *,
67
+ error: str | None = None,
68
+ status_code: int = 200,
69
+ ) -> HTMLResponse:
70
+ user = await service.store.get_user(user_id)
71
+ if user is None:
72
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "no such user")
73
+ role_ids = await service.store.get_user_role_ids(user.id)
74
+ all_roles = await admin.list_roles(service=service, _=identity)
75
+ return HTMLResponse(
76
+ pages.user_detail_page(admin.user_summary(user, role_ids), all_roles, error=error),
77
+ status_code=status_code,
78
+ )
79
+
80
+ # --- users: pages ---------------------------------------------------
81
+
82
+ @app.get("/ui/users", response_class=HTMLResponse)
83
+ async def ui_users(
84
+ service: AuthService = Depends(_service),
85
+ identity: Identity = Depends(require_ui(Permission.USERS_READ)),
86
+ ) -> HTMLResponse:
87
+ users = await admin.list_users(service=service, _=identity)
88
+ return HTMLResponse(pages.users_page(users))
89
+
90
+ # Declared BEFORE /ui/users/{user_id} so the literal segment wins the route match.
91
+ @app.get("/ui/users/new", response_class=HTMLResponse)
92
+ async def ui_user_new(
93
+ service: AuthService = Depends(_service),
94
+ identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
95
+ ) -> HTMLResponse:
96
+ roles = await admin.list_roles(service=service, _=identity)
97
+ return HTMLResponse(pages.user_new_page(roles))
98
+
99
+ @app.get("/ui/users/{user_id}", response_class=HTMLResponse)
100
+ async def ui_user_detail(
101
+ user_id: str,
102
+ service: AuthService = Depends(_service),
103
+ identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
104
+ ) -> HTMLResponse:
105
+ return await _user_detail(user_id, service, identity)
106
+
107
+ # --- users: actions ---------------------------------------------------
108
+
109
+ @app.post("/ui/users")
110
+ async def ui_user_create(
111
+ request: Request,
112
+ service: AuthService = Depends(_service),
113
+ identity: Identity = Depends(
114
+ require_ui_step_up(Permission.USERS_MANAGE, reauth_next=lambda _r: "/ui/users/new")
115
+ ),
116
+ ) -> Response:
117
+ assert_same_origin(request)
118
+ pairs = await _form_pairs(request)
119
+ form = dict(pairs)
120
+ roles = [v for k, v in pairs if k == "roles"]
121
+ try:
122
+ body = UserCreateRequest(
123
+ username=form.get("username", "").strip(),
124
+ password=form.get("password", ""),
125
+ display_name=form.get("display_name", "").strip() or None,
126
+ email=form.get("email", "").strip() or None,
127
+ roles=roles,
128
+ )
129
+ created = await admin.create_user(body=body, service=service, identity=identity)
130
+ except (ValidationError, HTTPException) as exc:
131
+ detail = "invalid input" if isinstance(exc, ValidationError) else str(exc.detail)
132
+ all_roles = await admin.list_roles(service=service, _=identity)
133
+ # Re-render preserving the NON-SECRET fields only — the password is never echoed.
134
+ return HTMLResponse(
135
+ pages.user_new_page(
136
+ all_roles,
137
+ error=detail,
138
+ username=form.get("username", "").strip(),
139
+ display_name=form.get("display_name", "").strip(),
140
+ email=form.get("email", "").strip(),
141
+ checked=roles,
142
+ ),
143
+ status_code=400,
144
+ )
145
+ return RedirectResponse(f"/ui/users/{created.id}", status_code=303)
146
+
147
+ @app.post("/ui/users/{user_id}/update")
148
+ async def ui_user_update(
149
+ user_id: str,
150
+ request: Request,
151
+ service: AuthService = Depends(_service),
152
+ identity: Identity = Depends(
153
+ require_ui_step_up(
154
+ Permission.USERS_MANAGE,
155
+ reauth_next=lambda r: r.url.path.removesuffix("/update"),
156
+ )
157
+ ),
158
+ ) -> Response:
159
+ assert_same_origin(request)
160
+ form = dict(await _form_pairs(request))
161
+ try:
162
+ # An HTML form always posts the full profile picture, so every field is set explicitly
163
+ # ("" clears to None; an absent checkbox means enabled) — the PATCH partial semantics of
164
+ # the JSON handler don't apply to a form submit.
165
+ body = UserUpdateRequest(
166
+ display_name=form.get("display_name", "").strip() or None,
167
+ email=form.get("email", "").strip() or None,
168
+ disabled="disabled" in form,
169
+ )
170
+ await admin.update_user(user_id, body=body, service=service, identity=identity)
171
+ except (ValidationError, HTTPException) as exc:
172
+ if isinstance(exc, HTTPException) and exc.status_code == status.HTTP_404_NOT_FOUND:
173
+ raise
174
+ detail = "invalid input" if isinstance(exc, ValidationError) else str(exc.detail)
175
+ return await _user_detail(user_id, service, identity, error=detail, status_code=400)
176
+ return RedirectResponse(f"/ui/users/{user_id}", status_code=303)
177
+
178
+ @app.post("/ui/users/{user_id}/roles")
179
+ async def ui_user_roles(
180
+ user_id: str,
181
+ request: Request,
182
+ service: AuthService = Depends(_service),
183
+ identity: Identity = Depends(
184
+ require_ui_step_up(
185
+ Permission.USERS_MANAGE,
186
+ reauth_next=lambda r: r.url.path.removesuffix("/roles"),
187
+ )
188
+ ),
189
+ ) -> Response:
190
+ assert_same_origin(request)
191
+ pairs = await _form_pairs(request)
192
+ roles = [v for k, v in pairs if k == "roles"]
193
+ try:
194
+ body = RolesUpdateRequest(roles=roles)
195
+ await admin.set_user_roles(user_id, body=body, service=service, identity=identity)
196
+ except (ValidationError, HTTPException) as exc:
197
+ if isinstance(exc, HTTPException) and exc.status_code == status.HTTP_404_NOT_FOUND:
198
+ raise
199
+ detail = "invalid input" if isinstance(exc, ValidationError) else str(exc.detail)
200
+ return await _user_detail(user_id, service, identity, error=detail, status_code=400)
201
+ return RedirectResponse(f"/ui/users/{user_id}", status_code=303)
202
+
203
+ @app.post("/ui/users/{user_id}/channel-scope")
204
+ async def ui_user_channel_scope(
205
+ user_id: str,
206
+ request: Request,
207
+ service: AuthService = Depends(_service),
208
+ identity: Identity = Depends(
209
+ require_ui_step_up(
210
+ Permission.USERS_MANAGE,
211
+ reauth_next=lambda r: r.url.path.removesuffix("/channel-scope"),
212
+ )
213
+ ),
214
+ ) -> Response:
215
+ assert_same_origin(request)
216
+ form = dict(await _form_pairs(request))
217
+ names = [ln.strip() for ln in form.get("channels", "").splitlines() if ln.strip()]
218
+ # The tri-state scope_mode keeps deny-all ([]) distinguishable from all-channels (None) —
219
+ # an empty textarea alone must never widen a stored deny-all scope (review PR2-M3).
220
+ # Absent (a pre-tri-state cached form) defaults to "list"; any OTHER value is a
221
+ # hand-crafted post — refused rather than guessed (deny-by-default).
222
+ mode = form.get("scope_mode", "list")
223
+ if mode not in ("all", "list", "none"):
224
+ return await _user_detail(
225
+ user_id, service, identity, error="unknown scope mode", status_code=400
226
+ )
227
+ if mode == "list" and not names:
228
+ return await _user_detail(
229
+ user_id,
230
+ service,
231
+ identity,
232
+ error=(
233
+ "list at least one connection, or choose the all-channels / "
234
+ "no-channels scope instead"
235
+ ),
236
+ status_code=400,
237
+ )
238
+ channels = None if mode == "all" else ([] if mode == "none" else names)
239
+ try:
240
+ body = ChannelScope(channels=channels)
241
+ await admin.set_channel_scope(user_id, body=body, service=service, identity=identity)
242
+ except (ValidationError, HTTPException) as exc:
243
+ if isinstance(exc, HTTPException) and exc.status_code == status.HTTP_404_NOT_FOUND:
244
+ raise
245
+ detail = "invalid input" if isinstance(exc, ValidationError) else str(exc.detail)
246
+ return await _user_detail(user_id, service, identity, error=detail, status_code=400)
247
+ return RedirectResponse(f"/ui/users/{user_id}", status_code=303)
248
+
249
+ @app.post("/ui/users/{user_id}/reset-password")
250
+ async def ui_user_reset_password(
251
+ user_id: str,
252
+ request: Request,
253
+ service: AuthService = Depends(_service),
254
+ identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
255
+ ) -> Response:
256
+ assert_same_origin(request)
257
+ try:
258
+ result = await admin.reset_user_password(user_id, service=service, identity=identity)
259
+ except HTTPException as exc:
260
+ if exc.status_code == status.HTTP_404_NOT_FOUND:
261
+ raise
262
+ return await _user_detail(
263
+ user_id, service, identity, error=str(exc.detail), status_code=400
264
+ )
265
+ user = await service.store.get_user(user_id)
266
+ username = user.username if user is not None else user_id
267
+ # The one-time credential is rendered ONCE for out-of-band delivery — never logged/stored.
268
+ return HTMLResponse(pages.temp_password_page(username, result.temp_password))
269
+
270
+ @app.post("/ui/users/{user_id}/reset-mfa")
271
+ async def ui_user_reset_mfa(
272
+ user_id: str,
273
+ request: Request,
274
+ service: AuthService = Depends(_service),
275
+ identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
276
+ ) -> Response:
277
+ assert_same_origin(request)
278
+ try:
279
+ await admin.reset_user_mfa(user_id, service=service, identity=identity)
280
+ except HTTPException as exc:
281
+ if exc.status_code == status.HTTP_404_NOT_FOUND:
282
+ raise
283
+ return await _user_detail(
284
+ user_id, service, identity, error=str(exc.detail), status_code=400
285
+ )
286
+ return RedirectResponse(f"/ui/users/{user_id}", status_code=303)
287
+
288
+ @app.post("/ui/users/{user_id}/revoke-sessions")
289
+ async def ui_user_revoke_sessions(
290
+ user_id: str,
291
+ request: Request,
292
+ service: AuthService = Depends(_service),
293
+ identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
294
+ ) -> Response:
295
+ assert_same_origin(request)
296
+ await admin.admin_revoke_user_sessions(user_id, service=service, identity=identity)
297
+ return RedirectResponse(f"/ui/users/{user_id}", status_code=303)
298
+
299
+ @app.post("/ui/users/{user_id}/delete")
300
+ async def ui_user_delete(
301
+ user_id: str,
302
+ request: Request,
303
+ service: AuthService = Depends(_service),
304
+ identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
305
+ ) -> Response:
306
+ assert_same_origin(request)
307
+ try:
308
+ await admin.delete_user(user_id, service=service, identity=identity)
309
+ except HTTPException as exc:
310
+ if exc.status_code == status.HTTP_404_NOT_FOUND:
311
+ raise
312
+ return await _user_detail(
313
+ user_id, service, identity, error=str(exc.detail), status_code=400
314
+ )
315
+ return RedirectResponse("/ui/users", status_code=303)
316
+
317
+ # --- roles ------------------------------------------------------------
318
+
319
+ @app.get("/ui/roles", response_class=HTMLResponse)
320
+ async def ui_roles(
321
+ service: AuthService = Depends(_service),
322
+ identity: Identity = Depends(require_ui(Permission.USERS_READ)),
323
+ ) -> HTMLResponse:
324
+ roles = await admin.list_roles(service=service, _=identity)
325
+ return HTMLResponse(pages.roles_page(roles))
326
+
327
+ @app.get("/ui/roles/new", response_class=HTMLResponse)
328
+ async def ui_role_new(
329
+ _identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
330
+ ) -> HTMLResponse:
331
+ return HTMLResponse(pages.role_form_page(_role_catalog))
332
+
333
+ @app.get("/ui/roles/{role_id}/edit", response_class=HTMLResponse)
334
+ async def ui_role_edit(
335
+ role_id: str,
336
+ service: AuthService = Depends(_service),
337
+ identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
338
+ ) -> HTMLResponse:
339
+ # Only CUSTOM roles are editable; a built-in (or unknown) id is a 404, mirroring the JSON API.
340
+ for info in await admin.list_custom_roles(service=service, _=identity):
341
+ if info.id == role_id:
342
+ return HTMLResponse(pages.role_form_page(_role_catalog, role=info))
343
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "no such custom role")
344
+
345
+ @app.post("/ui/roles/custom")
346
+ async def ui_role_create(
347
+ request: Request,
348
+ service: AuthService = Depends(_service),
349
+ identity: Identity = Depends(
350
+ require_ui_step_up(Permission.USERS_MANAGE, reauth_next=lambda _r: "/ui/roles/new")
351
+ ),
352
+ ) -> Response:
353
+ assert_same_origin(request)
354
+ pairs = await _form_pairs(request)
355
+ form = dict(pairs)
356
+ perms = [v for k, v in pairs if k == "permissions"]
357
+ try:
358
+ body = CustomRoleRequest(
359
+ display_name=form.get("display_name", "").strip(),
360
+ description=form.get("description", "").strip() or None,
361
+ permissions=perms,
362
+ )
363
+ await admin.create_custom_role(body=body, service=service, identity=identity)
364
+ except (ValidationError, HTTPException) as exc:
365
+ detail = "invalid input" if isinstance(exc, ValidationError) else str(exc.detail)
366
+ return HTMLResponse(
367
+ pages.role_form_page(
368
+ _role_catalog,
369
+ error=detail,
370
+ display_name=form.get("display_name", "").strip(),
371
+ description=form.get("description", "").strip(),
372
+ checked=perms,
373
+ ),
374
+ status_code=400,
375
+ )
376
+ return RedirectResponse("/ui/roles", status_code=303)
377
+
378
+ @app.post("/ui/roles/custom/{role_id}/update")
379
+ async def ui_role_update(
380
+ role_id: str,
381
+ request: Request,
382
+ service: AuthService = Depends(_service),
383
+ identity: Identity = Depends(
384
+ require_ui_step_up(
385
+ Permission.USERS_MANAGE,
386
+ reauth_next=lambda r: f"/ui/roles/{r.path_params['role_id']}/edit",
387
+ )
388
+ ),
389
+ ) -> Response:
390
+ assert_same_origin(request)
391
+ pairs = await _form_pairs(request)
392
+ form = dict(pairs)
393
+ perms = [v for k, v in pairs if k == "permissions"]
394
+ try:
395
+ body = CustomRoleRequest(
396
+ display_name=form.get("display_name", "").strip(),
397
+ description=form.get("description", "").strip() or None,
398
+ permissions=perms,
399
+ )
400
+ await admin.update_custom_role(role_id, body=body, service=service, identity=identity)
401
+ except (ValidationError, HTTPException) as exc:
402
+ if isinstance(exc, HTTPException) and exc.status_code == status.HTTP_404_NOT_FOUND:
403
+ raise
404
+ detail = "invalid input" if isinstance(exc, ValidationError) else str(exc.detail)
405
+ current = CustomRoleInfo(
406
+ id=role_id,
407
+ display_name=form.get("display_name", "").strip(),
408
+ description=form.get("description", "").strip() or None,
409
+ permissions=perms,
410
+ )
411
+ return HTMLResponse(
412
+ pages.role_form_page(_role_catalog, role=current, error=detail),
413
+ status_code=400,
414
+ )
415
+ return RedirectResponse("/ui/roles", status_code=303)
416
+
417
+ @app.post("/ui/roles/custom/{role_id}/delete")
418
+ async def ui_role_delete(
419
+ role_id: str,
420
+ request: Request,
421
+ service: AuthService = Depends(_service),
422
+ identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
423
+ ) -> Response:
424
+ assert_same_origin(request)
425
+ await admin.delete_custom_role(role_id, service=service, identity=identity)
426
+ return RedirectResponse("/ui/roles", status_code=303)
427
+
428
+ # --- AD group mappings --------------------------------------------------
429
+
430
+ async def _ad_groups_response(
431
+ service: AuthService,
432
+ identity: Identity,
433
+ *,
434
+ error: str | None = None,
435
+ status_code: int = 200,
436
+ ) -> HTMLResponse:
437
+ gmap = await admin.get_ad_group_map(service=service, _=identity)
438
+ smap = await admin.get_ad_group_scope_map(service=service, _=identity)
439
+ roles = await admin.list_roles(service=service, _=identity)
440
+ return HTMLResponse(
441
+ pages.ad_groups_page(gmap.entries, smap.entries, roles, error=error),
442
+ status_code=status_code,
443
+ )
444
+
445
+ @app.get("/ui/ad-groups", response_class=HTMLResponse)
446
+ async def ui_ad_groups(
447
+ service: AuthService = Depends(_service),
448
+ identity: Identity = Depends(require_ui_step_up(Permission.USERS_MANAGE)),
449
+ ) -> HTMLResponse:
450
+ return await _ad_groups_response(service, identity)
451
+
452
+ @app.post("/ui/ad-groups/map")
453
+ async def ui_ad_group_map(
454
+ request: Request,
455
+ service: AuthService = Depends(_service),
456
+ identity: Identity = Depends(
457
+ require_ui_step_up(Permission.USERS_MANAGE, reauth_next=lambda _r: "/ui/ad-groups")
458
+ ),
459
+ ) -> Response:
460
+ assert_same_origin(request)
461
+ pairs = await _form_pairs(request)
462
+ # Paired row inputs, zipped positionally (browsers submit fields in DOM order); a row with
463
+ # an empty group or unselected role is a blank filler row — dropped. The PUT-equivalent JSON
464
+ # handler replaces the whole map, so the surviving rows ARE the new map.
465
+ groups = [v.strip() for k, v in pairs if k == "ad_group"]
466
+ role_ids = [v.strip() for k, v in pairs if k == "role"]
467
+ try:
468
+ body = AdGroupMap(
469
+ entries=[
470
+ AdGroupMapEntry(ad_group=g, role=r)
471
+ for g, r in zip(groups, role_ids, strict=True)
472
+ if g and r
473
+ ]
474
+ )
475
+ await admin.set_ad_group_map(body=body, service=service, identity=identity)
476
+ except (ValidationError, ValueError, HTTPException) as exc:
477
+ detail = str(exc.detail) if isinstance(exc, HTTPException) else "invalid input"
478
+ return await _ad_groups_response(service, identity, error=detail, status_code=400)
479
+ return RedirectResponse("/ui/ad-groups", status_code=303)
480
+
481
+ @app.post("/ui/ad-groups/scope-map")
482
+ async def ui_ad_group_scope_map(
483
+ request: Request,
484
+ service: AuthService = Depends(_service),
485
+ identity: Identity = Depends(
486
+ require_ui_step_up(Permission.USERS_MANAGE, reauth_next=lambda _r: "/ui/ad-groups")
487
+ ),
488
+ ) -> Response:
489
+ assert_same_origin(request)
490
+ pairs = await _form_pairs(request)
491
+ groups = [v.strip() for k, v in pairs if k == "ad_group"]
492
+ channels = [v.strip() for k, v in pairs if k == "channel"]
493
+ try:
494
+ body = AdGroupScopeMap(
495
+ entries=[
496
+ AdGroupScopeEntry(ad_group=g, channel=c)
497
+ for g, c in zip(groups, channels, strict=True)
498
+ if g and c
499
+ ]
500
+ )
501
+ await admin.set_ad_group_scope_map(body=body, service=service, identity=identity)
502
+ except (ValidationError, ValueError, HTTPException) as exc:
503
+ detail = str(exc.detail) if isinstance(exc, HTTPException) else "invalid input"
504
+ return await _ad_groups_response(service, identity, error=detail, status_code=400)
505
+ return RedirectResponse("/ui/ad-groups", status_code=303)
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """L1c: read-only audit trail + self-service security events."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from fastapi import Depends, FastAPI
8
+ from fastapi.responses import HTMLResponse
9
+
10
+ from messagefoundry.api._ui_seam import UiDeps
11
+ from messagefoundry.auth import Identity, Permission
12
+ from messagefoundry.auth.service import AuthService
13
+
14
+ from .. import pages
15
+ from .._auth import (
16
+ require_ui,
17
+ )
18
+ from .._service import _service
19
+
20
+
21
+ def register(app: FastAPI, deps: UiDeps) -> None:
22
+ """L1c: read-only audit trail + self-service security events."""
23
+ admin = deps.admin
24
+
25
+ @app.get("/ui/audit", response_class=HTMLResponse)
26
+ async def ui_audit(
27
+ service: AuthService = Depends(_service),
28
+ identity: Identity = Depends(require_ui(Permission.AUDIT_READ)),
29
+ ) -> HTMLResponse:
30
+ data = await admin.list_audit(service=service, _=identity, limit=200)
31
+ return HTMLResponse(pages.audit_log(data))
32
+
33
+ @app.get("/ui/security-events", response_class=HTMLResponse)
34
+ async def ui_security_events(
35
+ service: AuthService = Depends(_service),
36
+ identity: Identity = Depends(require_ui()),
37
+ ) -> HTMLResponse:
38
+ data = await admin.my_security_events(service=service, identity=identity, limit=200)
39
+ return HTMLResponse(pages.security_events(data))
@@ -0,0 +1,67 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """L3c: config-deploy — reload the engine's already-configured graph from its own startup dir."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from fastapi import Depends, FastAPI, Request, Response
10
+ from fastapi.responses import HTMLResponse
11
+
12
+ from messagefoundry.api._ui_seam import UiDeps
13
+ from messagefoundry.api.models import (
14
+ PendingApprovalResponse,
15
+ ReloadRequest,
16
+ )
17
+ from messagefoundry.auth import Identity, Permission
18
+
19
+ from .. import pages
20
+ from .._auth import (
21
+ assert_same_origin,
22
+ register_ui_action,
23
+ require_ui,
24
+ require_ui_step_up,
25
+ )
26
+
27
+ # L3c: config reload is step-up-gated, so register it in the write-action allow-list (body-less
28
+ # POST, no path params — the /ui/reauth flow may auto-retry it after step-up).
29
+ register_ui_action(r"^/ui/config/reload$", Permission.CONFIG_DEPLOY)
30
+
31
+
32
+ def register(app: FastAPI, deps: UiDeps) -> None:
33
+ """L3c: config-deploy — reload the engine's ALREADY-CONFIGURED graph from its own startup dir.
34
+ STRICTLY within #26: no module editor, no filesystem picker, no dry-run diff. The reload is
35
+ step-up-gated (CONFIG_DEPLOY) + dual-control; it passes a fixed ReloadRequest() so config_dir
36
+ is ALWAYS the server startup dir (never user input) and dry_run is False."""
37
+ core = deps.core
38
+
39
+ @app.get("/ui/config", response_class=HTMLResponse)
40
+ async def ui_config(
41
+ engine: Any = Depends(deps.get_engine),
42
+ identity: Identity = Depends(require_ui(Permission.MONITORING_READ)),
43
+ ) -> HTMLResponse:
44
+ prov = await core.config_provenance(engine=engine, _user=identity)
45
+ return HTMLResponse(pages.config_page(prov))
46
+
47
+ @app.post("/ui/config/reload")
48
+ async def ui_config_reload(
49
+ request: Request,
50
+ engine: Any = Depends(deps.get_engine),
51
+ identity: Identity = Depends(require_ui_step_up(Permission.CONFIG_DEPLOY)),
52
+ gate: Any = Depends(deps.get_gate),
53
+ ) -> HTMLResponse:
54
+ assert_same_origin(request)
55
+ # Bright line: a FIXED request — config_dir=None (the server's startup dir) + dry_run=False.
56
+ # The /ui never lets the browser choose a path (the loader executes Python).
57
+ result = await core.reload_config(
58
+ ReloadRequest(config_dir=None, dry_run=False),
59
+ Response(),
60
+ engine=engine,
61
+ user=identity,
62
+ gate=gate,
63
+ request=request,
64
+ )
65
+ if isinstance(result, PendingApprovalResponse):
66
+ return HTMLResponse(pages.reload_pending(result))
67
+ return HTMLResponse(pages.reload_result(result))