mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
feat(auth): split inventory scope out of server scope
Carve a new nezha:inventory:{read,delete,*} resource family out of
nezha:server:*. Listing and deleting servers/server-groups (GET /server,
/server-group, /ws/server, batch-delete/server[-group], MCP server.list)
now require nezha:inventory:*, while nezha:server:* covers per-server
runtime operations (get, exec, fs, config, metrics, batch-move,
force-update).
Also declare MCP tool OutputSchema for exec/fs/meta/server/transfer and
wrap server.list output in {servers,count} so strict MCP clients accept
the structured result. Correct the /file all-of scope entry and the stale
server:read documentation.
This commit is contained in:
@@ -24,7 +24,7 @@ func setupOptionalAuthRouter(t *testing.T, plainToken string) *httptest.Server {
|
||||
|
||||
stub := func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
|
||||
optionalAuth := r.Group("/api/v1", authMw)
|
||||
optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeServerRead), stub)
|
||||
optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeInventoryRead), stub)
|
||||
optionalAuth.GET("/service", restScopeMiddleware(model.ScopeServiceRead), stub)
|
||||
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), stub)
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestOptionalAuth_PATWithMatchingScopeAllowed(t *testing.T) {
|
||||
cleanup, uid := setupMCPTest(t)
|
||||
defer cleanup()
|
||||
|
||||
_, plain := mkToken(t, uid, []string{model.ScopeServerRead, model.ScopeServiceRead}, nil)
|
||||
_, plain := mkToken(t, uid, []string{model.ScopeInventoryRead, model.ScopeServerRead, model.ScopeServiceRead}, nil)
|
||||
ts := setupOptionalAuthRouter(t, plain)
|
||||
|
||||
for _, path := range []string{
|
||||
|
||||
@@ -20,14 +20,14 @@ func setupRESTScopeServer(t *testing.T) (*httptest.Server, string, func()) {
|
||||
t.Helper()
|
||||
cleanupBase, uid := setupMCPTest(t)
|
||||
|
||||
_, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
||||
_, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
pat := apiTokenAuthMiddleware()
|
||||
r.GET("/api/v1/server",
|
||||
pat,
|
||||
restScopeMiddleware(model.ScopeServerRead),
|
||||
restScopeMiddleware(model.ScopeInventoryRead),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||
)
|
||||
r.POST("/api/v1/server/config",
|
||||
@@ -122,7 +122,7 @@ func TestRESTScope_JWTUserSkipsScope(t *testing.T) {
|
||||
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: uid}, Role: model.RoleMember})
|
||||
c.Next()
|
||||
},
|
||||
restScopeMiddleware(model.ScopeServerRead),
|
||||
restScopeMiddleware(model.ScopeInventoryRead),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||
)
|
||||
ts := httptest.NewServer(r)
|
||||
@@ -148,7 +148,7 @@ func TestRESTScope_NezhaAllUnlocksEverything(t *testing.T) {
|
||||
)
|
||||
r.POST("/api/v1/batch-delete/server",
|
||||
pat,
|
||||
restScopeMiddleware(model.ScopeServerDelete),
|
||||
restScopeMiddleware(model.ScopeInventoryDelete),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||
)
|
||||
ts := httptest.NewServer(r)
|
||||
@@ -193,7 +193,7 @@ func TestRESTScope_GoodPATClearsWAFCounter(t *testing.T) {
|
||||
cleanupBase, uid := setupMCPTest(t)
|
||||
defer cleanupBase()
|
||||
|
||||
_, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
||||
_, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -203,7 +203,7 @@ func TestRESTScope_GoodPATClearsWAFCounter(t *testing.T) {
|
||||
})
|
||||
r.GET("/api/v1/server",
|
||||
apiTokenAuthMiddleware(),
|
||||
restScopeMiddleware(model.ScopeServerRead),
|
||||
restScopeMiddleware(model.ScopeInventoryRead),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||
)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
@@ -88,8 +88,8 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
|
||||
optionalAuthMw := utils.IfOr(singleton.Conf.ForceAuth, authMw, patOrFallbackAuthMiddleware(patMw, fallbackAuthMw))
|
||||
|
||||
optionalAuth := api.Group("", optionalAuthMw)
|
||||
optionalAuth.GET("/ws/server", restScopeMiddleware(model.ScopeServerRead), commonHandler(serverStream))
|
||||
optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeServerRead), commonHandler(listServerGroup))
|
||||
optionalAuth.GET("/ws/server", restScopeMiddleware(model.ScopeInventoryRead), commonHandler(serverStream))
|
||||
optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeInventoryRead), commonHandler(listServerGroup))
|
||||
|
||||
optionalAuth.GET("/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(showService))
|
||||
optionalAuth.GET("/service/server", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerWithServices))
|
||||
@@ -112,21 +112,25 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
|
||||
auth.POST("/api-tokens", patForbidden, commonHandler(createAPIToken))
|
||||
auth.DELETE("/api-tokens/:id", patForbidden, commonHandler(deleteAPIToken))
|
||||
|
||||
// server / terminal / fm / transfer 共享 nezha:server:* 资源族
|
||||
// 资源族划分:
|
||||
// - nezha:inventory:* —— 对“服务器台账”的枚举与删除(列出 server / server-group、
|
||||
// 删除 server / server-group)。这是管理后台清单管理动作。
|
||||
// - nezha:server:* —— 对已知 server 的运行态操作(exec、文件读写、编辑配置、
|
||||
// force-update、batch-move)。
|
||||
auth.POST("/terminal", restScopeMiddleware(model.ScopeServerExec), commonHandler(createTerminal))
|
||||
auth.GET("/ws/terminal/:id", restScopeMiddleware(model.ScopeServerExec), commonHandler(terminalStream))
|
||||
auth.POST("/file", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(createFM))
|
||||
auth.GET("/ws/file/:id", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(fmStream))
|
||||
auth.GET("/server", restScopeMiddleware(model.ScopeServerRead), listHandler(listServer))
|
||||
auth.GET("/server", restScopeMiddleware(model.ScopeInventoryRead), listHandler(listServer))
|
||||
auth.PATCH("/server/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServer))
|
||||
auth.GET("/server/config/:id", restScopeMiddleware(serverConfigSensitiveScope()), commonHandler(getServerConfig))
|
||||
auth.POST("/server/config", restScopeMiddleware(model.ScopeServerWrite), commonHandler(setServerConfig))
|
||||
auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeServerDelete), commonHandler(batchDeleteServer))
|
||||
auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServer))
|
||||
auth.POST("/batch-move/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchMoveServer))
|
||||
auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer))
|
||||
auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup))
|
||||
auth.PATCH("/server-group/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServerGroup))
|
||||
auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeServerDelete), commonHandler(batchDeleteServerGroup))
|
||||
auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServerGroup))
|
||||
|
||||
// transfer — 严格使用 nezha:transfer 资源族 scope(read/write/delete)。
|
||||
// 注意:曾经计划让 nezha:server:read 兼听只读 transfer,但 restScopeMiddleware
|
||||
|
||||
@@ -85,9 +85,10 @@ type mcpInitializeResult struct {
|
||||
|
||||
// mcpToolDescriptor 是 tools/list 返回的单条 tool 描述。
|
||||
type mcpToolDescriptor struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
OutputSchema map[string]any `json:"outputSchema,omitempty"`
|
||||
}
|
||||
|
||||
// mcpToolsListResult tools/list 响应。
|
||||
@@ -119,7 +120,8 @@ type mcpTool struct {
|
||||
Name string
|
||||
Description string
|
||||
InputSchema map[string]any
|
||||
RequiredScope string // 闸 2 入口;空字符串 = 任意 PAT 都能调(如 meta.whoami)
|
||||
OutputSchema map[string]any // 可选;声明 structuredContent 形状,供严格客户端校验
|
||||
RequiredScope string // 闸 2 入口;空字符串 = 任意 PAT 都能调(如 meta.whoami)
|
||||
Handler mcpToolHandler
|
||||
}
|
||||
|
||||
@@ -253,9 +255,10 @@ func buildToolDescriptors() []mcpToolDescriptor {
|
||||
out := make([]mcpToolDescriptor, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, mcpToolDescriptor{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.InputSchema,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.InputSchema,
|
||||
OutputSchema: t.OutputSchema,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -228,9 +228,10 @@ func setupEndToEnd(t *testing.T) (*httptest.Server, string, func()) {
|
||||
}
|
||||
|
||||
_, plain := mkToken(t, uid, []string{
|
||||
model.ScopeInventoryRead,
|
||||
model.ScopeInventoryDelete,
|
||||
model.ScopeServerRead,
|
||||
model.ScopeServerExec,
|
||||
model.ScopeServerRead,
|
||||
model.ScopeServerWrite,
|
||||
model.ScopeServerDelete,
|
||||
}, nil)
|
||||
|
||||
@@ -53,6 +53,8 @@ func setupSDKCompat(t *testing.T) (string, string, func()) {
|
||||
srv.SetTaskStream(&e2eStream{dispatch: agentSim})
|
||||
|
||||
_, plain := mkToken(t, uid, []string{
|
||||
model.ScopeInventoryRead,
|
||||
model.ScopeInventoryDelete,
|
||||
model.ScopeServerRead,
|
||||
model.ScopeServerWrite,
|
||||
model.ScopeServerDelete,
|
||||
|
||||
@@ -51,6 +51,19 @@ func init() {
|
||||
},
|
||||
"required": []string{"server_id", "cmd"},
|
||||
},
|
||||
OutputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"exit_code": map[string]any{"type": "integer"},
|
||||
"stdout": map[string]any{"type": "string"},
|
||||
"stderr": map[string]any{"type": "string"},
|
||||
"duration_ms": map[string]any{"type": "integer"},
|
||||
"stdout_truncated": map[string]any{"type": "boolean"},
|
||||
"stderr_truncated": map[string]any{"type": "boolean"},
|
||||
"timed_out": map[string]any{"type": "boolean"},
|
||||
},
|
||||
"required": []string{"exit_code", "stdout", "stderr", "duration_ms"},
|
||||
},
|
||||
RequiredScope: model.ScopeServerExec,
|
||||
Handler: handleServerExec,
|
||||
})
|
||||
|
||||
@@ -14,6 +14,22 @@ import (
|
||||
|
||||
const fsCallTimeout = 30 * time.Second
|
||||
|
||||
func fsEntrySchema() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"size": map[string]any{"type": "integer"},
|
||||
"mode": map[string]any{"type": "string"},
|
||||
"mtime": map[string]any{"type": "integer"},
|
||||
"is_symlink": map[string]any{"type": "boolean"},
|
||||
"link_target": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"name", "type", "size", "mode", "mtime"},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerMCPTool(&mcpTool{
|
||||
Name: "fs.list",
|
||||
@@ -27,6 +43,15 @@ func init() {
|
||||
},
|
||||
"required": []string{"server_id", "path"},
|
||||
},
|
||||
OutputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"entries": map[string]any{"type": "array", "items": fsEntrySchema()},
|
||||
"truncated": map[string]any{"type": "boolean"},
|
||||
"total": map[string]any{"type": "integer"},
|
||||
},
|
||||
"required": []string{"entries"},
|
||||
},
|
||||
RequiredScope: model.ScopeServerRead,
|
||||
Handler: handleFsList,
|
||||
})
|
||||
@@ -45,6 +70,17 @@ func init() {
|
||||
},
|
||||
"required": []string{"server_id", "path"},
|
||||
},
|
||||
OutputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{"type": "string"},
|
||||
"encoding": map[string]any{"type": "string"},
|
||||
"size": map[string]any{"type": "integer"},
|
||||
"sha256": map[string]any{"type": "string"},
|
||||
"truncated": map[string]any{"type": "boolean"},
|
||||
},
|
||||
"required": []string{"content", "encoding", "size"},
|
||||
},
|
||||
RequiredScope: model.ScopeServerRead,
|
||||
Handler: handleFsRead,
|
||||
})
|
||||
@@ -65,6 +101,14 @@ func init() {
|
||||
},
|
||||
"required": []string{"server_id", "path", "content"},
|
||||
},
|
||||
OutputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"size": map[string]any{"type": "integer"},
|
||||
"sha256": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"size", "sha256"},
|
||||
},
|
||||
RequiredScope: model.ScopeServerWrite,
|
||||
Handler: handleFsWrite,
|
||||
})
|
||||
@@ -81,6 +125,13 @@ func init() {
|
||||
},
|
||||
"required": []string{"server_id", "path"},
|
||||
},
|
||||
OutputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"deleted_count": map[string]any{"type": "integer"},
|
||||
},
|
||||
"required": []string{"deleted_count"},
|
||||
},
|
||||
RequiredScope: model.ScopeServerDelete,
|
||||
Handler: handleFsDelete,
|
||||
})
|
||||
|
||||
@@ -27,6 +27,18 @@ func init() {
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
OutputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"user_id": map[string]any{"type": "integer"},
|
||||
"is_admin": map[string]any{"type": "boolean"},
|
||||
"token_id": map[string]any{"type": "integer"},
|
||||
"token_name": map[string]any{"type": "string"},
|
||||
"scopes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"server_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}},
|
||||
},
|
||||
"required": []string{"user_id", "is_admin", "token_id", "scopes"},
|
||||
},
|
||||
RequiredScope: "",
|
||||
Handler: handleMetaWhoami,
|
||||
})
|
||||
|
||||
@@ -30,6 +30,33 @@ type serverListArgs struct {
|
||||
OnlineOnly bool `json:"online_only,omitempty"`
|
||||
}
|
||||
|
||||
// serverListResult 是 server.list 的返回外壳。MCP 2025-06-18 规定
|
||||
// structuredContent 必须是 JSON object,不能是裸数组/标量,否则严格客户端
|
||||
// (官方 TS/Python SDK)会拒绝整条 tools/call 结果。因此这里把列表包进对象,
|
||||
// 不再直接返回 []serverListItem。
|
||||
type serverListResult struct {
|
||||
Servers []serverListItem `json:"servers"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func serverListItemSchema() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "integer"},
|
||||
"name": map[string]any{"type": "string"},
|
||||
"uuid": map[string]any{"type": "string"},
|
||||
"ipv4": map[string]any{"type": "string"},
|
||||
"ipv6": map[string]any{"type": "string"},
|
||||
"online": map[string]any{"type": "boolean"},
|
||||
"platform": map[string]any{"type": "string"},
|
||||
"arch": map[string]any{"type": "string"},
|
||||
"last_active": map[string]any{"type": "string", "format": "date-time"},
|
||||
},
|
||||
"required": []string{"id", "name", "online"},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerMCPTool(&mcpTool{
|
||||
Name: "server.list",
|
||||
@@ -43,14 +70,40 @@ func init() {
|
||||
},
|
||||
},
|
||||
},
|
||||
RequiredScope: model.ScopeServerRead,
|
||||
OutputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"servers": map[string]any{
|
||||
"type": "array",
|
||||
"items": serverListItemSchema(),
|
||||
},
|
||||
"count": map[string]any{"type": "integer"},
|
||||
},
|
||||
"required": []string{"servers", "count"},
|
||||
},
|
||||
RequiredScope: model.ScopeInventoryRead,
|
||||
Handler: handleServerList,
|
||||
})
|
||||
|
||||
registerMCPTool(&mcpTool{
|
||||
Name: "server.get",
|
||||
Description: "Return full Host/State snapshot for a single server.",
|
||||
InputSchema: serverGetSchema(),
|
||||
Name: "server.get",
|
||||
Description: "Return full Host/State snapshot for a single server.",
|
||||
InputSchema: serverGetSchema(),
|
||||
OutputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "integer"},
|
||||
"name": map[string]any{"type": "string"},
|
||||
"uuid": map[string]any{"type": "string"},
|
||||
"note": map[string]any{"type": "string"},
|
||||
"public_note": map[string]any{"type": "string"},
|
||||
"host": map[string]any{"type": "object"},
|
||||
"state": map[string]any{"type": "object"},
|
||||
"geoip": map[string]any{"type": "object"},
|
||||
"last_active": map[string]any{"type": "string", "format": "date-time"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
RequiredScope: model.ScopeServerRead,
|
||||
Handler: handleServerGet,
|
||||
})
|
||||
@@ -104,7 +157,7 @@ func handleServerList(c *gin.Context, raw json.RawMessage) (any, error) {
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
return serverListResult{Servers: out, Count: len(out)}, nil
|
||||
}
|
||||
|
||||
// server.get
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestServerList_FiltersByPermission(t *testing.T) {
|
||||
srv2.SetUserID(999)
|
||||
singleton.ServerShared.InsertForTest(srv2)
|
||||
|
||||
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
||||
tok, _ := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
|
||||
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
|
||||
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
|
||||
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}),
|
||||
@@ -30,13 +30,26 @@ func TestServerList_FiltersByPermission(t *testing.T) {
|
||||
_, tcr := decodeRPC(w)
|
||||
require.False(t, tcr.IsError)
|
||||
|
||||
rb, _ := json.Marshal(tcr.StructuredContent)
|
||||
var rows []map[string]any
|
||||
require.NoError(t, json.Unmarshal(rb, &rows))
|
||||
rows := decodeServerListRows(t, tcr.StructuredContent)
|
||||
require.Len(t, rows, 1, "must filter out non-owned server")
|
||||
require.EqualValues(t, 7, rows[0]["id"])
|
||||
}
|
||||
|
||||
// decodeServerListRows unwraps the {servers,count} object that server.list now
|
||||
// returns (MCP requires structuredContent to be an object, not a bare array)
|
||||
// and asserts count stays in sync with the servers slice length.
|
||||
func decodeServerListRows(t *testing.T, structured any) []map[string]any {
|
||||
t.Helper()
|
||||
rb, _ := json.Marshal(structured)
|
||||
var res struct {
|
||||
Servers []map[string]any `json:"servers"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rb, &res))
|
||||
require.Equal(t, len(res.Servers), res.Count, "count must match servers length")
|
||||
return res.Servers
|
||||
}
|
||||
|
||||
func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) {
|
||||
cleanup, uid := setupMCPTest(t)
|
||||
defer cleanup()
|
||||
@@ -47,7 +60,7 @@ func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) {
|
||||
srv2.SetUserID(uid)
|
||||
singleton.ServerShared.InsertForTest(srv2)
|
||||
|
||||
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, []uint64{8})
|
||||
tok, _ := mkToken(t, uid, []string{model.ScopeInventoryRead}, []uint64{8})
|
||||
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
|
||||
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
|
||||
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}),
|
||||
@@ -55,9 +68,7 @@ func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) {
|
||||
mcpEndpoint(c)
|
||||
_, tcr := decodeRPC(w)
|
||||
require.False(t, tcr.IsError)
|
||||
rb, _ := json.Marshal(tcr.StructuredContent)
|
||||
var rows []map[string]any
|
||||
require.NoError(t, json.Unmarshal(rb, &rows))
|
||||
rows := decodeServerListRows(t, tcr.StructuredContent)
|
||||
require.Len(t, rows, 1)
|
||||
require.EqualValues(t, 8, rows[0]["id"])
|
||||
}
|
||||
@@ -70,7 +81,7 @@ func TestServerList_OnlineOnlyFilter(t *testing.T) {
|
||||
require.NotNil(t, srv)
|
||||
srv.LastActive = time.Now()
|
||||
|
||||
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
||||
tok, _ := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
|
||||
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
|
||||
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
|
||||
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: jsonRaw(map[string]any{"online_only": true})}),
|
||||
@@ -78,9 +89,7 @@ func TestServerList_OnlineOnlyFilter(t *testing.T) {
|
||||
mcpEndpoint(c)
|
||||
_, tcr := decodeRPC(w)
|
||||
require.False(t, tcr.IsError)
|
||||
rb, _ := json.Marshal(tcr.StructuredContent)
|
||||
var rows []map[string]any
|
||||
require.NoError(t, json.Unmarshal(rb, &rows))
|
||||
rows := decodeServerListRows(t, tcr.StructuredContent)
|
||||
require.Len(t, rows, 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ func init() {
|
||||
},
|
||||
"required": []string{"server_id", "path"},
|
||||
},
|
||||
OutputSchema: transferURLOutputSchema(),
|
||||
RequiredScope: model.ScopeServerRead,
|
||||
Handler: handleFsDownloadURL,
|
||||
})
|
||||
@@ -264,11 +265,24 @@ func init() {
|
||||
},
|
||||
"required": []string{"server_id", "path"},
|
||||
},
|
||||
OutputSchema: transferURLOutputSchema(),
|
||||
RequiredScope: model.ScopeServerWrite,
|
||||
Handler: handleFsUploadURL,
|
||||
})
|
||||
}
|
||||
|
||||
func transferURLOutputSchema() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string"},
|
||||
"method": map[string]any{"type": "string"},
|
||||
"expires_at": map[string]any{"type": "string", "format": "date-time"},
|
||||
},
|
||||
"required": []string{"url", "method", "expires_at"},
|
||||
}
|
||||
}
|
||||
|
||||
func handleFsDownloadURL(c *gin.Context, raw json.RawMessage) (any, error) {
|
||||
var args fsDownloadURLArgs
|
||||
if err := decodeToolArgs(raw, &args); err != nil {
|
||||
|
||||
@@ -19,7 +19,7 @@ func setupRESTScopeTest(t *testing.T) (*httptest.Server, *model.APIToken, string
|
||||
t.Helper()
|
||||
cleanupBase, uid := setupMCPTest(t)
|
||||
|
||||
tok, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
||||
tok, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -27,7 +27,7 @@ func setupRESTScopeTest(t *testing.T) (*httptest.Server, *model.APIToken, string
|
||||
|
||||
r.GET("/server",
|
||||
patMw,
|
||||
restScopeMiddleware(model.ScopeServerRead),
|
||||
restScopeMiddleware(model.ScopeInventoryRead),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||
)
|
||||
r.POST("/server/config",
|
||||
@@ -102,11 +102,13 @@ func TestREST_PATWildcardCoversAllVerbs(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
patMw := apiTokenAuthMiddleware()
|
||||
r.GET("/server", patMw, restScopeMiddleware(model.ScopeServerRead),
|
||||
r.GET("/server/config/0", patMw, restScopeMiddleware(model.ScopeServerRead),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
r.POST("/server/config", patMw, restScopeMiddleware(model.ScopeServerWrite),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
r.POST("/batch-delete/server", patMw, restScopeMiddleware(model.ScopeServerDelete),
|
||||
r.POST("/file", patMw, restScopeMiddleware(model.ScopeServerDelete),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
r.POST("/terminal", patMw, restScopeMiddleware(model.ScopeServerExec),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
@@ -114,9 +116,10 @@ func TestREST_PATWildcardCoversAllVerbs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{"GET", "/server"},
|
||||
{"GET", "/server/config/0"},
|
||||
{"POST", "/server/config"},
|
||||
{"POST", "/batch-delete/server"},
|
||||
{"POST", "/file"},
|
||||
{"POST", "/terminal"},
|
||||
} {
|
||||
resp := doReq(t, ts, tc.method, tc.path, plain)
|
||||
resp.Body.Close()
|
||||
@@ -158,7 +161,7 @@ func TestREST_NoAuthGoesToJWTChain(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.GET("/server",
|
||||
jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt),
|
||||
restScopeMiddleware(model.ScopeServerRead),
|
||||
restScopeMiddleware(model.ScopeInventoryRead),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||
)
|
||||
ts := httptest.NewServer(r)
|
||||
@@ -184,7 +187,7 @@ func TestREST_BadPATShortCircuitsBeforeJWT(t *testing.T) {
|
||||
})
|
||||
r.GET("/server",
|
||||
jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt),
|
||||
restScopeMiddleware(model.ScopeServerRead),
|
||||
restScopeMiddleware(model.ScopeInventoryRead),
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||
)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
// # Scope naming
|
||||
//
|
||||
// nezha:{resource}:{verb}
|
||||
// resource: server | service | alertrule | cron | ddns | nat |
|
||||
// resource: inventory | server | service | alertrule | cron | ddns | nat |
|
||||
// notification | notification-group | transfer | admin
|
||||
// verb: read | write | delete | exec
|
||||
//
|
||||
// inventory vs server:inventory 管“能看到/能删哪些机器”(列出 server /
|
||||
// server-group、删除 server / server-group、MCP server.list);server 管对
|
||||
// 已知机器的运行态操作(exec / 文件读写 / 编辑配置 / metrics / server.get)。
|
||||
//
|
||||
// nezha:* Admin-only superuser
|
||||
// nezha:admin:* Admin-only user/waf/setting/online-user management
|
||||
// nezha:<res>:* All actions on a resource
|
||||
@@ -22,7 +26,7 @@
|
||||
// # MCP tools (POST /mcp tools/call)
|
||||
//
|
||||
// meta.whoami — (any scope)
|
||||
// server.list nezha:server:read
|
||||
// server.list nezha:inventory:read
|
||||
// server.get nezha:server:read
|
||||
// server.exec nezha:server:exec
|
||||
// fs.list nezha:server:read
|
||||
@@ -34,22 +38,22 @@
|
||||
//
|
||||
// # REST endpoints (PAT required scope)
|
||||
//
|
||||
// GET /api/v1/server nezha:server:read
|
||||
// GET /api/v1/server nezha:inventory:read
|
||||
// PATCH /api/v1/server/{id} nezha:server:write
|
||||
// GET /api/v1/server/config/{id} nezha:server:write
|
||||
// POST /api/v1/server/config nezha:server:write
|
||||
// POST /api/v1/batch-delete/server nezha:server:delete
|
||||
// POST /api/v1/batch-delete/server nezha:inventory:delete
|
||||
// POST /api/v1/batch-move/server nezha:server:write
|
||||
// POST /api/v1/force-update/server nezha:server:write
|
||||
// POST /api/v1/server-group nezha:server:write
|
||||
// PATCH /api/v1/server-group/{id} nezha:server:write
|
||||
// POST /api/v1/batch-delete/server-group nezha:server:delete
|
||||
// POST /api/v1/batch-delete/server-group nezha:inventory:delete
|
||||
// POST /api/v1/terminal nezha:server:exec
|
||||
// GET /api/v1/ws/terminal/{id} nezha:server:exec
|
||||
// POST /api/v1/file nezha:server:write
|
||||
// GET /api/v1/ws/file/{id} nezha:server:write
|
||||
// GET /api/v1/ws/server nezha:server:read
|
||||
// GET /api/v1/server-group nezha:server:read
|
||||
// POST /api/v1/file nezha:server:read+write+delete
|
||||
// GET /api/v1/ws/file/{id} nezha:server:read+write+delete
|
||||
// GET /api/v1/ws/server nezha:inventory:read
|
||||
// GET /api/v1/server-group nezha:inventory:read
|
||||
// GET /api/v1/service nezha:service:read
|
||||
// GET /api/v1/service/server nezha:service:read
|
||||
// GET /api/v1/service/{id}/history nezha:service:read
|
||||
|
||||
@@ -32,25 +32,25 @@ type scopedRoute struct {
|
||||
|
||||
func canonicalRoutes() []scopedRoute {
|
||||
return []scopedRoute{
|
||||
{"GET", "/api/v1/server", "nezha:server:read"},
|
||||
{"GET", "/api/v1/server", "nezha:inventory:read"},
|
||||
{"PATCH", "/api/v1/server/{id}", "nezha:server:write"},
|
||||
{"GET", "/api/v1/server/config/{id}", "nezha:server:write"},
|
||||
{"POST", "/api/v1/server/config", "nezha:server:write"},
|
||||
{"POST", "/api/v1/batch-delete/server", "nezha:server:delete"},
|
||||
{"POST", "/api/v1/batch-delete/server", "nezha:inventory:delete"},
|
||||
{"POST", "/api/v1/batch-move/server", "nezha:server:write"},
|
||||
{"POST", "/api/v1/force-update/server", "nezha:server:write"},
|
||||
{"POST", "/api/v1/server-group", "nezha:server:write"},
|
||||
{"PATCH", "/api/v1/server-group/{id}", "nezha:server:write"},
|
||||
{"POST", "/api/v1/batch-delete/server-group", "nezha:server:delete"},
|
||||
{"POST", "/api/v1/batch-delete/server-group", "nezha:inventory:delete"},
|
||||
{"POST", "/api/v1/terminal", "nezha:server:exec"},
|
||||
{"GET", "/api/v1/ws/terminal/{id}", "nezha:server:exec"},
|
||||
{"POST", "/api/v1/file", "nezha:server:write"},
|
||||
{"GET", "/api/v1/ws/file/{id}", "nezha:server:write"},
|
||||
{"POST", "/api/v1/file", "nezha:server:read+write+delete"},
|
||||
{"GET", "/api/v1/ws/file/{id}", "nezha:server:read+write+delete"},
|
||||
// optional-auth scoped routes(controller.go:91-98)。这些 GET 端点既支持
|
||||
// 未登录访客,也接受 PAT;当走 PAT 路径时 restScopeMiddleware 会强制对应的
|
||||
// read scope。漏掉这一段会让 scope_doc.go 与实际 router 漂移而测试不报错。
|
||||
{"GET", "/api/v1/ws/server", "nezha:server:read"},
|
||||
{"GET", "/api/v1/server-group", "nezha:server:read"},
|
||||
{"GET", "/api/v1/ws/server", "nezha:inventory:read"},
|
||||
{"GET", "/api/v1/server-group", "nezha:inventory:read"},
|
||||
{"GET", "/api/v1/service", "nezha:service:read"},
|
||||
{"GET", "/api/v1/service/server", "nezha:service:read"},
|
||||
{"GET", "/api/v1/service/{id}/history", "nezha:service:read"},
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// @Security BearerAuth
|
||||
// @Security APITokenAuth
|
||||
// @Schemes
|
||||
// @Description List server. PAT scope required: nezha:server:read.
|
||||
// @Description List server. PAT scope required: nezha:inventory:read.
|
||||
// @Tags auth required
|
||||
// @Param id query uint false "Resource ID"
|
||||
// @Produce json
|
||||
|
||||
+24
-12
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// Scope 命名规范(唯一一套):nezha:{resource}:{verb}
|
||||
//
|
||||
// - resource: server / service / alertrule / cron / ddns / nat /
|
||||
// - resource: inventory / server / service / alertrule / cron / ddns / nat /
|
||||
// notification / notification-group / transfer / admin
|
||||
// - verb: read / write / delete / exec
|
||||
//
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
// - nezha:server:* 给定资源的所有动作
|
||||
// - nezha:* admin-only 全权
|
||||
//
|
||||
// 同一 scope 同时管 MCP tool 和 REST endpoint:例如 nezha:server:read 既允许
|
||||
// `server.list` MCP tool,也允许 `GET /api/v1/server`。
|
||||
// inventory 与 server 已拆开:inventory 管“能看到/能删哪些机器”——`server.list`
|
||||
// MCP tool、`GET /api/v1/server`、`/server-group`、batch-delete server/group 都用
|
||||
// nezha:inventory:{read,delete};server 管对已知机器的运行态操作(server.get、
|
||||
// exec、文件读写、编辑配置、metrics)。同一 scope 同时管 MCP tool 和 REST endpoint。
|
||||
//
|
||||
// 历史上还有 mcp:* 一套,会被 HasScope 通过别名映射到 nezha:server:* 子集。
|
||||
// 由于 HasScope 同时服务 MCP tool 调度与 REST scope middleware,旧 mcp:fs:write
|
||||
@@ -33,6 +35,14 @@ import (
|
||||
const (
|
||||
ScopeNezhaAll = "nezha:*"
|
||||
|
||||
// inventory 资源域:管理后台对“服务器清单”本身的枚举与删除(列出 GET /server、
|
||||
// 删除 batch-delete/server、server-group 的列出/删除,以及 MCP server.list)。
|
||||
// 刻意与 nezha:server:* 分开:后者是对已知 server 的运行态操作(exec / 文件读写 /
|
||||
// 编辑 / metrics),而 inventory 是“能看到/能删哪些机器”的台账权限。拆开后,
|
||||
// 一张只跑命令的 PAT 不必同时具备遍历和删除整个清单的能力。
|
||||
ScopeInventoryRead = "nezha:inventory:read"
|
||||
ScopeInventoryDelete = "nezha:inventory:delete"
|
||||
|
||||
ScopeServerRead = "nezha:server:read"
|
||||
ScopeServerWrite = "nezha:server:write"
|
||||
ScopeServerDelete = "nezha:server:delete"
|
||||
@@ -75,6 +85,7 @@ const (
|
||||
)
|
||||
|
||||
var AllScopes = []string{
|
||||
ScopeInventoryRead, ScopeInventoryDelete,
|
||||
ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec,
|
||||
ScopeServiceRead, ScopeServiceWrite, ScopeServiceDelete,
|
||||
ScopeAlertRuleRead, ScopeAlertRuleWrite, ScopeAlertRuleDelete,
|
||||
@@ -85,6 +96,7 @@ var AllScopes = []string{
|
||||
ScopeNotificationGroupRead, ScopeNotificationGroupWrite, ScopeNotificationGroupDelete,
|
||||
ScopeTransferRead, ScopeTransferWrite, ScopeTransferDelete,
|
||||
|
||||
"nezha:inventory:*",
|
||||
"nezha:server:*",
|
||||
"nezha:service:*",
|
||||
"nezha:alertrule:*",
|
||||
@@ -124,17 +136,17 @@ const APITokenPrefix = "nzp_"
|
||||
// APIToken 是用户用于程序化访问的长期凭证。MCP 接入点 /mcp 用它做鉴权。
|
||||
// 双层鉴权:闸 1 用 UserID 复用 Server.HasPermission;闸 2 用 Scopes / ServerIDs。
|
||||
type APIToken struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
|
||||
UserID uint64 `gorm:"index" json:"user_id,omitempty"`
|
||||
Name string `gorm:"type:varchar(128)" json:"name,omitempty"`
|
||||
TokenHash string `gorm:"uniqueIndex;type:char(64)" json:"-"`
|
||||
ScopesCSV string `gorm:"type:text" json:"-"`
|
||||
ServersCSV string `gorm:"type:text" json:"-"`
|
||||
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
|
||||
UserID uint64 `gorm:"index" json:"user_id,omitempty"`
|
||||
Name string `gorm:"type:varchar(128)" json:"name,omitempty"`
|
||||
TokenHash string `gorm:"uniqueIndex;type:char(64)" json:"-"`
|
||||
ScopesCSV string `gorm:"type:text" json:"-"`
|
||||
ServersCSV string `gorm:"type:text" json:"-"`
|
||||
ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
LastUsedIP string `gorm:"type:varchar(64)" json:"last_used_ip,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
|
||||
LastUsedIP string `gorm:"type:varchar(64)" json:"last_used_ip,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func (APIToken) TableName() string {
|
||||
|
||||
Reference in New Issue
Block a user