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:
oliver 2026-08-02 16:24:34 +08:00
parent cb8e0d23c5
commit 633a9d55bd
38 changed files with 1808 additions and 452 deletions

View file

@ -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())

View file

@ -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