mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 13:50:44 +08:00
Harden auth scopes, SQL/WebCRT gates, and per-install JWT secrets.
Add capability RBAC, Alembic bootstrap, optional worker schedulers, and close public docs by default so lab installs stay usable without shared signing keys. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cb8e0d23c5
commit
633a9d55bd
38 changed files with 1808 additions and 452 deletions
|
|
@ -571,6 +571,40 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
|||
"queryTopologyEdges": _query_topology_edges,
|
||||
}
|
||||
|
||||
# Minimum scope required to advertise / invoke each tool (matches netx API RBAC).
|
||||
TOOL_REQUIRED_SCOPE: dict[str, str] = {
|
||||
"queryUmeAlarms": "alarms:read",
|
||||
"aggregateUmeAlarms": "alarms:read",
|
||||
"runUmeDiagnostics": "alarms:read",
|
||||
"queryUmeNeInventory": "ne:read",
|
||||
"getUmeNe": "ne:read",
|
||||
"queryUmeAlarmsRaw": "alarms:read",
|
||||
"aggregateUmeAlarmsRaw": "alarms:read",
|
||||
"listUmeAlarmFields": "alarms:read",
|
||||
"sqlQueryUme": "sql:query",
|
||||
"listManagedNe": "ne:read",
|
||||
"getManagedNe": "ne:read",
|
||||
"execManagedNe": "ne:exec",
|
||||
"listCliTargets": "ne:read",
|
||||
"queryTopologyEdges": "ne:read",
|
||||
}
|
||||
|
||||
|
||||
def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> list[dict[str, Any]]:
|
||||
"""Filter MCP tool list by granted scopes. Empty/None => return all (offline / unauthenticated listing)."""
|
||||
if scopes is None:
|
||||
return list(HTTP_MCP_TOOLS)
|
||||
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
|
||||
if not granted:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for tool in HTTP_MCP_TOOLS:
|
||||
name = str(tool.get("name") or "")
|
||||
need = TOOL_REQUIRED_SCOPE.get(name)
|
||||
if need is None or need in granted:
|
||||
out.append(tool)
|
||||
return out
|
||||
|
||||
|
||||
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
fn = _HANDLERS.get(str(name or "").strip())
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import json
|
|||
import sys
|
||||
from typing import Any
|
||||
|
||||
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool
|
||||
from netx_mcp.http_client import http_json
|
||||
from netx_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
|
||||
|
||||
|
||||
def _ensure_utf8_stdio() -> None:
|
||||
|
|
@ -39,7 +40,30 @@ def _err(rid: Any, code: int, message: str) -> None:
|
|||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _fetch_scopes() -> list[str] | None:
|
||||
"""Return granted scopes from /v1/auth/me, or None if the call fails (show all tools)."""
|
||||
try:
|
||||
data = http_json("GET", "/v1/auth/me")
|
||||
scopes = data.get("scopes") if isinstance(data, dict) else None
|
||||
if isinstance(scopes, list):
|
||||
return [str(s) for s in scopes]
|
||||
user = data.get("user") if isinstance(data, dict) else None
|
||||
if isinstance(user, dict) and isinstance(user.get("scopes"), list):
|
||||
return [str(s) for s in user["scopes"]]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def run_stdio_loop() -> None:
|
||||
cached_scopes: list[str] | None | object = object()
|
||||
|
||||
def scopes() -> list[str] | None:
|
||||
nonlocal cached_scopes
|
||||
if cached_scopes is object():
|
||||
cached_scopes = _fetch_scopes()
|
||||
return cached_scopes # type: ignore[return-value]
|
||||
|
||||
for line in sys.stdin:
|
||||
raw = line.strip()
|
||||
if not raw:
|
||||
|
|
@ -59,17 +83,22 @@ def run_stdio_loop() -> None:
|
|||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "netx-mcp", "version": "0.2.0", "mode": "http"},
|
||||
"serverInfo": {"name": "netx-mcp", "version": "0.2.1", "mode": "http"},
|
||||
},
|
||||
)
|
||||
continue
|
||||
if method == "notifications/initialized":
|
||||
continue
|
||||
if method == "tools/list":
|
||||
_ok(rid, {"tools": HTTP_MCP_TOOLS})
|
||||
_ok(rid, {"tools": tools_for_scopes(scopes())})
|
||||
continue
|
||||
if method == "tools/call":
|
||||
name = str(params.get("name") or "")
|
||||
need = TOOL_REQUIRED_SCOPE.get(name)
|
||||
granted = scopes()
|
||||
if need and granted is not None and need not in {str(s).lower() for s in granted}:
|
||||
_err(rid, -32001, f"insufficient_scope:{need}")
|
||||
continue
|
||||
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
||||
_ok(rid, call_http_tool(name, args))
|
||||
continue
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue