fix(security): elevate management HTTP routes from Operator to Admin role

Management endpoints (MCP, Agents, Skills, Channels, Builtin Tools)
previously used auto-detect auth which allowed Operator-level users
to perform CRUD operations. Now consistent with WS policy: Admin+
required for writes, Viewer+ for reads.

Also moves WS skills.update to isAdminMethod and improves 403 error
messages to include required role info.
This commit is contained in:
viettranx committed 2026-03-29 09:02:48 +07:00
1 parent 8b75e095e9
commit 028ea8a7d8
12 files changed
+84 -54

No files matched your search

+1 -1
View File
@@ -71,7 +71,7 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol
client.SendResponse(protocol.NewErrorResponse(
req.ID,
protocol.ErrUnauthorized,
i18n.T(locale, i18n.MsgPermissionDenied, req.Method),
i18n.T(locale, i18n.MsgPermissionDenied, req.Method+" requires "+string(permissions.MethodRole(req.Method))+" role"),
))
return
}
+19 -9
View File
@@ -14,6 +14,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -66,27 +67,36 @@ func (h *AgentsHandler) emitCacheInvalidate(kind, key string) {
// RegisterRoutes registers all agent management routes on the given mux.
func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
// Agent CRUD (reads: viewer+, writes: admin+)
mux.HandleFunc("GET /v1/agents", h.authMiddleware(h.handleList))
mux.HandleFunc("POST /v1/agents", h.authMiddleware(h.handleCreate))
mux.HandleFunc("POST /v1/agents", h.adminMiddleware(h.handleCreate))
mux.HandleFunc("GET /v1/agents/{id}", h.authMiddleware(h.handleGet))
mux.HandleFunc("PUT /v1/agents/{id}", h.authMiddleware(h.handleUpdate))
mux.HandleFunc("DELETE /v1/agents/{id}", h.authMiddleware(h.handleDelete))
mux.HandleFunc("PUT /v1/agents/{id}", h.adminMiddleware(h.handleUpdate))
mux.HandleFunc("DELETE /v1/agents/{id}", h.adminMiddleware(h.handleDelete))
// Sharing (admin+)
mux.HandleFunc("GET /v1/agents/{id}/shares", h.authMiddleware(h.handleListShares))
mux.HandleFunc("POST /v1/agents/{id}/shares", h.authMiddleware(h.handleShare))
mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.authMiddleware(h.handleRevokeShare))
mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.authMiddleware(h.handleRegenerate))
mux.HandleFunc("POST /v1/agents/{id}/resummon", h.authMiddleware(h.handleResummon))
mux.HandleFunc("POST /v1/agents/{id}/shares", h.adminMiddleware(h.handleShare))
mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.adminMiddleware(h.handleRevokeShare))
// Agent operations (admin+)
mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.adminMiddleware(h.handleRegenerate))
mux.HandleFunc("POST /v1/agents/{id}/resummon", h.adminMiddleware(h.handleResummon))
// Read-only (viewer+)
mux.HandleFunc("GET /v1/agents/{id}/codex-pool-activity", h.authMiddleware(h.handleCodexPoolActivity))
mux.HandleFunc("GET /v1/agents/{id}/instances", h.authMiddleware(h.handleListInstances))
mux.HandleFunc("GET /v1/agents/{id}/instances/{userID}/files", h.authMiddleware(h.handleGetInstanceFiles))
mux.HandleFunc("PUT /v1/agents/{id}/instances/{userID}/files/{fileName}", h.authMiddleware(h.handleSetInstanceFile))
mux.HandleFunc("PATCH /v1/agents/{id}/instances/{userID}/metadata", h.authMiddleware(h.handleUpdateInstanceMetadata))
// Instance writes (admin+)
mux.HandleFunc("PUT /v1/agents/{id}/instances/{userID}/files/{fileName}", h.adminMiddleware(h.handleSetInstanceFile))
mux.HandleFunc("PATCH /v1/agents/{id}/instances/{userID}/metadata", h.adminMiddleware(h.handleUpdateInstanceMetadata))
}
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return requireAuth("", next)
}
func (h *AgentsHandler) adminMiddleware(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(permissions.RoleAdmin, next)
}
func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
if userID == "" {
+2 -2
View File
@@ -367,7 +367,7 @@ func requireAuth(minRole permissions.Role, next http.HandlerFunc) http.HandlerFu
if !permissions.HasMinRole(auth.Role, required) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path),
"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path+" requires "+string(required)+" role"),
})
return
}
@@ -398,7 +398,7 @@ func requireAuthBearer(minRole permissions.Role, bearer string, w http.ResponseW
if !permissions.HasMinRole(auth.Role, required) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path),
"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path+" requires "+string(required)+" role"),
})
return r, false
}
+9 -3
View File
@@ -9,6 +9,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -29,17 +30,22 @@ func NewBuiltinToolsHandler(s store.BuiltinToolStore, tenantCfgs store.BuiltinTo
// RegisterRoutes registers all built-in tool routes on the given mux.
func (h *BuiltinToolsHandler) RegisterRoutes(mux *http.ServeMux) {
// Builtin tools (reads: viewer+, writes: admin+)
mux.HandleFunc("GET /v1/tools/builtin", h.auth(h.handleList))
mux.HandleFunc("GET /v1/tools/builtin/{name}", h.auth(h.handleGet))
mux.HandleFunc("PUT /v1/tools/builtin/{name}", h.auth(h.handleUpdate))
mux.HandleFunc("PUT /v1/tools/builtin/{name}/tenant-config", h.auth(h.handleSetTenantConfig))
mux.HandleFunc("DELETE /v1/tools/builtin/{name}/tenant-config", h.auth(h.handleDeleteTenantConfig))
mux.HandleFunc("PUT /v1/tools/builtin/{name}", h.adminAuth(h.handleUpdate))
mux.HandleFunc("PUT /v1/tools/builtin/{name}/tenant-config", h.adminAuth(h.handleSetTenantConfig))
mux.HandleFunc("DELETE /v1/tools/builtin/{name}/tenant-config", h.adminAuth(h.handleDeleteTenantConfig))
}
func (h *BuiltinToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth("", next)
}
func (h *BuiltinToolsHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(permissions.RoleAdmin, next)
}
func (h *BuiltinToolsHandler) emitCacheInvalidate(key string) {
if h.msgBus == nil {
return
+13 -7
View File
@@ -11,6 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -32,18 +33,19 @@ func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.A
// RegisterRoutes registers all channel instance routes on the given mux.
func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
// Channel instance CRUD (reads: viewer+, writes: admin+)
mux.HandleFunc("GET /v1/channels/instances", h.auth(h.handleList))
mux.HandleFunc("POST /v1/channels/instances", h.auth(h.handleCreate))
mux.HandleFunc("POST /v1/channels/instances", h.adminAuth(h.handleCreate))
mux.HandleFunc("GET /v1/channels/instances/{id}", h.auth(h.handleGet))
mux.HandleFunc("PUT /v1/channels/instances/{id}", h.auth(h.handleUpdate))
mux.HandleFunc("DELETE /v1/channels/instances/{id}", h.auth(h.handleDelete))
mux.HandleFunc("PUT /v1/channels/instances/{id}", h.adminAuth(h.handleUpdate))
mux.HandleFunc("DELETE /v1/channels/instances/{id}", h.adminAuth(h.handleDelete))
// Channel contacts (global, not per-agent)
if h.contactStore != nil {
mux.HandleFunc("GET /v1/contacts", h.auth(h.handleListContacts))
mux.HandleFunc("GET /v1/contacts/resolve", h.auth(h.handleResolveContacts))
mux.HandleFunc("POST /v1/contacts/merge", h.auth(h.handleMergeContacts))
mux.HandleFunc("POST /v1/contacts/unmerge", h.auth(h.handleUnmergeContacts))
mux.HandleFunc("POST /v1/contacts/merge", h.adminAuth(h.handleMergeContacts))
mux.HandleFunc("POST /v1/contacts/unmerge", h.adminAuth(h.handleUnmergeContacts))
mux.HandleFunc("GET /v1/contacts/merged/{tenantUserId}", h.auth(h.handleListMergedContacts))
}
if h.tenantStore != nil {
@@ -54,8 +56,8 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
if h.configPermStore != nil {
mux.HandleFunc("GET /v1/channels/instances/{id}/writers/groups", h.auth(h.handleWriterGroups))
mux.HandleFunc("GET /v1/channels/instances/{id}/writers", h.auth(h.handleListWriters))
mux.HandleFunc("POST /v1/channels/instances/{id}/writers", h.auth(h.handleAddWriter))
mux.HandleFunc("DELETE /v1/channels/instances/{id}/writers/{userId}", h.auth(h.handleRemoveWriter))
mux.HandleFunc("POST /v1/channels/instances/{id}/writers", h.adminAuth(h.handleAddWriter))
mux.HandleFunc("DELETE /v1/channels/instances/{id}/writers/{userId}", h.adminAuth(h.handleRemoveWriter))
}
}
@@ -63,6 +65,10 @@ func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth("", next)
}
func (h *ChannelInstancesHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(permissions.RoleAdmin, next)
}
func (h *ChannelInstancesHandler) emitCacheInvalidate() {
if h.msgBus == nil {
return
+22 -17
View File
@@ -9,6 +9,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -51,42 +52,46 @@ func (h *MCPHandler) emitCacheInvalidate() {
// RegisterRoutes registers all MCP management routes on the given mux.
func (h *MCPHandler) RegisterRoutes(mux *http.ServeMux) {
// Server CRUD
// Server CRUD (reads: viewer+, writes: admin+)
mux.HandleFunc("GET /v1/mcp/servers", h.auth(h.handleListServers))
mux.HandleFunc("POST /v1/mcp/servers", h.auth(h.handleCreateServer))
mux.HandleFunc("POST /v1/mcp/servers", h.adminAuth(h.handleCreateServer))
mux.HandleFunc("GET /v1/mcp/servers/{id}", h.auth(h.handleGetServer))
mux.HandleFunc("PUT /v1/mcp/servers/{id}", h.auth(h.handleUpdateServer))
mux.HandleFunc("DELETE /v1/mcp/servers/{id}", h.auth(h.handleDeleteServer))
mux.HandleFunc("PUT /v1/mcp/servers/{id}", h.adminAuth(h.handleUpdateServer))
mux.HandleFunc("DELETE /v1/mcp/servers/{id}", h.adminAuth(h.handleDeleteServer))
// Test connection (no save)
mux.HandleFunc("POST /v1/mcp/servers/test", h.auth(h.handleTestConnection))
// Test connection (admin+ — infra operation)
mux.HandleFunc("POST /v1/mcp/servers/test", h.adminAuth(h.handleTestConnection))
// Reconnect (evict pooled connection so next use re-discovers tools)
mux.HandleFunc("POST /v1/mcp/servers/{id}/reconnect", h.auth(h.handleReconnectServer))
// Reconnect (admin+ — evict pooled connection)
mux.HandleFunc("POST /v1/mcp/servers/{id}/reconnect", h.adminAuth(h.handleReconnectServer))
// Server tools (runtime-discovered)
// Server tools (read-only: viewer+)
mux.HandleFunc("GET /v1/mcp/servers/{id}/tools", h.auth(h.handleListServerTools))
// Agent grants
// Agent grants (reads: viewer+, writes: admin+)
mux.HandleFunc("GET /v1/mcp/servers/{id}/grants", h.auth(h.handleListServerGrants))
mux.HandleFunc("POST /v1/mcp/servers/{id}/grants/agent", h.auth(h.handleGrantAgent))
mux.HandleFunc("DELETE /v1/mcp/servers/{id}/grants/agent/{agentID}", h.auth(h.handleRevokeAgent))
mux.HandleFunc("POST /v1/mcp/servers/{id}/grants/agent", h.adminAuth(h.handleGrantAgent))
mux.HandleFunc("DELETE /v1/mcp/servers/{id}/grants/agent/{agentID}", h.adminAuth(h.handleRevokeAgent))
mux.HandleFunc("GET /v1/mcp/grants/agent/{agentID}", h.auth(h.handleListAgentGrants))
// User grants
mux.HandleFunc("POST /v1/mcp/servers/{id}/grants/user", h.auth(h.handleGrantUser))
mux.HandleFunc("DELETE /v1/mcp/servers/{id}/grants/user/{userID}", h.auth(h.handleRevokeUser))
// User grants (admin+)
mux.HandleFunc("POST /v1/mcp/servers/{id}/grants/user", h.adminAuth(h.handleGrantUser))
mux.HandleFunc("DELETE /v1/mcp/servers/{id}/grants/user/{userID}", h.adminAuth(h.handleRevokeUser))
// Access requests
// Access requests (create: viewer+, list: viewer+, review: admin+)
mux.HandleFunc("POST /v1/mcp/requests", h.auth(h.handleCreateRequest))
mux.HandleFunc("GET /v1/mcp/requests", h.auth(h.handleListPendingRequests))
mux.HandleFunc("POST /v1/mcp/requests/{id}/review", h.auth(h.handleReviewRequest))
mux.HandleFunc("POST /v1/mcp/requests/{id}/review", h.adminAuth(h.handleReviewRequest))
}
func (h *MCPHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth("", next)
}
func (h *MCPHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(permissions.RoleAdmin, next)
}
// --- Server CRUD ---
// mcpServerWithCounts extends MCPServerData with agent grant count for list responses.
+13 -10
View File
@@ -58,19 +58,22 @@ func (h *SkillsHandler) emitCacheInvalidate(kind, key string) {
// RegisterRoutes registers all skill management routes on the given mux.
func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) {
// Skill reads (viewer+)
mux.HandleFunc("GET /v1/skills", h.authMiddleware(h.handleList))
mux.HandleFunc("POST /v1/skills/upload", h.authMiddleware(h.handleUpload))
mux.HandleFunc("GET /v1/skills/{id}", h.authMiddleware(h.handleGet))
mux.HandleFunc("PUT /v1/skills/{id}", h.authMiddleware(h.handleUpdate))
mux.HandleFunc("DELETE /v1/skills/{id}", h.authMiddleware(h.handleDelete))
mux.HandleFunc("POST /v1/skills/{id}/grants/agent", h.authMiddleware(h.handleGrantAgent))
mux.HandleFunc("DELETE /v1/skills/{id}/grants/agent/{agentID}", h.authMiddleware(h.handleRevokeAgent))
mux.HandleFunc("POST /v1/skills/{id}/grants/user", h.authMiddleware(h.handleGrantUser))
mux.HandleFunc("DELETE /v1/skills/{id}/grants/user/{userID}", h.authMiddleware(h.handleRevokeUser))
mux.HandleFunc("GET /v1/agents/{agentID}/skills", h.authMiddleware(h.handleListAgentSkills))
mux.HandleFunc("GET /v1/skills/{id}/versions", h.authMiddleware(h.handleListVersions))
mux.HandleFunc("GET /v1/skills/{id}/files/{path...}", h.authMiddleware(h.handleReadFile))
mux.HandleFunc("GET /v1/skills/{id}/files", h.authMiddleware(h.handleListFiles))
// Skill writes (admin+)
mux.HandleFunc("POST /v1/skills/upload", h.adminMiddleware(h.handleUpload))
mux.HandleFunc("PUT /v1/skills/{id}", h.adminMiddleware(h.handleUpdate))
mux.HandleFunc("DELETE /v1/skills/{id}", h.adminMiddleware(h.handleDelete))
// Skill grants (admin+)
mux.HandleFunc("POST /v1/skills/{id}/grants/agent", h.adminMiddleware(h.handleGrantAgent))
mux.HandleFunc("DELETE /v1/skills/{id}/grants/agent/{agentID}", h.adminMiddleware(h.handleRevokeAgent))
mux.HandleFunc("POST /v1/skills/{id}/grants/user", h.adminMiddleware(h.handleGrantUser))
mux.HandleFunc("DELETE /v1/skills/{id}/grants/user/{userID}", h.adminMiddleware(h.handleRevokeUser))
// System-level operations: admin + master tenant only.
// These execute shell commands (pip/npm install) and affect the entire server.
mux.HandleFunc("POST /v1/skills/rescan-deps", h.adminMiddleware(h.handleRescanDeps))
@@ -78,9 +81,9 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/skills/install-dep", h.adminMiddleware(h.handleInstallDep))
mux.HandleFunc("GET /v1/skills/runtimes", h.adminMiddleware(h.handleRuntimes))
mux.HandleFunc("POST /v1/skills/{id}/toggle", h.adminMiddleware(h.handleToggle))
// Per-tenant overrides: tenant-level admin check inside handler (not system admin).
mux.HandleFunc("PUT /v1/skills/{id}/tenant-config", h.authMiddleware(h.handleSetTenantConfig))
mux.HandleFunc("DELETE /v1/skills/{id}/tenant-config", h.authMiddleware(h.handleDeleteTenantConfig))
// Per-tenant overrides (admin+)
mux.HandleFunc("PUT /v1/skills/{id}/tenant-config", h.adminMiddleware(h.handleSetTenantConfig))
mux.HandleFunc("DELETE /v1/skills/{id}/tenant-config", h.adminMiddleware(h.handleDeleteTenantConfig))
}
func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
+1 -1
View File
@@ -10,7 +10,7 @@ func init() {
MsgInvalidRequest: "invalid request: %s",
MsgInvalidJSON: "invalid JSON",
MsgUnauthorized: "unauthorized",
MsgPermissionDenied: "permission denied: insufficient role for %s",
MsgPermissionDenied: "permission denied: %s",
MsgInternalError: "internal error: %s",
MsgInvalidSlug: "%s must be a valid slug (lowercase letters, numbers, hyphens only)",
MsgFailedToList: "failed to list %s",
+1 -1
View File
@@ -10,7 +10,7 @@ func init() {
MsgInvalidRequest: "yêu cầu không hợp lệ: %s",
MsgInvalidJSON: "JSON không hợp lệ",
MsgUnauthorized: "chưa xác thực",
MsgPermissionDenied: "từ chối quyền truy cập: không đủ quyền cho %s",
MsgPermissionDenied: "từ chối quyền truy cập: %s",
MsgInternalError: "lỗi nội bộ: %s",
MsgInvalidSlug: "%s phải là slug hợp lệ (chữ thường, số, dấu gạch ngang)",
MsgFailedToList: "không thể liệt kê %s",
+1 -1
View File
@@ -10,7 +10,7 @@ func init() {
MsgInvalidRequest: "无效请求:%s",
MsgInvalidJSON: "无效的 JSON",
MsgUnauthorized: "未授权",
MsgPermissionDenied: "权限不足:无法访问 %s",
MsgPermissionDenied: "权限不足:%s",
MsgInternalError: "内部错误:%s",
MsgInvalidSlug: "%s 必须是有效的 slug(小写字母、数字、连字符)",
MsgFailedToList: "获取 %s 列表失败",
+1 -1
View File
@@ -11,7 +11,7 @@ const (
MsgInvalidRequest = "error.invalid_request" // "invalid request: %s"
MsgInvalidJSON = "error.invalid_json" // "invalid JSON"
MsgUnauthorized = "error.unauthorized" // "unauthorized"
MsgPermissionDenied = "error.permission_denied" // "permission denied: insufficient role for %s"
MsgPermissionDenied = "error.permission_denied" // "permission denied: %s"
MsgInternalError = "error.internal" // "internal error: %s"
MsgInvalidSlug = "error.invalid_slug" // "%s must be a valid slug (lowercase letters, numbers, hyphens only)"
MsgFailedToList = "error.failed_to_list" // "failed to list %s"
+1 -1
View File
@@ -180,6 +180,7 @@ func isAdminMethod(method string) bool {
protocol.MethodAPIKeysList,
protocol.MethodAPIKeysCreate,
protocol.MethodAPIKeysRevoke,
protocol.MethodSkillsUpdate,
}
return slices.Contains(adminMethods, method)
}
@@ -195,7 +196,6 @@ func isWriteMethod(method string) bool {
protocol.MethodCronUpdate,
protocol.MethodCronDelete,
protocol.MethodCronToggle,
protocol.MethodSkillsUpdate,
"pairing.",
"device.pair.",
"approvals.",