diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 087144bd..640bfdd9 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -729,22 +729,15 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry( } // ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复 -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) +func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform string, principal authctx.Principal, conversationID, message, role string) (response string, convID string, err error) { + ownerUserID := strings.TrimSpace(principal.UserID) if ownerUserID == "" { - return "", "", fmt.Errorf("robot owner identity is required") + return "", "", fmt.Errorf("authenticated robot principal is required") } - ctx = authctx.WithPrincipal(ctx, authctx.NewPrincipal(ownerUserID, "robot-service", database.RBACScopeOwn, permissions)) + if !principal.HasPermission("agent:execute") || !principal.HasPermission("chat:read") || !principal.HasPermission("chat:write") { + return "", "", fmt.Errorf("机器人账号缺少 agent:execute、chat:read 或 chat:write 权限") + } + ctx = authctx.WithPrincipal(ctx, principal) if conversationID == "" { title := safeTruncateString(message, 50) src := "robot" @@ -753,7 +746,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform, own } meta := audit.ConversationCreateMeta(src) meta.ProjectID = effectiveProjectID(h.config, "") - if meta.ProjectID != "" && !h.db.UserCanAccessResource(ownerUserID, database.RBACScopeOwn, "project", meta.ProjectID) { + if meta.ProjectID != "" && (!principal.HasPermission("project:read") || !h.db.UserCanAccessResource(ownerUserID, principal.ScopeFor("project:read"), "project", meta.ProjectID)) { meta.ProjectID = "" } conv, createErr := h.db.CreateConversation(title, meta) @@ -763,7 +756,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform, own conversationID = conv.ID _ = h.db.SetResourceOwner("conversation", conversationID, ownerUserID) } else { - if _, getErr := h.db.GetConversation(conversationID); getErr != nil || !h.db.UserCanAccessResource(ownerUserID, database.RBACScopeOwn, "conversation", conversationID) { + if _, getErr := h.db.GetConversation(conversationID); getErr != nil || !h.db.UserCanAccessResource(ownerUserID, principal.ScopeFor("chat:write"), "conversation", conversationID) { return "", "", fmt.Errorf("对话不存在") } } diff --git a/internal/handler/config.go b/internal/handler/config.go index 1872d180..fa4463db 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -16,6 +16,7 @@ import ( "cyberstrike-ai/internal/agents" "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" "cyberstrike-ai/internal/knowledge" "cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp/builtin" @@ -89,11 +90,32 @@ type ConfigHandler struct { appUpdater AppUpdater // App更新器(可选) robotRestarter RobotRestarter // 机器人连接重启器(可选),ApplyConfig 时重启钉钉/飞书 audit *audit.Service + db *database.DB logger *zap.Logger mu sync.RWMutex lastEmbeddingConfig *config.EmbeddingConfig // 上一次的嵌入模型配置(用于检测变更) } +func (h *ConfigHandler) SetDB(db *database.DB) { + h.db = db +} + +func (h *ConfigHandler) validateRobotServiceAccounts(robots config.RobotsConfig) error { + if h.db == nil { + return fmt.Errorf("RBAC 服务不可用,无法校验机器人服务账号") + } + for platform, userID := range robots.ServiceAccountUserIDs() { + user, err := h.db.GetRBACUserByID(userID) + if err != nil { + return fmt.Errorf("robots.%s.auth.service_user_id 对应用户不存在", platform) + } + if !user.Enabled { + return fmt.Errorf("robots.%s.auth.service_user_id 对应用户已禁用", platform) + } + } + return nil +} + // AttackChainUpdater 攻击链处理器更新接口 type AttackChainUpdater interface { UpdateConfig(cfg *config.OpenAIConfig) @@ -827,6 +849,14 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if err := config.ValidateRobotsAuthorization(*req.Robots); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := h.validateRobotServiceAccounts(*req.Robots); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } h.config.Robots = *req.Robots h.logger.Info("更新机器人配置", zap.Bool("wechat_enabled", h.config.Robots.Wechat.Enabled), diff --git a/internal/handler/robot.go b/internal/handler/robot.go index f67c495b..557fd253 100644 --- a/internal/handler/robot.go +++ b/internal/handler/robot.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "crypto/sha1" "crypto/sha256" + "encoding/base32" "encoding/base64" "encoding/binary" "encoding/json" @@ -22,8 +23,11 @@ import ( "sync" "time" + "cyberstrike-ai/internal/audit" + "cyberstrike-ai/internal/authctx" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -49,6 +53,10 @@ const ( robotCmdBindProject = "绑定项目" robotCmdNewProject = "新建项目" robotCmdUnbindProject = "解除项目" + robotCmdBindUser = "绑定" + robotCmdUnbindUser = "解绑" + robotCmdIdentity = "身份" + robotBindingCodeTTL = 5 * time.Minute ) // RobotHandler 企业微信/钉钉/飞书等机器人回调处理 @@ -63,6 +71,7 @@ type RobotHandler struct { cancelMu sync.Mutex // 保护 runningCancels runningCancels map[string]context.CancelFunc // key: "platform_userID", 用于停止命令中断任务 wecomReplay map[string]time.Time + audit *audit.Service } // NewRobotHandler 创建机器人处理器 @@ -79,6 +88,10 @@ func NewRobotHandler(cfg *config.Config, db *database.DB, agentHandler *AgentHan } } +func (h *RobotHandler) SetAudit(s *audit.Service) { + h.audit = s +} + func (h *RobotHandler) acceptFreshWecomRequest(timestamp, nonce, signature string) bool { unixSeconds, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64) if err != nil { @@ -112,12 +125,54 @@ 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 normalizeRobotBindingCode(code string) string { + return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), "-", "")) +} + +func hashRobotBindingCode(code string) string { + sum := sha256.Sum256([]byte(normalizeRobotBindingCode(code))) + return fmt.Sprintf("%x", sum[:]) +} + +func (h *RobotHandler) resolveRobotAccess(platform, userID string) (*database.RBACAccess, error) { + if h.db == nil { + return nil, fmt.Errorf("机器人鉴权服务不可用") + } + authorization := h.config.Robots.AuthorizationFor(platform) + var access *database.RBACAccess + var err error + switch authorization.EffectiveMode() { + case config.RobotAuthModeUserBinding: + access, err = h.db.ResolveRobotRBACAccess(platform, userID) + case config.RobotAuthModeServiceAccount: + if !authorization.ExternalUserAllowed(userID) { + return nil, fmt.Errorf("机器人发送者不在服务账号白名单中") + } + access, err = h.db.ResolveRBACAccess(strings.TrimSpace(authorization.ServiceUserID)) + default: + return nil, fmt.Errorf("机器人鉴权模式无效") + } + if err != nil { + return nil, err + } + if !access.User.Enabled { + return nil, fmt.Errorf("绑定的平台账号已被禁用") + } + return access, nil +} + +func robotPrincipal(access *database.RBACAccess) authctx.Principal { + if access == nil { + return authctx.Principal{} + } + return authctx.NewPrincipalWithScopes(access.User.ID, access.User.Username, access.Scope, access.Permissions, access.PermissionScopes) +} + +func (h *RobotHandler) robotAccessDeniedMessage(platform string) string { + if h.config.Robots.AuthorizationFor(platform).EffectiveMode() == config.RobotAuthModeServiceAccount { + return "当前平台账号不在该机器人的服务账号白名单中,或服务账号不可用。" + } + return "当前平台账号尚未绑定 CyberStrikeAI 用户。请先在网页端生成绑定码,然后发送:绑定 XXXX-XXXX" } func (h *RobotHandler) loadSessionBinding(sk string) (convID, role string) { @@ -154,17 +209,18 @@ func (h *RobotHandler) deleteSessionBinding(sk string) { } // getOrCreateConversation 获取或创建当前会话,title 用于新对话的标题(取用户首条消息前50字) -func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (convID string, isNew bool) { +func (h *RobotHandler) getOrCreateConversation(platform, userID, title string, access *database.RBACAccess) (convID string, isNew bool) { sk := h.sessionKey(platform, userID) h.mu.RLock() convID = h.sessions[sk] h.mu.RUnlock() - ownerID := robotOwnerID(platform, userID) - if convID != "" && h.db.UserCanAccessResource(ownerID, database.RBACScopeOwn, "conversation", convID) { + ownerID := access.User.ID + readScope := robotPrincipal(access).ScopeFor("chat:read") + if convID != "" && access.Permissions["chat:read"] && h.db.UserCanAccessResource(ownerID, readScope, "conversation", convID) { return convID, false } if persistedConvID, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedConvID) != "" { - if !h.db.UserCanAccessResource(ownerID, database.RBACScopeOwn, "conversation", persistedConvID) { + if !access.Permissions["chat:read"] || !h.db.UserCanAccessResource(ownerID, readScope, "conversation", persistedConvID) { h.deleteSessionBinding(sk) } else { // 会话绑定持久化:服务重启后也可恢复当前对话和角色。 @@ -184,8 +240,11 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) ( t = safeTruncateString(t, 50) } meta := database.ConversationCreateMeta{Source: "robot:" + platform} + if !access.Permissions["chat:write"] { + return "", false + } meta.ProjectID = effectiveProjectID(h.config, "") - if meta.ProjectID != "" && !h.db.UserCanAccessResource(ownerID, database.RBACScopeOwn, "project", meta.ProjectID) { + if meta.ProjectID != "" && (!access.Permissions["project:read"] || !h.db.UserCanAccessResource(ownerID, robotPrincipal(access).ScopeFor("project:read"), "project", meta.ProjectID)) { meta.ProjectID = "" } conv, err := h.db.CreateConversation(t, meta) @@ -242,12 +301,12 @@ func (h *RobotHandler) setRole(platform, userID, roleName string) { } // clearConversation 清空当前会话(切换到新对话) -func (h *RobotHandler) clearConversation(platform, userID string) (newConvID string) { +func (h *RobotHandler) clearConversation(platform, userID string, access *database.RBACAccess) (newConvID string) { 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) { + ownerID := access.User.ID + if meta.ProjectID != "" && (!access.Permissions["project:read"] || !h.db.UserCanAccessResource(ownerID, robotPrincipal(access).ScopeFor("project:read"), "project", meta.ProjectID)) { meta.ProjectID = "" } conv, err := h.db.CreateConversation(title, meta) @@ -280,9 +339,24 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin if cmdReply, ok := h.handleRobotCommand(platform, userID, text); ok { return cmdReply } + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return h.robotAccessDeniedMessage(platform) + } + if !access.Permissions["agent:execute"] || !access.Permissions["chat:read"] || !access.Permissions["chat:write"] { + return "权限不足:机器人对话需要 agent:execute、chat:read 和 chat:write 权限。" + } + if h.audit != nil && h.config.Robots.AuthorizationFor(platform).EffectiveMode() == config.RobotAuthModeServiceAccount { + hint := sha256.Sum256([]byte(userID)) + h.audit.RecordSystem(audit.Entry{ + Category: "robot", Action: "service_account_execute", Result: "success", Actor: access.User.Username, + ResourceType: "robot_sender", ResourceID: platform + ":" + fmt.Sprintf("%x", hint[:4]), + Message: "白名单平台发送者使用机器人服务账号执行 Agent", + }) + } // 普通消息:走 Agent - convID, _ := h.getOrCreateConversation(platform, userID, text) + convID, _ := h.getOrCreateConversation(platform, userID, text, access) if convID == "" { return "无法创建或获取对话,请稍后再试。" } @@ -305,7 +379,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, robotOwnerID(platform, userID), convID, text, role) + resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, platform, robotPrincipal(access), 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) { @@ -333,6 +407,9 @@ func (h *RobotHandler) cmdHelp() string { b.WriteString("【通用 General】\n") b.WriteString("· 帮助 / help — 显示本帮助\n") b.WriteString("· 版本 / version — 显示当前版本号\n") + b.WriteString("· 绑定 <绑定码> / bind — 绑定网页端 RBAC 用户\n") + b.WriteString("· 解绑 / unbind — 解除当前平台账号绑定\n") + b.WriteString("· 身份 / whoami — 显示平台发送者、鉴权模式及当前实际 RBAC 身份\n") b.WriteString("\n【对话 Conversation】\n") b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n") b.WriteString("· 切换 / switch — 指定对话继续\n") @@ -360,19 +437,20 @@ func (h *RobotHandler) projectsEnabled() bool { return h.config != nil && h.config.Project.Enabled } -func (h *RobotHandler) resolveProjectByIDOrName(platform, userID, idOrName string) (*database.Project, string) { +func (h *RobotHandler) resolveProjectByIDOrName(access *database.RBACAccess, idOrName string) (*database.Project, string) { idOrName = strings.TrimSpace(idOrName) if idOrName == "" { return nil, "请指定项目 ID 或名称,例如:绑定项目 xxx-xxx" } - ownerID := robotOwnerID(platform, userID) + ownerID := access.User.ID + scope := robotPrincipal(access).ScopeFor("project:read") if p, err := h.db.GetProject(idOrName); err == nil { - if h.db.UserCanAccessResource(ownerID, database.RBACScopeOwn, "project", p.ID) { + if h.db.UserCanAccessResource(ownerID, scope, "project", p.ID) { return p, "" } return nil, "项目不存在或无权访问。" } - list, err := h.db.ListProjectsForAccess("", "", 200, 0, ownerID, database.RBACScopeOwn) + list, err := h.db.ListProjectsForAccess("", "", 200, 0, ownerID, scope) if err != nil { return nil, "查询项目失败: " + err.Error() } @@ -411,7 +489,11 @@ func (h *RobotHandler) cmdProjects(platform, userID string) string { if !h.projectsEnabled() { return "项目功能未启用(config.project.enabled)。" } - list, err := h.db.ListProjectsForAccess("", "", 50, 0, robotOwnerID(platform, userID), database.RBACScopeOwn) + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return "当前平台账号尚未绑定。" + } + list, err := h.db.ListProjectsForAccess("", "", 50, 0, access.User.ID, robotPrincipal(access).ScopeFor("project:read")) if err != nil { return "获取项目列表失败: " + err.Error() } @@ -438,11 +520,15 @@ func (h *RobotHandler) cmdBindProject(platform, userID, idOrName string) string if !h.projectsEnabled() { return "项目功能未启用(config.project.enabled)。" } - p, errMsg := h.resolveProjectByIDOrName(platform, userID, idOrName) + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return "当前平台账号尚未绑定。" + } + p, errMsg := h.resolveProjectByIDOrName(access, idOrName) if p == nil { return errMsg } - convID, _ := h.getOrCreateConversation(platform, userID, "") + convID, _ := h.getOrCreateConversation(platform, userID, "", access) if convID == "" { return "无法获取当前对话,请稍后再试。" } @@ -460,13 +546,17 @@ func (h *RobotHandler) cmdNewProject(platform, userID, name string) string { if name == "" { return "请指定项目名称,例如:新建项目 某目标渗透" } + access, accessErr := h.resolveRobotAccess(platform, userID) + if accessErr != nil { + return "当前平台账号尚未绑定。" + } p := &database.Project{Name: name, Status: "active"} created, err := h.db.CreateProject(p) if err != nil { return "创建项目失败: " + err.Error() } - _ = h.db.SetResourceOwner("project", created.ID, robotOwnerID(platform, userID)) - convID, _ := h.getOrCreateConversation(platform, userID, name) + _ = h.db.SetResourceOwner("project", created.ID, access.User.ID) + convID, _ := h.getOrCreateConversation(platform, userID, name, access) if convID == "" { return fmt.Sprintf("项目已创建:「%s」\nID: %s\n(绑定当前对话失败,请手动发送「绑定项目 %s」)", created.Name, created.ID, created.ID) } @@ -489,7 +579,11 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string { convID = persistedConvID } } - if !h.db.UserCanAccessResource(robotOwnerID(platform, userID), database.RBACScopeOwn, "conversation", convID) { + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return "当前平台账号尚未绑定。" + } + if !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:write"), "conversation", convID) { return "当前对话不存在或无权访问。" } if convID == "" { @@ -509,7 +603,11 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string { } func (h *RobotHandler) cmdList(platform, userID string) string { - convs, err := h.db.ListConversationsForAccess(50, 0, "", "", "", robotOwnerID(platform, userID), database.RBACScopeOwn) + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return "当前平台账号尚未绑定。" + } + convs, err := h.db.ListConversationsForAccess(50, 0, "", "", "", access.User.ID, robotPrincipal(access).ScopeFor("chat:read")) if err != nil { return "获取对话列表失败: " + err.Error() } @@ -532,8 +630,12 @@ func (h *RobotHandler) cmdSwitch(platform, userID, convID string) string { if convID == "" { return "请指定对话 ID,例如:切换 xxx-xxx-xxx" } + access, accessErr := h.resolveRobotAccess(platform, userID) + if accessErr != nil { + return "当前平台账号尚未绑定。" + } conv, err := h.db.GetConversation(convID) - if err != nil || !h.db.UserCanAccessResource(robotOwnerID(platform, userID), database.RBACScopeOwn, "conversation", convID) { + if err != nil || !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:read"), "conversation", convID) { return "对话不存在或 ID 错误。" } h.setConversation(platform, userID, conv.ID) @@ -541,7 +643,11 @@ func (h *RobotHandler) cmdSwitch(platform, userID, convID string) string { } func (h *RobotHandler) cmdNew(platform, userID string) string { - newID := h.clearConversation(platform, userID) + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return "当前平台账号尚未绑定。" + } + newID := h.clearConversation(platform, userID, access) if newID == "" { return "创建新对话失败,请重试。" } @@ -574,7 +680,11 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string { if convID == "" { return "当前没有进行中的对话。发送任意内容将创建新对话。" } - if !h.db.UserCanAccessResource(robotOwnerID(platform, userID), database.RBACScopeOwn, "conversation", convID) { + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return "当前平台账号尚未绑定。" + } + if !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:read"), "conversation", convID) { return "当前对话不存在或无权访问。" } conv, err := h.db.GetConversation(convID) @@ -647,7 +757,11 @@ 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) { + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return "当前平台账号尚未绑定。" + } + if !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:delete"), "conversation", convID) { return "对话不存在或无权访问。" } sk := h.sessionKey(platform, userID) @@ -679,11 +793,162 @@ func (h *RobotHandler) cmdVersion() string { return "CyberStrikeAI " + v } +func (h *RobotHandler) cmdIdentity(platform, userID string) string { + authorization := h.config.Robots.AuthorizationFor(platform) + mode := authorization.EffectiveMode() + modeLabel := "逐用户绑定(user_binding)" + if mode == config.RobotAuthModeServiceAccount { + modeLabel = "专用服务账号(service_account)" + } + var b strings.Builder + b.WriteString("【机器人身份】\n") + b.WriteString("平台:" + platform + "\n") + b.WriteString("发送者 ID:" + userID + "\n") + b.WriteString("鉴权模式:" + modeLabel + "\n") + + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + if mode == config.RobotAuthModeServiceAccount { + b.WriteString("鉴权状态:拒绝(发送者不在白名单中,或服务账号不可用)") + } else { + b.WriteString("鉴权状态:未绑定\n") + b.WriteString("操作提示:请在 Web 端生成绑定码,然后发送“绑定 XXXX-XXXX”") + } + return b.String() + } + + name := strings.TrimSpace(access.User.DisplayName) + if name == "" { + name = access.User.Username + } + roleNames := make([]string, 0, len(access.Roles)) + for _, role := range access.Roles { + roleNames = append(roleNames, role.Name) + } + if len(roleNames) == 0 { + roleNames = append(roleNames, "未分配角色") + } + b.WriteString("鉴权状态:已授权\n") + b.WriteString("实际身份:" + name + " (" + access.User.Username + ")\n") + b.WriteString("RBAC User ID:" + access.User.ID + "\n") + b.WriteString("平台角色:" + strings.Join(roleNames, "、") + "\n") + b.WriteString("资源范围:" + access.Scope + "\n") + b.WriteString(fmt.Sprintf("有效权限:%d 项", len(access.Permissions))) + return b.String() +} + +func robotCommandPermission(text string) (string, bool) { + switch { + case text == robotCmdHelp || text == "help" || text == "?" || text == "?", text == robotCmdVersion || text == "version", text == robotCmdIdentity || text == "whoami": + return "", true + case text == robotCmdList || text == robotCmdListAlt || text == "list", + strings.HasPrefix(text, robotCmdSwitch+" "), strings.HasPrefix(text, robotCmdContinue+" "), + strings.HasPrefix(text, "switch "), strings.HasPrefix(text, "continue "), + text == robotCmdCurrent || text == "current": + return "chat:read", true + case text == robotCmdNew || text == "new", text == robotCmdClear || text == "clear": + return "chat:write", true + case strings.HasPrefix(text, robotCmdDelete+" "), strings.HasPrefix(text, "delete "): + return "chat:delete", true + case text == robotCmdStop || text == "stop": + return "agent:execute", true + case text == robotCmdRoles || text == robotCmdRolesList || text == "roles", + strings.HasPrefix(text, robotCmdRoles+" "), strings.HasPrefix(text, robotCmdSwitchRole+" "), strings.HasPrefix(text, "role "): + return "roles:read", true + case text == robotCmdProjects || text == robotCmdProjectsList || text == "projects": + return "project:read", true + case text == robotCmdUnbindProject || text == "unbind project", + strings.HasPrefix(text, robotCmdNewProject+" "), strings.HasPrefix(text, "new project "), + strings.HasPrefix(text, robotCmdBindProject+" "), strings.HasPrefix(text, "bind project "): + return "project:write", true + default: + return "", false + } +} + +func (h *RobotHandler) cmdBindUser(platform, userID, code string) string { + if h.config.Robots.AuthorizationFor(platform).EffectiveMode() != config.RobotAuthModeUserBinding { + return "该机器人使用受控服务账号模式,不接受用户绑定。" + } + code = normalizeRobotBindingCode(code) + if code == "" { + return "请提供绑定码,例如:绑定 ABCD-1234" + } + user, err := h.db.ConsumeRobotBindingCode(platform, userID, hashRobotBindingCode(code)) + if err != nil { + return "绑定失败:绑定码无效、已使用或已过期。请在网页端重新生成。" + } + // Never carry an old synthetic-owner conversation into the RBAC identity. + sk := h.sessionKey(platform, userID) + h.mu.Lock() + delete(h.sessions, sk) + delete(h.sessionRoles, sk) + h.mu.Unlock() + h.deleteSessionBinding(sk) + name := strings.TrimSpace(user.DisplayName) + if name == "" { + name = user.Username + } + if h.audit != nil { + hint := sha256.Sum256([]byte(userID)) + h.audit.RecordSystem(audit.Entry{ + Category: "auth", Action: "robot_bind", Result: "success", Actor: user.Username, + ResourceType: "robot_binding", ResourceID: platform + ":" + fmt.Sprintf("%x", hint[:4]), Message: "机器人平台账号绑定成功", + }) + } + return fmt.Sprintf("绑定成功,当前身份:%s。后续操作将实时使用该用户的 RBAC 权限。", name) +} + +func (h *RobotHandler) cmdUnbindUser(platform, userID string) string { + if h.config.Robots.AuthorizationFor(platform).EffectiveMode() != config.RobotAuthModeUserBinding { + return "该机器人使用受控服务账号模式,无需用户解绑。" + } + access, accessErr := h.resolveRobotAccess(platform, userID) + if accessErr != nil { + return "当前平台账号尚未绑定。" + } + if err := h.db.DeleteRobotIdentityBinding(platform, userID); err != nil { + return "解绑失败,请稍后重试。" + } + sk := h.sessionKey(platform, userID) + h.mu.Lock() + delete(h.sessions, sk) + delete(h.sessionRoles, sk) + h.mu.Unlock() + h.deleteSessionBinding(sk) + if h.audit != nil { + hint := sha256.Sum256([]byte(userID)) + h.audit.RecordSystem(audit.Entry{ + Category: "auth", Action: "robot_unbind", Result: "success", Actor: access.User.Username, + ResourceType: "robot_binding", ResourceID: platform + ":" + fmt.Sprintf("%x", hint[:4]), Message: "机器人平台账号解绑成功", + }) + } + return "已解除当前平台账号与 CyberStrikeAI 用户的绑定。" +} + // handleRobotCommand 处理机器人内置命令;若匹配到命令返回 (回复内容, true),否则返回 ("", false) func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string, bool) { + if (strings.HasPrefix(text, robotCmdBindUser+" ") || strings.HasPrefix(text, "bind ")) && !strings.HasPrefix(text, "bind project ") { + parts := strings.SplitN(text, " ", 2) + return h.cmdBindUser(platform, userID, strings.TrimSpace(parts[1])), true + } + if text == robotCmdUnbindUser || text == "unbind" { + return h.cmdUnbindUser(platform, userID), true + } + if permission, recognized := robotCommandPermission(text); recognized && permission != "" { + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return h.robotAccessDeniedMessage(platform), true + } + if !access.Permissions[permission] { + return fmt.Sprintf("权限不足:缺少 %s 权限。", permission), true + } + } switch { case text == robotCmdHelp || text == "help" || text == "?" || text == "?": return h.cmdHelp(), true + case text == robotCmdIdentity || text == "whoami": + return h.cmdIdentity(platform, userID), true case text == robotCmdList || text == robotCmdListAlt || text == "list": return h.cmdList(platform, userID), true case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "): @@ -1152,6 +1417,74 @@ func (h *RobotHandler) sendWecomReply(c *gin.Context, toUser, fromUser, content, // —————— 测试接口(需登录,用于验证机器人逻辑,无需钉钉/飞书客户端) —————— +// CreateRobotBindingCode creates a short-lived, single-use secret for the +// currently authenticated RBAC user. Only its hash is persisted. +func (h *RobotHandler) CreateRobotBindingCode(c *gin.Context) { + session, ok := security.CurrentSession(c) + if !ok || strings.TrimSpace(session.UserID) == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权访问"}) + return + } + random := make([]byte, 5) + if _, err := rand.Read(random); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "生成绑定码失败"}) + return + } + raw := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(random) + code := raw[:4] + "-" + raw[4:] + expiresAt := time.Now().Add(robotBindingCodeTTL) + if err := h.db.CreateRobotBindingCode(session.UserID, hashRobotBindingCode(code), expiresAt); err != nil { + h.logger.Warn("创建机器人绑定码失败", zap.String("user_id", session.UserID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "生成绑定码失败"}) + return + } + if h.audit != nil { + h.audit.Record(c, audit.Entry{Category: "auth", Action: "robot_binding_code_create", Result: "success", ResourceType: "user", ResourceID: session.UserID, Message: "生成机器人一次性绑定码"}) + } + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, gin.H{ + "code": code, "expires_at": expiresAt.UTC().Format(time.RFC3339), "expires_in_seconds": int(robotBindingCodeTTL.Seconds()), + }) +} + +func (h *RobotHandler) ListMyRobotBindings(c *gin.Context) { + session, ok := security.CurrentSession(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权访问"}) + return + } + bindings, err := h.db.ListRobotUserBindings(session.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取机器人绑定失败"}) + return + } + items := make([]gin.H, 0, len(bindings)) + for _, binding := range bindings { + sum := sha256.Sum256([]byte(binding.ExternalUserID)) + items = append(items, gin.H{ + "id": binding.ID, "platform": binding.Platform, "external_user_hint": fmt.Sprintf("%x", sum[:4]), + "enabled": binding.Enabled, "created_at": binding.CreatedAt, "updated_at": binding.UpdatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"bindings": items}) +} + +func (h *RobotHandler) DeleteMyRobotBinding(c *gin.Context) { + session, ok := security.CurrentSession(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权访问"}) + return + } + if err := h.db.DeleteRobotUserBindingForUser(c.Param("id"), session.UserID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "绑定不存在"}) + return + } + if h.audit != nil { + h.audit.Record(c, audit.Entry{Category: "auth", Action: "robot_binding_revoke", Result: "success", ResourceType: "robot_binding", ResourceID: c.Param("id"), Message: "撤销机器人平台账号绑定"}) + } + c.Status(http.StatusNoContent) +} + // RobotTestRequest 模拟机器人消息请求 type RobotTestRequest struct { Platform string `json:"platform"` // 如 "dingtalk"、"lark"、"wecom" diff --git a/internal/handler/robot_rbac_test.go b/internal/handler/robot_rbac_test.go index 88e88e54..d1a93bb8 100644 --- a/internal/handler/robot_rbac_test.go +++ b/internal/handler/robot_rbac_test.go @@ -9,6 +9,7 @@ import ( "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" "go.uber.org/zap" ) @@ -22,8 +23,35 @@ func TestRobotUsersAreResourceIsolated(t *testing.T) { cfg := &config.Config{} cfg.Project.Enabled = true h := NewRobotHandler(cfg, db, nil, zap.NewNop()) + if err := db.BootstrapRBAC("hash", security.PermissionCatalog); err != nil { + t.Fatal(err) + } + alice, err := db.CreateRBACUser("robot-alice", "Robot Alice", "hash", true, []string{database.RBACSystemRoleOperator}) + if err != nil { + t.Fatal(err) + } + bob, err := db.CreateRBACUser("robot-bob", "Robot Bob", "hash", true, []string{database.RBACSystemRoleOperator}) + if err != nil { + t.Fatal(err) + } + for externalID, user := range map[string]*database.RBACUser{"alice": alice, "bob": bob} { + code := "TEST-" + strings.ToUpper(externalID) + if err := db.CreateRobotBindingCode(user.ID, hashRobotBindingCode(code), time.Now().Add(time.Minute)); err != nil { + t.Fatal(err) + } + if _, err := db.ConsumeRobotBindingCode("wecom", externalID, hashRobotBindingCode(code)); err != nil { + t.Fatal(err) + } + } + aliceAccess, err := db.ResolveRobotRBACAccess("wecom", "alice") + if err != nil { + t.Fatal(err) + } + if got := h.HandleMessage("wecom", "alice", "身份"); !strings.Contains(got, "Robot Alice") || !strings.Contains(got, alice.ID) || !strings.Contains(got, "user_binding") { + t.Fatalf("bound identity output is incomplete: %s", got) + } - conversationID, _ := h.getOrCreateConversation("wecom", "alice", "alice conversation") + conversationID, _ := h.getOrCreateConversation("wecom", "alice", "alice conversation", aliceAccess) if conversationID == "" { t.Fatal("alice conversation was not created") } @@ -62,3 +90,49 @@ func TestWecomReplayGuardRequiresFreshUniqueRequest(t *testing.T) { t.Fatal("stale request was accepted") } } + +func TestRobotServiceAccountRequiresExactSenderAllowlist(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "robot-service.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.BootstrapRBAC("hash", security.PermissionCatalog); err != nil { + t.Fatal(err) + } + serviceUser, err := db.CreateRBACUser("robot-service-user", "Robot Service", "hash", true, []string{database.RBACSystemRoleOperator}) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Robots.Lark.Auth = config.RobotAuthorizationConfig{ + Mode: config.RobotAuthModeServiceAccount, ServiceUserID: serviceUser.ID, + AllowedExternalUsers: []string{"t:tenant|u:allowed"}, + } + h := NewRobotHandler(cfg, db, nil, zap.NewNop()) + if got := h.HandleMessage("lark", "t:tenant|u:allowed", "列表"); strings.Contains(got, "白名单") || strings.Contains(got, "尚未绑定") { + t.Fatalf("allowed service account sender was denied: %s", got) + } + if got := h.HandleMessage("lark", "t:tenant|u:denied", "列表"); !strings.Contains(got, "白名单") { + t.Fatalf("non-allowlisted sender was not denied: %s", got) + } + if got := h.HandleMessage("lark", "t:tenant|u:allowed", "绑定 ABCD-1234"); !strings.Contains(got, "服务账号模式") { + t.Fatalf("service-account robot accepted user binding: %s", got) + } + if got := h.HandleMessage("lark", "t:tenant|u:allowed", "whoami"); !strings.Contains(got, "Robot Service") || !strings.Contains(got, serviceUser.ID) || !strings.Contains(got, "service_account") { + t.Fatalf("service-account identity output is incomplete: %s", got) + } + if got := h.HandleMessage("lark", "t:tenant|u:denied", "whoami"); !strings.Contains(got, "鉴权状态:拒绝") || strings.Contains(got, serviceUser.ID) { + t.Fatalf("denied identity output leaked or omitted status: %s", got) + } + + adminCfg := &config.Config{} + adminCfg.Robots.Lark.Auth = config.RobotAuthorizationConfig{ + Mode: config.RobotAuthModeServiceAccount, ServiceUserID: "admin", + AllowedExternalUsers: []string{"t:tenant|u:owner"}, + } + adminHandler := NewRobotHandler(adminCfg, db, nil, zap.NewNop()) + if got := adminHandler.HandleMessage("lark", "t:tenant|u:owner", "身份"); !strings.Contains(got, "admin") || !strings.Contains(got, "鉴权状态:已授权") { + t.Fatalf("allowlisted admin service account was denied: %s", got) + } +}