keywright-server 0.0.1__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.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
"""Admin UI: server-rendered pages, plain forms, no JS dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import ipaddress
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import shlex
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, Depends, Form, Request, WebSocket, WebSocketDisconnect
|
|
17
|
+
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
|
|
18
|
+
from fastapi.templating import Jinja2Templates
|
|
19
|
+
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|
20
|
+
from keywright.hazards import SEVERITIES
|
|
21
|
+
from keywright.hazards import blocks as hazard_blocks
|
|
22
|
+
from keywright.patterns import validate
|
|
23
|
+
from sqlalchemy import String, cast, desc, func, or_, select
|
|
24
|
+
from starlette.concurrency import run_in_threadpool
|
|
25
|
+
|
|
26
|
+
from .. import (
|
|
27
|
+
access_view,
|
|
28
|
+
approvals,
|
|
29
|
+
audit,
|
|
30
|
+
bundles,
|
|
31
|
+
events,
|
|
32
|
+
integrations,
|
|
33
|
+
inventory,
|
|
34
|
+
inventory_mcp,
|
|
35
|
+
llm,
|
|
36
|
+
mcp_oauth,
|
|
37
|
+
mcp_proxy,
|
|
38
|
+
nudges,
|
|
39
|
+
omada,
|
|
40
|
+
policy_preview,
|
|
41
|
+
result_inspection,
|
|
42
|
+
semantic,
|
|
43
|
+
ssh,
|
|
44
|
+
tool_access,
|
|
45
|
+
)
|
|
46
|
+
from ..access import (
|
|
47
|
+
binds_exactly,
|
|
48
|
+
constraint_label,
|
|
49
|
+
hazard_level,
|
|
50
|
+
hazards_of,
|
|
51
|
+
operation_detail,
|
|
52
|
+
operations,
|
|
53
|
+
path_scoped,
|
|
54
|
+
)
|
|
55
|
+
from ..config import settings
|
|
56
|
+
from ..db import session
|
|
57
|
+
from ..mcp_server import hash_token
|
|
58
|
+
from ..models import AccessRequest, Agent, AuditEvent, Grant, Nudge, Policy, Resource, now
|
|
59
|
+
from ..notify.base import operation_line, registry
|
|
60
|
+
from ..notify.manager import DEFAULT_BUTTONS, manager
|
|
61
|
+
from ..settings_store import get as get_setting
|
|
62
|
+
from ..settings_store import put as put_setting
|
|
63
|
+
from . import auth, mcp_oauth_ui, oidc
|
|
64
|
+
from .security import require_same_origin, safe_redirect, websocket_same_origin
|
|
65
|
+
|
|
66
|
+
log = logging.getLogger("keywright.ui")
|
|
67
|
+
|
|
68
|
+
router = APIRouter(dependencies=[Depends(require_same_origin)])
|
|
69
|
+
# Websockets never carry a method require_same_origin could check, so they live on their own
|
|
70
|
+
# router and do the origin check themselves.
|
|
71
|
+
ws_router = APIRouter()
|
|
72
|
+
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
|
73
|
+
templates.env.filters["shellquote"] = shlex.quote
|
|
74
|
+
templates.env.filters["redact"] = audit._scrub
|
|
75
|
+
templates.env.globals.update(scope_labels=approvals.SCOPE_LABELS, scopes=approvals.SCOPES,
|
|
76
|
+
bundles_text=bundles.format_text, operation_detail=operation_detail,
|
|
77
|
+
operations=operations, binds_exactly=binds_exactly, path_scoped=path_scoped,
|
|
78
|
+
constraint_label=constraint_label, remaining=access_view.remaining,
|
|
79
|
+
decision_label=approvals.decision_label, hazards_of=hazards_of,
|
|
80
|
+
hazard_level=hazard_level, hazard_blocks=hazard_blocks,
|
|
81
|
+
operation_line=operation_line, jev_line=semantic.describe,
|
|
82
|
+
jev_effect_words=semantic.EFFECT_WORDS,
|
|
83
|
+
session_cap_minutes=settings.session_grant_ttl_s // 60)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def render(request: Request, name: str, **ctx: Any) -> HTMLResponse:
|
|
87
|
+
flash = request.cookies.get("kw_flash")
|
|
88
|
+
resp = templates.TemplateResponse(request, name, {"request": request, "flash": flash, "now": now(), **ctx})
|
|
89
|
+
if flash:
|
|
90
|
+
resp.delete_cookie("kw_flash")
|
|
91
|
+
return resp
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def redirect(url: str, flash: str | None = None) -> RedirectResponse:
|
|
95
|
+
resp = RedirectResponse(safe_redirect(url), status_code=303)
|
|
96
|
+
if flash:
|
|
97
|
+
resp.set_cookie("kw_flash", flash[:500], max_age=30, httponly=True, samesite="lax")
|
|
98
|
+
return resp
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---- auth pages ---------------------------------------------------------------------------------
|
|
102
|
+
@router.get("/", include_in_schema=False)
|
|
103
|
+
async def root():
|
|
104
|
+
return RedirectResponse("/ui", status_code=302)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.get("/ui/setup", response_class=HTMLResponse)
|
|
108
|
+
async def setup_get(request: Request):
|
|
109
|
+
if auth.password_is_set() or auth.oidc_enabled():
|
|
110
|
+
return redirect("/ui/login")
|
|
111
|
+
return render(request, "setup.html")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.post("/ui/setup")
|
|
115
|
+
async def setup_post(request: Request, password: str = Form(...), repeat: str = Form(...)):
|
|
116
|
+
if auth.password_is_set() or auth.oidc_enabled():
|
|
117
|
+
return redirect("/ui/login")
|
|
118
|
+
if not 8 <= len(password) <= 1024 or password != repeat:
|
|
119
|
+
return render(request, "setup.html", error="Passwords must match and be at least 8 characters.")
|
|
120
|
+
async with session() as db:
|
|
121
|
+
await auth.set_password(db, password)
|
|
122
|
+
return auth.login_response("/ui")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.get("/ui/login", response_class=HTMLResponse)
|
|
126
|
+
async def login_get(request: Request, next: str = "/ui", local: bool = False, logged_out: bool = False):
|
|
127
|
+
if await auth.authenticated(request):
|
|
128
|
+
return redirect(next)
|
|
129
|
+
if auth.oidc_enabled() and not local and not logged_out:
|
|
130
|
+
from urllib.parse import urlencode
|
|
131
|
+
|
|
132
|
+
return redirect("/ui/oidc/login?" + urlencode({"next": safe_redirect(next)}))
|
|
133
|
+
if not auth.password_is_set() and not auth.oidc_enabled():
|
|
134
|
+
return redirect("/ui/setup")
|
|
135
|
+
return render(request, "login.html", next=safe_redirect(next), oidc_enabled=auth.oidc_enabled(),
|
|
136
|
+
password_enabled=settings.auth_mode != "oidc" and auth.password_is_set(), logged_out=logged_out)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.post("/ui/login")
|
|
140
|
+
async def login_post(request: Request, password: str = Form(...), next: str = Form("/ui")):
|
|
141
|
+
if settings.auth_mode == "oidc":
|
|
142
|
+
return oidc.failure("Password login is disabled. Sign in with Authentik.", 403)
|
|
143
|
+
address = auth.limit_login(request)
|
|
144
|
+
if await run_in_threadpool(auth.check_password, password):
|
|
145
|
+
auth.login_succeeded(address)
|
|
146
|
+
return auth.login_response(next if next.startswith("/") else "/ui")
|
|
147
|
+
return render(request, "login.html", next=next, error="Wrong password.",
|
|
148
|
+
oidc_enabled=auth.oidc_enabled(), password_enabled=True)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@router.post("/ui/logout")
|
|
152
|
+
async def logout(request: Request):
|
|
153
|
+
await oidc.logout(request)
|
|
154
|
+
return auth.logout_response("/ui/login?logged_out=1")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---- dashboard & requests -----------------------------------------------------------------------
|
|
158
|
+
async def _waiting(db) -> dict[str, Any]:
|
|
159
|
+
"""What is waiting on the human right now: the live region of the dashboard."""
|
|
160
|
+
pending = (await db.execute(
|
|
161
|
+
select(AccessRequest).where(AccessRequest.status == "pending").order_by(desc(AccessRequest.created_at))
|
|
162
|
+
)).scalars().all()
|
|
163
|
+
return {"pending": list(pending), "nudges": await nudges.open_nudges(db)}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def _counts(db) -> dict[str, int]:
|
|
167
|
+
"""The numbers on the stat tiles, cheap enough to recompute on every live event."""
|
|
168
|
+
at = now()
|
|
169
|
+
# Deliberately the same condition as _waiting: a request whose clock has run out but which the
|
|
170
|
+
# janitor has not swept yet is still a tile on the page, so it is still a number on the tile.
|
|
171
|
+
pending = await db.scalar(select(func.count()).select_from(AccessRequest).where(
|
|
172
|
+
AccessRequest.status == "pending"))
|
|
173
|
+
waiting = await db.scalar(select(func.count()).select_from(Nudge).where(Nudge.cleared_at.is_(None)))
|
|
174
|
+
grants = (await db.execute(select(Grant).where(
|
|
175
|
+
Grant.revoked_at.is_(None),
|
|
176
|
+
(Grant.expires_at.is_(None)) | (Grant.expires_at > at),
|
|
177
|
+
(Grant.uses_remaining.is_(None)) | (Grant.uses_remaining > 0),
|
|
178
|
+
))).scalars().all()
|
|
179
|
+
# Grants from one approval are one lease to a human, exactly as the access overview groups them.
|
|
180
|
+
leases = {(g.agent_id, g.request_id or g.id) for g in grants if g.is_active(at, g.session_id)}
|
|
181
|
+
return {"pending": pending or 0, "nudges": waiting or 0, "leases": len(leases)}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@router.get("/ui", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
185
|
+
async def dashboard(request: Request):
|
|
186
|
+
async with session() as db:
|
|
187
|
+
await approvals.expire_stale(db)
|
|
188
|
+
await nudges.expire_stale(db)
|
|
189
|
+
waiting = await _waiting(db)
|
|
190
|
+
access = await access_view.overview(db)
|
|
191
|
+
recent = (await db.execute(select(AuditEvent).order_by(desc(AuditEvent.id)).limit(15))).scalars().all()
|
|
192
|
+
drifted = [r for r in (await db.execute(select(Resource).where(Resource.kind == "mcp"))).scalars().all()
|
|
193
|
+
if r.config.get("tools_drift")]
|
|
194
|
+
return render(request, "dashboard.html", recent=recent, notifier=manager, drifted=drifted, **waiting, **access)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@router.post("/ui/nudges/{nid}/dismiss", dependencies=[Depends(auth.require_admin)])
|
|
198
|
+
async def nudge_dismiss(nid: str, next: str = Form("/ui")):
|
|
199
|
+
async with session() as db:
|
|
200
|
+
nudge = await nudges.clear(db, nid, via="ui")
|
|
201
|
+
if nudge is None:
|
|
202
|
+
return redirect(next, "No such request for attention.")
|
|
203
|
+
return redirect(next, f"Cleared: {nudge.agent.name} is no longer shown as waiting on you.")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---- live dashboard -----------------------------------------------------------------------------
|
|
207
|
+
def _render(name: str, **ctx: Any) -> str:
|
|
208
|
+
"""Render a partial outside any HTTP request, for delivery over the websocket. Partials used
|
|
209
|
+
this way must not touch `request` or `flash`."""
|
|
210
|
+
return templates.get_template(name).render(now=now(), **ctx)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def _live_payload(db, event: dict[str, Any]) -> dict[str, Any] | None:
|
|
214
|
+
"""Turn a fact from the event hub into something a dashboard can apply. Markup is built here,
|
|
215
|
+
not in the approval path, so the two stay independent and Jinja stays in the web layer."""
|
|
216
|
+
kind = event["type"]
|
|
217
|
+
if kind == "resync":
|
|
218
|
+
return {**event, **await _live_state(db)}
|
|
219
|
+
counts = await _counts(db)
|
|
220
|
+
if kind == "request.created":
|
|
221
|
+
req = await db.get(AccessRequest, event["id"])
|
|
222
|
+
if req is None or req.status != "pending":
|
|
223
|
+
return None # decided or expired between the publish and here; its own event follows
|
|
224
|
+
return {**event, "counts": counts, "html": _render("_request_card.html", req=req)}
|
|
225
|
+
if kind == "nudge.created":
|
|
226
|
+
nudge = await db.get(Nudge, event["id"])
|
|
227
|
+
if nudge is None or not nudge.open:
|
|
228
|
+
return None
|
|
229
|
+
return {**event, "counts": counts, "html": _render("_nudge_card.html", n=nudge)}
|
|
230
|
+
return {**event, "counts": counts}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def _live_state(db) -> dict[str, Any]:
|
|
234
|
+
return {"counts": await _counts(db), "html": _render("_live.html", **await _waiting(db))}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@router.get("/ui/live", response_class=JSONResponse, dependencies=[Depends(auth.require_admin)])
|
|
238
|
+
async def live_state(request: Request):
|
|
239
|
+
"""The whole live region, for a dashboard that has just reconnected or fallen behind."""
|
|
240
|
+
async with session() as db:
|
|
241
|
+
return JSONResponse({"type": "resync", **await _live_state(db)})
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@ws_router.websocket("/ui/events")
|
|
245
|
+
async def live_events(ws: WebSocket) -> None:
|
|
246
|
+
"""One socket per open dashboard: new requests appear, decided ones resolve, including when the
|
|
247
|
+
decision came from a phone. Read-only - nothing a browser sends here changes anything."""
|
|
248
|
+
if not websocket_same_origin(ws.headers.get("origin"), str(ws.url)):
|
|
249
|
+
log.warning("rejected dashboard websocket from origin %r", ws.headers.get("origin"))
|
|
250
|
+
return await ws.close(code=1008)
|
|
251
|
+
if not await auth.authenticated(ws):
|
|
252
|
+
return await ws.close(code=1008)
|
|
253
|
+
await ws.accept()
|
|
254
|
+
async with events.subscribe() as subscriber:
|
|
255
|
+
async with session() as db:
|
|
256
|
+
await ws.send_json({"type": "hello", "counts": await _counts(db)})
|
|
257
|
+
closed = asyncio.create_task(_until_closed(ws))
|
|
258
|
+
pending: asyncio.Task | None = None
|
|
259
|
+
try:
|
|
260
|
+
while not closed.done():
|
|
261
|
+
pending = asyncio.ensure_future(subscriber.get())
|
|
262
|
+
done, _ = await asyncio.wait({closed, pending}, timeout=HEARTBEAT_S,
|
|
263
|
+
return_when=asyncio.FIRST_COMPLETED)
|
|
264
|
+
if closed in done:
|
|
265
|
+
break
|
|
266
|
+
if not await auth.authenticated(ws):
|
|
267
|
+
await ws.close(code=1008)
|
|
268
|
+
break
|
|
269
|
+
if pending not in done:
|
|
270
|
+
pending.cancel()
|
|
271
|
+
await ws.send_json({"type": "ping"}) # keeps idle proxies from closing us
|
|
272
|
+
continue
|
|
273
|
+
async with session() as db:
|
|
274
|
+
payload = await _live_payload(db, pending.result())
|
|
275
|
+
if payload is not None:
|
|
276
|
+
await ws.send_json(payload)
|
|
277
|
+
except (WebSocketDisconnect, RuntimeError, ConnectionError):
|
|
278
|
+
pass
|
|
279
|
+
finally:
|
|
280
|
+
closed.cancel()
|
|
281
|
+
if pending is not None:
|
|
282
|
+
pending.cancel()
|
|
283
|
+
with contextlib.suppress(Exception):
|
|
284
|
+
await ws.close()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
HEARTBEAT_S = 25
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def _until_closed(ws: WebSocket) -> None:
|
|
291
|
+
"""Completes when the browser goes away. A dashboard sends nothing, so anything it does send
|
|
292
|
+
is discarded."""
|
|
293
|
+
with contextlib.suppress(Exception):
|
|
294
|
+
while True:
|
|
295
|
+
if (await ws.receive())["type"] == "websocket.disconnect":
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@router.get("/ui/requests", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
300
|
+
async def requests_list(request: Request):
|
|
301
|
+
async with session() as db:
|
|
302
|
+
rows = (await db.execute(select(AccessRequest).order_by(desc(AccessRequest.created_at)).limit(200))).scalars().all()
|
|
303
|
+
return render(request, "requests.html", rows=rows)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@router.get("/ui/requests/{rid}", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
307
|
+
async def request_detail(request: Request, rid: str):
|
|
308
|
+
async with session() as db:
|
|
309
|
+
req = await db.get(AccessRequest, rid)
|
|
310
|
+
if req is None:
|
|
311
|
+
return redirect("/ui", "No such request.")
|
|
312
|
+
grants = (await db.execute(select(Grant).where(Grant.request_id == rid))).scalars().all()
|
|
313
|
+
grant_refs = {f"grant:{g.id}" for g in grants}
|
|
314
|
+
rules = (await db.execute(select(Policy).where(Policy.request_id == rid))).scalars().all()
|
|
315
|
+
refs = grant_refs | {f"policy:{p.id}" for p in rules}
|
|
316
|
+
# Follow recorded authorization references, without inferring downstream effects.
|
|
317
|
+
# The ts index bounds the scan; the text match only narrows it, the filter below decides.
|
|
318
|
+
text = cast(AuditEvent.detail, String)
|
|
319
|
+
events = (await db.execute(select(AuditEvent).where(
|
|
320
|
+
AuditEvent.ts >= req.created_at,
|
|
321
|
+
or_(*(text.contains(ref, autoescape=True) for ref in sorted({rid, *refs}))),
|
|
322
|
+
).order_by(AuditEvent.id))).scalars().all()
|
|
323
|
+
timeline = [e for e in events if (e.detail or {}).get("request_id") == rid
|
|
324
|
+
or rid in ((e.detail or {}).get("request_ids") or [])
|
|
325
|
+
or refs.intersection(((e.detail or {}).get("matched") or {}).values())]
|
|
326
|
+
return render(request, "request_detail.html", req=req, grants=grants, timeline=timeline, rules=rules)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@router.post("/ui/requests/{rid}/decide", dependencies=[Depends(auth.require_admin)])
|
|
330
|
+
async def request_decide(rid: str, scope: str = Form(...), deny_reason: str = Form(""), ttl_minutes: int = Form(60),
|
|
331
|
+
next: str = Form("/ui"), extent: str | None = Form(None)):
|
|
332
|
+
started = now()
|
|
333
|
+
async with session() as db:
|
|
334
|
+
try:
|
|
335
|
+
req = await approvals.decide(db, rid, scope, via="ui", deny_reason=deny_reason,
|
|
336
|
+
ttl_s=ttl_minutes * 60, extent=extent)
|
|
337
|
+
except (LookupError, ValueError) as e:
|
|
338
|
+
return redirect(next, str(e))
|
|
339
|
+
if req.status == "expired":
|
|
340
|
+
return redirect(next, f"Request {req.id} expired before it was decided; nothing was granted.")
|
|
341
|
+
if req.decided_at is None or req.decided_at < started:
|
|
342
|
+
return redirect(next, f"Request {req.id} was already {req.status} ({approvals.decision_label(req)}) "
|
|
343
|
+
f"via {req.decided_via}; this choice was not applied.")
|
|
344
|
+
return redirect(next, f"Request {req.id}: {req.status} ({approvals.decision_label(req)}).")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---- decision links (signed, no login) ----------------------------------------------------------
|
|
348
|
+
@router.get("/d/{token}", response_class=HTMLResponse)
|
|
349
|
+
async def decision_page(request: Request, token: str):
|
|
350
|
+
parsed = approvals.parse_decision_token(token)
|
|
351
|
+
if not parsed:
|
|
352
|
+
return render(request, "decision.html", error="This link is invalid or has expired.")
|
|
353
|
+
rid, scope = parsed
|
|
354
|
+
async with session() as db:
|
|
355
|
+
req = await db.get(AccessRequest, rid)
|
|
356
|
+
if req is None:
|
|
357
|
+
return render(request, "decision.html", error="No such request.")
|
|
358
|
+
return render(request, "decision.html", req=req, scope=scope, token=token)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@router.post("/d/{token}")
|
|
362
|
+
async def decision_submit(request: Request, token: str, deny_reason: str = Form("")):
|
|
363
|
+
parsed = approvals.parse_decision_token(token)
|
|
364
|
+
if not parsed:
|
|
365
|
+
return render(request, "decision.html", error="This link is invalid or has expired.")
|
|
366
|
+
rid, scope = parsed
|
|
367
|
+
async with session() as db:
|
|
368
|
+
try:
|
|
369
|
+
req = await approvals.decide(db, rid, scope, via="link", deny_reason=deny_reason)
|
|
370
|
+
except (LookupError, ValueError) as e:
|
|
371
|
+
return render(request, "decision.html", error=str(e))
|
|
372
|
+
return render(request, "decision.html", req=req, scope=scope, done=True)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---- grants & policies --------------------------------------------------------------------------
|
|
376
|
+
@router.get("/ui/grants", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
377
|
+
async def grants_list(request: Request, show: str = ""):
|
|
378
|
+
history = show == "all"
|
|
379
|
+
async with session() as db:
|
|
380
|
+
q = select(Grant).order_by(desc(Grant.created_at))
|
|
381
|
+
live = q.where(Grant.revoked_at.is_(None), or_(Grant.expires_at.is_(None), Grant.expires_at > now()))
|
|
382
|
+
rows = (await db.execute(q.limit(500) if history else live)).scalars().all()
|
|
383
|
+
agents = {a.id: a for a in (await db.execute(select(Agent))).scalars().all()}
|
|
384
|
+
ids = {g.request_id for g in rows if g.request_id}
|
|
385
|
+
requests = {r.id: r for r in (await db.execute(select(AccessRequest).where(
|
|
386
|
+
AccessRequest.id.in_(ids)))).scalars().all()} if ids else {}
|
|
387
|
+
groups = access_view.grant_groups(rows, agents, requests, now(), include_inactive=history)
|
|
388
|
+
return render(request, "grants.html", groups=groups, history=history,
|
|
389
|
+
active_total=sum(a["active"] for a in groups))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@router.post("/ui/grants/{gid}/revoke", dependencies=[Depends(auth.require_admin)])
|
|
393
|
+
async def grant_revoke(gid: str, next: str = Form("/ui/grants")):
|
|
394
|
+
async with session() as db:
|
|
395
|
+
await approvals.revoke_grant(db, gid)
|
|
396
|
+
return redirect(next, "Grant revoked for future calls. Other grants, rules, or open defaults may still allow access. "
|
|
397
|
+
"Running operations are not stopped.")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@router.post("/ui/requests/{rid}/revoke", dependencies=[Depends(auth.require_admin)])
|
|
401
|
+
async def request_revoke(rid: str, next: str = Form("/ui")):
|
|
402
|
+
async with session() as db:
|
|
403
|
+
count = await approvals.revoke_request_grants(db, rid)
|
|
404
|
+
return redirect(next, f"Revoked {count} grants from this approval. Other permissions may still allow access. "
|
|
405
|
+
"Running operations are not stopped.")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@router.post("/ui/grants/revoke-all", dependencies=[Depends(auth.require_admin)])
|
|
409
|
+
async def grants_revoke_all(agent_id: str = Form(""), next: str = Form("/ui/grants")):
|
|
410
|
+
async with session() as db:
|
|
411
|
+
q = select(Grant).where(Grant.revoked_at.is_(None))
|
|
412
|
+
if agent_id:
|
|
413
|
+
q = q.where(Grant.agent_id == agent_id)
|
|
414
|
+
n = 0
|
|
415
|
+
for g in (await db.execute(q)).scalars().all():
|
|
416
|
+
await approvals.revoke_grant(db, g.id)
|
|
417
|
+
n += 1
|
|
418
|
+
return redirect(next, f"Revoked {n} grants. Standing rules and open defaults remain. Running operations continue.")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@router.get("/ui/policies", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
422
|
+
async def policies_list(request: Request):
|
|
423
|
+
return await _policies_page(request)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def _policies_page(request: Request, *, draft=None, preview=None, error=None):
|
|
427
|
+
async with session() as db:
|
|
428
|
+
rows = (await db.execute(select(Policy).order_by(Policy.effect, Policy.pattern))).scalars().all()
|
|
429
|
+
agents = (await db.execute(select(Agent).order_by(Agent.name))).scalars().all()
|
|
430
|
+
return render(request, "policies.html", rows=rows, agents=agents, agent_map={a.id: a for a in agents},
|
|
431
|
+
draft=draft or {}, preview=preview, error=error)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@router.post("/ui/policies", dependencies=[Depends(auth.require_admin)])
|
|
435
|
+
async def policy_create(request: Request, pattern: str = Form(...), effect: str = Form(...),
|
|
436
|
+
agent_id: str = Form(""), note: str = Form(""), action: str = Form("save")):
|
|
437
|
+
draft = {"pattern": pattern, "effect": effect, "agent_id": agent_id, "note": note}
|
|
438
|
+
try:
|
|
439
|
+
pattern = validate(pattern)
|
|
440
|
+
except ValueError as e:
|
|
441
|
+
return await _policies_page(request, draft=draft, error=f"Invalid pattern: {e}")
|
|
442
|
+
if effect not in ("allow", "deny"):
|
|
443
|
+
return await _policies_page(request, draft=draft, error="Effect must be allow or deny.")
|
|
444
|
+
if action not in ("save", "preview"):
|
|
445
|
+
return await _policies_page(request, draft=draft, error="Unknown rule action.")
|
|
446
|
+
async with session() as db:
|
|
447
|
+
if agent_id and await db.get(Agent, agent_id) is None:
|
|
448
|
+
return await _policies_page(request, draft=draft, error="No such agent.")
|
|
449
|
+
candidate = Policy(id="preview" if action == "preview" else None, pattern=pattern, effect=effect,
|
|
450
|
+
agent_id=agent_id or None, note=note, created_by="ui")
|
|
451
|
+
if action == "preview":
|
|
452
|
+
report = await policy_preview.preview(db, candidate)
|
|
453
|
+
return await _policies_page(request, draft=draft, preview=report)
|
|
454
|
+
db.add(candidate)
|
|
455
|
+
await db.commit()
|
|
456
|
+
return redirect("/ui/policies", f"Rule added: {effect} {pattern}")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@router.post("/ui/policies/{pid}/delete", dependencies=[Depends(auth.require_admin)])
|
|
460
|
+
async def policy_delete(pid: str):
|
|
461
|
+
async with session() as db:
|
|
462
|
+
p = await db.get(Policy, pid)
|
|
463
|
+
if p:
|
|
464
|
+
await db.delete(p)
|
|
465
|
+
await db.commit()
|
|
466
|
+
return redirect("/ui/policies", "Rule deleted.")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ---- agents -------------------------------------------------------------------------------------
|
|
470
|
+
# ---- access page: every tool (or model) of one resource, and one agent's standing on each ---------
|
|
471
|
+
@router.get("/ui/access", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
472
|
+
async def access_page(request: Request, resource: str = "", agent: str = "", scope: str | None = None,
|
|
473
|
+
refresh: str = ""):
|
|
474
|
+
async with session() as db:
|
|
475
|
+
resources = (await db.execute(select(Resource).where(Resource.kind.in_(("mcp", "llm")))
|
|
476
|
+
.order_by(Resource.kind, Resource.name))).scalars().all()
|
|
477
|
+
agents = (await db.execute(select(Agent).order_by(Agent.name))).scalars().all()
|
|
478
|
+
res = next((r for r in resources if r.id == resource), resources[0] if resources else None)
|
|
479
|
+
ag = next((a for a in agents if a.id == agent), None)
|
|
480
|
+
ctx: dict[str, Any] = {"resources": resources, "agents": agents, "res": res, "ag": ag, "groups": {},
|
|
481
|
+
"error": None, "scope": "", "drift": None}
|
|
482
|
+
if res is None:
|
|
483
|
+
return render(request, "access.html", **ctx)
|
|
484
|
+
try:
|
|
485
|
+
rows, scope_text = await _access_rows(res, ag.id if ag else None, scope, force=bool(refresh))
|
|
486
|
+
except ValueError as e:
|
|
487
|
+
rows, scope_text, ctx["error"] = [], scope or "", str(e)
|
|
488
|
+
except Exception as e: # noqa: BLE001
|
|
489
|
+
rows, scope_text, ctx["error"] = [], scope or "", f"Could not list upstream tools: {mcp_proxy.describe(e)}"
|
|
490
|
+
for row in rows:
|
|
491
|
+
ctx["groups"].setdefault(row.group, []).append(row)
|
|
492
|
+
order = {g: i for i, g in enumerate((*tool_access.GROUPS, "models"))}
|
|
493
|
+
ctx["groups"] = dict(sorted(ctx["groups"].items(), key=lambda kv: order.get(kv[0], 99)))
|
|
494
|
+
ctx.update(scope=scope_text, drift=res.config.get("tools_drift"), arg_keys=res.config.get("arg_keys") or [])
|
|
495
|
+
return render(request, "access.html", **ctx)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
async def _access_rows(res: Resource, agent_id: str | None, scope: str | None, *, force: bool = False):
|
|
499
|
+
"""The page's rows, fresh from the upstream. `scope` None means the one last saved for this resource."""
|
|
500
|
+
async with session() as db:
|
|
501
|
+
policies = (await db.execute(select(Policy).where(
|
|
502
|
+
(Policy.agent_id == agent_id) | Policy.agent_id.is_(None)))).scalars().all()
|
|
503
|
+
if res.kind == "llm":
|
|
504
|
+
return tool_access.llm_rows(res, llm.model_paths(res), policies, agent_id), ""
|
|
505
|
+
scope_text = res.config.get("access_scope", "") if scope is None else scope
|
|
506
|
+
parsed = tool_access.parse_scope(scope_text, res.config.get("arg_keys") or [])
|
|
507
|
+
tools = await mcp_proxy.list_tools(res.name, res.config, force=force)
|
|
508
|
+
return tool_access.mcp_rows(res, tools, policies, agent_id, parsed), scope_text
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@router.post("/ui/access", dependencies=[Depends(auth.require_admin)])
|
|
512
|
+
async def access_save(request: Request):
|
|
513
|
+
form = await request.form()
|
|
514
|
+
rid, aid, scope = str(form.get("resource", "")), str(form.get("agent", "")), str(form.get("scope", ""))
|
|
515
|
+
back = f"/ui/access?resource={rid}&agent={aid}"
|
|
516
|
+
async with session() as db:
|
|
517
|
+
res = await db.get(Resource, rid)
|
|
518
|
+
ag = await db.get(Agent, aid) if aid else None
|
|
519
|
+
if res is None or (aid and ag is None):
|
|
520
|
+
return redirect("/ui/access", "No such resource or agent.")
|
|
521
|
+
try:
|
|
522
|
+
rows, _ = await _access_rows(res, ag.id if ag else None, scope)
|
|
523
|
+
except ValueError as e:
|
|
524
|
+
return redirect(back, f"Not saved: {e}")
|
|
525
|
+
except Exception as e: # noqa: BLE001
|
|
526
|
+
return redirect(back, f"Not saved; could not list upstream tools: {mcp_proxy.describe(e)}")
|
|
527
|
+
wanted = {k[2:]: str(v) for k, v in form.items() if k.startswith("s:")}
|
|
528
|
+
async with session() as db:
|
|
529
|
+
policies = (await db.execute(select(Policy).where(
|
|
530
|
+
(Policy.agent_id == (ag.id if ag else None)) if ag else Policy.agent_id.is_(None)))).scalars().all()
|
|
531
|
+
change = tool_access.plan(rows, wanted, policies, ag.id if ag else None)
|
|
532
|
+
for p in change.delete:
|
|
533
|
+
await db.delete(p)
|
|
534
|
+
for pattern, effect in change.add:
|
|
535
|
+
db.add(Policy(agent_id=ag.id if ag else None, pattern=pattern, effect=effect, created_by="ui",
|
|
536
|
+
note="access page"))
|
|
537
|
+
fresh = await db.get(Resource, res.id)
|
|
538
|
+
if fresh.kind == "mcp" and fresh.config.get("access_scope", "") != scope:
|
|
539
|
+
fresh.config = {**fresh.config, "access_scope": scope}
|
|
540
|
+
await db.commit()
|
|
541
|
+
if change:
|
|
542
|
+
await audit.record(db, tool="access.edit", decision="edit", agent=ag,
|
|
543
|
+
detail={"resource": res.name, "every_agent": ag is None,
|
|
544
|
+
"added": [f"{e} {p}" for p, e in change.add],
|
|
545
|
+
"removed": [f"{p.effect} {p.pattern}" for p in change.delete]})
|
|
546
|
+
who = ag.name if ag else "every agent"
|
|
547
|
+
if not change:
|
|
548
|
+
return redirect(back, "No changes.")
|
|
549
|
+
return redirect(back, f"{res.name} for {who}: {len(change.add)} rule(s) added, {len(change.delete)} removed.")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@router.post("/ui/resources/{rid}/tools/accept", dependencies=[Depends(auth.require_admin)])
|
|
553
|
+
async def resource_tools_accept(rid: str, agent: str = Form("")):
|
|
554
|
+
"""The owner has looked at the drift: today's tool list becomes the baseline."""
|
|
555
|
+
async with session() as db:
|
|
556
|
+
res = await db.get(Resource, rid)
|
|
557
|
+
if res is None or res.kind != "mcp":
|
|
558
|
+
return redirect("/ui/access", "No such MCP resource.")
|
|
559
|
+
try:
|
|
560
|
+
tools = await mcp_proxy.list_tools(res.name, res.config, force=True)
|
|
561
|
+
except Exception as e: # noqa: BLE001
|
|
562
|
+
return redirect(f"/ui/access?resource={rid}&agent={agent}", f"Could not list tools: {mcp_proxy.describe(e)}")
|
|
563
|
+
res.config = {**res.config, **tool_access.baseline(tools)}
|
|
564
|
+
await db.commit()
|
|
565
|
+
await audit.record(db, tool="tools.accept", decision="accept", detail={"resource": res.name,
|
|
566
|
+
"tools": len(tools)})
|
|
567
|
+
return redirect(f"/ui/access?resource={rid}&agent={agent}", f"{res.name}: {len(tools)} tools are the new baseline.")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@router.get("/ui/agents", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
571
|
+
async def agents_list(request: Request):
|
|
572
|
+
async with session() as db:
|
|
573
|
+
rows = (await db.execute(select(Agent).order_by(Agent.name))).scalars().all()
|
|
574
|
+
gateways = (await db.execute(select(Resource).where(Resource.kind == "llm").order_by(Resource.name))
|
|
575
|
+
).scalars().all()
|
|
576
|
+
new_token = request.cookies.get("kw_new_token")
|
|
577
|
+
resp = render(request, "agents.html", rows=rows, new_token=new_token, mcp_url=_public_url() + "/mcp",
|
|
578
|
+
gateways=gateways, public_url=_public_url())
|
|
579
|
+
if new_token:
|
|
580
|
+
resp.delete_cookie("kw_new_token")
|
|
581
|
+
return resp
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@router.post("/ui/agents", dependencies=[Depends(auth.require_admin)])
|
|
585
|
+
async def agent_create(name: str = Form(...), note: str = Form("")):
|
|
586
|
+
name = name.strip()
|
|
587
|
+
if not name:
|
|
588
|
+
return redirect("/ui/agents", "Name is required.")
|
|
589
|
+
token = "kw_" + secrets.token_urlsafe(32)
|
|
590
|
+
async with session() as db:
|
|
591
|
+
if (await db.execute(select(Agent).where(Agent.name == name))).scalar_one_or_none():
|
|
592
|
+
return redirect("/ui/agents", "An agent with that name exists.")
|
|
593
|
+
db.add(Agent(name=name, token_hash=hash_token(token), token_prefix=token[:8], note=note))
|
|
594
|
+
await db.commit()
|
|
595
|
+
resp = redirect("/ui/agents", f"Agent {name} created. Copy the token now; it is not shown again.")
|
|
596
|
+
resp.set_cookie("kw_new_token", token, max_age=120, httponly=True, samesite="lax")
|
|
597
|
+
return resp
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@router.post("/ui/agents/{aid}/{action}", dependencies=[Depends(auth.require_admin)])
|
|
601
|
+
async def agent_action(aid: str, action: str, next: str = Form("/ui/agents")):
|
|
602
|
+
async with session() as db:
|
|
603
|
+
a = await db.get(Agent, aid)
|
|
604
|
+
if a is None:
|
|
605
|
+
return redirect("/ui/agents", "No such agent.")
|
|
606
|
+
token = None
|
|
607
|
+
if action == "disable":
|
|
608
|
+
a.disabled_at = now()
|
|
609
|
+
elif action == "enable":
|
|
610
|
+
a.disabled_at = None
|
|
611
|
+
elif action == "rotate":
|
|
612
|
+
token = "kw_" + secrets.token_urlsafe(32)
|
|
613
|
+
a.token_hash, a.token_prefix = hash_token(token), token[:8]
|
|
614
|
+
elif action == "delete":
|
|
615
|
+
await db.delete(a)
|
|
616
|
+
else:
|
|
617
|
+
return redirect("/ui/agents", "Unknown action.")
|
|
618
|
+
if action in ("disable", "delete"):
|
|
619
|
+
# The agent's LLM keys are its authority to spend; they go before the row does.
|
|
620
|
+
await llm.revoke_for_agent(db, a.id, via="ui")
|
|
621
|
+
await db.commit()
|
|
622
|
+
await audit.record(db, tool=f"agent.{action}", decision=action, agent=a,
|
|
623
|
+
detail={"via": "ui"})
|
|
624
|
+
message = f"Agent {a.name}: {action}."
|
|
625
|
+
if action == "disable":
|
|
626
|
+
message += " New operations are blocked. Running operations are not stopped."
|
|
627
|
+
resp = redirect(next, message)
|
|
628
|
+
if token:
|
|
629
|
+
resp.set_cookie("kw_new_token", token, max_age=120, httponly=True, samesite="lax")
|
|
630
|
+
return resp
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ---- resources ----------------------------------------------------------------------------------
|
|
634
|
+
@router.get("/ui/resources", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
635
|
+
async def resources_list(request: Request, sort: str = "type", group: str = "none"):
|
|
636
|
+
async with session() as db:
|
|
637
|
+
data = await inventory.snapshot(db)
|
|
638
|
+
return render(request, "resources.html", **inventory.resource_inventory(data, sort, group))
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@router.get("/ui/resources/new", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
642
|
+
async def resource_new(request: Request, kind: str = "", adapter: str = "", integration: str = "", q: str = ""):
|
|
643
|
+
preset = integrations.PRESET_BY_ID.get(integration or adapter)
|
|
644
|
+
if integration and preset is None:
|
|
645
|
+
return redirect("/ui/resources/new", "Choose an integration from the list.")
|
|
646
|
+
if not kind and not preset:
|
|
647
|
+
terms = q.lower().split()
|
|
648
|
+
groups = [[{"item": item, "matches": all(term in item.search_text for term in terms)} for item in group]
|
|
649
|
+
for group in (integrations.PRESETS, integrations.GENERICS)]
|
|
650
|
+
return render(request, "integration_picker.html", presets=groups[0], generics=groups[1], query=q,
|
|
651
|
+
matches=sum(entry["matches"] for group in groups for entry in group))
|
|
652
|
+
if preset:
|
|
653
|
+
kind, adapter = preset.kind, preset.adapter
|
|
654
|
+
if kind not in ("ssh", "mcp", "llm"):
|
|
655
|
+
return redirect("/ui/resources/new", "Choose a connection type from the list.")
|
|
656
|
+
return render(request, "resource_form.html", res=None, kind=kind, keys_dir=settings.keys_dir,
|
|
657
|
+
preset=preset,
|
|
658
|
+
oauth_callback=settings.public_url.rstrip("/") + mcp_oauth.CALLBACK_PATH,
|
|
659
|
+
inventory_bundles=inventory_mcp.DEFAULT_BUNDLES,
|
|
660
|
+
adapter=adapter if kind == "mcp" and adapter in ("omada", "keywright") else "")
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@router.get("/ui/resources/{rid}", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
664
|
+
async def resource_edit(request: Request, rid: str):
|
|
665
|
+
async with session() as db:
|
|
666
|
+
res = await db.get(Resource, rid)
|
|
667
|
+
link = await db.get(inventory.ResourceLink, rid)
|
|
668
|
+
asset = await db.get(inventory.Asset, link.asset_id) if link else None
|
|
669
|
+
if res is None:
|
|
670
|
+
return redirect("/ui/resources", "No such resource.")
|
|
671
|
+
if res.config.get("adapter") == "knowledge":
|
|
672
|
+
return redirect("/ui/knowledge?collection=" + res.id)
|
|
673
|
+
return render(request, "resource_form.html", res=res, kind=res.kind, keys_dir=settings.keys_dir,
|
|
674
|
+
preset=integrations.for_resource(res),
|
|
675
|
+
inventory_asset=asset,
|
|
676
|
+
oauth_callback=settings.public_url.rstrip("/") + mcp_oauth.CALLBACK_PATH,
|
|
677
|
+
oauth_status=await mcp_oauth.status(res.config) if res.config.get("auth_type") == "oauth" else None,
|
|
678
|
+
adapter=res.config.get("adapter", ""),
|
|
679
|
+
key=key_info(res) if res.kind == "ssh" else None,
|
|
680
|
+
leases=await llm_leases(res) if res.kind == "llm" else None)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
async def llm_leases(res: Resource) -> dict[str, Any]:
|
|
684
|
+
"""Active keys on a gateway with what they have spent, and the exposure they add up to."""
|
|
685
|
+
async with session() as db:
|
|
686
|
+
keys = await llm.active_keys(db, res)
|
|
687
|
+
agents = {a.id: a.name for a in (await db.execute(select(Agent))).scalars().all()}
|
|
688
|
+
spend = {k.id: await llm.key_spend(res.config, k.key) for k in keys}
|
|
689
|
+
return {"leases": [{"row": k, "agent": agents.get(k.agent_id, "?"), "spend": spend[k.id],
|
|
690
|
+
"remaining": access_view.remaining(k.expires_at, now())} for k in keys],
|
|
691
|
+
"exposure": llm.exposure(keys, spend), "health": res.config.get("health") or {},
|
|
692
|
+
"enforced": llm.enforces_budgets(res.config.get("health"))}
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@router.post("/ui/resources", dependencies=[Depends(auth.require_admin)])
|
|
696
|
+
async def resource_save(
|
|
697
|
+
request: Request,
|
|
698
|
+
rid: str = Form(""), kind: str = Form(...), name: str = Form(...), default_mode: str = Form("approval"),
|
|
699
|
+
description: str = Form(""), host: str = Form(""), port: int = Form(22), user: str = Form("root"),
|
|
700
|
+
key_path: str = Form(""), private_key: str = Form(""), url: str = Form(""), headers: str = Form(""),
|
|
701
|
+
forget_host_key: str = Form(""), arg_keys: str = Form(""), audit_handle: str = Form(""),
|
|
702
|
+
bundles_text: str = Form(""), hazard_tools_text: str = Form(""),
|
|
703
|
+
result_inspection_mode: str = Form(""),
|
|
704
|
+
master_key: str = Form(""), models_text: str = Form(""), max_budget: str = Form(""), rpm_limit: str = Form(""),
|
|
705
|
+
tpm_limit: str = Form(""), max_parallel_requests: str = Form(""), agent_max_budget: str = Form(""),
|
|
706
|
+
agent_budget_duration: str = Form("1d"),
|
|
707
|
+
adapter: str = Form(""), omada_id: str = Form(""), client_id: str = Form(""),
|
|
708
|
+
client_secret: str = Form(""), verify_tls: str = Form("1"), ca_bundle: str = Form(""),
|
|
709
|
+
auth_type: str = Form("headers"), oauth_client_id: str = Form(""), oauth_client_secret: str = Form(""),
|
|
710
|
+
oauth_scope: str = Form(""), oauth_auth_method: str = Form("none"), oauth_origins: str = Form(""),
|
|
711
|
+
integration: str = Form(""), setup_action: str = Form(""),
|
|
712
|
+
):
|
|
713
|
+
preset = integrations.PRESET_BY_ID.get(integration)
|
|
714
|
+
if integration and preset is None:
|
|
715
|
+
return redirect("/ui/resources/new", "Choose an integration from the list.")
|
|
716
|
+
if preset:
|
|
717
|
+
kind, adapter = preset.kind, preset.adapter
|
|
718
|
+
if preset.url:
|
|
719
|
+
url, auth_type = preset.url, preset.auth_type
|
|
720
|
+
if setup_action and (setup_action != "connect" or not preset or preset.auth_type != "oauth"):
|
|
721
|
+
return redirect("/ui/resources/new", "Invalid integration setup action.")
|
|
722
|
+
name = name.strip()
|
|
723
|
+
if not name or not all(c.isalnum() or c in "_.-" for c in name):
|
|
724
|
+
return redirect("/ui/resources", "Name must be alphanumeric with _ . - only.")
|
|
725
|
+
if kind not in ("ssh", "mcp", "llm") or default_mode not in ("open", "approval", "locked"):
|
|
726
|
+
return redirect("/ui/resources", "Invalid kind or mode.")
|
|
727
|
+
if kind == "llm":
|
|
728
|
+
try:
|
|
729
|
+
limits = {"max_budget": _money(max_budget, required=True), "rpm_limit": _count(rpm_limit),
|
|
730
|
+
"tpm_limit": _count(tpm_limit), "max_parallel_requests": _count(max_parallel_requests)}
|
|
731
|
+
agent_budget = {"max_budget": _money(agent_max_budget, required=True),
|
|
732
|
+
"budget_duration": agent_budget_duration}
|
|
733
|
+
except ValueError as e:
|
|
734
|
+
return redirect(f"/ui/resources/{rid}" if rid else "/ui/resources/new?kind=llm", f"Not saved: {e}")
|
|
735
|
+
if agent_budget_duration not in llm.BUDGET_DURATIONS:
|
|
736
|
+
return redirect("/ui/resources", "Agent budget window must be one of " + ", ".join(llm.BUDGET_DURATIONS))
|
|
737
|
+
async with session() as db:
|
|
738
|
+
res = await db.get(Resource, rid) if rid else None
|
|
739
|
+
if res is None:
|
|
740
|
+
if (await db.execute(select(Resource).where(Resource.name == name))).scalar_one_or_none():
|
|
741
|
+
return redirect("/ui/resources", "A resource with that name exists.")
|
|
742
|
+
res = Resource(kind=kind, name=name, config={})
|
|
743
|
+
db.add(res)
|
|
744
|
+
cfg = dict(res.config or {})
|
|
745
|
+
if cfg.get("integration") and cfg["integration"] != integration:
|
|
746
|
+
return redirect(f"/ui/resources/{res.id}", "Keep this resource's integration when saving its settings.")
|
|
747
|
+
if preset:
|
|
748
|
+
cfg["integration"] = preset.id
|
|
749
|
+
if res.id and (kind != res.kind or adapter != cfg.get("adapter", "")):
|
|
750
|
+
return redirect("/ui/resources", "Create a new resource to change its connection type.")
|
|
751
|
+
if adapter not in ("", "omada", "keywright") or (adapter and kind != "mcp"):
|
|
752
|
+
return redirect("/ui/resources", "Invalid connection type.")
|
|
753
|
+
if kind == "ssh":
|
|
754
|
+
if private_key.strip():
|
|
755
|
+
settings.ensure_dirs()
|
|
756
|
+
p = settings.keys_dir / name
|
|
757
|
+
p.write_text(private_key.strip() + "\n")
|
|
758
|
+
p.chmod(0o600)
|
|
759
|
+
key_path = str(p)
|
|
760
|
+
cfg.update({"host": host.strip(), "port": int(port or 22), "user": user.strip() or "root",
|
|
761
|
+
"key_path": key_path.strip() or cfg.get("key_path", "")})
|
|
762
|
+
if forget_host_key:
|
|
763
|
+
cfg.pop("host_key", None)
|
|
764
|
+
elif kind == "llm":
|
|
765
|
+
models = [m.strip() for m in models_text.replace("\n", ",").split(",") if m.strip()]
|
|
766
|
+
cfg.update({"url": url.strip().rstrip("/"),
|
|
767
|
+
"master_key": cfg.get("master_key", "") if master_key.strip() in ("", "***")
|
|
768
|
+
else master_key.strip(),
|
|
769
|
+
"models": models, "limits": limits, "agent_budget": agent_budget})
|
|
770
|
+
else:
|
|
771
|
+
inspection_mode = result_inspection_mode or cfg.get("result_inspection", "off")
|
|
772
|
+
if inspection_mode not in result_inspection.MODES:
|
|
773
|
+
return redirect("/ui/resources", "Invalid result inspection mode.")
|
|
774
|
+
hdrs: dict[str, str] = {}
|
|
775
|
+
for line in headers.splitlines():
|
|
776
|
+
if ":" in line:
|
|
777
|
+
k, v = line.split(":", 1)
|
|
778
|
+
hdrs[k.strip()] = v.strip()
|
|
779
|
+
existing = cfg.get("headers") or {}
|
|
780
|
+
# keep a stored secret if the form shows the placeholder
|
|
781
|
+
for k, v in hdrs.items():
|
|
782
|
+
if v == "***" and k in existing:
|
|
783
|
+
hdrs[k] = existing[k]
|
|
784
|
+
keys = [k.strip() for k in arg_keys.replace("\n", ",").split(",") if k.strip()]
|
|
785
|
+
if any(not all(c.isalnum() or c in "_.-" for c in k) for k in keys):
|
|
786
|
+
return redirect("/ui/resources", "Argument keys must be dotted names, e.g. service, data.entity_id.")
|
|
787
|
+
try:
|
|
788
|
+
parsed_bundles = bundles.parse(bundles_text, name)
|
|
789
|
+
except ValueError as e:
|
|
790
|
+
return redirect(f"/ui/resources/{res.id}" if res.id else "/ui/resources", f"Bundles not saved: {e}")
|
|
791
|
+
try:
|
|
792
|
+
parsed_hazards = bundles.parse(hazard_tools_text, name)
|
|
793
|
+
except ValueError as e:
|
|
794
|
+
return redirect(f"/ui/resources/{res.id}" if res.id else "/ui/resources", f"Hazard catalog not saved: {e}")
|
|
795
|
+
if any(sev not in SEVERITIES for sev in parsed_hazards):
|
|
796
|
+
return redirect(f"/ui/resources/{res.id}" if res.id else "/ui/resources",
|
|
797
|
+
f"Hazard catalog lines must start with one of {', '.join(SEVERITIES)}.")
|
|
798
|
+
cfg.update({"url": url.strip(), "headers": hdrs, "arg_keys": keys, "audit_handle": audit_handle == "1",
|
|
799
|
+
"bundles": parsed_bundles, "hazard_tools": parsed_hazards,
|
|
800
|
+
"result_inspection": inspection_mode})
|
|
801
|
+
if not adapter:
|
|
802
|
+
if auth_type not in ("headers", "oauth"):
|
|
803
|
+
return redirect("/ui/resources", "Invalid MCP authentication method.")
|
|
804
|
+
cfg["auth_type"] = auth_type
|
|
805
|
+
if auth_type == "oauth":
|
|
806
|
+
cfg["headers"] = {k: v for k, v in hdrs.items() if k.lower() != "authorization"}
|
|
807
|
+
cfg["oauth"] = {"client_id": oauth_client_id.strip(), "scope": oauth_scope.strip(),
|
|
808
|
+
"token_endpoint_auth_method": oauth_auth_method,
|
|
809
|
+
"origins": [v.strip().rstrip("/") for v in
|
|
810
|
+
oauth_origins.replace("\n", ",").split(",") if v.strip()]}
|
|
811
|
+
try:
|
|
812
|
+
await mcp_oauth.configure(db, res, cfg, secret=oauth_client_secret.strip())
|
|
813
|
+
except mcp_oauth.ConnectionError as exc:
|
|
814
|
+
return redirect(f"/ui/resources/{res.id}" if res.id else "/ui/resources", str(exc))
|
|
815
|
+
if adapter == "keywright":
|
|
816
|
+
cfg.update({"adapter": "keywright"})
|
|
817
|
+
cfg.pop("url", None)
|
|
818
|
+
cfg.pop("headers", None)
|
|
819
|
+
if adapter == "omada":
|
|
820
|
+
if verify_tls not in ("0", "1"):
|
|
821
|
+
return redirect("/ui/resources", "Invalid TLS verification setting.")
|
|
822
|
+
cfg.update({"adapter": "omada", "url": url.strip().rstrip("/"),
|
|
823
|
+
"omada_id": omada_id.strip(), "client_id": client_id.strip(),
|
|
824
|
+
"client_secret": cfg.get("client_secret", "") if client_secret.strip() in ("", "***")
|
|
825
|
+
else client_secret.strip(), "verify_tls": verify_tls == "1",
|
|
826
|
+
"ca_bundle": ca_bundle.strip()})
|
|
827
|
+
try:
|
|
828
|
+
omada.validate_config(cfg)
|
|
829
|
+
except ValueError as e:
|
|
830
|
+
return redirect(f"/ui/resources/{res.id}" if res.id else
|
|
831
|
+
"/ui/resources/new?kind=mcp&adapter=omada", f"Not saved: {e}")
|
|
832
|
+
mcp_proxy.forget(name)
|
|
833
|
+
res.name, res.default_mode, res.description, res.config = name, default_mode, description, cfg
|
|
834
|
+
await db.commit()
|
|
835
|
+
rid = res.id
|
|
836
|
+
if setup_action == "connect":
|
|
837
|
+
return await mcp_oauth_ui.connect(request, rid)
|
|
838
|
+
return redirect(f"/ui/resources/{rid}", "Saved.")
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _money(text: str, *, required: bool = False) -> float | None:
|
|
842
|
+
text = text.strip().lstrip("$")
|
|
843
|
+
if not text:
|
|
844
|
+
if required:
|
|
845
|
+
raise ValueError("a spend ceiling in USD is required; it is the whole point")
|
|
846
|
+
return None
|
|
847
|
+
try:
|
|
848
|
+
value = float(text)
|
|
849
|
+
except ValueError:
|
|
850
|
+
raise ValueError(f"{text!r} is not an amount in USD") from None
|
|
851
|
+
if value <= 0 or value > 100000:
|
|
852
|
+
raise ValueError("a spend ceiling must be more than $0 and at most $100000")
|
|
853
|
+
return round(value, 4)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _count(text: str) -> int | None:
|
|
857
|
+
text = text.strip()
|
|
858
|
+
if not text:
|
|
859
|
+
return None
|
|
860
|
+
if not text.isdigit() or int(text) <= 0:
|
|
861
|
+
raise ValueError(f"{text!r} is not a positive whole number")
|
|
862
|
+
return int(text)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
@router.post("/ui/resources/{rid}/suggest-bundles", dependencies=[Depends(auth.require_admin)])
|
|
866
|
+
async def resource_suggest_bundles(rid: str):
|
|
867
|
+
async with session() as db:
|
|
868
|
+
res = await db.get(Resource, rid)
|
|
869
|
+
if res is None or res.kind != "mcp":
|
|
870
|
+
return redirect("/ui/resources", "No such MCP resource.")
|
|
871
|
+
try:
|
|
872
|
+
tools = await mcp_proxy.list_tools(res.name, res.config, force=True)
|
|
873
|
+
except Exception as e: # noqa: BLE001
|
|
874
|
+
return redirect(f"/ui/resources/{rid}", f"Could not list upstream tools: {type(e).__name__}: {e}")
|
|
875
|
+
suggested = bundles.suggest([t.name for t in tools])
|
|
876
|
+
existing = dict(res.config.get("bundles") or {})
|
|
877
|
+
added = {n: es for n, es in suggested.items() if n not in existing}
|
|
878
|
+
res.config = {**res.config, "bundles": {**existing, **added}}
|
|
879
|
+
await db.commit()
|
|
880
|
+
return redirect(f"/ui/resources/{rid}", f"Suggested {len(added)} bundle(s) from {len(tools)} tools by verb; "
|
|
881
|
+
"review and edit them, they are a starting point, not a policy.")
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@router.post("/ui/resources/{rid}/probe", dependencies=[Depends(auth.require_admin)])
|
|
885
|
+
async def resource_probe(rid: str):
|
|
886
|
+
async with session() as db:
|
|
887
|
+
res = await db.get(Resource, rid)
|
|
888
|
+
if res is None:
|
|
889
|
+
return redirect("/ui/resources", "No such resource.")
|
|
890
|
+
if res.kind == "ssh":
|
|
891
|
+
ok, msg, learned = await ssh.probe(res.config)
|
|
892
|
+
if learned:
|
|
893
|
+
res.config = {**res.config, "host_key": learned}
|
|
894
|
+
await db.commit()
|
|
895
|
+
msg += " Host key pinned."
|
|
896
|
+
elif res.kind == "llm":
|
|
897
|
+
ok, msg, health = await llm.probe(res.config)
|
|
898
|
+
models = health.pop("models", None)
|
|
899
|
+
cfg = {**res.config, "health": health}
|
|
900
|
+
if models is not None:
|
|
901
|
+
cfg["models"] = models
|
|
902
|
+
msg += " Model list refreshed."
|
|
903
|
+
res.config = cfg
|
|
904
|
+
await db.commit()
|
|
905
|
+
else:
|
|
906
|
+
ok, msg = await mcp_proxy.probe(res.config)
|
|
907
|
+
return redirect(f"/ui/resources/{rid}", ("OK: " if ok else "FAILED: ") + msg)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
@router.post("/ui/resources/{rid}/delete", dependencies=[Depends(auth.require_admin)])
|
|
911
|
+
async def resource_delete(rid: str):
|
|
912
|
+
async with session() as db:
|
|
913
|
+
res = await db.get(Resource, rid)
|
|
914
|
+
removed = False
|
|
915
|
+
if res:
|
|
916
|
+
kp = (res.config or {}).get("key_path") or ""
|
|
917
|
+
if res.kind == "llm":
|
|
918
|
+
await llm.revoke_for_resource(db, res, via="ui")
|
|
919
|
+
if res.config.get("oauth_id"):
|
|
920
|
+
await mcp_oauth.configure(db, res, {"auth_type": "headers"})
|
|
921
|
+
if res.kind == "mcp":
|
|
922
|
+
mcp_proxy.forget(res.name)
|
|
923
|
+
await db.delete(res)
|
|
924
|
+
await db.commit()
|
|
925
|
+
# A key Keywright keeps for this resource goes with it, or the name can never be keyed
|
|
926
|
+
# again. Keys outside keys_dir, or still used by another resource, are the admin's.
|
|
927
|
+
others = (await db.execute(select(Resource))).scalars().all()
|
|
928
|
+
if (kp and Path(kp).resolve().parent == settings.keys_dir.resolve()
|
|
929
|
+
and not any((o.config or {}).get("key_path") == kp for o in others)):
|
|
930
|
+
for f in (Path(kp), Path(kp + ".pub")):
|
|
931
|
+
removed = removed or f.exists()
|
|
932
|
+
f.unlink(missing_ok=True)
|
|
933
|
+
return redirect("/ui/resources", "Resource deleted, with its key on this Keywright." if removed
|
|
934
|
+
else "Resource deleted.")
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# ---- SSH key generation & host enrolment --------------------------------------------------------
|
|
938
|
+
def _enroll_serializer() -> URLSafeTimedSerializer:
|
|
939
|
+
return URLSafeTimedSerializer(settings.secret_key(), salt="enroll")
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def key_info(res: Resource) -> dict[str, Any]:
|
|
943
|
+
"""What the resource form shows about the key: exists? public line? enrol one-liner."""
|
|
944
|
+
kp = (res.config or {}).get("key_path") or ""
|
|
945
|
+
info: dict[str, Any] = {"key_path": kp, "exists": bool(kp) and os.path.exists(kp), "pub": ""}
|
|
946
|
+
if info["exists"]:
|
|
947
|
+
pub = kp + ".pub"
|
|
948
|
+
if os.path.exists(pub):
|
|
949
|
+
info["pub"] = Path(pub).read_text().strip()
|
|
950
|
+
if info["pub"]:
|
|
951
|
+
tok = _enroll_serializer().dumps({"r": res.id})
|
|
952
|
+
info["enroll_cmd"] = f"curl -fsSL {shlex.quote(_public_url() + '/enroll/' + tok)} | sh"
|
|
953
|
+
return info
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
@router.post("/ui/resources/{rid}/keygen", dependencies=[Depends(auth.require_admin)])
|
|
957
|
+
async def resource_keygen(rid: str):
|
|
958
|
+
async with session() as db:
|
|
959
|
+
res = await db.get(Resource, rid)
|
|
960
|
+
if res is None or res.kind != "ssh":
|
|
961
|
+
return redirect("/ui/resources", "No such SSH resource.")
|
|
962
|
+
try:
|
|
963
|
+
priv, _ = ssh.generate_key(res.name, settings.keys_dir)
|
|
964
|
+
except FileExistsError as e:
|
|
965
|
+
return redirect(f"/ui/resources/{rid}", f"Not overwriting: {e}. Delete it on the container first.")
|
|
966
|
+
res.config = {**res.config, "key_path": priv}
|
|
967
|
+
await db.commit()
|
|
968
|
+
return redirect(f"/ui/resources/{rid}", "Key generated on this Keywright. Now enrol it on the host.")
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
@router.post("/ui/resources/{rid}/enroll", dependencies=[Depends(auth.require_admin)])
|
|
972
|
+
async def resource_enroll(rid: str, password: str = Form(...), restrict: str = Form(""), login_user: str = Form(""),
|
|
973
|
+
escalate: str = Form(""), root_password: str = Form("")):
|
|
974
|
+
async with session() as db:
|
|
975
|
+
res = await db.get(Resource, rid)
|
|
976
|
+
if res is None or res.kind != "ssh":
|
|
977
|
+
return redirect("/ui/resources", "No such SSH resource.")
|
|
978
|
+
info = key_info(res)
|
|
979
|
+
if not info["pub"]:
|
|
980
|
+
return redirect(f"/ui/resources/{rid}", "Generate a key first.")
|
|
981
|
+
ok, msg, learned = await ssh.enroll_with_password(
|
|
982
|
+
res.config, password, info["pub"], restrict=bool(restrict), login_user=login_user,
|
|
983
|
+
escalate=escalate or None, root_password=root_password or None)
|
|
984
|
+
if learned:
|
|
985
|
+
res.config = {**res.config, "host_key": learned}
|
|
986
|
+
await db.commit()
|
|
987
|
+
await audit.record(db, tool="ssh.enroll", decision="allow" if ok else "error",
|
|
988
|
+
patterns=[f"ssh/{res.name}"], detail={"host": res.config.get("host"), "message": msg})
|
|
989
|
+
return redirect(f"/ui/resources/{rid}", ("Enrolled: " if ok else "Enrol failed: ") + msg)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
@router.get("/enroll/{token}", response_class=PlainTextResponse)
|
|
993
|
+
async def enroll_script(token: str):
|
|
994
|
+
"""Shell script that installs this resource's public key for the invoking user. The URL is a
|
|
995
|
+
signed 30-minute token, so it can be pasted into a terminal without logging into the UI."""
|
|
996
|
+
try:
|
|
997
|
+
data = _enroll_serializer().loads(token, max_age=1800)
|
|
998
|
+
except (BadSignature, SignatureExpired):
|
|
999
|
+
return PlainTextResponse("echo 'keywright: enrol link invalid or expired' >&2; exit 1\n", status_code=404)
|
|
1000
|
+
async with session() as db:
|
|
1001
|
+
res = await db.get(Resource, data.get("r", ""))
|
|
1002
|
+
src = (await get_setting(db, "general", {})).get("outbound_ip") or None
|
|
1003
|
+
if res is None or res.kind != "ssh":
|
|
1004
|
+
return PlainTextResponse("echo 'keywright: unknown resource' >&2; exit 1\n", status_code=404)
|
|
1005
|
+
info = key_info(res)
|
|
1006
|
+
if not info["pub"]:
|
|
1007
|
+
return PlainTextResponse("echo 'keywright: no key generated yet' >&2; exit 1\n", status_code=404)
|
|
1008
|
+
return ssh.enroll_script(info["pub"], src)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
# ---- settings -----------------------------------------------------------------------------------
|
|
1012
|
+
def _public_url() -> str:
|
|
1013
|
+
return approvals.public_url()
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
@router.get("/ui/settings", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
1017
|
+
async def settings_page(request: Request):
|
|
1018
|
+
async with session() as db:
|
|
1019
|
+
general = await get_setting(db, "general", {"public_url": settings.public_url})
|
|
1020
|
+
notif = await get_setting(db, "notifications", {"driver": "log", "config": {}, "buttons": DEFAULT_BUTTONS})
|
|
1021
|
+
jev = await semantic.config(db)
|
|
1022
|
+
jev_tally = await semantic.shadow_tally(db)
|
|
1023
|
+
driver = registry.get(notif.get("driver") or "log", registry["log"])
|
|
1024
|
+
return render(request, "settings.html", general=general, notif=notif, drivers=registry, driver=driver,
|
|
1025
|
+
probe=request.cookies.get("kw_probe"), notifier=manager, default_buttons=DEFAULT_BUTTONS,
|
|
1026
|
+
jev=jev, jev_tally=jev_tally, jev_configured=semantic.configured(),
|
|
1027
|
+
jev_trust_selectable=semantic.TRUST_SELECTABLE)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
@router.post("/ui/settings/semantic", dependencies=[Depends(auth.require_admin)])
|
|
1031
|
+
async def settings_semantic(request: Request):
|
|
1032
|
+
form = await request.form()
|
|
1033
|
+
try:
|
|
1034
|
+
value = semantic.validate_config({k: form.get(k) for k in ("mode", "timeout_s", "daily_cap")})
|
|
1035
|
+
except ValueError as e:
|
|
1036
|
+
return redirect("/ui/settings", str(e))
|
|
1037
|
+
async with session() as db:
|
|
1038
|
+
await put_setting(db, "semantic", value)
|
|
1039
|
+
return redirect("/ui/settings", "Saved.")
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
@router.post("/ui/settings/general", dependencies=[Depends(auth.require_admin)])
|
|
1043
|
+
async def settings_general(public_url: str = Form(""), outbound_ip: str = Form("")):
|
|
1044
|
+
if outbound_ip.strip():
|
|
1045
|
+
try:
|
|
1046
|
+
ipaddress.ip_address(outbound_ip.strip())
|
|
1047
|
+
except ValueError:
|
|
1048
|
+
return redirect("/ui/settings", "Outbound IP must be one IPv4 or IPv6 address.")
|
|
1049
|
+
async with session() as db:
|
|
1050
|
+
await put_setting(db, "general", {"public_url": public_url.strip().rstrip("/"), "outbound_ip": outbound_ip.strip()})
|
|
1051
|
+
return redirect("/ui/settings", "Saved.")
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
async def _read_notif_form(request: Request) -> dict[str, Any]:
|
|
1055
|
+
form = await request.form()
|
|
1056
|
+
driver_id = str(form.get("driver") or "log")
|
|
1057
|
+
cls = registry.get(driver_id, registry["log"])
|
|
1058
|
+
async with session() as db:
|
|
1059
|
+
current = await get_setting(db, "notifications", {})
|
|
1060
|
+
old_cfg = (current.get("config") or {}) if current.get("driver") == driver_id else {}
|
|
1061
|
+
cfg: dict[str, Any] = {}
|
|
1062
|
+
for f in cls.fields:
|
|
1063
|
+
if f.type == "multiselect":
|
|
1064
|
+
cfg[f.name] = [str(v) for v in form.getlist(f.name)]
|
|
1065
|
+
elif f.type == "checkbox":
|
|
1066
|
+
cfg[f.name] = form.get(f.name) == "on"
|
|
1067
|
+
else:
|
|
1068
|
+
val = str(form.get(f.name) or "").strip()
|
|
1069
|
+
if f.type == "password" and val == "***":
|
|
1070
|
+
val = old_cfg.get(f.name, "")
|
|
1071
|
+
cfg[f.name] = val
|
|
1072
|
+
buttons = [b for b in form.getlist("buttons") if b in approvals.PHONE_SCOPES] or DEFAULT_BUTTONS
|
|
1073
|
+
return {"driver": driver_id, "config": cfg, "buttons": buttons, "choices": current.get("choices") or {}}
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
@router.post("/ui/settings/notifications", dependencies=[Depends(auth.require_admin)])
|
|
1077
|
+
async def settings_notifications(request: Request):
|
|
1078
|
+
cfg = await _read_notif_form(request)
|
|
1079
|
+
async with session() as db:
|
|
1080
|
+
await put_setting(db, "notifications", cfg)
|
|
1081
|
+
await manager.reload()
|
|
1082
|
+
return redirect("/ui/settings", f"Notifications saved: {registry[cfg['driver']].label}.")
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
@router.post("/ui/settings/notifications/probe", dependencies=[Depends(auth.require_admin)])
|
|
1086
|
+
async def settings_notifications_probe(request: Request):
|
|
1087
|
+
cfg = await _read_notif_form(request)
|
|
1088
|
+
cls = registry[cfg["driver"]]
|
|
1089
|
+
result = await cls(cfg["config"]).probe()
|
|
1090
|
+
if result.ok:
|
|
1091
|
+
cfg["choices"] = {k: [list(x) for x in v] for k, v in result.choices.items()}
|
|
1092
|
+
async with session() as db:
|
|
1093
|
+
await put_setting(db, "notifications", cfg)
|
|
1094
|
+
await manager.reload()
|
|
1095
|
+
resp = redirect("/ui/settings", ("Probe OK: " if result.ok else "Probe failed: ") + result.message)
|
|
1096
|
+
return resp
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
@router.post("/ui/settings/notifications/test", dependencies=[Depends(auth.require_admin)])
|
|
1100
|
+
async def settings_notifications_test():
|
|
1101
|
+
async with session() as db:
|
|
1102
|
+
agent = (await db.execute(select(Agent).order_by(Agent.created_at).limit(1))).scalar_one_or_none()
|
|
1103
|
+
if agent is None:
|
|
1104
|
+
return redirect("/ui/settings", "Create an agent first; the test sends a real request in its name.")
|
|
1105
|
+
req = await approvals.create(db, agent, None, ["ssh/example/exec/uptime"],
|
|
1106
|
+
"Test notification from Keywright settings", {"command": "uptime"})
|
|
1107
|
+
req.expires_at = now() + timedelta(minutes=10)
|
|
1108
|
+
await db.commit()
|
|
1109
|
+
err = manager.last_error
|
|
1110
|
+
return redirect("/ui/settings", f"Test request {req.id} sent via {manager.driver_id}." + (f" ERROR: {err}" if err else ""))
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
# ---- audit --------------------------------------------------------------------------------------
|
|
1114
|
+
@router.get("/ui/audit", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
|
|
1115
|
+
async def audit_list(request: Request, q: str = "", limit: int = 200):
|
|
1116
|
+
async with session() as db:
|
|
1117
|
+
stmt = select(AuditEvent).order_by(desc(AuditEvent.id)).limit(min(limit, 1000))
|
|
1118
|
+
if q.startswith("#") and q[1:].isdigit():
|
|
1119
|
+
# "audit #id" links must resolve however old the event is.
|
|
1120
|
+
stmt = select(AuditEvent).where(AuditEvent.id == int(q[1:]))
|
|
1121
|
+
rows = (await db.execute(stmt)).scalars().all()
|
|
1122
|
+
if q:
|
|
1123
|
+
ql = q.lower()
|
|
1124
|
+
rows = [r for r in rows if ql in (r.agent_name + r.tool + r.decision + " ".join(r.patterns) + str(r.detail)).lower()
|
|
1125
|
+
or ql.lstrip("#") == str(r.id)]
|
|
1126
|
+
return render(request, "audit.html", rows=rows, q=q)
|