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,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
|
11
|
+
from mcp.server.auth.provider import AccessToken
|
|
12
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
13
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
14
|
+
from starlette.routing import Route
|
|
15
|
+
|
|
16
|
+
from .. import __version__, approvals, llm_proxy, mail, nudges, tool_access
|
|
17
|
+
from ..config import settings
|
|
18
|
+
from ..db import init_db, session
|
|
19
|
+
from ..mcp_server import build_server, resolve_agent
|
|
20
|
+
from ..notify.manager import manager
|
|
21
|
+
from ..settings_store import warm
|
|
22
|
+
from . import (
|
|
23
|
+
admin,
|
|
24
|
+
discovery_ui,
|
|
25
|
+
friction_ui,
|
|
26
|
+
inventory_ui,
|
|
27
|
+
knowledge_ui,
|
|
28
|
+
mail_ui,
|
|
29
|
+
mcp_oauth_ui,
|
|
30
|
+
network_map_ui,
|
|
31
|
+
oidc,
|
|
32
|
+
usage_ui,
|
|
33
|
+
)
|
|
34
|
+
from .auth import password_is_set, set_password
|
|
35
|
+
from .security import BrowserSecurityHeaders
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger("keywright")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MCPAuthASGI:
|
|
41
|
+
"""Rejects MCP traffic that doesn't carry a valid agent bearer token, then hands off to the SDK."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, session_manager: StreamableHTTPSessionManager):
|
|
44
|
+
self.sm = session_manager
|
|
45
|
+
|
|
46
|
+
async def __call__(self, scope, receive, send):
|
|
47
|
+
if scope["type"] != "http":
|
|
48
|
+
await send({"type": "http.response.start", "status": 404, "headers": []})
|
|
49
|
+
await send({"type": "http.response.body", "body": b""})
|
|
50
|
+
return
|
|
51
|
+
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
|
52
|
+
async with session() as db:
|
|
53
|
+
agent = await resolve_agent(db, headers)
|
|
54
|
+
if agent is None:
|
|
55
|
+
# A JSON-RPC error body: MCP SDK clients surface its message instead of a generic
|
|
56
|
+
# "server returned an error", so an agent learns it is the token that is wrong.
|
|
57
|
+
resp = JSONResponse({"jsonrpc": "2.0", "id": None,
|
|
58
|
+
"error": {"code": -32001, "message": "unauthorized: Authorization: Bearer <agent token>",
|
|
59
|
+
"data": {"error": "unauthorized"}}},
|
|
60
|
+
status_code=401, headers={"WWW-Authenticate": "Bearer"})
|
|
61
|
+
await resp(scope, receive, send)
|
|
62
|
+
return
|
|
63
|
+
# Give the SDK a principal so GET/POST/DELETE sessions cannot be shared across agents/tokens.
|
|
64
|
+
scope = dict(scope)
|
|
65
|
+
scope["user"] = AuthenticatedUser(AccessToken(
|
|
66
|
+
token=agent.token_hash, client_id=agent.id + ":" + agent.token_hash, scopes=[],
|
|
67
|
+
))
|
|
68
|
+
await self.sm.handle_request(scope, receive, send)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _janitor() -> None:
|
|
72
|
+
while True:
|
|
73
|
+
await asyncio.sleep(60)
|
|
74
|
+
try:
|
|
75
|
+
async with session() as db:
|
|
76
|
+
n = await approvals.expire_stale(db)
|
|
77
|
+
if n:
|
|
78
|
+
log.info("expired %d stale requests", n)
|
|
79
|
+
n = await nudges.expire_stale(db)
|
|
80
|
+
if n:
|
|
81
|
+
log.info("cleared %d nudges nobody answered", n)
|
|
82
|
+
except Exception:
|
|
83
|
+
log.exception("janitor failed")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
TOOL_WATCH_S = 3600
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _tool_watch() -> None:
|
|
90
|
+
"""Hourly: what each MCP server offers, against the baseline the owner accepted."""
|
|
91
|
+
await asyncio.sleep(30)
|
|
92
|
+
while True:
|
|
93
|
+
try:
|
|
94
|
+
async with session() as db:
|
|
95
|
+
await tool_access.watch_once(db)
|
|
96
|
+
except Exception:
|
|
97
|
+
log.exception("tool watch failed")
|
|
98
|
+
await asyncio.sleep(TOOL_WATCH_S)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_app() -> FastAPI:
|
|
102
|
+
server = build_server()
|
|
103
|
+
sm = StreamableHTTPSessionManager(
|
|
104
|
+
app=server,
|
|
105
|
+
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@contextlib.asynccontextmanager
|
|
109
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
110
|
+
logging.basicConfig(level=settings.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
111
|
+
await init_db()
|
|
112
|
+
async with session() as db:
|
|
113
|
+
await warm(db)
|
|
114
|
+
if settings.auth_mode != "oidc" and settings.admin_password and not password_is_set():
|
|
115
|
+
await set_password(db, settings.admin_password)
|
|
116
|
+
log.info("admin password seeded from KW_ADMIN_PASSWORD")
|
|
117
|
+
await manager.reload()
|
|
118
|
+
janitor = asyncio.create_task(_janitor(), name="janitor")
|
|
119
|
+
watch = asyncio.create_task(_tool_watch(), name="tool-watch")
|
|
120
|
+
mail_worker = asyncio.create_task(mail.worker(), name="mail-inbox")
|
|
121
|
+
async with sm.run():
|
|
122
|
+
log.info("Keywright listening; MCP at /mcp, admin at /ui")
|
|
123
|
+
try:
|
|
124
|
+
yield
|
|
125
|
+
finally:
|
|
126
|
+
janitor.cancel()
|
|
127
|
+
watch.cancel()
|
|
128
|
+
mail_worker.cancel()
|
|
129
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
130
|
+
await mail_worker
|
|
131
|
+
await manager.stop()
|
|
132
|
+
|
|
133
|
+
app = FastAPI(title="Keywright", version=__version__, lifespan=lifespan, docs_url=None, redoc_url=None)
|
|
134
|
+
oidc.configure(app)
|
|
135
|
+
app.add_middleware(BrowserSecurityHeaders)
|
|
136
|
+
app.include_router(admin.router)
|
|
137
|
+
app.include_router(oidc.router)
|
|
138
|
+
app.include_router(mcp_oauth_ui.router)
|
|
139
|
+
app.include_router(inventory_ui.router)
|
|
140
|
+
app.include_router(knowledge_ui.router)
|
|
141
|
+
app.include_router(network_map_ui.router)
|
|
142
|
+
app.include_router(discovery_ui.router)
|
|
143
|
+
app.include_router(usage_ui.router)
|
|
144
|
+
app.include_router(friction_ui.router)
|
|
145
|
+
app.include_router(mail_ui.router)
|
|
146
|
+
app.include_router(admin.ws_router)
|
|
147
|
+
app.include_router(llm_proxy.router)
|
|
148
|
+
app.router.routes.append(Route("/mcp", endpoint=MCPAuthASGI(sm), methods=["GET", "POST", "DELETE"]))
|
|
149
|
+
|
|
150
|
+
@app.get("/healthz")
|
|
151
|
+
async def healthz():
|
|
152
|
+
return {"ok": True, "notifier": manager.driver_id, "version": __version__}
|
|
153
|
+
|
|
154
|
+
return app
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Admin authentication: local password or an OIDC browser session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from collections import OrderedDict, deque
|
|
10
|
+
|
|
11
|
+
from fastapi import HTTPException, Request
|
|
12
|
+
from fastapi.responses import RedirectResponse
|
|
13
|
+
from itsdangerous import BadSignature, TimestampSigner
|
|
14
|
+
|
|
15
|
+
from ..config import settings
|
|
16
|
+
from ..settings_store import cached, put
|
|
17
|
+
from .security import safe_redirect
|
|
18
|
+
|
|
19
|
+
COOKIE = "kw_admin"
|
|
20
|
+
MAX_AGE = 30 * 86400
|
|
21
|
+
_signer: TimestampSigner | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def signer() -> TimestampSigner:
|
|
25
|
+
global _signer
|
|
26
|
+
if _signer is None:
|
|
27
|
+
_signer = TimestampSigner(settings.secret_key(), salt="admin-session")
|
|
28
|
+
return _signer
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hash_password(pw: str, salt: str | None = None) -> str:
|
|
32
|
+
salt = salt or secrets.token_hex(16)
|
|
33
|
+
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 200_000)
|
|
34
|
+
return f"{salt}${dk.hex()}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verify_password(pw: str, stored: str) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
salt, _ = stored.split("$", 1)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return False
|
|
42
|
+
return hmac.compare_digest(hash_password(pw, salt), stored)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def password_is_set() -> bool:
|
|
46
|
+
return bool((cached("admin") or {}).get("password_hash"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def set_password(db, pw: str) -> None:
|
|
50
|
+
await put(db, "admin", {"password_hash": hash_password(pw)})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_password(pw: str) -> bool:
|
|
54
|
+
if len(pw) > 1024:
|
|
55
|
+
return False
|
|
56
|
+
stored = (cached("admin") or {}).get("password_hash", "")
|
|
57
|
+
return bool(stored) and verify_password(pw, stored)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _session_payload() -> bytes:
|
|
61
|
+
# A password reset invalidates previously issued cookies, including across restarts.
|
|
62
|
+
stored = (cached("admin") or {}).get("password_hash", "")
|
|
63
|
+
return b"admin:" + hashlib.sha256(stored.encode()).hexdigest().encode()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_logged_in(request: Request) -> bool:
|
|
67
|
+
# Legacy password cookie validation stays separate from server-side OIDC sessions.
|
|
68
|
+
if settings.auth_mode == "oidc":
|
|
69
|
+
return False
|
|
70
|
+
raw = request.cookies.get(COOKIE)
|
|
71
|
+
if not raw or not password_is_set():
|
|
72
|
+
return False
|
|
73
|
+
try:
|
|
74
|
+
return hmac.compare_digest(signer().unsign(raw, max_age=MAX_AGE), _session_payload())
|
|
75
|
+
except BadSignature:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def oidc_enabled() -> bool:
|
|
80
|
+
return settings.auth_mode in ("both", "oidc")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def authenticated(request) -> bool:
|
|
84
|
+
from .oidc import session_identity
|
|
85
|
+
|
|
86
|
+
identity = await session_identity(request) if oidc_enabled() else None
|
|
87
|
+
if identity is not None:
|
|
88
|
+
request.state.admin_identity = identity
|
|
89
|
+
return True
|
|
90
|
+
if is_logged_in(request):
|
|
91
|
+
request.state.admin_identity = {"method": "password"}
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def login_response(url: str) -> RedirectResponse:
|
|
97
|
+
resp = RedirectResponse(safe_redirect(url), status_code=303)
|
|
98
|
+
resp.set_cookie(COOKIE, signer().sign(_session_payload()).decode(), max_age=MAX_AGE, httponly=True, samesite="lax")
|
|
99
|
+
return resp
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def logout_response(url: str) -> RedirectResponse:
|
|
103
|
+
resp = RedirectResponse(safe_redirect(url), status_code=303)
|
|
104
|
+
resp.delete_cookie(COOKIE)
|
|
105
|
+
return resp
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RequireAdmin:
|
|
109
|
+
"""FastAPI dependency: redirect to setup/login when needed."""
|
|
110
|
+
|
|
111
|
+
async def __call__(self, request: Request) -> None:
|
|
112
|
+
if not password_is_set() and not oidc_enabled():
|
|
113
|
+
raise HTTPException(status_code=303, headers={"Location": "/ui/setup"})
|
|
114
|
+
if not await authenticated(request):
|
|
115
|
+
raise HTTPException(status_code=303, headers={"Location": f"/ui/login?next={request.url.path}"})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
require_admin = RequireAdmin()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Bounded per-process throttles. Requests never trust client-supplied forwarding headers here.
|
|
122
|
+
_login_attempts: OrderedDict[str, deque[float]] = OrderedDict()
|
|
123
|
+
_global_attempts: deque[float] = deque()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def limit_login(request: Request) -> str:
|
|
127
|
+
address = request.client.host if request.client else "unknown"
|
|
128
|
+
at = time.monotonic()
|
|
129
|
+
attempts = _login_attempts.setdefault(address, deque())
|
|
130
|
+
_login_attempts.move_to_end(address)
|
|
131
|
+
while len(_login_attempts) > 4096:
|
|
132
|
+
_login_attempts.popitem(last=False)
|
|
133
|
+
for queue in (attempts, _global_attempts):
|
|
134
|
+
while queue and queue[0] <= at - 60:
|
|
135
|
+
queue.popleft()
|
|
136
|
+
if len(attempts) >= 8 or len(_global_attempts) >= 100:
|
|
137
|
+
raise HTTPException(429, "Too many login attempts; try again in a minute", headers={"Retry-After": "60"})
|
|
138
|
+
attempts.append(at)
|
|
139
|
+
_global_attempts.append(at)
|
|
140
|
+
return address
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def login_succeeded(address: str) -> None:
|
|
144
|
+
_login_attempts.pop(address, None)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Admin-only discovery previews; all source reads are audited."""
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from urllib.parse import urlencode
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, Request
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.exc import IntegrityError
|
|
8
|
+
|
|
9
|
+
from .. import discovery, inventory, reconciliation
|
|
10
|
+
from ..db import session
|
|
11
|
+
from ..models import DiscoveryLink, DiscoveryObservation, DiscoveryRun, now
|
|
12
|
+
from . import auth
|
|
13
|
+
from .admin import redirect, render
|
|
14
|
+
from .security import require_same_origin
|
|
15
|
+
|
|
16
|
+
router = APIRouter(dependencies=[Depends(require_same_origin), Depends(auth.require_admin)])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get('/ui/inventory/discovery')
|
|
20
|
+
async def discovery_page(request: Request, source: str = '', q: str = '', page: int = 1, state: str = ''):
|
|
21
|
+
async with session() as db:
|
|
22
|
+
data = await inventory.snapshot(db)
|
|
23
|
+
runs = list((await db.scalars(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc()))).all())
|
|
24
|
+
observations = list((await db.scalars(select(DiscoveryObservation))).all())
|
|
25
|
+
links = {r.observation_id: r for r in (await db.scalars(select(DiscoveryLink))).all()}
|
|
26
|
+
latest, succeeded = {}, {}
|
|
27
|
+
for run in runs:
|
|
28
|
+
key = (run.resource_id, run.provider)
|
|
29
|
+
latest.setdefault(key, run)
|
|
30
|
+
if run.status == 'succeeded':
|
|
31
|
+
succeeded.setdefault(key, run)
|
|
32
|
+
by_identity = {(r.resource_id, r.provider, r.external_id): r for r in observations}
|
|
33
|
+
def review_state(record):
|
|
34
|
+
link = links.get(record.id)
|
|
35
|
+
return ('changed' if link.reviewed_data != record.data else 'reviewed') if link else 'new'
|
|
36
|
+
filtered = [r for r in observations if (not source or r.resource_id == source) and q.casefold() in
|
|
37
|
+
' '.join([r.data['name'], r.data['mac'], *r.data['addresses'], r.data['site']]).casefold()
|
|
38
|
+
and (state not in ('new', 'changed', 'reviewed') or review_state(r) == state)]
|
|
39
|
+
filtered.sort(key=lambda r: (r.data['name'].casefold(), r.resource_id, r.external_id))
|
|
40
|
+
total_pages = max(1, (len(filtered) + 99) // 100)
|
|
41
|
+
page = min(max(1, page), total_pages)
|
|
42
|
+
rows = []
|
|
43
|
+
for record in filtered[(page - 1) * 100:page * 100]:
|
|
44
|
+
link = links.get(record.id)
|
|
45
|
+
baseline = link.reviewed_data if link else record.previous
|
|
46
|
+
key = (record.resource_id, record.provider)
|
|
47
|
+
success = succeeded.get(key)
|
|
48
|
+
parent = by_identity.get((*key, record.data['parent']))
|
|
49
|
+
attachment = by_identity.get((*key, record.data['attachment']))
|
|
50
|
+
labels = {'name': 'Name', 'kind': 'Device type', 'site': 'Site', 'mac': 'MAC address',
|
|
51
|
+
'addresses': 'IP addresses', 'medium': 'Connection', 'attachment': 'Connected to',
|
|
52
|
+
'port': 'Port / SSID', 'parent': 'Hosted on', 'status': 'Source state',
|
|
53
|
+
'device_type': 'Network device type', 'model': 'Model', 'uplink_note': 'Uplink details'}
|
|
54
|
+
def display(field, value, source_key=key):
|
|
55
|
+
if field in ('parent', 'attachment') and value:
|
|
56
|
+
target = by_identity.get((*source_key, value))
|
|
57
|
+
return target.data['name'] if target else value
|
|
58
|
+
if field == 'medium':
|
|
59
|
+
return inventory.MEDIA.get(value, 'Unknown')
|
|
60
|
+
if field == 'kind':
|
|
61
|
+
return inventory.KINDS.get(value, 'Unknown')
|
|
62
|
+
return (', '.join(value) if isinstance(value, list) else value) or 'Not reported'
|
|
63
|
+
rows.append({'record': record, 'matches': discovery.matches(record, data), 'link': link,
|
|
64
|
+
'review_state': ('Needs review' if link.reviewed_data != record.data else 'Reviewed') if link else 'New',
|
|
65
|
+
'parent': parent.data['name'] if parent else record.data['parent'],
|
|
66
|
+
'attachment': attachment.data['name'] if attachment else record.data['attachment'],
|
|
67
|
+
'missing': success is not None and record.run_id != success.id,
|
|
68
|
+
'stale': record.last_seen < now() - timedelta(hours=24),
|
|
69
|
+
'changes': [{'label': labels[k], 'before': display(k, baseline.get(k)),
|
|
70
|
+
'after': display(k, v)} for k, v in record.data.items()
|
|
71
|
+
if baseline is not None and baseline.get(k) != v]})
|
|
72
|
+
return render(request, 'inventory_discovery.html', **data, rows=rows, latest=list(latest.values()),
|
|
73
|
+
kinds=inventory.KINDS, media=inventory.MEDIA, providers=discovery.PROVIDERS,
|
|
74
|
+
source=source, q=q, state=state, page=page, total_pages=total_pages, total_count=len(filtered),
|
|
75
|
+
previous_query=urlencode({'source': source, 'q': q, 'page': page - 1, 'state': state}),
|
|
76
|
+
next_query=urlencode({'source': source, 'q': q, 'page': page + 1, 'state': state}),
|
|
77
|
+
interrupted_before=now() - timedelta(minutes=5))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.post('/ui/inventory/discovery/refresh')
|
|
81
|
+
async def discovery_refresh(request: Request):
|
|
82
|
+
form = await request.form()
|
|
83
|
+
try:
|
|
84
|
+
resource_id = inventory.value(form, 'resource_id', required=True)
|
|
85
|
+
provider = inventory.value(form, 'provider', required=True)
|
|
86
|
+
run = await discovery.refresh(resource_id, provider)
|
|
87
|
+
except (discovery.DiscoveryError, inventory.InventoryError) as exc:
|
|
88
|
+
return redirect('/ui/inventory/discovery', str(exc))
|
|
89
|
+
message = f'Refreshed {run.count} observations. Inventory records are unchanged.' if run.status == 'succeeded' else run.error
|
|
90
|
+
return redirect('/ui/inventory/discovery', message)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.get('/ui/inventory/discovery/{observation_id}/review')
|
|
94
|
+
async def observation_review(request: Request, observation_id: str, target_id: str = '', interface_id: str = ''):
|
|
95
|
+
try:
|
|
96
|
+
async with session() as db:
|
|
97
|
+
ctx = await reconciliation.review(db, observation_id, target_id, interface_id)
|
|
98
|
+
except (discovery.DiscoveryError, inventory.InventoryError) as exc:
|
|
99
|
+
return redirect('/ui/inventory/discovery', str(exc))
|
|
100
|
+
return render(request, 'inventory_discovery_review.html', **ctx)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.post('/ui/inventory/discovery/{observation_id}/review')
|
|
104
|
+
async def observation_apply(request: Request, observation_id: str):
|
|
105
|
+
form = await request.form()
|
|
106
|
+
try:
|
|
107
|
+
asset_id = await reconciliation.apply(observation_id, form)
|
|
108
|
+
except (discovery.DiscoveryError, inventory.InventoryError, IntegrityError) as exc:
|
|
109
|
+
message = str(exc) if not isinstance(exc, IntegrityError) else 'A conflicting record was saved. Review again.'
|
|
110
|
+
query = urlencode({'target_id': form.get('target_id', ''), 'interface_id': form.get('interface_id', '')})
|
|
111
|
+
return redirect(f'/ui/inventory/discovery/{observation_id}/review?{query}', message)
|
|
112
|
+
return redirect(f'/ui/inventory/assets/{asset_id}', 'Discovery review saved. Only the selected inventory changes were applied.')
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Admin report and explicit review/save flow for constrained friction suggestions."""
|
|
2
|
+
from datetime import UTC, date, datetime, time, timedelta
|
|
3
|
+
from urllib.parse import urlencode
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, Form, Request
|
|
6
|
+
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|
7
|
+
from sqlalchemy import text
|
|
8
|
+
|
|
9
|
+
from .. import friction, policy_preview
|
|
10
|
+
from ..config import settings
|
|
11
|
+
from ..db import session
|
|
12
|
+
from ..models import now
|
|
13
|
+
from . import auth
|
|
14
|
+
from .admin import redirect, render
|
|
15
|
+
from .security import require_same_origin
|
|
16
|
+
|
|
17
|
+
router = APIRouter(dependencies=[Depends(require_same_origin), Depends(auth.require_admin)])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def dates(start, end):
|
|
21
|
+
today = now().date()
|
|
22
|
+
first = date.fromisoformat(start) if start else today - timedelta(days=29)
|
|
23
|
+
last = date.fromisoformat(end) if end else today
|
|
24
|
+
if first > last or last > today or (last - first).days >= 90:
|
|
25
|
+
raise ValueError("Choose 1 to 90 days, ending no later than today.")
|
|
26
|
+
return (datetime.combine(first, time.min, UTC),
|
|
27
|
+
datetime.combine(last + timedelta(days=1), time.min, UTC))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def signer():
|
|
31
|
+
return URLSafeTimedSerializer(settings.secret_key(), salt="friction-rule-review")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/ui/friction")
|
|
35
|
+
async def friction_page(request: Request, start: str = "", end: str = "", agent: str = ""):
|
|
36
|
+
error = None
|
|
37
|
+
try:
|
|
38
|
+
since, until = dates(start, end)
|
|
39
|
+
except (ValueError, OverflowError):
|
|
40
|
+
since, until = dates("", "")
|
|
41
|
+
error = "Choose a valid range of 1 to 90 days, ending no later than today. Showing the last 30 days."
|
|
42
|
+
filters = {"start": since.date().isoformat(), "end": (until - timedelta(days=1)).date().isoformat(),
|
|
43
|
+
"agent": agent}
|
|
44
|
+
async with session() as db:
|
|
45
|
+
report = await friction.report(db, since, until, agent_id=agent)
|
|
46
|
+
report.pop("proposals") # Raw exact constraints are never passed to the report template.
|
|
47
|
+
return render(request, "friction.html", **report, filters=filters, error=error,
|
|
48
|
+
review_query=urlencode(filters))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("/ui/friction/review/{rid}")
|
|
52
|
+
async def review(request: Request, rid: str, start: str = "", end: str = "", agent: str = ""):
|
|
53
|
+
try:
|
|
54
|
+
since, until = dates(start, end)
|
|
55
|
+
except (ValueError, OverflowError):
|
|
56
|
+
return redirect("/ui/friction", "Choose a valid date range before reviewing a suggestion.")
|
|
57
|
+
filters = {"start": since.date().isoformat(), "end": (until - timedelta(days=1)).date().isoformat(),
|
|
58
|
+
"agent": agent}
|
|
59
|
+
async with session() as db:
|
|
60
|
+
report = await friction.report(db, since, until, agent_id=agent)
|
|
61
|
+
proposal = report["proposals"].get(rid)
|
|
62
|
+
if proposal is None:
|
|
63
|
+
return redirect("/ui/friction?" + urlencode(filters), "This suggestion is no longer eligible. Refresh the report.")
|
|
64
|
+
rules = proposal["rules"]
|
|
65
|
+
preview = await policy_preview.preview(db, rules[0], additional=tuple(rules[1:]))
|
|
66
|
+
token = signer().dumps({"request": rid, "fingerprint": proposal["fingerprint"], "filters": filters})
|
|
67
|
+
public = {k: proposal[k] for k in ("request_id", "agent", "resource", "tool", "support", "examples")}
|
|
68
|
+
return render(request, "friction_review.html", proposal=public, preview=preview, token=token,
|
|
69
|
+
rule_count=len(rules), back="/ui/friction?" + urlencode(filters))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/ui/friction/review/{rid}")
|
|
73
|
+
async def save(rid: str, token: str = Form(...)):
|
|
74
|
+
try:
|
|
75
|
+
reviewed = signer().loads(token, max_age=600)
|
|
76
|
+
if reviewed["request"] != rid:
|
|
77
|
+
raise ValueError
|
|
78
|
+
filters = reviewed["filters"]
|
|
79
|
+
since, until = dates(filters["start"], filters["end"])
|
|
80
|
+
except (BadSignature, SignatureExpired, ValueError, TypeError, KeyError, OverflowError):
|
|
81
|
+
return redirect("/ui/friction", "Review expired or invalid. Preview the suggestion again before saving.")
|
|
82
|
+
async with session() as db:
|
|
83
|
+
# Serialize SQLite writers while revalidating support, targets, hazards and standing policy.
|
|
84
|
+
if db.bind.dialect.name == "sqlite":
|
|
85
|
+
await db.execute(text("BEGIN IMMEDIATE"))
|
|
86
|
+
report = await friction.report(db, since, until, agent_id=filters["agent"])
|
|
87
|
+
proposal = report["proposals"].get(rid)
|
|
88
|
+
if proposal is None or proposal["fingerprint"] != reviewed["fingerprint"]:
|
|
89
|
+
return redirect("/ui/friction?" + urlencode(filters),
|
|
90
|
+
"Evidence or access settings changed. Review a fresh preview before saving.")
|
|
91
|
+
added = await friction.save_proposal(db, proposal)
|
|
92
|
+
return redirect("/ui/policies", f"Added {added} exact-operation rule(s) for {proposal['agent']}.")
|