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:
naiba
2026-05-31 15:14:41 +00:00
parent f7f8264ec0
commit 083bc985c5
17 changed files with 257 additions and 76 deletions
@@ -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)
+10 -6
View File
@@ -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 资源族 scoperead/write/delete)。
// 注意:曾经计划让 nezha:server:read 兼听只读 transfer,但 restScopeMiddleware
+10 -7
View File
@@ -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,
})
+51
View File
@@ -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,
})
+58 -5
View File
@@ -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)
}
+14
View File
@@ -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 {
+11 -8
View File
@@ -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)
+13 -9
View File
@@ -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 serverinventory 管“能看到/能删哪些机器”(列出 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 routescontroller.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"},
+1 -1
View File
@@ -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