mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.
Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.
Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.
Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/nezhahq/nezha/model"
|
||||
"github.com/nezhahq/nezha/service/singleton"
|
||||
)
|
||||
|
||||
// MCP 协议兼容性集成测试:用 modelcontextprotocol/go-sdk 官方 Go MCP client
|
||||
// 对 dashboard /mcp 跑完整 initialize + tools/list + tools/call。
|
||||
// 协议层用官方 SDK 严格编解码 — 任何与 MCP spec 的偏差都会被立即报错。
|
||||
|
||||
type sdkPATRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
token string
|
||||
}
|
||||
|
||||
func (rt *sdkPATRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("Authorization", "Bearer "+rt.token)
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func sdkTransport(endpoint, token string) *mcp.StreamableClientTransport {
|
||||
return &mcp.StreamableClientTransport{
|
||||
Endpoint: endpoint,
|
||||
HTTPClient: &http.Client{
|
||||
Transport: &sdkPATRoundTripper{base: http.DefaultTransport, token: token},
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
// /mcp 当前只实现 POST 半边 Streamable HTTP;GET SSE 通道未实现也不计划
|
||||
// 短期内上线(不需要 server→client 主动推送)。SDK 默认会试图发 GET,
|
||||
// 关掉 standalone SSE 即可严格互通。
|
||||
DisableStandaloneSSE: true,
|
||||
}
|
||||
}
|
||||
|
||||
func setupSDKCompat(t *testing.T) (string, string, func()) {
|
||||
t.Helper()
|
||||
cleanupBase, uid := setupMCPTest(t)
|
||||
|
||||
srv, _ := singleton.ServerShared.Get(7)
|
||||
srv.SetTaskStream(&e2eStream{dispatch: agentSim})
|
||||
|
||||
_, plain := mkToken(t, uid, []string{
|
||||
model.ScopeServerRead,
|
||||
model.ScopeServerWrite,
|
||||
model.ScopeServerDelete,
|
||||
model.ScopeServerExec,
|
||||
}, nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/mcp", apiTokenAuthMiddleware(), mcpEndpoint)
|
||||
ts := httptest.NewServer(r)
|
||||
return ts.URL + "/mcp", plain, func() {
|
||||
ts.Close()
|
||||
cleanupBase()
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKClient_InitializeHandshake(t *testing.T) {
|
||||
endpoint, token, cleanup := setupSDKCompat(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||||
require.NoError(t, err, "official Go SDK must initialize against /mcp")
|
||||
defer session.Close()
|
||||
}
|
||||
|
||||
func TestSDKClient_ToolsList(t *testing.T) {
|
||||
endpoint, token, cleanup := setupSDKCompat(t)
|
||||
defer cleanup()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
lst, err := session.ListTools(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
names := make(map[string]bool, len(lst.Tools))
|
||||
for _, tl := range lst.Tools {
|
||||
names[tl.Name] = true
|
||||
}
|
||||
for _, must := range []string{
|
||||
"meta.whoami",
|
||||
"server.list", "server.get", "server.exec",
|
||||
"fs.list", "fs.read", "fs.write", "fs.delete",
|
||||
"fs.download_url", "fs.upload_url",
|
||||
} {
|
||||
require.Truef(t, names[must], "tools/list missing %q", must)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKClient_Whoami(t *testing.T) {
|
||||
endpoint, token, cleanup := setupSDKCompat(t)
|
||||
defer cleanup()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
res, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "meta.whoami",
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.IsError)
|
||||
tc, ok := res.Content[0].(*mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(tc.Text), &payload))
|
||||
require.NotZero(t, payload["user_id"])
|
||||
require.NotEmpty(t, payload["scopes"])
|
||||
}
|
||||
|
||||
func TestSDKClient_ServerExec(t *testing.T) {
|
||||
endpoint, token, cleanup := setupSDKCompat(t)
|
||||
defer cleanup()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
res, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "server.exec",
|
||||
Arguments: map[string]any{
|
||||
"server_id": 7,
|
||||
"cmd": "echo",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.IsError, "exec failed: %v", res.Content)
|
||||
tc := res.Content[0].(*mcp.TextContent)
|
||||
require.Contains(t, tc.Text, "simulated")
|
||||
}
|
||||
|
||||
func TestSDKClient_FSLifecycle(t *testing.T) {
|
||||
endpoint, token, cleanup := setupSDKCompat(t)
|
||||
defer cleanup()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
path := t.TempDir() + "/sdk.txt"
|
||||
for _, step := range []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
}{
|
||||
{"fs.write", map[string]any{"server_id": 7, "path": path, "content": "via-sdk", "encoding": "utf8"}},
|
||||
{"fs.read", map[string]any{"server_id": 7, "path": path}},
|
||||
{"fs.delete", map[string]any{"server_id": 7, "path": path}},
|
||||
} {
|
||||
res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: step.name, Arguments: step.args})
|
||||
require.NoError(t, err, step.name)
|
||||
require.False(t, res.IsError, "%s failed: %v", step.name, res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKClient_BadPAT(t *testing.T) {
|
||||
endpoint, _, cleanup := setupSDKCompat(t)
|
||||
defer cleanup()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||||
_, err := client.Connect(ctx, sdkTransport(endpoint, "nzp_invalid"), nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user