mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-14 07:57:26 +02:00
Add files via upload
This commit is contained in:
@@ -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"` // 任务标题(可选)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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 连接")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()})
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 调用)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user