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.
Files changed (127) hide show
  1. keywright_server/__init__.py +3 -0
  2. keywright_server/__main__.py +71 -0
  3. keywright_server/access.py +131 -0
  4. keywright_server/access_view.py +144 -0
  5. keywright_server/actions.py +30 -0
  6. keywright_server/approvals.py +433 -0
  7. keywright_server/audit.py +58 -0
  8. keywright_server/bundles.py +87 -0
  9. keywright_server/config.py +102 -0
  10. keywright_server/db.py +119 -0
  11. keywright_server/discovery.py +277 -0
  12. keywright_server/events.py +78 -0
  13. keywright_server/friction.py +242 -0
  14. keywright_server/integrations.py +64 -0
  15. keywright_server/inventory.py +415 -0
  16. keywright_server/inventory_mcp.py +288 -0
  17. keywright_server/jev.py +80 -0
  18. keywright_server/knowledge.py +403 -0
  19. keywright_server/knowledge_chunking.py +118 -0
  20. keywright_server/knowledge_index.py +233 -0
  21. keywright_server/knowledge_mcp.py +82 -0
  22. keywright_server/llm.py +338 -0
  23. keywright_server/llm_proxy.py +271 -0
  24. keywright_server/llm_usage.py +99 -0
  25. keywright_server/mail.py +312 -0
  26. keywright_server/mcp_oauth.py +422 -0
  27. keywright_server/mcp_proxy.py +128 -0
  28. keywright_server/mcp_server.py +1025 -0
  29. keywright_server/models.py +442 -0
  30. keywright_server/network_map.py +128 -0
  31. keywright_server/notify/__init__.py +8 -0
  32. keywright_server/notify/base.py +156 -0
  33. keywright_server/notify/homeassistant.py +221 -0
  34. keywright_server/notify/log.py +30 -0
  35. keywright_server/notify/manager.py +131 -0
  36. keywright_server/notify/webhook.py +79 -0
  37. keywright_server/nudges.py +121 -0
  38. keywright_server/omada.py +179 -0
  39. keywright_server/policy.py +265 -0
  40. keywright_server/policy_preview.py +151 -0
  41. keywright_server/reconciliation.py +188 -0
  42. keywright_server/result_inspection.py +293 -0
  43. keywright_server/semantic.py +451 -0
  44. keywright_server/semantic_questions.json +120 -0
  45. keywright_server/settings_store.py +44 -0
  46. keywright_server/ssh.py +290 -0
  47. keywright_server/tool_access.py +260 -0
  48. keywright_server/usage.py +121 -0
  49. keywright_server/web/__init__.py +0 -0
  50. keywright_server/web/admin.py +1126 -0
  51. keywright_server/web/app.py +154 -0
  52. keywright_server/web/auth.py +144 -0
  53. keywright_server/web/discovery_ui.py +112 -0
  54. keywright_server/web/friction_ui.py +92 -0
  55. keywright_server/web/inventory_ui.py +192 -0
  56. keywright_server/web/knowledge_ui.py +177 -0
  57. keywright_server/web/mail_ui.py +190 -0
  58. keywright_server/web/mcp_oauth_ui.py +87 -0
  59. keywright_server/web/network_map_ui.py +37 -0
  60. keywright_server/web/oidc.py +231 -0
  61. keywright_server/web/security.py +77 -0
  62. keywright_server/web/templates/_activity_row.html +25 -0
  63. keywright_server/web/templates/_agent_setup.html +50 -0
  64. keywright_server/web/templates/_collections_style.html +88 -0
  65. keywright_server/web/templates/_constraints.html +6 -0
  66. keywright_server/web/templates/_decide.html +39 -0
  67. keywright_server/web/templates/_device_kind_picker.html +66 -0
  68. keywright_server/web/templates/_hazards.html +6 -0
  69. keywright_server/web/templates/_icons.html +19 -0
  70. keywright_server/web/templates/_infrastructure_nav.html +8 -0
  71. keywright_server/web/templates/_integration_style.html +30 -0
  72. keywright_server/web/templates/_inventory_forms.html +36 -0
  73. keywright_server/web/templates/_inventory_style.html +36 -0
  74. keywright_server/web/templates/_live.html +10 -0
  75. keywright_server/web/templates/_mail_nav.html +5 -0
  76. keywright_server/web/templates/_mail_style.html +10 -0
  77. keywright_server/web/templates/_network_map_script.html +5 -0
  78. keywright_server/web/templates/_nudge_card.html +12 -0
  79. keywright_server/web/templates/_patterns.html +6 -0
  80. keywright_server/web/templates/_policy_preview_results.html +13 -0
  81. keywright_server/web/templates/_request_card.html +46 -0
  82. keywright_server/web/templates/_semantic.html +10 -0
  83. keywright_server/web/templates/_topology_controller.js +217 -0
  84. keywright_server/web/templates/_topology_layout.js +174 -0
  85. keywright_server/web/templates/_topology_render.js +117 -0
  86. keywright_server/web/templates/access.html +119 -0
  87. keywright_server/web/templates/agents.html +49 -0
  88. keywright_server/web/templates/audit.html +6 -0
  89. keywright_server/web/templates/base.html +198 -0
  90. keywright_server/web/templates/dashboard.html +226 -0
  91. keywright_server/web/templates/decision.html +26 -0
  92. keywright_server/web/templates/friction.html +36 -0
  93. keywright_server/web/templates/friction_review.html +21 -0
  94. keywright_server/web/templates/grants.html +61 -0
  95. keywright_server/web/templates/integration_picker.html +47 -0
  96. keywright_server/web/templates/inventory.html +21 -0
  97. keywright_server/web/templates/inventory_asset.html +42 -0
  98. keywright_server/web/templates/inventory_discovery.html +32 -0
  99. keywright_server/web/templates/inventory_discovery_review.html +34 -0
  100. keywright_server/web/templates/inventory_map.html +45 -0
  101. keywright_server/web/templates/inventory_networks.html +12 -0
  102. keywright_server/web/templates/inventory_subnet.html +41 -0
  103. keywright_server/web/templates/inventory_topology.html +22 -0
  104. keywright_server/web/templates/knowledge.html +25 -0
  105. keywright_server/web/templates/knowledge_document.html +7 -0
  106. keywright_server/web/templates/knowledge_proposal.html +7 -0
  107. keywright_server/web/templates/login.html +9 -0
  108. keywright_server/web/templates/mail.html +45 -0
  109. keywright_server/web/templates/mail_rules.html +24 -0
  110. keywright_server/web/templates/mail_settings.html +19 -0
  111. keywright_server/web/templates/mcp_oauth_continue.html +14 -0
  112. keywright_server/web/templates/policies.html +30 -0
  113. keywright_server/web/templates/request_detail.html +27 -0
  114. keywright_server/web/templates/requests.html +9 -0
  115. keywright_server/web/templates/resource_form.html +198 -0
  116. keywright_server/web/templates/resources.html +76 -0
  117. keywright_server/web/templates/settings.html +53 -0
  118. keywright_server/web/templates/setup.html +8 -0
  119. keywright_server/web/templates/usage.html +84 -0
  120. keywright_server/web/usage_ui.py +41 -0
  121. keywright_server-0.0.1.dist-info/METADATA +152 -0
  122. keywright_server-0.0.1.dist-info/RECORD +127 -0
  123. keywright_server-0.0.1.dist-info/WHEEL +4 -0
  124. keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
  125. keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
  126. keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
  127. 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']}.")