agami-core 0.3.2__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.
admin.py ADDED
@@ -0,0 +1,1343 @@
1
+ """The admin web surface — onboard/enable/disable/list users, plus the friendly browser landings.
2
+
3
+ Two auth surfaces live in this server: the MCP bearer JWT (claude.ai) and — here — a browser
4
+ **session cookie** for the human admin. `/admin/*` is session-gated (the admin-gate = the
5
+ env-configured `AGAMI_ADMIN_USERNAME`); a non-admin, even with valid credentials, can't get in. This
6
+ module also renders the friendly landings a human sees if they point a browser at the server.
7
+
8
+ The page builders (`*_html`) are split from the request handlers so previews can render them with
9
+ sample values. Every interpolated value goes through `ui.esc` (these pages show emails/names).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import os
17
+ from datetime import datetime, timedelta, timezone
18
+ from typing import Any
19
+ from urllib.parse import quote
20
+
21
+ import jwt
22
+ import ui
23
+ import user_store
24
+
25
+ # Reuse the OAuth provider's shared HS256 secret accessor + store opener + the admin OIDC-start handler
26
+ # so the admin surface signs with the same key, reads the same datastore, and runs the same hardened
27
+ # OIDC flow (no second source of truth).
28
+ from oauth_server import _open_store, _signing_secret, admin_oidc_start
29
+ from starlette.requests import Request
30
+ from starlette.responses import HTMLResponse, RedirectResponse, Response
31
+
32
+
33
+ def _full_name(user: dict[str, Any]) -> str:
34
+ """A user's display name from first/last; falls back to the email's local part when unnamed."""
35
+ name = " ".join(p for p in (user.get("first_name"), user.get("last_name")) if p).strip()
36
+ return name or (user.get("email") or "").split("@")[0]
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Auth pages
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def admin_login_body_html(error: str = "", provider: str | None = None) -> str:
45
+ """The admin sign-in page: the admin's pinned social provider (when configured) above the password
46
+ form — matching the MCP login's options. Only the admin's *one* pinned provider is offered, since
47
+ that's the only one that can resolve to the admin (a second button would just say "not an admin").
48
+ No banner copy — the logo and the form speak for themselves (this is the admin's own login)."""
49
+ button = (
50
+ ui.provider_button(provider, f"/admin/oidc/start?provider={provider}") if provider else ""
51
+ )
52
+ social = f'<div class="providers">{button}</div><div class="divider">or</div>' if button else ""
53
+ alert = f'<div class="alert error">{ui.esc(error)}</div>' if error else ""
54
+ body = f"""{alert}{social}
55
+ <form method="post">
56
+ <label for="u">Email</label>
57
+ <input id="u" name="username" type="email" autocomplete="email" placeholder="you@example.com">
58
+ <label for="p">Password</label>
59
+ <input id="p" name="password" type="password" autocomplete="current-password" placeholder="••••••••">
60
+ <button class="btn" type="submit" style="margin-top:22px">Sign in</button>
61
+ </form>"""
62
+ return ui.auth_page("Admin sign in", body)
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Admin console — Users tab (+ Dashboard / Activity placeholders)
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def _status_pill(status: str) -> str:
71
+ cls = "active" if status == "active" else "disabled"
72
+ return f'<span class="pill {cls}">{ui.esc(status)}</span>'
73
+
74
+
75
+ def _row_action(user: dict[str, Any], csrf: str, admin_username: str) -> str:
76
+ # The admin can't disable their own account (it would lock themselves out). Keyed on username —
77
+ # the stable identity column the status update writes against (email is display-only).
78
+ if user.get("username") == admin_username:
79
+ return '<span class="muted">—</span>'
80
+ active = user["status"] == "active"
81
+ target, label, cls = (
82
+ ("disabled", "Disable", "danger") if active else ("active", "Enable", "secondary")
83
+ )
84
+ return (
85
+ '<form method="post" action="/admin/users/status" style="display:inline">'
86
+ f'<input type="hidden" name="csrf" value="{ui.esc(csrf)}">'
87
+ f'<input type="hidden" name="username" value="{ui.esc(user.get("username"))}">'
88
+ f'<input type="hidden" name="status" value="{target}">'
89
+ f'<button class="btn tiny {cls}" type="submit">{label}</button></form>'
90
+ )
91
+
92
+
93
+ def _add_user_drawer(csrf: str) -> str:
94
+ """A CSS-only right-side drawer (no JS) with a minimal 'Add user' form.
95
+
96
+ Just email + first/last name: the teammate picks their own sign-in method (Google, Microsoft, or
97
+ a password they set themselves) the first time they sign in — the admin never sets a password."""
98
+ return f"""<input type="checkbox" id="add-user" class="drawer-toggle">
99
+ <div class="drawer-wrap">
100
+ <label for="add-user" class="drawer-backdrop"></label>
101
+ <aside class="drawer">
102
+ <div class="drawer-head"><h1 style="font-size:17px">Add user</h1>
103
+ <label for="add-user" class="drawer-x" aria-label="Close">&times;</label></div>
104
+ <p class="sub" style="margin-bottom:8px">They'll sign in with this email — through Google, Microsoft,
105
+ or a password they set the first time — and can then use this agami server from Claude.</p>
106
+ <form method="post" action="/admin/users">
107
+ <input type="hidden" name="csrf" value="{ui.esc(csrf)}">
108
+ <label for="d-email">Email</label>
109
+ <input id="d-email" name="email" type="email" placeholder="you@example.com">
110
+ <label for="d-first">First name</label>
111
+ <input id="d-first" name="first_name" type="text" placeholder="Jordan">
112
+ <label for="d-last">Last name</label>
113
+ <input id="d-last" name="last_name" type="text" placeholder="Lee">
114
+ <button class="btn" type="submit" style="margin-top:22px">Add user</button>
115
+ </form>
116
+ </aside>
117
+ </div>"""
118
+
119
+
120
+ def _signin_cell(user: dict[str, Any], setup_links: dict[str, str]) -> str:
121
+ """The Sign-in column: the user's method, plus — for a *pending* user in a password deployment —
122
+ a copy-able setup link the admin shares out-of-band (the page is session-gated, admin-only)."""
123
+ sign_in = user.get("oidc_provider") or (
124
+ "password" if user.get("has_password") else "not set yet"
125
+ )
126
+ link = setup_links.get(user.get("username", ""))
127
+ extra = (
128
+ f'<details class="setup"><summary>Setup link</summary>'
129
+ f'<input class="code" readonly value="{ui.esc(link)}" style="width:100%;margin-top:6px">'
130
+ f"</details>"
131
+ if link
132
+ else ""
133
+ )
134
+ return f'<td class="muted">{ui.esc(sign_in)}{extra}</td>'
135
+
136
+
137
+ def users_tab_html(
138
+ users: list[dict[str, Any]],
139
+ csrf: str,
140
+ *,
141
+ admin_username: str = "",
142
+ admin_email: str = "",
143
+ admin_label: str = "",
144
+ setup_links: dict[str, str] | None = None,
145
+ error: str = "",
146
+ ok: str = "",
147
+ ) -> str:
148
+ """The Users tab: a roster table + an 'Add user' button that opens the drawer. `setup_links`
149
+ (username → URL) attaches a copy-able setup link to each pending row (password deployments)."""
150
+ setup_links = setup_links or {}
151
+ rows = ""
152
+ for u in users:
153
+ rows += (
154
+ "<tr>"
155
+ f"<td><strong>{ui.esc(_full_name(u))}</strong></td>"
156
+ f'<td class="muted">{ui.esc(u.get("email") or "—")}</td>'
157
+ f"{_signin_cell(u, setup_links)}"
158
+ f"<td>{_status_pill(u['status'])}</td>"
159
+ f'<td style="text-align:right">{_row_action(u, csrf, admin_username)}</td>'
160
+ "</tr>"
161
+ )
162
+ alerts = (f'<div class="alert ok">{ui.esc(ok)}</div>' if ok else "") + (
163
+ f'<div class="alert error">{ui.esc(error)}</div>' if error else ""
164
+ )
165
+ panel = f"""{alerts}
166
+ <div class="row" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
167
+ <p class="muted" style="margin:0">People who can use this agami server.</p>
168
+ <label for="add-user" class="btn tiny">+ Add user</label>
169
+ </div>
170
+ <div class="table-wrap"><table>
171
+ <thead><tr><th>Name</th><th>Email</th><th>Sign-in</th><th>Status</th><th></th></tr></thead>
172
+ <tbody>{rows}</tbody>
173
+ </table></div>"""
174
+ return ui.admin_shell(
175
+ "Users · agami admin",
176
+ "users",
177
+ panel,
178
+ admin_label=admin_label or admin_username,
179
+ admin_email=admin_email,
180
+ extra=_add_user_drawer(csrf),
181
+ )
182
+
183
+
184
+ def _coming_soon(tab: str, label: str, *, admin_label: str = "", admin_email: str = "") -> str:
185
+ panel = f'<div class="empty"><strong>{ui.esc(label)}</strong><br>Coming soon.</div>'
186
+ return ui.admin_shell(
187
+ f"{label} · agami admin", tab, panel, admin_label=admin_label, admin_email=admin_email
188
+ )
189
+
190
+
191
+ def dashboard_tab_html(*, admin_label: str = "", admin_email: str = "") -> str:
192
+ return _coming_soon("dashboard", "Dashboard", admin_label=admin_label, admin_email=admin_email)
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Activity view — every tool call, folded into its conversation (thread ▸ turn ▸ call)
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ def _ok_pill(success: Any) -> str:
201
+ ok = bool(success)
202
+ return f'<span class="pill {"active" if ok else "disabled"}">{"ok" if ok else "error"}</span>'
203
+
204
+
205
+ def _utc(ts: str | None) -> str:
206
+ """A <time> the browser-local script (ui._doc) renders in the viewer's zone; the UTC value is the
207
+ fallback text for no-JS."""
208
+ return f'<time data-utc="{ui.esc(ts)}">{ui.esc(ts)}</time>'
209
+
210
+
211
+ def _call_card(c: dict[str, Any]) -> str:
212
+ """One call inside a turn. A **query** call (has `sql`) shows its agent-framing + SQL; a **non-query**
213
+ call (list_datasources, get_datasource_schema, …) has no SQL, so it shows its tool name — so every call
214
+ in the conversation is visible, not just the queries. Every card's meta line carries the call's OWN
215
+ datasource (a turn can span datasources — a cross-datasource question runs one execute_sql each — so
216
+ per-call attribution matters). SQL/framing are self-reported + attacker-influenceable → escaped; rows
217
+ shown only when the call recorded a count."""
218
+ if c.get("sql"):
219
+ head = (
220
+ f'<div class="muted" style="margin:0 0 6px">↳ {ui.esc(c["agent_query"])} '
221
+ f'<span class="muted">· agent-reported</span></div>'
222
+ if c.get("agent_query")
223
+ else ""
224
+ )
225
+ body = (
226
+ f'<pre class="code" style="white-space:pre-wrap;padding:12px;display:block;margin-top:4px">'
227
+ f"{ui.esc(c['sql'])}</pre>"
228
+ )
229
+ else:
230
+ head = (
231
+ '<div style="margin:0 0 6px">'
232
+ f'<span class="pill" style="background:var(--chip);color:var(--ink)">{ui.esc(c["tool_name"])}</span>'
233
+ "</div>"
234
+ )
235
+ body = ""
236
+ ds = ui.esc(c.get("datasource") or "—")
237
+ lat = (str(c["execution_ms"]) + " ms") if c.get("execution_ms") is not None else ""
238
+ rows_bit = f' · {c["row_count"]} rows' if c.get("row_count") is not None else ""
239
+ return (
240
+ '<div style="border-top:1px solid var(--line);padding:9px 0 11px">'
241
+ f"{head}{body}"
242
+ f'<div class="muted" style="font-size:13px;margin-top:6px">{_utc(c["ts"])} · {ds} · '
243
+ f"{lat} {_ok_pill(c['success'])}{rows_bit}</div></div>"
244
+ )
245
+
246
+
247
+ def _session_drawer(s: dict[str, Any], idx: int) -> str:
248
+ sid = f"sess-{idx}" # DOM id is the row index, never the (self-reported, attacker-influenceable) key
249
+ # Render the conversation as **turns** (one user question -> the N calls answering it), grouped on
250
+ # correlation_id by list_sessions. The turn header shows the verbatim question once; each call (a
251
+ # query's SQL, or a non-query tool's name) lists beneath it. Degrades cleanly: a call with no
252
+ # correlation_id is its own one-call turn, so this reads like a flat list when Claude doesn't self-report.
253
+ cards = ""
254
+ for t in s["turns"]:
255
+ question = t.get("question") or "(no question reported)"
256
+ n = len(t["calls"])
257
+ call_cards = "".join(_call_card(c) for c in t["calls"])
258
+ # The question is Claude-self-reported (best-effort, attacker-influenceable) — mark it so, like
259
+ # the rest of the activity log; "User asked" is the framing, "· self-reported" the provenance.
260
+ asked = (
261
+ f'<span class="muted">User asked</span> <strong>{ui.esc(question)}</strong> '
262
+ '<span class="muted" style="font-size:13px">· self-reported</span>'
263
+ if t.get("question")
264
+ else f'<strong class="muted">{ui.esc(question)}</strong>'
265
+ )
266
+ cards += (
267
+ '<div style="border-top:2px solid var(--line);padding:14px 0 2px;margin-top:8px">'
268
+ f'<div style="margin-bottom:4px">{asked} '
269
+ f'<span class="muted" style="font-size:13px">· {n} {"call" if n == 1 else "calls"}</span>'
270
+ f"</div>{call_cards}</div>"
271
+ )
272
+ return f"""<input type="checkbox" id="{sid}" class="drawer-toggle">
273
+ <div class="drawer-wrap"><label for="{sid}" class="drawer-backdrop"></label>
274
+ <aside class="drawer" style="width:560px">
275
+ <div class="drawer-head"><h1 style="font-size:17px">Conversation</h1>
276
+ <label for="{sid}" class="drawer-x" aria-label="Close">&times;</label></div>
277
+ <p class="sub" style="margin-bottom:8px">{ui.esc(s.get("actor") or "—")} · {ui.esc(", ".join(s["datasources"]) or "—")} · {s["call_count"]} calls · started {_utc(s["started"])}</p>
278
+ {cards}</aside></div>"""
279
+
280
+
281
+ def activity_tab_html(
282
+ sessions: list[dict[str, Any]] | None = None, *, admin_label: str = "", admin_email: str = ""
283
+ ) -> str:
284
+ """The Activity tab: **every** tool call grouped into conversations (best-effort via the self-reported
285
+ `thread_id`; ungrouped singletons otherwise — so it stays audit-complete). Each row opens to the
286
+ conversation's turns, and within them every call (query or not)."""
287
+ sessions = sessions or []
288
+ body_rows, drawers = "", ""
289
+ for i, s in enumerate(sessions):
290
+ body_rows += (
291
+ "<tr>"
292
+ f'<td><label for="sess-{i}" style="cursor:pointer;color:var(--brand)">{_utc(s["started"])}</label></td>'
293
+ f"<td><strong>{ui.esc(s.get('actor') or '—')}</strong></td>"
294
+ f'<td class="muted">{ui.esc(", ".join(s["datasources"]) or "—")}</td>'
295
+ f'<td class="muted">{s["call_count"]}</td>'
296
+ f'<td class="muted">{s["error_count"] or "—"}</td>'
297
+ f'<td class="muted">{(str(s["avg_ms"]) + " ms") if s.get("avg_ms") is not None else "—"}</td>'
298
+ f"<td>{_utc(s['last_activity'])}</td>"
299
+ f'<td style="text-align:right"><label for="sess-{i}" class="btn tiny secondary">Open</label></td>'
300
+ "</tr>"
301
+ )
302
+ drawers += _session_drawer(s, i)
303
+ empty = '<p class="muted">No activity yet.</p>' if not sessions else ""
304
+ panel = f"""{empty}
305
+ <div class="table-wrap"><table>
306
+ <thead><tr><th>Started</th><th>User</th><th>Datasources</th><th>Calls</th><th>Errors</th><th>Avg time</th><th>Last activity</th><th></th></tr></thead>
307
+ <tbody>{body_rows}</tbody></table></div>"""
308
+ return ui.admin_shell(
309
+ "Activity · agami admin",
310
+ "activity",
311
+ panel,
312
+ admin_label=admin_label,
313
+ admin_email=admin_email,
314
+ extra=drawers,
315
+ )
316
+
317
+
318
+ # ---------------------------------------------------------------------------
319
+ # Friendly browser landings
320
+ # ---------------------------------------------------------------------------
321
+
322
+
323
+ def _connect_block(base_url: str) -> str:
324
+ return (
325
+ '<p class="small" style="margin-bottom:6px">Add this server to Claude as a custom connector:</p>'
326
+ f'<p><span class="code">{ui.esc(base_url)}/mcp</span></p>'
327
+ )
328
+
329
+
330
+ def landing_body_html(base_url: str) -> str:
331
+ """The root page a human lands on if they open the server URL in a browser."""
332
+ body = f"""<div class="consent"><p class="who">agami</p>
333
+ <p class="small">A governed, self-hosted data agent for Claude.</p></div>
334
+ {_connect_block(base_url)}
335
+ <p class="foot"><a href="/admin">Admin sign in →</a></p>"""
336
+ return ui.auth_page("agami", body)
337
+
338
+
339
+ def not_admin_body_html(base_url: str) -> str:
340
+ """Shown when a valid but non-admin user signs in at /admin/login."""
341
+ body = f"""<div class="consent"><p class="who">You're signed in</p>
342
+ <p class="small">This account isn't an administrator.</p></div>
343
+ {_connect_block(base_url)}
344
+ <p class="foot muted">Only the administrator can manage users here.</p>"""
345
+ return ui.auth_page("Signed in", body)
346
+
347
+
348
+ def not_authorized_body_html(email: str) -> str:
349
+ """The branded "your identity is real but no admin has added you" page for an un-onboarded
350
+ Google/Microsoft sign-in. Not yet wired into the OIDC rejection (which still returns a JSON OAuth
351
+ error); rendered in previews and ready for the self-onboarding flow to adopt. No connector hint —
352
+ they can't use it yet."""
353
+ body = f"""<div class="consent"><p class="who">Not set up yet</p>
354
+ <p class="small">{ui.esc(email)} isn't authorized for this agami server.</p></div>
355
+ <p class="foot muted">Ask the administrator to add you, then sign in again.</p>"""
356
+ return ui.auth_page("Not authorized", body)
357
+
358
+
359
+ def mcp_landing_body_html(base_url: str) -> str:
360
+ """The branded body returned when a *browser* hits /mcp (a machine endpoint) unauthenticated."""
361
+ body = f"""<div class="consent"><p class="who">This is an MCP endpoint</p>
362
+ <p class="small">It's meant for Claude, not a browser.</p></div>
363
+ {_connect_block(base_url)}
364
+ <p class="foot"><a href="/admin">Admin sign in →</a></p>"""
365
+ return ui.auth_page("agami · MCP endpoint", body)
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Session auth — a browser session cookie (separate from the MCP bearer JWT)
370
+ # ---------------------------------------------------------------------------
371
+ #
372
+ # `/admin/*` is gated by a signed session cookie, NOT the MCP bearer token. The two share the HS256
373
+ # signing secret + algorithm, so the `purpose` claim keeps them from being interchangeable: a token
374
+ # minted for the query surface (`issue_jwt`, which carries no `purpose`) must never satisfy the admin
375
+ # gate — even for the admin's own user. The gate also requires `sub == AGAMI_ADMIN_USERNAME`, so a
376
+ # valid non-admin can never hold an admin session.
377
+
378
+ _SESSION_COOKIE = "agami_admin_session"
379
+ _SESSION_TTL = timedelta(hours=12)
380
+ _SESSION_PURPOSE = "admin_session"
381
+
382
+
383
+ def _admin_username() -> str | None:
384
+ """The single admin's username = the **normalized** admin email (the admin-gate). Lowercased+trimmed
385
+ to match how the seed stores it + how OIDC resolves it, so the gate is case-insensitive. Unset ⇒ the
386
+ admin UI is disabled entirely."""
387
+ name = os.environ.get("AGAMI_ADMIN_USERNAME", "").strip().lower()
388
+ return name or None
389
+
390
+
391
+ def _admin_provider() -> str | None:
392
+ """The admin's pinned OIDC provider (`AGAMI_ADMIN_PROVIDER`), or None — only when it's a provider
393
+ that's actually configured (client id/secret present)."""
394
+ key = os.environ.get("AGAMI_ADMIN_PROVIDER", "").strip().lower()
395
+ if not key:
396
+ return None
397
+ import oidc # lazy: the egress module, server-only
398
+
399
+ return key if key in oidc.available_providers() else None
400
+
401
+
402
+ def _admin_login_provider() -> str | None:
403
+ """The provider button to render on the admin login: the pinned, configured provider, but ONLY
404
+ when the admin's stored row is actually bound to it. This avoids a dead button — e.g. if
405
+ `AGAMI_ADMIN_PROVIDER` is set after the admin was seeded password-only (the seed is idempotent and
406
+ won't backfill `oidc_provider`), the button would otherwise show but dead-end at "not an admin"."""
407
+ provider = _admin_provider()
408
+ admin = _admin_username()
409
+ if provider is None or admin is None:
410
+ return None
411
+ store = _open_store()
412
+ if store is None:
413
+ return None
414
+ try:
415
+ row = user_store.get_user(store, admin)
416
+ finally:
417
+ store.close()
418
+ return provider if row is not None and row.get("oidc_provider") == provider else None
419
+
420
+
421
+ def issue_session(username: str) -> str:
422
+ """A short-TTL HS256 session JWT (sub=username, purpose=admin_session) for the cookie."""
423
+ now = datetime.now(timezone.utc)
424
+ return jwt.encode(
425
+ {
426
+ "sub": username,
427
+ "purpose": _SESSION_PURPOSE,
428
+ "iat": int(now.timestamp()),
429
+ "exp": int((now + _SESSION_TTL).timestamp()),
430
+ },
431
+ _signing_secret(),
432
+ algorithm="HS256",
433
+ )
434
+
435
+
436
+ def current_admin(request: Request) -> str | None:
437
+ """The signed-in admin's username, or None: verify the cookie (sig + exp + purpose) AND that its
438
+ subject is THE configured admin. Any failure → None (fail closed → redirect to login)."""
439
+ admin = _admin_username()
440
+ token = request.cookies.get(_SESSION_COOKIE)
441
+ if admin is None or not token:
442
+ return None
443
+ try:
444
+ claims = jwt.decode(
445
+ token, _signing_secret(), algorithms=["HS256"], options={"require": ["exp", "sub"]}
446
+ )
447
+ except Exception:
448
+ return None
449
+ if claims.get("purpose") != _SESSION_PURPOSE:
450
+ return None
451
+ sub = claims.get("sub")
452
+ if not isinstance(sub, str) or sub != admin:
453
+ return None
454
+ return sub
455
+
456
+
457
+ def _set_session(resp: Response, token: str) -> None:
458
+ # HttpOnly (no JS read) + Secure (HTTPS only — deployments are HTTPS) + SameSite=Lax (not sent on
459
+ # cross-site POSTs); scoped to /admin so it never rides along to /mcp or /static.
460
+ resp.set_cookie(
461
+ _SESSION_COOKIE,
462
+ token,
463
+ max_age=int(_SESSION_TTL.total_seconds()),
464
+ httponly=True,
465
+ secure=True,
466
+ samesite="lax",
467
+ path="/admin",
468
+ )
469
+
470
+
471
+ def _clear_session(resp: Response) -> None:
472
+ resp.delete_cookie(_SESSION_COOKIE, path="/admin")
473
+
474
+
475
+ # CSRF: a token derived from the session cookie via HMAC(secret, cookie). An attacker who can neither
476
+ # read the HttpOnly cookie nor know the server secret can't forge it — defense-in-depth over SameSite.
477
+ def _csrf_for(session_token: str) -> str:
478
+ return hmac.new(_signing_secret().encode(), session_token.encode(), hashlib.sha256).hexdigest()
479
+
480
+
481
+ def _origin_ok(request: Request) -> bool:
482
+ """A second, independent CSRF gate: if the browser sent an Origin (or Referer) on this POST, its
483
+ scheme+host MUST match our own. Browsers always attach Origin to cross-site POSTs, so a forged
484
+ request from another site is caught here even before the token check. When neither header is
485
+ present (some same-origin form posts, test clients) we don't fail — the signed CSRF token carries
486
+ the load — so this only ever *adds* protection."""
487
+ from urllib.parse import urlsplit
488
+
489
+ origin = request.headers.get("origin") or request.headers.get("referer")
490
+ if not origin:
491
+ return True
492
+ want = urlsplit(_base_url())
493
+ got = urlsplit(origin)
494
+ return (got.scheme, got.hostname, got.port) == (want.scheme, want.hostname, want.port)
495
+
496
+
497
+ def _csrf_ok(request: Request, presented: str) -> bool:
498
+ token = request.cookies.get(_SESSION_COOKIE)
499
+ if not token or not presented:
500
+ return False
501
+ if not _origin_ok(request):
502
+ return False
503
+ return hmac.compare_digest(_csrf_for(token), presented)
504
+
505
+
506
+ # ---------------------------------------------------------------------------
507
+ # Request handlers
508
+ # ---------------------------------------------------------------------------
509
+
510
+ # Flash text is server-owned (keyed by a short code in the redirect query) — never echoed user input,
511
+ # so a redirect can't be turned into a reflected-content vector.
512
+ _OK_FLASH = {"added": "User added.", "enabled": "User enabled.", "disabled": "User disabled."}
513
+ _ERR_FLASH = {
514
+ "dup": "A user with that email already exists.",
515
+ "bad_email": "Enter a valid email address.",
516
+ "csrf": "Your session expired — please try again.",
517
+ "self": "You can't change your own status.",
518
+ "bad": "That action isn't allowed.",
519
+ "notfound": "No such user.",
520
+ }
521
+
522
+
523
+ async def _form(request: Request) -> dict[str, str]:
524
+ data = await request.form()
525
+ return {k: (v if isinstance(v, str) else "") for k, v in data.items()}
526
+
527
+
528
+ def _base_url() -> str:
529
+ from mcp_http import public_base_url
530
+
531
+ return public_base_url()
532
+
533
+
534
+ def _is_integrity_error(exc: Exception) -> bool:
535
+ # Portable across backends (sqlite3.IntegrityError / psycopg2 UniqueViolation) without importing
536
+ # the driver here — a UNIQUE collision is "duplicate", any other error must surface.
537
+ return any(cls.__name__ == "IntegrityError" for cls in type(exc).__mro__)
538
+
539
+
540
+ def _admin_chrome(store: Any, admin_username: str) -> dict[str, str]:
541
+ """Avatar label + email for the top-bar account menu (from the admin's own user row)."""
542
+ row = user_store.get_user(store, admin_username) if store is not None else None
543
+ return {
544
+ "admin_label": _full_name(row) if row else admin_username,
545
+ "admin_email": (row or {}).get("email") or "",
546
+ }
547
+
548
+
549
+ def complete_admin_oidc_login(username: str | None) -> Response:
550
+ """Finish an admin OIDC login (called by the shared OIDC callback for an `admin_login` state): a
551
+ verified identity that resolves to THE configured admin gets an admin session; anyone else — an
552
+ unresolved identity, or a valid but non-admin user — is refused with the branded page, no session.
553
+ The provider-pin + subject binding were already enforced upstream by `_resolve_oidc_user`."""
554
+ if username is not None and username == _admin_username():
555
+ resp: Response = RedirectResponse("/admin", status_code=302)
556
+ _set_session(resp, issue_session(username))
557
+ return resp
558
+ return HTMLResponse(not_admin_body_html(_base_url()), status_code=403)
559
+
560
+
561
+ async def admin_login(request: Request) -> Response:
562
+ """GET → the admin sign-in page; POST → authenticate, gate on the admin-username, mint a session."""
563
+ if request.method == "GET":
564
+ if current_admin(request) is not None:
565
+ return RedirectResponse("/admin", status_code=302)
566
+ return HTMLResponse(admin_login_body_html(provider=_admin_login_provider()))
567
+
568
+ form = await _form(request)
569
+ # Email is the identity: normalize the typed address (trim + lowercase) so login is
570
+ # case-insensitive and matches the normalized username the seed stored.
571
+ typed = form.get("username", "").strip().lower()
572
+ store = _open_store()
573
+ try:
574
+ principal = (
575
+ user_store.authenticate(store, typed, form.get("password", ""))
576
+ if store is not None
577
+ else None
578
+ )
579
+ finally:
580
+ if store is not None:
581
+ store.close()
582
+ if principal is None:
583
+ # Same generic message for wrong password, unknown user, or disabled — no enumeration oracle.
584
+ # Keep the social button on the re-render so a failed password attempt doesn't hide it.
585
+ return HTMLResponse(
586
+ admin_login_body_html(
587
+ error="Invalid email or password.", provider=_admin_login_provider()
588
+ ),
589
+ status_code=401,
590
+ )
591
+ if principal.subject != _admin_username():
592
+ # Valid credentials, but not THE admin: no session minted; a friendly "use via Claude" page.
593
+ return HTMLResponse(not_admin_body_html(_base_url()), status_code=403)
594
+ resp = RedirectResponse("/admin", status_code=302)
595
+ _set_session(resp, issue_session(principal.subject))
596
+ return resp
597
+
598
+
599
+ async def admin_logout(request: Request) -> Response:
600
+ resp = RedirectResponse("/admin/login", status_code=302)
601
+ _clear_session(resp)
602
+ return resp
603
+
604
+
605
+ async def admin_home(request: Request) -> Response:
606
+ """The console. `?tab=` picks Dashboard / Users (default) / Activity. Session-gated."""
607
+ import model_store
608
+
609
+ admin = current_admin(request)
610
+ if admin is None:
611
+ return RedirectResponse("/admin/login", status_code=302)
612
+ store = _open_store()
613
+ try:
614
+ chrome = _admin_chrome(store, admin)
615
+ tab = request.query_params.get("tab", "users")
616
+ if tab == "dashboard":
617
+ return HTMLResponse(dashboard_tab_html(**chrome))
618
+ if tab == "activity":
619
+ sessions = model_store.list_sessions(store) if store is not None else []
620
+ return HTMLResponse(activity_tab_html(sessions, **chrome))
621
+ users = user_store.list_users(store) if store is not None else []
622
+ finally:
623
+ if store is not None:
624
+ store.close()
625
+ # current_admin already proved the cookie is present + valid; .get (not bracket) keeps a malformed
626
+ # duplicate-cookie header from turning into an unhandled 500.
627
+ csrf = _csrf_for(request.cookies.get(_SESSION_COOKIE, ""))
628
+ ok = _OK_FLASH.get(request.query_params.get("ok", ""), "")
629
+ err = _ERR_FLASH.get(request.query_params.get("err", ""), "")
630
+ return HTMLResponse(
631
+ users_tab_html(
632
+ users,
633
+ csrf,
634
+ admin_username=admin,
635
+ setup_links=_setup_links(users),
636
+ ok=ok,
637
+ error=err,
638
+ **chrome,
639
+ )
640
+ )
641
+
642
+
643
+ def _setup_links(users: list[dict[str, Any]]) -> dict[str, str]:
644
+ """Per-pending-user setup links — but only in a **password** deployment (no OIDC configured). When
645
+ an OIDC provider is configured, teammates onboard by signing in with it, so no link is offered."""
646
+ import oidc # lazy: the egress module, server-only
647
+
648
+ if oidc.available_providers():
649
+ return {}
650
+ import onboarding
651
+
652
+ base = _base_url()
653
+ return {
654
+ u["username"]: f"{base}/claim?token={onboarding.mint_setup_token(u['username'])}"
655
+ for u in users
656
+ if onboarding.is_pending(u)
657
+ }
658
+
659
+
660
+ def _valid_email(email: str) -> bool:
661
+ # A deliberately loose check — we're not validating deliverability, just rejecting obvious junk
662
+ # before it becomes a username. Real verification happens when the user first signs in (a later
663
+ # self-onboarding step).
664
+ email = email.strip()
665
+ return "@" in email and "." in email.rsplit("@", 1)[-1] and " " not in email
666
+
667
+
668
+ async def admin_create_user(request: Request) -> Response:
669
+ """Onboard a teammate: a *pending* user (no password, no provider) keyed by their email. They pick
670
+ their sign-in method on first login (a later self-onboarding step). Admin-gated + CSRF-checked."""
671
+ admin = current_admin(request)
672
+ if admin is None:
673
+ return RedirectResponse("/admin/login", status_code=302)
674
+ form = await _form(request)
675
+ if not _csrf_ok(request, form.get("csrf", "")):
676
+ return RedirectResponse("/admin?err=csrf", status_code=302)
677
+ email = (form.get("email") or "").strip()
678
+ if not _valid_email(email):
679
+ return RedirectResponse("/admin?err=bad_email", status_code=302)
680
+ store = _open_store()
681
+ if store is None:
682
+ return RedirectResponse("/admin?err=bad", status_code=302)
683
+ try:
684
+ # Username == the normalized email, so the teammate signs in with the address they know.
685
+ normalized = email.lower()
686
+ user_store.create_user(
687
+ store,
688
+ username=normalized,
689
+ email=normalized,
690
+ first_name=form.get("first_name", ""),
691
+ last_name=form.get("last_name", ""),
692
+ password=None,
693
+ )
694
+ except Exception as exc:
695
+ # A UNIQUE collision is a duplicate → flash, not a 500. Closing the store in `finally` rolls
696
+ # back the failed INSERT so its write lock doesn't strand the connection.
697
+ if _is_integrity_error(exc):
698
+ return RedirectResponse("/admin?err=dup", status_code=302)
699
+ raise
700
+ finally:
701
+ store.close()
702
+ return RedirectResponse("/admin?ok=added", status_code=302)
703
+
704
+
705
+ async def admin_set_status(request: Request) -> Response:
706
+ """Enable/disable a user (the existing active/disabled status flag). Admin-gated + CSRF-checked;
707
+ can't disable self."""
708
+ admin = current_admin(request)
709
+ if admin is None:
710
+ return RedirectResponse("/admin/login", status_code=302)
711
+ form = await _form(request)
712
+ if not _csrf_ok(request, form.get("csrf", "")):
713
+ return RedirectResponse("/admin?err=csrf", status_code=302)
714
+ username = form.get("username", "")
715
+ status = form.get("status", "")
716
+ if status not in ("active", "disabled"):
717
+ return RedirectResponse("/admin?err=bad", status_code=302)
718
+ if username == admin:
719
+ return RedirectResponse("/admin?err=self", status_code=302)
720
+ store = _open_store()
721
+ if store is None:
722
+ # Mirror create: a missing datastore is an error, not a silent "done" (no false success flash).
723
+ return RedirectResponse("/admin?err=bad", status_code=302)
724
+ try:
725
+ changed = user_store.set_status(store, username, status)
726
+ finally:
727
+ store.close()
728
+ if not changed:
729
+ # The username matched no row (e.g. a stale form) — don't flash a success for a no-op.
730
+ return RedirectResponse("/admin?err=notfound", status_code=302)
731
+ return RedirectResponse(
732
+ f"/admin?ok={'disabled' if status == 'disabled' else 'enabled'}", status_code=302
733
+ )
734
+
735
+
736
+ # ---------------------------------------------------------------------------
737
+ # Admin console — Model tab (read-only model explorer)
738
+ #
739
+ # A pure projection of the served model (`model_store.load_organization`) + the domain docs
740
+ # (`load_memory`) — the SAME tree every MCP tool reads, so there is zero drift and no second store.
741
+ # Read-only by construction: the only route is a GET (see `routes()`), there is no write path, and
742
+ # `storage_connections[].storage_config` (hosts/credentials) is NEVER rendered. The catalog idiom (a
743
+ # browse tree + one page at a time) keeps a wide model — real tables run to dozens of columns —
744
+ # legible. Builders are split from the handler so previews can render them with sample data.
745
+ # ---------------------------------------------------------------------------
746
+
747
+ # Trust posture is surfaced as an honest read-only badge (agami's differentiator: the model says how
748
+ # much to trust each piece), never as a clickable review control — that editor is Hosted.
749
+ _CONF_LABEL = {"confirmed": "✓ confirmed", "inferred": "~ inferred", "proposed": "⋯ proposed"}
750
+
751
+
752
+ def _conf_badge(confidence: str | None) -> str:
753
+ c = (confidence or "").lower()
754
+ if c not in _CONF_LABEL:
755
+ return ""
756
+ return f'<span class="badge b-{c}">{_CONF_LABEL[c]}</span>'
757
+
758
+
759
+ def _human_count(n: int | None) -> str:
760
+ """A compact row-count, e.g. 6591 -> '≈ 6.6k', 612000 -> '≈ 612k'. None -> '' (unknown)."""
761
+ if n is None:
762
+ return ""
763
+ if n < 1000:
764
+ return f"≈ {n}"
765
+ if n < 1_000_000:
766
+ return f"≈ {n / 1000:.1f}k".replace(".0k", "k")
767
+ return f"≈ {n / 1_000_000:.1f}M".replace(".0M", "M")
768
+
769
+
770
+ def _model_url(
771
+ datasource: str, *, area: str | None = None, table: str | None = None, view: str | None = None
772
+ ) -> str:
773
+ """An attribute-safe `/admin/model` href. Values are %-encoded (names may contain spaces); the
774
+ `&` separators are written as `&amp;` so the whole string is safe in an HTML attribute."""
775
+ parts = [f"datasource={quote(datasource)}"]
776
+ if area is not None:
777
+ parts.append(f"area={quote(area)}")
778
+ if table is not None:
779
+ parts.append(f"table={quote(table)}")
780
+ if view is not None:
781
+ parts.append(f"view={quote(view)}")
782
+ return "/admin/model?" + "&amp;".join(parts)
783
+
784
+
785
+ def _area_nav_html(
786
+ a: Any, datasource: str, active_area: str | None, active_table: str | None
787
+ ) -> str:
788
+ """One area node; when it's the active area it expands into links to its tables."""
789
+ head = (
790
+ f'<a class="navitem{" active" if a.name == active_area else ""}" '
791
+ f'href="{_model_url(datasource, area=a.name)}">{ui.esc(a.name)} '
792
+ f'<span class="n">{len(a.tables_defined)}</span></a>'
793
+ )
794
+ if a.name != active_area:
795
+ return head
796
+ leaves = "".join(
797
+ f'<a class="leaf{" active" if t.name == active_table else ""}" '
798
+ f'href="{_model_url(datasource, area=a.name, table=t.name)}">{ui.esc(t.name)}</a>'
799
+ for t in a.tables_defined
800
+ )
801
+ return head + f'<div class="children">{leaves}</div>' if leaves else head
802
+
803
+
804
+ def _model_tree_html(
805
+ org: Any,
806
+ datasource: str,
807
+ datasources: list[str],
808
+ *,
809
+ active_area: str | None = None,
810
+ active_table: str | None = None,
811
+ active_view: str | None = None,
812
+ ) -> str:
813
+ """The left browse rail: a datasource picker (only when more than one is served), an Overview
814
+ link, the subject areas (the active one expands to its tables), and the Domain-context node."""
815
+ if len(datasources) > 1:
816
+ opts = "".join(
817
+ f'<option value="{ui.esc(d)}"{" selected" if d == datasource else ""}>{ui.esc(d)}'
818
+ "</option>"
819
+ for d in datasources
820
+ )
821
+ # onchange auto-submits for JS users; the Go button is the no-JS fallback (the rest of the
822
+ # admin UI is JS-free, so the picker keeps a working control without JavaScript too).
823
+ picker = (
824
+ '<form class="ds" method="get" action="/admin/model">'
825
+ '<span class="muted">Datasource</span>'
826
+ f'<select name="datasource" onchange="this.form.submit()">{opts}</select>'
827
+ '<button type="submit" class="ds-go">Go</button></form>'
828
+ )
829
+ else:
830
+ picker = (
831
+ f'<div class="ds"><span class="muted">Datasource</span>'
832
+ f"<b>{ui.esc(datasource)}</b></div>"
833
+ )
834
+ overview_cls = "navitem" + (" active" if active_area is None and active_view is None else "")
835
+ areas = "".join(
836
+ _area_nav_html(a, datasource, active_area, active_table) for a in org.subject_areas
837
+ )
838
+ # The cross-area Relationships node only appears when the model actually has org-level
839
+ # (cross-subject-area) relationships — no dead node for a single-area model.
840
+ rel_node = ""
841
+ if org.cross_subject_area_relationships:
842
+ rel_cls = "navitem" + (" active" if active_view == "relationships" else "")
843
+ rel_node = (
844
+ f'<a class="{rel_cls}" href="{_model_url(datasource, view="relationships")}">'
845
+ f'Relationships <span class="n">{len(org.cross_subject_area_relationships)}</span></a>'
846
+ )
847
+ context_cls = "navitem" + (" active" if active_view == "context" else "")
848
+ return (
849
+ f'<aside class="tree">{picker}'
850
+ f'<a class="{overview_cls}" href="{_model_url(datasource)}">Overview</a>'
851
+ f"<h4>Subject areas</h4>{areas}"
852
+ f"<h4>Browse</h4>{rel_node}"
853
+ f'<a class="{context_cls}" href="{_model_url(datasource, view="context")}">Domain context</a>'
854
+ "</aside>"
855
+ )
856
+
857
+
858
+ def _model_shell(content: str, tree: str, *, admin_label: str = "", admin_email: str = "") -> str:
859
+ """Wrap the browse tree + a content pane in the admin shell with the Model tab active."""
860
+ body = f'<div class="explorer">{tree}<main class="content">{content}</main></div>'
861
+ return ui.admin_shell(
862
+ "Model · agami admin", "model", body, admin_label=admin_label, admin_email=admin_email
863
+ )
864
+
865
+
866
+ def model_empty_html(datasource: str, datasources: list[str], **chrome: str) -> str:
867
+ """The clean state when nothing is deployed yet (no served model rows)."""
868
+ content = (
869
+ '<div class="crumbs">Model</div><h1>Model</h1>'
870
+ '<p class="lead">No model deployed yet. Author your semantic model in Claude with the agami '
871
+ "plugin and deploy it — the served subject areas, tables, and metrics will show up here, "
872
+ "read-only.</p>"
873
+ )
874
+ label = datasource or (datasources[0] if datasources else "")
875
+ tree = (
876
+ f'<aside class="tree"><div class="ds"><span class="muted">Datasource</span>'
877
+ f"<b>{ui.esc(label) or '—'}</b></div></aside>"
878
+ )
879
+ return _model_shell(content, tree, **chrome)
880
+
881
+
882
+ def _glossary_html(key_terminology: dict[str, str]) -> str:
883
+ if not key_terminology:
884
+ return ""
885
+ terms = "".join(
886
+ f'<span class="term"><b>{ui.esc(k)}</b> {ui.esc(v)}</span>'
887
+ for k, v in key_terminology.items()
888
+ )
889
+ return f'<h2 class="sec">Glossary</h2><div class="gloss">{terms}</div>'
890
+
891
+
892
+ def _storage_html(connections: list[Any]) -> str:
893
+ # Names + types only — storage_config (hosts/credentials) is deliberately never rendered.
894
+ if not connections:
895
+ return ""
896
+ rows = "".join(
897
+ f'<span class="term"><b>{ui.esc(c.name)}</b> '
898
+ f"{ui.esc(getattr(c, 'storage_type', '') or '')}</span>"
899
+ for c in connections
900
+ )
901
+ return f'<h2 class="sec">Storage connections</h2><div class="gloss">{rows}</div>'
902
+
903
+
904
+ def model_overview_html(
905
+ org: Any, version: str | None, datasource: str, datasources: list[str], **chrome: str
906
+ ) -> str:
907
+ """The datasource landing: org header + glossary + storage + the subject-area list."""
908
+ tree = _model_tree_html(org, datasource, datasources)
909
+ table_total = sum(len(a.tables_defined) for a in org.subject_areas)
910
+ ver = ui.esc(version[:8]) if version else f"v{org.version}"
911
+ stats = (
912
+ f'<div class="stat"><div class="k">Subject areas</div>'
913
+ f'<div class="v">{len(org.subject_areas)}</div></div>'
914
+ f'<div class="stat"><div class="k">Tables</div><div class="v">{table_total}</div></div>'
915
+ f'<div class="stat"><div class="k">Version</div>'
916
+ f'<div class="v mono" style="font-size:14px">{ver}</div></div>'
917
+ f'<div class="stat"><div class="k">Fiscal year</div>'
918
+ f'<div class="v" style="font-size:14px">Starts month {org.fiscal_year_start_month}</div></div>'
919
+ )
920
+ areas = "".join(
921
+ f'<a class="trow" href="{_model_url(datasource, area=a.name)}">'
922
+ f'<span class="nm">{ui.esc(a.name)}</span>'
923
+ f'<span class="d">{ui.esc(a.description or "")}</span>'
924
+ f'<span class="meta">{len(a.tables_defined)} tables · {len(a.metrics)} metrics</span>'
925
+ '<span class="chev">›</span></a>'
926
+ for a in org.subject_areas
927
+ )
928
+ content = (
929
+ '<div class="crumbs">Model</div>'
930
+ f'<div class="h1row"><h1>{ui.esc(org.organization)}</h1>'
931
+ '<span class="readonly-pill">Read-only · edit in Claude</span></div>'
932
+ f'<p class="lead">{ui.esc(org.description or "The deployed semantic model.")}</p>'
933
+ f'<div class="statrow">{stats}</div>'
934
+ f"{_glossary_html(org.key_terminology)}"
935
+ f"{_storage_html(org.storage_connections)}"
936
+ f'<h2 class="sec">Subject areas <span class="c">{len(org.subject_areas)}</span></h2>'
937
+ f'<div class="tlist">{areas}</div>'
938
+ )
939
+ # Org-level (cross-area) metrics/entities belong to no single area — surface them here so they
940
+ # aren't silently dropped.
941
+ if org.cross_subject_area_metrics:
942
+ cards = "".join(_metric_card_html(m) for m in org.cross_subject_area_metrics)
943
+ content += f'<h2 class="sec">Cross-area metrics</h2><div class="grid">{cards}</div>'
944
+ if org.cross_subject_area_entities:
945
+ cards = "".join(_entity_card_html(e) for e in org.cross_subject_area_entities)
946
+ content += f'<h2 class="sec">Cross-area entities</h2><div class="grid">{cards}</div>'
947
+ return _model_shell(content, tree, **chrome)
948
+
949
+
950
+ def _metric_card_html(m: Any) -> str:
951
+ aliases = ", ".join(m.other_names) if m.other_names else ""
952
+ alias_html = f' <span class="al">· {ui.esc(aliases)}</span>' if aliases else ""
953
+ unit = f' <span class="al">· {ui.esc(m.unit)}</span>' if m.unit else ""
954
+ calc = ui.esc(m.calculation or "")
955
+ return (
956
+ f'<div class="mcard"><div class="nm">{ui.esc(m.name)}{alias_html}{unit}</div>'
957
+ f'<div class="muted" style="font-size:13px">{ui.esc(m.description or "")}</div>'
958
+ f'<span class="calc">{calc}</span></div>'
959
+ )
960
+
961
+
962
+ def _entity_card_html(e: Any) -> str:
963
+ aliases = ", ".join(e.other_names) if e.other_names else ""
964
+ alias_html = f' <span class="al">· {ui.esc(aliases)}</span>' if aliases else ""
965
+ pattern = (
966
+ f' <span class="muted mono" style="font-size:12px">{ui.esc(e.value_pattern)}</span>'
967
+ if e.value_pattern
968
+ else ""
969
+ )
970
+ return (
971
+ f'<div class="mcard"><div class="nm">{ui.esc(e.name)}{alias_html} '
972
+ f"{_conf_badge(e.confidence)}</div>"
973
+ f'<div class="muted" style="font-size:13px">{ui.esc(e.description or "")}{pattern}</div></div>'
974
+ )
975
+
976
+
977
+ def model_area_html(
978
+ org: Any, area: Any, datasource: str, datasources: list[str], **chrome: str
979
+ ) -> str:
980
+ """A subject-area landing: its tables (scannable), then metrics + entities as cards."""
981
+ tree = _model_tree_html(org, datasource, datasources, active_area=area.name)
982
+ tables = "".join(
983
+ f'<a class="trow" href="{_model_url(datasource, area=area.name, table=t.name)}">'
984
+ f'<span class="nm">{ui.esc(t.name)}</span>'
985
+ f'<span class="d">{ui.esc(t.description or "")}</span>'
986
+ f'<span class="meta">{len(t.columns)} cols · '
987
+ f"{ui.esc(_human_count(_est_rows_obj(t)))}</span>{_conf_badge(t.confidence)}"
988
+ '<span class="chev">›</span></a>'
989
+ for t in area.tables_defined
990
+ )
991
+ metrics = "".join(_metric_card_html(m) for m in area.metrics)
992
+ entities = "".join(_entity_card_html(e) for e in area.entities)
993
+ window = (
994
+ f"<span>default window · <b>{ui.esc(area.default_time_window)}</b></span>"
995
+ if area.default_time_window
996
+ else ""
997
+ )
998
+ content = (
999
+ f'<div class="crumbs"><a href="{_model_url(datasource)}">{ui.esc(datasource)}</a>'
1000
+ f'<span class="sep">/</span>{ui.esc(area.name)}</div>'
1001
+ f"<h1>{ui.esc(area.name)}</h1>"
1002
+ f'<div class="subline"><span><b>{len(area.tables_defined)}</b> tables</span>'
1003
+ f"<span><b>{len(area.metrics)}</b> metrics</span>"
1004
+ f"<span><b>{len(area.entities)}</b> entities</span>{window}</div>"
1005
+ f'<p class="lead">{ui.esc(area.description or "")}</p>'
1006
+ f'<h2 class="sec">Tables <span class="c">{len(area.tables_defined)}</span></h2>'
1007
+ f'<div class="tlist">{tables}</div>'
1008
+ )
1009
+ if metrics:
1010
+ content += f'<h2 class="sec">Metrics</h2><div class="grid">{metrics}</div>'
1011
+ if entities:
1012
+ content += f'<h2 class="sec">Entities</h2><div class="grid">{entities}</div>'
1013
+ return _model_shell(content, tree, **chrome)
1014
+
1015
+
1016
+ def _est_rows_obj(table: Any) -> int | None:
1017
+ ph = getattr(table, "performance_hints", None)
1018
+ return getattr(ph, "estimated_row_count", None) if ph is not None else None
1019
+
1020
+
1021
+ # --- the table (dataset) page ------------------------------------------------
1022
+
1023
+ _COL_THEAD = (
1024
+ '<thead><tr><th style="width:210px">Column</th><th style="width:120px">Type</th>'
1025
+ '<th>Description</th><th style="width:170px" class="flags">Flags</th></tr></thead>'
1026
+ )
1027
+
1028
+
1029
+ def _col_flags_html(col: Any) -> str:
1030
+ """Per-column flags — only what carries signal (PK / FK / enum / unit / sensitive / caveat); the
1031
+ redundant per-column 'confirmed/approved' the old view repeated on every row is left out."""
1032
+ flags = []
1033
+ if col.primary_key:
1034
+ flags.append('<span class="badge b-pk">PK</span>')
1035
+ fk = getattr(col, "foreign_key", None)
1036
+ if fk is not None and getattr(fk, "table", None):
1037
+ flags.append(f'<span class="badge b-fk">FK → {ui.esc(fk.table)}</span>')
1038
+ if getattr(col, "choice_field", None):
1039
+ flags.append('<span class="badge b-soft">enum</span>')
1040
+ if col.unit:
1041
+ flags.append(f'<span class="badge b-soft">{ui.esc(str(col.unit))}</span>')
1042
+ if col.sensitive:
1043
+ flags.append('<span class="badge b-sensitive">● sensitive</span>')
1044
+ if col.caveats:
1045
+ flags.append('<span class="badge b-proposed">⚠ caveat</span>')
1046
+ return " ".join(flags)
1047
+
1048
+
1049
+ def _col_rows_html(columns: list[Any]) -> str:
1050
+ """The <tr>s for a set of columns; a column with caveats gets an inline note row beneath it."""
1051
+ out = ""
1052
+ for col in columns:
1053
+ if col.description:
1054
+ desc = ui.esc(col.description)
1055
+ if getattr(col, "description_source", None) == "ai_unvalidated":
1056
+ desc += ' <span class="aichip" title="AI-described, unvalidated">AI</span>'
1057
+ else:
1058
+ desc = '<span class="dash">—</span>'
1059
+ out += (
1060
+ '<tr class="crow">'
1061
+ f'<td class="cn">{ui.esc(col.name)}</td>'
1062
+ f'<td><span class="ct">{ui.esc(str(col.type))}</span></td>'
1063
+ f'<td class="cd">{desc}</td>'
1064
+ f'<td class="flags">{_col_flags_html(col)}</td></tr>'
1065
+ )
1066
+ if col.caveats:
1067
+ note = "<br>".join(ui.esc(c) for c in col.caveats)
1068
+ out += f'<tr class="noterow"><td colspan="4"><div class="note">{note}</div></td></tr>'
1069
+ return out
1070
+
1071
+
1072
+ def _columns_flat_html(columns: list[Any]) -> str:
1073
+ """A flat schema table. Narrow tables show in full; wide ones show the first 8 and tuck the rest
1074
+ behind a JS-free 'show all N' <details> — the default stays short without hiding anything."""
1075
+ if len(columns) <= 12:
1076
+ return f'<table class="cols">{_COL_THEAD}<tbody>{_col_rows_html(columns)}</tbody></table>'
1077
+ head = _col_rows_html(columns[:8])
1078
+ rest = _col_rows_html(columns[8:])
1079
+ return (
1080
+ f'<table class="cols">{_COL_THEAD}<tbody>{head}</tbody></table>'
1081
+ f'<details class="showmore"><summary>Show all {len(columns)} columns</summary>'
1082
+ f'<table class="cols"><tbody>{rest}</tbody></table></details>'
1083
+ )
1084
+
1085
+
1086
+ def _columns_grouped_html(table: Any) -> str:
1087
+ """Collapsible groups from the table's authored `column_groups` (labelled by
1088
+ `column_group_descriptions`); columns in no authored group fall into a trailing 'Other'."""
1089
+ descs = getattr(table, "column_group_descriptions", {}) or {}
1090
+ by_name = {c.name: c for c in table.columns}
1091
+ seen: set[str] = set()
1092
+ blocks = ""
1093
+ for i, (gname, colnames) in enumerate(table.column_groups.items()):
1094
+ cols = [by_name[n] for n in colnames if n in by_name]
1095
+ seen.update(colnames)
1096
+ gloss = ui.esc(descs.get(gname, ""))
1097
+ gloss_html = f'<span class="gdesc">{gloss}</span>' if gloss else ""
1098
+ blocks += (
1099
+ f'<details class="grp"{" open" if i < 2 else ""}><summary>'
1100
+ f'<span class="gname">{ui.esc(gname)}</span>{gloss_html}'
1101
+ f'<span class="gn">{len(cols)}</span></summary>'
1102
+ f'<table class="cols"><tbody>{_col_rows_html(cols)}</tbody></table></details>'
1103
+ )
1104
+ other = [c for c in table.columns if c.name not in seen]
1105
+ if other:
1106
+ blocks += (
1107
+ '<details class="grp"><summary><span class="gname">Other</span>'
1108
+ f'<span class="gn">{len(other)}</span></summary>'
1109
+ f'<table class="cols"><tbody>{_col_rows_html(other)}</tbody></table></details>'
1110
+ )
1111
+ return blocks
1112
+
1113
+
1114
+ def _caveat_callout(caveats: list[str]) -> str:
1115
+ if not caveats:
1116
+ return ""
1117
+ body = "<br>".join(ui.esc(c) for c in caveats)
1118
+ return f'<div class="caveat"><span class="ic">⚠</span><div class="t">{body}</div></div>'
1119
+
1120
+
1121
+ def _table_rels_html(org: Any, area: Any, table_name: str) -> str:
1122
+ """Relationships touching this table — within-area + the org-level cross-area ones."""
1123
+ rels = list(area.relationships) + list(org.cross_subject_area_relationships)
1124
+ rows = "".join(
1125
+ f'<div class="rel"><span class="mono">{ui.esc(r.from_table)}</span>'
1126
+ f'<span class="arr">→</span><span class="mono">{ui.esc(r.to_table)}</span>'
1127
+ f'<span class="badge b-soft">{ui.esc(str(r.relationship))}</span>'
1128
+ f'<span class="ro">{ui.esc(str(r.join_type))} · {ui.esc(str(r.confidence))}</span></div>'
1129
+ for r in rels
1130
+ if r.from_table == table_name or r.to_table == table_name
1131
+ )
1132
+ if not rows:
1133
+ return ""
1134
+ return f'<h2 class="sec">Relationships</h2><div class="card">{rows}</div>'
1135
+
1136
+
1137
+ def _table_metrics_html(area: Any, table_name: str) -> str:
1138
+ """Metrics whose `source_tables` include this table."""
1139
+ using = [m for m in area.metrics if table_name in (m.source_tables or [])]
1140
+ if not using:
1141
+ return ""
1142
+ cards = "".join(_metric_card_html(m) for m in using)
1143
+ return f'<h2 class="sec">Used by metrics</h2><div class="grid">{cards}</div>'
1144
+
1145
+
1146
+ def model_table_html(
1147
+ org: Any, area: Any, table: Any, datasource: str, datasources: list[str], **chrome: str
1148
+ ) -> str:
1149
+ """A table (dataset) page — the heart of the explorer: header, caveats, columns
1150
+ (grouped-when-authored else flat), then relationships + metrics that use it."""
1151
+ tree = _model_tree_html(
1152
+ org, datasource, datasources, active_area=area.name, active_table=table.name
1153
+ )
1154
+ schema = (
1155
+ f'<span class="schema">{ui.esc(table.schema_name)}.</span>' if table.schema_name else ""
1156
+ )
1157
+ rows = _human_count(_est_rows_obj(table))
1158
+ grain = ", ".join(table.grain) if table.grain else ""
1159
+ aichip = (
1160
+ ' <span class="descsrc">AI-described · unvalidated</span>'
1161
+ if getattr(table, "description_source", None) == "ai_unvalidated"
1162
+ else ""
1163
+ )
1164
+ sql_block = ""
1165
+ if getattr(table, "source_type", None) == "sql" and table.sql:
1166
+ sql_block = (
1167
+ '<h2 class="sec">Defining SQL</h2>'
1168
+ f'<pre class="code" style="white-space:pre-wrap;display:block;padding:12px">'
1169
+ f"{ui.esc(table.sql)}</pre>"
1170
+ )
1171
+ subline = "".join(
1172
+ f"<span>{s}</span>"
1173
+ for s in (
1174
+ f"<b>{len(table.columns)}</b> columns",
1175
+ f"<b>{ui.esc(rows)}</b> rows" if rows else "",
1176
+ f'grain · <b class="mono">{ui.esc(grain)}</b>' if grain else "",
1177
+ ui.esc(table.storage_connection or ""),
1178
+ )
1179
+ if s
1180
+ )
1181
+ columns = (
1182
+ _columns_grouped_html(table) if table.column_groups else _columns_flat_html(table.columns)
1183
+ )
1184
+ content = (
1185
+ f'<div class="crumbs"><a href="{_model_url(datasource)}">{ui.esc(datasource)}</a>'
1186
+ f'<span class="sep">/</span>'
1187
+ f'<a href="{_model_url(datasource, area=area.name)}">{ui.esc(area.name)}</a>'
1188
+ f'<span class="sep">/</span>{ui.esc(table.name)}</div>'
1189
+ f'<div class="h1row"><h1>{schema}{ui.esc(table.name)}</h1>'
1190
+ f"{_conf_badge(table.confidence)}"
1191
+ '<span class="readonly-pill">Read-only · edit in Claude</span></div>'
1192
+ f'<div class="subline">{subline}</div>'
1193
+ f'<p class="desc">{ui.esc(table.description or "")}{aichip}</p>'
1194
+ f"{_caveat_callout(table.caveats)}"
1195
+ f'<h2 class="sec">Columns <span class="c">{len(table.columns)}</span></h2>'
1196
+ f"{columns}{sql_block}"
1197
+ f"{_table_rels_html(org, area, table.name)}"
1198
+ f"{_table_metrics_html(area, table.name)}"
1199
+ )
1200
+ return _model_shell(content, tree, **chrome)
1201
+
1202
+
1203
+ def _qualified(schema: str | None, table: str) -> str:
1204
+ return f"{ui.esc(schema)}.{ui.esc(table)}" if schema else ui.esc(table)
1205
+
1206
+
1207
+ def _cross_rel_row_html(r: Any) -> str:
1208
+ """One cross-area relationship: schema-qualified from→to, the join columns, cardinality, trust."""
1209
+ on = f"{ui.esc(r.from_column)} = {ui.esc(r.to_column)}" if r.from_column and r.to_column else ""
1210
+ meta = " · ".join(p for p in (on, ui.esc(str(r.join_type)), ui.esc(str(r.confidence))) if p)
1211
+ return (
1212
+ f'<div class="rel"><span class="mono">{_qualified(r.from_schema, r.from_table)}</span>'
1213
+ f'<span class="arr">→</span>'
1214
+ f'<span class="mono">{_qualified(r.to_schema, r.to_table)}</span>'
1215
+ f'<span class="badge b-soft">{ui.esc(str(r.relationship))}</span>'
1216
+ f'<span class="ro">{meta}</span></div>'
1217
+ )
1218
+
1219
+
1220
+ def model_relationships_html(
1221
+ org: Any, datasource: str, datasources: list[str], **chrome: str
1222
+ ) -> str:
1223
+ """The cross-area relationships — the org-level joins that span subject areas — grouped by
1224
+ area-pair, so the model's cross-area topology is readable in one place (within-area joins stay
1225
+ on each table page)."""
1226
+ tree = _model_tree_html(org, datasource, datasources, active_view="relationships")
1227
+ rels = org.cross_subject_area_relationships
1228
+ groups: dict[tuple[str, str], list[Any]] = {}
1229
+ for r in rels:
1230
+ groups.setdefault((r.from_subject_area, r.to_subject_area), []).append(r)
1231
+ # Most-connected area-pairs first, then alphabetical — the same ordering as the topology view.
1232
+ blocks = ""
1233
+ for (fa, ta), items in sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[0])):
1234
+ rows = "".join(_cross_rel_row_html(r) for r in items)
1235
+ blocks += (
1236
+ f'<details class="grp" open><summary>'
1237
+ f'<span class="gname">{ui.esc(fa)} <span class="arr">→</span> {ui.esc(ta)}</span>'
1238
+ f'<span class="gn">{len(items)}</span></summary>{rows}</details>'
1239
+ )
1240
+ body = blocks if rels else '<p class="lead">No cross-area relationships in this model.</p>'
1241
+ content = (
1242
+ f'<div class="crumbs"><a href="{_model_url(datasource)}">{ui.esc(datasource)}</a>'
1243
+ '<span class="sep">/</span>Relationships</div><h1>Cross-area relationships</h1>'
1244
+ f'<p class="lead">The <b>{len(rels)}</b> org-level joins that span subject areas, grouped by '
1245
+ "area-pair. (Joins within a single area show on each table page.)</p>"
1246
+ f"{body}"
1247
+ )
1248
+ return _model_shell(content, tree, **chrome)
1249
+
1250
+
1251
+ def model_context_html(
1252
+ org: Any, memory: dict[str, str], datasource: str, datasources: list[str], **chrome: str
1253
+ ) -> str:
1254
+ """The Domain-context page — the deployed ORGANIZATION.md rendered as (safe) markdown."""
1255
+ tree = _model_tree_html(org, datasource, datasources, active_view="context")
1256
+ org_md = memory.get("organization")
1257
+ doc = (
1258
+ f'<div class="context">{ui.md(org_md)}</div>'
1259
+ if org_md
1260
+ else '<p class="lead">No domain context (ORGANIZATION.md) deployed for this datasource.</p>'
1261
+ )
1262
+ content = (
1263
+ f'<div class="crumbs"><a href="{_model_url(datasource)}">{ui.esc(datasource)}</a>'
1264
+ '<span class="sep">/</span>Domain context</div><h1>Domain context</h1>'
1265
+ '<p class="lead">The deployed ORGANIZATION.md — the domain notes Claude reads as context. '
1266
+ f"Read-only.</p>{doc}"
1267
+ )
1268
+ return _model_shell(content, tree, **chrome)
1269
+
1270
+
1271
+ async def admin_model(request: Request) -> Response:
1272
+ """The read-only Model explorer. Session-gated; a pure GET projection of the served model. Query:
1273
+ `?datasource=` (defaults to the first served), `?area=`, `?view=context`."""
1274
+ import model_store
1275
+
1276
+ admin = current_admin(request)
1277
+ if admin is None:
1278
+ return RedirectResponse("/admin/login", status_code=302)
1279
+ store = _open_store()
1280
+ try:
1281
+ chrome = _admin_chrome(store, admin)
1282
+ datasources = model_store.list_datasources(store) if store is not None else []
1283
+ if not datasources:
1284
+ return HTMLResponse(model_empty_html("", [], **chrome))
1285
+ datasource = request.query_params.get("datasource") or datasources[0]
1286
+ if datasource not in datasources: # an unknown/stale datasource param → the first served
1287
+ datasource = datasources[0]
1288
+ org = model_store.load_organization(store, datasource)
1289
+ if org is None:
1290
+ return HTMLResponse(model_empty_html(datasource, datasources, **chrome))
1291
+ view = request.query_params.get("view")
1292
+ if view == "relationships":
1293
+ return HTMLResponse(model_relationships_html(org, datasource, datasources, **chrome))
1294
+ if view == "context":
1295
+ memory = model_store.load_memory(store, datasource)
1296
+ return HTMLResponse(model_context_html(org, memory, datasource, datasources, **chrome))
1297
+ area_name = request.query_params.get("area")
1298
+ if area_name:
1299
+ area = next((a for a in org.subject_areas if a.name == area_name), None)
1300
+ if area is not None:
1301
+ table_name = request.query_params.get("table")
1302
+ if table_name:
1303
+ table = next((t for t in area.tables_defined if t.name == table_name), None)
1304
+ if table is not None:
1305
+ return HTMLResponse(
1306
+ model_table_html(org, area, table, datasource, datasources, **chrome)
1307
+ )
1308
+ return HTMLResponse(model_area_html(org, area, datasource, datasources, **chrome))
1309
+ version = model_store.newest_model_version(store, datasource)
1310
+ return HTMLResponse(model_overview_html(org, version, datasource, datasources, **chrome))
1311
+ finally:
1312
+ if store is not None:
1313
+ store.close()
1314
+
1315
+
1316
+ def routes() -> list:
1317
+ """The `/admin/*` routes, for the transport to mount. Each is session-gated in the handler (the
1318
+ transport adds these paths to the bearer public-skip — they do their own auth, not the MCP one)."""
1319
+ from starlette.routing import Route
1320
+
1321
+ return [
1322
+ Route("/admin", admin_home, methods=["GET"]),
1323
+ # Read-only model explorer — GET only, by design (no write path can hide behind /admin/model).
1324
+ Route("/admin/model", admin_model, methods=["GET"]),
1325
+ Route("/admin/login", admin_login, methods=["GET", "POST"]),
1326
+ Route("/admin/logout", admin_logout, methods=["GET"]),
1327
+ # The admin OIDC start (handler lives in oauth_server, with the OIDC machinery). The IdP
1328
+ # redirects back to the shared /oauth/oidc/callback, which branches on the state's purpose.
1329
+ Route("/admin/oidc/start", admin_oidc_start, methods=["GET"]),
1330
+ Route("/admin/users", admin_create_user, methods=["POST"]),
1331
+ Route("/admin/users/status", admin_set_status, methods=["POST"]),
1332
+ ]
1333
+
1334
+
1335
+ ADMIN_PATHS = (
1336
+ "/admin",
1337
+ "/admin/model",
1338
+ "/admin/login",
1339
+ "/admin/logout",
1340
+ "/admin/oidc/start",
1341
+ "/admin/users",
1342
+ "/admin/users/status",
1343
+ )