package security import ( "net/http" "strings" "cyberstrike-ai/internal/database" "github.com/gin-gonic/gin" ) // RBACMiddleware maps protected API routes to platform permissions. It keeps // enforcement centralized so route declarations stay readable. func RBACMiddleware(db *database.DB) gin.HandlerFunc { return RBACMiddlewareWithDenyHook(db, nil) } type RBACDenyHook func(c *gin.Context, reason, permission string) func RBACMiddlewareWithDenyHook(db *database.DB, denyHook RBACDenyHook) gin.HandlerFunc { return func(c *gin.Context) { permission := permissionForRequest(c.Request.Method, c.FullPath()) if permission == "" { if denyHook != nil { denyHook(c, "unmapped_route", "") } c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "未配置访问权限", }) return } permission, allowed := sessionHasRoutePermission(c, c.Request.Method, c.FullPath()) if !allowed { if denyHook != nil { denyHook(c, "permission_denied", permission) } c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "权限不足", "permission": permission, }) return } // Bind the scope of the permission authorizing this request. Scope is // permission-specific; using the user's broadest role scope here would // let an unrelated global read role widen a write permission. session, _ := CurrentSession(c) session.Scope = session.ScopeFor(permission) c.Set(ContextSessionKey, session) c.Set(ContextUserScopeKey, session.Scope) if db != nil && !resourceAllowed(c, db) { if denyHook != nil { denyHook(c, "resource_denied", permission) } c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) return } c.Next() } } func sessionHasRoutePermission(c *gin.Context, method, fullPath string) (string, bool) { path := strings.TrimPrefix(fullPath, "/api") if alts := permissionAlternativesForRequest(method, path); len(alts) > 0 { for _, permission := range alts { if SessionHasPermission(c, permission) { return permission, true } } return alts[0], false } permission := permissionForRequest(method, fullPath) if permission == "" { return "", false } return permission, SessionHasPermission(c, permission) } func permissionAlternativesForRequest(method, path string) []string { if method != http.MethodGet && method != http.MethodHead { return nil } switch { case strings.HasPrefix(path, "/config/tools"): // MCP 管理页只需 mcp:read;系统设置页仍可用 config:read 访问同一接口。 return []string{"mcp:read", "config:read"} default: return nil } } func permissionForRequest(method, fullPath string) string { path := strings.TrimPrefix(fullPath, "/api") switch { case path == "/rbac/me": return "auth:self" case path == "/rbac/resources": // The picker enumerates resource names and IDs and is only needed by // administrators who can actually create assignments. return "rbac:write" case strings.HasPrefix(path, "/rbac"): if method == http.MethodGet { return "rbac:read" } return "rbac:write" case strings.HasPrefix(path, "/robot/wechat/status"): return "robot:read" case strings.HasPrefix(path, "/robot"): return "robot:write" case strings.HasPrefix(path, "/eino-agent"), strings.HasPrefix(path, "/multi-agent"): if strings.Contains(path, "/markdown-agents") { return crudPermission(method, "agents") } return "agent:execute" case strings.HasPrefix(path, "/hitl"): if method == http.MethodGet || method == http.MethodHead { return "hitl:read" } return "hitl:write" case strings.HasPrefix(path, "/agent-loop"), strings.HasPrefix(path, "/batch-tasks"): return crudPermission(method, "tasks") case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"): return crudPermission(method, "chat") case strings.HasPrefix(path, "/groups"): return crudPermission(method, "group") case strings.HasPrefix(path, "/monitor"): return crudPermission(method, "monitor") case strings.HasPrefix(path, "/notifications"): if method == http.MethodGet { return "notification:read" } return "notification:write" case strings.HasPrefix(path, "/config"): return crudPermission(method, "config") case strings.HasPrefix(path, "/terminal"): return "terminal:execute" case strings.HasPrefix(path, "/audit"): return crudPermission(method, "audit") case path == "/mcp": return "mcp:execute" case strings.HasPrefix(path, "/external-mcp"): if method == http.MethodGet || method == http.MethodHead { return "mcp:read" } return "mcp:write" case strings.HasPrefix(path, "/attack-chain"): return crudPermission(method, "attackchain") case strings.HasPrefix(path, "/knowledge"): if path == "/knowledge/search" { return "knowledge:read" } return crudPermission(method, "knowledge") case strings.HasPrefix(path, "/vulnerabilities"): return crudPermission(method, "vulnerability") case strings.HasPrefix(path, "/projects"): return crudPermission(method, "project") case strings.HasPrefix(path, "/webshell"): return crudPermission(method, "webshell") case strings.HasPrefix(path, "/c2"): return crudPermission(method, "c2") case strings.HasPrefix(path, "/chat-uploads"): return crudPermission(method, "files") case strings.HasPrefix(path, "/roles"): return crudPermission(method, "roles") case strings.HasPrefix(path, "/workflows"): if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") { return "workflow:execute" } return crudPermission(method, "workflow") case strings.HasPrefix(path, "/skills"): return crudPermission(method, "skills") case strings.HasPrefix(path, "/openapi"): return "openapi:read" case strings.HasPrefix(path, "/fofa"): return "fofa:execute" default: return "" } } func crudPermission(method, module string) string { switch method { case http.MethodGet, http.MethodHead: return module + ":read" case http.MethodDelete: return module + ":delete" default: return module + ":write" } } func resourceAllowed(c *gin.Context, db *database.DB) bool { session, ok := CurrentSession(c) if !ok || session.Scope == database.RBACScopeAll { return ok } path := strings.TrimPrefix(c.FullPath(), "/api") switch { case path == "/monitor/stats", path == "/monitor/calls-timeline": // These APIs currently operate on process-global state. Until every MCP // invocation and persisted execution record carries an immutable owner, // allowing an assigned/own-scoped session would be a cross-user bypass. return session.Scope == database.RBACScopeAll case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet: return session.Scope == database.RBACScopeAll case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet: return session.Scope == database.RBACScopeAll case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path): // These definitions/configurations are shared by every user and do not // carry owners. A module write permission with assigned/own scope must // not silently become a process-global administrative capability. return session.Scope == database.RBACScopeAll case strings.HasPrefix(path, "/projects/:id"): return db.UserCanAccessResource(session.UserID, session.Scope, "project", c.Param("id")) case strings.HasPrefix(path, "/conversations/:id"): return db.UserCanAccessResource(session.UserID, session.Scope, "conversation", c.Param("id")) case strings.HasPrefix(path, "/messages/:id/process-details"): return db.UserCanAccessMessage(session.UserID, session.Scope, c.Param("id")) case strings.HasPrefix(path, "/process-details/:id"): return db.UserCanAccessProcessDetail(session.UserID, session.Scope, c.Param("id")) case strings.HasPrefix(path, "/attack-chain/:conversationId"): return db.UserCanAccessResource(session.UserID, session.Scope, "conversation", c.Param("conversationId")) case strings.HasPrefix(path, "/webshell/connections/:id"): return db.UserCanAccessResource(session.UserID, session.Scope, "webshell", c.Param("id")) case strings.HasPrefix(path, "/batch-tasks/:queueId"): return db.UserCanAccessResource(session.UserID, session.Scope, "batch_task", c.Param("queueId")) case strings.HasPrefix(path, "/vulnerabilities/:id"): return db.UserCanAccessResource(session.UserID, session.Scope, "vulnerability", c.Param("id")) case strings.HasPrefix(path, "/c2/listeners/:id"): return db.UserCanAccessResource(session.UserID, session.Scope, "c2_listener", c.Param("id")) case strings.HasPrefix(path, "/c2/sessions/:id"): return db.UserCanAccessResource(session.UserID, session.Scope, "c2_session", c.Param("id")) case strings.HasPrefix(path, "/c2/tasks/:id"): return db.UserCanAccessResource(session.UserID, session.Scope, "c2_task", c.Param("id")) default: return true } } func isMutationMethod(method string) bool { switch method { case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: return true default: return false } } func isProcessGlobalMutationPath(path string) bool { if strings.HasPrefix(path, "/roles") || strings.HasPrefix(path, "/skills") || strings.HasPrefix(path, "/external-mcp") || strings.HasPrefix(path, "/robot") { return true } if strings.HasPrefix(path, "/workflows") { // Workflow runs inherit conversation access; definitions are global. return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run" } if strings.HasPrefix(path, "/knowledge") { return path != "/knowledge/search" } if strings.HasPrefix(path, "/eino-agent/markdown-agents") || strings.HasPrefix(path, "/multi-agent/markdown-agents") { return true } return false }