Add files via upload

This commit is contained in:
公明
2026-07-10 18:54:04 +08:00
committed by GitHub
parent a145687508
commit 1b64f5d8a0
28 changed files with 1040 additions and 125 deletions
+51 -2
View File
@@ -18,6 +18,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp/builtin"
@@ -728,7 +729,22 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
}
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform, conversationID, message, role string) (response string, convID string, err error) {
func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform, ownerUserID, conversationID, message, role string) (response string, convID string, err error) {
permissions := map[string]bool{
"agent:execute": true,
"chat:read": true, "chat:write": true, "chat:delete": true,
"project:read": true, "project:write": true,
"knowledge:read": true,
"vulnerability:read": true, "vulnerability:write": true,
"attackchain:read": true, "attackchain:write": true,
"workflow:read": true,
"hitl:read": true, "hitl:write": true,
}
ownerUserID = strings.TrimSpace(ownerUserID)
if ownerUserID == "" {
return "", "", fmt.Errorf("robot owner identity is required")
}
ctx = authctx.WithPrincipal(ctx, authctx.NewPrincipal(ownerUserID, "robot-service", database.RBACScopeOwn, permissions))
if conversationID == "" {
title := safeTruncateString(message, 50)
src := "robot"
@@ -737,13 +753,17 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform, con
}
meta := audit.ConversationCreateMeta(src)
meta.ProjectID = effectiveProjectID(h.config, "")
if meta.ProjectID != "" && !h.db.UserCanAccessResource(ownerUserID, database.RBACScopeOwn, "project", meta.ProjectID) {
meta.ProjectID = ""
}
conv, createErr := h.db.CreateConversation(title, meta)
if createErr != nil {
return "", "", fmt.Errorf("创建对话失败: %w", createErr)
}
conversationID = conv.ID
_ = h.db.SetResourceOwner("conversation", conversationID, ownerUserID)
} else {
if _, getErr := h.db.GetConversation(conversationID); getErr != nil {
if _, getErr := h.db.GetConversation(conversationID); getErr != nil || !h.db.UserCanAccessResource(ownerUserID, database.RBACScopeOwn, "conversation", conversationID) {
return "", "", fmt.Errorf("对话不存在")
}
}
@@ -1489,6 +1509,10 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !h.agentConversationAllowed(c, req.ConversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if req.ContinueAfter {
if h.tasks.GetTask(req.ConversationID) == nil {
@@ -1570,6 +1594,10 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "conversationId is required"})
return
}
if !h.agentConversationAllowed(c, conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if h.tasks.GetTask(conversationID) == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no active task for this conversation"})
return
@@ -1648,6 +1676,9 @@ func (h *AgentHandler) enrichCompletedTasksWithConversationTitles(tasks []*Compl
// ListAgentTasks 列出所有运行中的任务
func (h *AgentHandler) ListAgentTasks(c *gin.Context) {
tasks := h.tasks.GetActiveTasks()
tasks = filterSlice(tasks, func(task *AgentTask) bool {
return task != nil && h.agentConversationAllowed(c, task.ConversationID)
})
h.enrichAgentTasksWithConversationTitles(tasks)
c.JSON(http.StatusOK, gin.H{
"tasks": tasks,
@@ -1657,12 +1688,30 @@ func (h *AgentHandler) ListAgentTasks(c *gin.Context) {
// ListCompletedTasks 列出最近完成的任务历史
func (h *AgentHandler) ListCompletedTasks(c *gin.Context) {
tasks := h.tasks.GetCompletedTasks()
tasks = filterSlice(tasks, func(task *CompletedTask) bool {
return task != nil && h.agentConversationAllowed(c, task.ConversationID)
})
h.enrichCompletedTasksWithConversationTitles(tasks)
c.JSON(http.StatusOK, gin.H{
"tasks": tasks,
})
}
func (h *AgentHandler) agentConversationAllowed(c *gin.Context, conversationID string) bool {
session, ok := security.CurrentSession(c)
return ok && h.db != nil && h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", strings.TrimSpace(conversationID))
}
func filterSlice[T any](items []T, keep func(T) bool) []T {
out := make([]T, 0, len(items))
for _, item := range items {
if keep(item) {
out = append(out, item)
}
}
return out
}
// BatchTaskRequest 批量任务请求
type BatchTaskRequest struct {
Title string `json:"title"` // 任务标题(可选)
+20 -8
View File
@@ -6,6 +6,7 @@ import (
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -32,8 +33,8 @@ func (h *AuditHandler) Meta(c *gin.Context) {
retentionDays = h.audit.RetentionDays()
}
c.JSON(http.StatusOK, gin.H{
"enabled": enabled,
"retention_days": retentionDays,
"enabled": enabled,
"retention_days": retentionDays,
"default_page_size": 20,
"max_page_size": 100,
"max_export": 5000,
@@ -46,7 +47,7 @@ func (h *AuditHandler) Summary(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"})
return
}
base := auditFilterFromQuery(c)
base := auditFilterForAccess(c, auditFilterFromQuery(c))
total, err := h.db.CountAuditLogs(base)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -68,9 +69,9 @@ func (h *AuditHandler) Summary(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{
"total": total,
"failures": failures,
"recent_7d": recent7d,
"total": total,
"failures": failures,
"recent_7d": recent7d,
"has_filters": c.Query("category") != "" || c.Query("action") != "" || c.Query("result") != "" ||
c.Query("q") != "" || c.Query("since") != "" || c.Query("until") != "",
})
@@ -82,7 +83,7 @@ func (h *AuditHandler) ListLogs(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"})
return
}
filter := auditFilterFromQuery(c)
filter := auditFilterForAccess(c, auditFilterFromQuery(c))
page, pageSize := auditPaginationFromQuery(c)
filter.Limit = pageSize
filter.Offset = (page - 1) * pageSize
@@ -116,6 +117,10 @@ func (h *AuditHandler) GetLog(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "审计记录不存在"})
return
}
if session, ok := security.CurrentSession(c); !ok || (session.Scope != database.RBACScopeAll && row.Actor != session.Username) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
audit.ApplyResourceAvailability(h.db, row)
c.JSON(http.StatusOK, gin.H{"log": row})
}
@@ -126,7 +131,7 @@ func (h *AuditHandler) ExportLogs(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"})
return
}
filter := auditFilterFromQuery(c)
filter := auditFilterForAccess(c, auditFilterFromQuery(c))
filter.Limit = 5000
filter.Offset = 0
@@ -145,3 +150,10 @@ func (h *AuditHandler) ExportLogs(c *gin.Context) {
"logs": logs,
})
}
func auditFilterForAccess(c *gin.Context, filter database.ListAuditLogsFilter) database.ListAuditLogsFilter {
if session, ok := security.CurrentSession(c); ok && session.Scope != database.RBACScopeAll {
filter.Actor = session.Username
}
return filter
}
+10 -6
View File
@@ -64,6 +64,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
Action: "login",
Result: "failure",
Message: "登录失败:密码错误",
Actor: strings.TrimSpace(req.Username),
})
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
@@ -78,6 +79,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
Result: "success",
SessionHint: audit.HintFromToken(token),
Message: "登录成功",
Actor: session.Username,
Detail: map[string]interface{}{
"expires_at": expiresAt.UTC().Format(time.RFC3339),
},
@@ -93,9 +95,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
"username": session.Username,
"display_name": session.DisplayName,
},
"roles": session.Roles,
"permissions": permissionKeys(session.Permissions),
"scope": session.Scope,
"roles": session.Roles,
"permissions": permissionKeys(session.Permissions),
"permission_scopes": session.PermissionScopes,
"scope": session.Scope,
})
}
@@ -234,9 +237,10 @@ func (h *AuthHandler) Validate(c *gin.Context) {
"username": session.Username,
"display_name": session.DisplayName,
},
"roles": session.Roles,
"permissions": permissionKeys(session.Permissions),
"scope": session.Scope,
"roles": session.Roles,
"permissions": permissionKeys(session.Permissions),
"permission_scopes": session.PermissionScopes,
"scope": session.Scope,
})
}
+12 -1
View File
@@ -11,6 +11,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
@@ -109,6 +110,13 @@ func (h *AgentHandler) tryFinalizeBatchQueue(queueID string) {
// executeOneBatchSubTask 执行单条批量子任务(各自独立会话)。
func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQueue, task *BatchTask) {
ownerUserID := h.db.GetResourceOwner("batch_task", queueID)
access, accessErr := h.db.ResolveRBACAccess(ownerUserID)
if accessErr != nil || access == nil || !access.User.Enabled {
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, BatchTaskStatusFailed, "", "队列所有者不存在或已禁用")
return
}
principal := authctx.NewPrincipalWithScopes(access.User.ID, access.User.Username, access.Scope, access.Permissions, access.PermissionScopes)
title := safeTruncateString(task.Message, 50)
batchMeta := audit.ConversationCreateMeta("batch_task")
batchMeta.ProjectID = effectiveProjectID(h.config, queue.ProjectID)
@@ -119,6 +127,8 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
return
}
conversationID := conv.ID
_ = h.db.SetResourceOwner("conversation", conversationID, access.User.ID)
_ = h.db.AssignResourceToUser(access.User.ID, "conversation", conversationID)
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusRunning, "", "", conversationID)
@@ -156,7 +166,8 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("role", queue.Role), zap.String("conversationId", conversationID))
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
principalCtx := authctx.WithPrincipal(context.Background(), principal)
baseCtx, cancelWithCause := context.WithCancelCause(principalCtx)
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 6*time.Hour)
registered := false
+19 -1
View File
@@ -9,7 +9,9 @@ import (
"strings"
"time"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
@@ -74,7 +76,14 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
if offset > 100000 {
offset = 100000
}
queues, total, err := h.batchTaskManager.ListQueues(pageSize, offset, status, keyword)
queues := []*BatchTaskQueue{}
total := 0
var err error
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
queues, total, err = h.batchTaskManager.ListQueuesForAccess(pageSize, offset, status, keyword, principal.UserID, principal.ScopeFor("tasks:read"))
} else {
return batchMCPTextResult("缺少认证身份", true), nil
}
if err != nil {
return batchMCPTextResult(fmt.Sprintf("列出队列失败: %v", err), true), nil
}
@@ -215,11 +224,20 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
executeNow = false
}
projectID := strings.TrimSpace(mcpArgString(args, "project_id"))
if principal, ok := authctx.PrincipalFromContext(ctx); ok && projectID != "" && principal.ScopeFor("tasks:write") != database.RBACScopeAll {
if h.db == nil || !h.db.UserCanAccessResource(principal.UserID, principal.ScopeFor("tasks:write"), "project", projectID) {
return batchMCPTextResult("无权访问目标项目", true), nil
}
}
concurrency := int(mcpArgFloat(args, "concurrency"))
queue, createErr := h.batchTaskManager.CreateBatchQueue(title, role, agentMode, scheduleMode, cronExpr, projectID, nextRunAt, concurrency, tasks)
if createErr != nil {
return batchMCPTextResult("创建队列失败: "+createErr.Error(), true), nil
}
if principal, ok := authctx.PrincipalFromContext(ctx); ok && h.db != nil {
_ = h.db.SetResourceOwner("batch_task", queue.ID, principal.UserID)
_ = h.db.AssignResourceToUser(principal.UserID, "batch_task", queue.ID)
}
started := false
if executeNow {
ok, err := h.startBatchQueueExecution(queue.ID, false)
+16
View File
@@ -536,6 +536,14 @@ func (h *C2Handler) CreateTask(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if conversationID := strings.TrimSpace(req.ConversationID); conversationID != "" {
session, ok := security.CurrentSession(c)
if !ok || !h.mgr().DB().UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权关联目标对话"})
return
}
req.ConversationID = conversationID
}
input := c2.EnqueueTaskInput{
SessionID: req.SessionID,
@@ -722,6 +730,9 @@ func (h *C2Handler) PayloadBuild(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if session, ok := security.CurrentSession(c); ok {
_ = h.mgr().DB().RecordC2PayloadArtifact(filepath.Base(result.OutputPath), result.PayloadID, result.ListenerID, session.UserID)
}
c.JSON(http.StatusOK, gin.H{
"payload": result,
@@ -740,6 +751,11 @@ func (h *C2Handler) PayloadDownload(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload id"})
return
}
session, ok := security.CurrentSession(c)
if !ok || !h.mgr().DB().UserCanAccessC2Payload(session.UserID, session.Scope, filename) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
builder := c2.NewPayloadBuilder(h.mgr(), h.logger, "", "")
storageDir := builder.GetPayloadStoragePath()
+90 -3
View File
@@ -13,6 +13,8 @@ import (
"unicode/utf8"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -27,6 +29,7 @@ const (
type ChatUploadsHandler struct {
logger *zap.Logger
audit *audit.Service
db *database.DB
}
// SetAudit wires platform audit logging.
@@ -35,8 +38,32 @@ func (h *ChatUploadsHandler) SetAudit(s *audit.Service) {
}
// NewChatUploadsHandler 创建处理器
func NewChatUploadsHandler(logger *zap.Logger) *ChatUploadsHandler {
return &ChatUploadsHandler{logger: logger}
func NewChatUploadsHandler(logger *zap.Logger, databases ...*database.DB) *ChatUploadsHandler {
h := &ChatUploadsHandler{logger: logger}
if len(databases) > 0 {
h.db = databases[0]
}
return h
}
func (h *ChatUploadsHandler) pathAllowed(c *gin.Context, relativePath string) bool {
session, ok := security.CurrentSession(c)
if !ok || h.db == nil {
return false
}
if session.Scope == database.RBACScopeAll {
return true
}
rel := filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.TrimSpace(relativePath))))
rel = strings.Trim(rel, "/")
if conversationID, ownerUserID, found := h.db.GetChatUploadArtifact(rel); found {
return strings.TrimSpace(ownerUserID) == session.UserID || h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID)
}
parts := strings.Split(strings.Trim(rel, "/"), "/")
if len(parts) < 2 || parts[1] == "" || parts[1] == "_manual" {
return false
}
return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", parts[1])
}
func (h *ChatUploadsHandler) absRoot() (string, error) {
@@ -175,6 +202,21 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
}
folders = filteredFolders
}
files = filterSlice(files, func(file ChatUploadFileItem) bool {
return h.pathAllowed(c, file.RelativePath)
})
folders = filterSlice(folders, func(folder string) bool {
if h.pathAllowed(c, folder) {
return true
}
prefix := strings.TrimSuffix(folder, "/") + "/"
for _, file := range files {
if strings.HasPrefix(file.RelativePath, prefix) {
return true
}
}
return false
})
sort.Strings(folders)
sort.Slice(files, func(i, j int) bool {
return files[i].ModifiedUnix > files[j].ModifiedUnix
@@ -185,6 +227,10 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
// Download GET /api/chat-uploads/download?path=...
func (h *ChatUploadsHandler) Download(c *gin.Context) {
p := c.Query("path")
if !h.pathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err := h.resolveUnderChatUploads(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -209,6 +255,10 @@ func (h *ChatUploadsHandler) Delete(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if !h.pathAllowed(c, body.Path) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err := h.resolveUnderChatUploads(body.Path)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -238,6 +288,7 @@ func (h *ChatUploadsHandler) Delete(c *gin.Context) {
return
}
}
_ = h.db.DeleteChatUploadArtifactPath(filepath.ToSlash(filepath.Clean(filepath.FromSlash(body.Path))))
if h.audit != nil {
h.audit.RecordOK(c, "file", "delete", "删除对话附件", "chat_upload", body.Path, nil)
}
@@ -272,6 +323,10 @@ func (h *ChatUploadsHandler) Mkdir(c *gin.Context) {
if parent == "." {
parent = ""
}
if !h.pathAllowed(c, parent) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
root, err := h.absRoot()
if err != nil {
@@ -327,6 +382,10 @@ func (h *ChatUploadsHandler) Rename(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if !h.pathAllowed(c, body.Path) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
newName := strings.TrimSpace(body.NewName)
if newName == "" || strings.ContainsAny(newName, `/\`) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid newName"})
@@ -354,6 +413,8 @@ func (h *ChatUploadsHandler) Rename(c *gin.Context) {
return
}
newRel, _ := filepath.Rel(root, newAbs)
oldRel := filepath.ToSlash(filepath.Clean(filepath.FromSlash(body.Path)))
_ = h.db.RenameChatUploadArtifactPath(oldRel, filepath.ToSlash(newRel))
c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(newRel)})
}
@@ -365,6 +426,10 @@ type chatUploadContentBody struct {
// GetContent GET /api/chat-uploads/content?path=...
func (h *ChatUploadsHandler) GetContent(c *gin.Context) {
p := c.Query("path")
if !h.pathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err := h.resolveUnderChatUploads(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -398,6 +463,10 @@ func (h *ChatUploadsHandler) PutContent(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if !h.pathAllowed(c, body.Path) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if !utf8.ValidString(body.Content) {
c.JSON(http.StatusBadRequest, gin.H{"error": "content must be valid UTF-8"})
return
@@ -444,6 +513,10 @@ func (h *ChatUploadsHandler) Upload(c *gin.Context) {
var targetDir string
targetRel := strings.TrimSpace(c.PostForm("relativeDir"))
if targetRel != "" {
if !h.pathAllowed(c, targetRel) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
absDir, err := h.resolveUnderChatUploads(targetRel)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -467,13 +540,17 @@ func (h *ChatUploadsHandler) Upload(c *gin.Context) {
targetDir = absDir
} else {
convID := strings.TrimSpace(c.PostForm("conversationId"))
dateStr := time.Now().Format("2006-01-02")
if !h.pathAllowed(c, filepath.ToSlash(filepath.Join(dateStr, convID))) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
convDir := convID
if convDir == "" {
convDir = "_manual"
} else {
convDir = strings.ReplaceAll(convDir, string(filepath.Separator), "_")
}
dateStr := time.Now().Format("2006-01-02")
targetDir = filepath.Join(root, dateStr, convDir)
if err := os.MkdirAll(targetDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -514,6 +591,16 @@ func (h *ChatUploadsHandler) Upload(c *gin.Context) {
}
rel, _ := filepath.Rel(root, fullPath)
absSaved, _ := filepath.Abs(fullPath)
if session, ok := security.CurrentSession(c); ok {
conversationID := strings.TrimSpace(c.PostForm("conversationId"))
if conversationID == "" {
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) >= 2 {
conversationID = parts[1]
}
}
_ = h.db.UpsertChatUploadArtifact(filepath.ToSlash(rel), conversationID, session.UserID)
}
if h.audit != nil {
h.audit.RecordOK(c, "file", "upload", "上传对话附件", "chat_upload", filepath.ToSlash(rel), map[string]interface{}{
"name": unique,
@@ -13,11 +13,11 @@ import (
)
// rebindEinoRunningTask 中断并继续 / 空正文续跑:重建 cancel 链与超时 ctx,保持任务 running。
func (h *AgentHandler) rebindEinoRunningTask(conversationID string, timeoutCancel context.CancelFunc) (context.Context, context.CancelCauseFunc, context.Context, context.CancelFunc) {
func (h *AgentHandler) rebindEinoRunningTask(parent context.Context, conversationID string, timeoutCancel context.CancelFunc) (context.Context, context.CancelCauseFunc, context.Context, context.CancelFunc) {
if timeoutCancel != nil {
timeoutCancel()
}
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
baseCtx, cancelWithCause := context.WithCancelCause(detachedAgentContext(parent))
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, newTimeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
+6 -5
View File
@@ -116,7 +116,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
"userMessageId": prep.UserMessageID,
})
}
if h.runRoleWorkflowStreamIfBound(&req, prep, sendEvent) {
if h.runRoleWorkflowStreamIfBound(c, &req, prep, sendEvent) {
return
}
@@ -154,7 +154,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
var result *multiagent.RunResult
var runErr error
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
baseCtx, cancelWithCause = context.WithCancelCause(detachedAgentContext(c.Request.Context()))
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
@@ -247,7 +247,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, conversationID, result, &emptyResponseContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(conversationID, timeoutCancel)
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
timeoutCancel()
@@ -279,7 +279,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
})
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
baseCtx, cancelWithCause = context.WithCancelCause(detachedAgentContext(baseCtx))
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
@@ -381,7 +381,8 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
prep, err := h.prepareMultiAgentSession(&req, c, "eino_agent")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
status, msg := multiAgentHTTPErrorStatus(err)
c.JSON(status, gin.H{"error": msg})
return
}
h.activateHITLForConversation(prep.ConversationID, req.Hitl)
+23 -2
View File
@@ -9,6 +9,7 @@ import (
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -67,7 +68,7 @@ func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) {
errorMsg := externalMCPStatusError(h.manager, name, status)
result[name] = ExternalMCPResponse{
Config: cfg,
Config: externalMCPConfigForResponse(c, cfg),
Status: status,
ToolCount: toolCount,
Error: errorMsg,
@@ -113,13 +114,33 @@ func (h *ExternalMCPHandler) GetExternalMCP(c *gin.Context) {
}
c.JSON(http.StatusOK, ExternalMCPResponse{
Config: cfg,
Config: externalMCPConfigForResponse(c, cfg),
Status: status,
ToolCount: toolCount,
Error: externalMCPStatusError(h.manager, name, status),
})
}
func externalMCPConfigForResponse(c *gin.Context, cfg config.ExternalMCPServerConfig) config.ExternalMCPServerConfig {
if security.SessionHasPermission(c, "mcp:write") {
return cfg
}
copyCfg := cfg
if len(cfg.Env) > 0 {
copyCfg.Env = make(map[string]string, len(cfg.Env))
for key := range cfg.Env {
copyCfg.Env[key] = "***"
}
}
if len(cfg.Headers) > 0 {
copyCfg.Headers = make(map[string]string, len(cfg.Headers))
for key := range cfg.Headers {
copyCfg.Headers[key] = "***"
}
}
return copyCfg
}
// externalMCPStatusError 在 error/disconnected 状态下返回最近错误(含断连原因)。
func externalMCPStatusError(manager *mcp.ExternalMCPManager, name, status string) string {
if status != "error" && status != "disconnected" {
+42 -3
View File
@@ -44,7 +44,8 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
return
}
group, err := h.db.CreateGroup(req.Name, req.Icon)
session, _ := security.CurrentSession(c)
group, err := h.db.CreateGroup(req.Name, req.Icon, session.UserID)
if err != nil {
h.logger.Error("创建分组失败", zap.Error(err))
// 如果是名称重复错误,返回400状态码
@@ -61,7 +62,8 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
// ListGroups 列出所有分组
func (h *GroupHandler) ListGroups(c *gin.Context) {
groups, err := h.db.ListGroups()
session, _ := security.CurrentSession(c)
groups, err := h.db.ListGroupsForAccess(session.UserID, session.Scope)
if err != nil {
h.logger.Error("获取分组列表失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -74,6 +76,10 @@ func (h *GroupHandler) ListGroups(c *gin.Context) {
// GetGroup 获取分组
func (h *GroupHandler) GetGroup(c *gin.Context) {
id := c.Param("id")
if !h.groupAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
group, err := h.db.GetGroup(id)
if err != nil {
@@ -94,6 +100,10 @@ type UpdateGroupRequest struct {
// UpdateGroup 更新分组
func (h *GroupHandler) UpdateGroup(c *gin.Context) {
id := c.Param("id")
if !h.groupAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
var req UpdateGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -130,6 +140,10 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
// DeleteGroup 删除分组
func (h *GroupHandler) DeleteGroup(c *gin.Context) {
id := c.Param("id")
if !h.groupAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if err := h.db.DeleteGroup(id); err != nil {
h.logger.Error("删除分组失败", zap.Error(err))
@@ -157,6 +171,10 @@ func (h *GroupHandler) AddConversationToGroup(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if !h.groupAllowed(c, req.GroupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
if err := h.db.AddConversationToGroup(req.ConversationID, req.GroupID); err != nil {
h.logger.Error("添加对话到分组失败", zap.Error(err))
@@ -171,6 +189,10 @@ func (h *GroupHandler) AddConversationToGroup(c *gin.Context) {
func (h *GroupHandler) RemoveConversationFromGroup(c *gin.Context) {
conversationID := c.Param("conversationId")
groupID := c.Param("id")
if !h.groupAllowed(c, groupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
if !h.groupConversationAllowed(c, conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
@@ -198,6 +220,10 @@ type GroupConversation struct {
// GetGroupConversations 获取分组中的所有对话
func (h *GroupHandler) GetGroupConversations(c *gin.Context) {
groupID := c.Param("id")
if !h.groupAllowed(c, groupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
searchQuery := c.Query("search") // 获取搜索参数
var conversations []*database.Conversation
@@ -256,7 +282,7 @@ func (h *GroupHandler) GetAllMappings(c *gin.Context) {
}
filtered := mappings[:0]
for _, mapping := range mappings {
if h.groupConversationAllowed(c, mapping.ConversationID) {
if h.groupConversationAllowed(c, mapping.ConversationID) && h.groupAllowed(c, mapping.GroupID) {
filtered = append(filtered, mapping)
}
}
@@ -300,6 +326,10 @@ type UpdateGroupPinnedRequest struct {
// UpdateGroupPinned 更新分组置顶状态
func (h *GroupHandler) UpdateGroupPinned(c *gin.Context) {
groupID := c.Param("id")
if !h.groupAllowed(c, groupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
var req UpdateGroupPinnedRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -325,6 +355,10 @@ type UpdateConversationPinnedInGroupRequest struct {
func (h *GroupHandler) UpdateConversationPinnedInGroup(c *gin.Context) {
groupID := c.Param("id")
conversationID := c.Param("conversationId")
if !h.groupAllowed(c, groupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
if !h.groupConversationAllowed(c, conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
@@ -352,3 +386,8 @@ func (h *GroupHandler) groupConversationAllowed(c *gin.Context, conversationID s
}
return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID)
}
func (h *GroupHandler) groupAllowed(c *gin.Context, groupID string) bool {
session, ok := security.CurrentSession(c)
return ok && h.db.UserCanAccessGroup(session.UserID, session.Scope, groupID)
}
+112 -9
View File
@@ -120,9 +120,22 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
// 解析工具筛选参数(兼容 mcp__tool 与内部 mcp::tool
toolName := normalizeToolNameFilter(c.Query("tool"))
executions, total := h.loadExecutionListWithPagination(page, pageSize, status, toolName)
access := notificationAccessFromContext(c)
executions, total := h.loadExecutionListWithPagination(page, pageSize, status, toolName, access)
h.enrichExecutionsConversationID(executions)
summary, topTools := h.loadStatsSummary(monitorPageTopTools)
var summary *MonitorStatsSummary
var topTools []*mcp.ToolStats
if access.Scope == database.RBACScopeAll {
summary, topTools = h.loadStatsSummary(monitorPageTopTools)
} else if h.db != nil {
if scoped, err := h.db.LoadToolStatsSummaryForAccess(monitorPageTopTools, access); err == nil {
summary, topTools = dbStatsSummaryToMonitor(scoped), scoped.TopTools
} else {
summary, topTools = summarizeAccessibleExecutionPage(executions, monitorPageTopTools)
}
} else {
summary, topTools = summarizeAccessibleExecutionPage(executions, monitorPageTopTools)
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages == 0 {
@@ -142,6 +155,31 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
})
}
func summarizeAccessibleExecutionPage(executions []*mcp.ToolExecution, topN int) (*MonitorStatsSummary, []*mcp.ToolStats) {
stats := map[string]*mcp.ToolStats{}
for _, exec := range executions {
if exec == nil {
continue
}
stat := stats[exec.ToolName]
if stat == nil {
stat = &mcp.ToolStats{ToolName: exec.ToolName}
stats[exec.ToolName] = stat
}
stat.TotalCalls++
if exec.Status == "failed" || exec.Status == "cancelled" {
stat.FailedCalls++
} else if exec.Status == "completed" {
stat.SuccessCalls++
}
started := exec.StartTime
if stat.LastCallTime == nil || started.After(*stat.LastCallTime) {
stat.LastCallTime = &started
}
}
return summarizeToolStats(stats, topN)
}
func (h *MonitorHandler) monitorRetentionDays() int {
if h.monitorRetention != nil {
return h.monitorRetention.RetentionDays()
@@ -154,9 +192,9 @@ func (h *MonitorHandler) loadExecutions() []*mcp.ToolExecution {
return executions
}
func (h *MonitorHandler) loadExecutionListWithPagination(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
func (h *MonitorHandler) loadExecutionListWithPagination(page, pageSize int, status, toolName string, access database.RBACListAccess) ([]*mcp.ToolExecution, int) {
if h.db == nil {
allExecutions := h.mcpServer.GetAllExecutions()
allExecutions := filterToolExecutionsForAccess(h.mcpServer.GetAllExecutions(), access, h.db)
if status != "" || toolName != "" {
filtered := make([]*mcp.ToolExecution, 0)
for _, exec := range allExecutions {
@@ -189,13 +227,13 @@ func (h *MonitorHandler) loadExecutionListWithPagination(page, pageSize int, sta
}
offset := (page - 1) * pageSize
executions, err := h.db.LoadToolExecutionListPage(offset, pageSize, status, toolName)
executions, err := h.db.LoadToolExecutionListPageForAccess(offset, pageSize, status, toolName, access)
if err != nil {
h.logger.Warn("从数据库加载执行记录列表失败,回退到内存数据", zap.Error(err))
return h.loadExecutionListWithPaginationFromMemory(page, pageSize, status, toolName)
return h.loadExecutionListWithPaginationFromMemory(page, pageSize, status, toolName, access)
}
total, err := h.db.CountToolExecutions(status, toolName)
total, err := h.db.CountToolExecutionsForAccess(status, toolName, access)
if err != nil {
h.logger.Warn("获取执行记录总数失败", zap.Error(err))
total = offset + len(executions)
@@ -207,8 +245,8 @@ func (h *MonitorHandler) loadExecutionListWithPagination(page, pageSize int, sta
return executions, total
}
func (h *MonitorHandler) loadExecutionListWithPaginationFromMemory(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
allExecutions := h.mcpServer.GetAllExecutions()
func (h *MonitorHandler) loadExecutionListWithPaginationFromMemory(page, pageSize int, status, toolName string, access database.RBACListAccess) ([]*mcp.ToolExecution, int) {
allExecutions := filterToolExecutionsForAccess(h.mcpServer.GetAllExecutions(), access, h.db)
if status != "" || toolName != "" {
filtered := make([]*mcp.ToolExecution, 0)
for _, exec := range allExecutions {
@@ -260,6 +298,50 @@ func slimToolExecution(exec *mcp.ToolExecution) *mcp.ToolExecution {
return slim
}
func filterToolExecutionsForAccess(executions []*mcp.ToolExecution, access database.RBACListAccess, db *database.DB) []*mcp.ToolExecution {
if access.Scope == database.RBACScopeAll {
return executions
}
out := make([]*mcp.ToolExecution, 0, len(executions))
for _, exec := range executions {
if toolExecutionVisible(exec, access, db) {
out = append(out, exec)
}
}
return out
}
func toolExecutionVisible(exec *mcp.ToolExecution, access database.RBACListAccess, db *database.DB) bool {
if exec == nil || strings.TrimSpace(access.UserID) == "" {
return false
}
if access.Scope == database.RBACScopeAll || strings.TrimSpace(exec.OwnerUserID) == strings.TrimSpace(access.UserID) {
return true
}
conversationID := strings.TrimSpace(exec.ConversationID)
return conversationID != "" && db != nil && db.UserCanAccessResource(access.UserID, access.Scope, "conversation", conversationID)
}
func (h *MonitorHandler) monitorExecutionAllowed(c *gin.Context, id string) bool {
access := notificationAccessFromContext(c)
if access.Scope == database.RBACScopeAll {
return true
}
id = strings.TrimSpace(id)
if id == "" {
return false
}
if exec, ok := h.mcpServer.GetExecution(id); ok {
return toolExecutionVisible(exec, access, h.db)
}
if h.externalMCPMgr != nil {
if exec, ok := h.externalMCPMgr.GetExecution(id); ok {
return toolExecutionVisible(exec, access, h.db)
}
}
return h.db != nil && h.db.UserCanAccessToolExecution(access.UserID, access.Scope, id)
}
func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
if h.db == nil {
allExecutions := h.mcpServer.GetAllExecutions()
@@ -453,6 +535,10 @@ func (h *MonitorHandler) loadStatsMap() map[string]*mcp.ToolStats {
// GetExecution 获取特定执行记录
func (h *MonitorHandler) GetExecution(c *gin.Context) {
id := c.Param("id")
if !h.monitorExecutionAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
// 先从内部MCP服务器查找
exec, exists := h.mcpServer.GetExecution(id)
@@ -493,6 +579,10 @@ func (h *MonitorHandler) CancelExecution(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "执行记录ID不能为空"})
return
}
if !h.monitorExecutionAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
note := ""
dec := json.NewDecoder(c.Request.Body)
var body struct {
@@ -592,6 +682,9 @@ func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
result := make(map[string]executionSummary, len(req.IDs))
for _, id := range req.IDs {
if !h.monitorExecutionAllowed(c, id) {
continue
}
// 先从内部MCP服务器查找
if exec, exists := h.mcpServer.GetExecution(id); exists {
result[id] = executionSummary{ToolName: exec.ToolName, Status: exec.Status}
@@ -755,6 +848,10 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "执行记录ID不能为空"})
return
}
if !h.monitorExecutionAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
// 如果使用数据库,先获取执行记录信息,然后删除并更新统计
if h.db != nil {
@@ -823,6 +920,12 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "执行记录ID列表不能为空"})
return
}
for _, id := range request.IDs {
if !h.monitorExecutionAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问一个或多个执行记录"})
return
}
}
// 如果使用数据库,先获取执行记录信息,然后删除并更新统计
if h.db != nil {
+6 -4
View File
@@ -133,7 +133,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
"userMessageId": prep.UserMessageID,
})
}
if h.runRoleWorkflowStreamIfBound(&req, prep, sendEvent) {
if h.runRoleWorkflowStreamIfBound(c, &req, prep, sendEvent) {
return
}
@@ -163,7 +163,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
var result *multiagent.RunResult
var runErr error
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
baseCtx, cancelWithCause = context.WithCancelCause(detachedAgentContext(c.Request.Context()))
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
@@ -259,7 +259,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, conversationID, result, &emptyResponseContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(conversationID, timeoutCancel)
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
timeoutCancel()
@@ -291,7 +291,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
})
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
baseCtx, cancelWithCause = context.WithCancelCause(detachedAgentContext(baseCtx))
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
@@ -541,6 +541,8 @@ func formatInterruptContinueUserMessage(note string) string {
func multiAgentHTTPErrorStatus(err error) (int, string) {
msg := err.Error()
switch {
case strings.Contains(msg, "无权访问"):
return http.StatusForbidden, msg
case strings.Contains(msg, "对话不存在"):
return http.StatusNotFound, msg
case strings.Contains(msg, "未找到该 WebShell"):
+31 -5
View File
@@ -8,6 +8,7 @@ import (
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -30,16 +31,34 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
}
conversationID := strings.TrimSpace(req.ConversationID)
projectID := strings.TrimSpace(effectiveProjectID(h.config, req.ProjectID))
webshellID := strings.TrimSpace(req.WebShellConnectionID)
session, hasSession := security.CurrentSession(c)
if !hasSession || !session.Permissions["chat:write"] {
return nil, fmt.Errorf("无权写入对话")
}
canAccess := func(resourceType, resourceID string) bool {
if !hasSession || h.db == nil || strings.TrimSpace(resourceID) == "" {
return false
}
return h.db.UserCanAccessResource(session.UserID, session.Scope, resourceType, resourceID)
}
if projectID != "" && (!session.Permissions["project:read"] || !canAccess("project", projectID)) {
return nil, fmt.Errorf("无权访问目标项目")
}
if webshellID != "" && (!session.Permissions["webshell:write"] || !canAccess("webshell", webshellID)) {
return nil, fmt.Errorf("无权访问该 WebShell 连接")
}
createdNew := false
if conversationID == "" {
title := safeTruncateString(req.Message, 50)
var conv *database.Conversation
var err error
meta := audit.ConversationCreateMetaFromGin(c, source)
meta.ProjectID = effectiveProjectID(h.config, req.ProjectID)
if strings.TrimSpace(req.WebShellConnectionID) != "" {
meta.ProjectID = projectID
if webshellID != "" {
meta.Source = source + "_webshell"
meta.WebShellConnectionID = strings.TrimSpace(req.WebShellConnectionID)
meta.WebShellConnectionID = webshellID
conv, err = h.db.CreateConversationWithWebshell(meta.WebShellConnectionID, title, meta)
} else {
conv, err = h.db.CreateConversation(title, meta)
@@ -49,10 +68,17 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
}
conversationID = conv.ID
createdNew = true
if hasSession {
_ = h.db.SetResourceOwner("conversation", conversationID, session.UserID)
_ = h.db.AssignResourceToUser(session.UserID, "conversation", conversationID)
}
} else {
if _, err := h.db.GetConversation(conversationID); err != nil {
return nil, fmt.Errorf("对话不存在")
}
if !canAccess("conversation", conversationID) {
return nil, fmt.Errorf("无权访问该对话")
}
}
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
@@ -67,8 +93,8 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
finalMessage := req.Message
var roleTools []string
if req.WebShellConnectionID != "" {
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
if webshellID != "" {
conn, errConn := h.db.GetWebshellConnection(webshellID)
if errConn != nil || conn == nil {
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
return nil, fmt.Errorf("未找到该 WebShell 连接")
+34 -22
View File
@@ -494,14 +494,16 @@ func buildPlaceholders(n int) string {
return strings.Join(out, ",")
}
func (h *NotificationHandler) readStatesByIDs(ids []string) (map[string]bool, error) {
func (h *NotificationHandler) readStatesByIDs(userID string, ids []string) (map[string]bool, error) {
result := make(map[string]bool, len(ids))
if len(ids) == 0 {
userID = strings.TrimSpace(userID)
if len(ids) == 0 || userID == "" {
return result, nil
}
holders := buildPlaceholders(len(ids))
query := "SELECT event_id FROM notification_reads WHERE event_id IN (" + holders + ")"
args := make([]interface{}, 0, len(ids))
query := "SELECT event_id FROM notification_reads_by_user WHERE user_id = ? AND event_id IN (" + holders + ")"
args := make([]interface{}, 0, len(ids)+1)
args = append(args, userID)
for _, id := range ids {
args = append(args, id)
}
@@ -520,7 +522,7 @@ func (h *NotificationHandler) readStatesByIDs(ids []string) (map[string]bool, er
return result, nil
}
func (h *NotificationHandler) applyReadStates(items []NotificationSummaryItem) ([]NotificationSummaryItem, error) {
func (h *NotificationHandler) applyReadStates(userID string, items []NotificationSummaryItem) ([]NotificationSummaryItem, error) {
markableIDs := make([]string, 0, len(items))
for _, item := range items {
if item.Actionable {
@@ -528,7 +530,7 @@ func (h *NotificationHandler) applyReadStates(items []NotificationSummaryItem) (
}
markableIDs = append(markableIDs, item.ID)
}
readMap, err := h.readStatesByIDs(markableIDs)
readMap, err := h.readStatesByIDs(userID, markableIDs)
if err != nil {
return items, err
}
@@ -585,34 +587,38 @@ func createNotificationReadTableIfNeeded(db *database.DB) error {
return fmt.Errorf("db is nil")
}
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS notification_reads (
event_id TEXT PRIMARY KEY,
read_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
CREATE TABLE IF NOT EXISTS notification_reads_by_user (
user_id TEXT NOT NULL,
event_id TEXT NOT NULL,
read_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id, event_id)
);
`)
if err != nil {
return err
}
_, idxErr := db.Exec(`CREATE INDEX IF NOT EXISTS idx_notification_reads_read_at ON notification_reads(read_at DESC);`)
_, idxErr := db.Exec(`CREATE INDEX IF NOT EXISTS idx_notification_reads_user_read_at ON notification_reads_by_user(user_id, read_at DESC);`)
return idxErr
}
func pruneNotificationReads(db *database.DB, maxRows int) error {
func pruneNotificationReads(db *database.DB, userID string, maxRows int) error {
if db == nil {
return fmt.Errorf("db is nil")
}
if maxRows <= 0 {
userID = strings.TrimSpace(userID)
if maxRows <= 0 || userID == "" {
return nil
}
_, err := db.Exec(`
DELETE FROM notification_reads
WHERE event_id NOT IN (
DELETE FROM notification_reads_by_user
WHERE user_id = ? AND event_id NOT IN (
SELECT event_id
FROM notification_reads
FROM notification_reads_by_user
WHERE user_id = ?
ORDER BY read_at DESC, rowid DESC
LIMIT ?
)
`, maxRows)
`, userID, userID, maxRows)
return err
}
@@ -655,6 +661,11 @@ func (h *NotificationHandler) MarkRead(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "marked": 0})
return
}
session, ok := security.CurrentSession(c)
if !ok || strings.TrimSpace(session.UserID) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authenticated user"})
return
}
tx, err := h.db.Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin transaction"})
@@ -664,9 +675,9 @@ func (h *NotificationHandler) MarkRead(c *gin.Context) {
_ = tx.Rollback()
}()
stmt, err := tx.Prepare(`
INSERT INTO notification_reads(event_id, read_at)
VALUES(?, CURRENT_TIMESTAMP)
ON CONFLICT(event_id) DO UPDATE SET read_at = CURRENT_TIMESTAMP
INSERT INTO notification_reads_by_user(user_id, event_id, read_at)
VALUES(?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, event_id) DO UPDATE SET read_at = CURRENT_TIMESTAMP
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare statement"})
@@ -679,7 +690,7 @@ func (h *NotificationHandler) MarkRead(c *gin.Context) {
if !ok {
continue
}
if _, err := stmt.Exec(id); err != nil {
if _, err := stmt.Exec(session.UserID, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mark read"})
return
}
@@ -689,7 +700,7 @@ func (h *NotificationHandler) MarkRead(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit read marks"})
return
}
if err := pruneNotificationReads(h.db, notificationReadMaxRows); err != nil {
if err := pruneNotificationReads(h.db, session.UserID, notificationReadMaxRows); err != nil {
h.logger.Warn("裁剪通知已读记录失败", zap.Error(err))
}
c.JSON(http.StatusOK, gin.H{"ok": true, "marked": marked})
@@ -776,7 +787,8 @@ func (h *NotificationHandler) GetSummary(c *gin.Context) {
items = append(items, longRunningItems...)
items = append(items, completedItems...)
items, err := h.applyReadStates(items)
session, _ := security.CurrentSession(c)
items, err := h.applyReadStates(session.UserID, items)
if err != nil {
h.logger.Warn("加载通知已读状态失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load notification read states"})
@@ -0,0 +1,45 @@
package handler
import (
"bytes"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestNotificationReadStateIsPerUser(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "notification-rbac.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
h := NewNotificationHandler(db, nil, zap.NewNop())
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(security.ContextSessionKey, security.Session{UserID: "u1", Scope: database.RBACScopeAssigned})
c.Next()
})
router.POST("/notifications/read", h.MarkRead)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/notifications/read", bytes.NewBufferString(`{"eventIds":["vuln:v1"]}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("mark read status = %d: %s", w.Code, w.Body.String())
}
u1, err := h.readStatesByIDs("u1", []string{"vuln:v1"})
if err != nil || !u1["vuln:v1"] {
t.Fatalf("u1 read state = %#v, err=%v", u1, err)
}
u2, err := h.readStatesByIDs("u2", []string{"vuln:v1"})
if err != nil || u2["vuln:v1"] {
t.Fatalf("u2 inherited u1 read state = %#v, err=%v", u2, err)
}
}
+6
View File
@@ -642,6 +642,12 @@ func (h *ProjectHandler) DeleteFactEdge(c *gin.Context) {
func (h *ProjectHandler) PromoteAttackChain(c *gin.Context) {
projectID := c.Param("id")
conversationID := c.Param("conversationId")
session, ok := security.CurrentSession(c)
if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "project", projectID) ||
!h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问目标项目或来源对话"})
return
}
result, err := attackchain.PromoteToProject(h.db, projectID, conversationID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+33 -3
View File
@@ -1,11 +1,13 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
@@ -34,15 +36,22 @@ func (h *RBACHandler) SetAuthManager(m *security.AuthManager) {
func (h *RBACHandler) Me(c *gin.Context) {
session, _ := security.CurrentSession(c)
resolvedScope := session.Scope
permissionScopes := session.PermissionScopes
if principal, ok := authctx.PrincipalFromContext(c.Request.Context()); ok {
resolvedScope = principal.Scope
permissionScopes = principal.PermissionScopes
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": session.UserID,
"username": session.Username,
"display_name": session.DisplayName,
},
"roles": session.Roles,
"permissions": permissionKeys(session.Permissions),
"scope": session.Scope,
"roles": session.Roles,
"permissions": permissionKeys(session.Permissions),
"scope": resolvedScope,
"permission_scopes": permissionScopes,
})
}
@@ -96,12 +105,29 @@ type upsertRBACRoleRequest struct {
Permissions []string `json:"permissions"`
}
func validateRBACPermissionKeys(keys []string) error {
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := security.PermissionCatalog[key]; !ok {
return fmt.Errorf("未知权限: %s", key)
}
}
return nil
}
func (h *RBACHandler) CreateRole(c *gin.Context) {
var req upsertRBACRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateRBACPermissionKeys(req.Permissions); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
role, err := h.db.UpsertRBACRole("", req.Name, req.Description, req.Scope, req.Permissions)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -129,6 +155,10 @@ func (h *RBACHandler) UpdateRole(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateRBACPermissionKeys(req.Permissions); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
role, err := h.db.UpsertRBACRole(id, req.Name, req.Description, req.Scope, req.Permissions)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+206
View File
@@ -0,0 +1,206 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestDetachedAgentContextRetainsPrincipalWithoutParentCancellation(t *testing.T) {
parent, cancel := context.WithCancel(context.Background())
parent = authctx.WithPrincipal(parent, authctx.NewPrincipal("u1", "user", database.RBACScopeAssigned, map[string]bool{"agent:execute": true}))
detached := detachedAgentContext(parent)
cancel()
if err := detached.Err(); err != nil {
t.Fatalf("detached context inherited cancellation: %v", err)
}
principal, ok := authctx.PrincipalFromContext(detached)
if !ok || principal.UserID != "u1" || !principal.HasPermission("agent:execute") {
t.Fatalf("detached context lost principal: %#v, ok=%v", principal, ok)
}
}
func TestPromoteAttackChainRequiresSourceConversationAccess(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "promote-rbac.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
project, _ := db.CreateProject(&database.Project{Name: "owned"})
conversation, _ := db.CreateConversation("foreign", database.ConversationCreateMeta{})
_ = db.SetResourceOwner("project", project.ID, "u1")
_ = db.SetResourceOwner("conversation", conversation.ID, "u2")
h := NewProjectHandler(db, zap.NewNop())
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(security.ContextSessionKey, security.Session{UserID: "u1", Scope: database.RBACScopeOwn})
c.Next()
})
router.POST("/api/projects/:id/promote-attack-chain/:conversationId", h.PromoteAttackChain)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/projects/"+project.ID+"/promote-attack-chain/"+conversation.ID, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403: %s", w.Code, w.Body.String())
}
}
func TestVulnerabilityCannotBeReparentedToForeignProject(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "vuln-reparent-rbac.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
owned, _ := db.CreateProject(&database.Project{Name: "owned"})
foreign, _ := db.CreateProject(&database.Project{Name: "foreign"})
_ = db.SetResourceOwner("project", owned.ID, "u1")
_ = db.SetResourceOwner("project", foreign.ID, "u2")
vulnerability, err := db.CreateVulnerability(&database.Vulnerability{Title: "v", Severity: "high", ProjectID: owned.ID})
if err != nil {
t.Fatal(err)
}
_ = db.SetResourceOwner("vulnerability", vulnerability.ID, "u1")
h := NewVulnerabilityHandler(db, zap.NewNop())
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(security.ContextSessionKey, security.Session{UserID: "u1", Scope: database.RBACScopeOwn})
c.Next()
})
router.PUT("/api/vulnerabilities/:id", h.UpdateVulnerability)
body, _ := json.Marshal(map[string]interface{}{"project_id": foreign.ID})
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodPut, "/api/vulnerabilities/"+vulnerability.ID, bytes.NewReader(body)))
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403: %s", w.Code, w.Body.String())
}
}
func TestAgentTaskEndpointsFilterAndRejectForeignConversations(t *testing.T) {
gin.SetMode(gin.TestMode)
db, user := setupConversationRBACTest(t)
allowed, _ := db.CreateConversation("allowed", database.ConversationCreateMeta{})
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
if err := db.AssignResourceToUser(user.ID, "conversation", allowed.ID); err != nil {
t.Fatal(err)
}
tasks := NewAgentTaskManager()
if _, err := tasks.StartTask(allowed.ID, "visible", func(error) {}); err != nil {
t.Fatal(err)
}
if _, err := tasks.StartTask(hidden.ID, "secret", func(error) {}); err != nil {
t.Fatal(err)
}
h := &AgentHandler{db: db, tasks: tasks, logger: zap.NewNop()}
w := performAssignedHandler(user, http.MethodGet, "/api/agent-loop/tasks", nil, h.ListAgentTasks)
if w.Code != http.StatusOK {
t.Fatalf("list status = %d: %s", w.Code, w.Body.String())
}
var response struct {
Tasks []*AgentTask `json:"tasks"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Tasks) != 1 || response.Tasks[0].ConversationID != allowed.ID {
t.Fatalf("tasks = %#v, want only %s", response.Tasks, allowed.ID)
}
w = performAssignedHandler(user, http.MethodPost, "/api/agent-loop/cancel", map[string]string{"conversationId": hidden.ID}, h.CancelAgentLoop)
if w.Code != http.StatusForbidden {
t.Fatalf("cancel status = %d, want %d: %s", w.Code, http.StatusForbidden, w.Body.String())
}
}
func TestChatUploadPathAuthorizationFollowsConversationAccess(t *testing.T) {
db, user := setupConversationRBACTest(t)
allowed, _ := db.CreateConversation("allowed", database.ConversationCreateMeta{})
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
if err := db.AssignResourceToUser(user.ID, "conversation", allowed.ID); err != nil {
t.Fatal(err)
}
h := NewChatUploadsHandler(zap.NewNop(), db)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned, Permissions: map[string]bool{"chat:write": true}})
if !h.pathAllowed(c, filepath.ToSlash(filepath.Join("2026-07-10", allowed.ID, "a.txt"))) {
t.Fatal("assigned conversation attachment should be accessible")
}
if h.pathAllowed(c, filepath.ToSlash(filepath.Join("2026-07-10", hidden.ID, "secret.txt"))) {
t.Fatal("foreign conversation attachment should be denied")
}
if h.pathAllowed(c, "2026-07-10/_manual/secret.txt") {
t.Fatal("unowned manual attachment should fail closed")
}
}
func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) {
db, user := setupConversationRBACTest(t)
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
h := &AgentHandler{db: db, logger: zap.NewNop()}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned, Permissions: map[string]bool{"chat:write": true}})
_, err := h.prepareMultiAgentSession(&ChatRequest{ConversationID: hidden.ID, Message: "write"}, c, "test")
if err == nil || err.Error() != "无权访问该对话" {
t.Fatalf("err = %v, want unauthorized conversation", err)
}
}
func TestMonitorExecutionDetailRejectsForeignOwner(t *testing.T) {
db, user := setupConversationRBACTest(t)
for _, exec := range []*mcp.ToolExecution{
{ID: "exec-allowed", ToolName: "allowed", Status: "completed", StartTime: time.Now(), OwnerUserID: user.ID},
{ID: "exec-hidden", ToolName: "hidden", Status: "completed", StartTime: time.Now(), OwnerUserID: "another-user"},
} {
if err := db.SaveToolExecution(exec); err != nil {
t.Fatal(err)
}
}
h := NewMonitorHandler(mcp.NewServerWithStorage(zap.NewNop(), db), nil, db, zap.NewNop())
request := func(id string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/monitor/execution/"+id, nil)
c.Params = gin.Params{{Key: "id", Value: id}}
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.GetExecution(c)
return w
}
if w := request("exec-hidden"); w.Code != http.StatusForbidden {
t.Fatalf("hidden status = %d, want %d: %s", w.Code, http.StatusForbidden, w.Body.String())
}
if w := request("exec-allowed"); w.Code != http.StatusOK {
t.Fatalf("allowed status = %d, want %d: %s", w.Code, http.StatusOK, w.Body.String())
}
}
func performAssignedHandler(user *database.RBACUser, method, path string, body interface{}, handler gin.HandlerFunc) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
var req *http.Request
if body == nil {
req = httptest.NewRequest(method, path, nil)
} else {
payload, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
}
c.Request = req
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
handler(c)
return w
}
+12
View File
@@ -0,0 +1,12 @@
package handler
import "context"
// detachedAgentContext lets a long-running Agent survive an SSE disconnect
// while retaining immutable request values such as the authenticated Principal.
func detachedAgentContext(parent context.Context) context.Context {
if parent == nil {
parent = context.Background()
}
return context.WithoutCancel(parent)
}
+111 -38
View File
@@ -7,6 +7,7 @@ import (
"crypto/cipher"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
@@ -16,6 +17,7 @@ import (
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -28,24 +30,24 @@ import (
)
const (
robotCmdHelp = "帮助"
robotCmdList = "列表"
robotCmdListAlt = "对话列表"
robotCmdSwitch = "切换"
robotCmdContinue = "继续"
robotCmdNew = "新对话"
robotCmdClear = "清空"
robotCmdCurrent = "当前"
robotCmdStop = "停止"
robotCmdRoles = "角色"
robotCmdRolesList = "角色列表"
robotCmdSwitchRole = "切换角色"
robotCmdDelete = "删除"
robotCmdVersion = "版本"
robotCmdProjects = "项目"
robotCmdProjectsList = "项目列表"
robotCmdBindProject = "绑定项目"
robotCmdNewProject = "新建项目"
robotCmdHelp = "帮助"
robotCmdList = "列表"
robotCmdListAlt = "对话列表"
robotCmdSwitch = "切换"
robotCmdContinue = "继续"
robotCmdNew = "新对话"
robotCmdClear = "清空"
robotCmdCurrent = "当前"
robotCmdStop = "停止"
robotCmdRoles = "角色"
robotCmdRolesList = "角色列表"
robotCmdSwitchRole = "切换角色"
robotCmdDelete = "删除"
robotCmdVersion = "版本"
robotCmdProjects = "项目"
robotCmdProjectsList = "项目列表"
robotCmdBindProject = "绑定项目"
robotCmdNewProject = "新建项目"
robotCmdUnbindProject = "解除项目"
)
@@ -60,6 +62,7 @@ type RobotHandler struct {
sessionRoles map[string]string // key: "platform_userID", value: roleName(默认"默认"
cancelMu sync.Mutex // 保护 runningCancels
runningCancels map[string]context.CancelFunc // key: "platform_userID", 用于停止命令中断任务
wecomReplay map[string]time.Time
}
// NewRobotHandler 创建机器人处理器
@@ -72,14 +75,51 @@ func NewRobotHandler(cfg *config.Config, db *database.DB, agentHandler *AgentHan
sessions: make(map[string]string),
sessionRoles: make(map[string]string),
runningCancels: make(map[string]context.CancelFunc),
wecomReplay: make(map[string]time.Time),
}
}
func (h *RobotHandler) acceptFreshWecomRequest(timestamp, nonce, signature string) bool {
unixSeconds, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
if err != nil {
return false
}
now := time.Now()
requestTime := time.Unix(unixSeconds, 0)
if requestTime.Before(now.Add(-5*time.Minute)) || requestTime.After(now.Add(5*time.Minute)) {
return false
}
key := strings.TrimSpace(timestamp) + "\x00" + strings.TrimSpace(nonce) + "\x00" + strings.TrimSpace(signature)
if strings.TrimSpace(nonce) == "" || strings.TrimSpace(signature) == "" {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
for replayKey, seenAt := range h.wecomReplay {
if now.Sub(seenAt) > 10*time.Minute {
delete(h.wecomReplay, replayKey)
}
}
if _, exists := h.wecomReplay[key]; exists {
return false
}
h.wecomReplay[key] = now
return true
}
// sessionKey 生成会话 key
func (h *RobotHandler) sessionKey(platform, userID string) string {
return platform + "_" + userID
}
// robotOwnerID creates a stable, non-reversible local owner identity for one
// platform user. Robot users are isolated from each other and from Web users
// even though they authenticate through a shared platform callback secret.
func robotOwnerID(platform, userID string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(platform) + "\x00" + strings.TrimSpace(userID)))
return fmt.Sprintf("robot:%x", sum[:16])
}
func (h *RobotHandler) loadSessionBinding(sk string) (convID, role string) {
if h.db == nil || strings.TrimSpace(sk) == "" {
return "", ""
@@ -119,18 +159,23 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (
h.mu.RLock()
convID = h.sessions[sk]
h.mu.RUnlock()
if convID != "" {
ownerID := robotOwnerID(platform, userID)
if convID != "" && h.db.UserCanAccessResource(ownerID, database.RBACScopeOwn, "conversation", convID) {
return convID, false
}
if persistedConvID, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedConvID) != "" {
// 会话绑定持久化:服务重启后也可恢复当前对话和角色。
h.mu.Lock()
h.sessions[sk] = persistedConvID
if strings.TrimSpace(persistedRole) != "" {
h.sessionRoles[sk] = persistedRole
if !h.db.UserCanAccessResource(ownerID, database.RBACScopeOwn, "conversation", persistedConvID) {
h.deleteSessionBinding(sk)
} else {
// 会话绑定持久化:服务重启后也可恢复当前对话和角色。
h.mu.Lock()
h.sessions[sk] = persistedConvID
if strings.TrimSpace(persistedRole) != "" {
h.sessionRoles[sk] = persistedRole
}
h.mu.Unlock()
return persistedConvID, false
}
h.mu.Unlock()
return persistedConvID, false
}
t := strings.TrimSpace(title)
if t == "" {
@@ -140,12 +185,16 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (
}
meta := database.ConversationCreateMeta{Source: "robot:" + platform}
meta.ProjectID = effectiveProjectID(h.config, "")
if meta.ProjectID != "" && !h.db.UserCanAccessResource(ownerID, database.RBACScopeOwn, "project", meta.ProjectID) {
meta.ProjectID = ""
}
conv, err := h.db.CreateConversation(t, meta)
if err != nil {
h.logger.Warn("创建机器人会话失败", zap.Error(err))
return "", false
}
convID = conv.ID
_ = h.db.SetResourceOwner("conversation", convID, ownerID)
h.mu.Lock()
role := h.sessionRoles[sk]
h.sessions[sk] = convID
@@ -197,11 +246,16 @@ func (h *RobotHandler) clearConversation(platform, userID string) (newConvID str
title := "新对话 " + time.Now().Format("01-02 15:04")
meta := database.ConversationCreateMeta{Source: "robot:" + platform + ":new"}
meta.ProjectID = effectiveProjectID(h.config, "")
ownerID := robotOwnerID(platform, userID)
if meta.ProjectID != "" && !h.db.UserCanAccessResource(ownerID, database.RBACScopeOwn, "project", meta.ProjectID) {
meta.ProjectID = ""
}
conv, err := h.db.CreateConversation(title, meta)
if err != nil {
h.logger.Warn("创建新对话失败", zap.Error(err))
return ""
}
_ = h.db.SetResourceOwner("conversation", conv.ID, ownerID)
h.setConversation(platform, userID, conv.ID)
return conv.ID
}
@@ -251,7 +305,7 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
h.cancelMu.Unlock()
}()
role := h.getRole(platform, userID)
resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, platform, convID, text, role)
resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, platform, robotOwnerID(platform, userID), convID, text, role)
if err != nil {
h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err))
if errors.Is(err, context.Canceled) {
@@ -306,15 +360,19 @@ func (h *RobotHandler) projectsEnabled() bool {
return h.config != nil && h.config.Project.Enabled
}
func (h *RobotHandler) resolveProjectByIDOrName(idOrName string) (*database.Project, string) {
func (h *RobotHandler) resolveProjectByIDOrName(platform, userID, idOrName string) (*database.Project, string) {
idOrName = strings.TrimSpace(idOrName)
if idOrName == "" {
return nil, "请指定项目 ID 或名称,例如:绑定项目 xxx-xxx"
}
ownerID := robotOwnerID(platform, userID)
if p, err := h.db.GetProject(idOrName); err == nil {
return p, ""
if h.db.UserCanAccessResource(ownerID, database.RBACScopeOwn, "project", p.ID) {
return p, ""
}
return nil, "项目不存在或无权访问。"
}
list, err := h.db.ListProjects("", "", 200, 0)
list, err := h.db.ListProjectsForAccess("", "", 200, 0, ownerID, database.RBACScopeOwn)
if err != nil {
return nil, "查询项目失败: " + err.Error()
}
@@ -349,11 +407,11 @@ func (h *RobotHandler) formatProjectLabel(projectID string) string {
return projectID
}
func (h *RobotHandler) cmdProjects() string {
func (h *RobotHandler) cmdProjects(platform, userID string) string {
if !h.projectsEnabled() {
return "项目功能未启用(config.project.enabled)。"
}
list, err := h.db.ListProjects("", "", 50, 0)
list, err := h.db.ListProjectsForAccess("", "", 50, 0, robotOwnerID(platform, userID), database.RBACScopeOwn)
if err != nil {
return "获取项目列表失败: " + err.Error()
}
@@ -380,7 +438,7 @@ func (h *RobotHandler) cmdBindProject(platform, userID, idOrName string) string
if !h.projectsEnabled() {
return "项目功能未启用(config.project.enabled)。"
}
p, errMsg := h.resolveProjectByIDOrName(idOrName)
p, errMsg := h.resolveProjectByIDOrName(platform, userID, idOrName)
if p == nil {
return errMsg
}
@@ -407,6 +465,7 @@ func (h *RobotHandler) cmdNewProject(platform, userID, name string) string {
if err != nil {
return "创建项目失败: " + err.Error()
}
_ = h.db.SetResourceOwner("project", created.ID, robotOwnerID(platform, userID))
convID, _ := h.getOrCreateConversation(platform, userID, name)
if convID == "" {
return fmt.Sprintf("项目已创建:「%s」\nID: %s\n(绑定当前对话失败,请手动发送「绑定项目 %s」)", created.Name, created.ID, created.ID)
@@ -430,6 +489,9 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
convID = persistedConvID
}
}
if !h.db.UserCanAccessResource(robotOwnerID(platform, userID), database.RBACScopeOwn, "conversation", convID) {
return "当前对话不存在或无权访问。"
}
if convID == "" {
return "当前没有进行中的对话,无需解除绑定。"
}
@@ -446,8 +508,8 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
return "已解除当前对话的项目绑定。"
}
func (h *RobotHandler) cmdList() string {
convs, err := h.db.ListConversations(50, 0, "", "", "")
func (h *RobotHandler) cmdList(platform, userID string) string {
convs, err := h.db.ListConversationsForAccess(50, 0, "", "", "", robotOwnerID(platform, userID), database.RBACScopeOwn)
if err != nil {
return "获取对话列表失败: " + err.Error()
}
@@ -471,7 +533,7 @@ func (h *RobotHandler) cmdSwitch(platform, userID, convID string) string {
return "请指定对话 ID,例如:切换 xxx-xxx-xxx"
}
conv, err := h.db.GetConversation(convID)
if err != nil {
if err != nil || !h.db.UserCanAccessResource(robotOwnerID(platform, userID), database.RBACScopeOwn, "conversation", convID) {
return "对话不存在或 ID 错误。"
}
h.setConversation(platform, userID, conv.ID)
@@ -512,6 +574,9 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string {
if convID == "" {
return "当前没有进行中的对话。发送任意内容将创建新对话。"
}
if !h.db.UserCanAccessResource(robotOwnerID(platform, userID), database.RBACScopeOwn, "conversation", convID) {
return "当前对话不存在或无权访问。"
}
conv, err := h.db.GetConversation(convID)
if err != nil {
return "当前对话 ID: " + convID + "(获取标题失败)"
@@ -582,6 +647,9 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
if convID == "" {
return "请指定对话 ID,例如:删除 xxx-xxx-xxx"
}
if !h.db.UserCanAccessResource(robotOwnerID(platform, userID), database.RBACScopeOwn, "conversation", convID) {
return "对话不存在或无权访问。"
}
sk := h.sessionKey(platform, userID)
h.mu.RLock()
currentConvID := h.sessions[sk]
@@ -617,7 +685,7 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string
case text == robotCmdHelp || text == "help" || text == "" || text == "?":
return h.cmdHelp(), true
case text == robotCmdList || text == robotCmdListAlt || text == "list":
return h.cmdList(), true
return h.cmdList(platform, userID), true
case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "):
var id string
switch {
@@ -663,7 +731,7 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string
case text == robotCmdVersion || text == "version":
return h.cmdVersion(), true
case text == robotCmdProjects || text == robotCmdProjectsList || text == "projects":
return h.cmdProjects(), true
return h.cmdProjects(platform, userID), true
case text == robotCmdUnbindProject || text == "unbind project":
return h.cmdUnbindProject(platform, userID), true
case strings.HasPrefix(text, robotCmdNewProject+" ") || strings.HasPrefix(text, "new project "):
@@ -903,6 +971,11 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
c.String(http.StatusOK, "")
return
}
if !h.acceptFreshWecomRequest(timestamp, nonce, msgSignature) {
h.logger.Warn("企业微信 POST 时间戳过期或请求重放,已拒绝")
c.String(http.StatusOK, "")
return
}
var body wecomXML
if err := xml.Unmarshal(bodyRaw, &body); err != nil {
+64
View File
@@ -0,0 +1,64 @@
package handler
import (
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
func TestRobotUsersAreResourceIsolated(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "robot-rbac.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
cfg := &config.Config{}
cfg.Project.Enabled = true
h := NewRobotHandler(cfg, db, nil, zap.NewNop())
conversationID, _ := h.getOrCreateConversation("wecom", "alice", "alice conversation")
if conversationID == "" {
t.Fatal("alice conversation was not created")
}
if got := h.cmdList("wecom", "bob"); strings.Contains(got, conversationID) {
t.Fatalf("bob listed alice conversation: %s", got)
}
if got := h.cmdSwitch("wecom", "bob", conversationID); !strings.Contains(got, "不存在") && !strings.Contains(got, "无权访问") {
t.Fatalf("bob switched to alice conversation: %s", got)
}
if got := h.cmdDelete("wecom", "bob", conversationID); !strings.Contains(got, "无权访问") {
t.Fatalf("bob deleted alice conversation: %s", got)
}
if _, err := db.GetConversation(conversationID); err != nil {
t.Fatalf("alice conversation was deleted: %v", err)
}
createReply := h.cmdNewProject("wecom", "alice", "alice project")
if !strings.Contains(createReply, "已创建项目") {
t.Fatalf("create project reply: %s", createReply)
}
if got := h.cmdProjects("wecom", "bob"); strings.Contains(got, "alice project") {
t.Fatalf("bob listed alice project: %s", got)
}
}
func TestWecomReplayGuardRequiresFreshUniqueRequest(t *testing.T) {
h := NewRobotHandler(&config.Config{}, nil, nil, zap.NewNop())
timestamp := time.Now().Unix()
if !h.acceptFreshWecomRequest(fmt.Sprintf("%d", timestamp), "nonce", "signature") {
t.Fatal("fresh request was rejected")
}
if h.acceptFreshWecomRequest(fmt.Sprintf("%d", timestamp), "nonce", "signature") {
t.Fatal("duplicate request was accepted")
}
if h.acceptFreshWecomRequest(fmt.Sprintf("%d", timestamp-600), "old", "signature") {
t.Fatal("stale request was accepted")
}
}
+9 -1
View File
@@ -254,7 +254,15 @@ func (h *VulnerabilityHandler) UpdateVulnerability(c *gin.Context) {
// 更新字段
if req.ProjectID != nil {
existing.ProjectID = strings.TrimSpace(*req.ProjectID)
targetProjectID := strings.TrimSpace(*req.ProjectID)
if targetProjectID != "" {
session, ok := security.CurrentSession(c)
if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "project", targetProjectID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权关联目标项目"})
return
}
}
existing.ProjectID = targetProjectID
}
if req.ConversationTag != nil {
existing.ConversationTag = *req.ConversationTag
+19 -8
View File
@@ -721,10 +721,15 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"})
return
}
if !h.webshellConnectionRequestAllowed(c, req.ConnectionID, req.URL) {
conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL)
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
// The database record is authoritative. Never let a caller pair an
// authorized ID with attacker-controlled transport credentials or a URL.
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding = conn.Method, conn.CmdParam, conn.Encoding
parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
@@ -818,10 +823,13 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "url and action are required"})
return
}
if !h.webshellConnectionRequestAllowed(c, req.ConnectionID, req.URL) {
conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL)
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding, req.OS = conn.Method, conn.CmdParam, conn.Encoding, conn.OS
parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
@@ -898,23 +906,26 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
})
}
func (h *WebShellHandler) webshellConnectionRequestAllowed(c *gin.Context, connectionID, requestURL string) bool {
func (h *WebShellHandler) authorizedWebshellConnection(c *gin.Context, connectionID, requestURL string) (*database.WebShellConnection, bool) {
connectionID = strings.TrimSpace(connectionID)
if connectionID == "" {
return true
return nil, false
}
if h.db == nil {
return false
return nil, false
}
session, ok := security.CurrentSession(c)
if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "webshell", connectionID) {
return false
return nil, false
}
conn, err := h.db.GetWebshellConnection(connectionID)
if err != nil || conn == nil {
return false
return nil, false
}
return strings.TrimSpace(conn.URL) == strings.TrimSpace(requestURL)
if requestURL = strings.TrimSpace(requestURL); requestURL != "" && strings.TrimSpace(conn.URL) != requestURL {
return nil, false
}
return conn, true
}
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
+12
View File
@@ -40,6 +40,18 @@ func TestWebshellExecRequiresConnectionAccessWhenConnectionIDProvided(t *testing
}
}
func TestWebshellExecRejectsAdHocURLWithoutConnectionID(t *testing.T) {
gin.SetMode(gin.TestMode)
_, user, _, _ := setupWebshellRBACTest(t)
handler := NewWebShellHandler(zap.NewNop(), nil)
w := performWebshellJSON(user, http.MethodPost, "/api/webshell/exec", map[string]interface{}{
"url": "http://127.0.0.1/admin", "command": "id",
}, handler.Exec)
if w.Code != http.StatusForbidden {
t.Fatalf("ad-hoc URL status = %d, want %d: %s", w.Code, http.StatusForbidden, w.Body.String())
}
}
func TestWebshellFileOpRequiresConnectionAccessWhenConnectionIDProvided(t *testing.T) {
gin.SetMode(gin.TestMode)
db, user, _, hidden := setupWebshellRBACTest(t)
+5 -1
View File
@@ -36,6 +36,7 @@ func (h *AgentHandler) roleForWorkflow(req *ChatRequest) (config.RoleConfig, boo
}
func (h *AgentHandler) runRoleWorkflowStreamIfBound(
c *gin.Context,
req *ChatRequest,
prep *multiAgentPrepared,
sendEvent func(eventType, message string, data interface{}),
@@ -60,7 +61,10 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound(
}
}()
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
if c == nil || c.Request == nil {
return false
}
baseCtx, cancelWithCause := context.WithCancelCause(detachedAgentContext(c.Request.Context()))
defer cancelWithCause(nil)
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
defer timeoutCancel()
+38
View File
@@ -8,6 +8,8 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
workflowrunner "cyberstrike-ai/internal/workflow"
"github.com/gin-gonic/gin"
@@ -20,6 +22,10 @@ func (h *WorkflowHandler) SetRuntime(agent *agent.Agent, cfg *config.Config) {
func (h *WorkflowHandler) GetRun(c *gin.Context) {
runID := strings.TrimSpace(c.Param("runId"))
if !h.workflowRunAllowed(c, runID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
run, err := h.db.GetWorkflowRun(runID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -39,6 +45,10 @@ func (h *WorkflowHandler) GetRun(c *gin.Context) {
func (h *WorkflowHandler) ReplayRun(c *gin.Context) {
runID := strings.TrimSpace(c.Param("runId"))
if !h.workflowRunAllowed(c, runID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
nodeRuns, err := h.db.ListWorkflowNodeRuns(runID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -67,11 +77,18 @@ func (h *WorkflowHandler) ReplayRun(c *gin.Context) {
func (h *WorkflowHandler) ListPendingRuns(c *gin.Context) {
conversationID := strings.TrimSpace(c.Query("conversationId"))
if conversationID != "" && !h.workflowConversationAllowed(c, conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
runs, err := h.db.ListWorkflowRunsAwaitingHITLFiltered(conversationID, 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
runs = filterSlice(runs, func(run *database.WorkflowRun) bool {
return run != nil && h.workflowConversationAllowed(c, run.ConversationID)
})
c.JSON(http.StatusOK, gin.H{"runs": runs})
}
@@ -86,6 +103,10 @@ func (h *WorkflowHandler) ResumeRun(c *gin.Context) {
return
}
runID := strings.TrimSpace(c.Param("runId"))
if !h.workflowRunAllowed(c, runID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
var req workflowResumeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
@@ -160,3 +181,20 @@ func (h *WorkflowHandler) ResumeRun(c *gin.Context) {
"awaitingHitl": result.AwaitingHITL,
})
}
func (h *WorkflowHandler) workflowConversationAllowed(c *gin.Context, conversationID string) bool {
session, ok := security.CurrentSession(c)
return ok && h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", strings.TrimSpace(conversationID))
}
func (h *WorkflowHandler) workflowRunAllowed(c *gin.Context, runID string) bool {
session, ok := security.CurrentSession(c)
if !ok {
return false
}
if session.Scope == database.RBACScopeAll {
return true
}
run, err := h.db.GetWorkflowRun(strings.TrimSpace(runID))
return err == nil && run != nil && h.workflowConversationAllowed(c, run.ConversationID)
}