From 49d21758724a5c439fd4a1fe4502cbf5dd4c55bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:49:42 +0800 Subject: [PATCH] Add files via upload --- internal/database/audit.go | 35 ++--- internal/database/c2_payload.go | 30 +++++ internal/database/chat_upload.go | 56 ++++++++ internal/database/database.go | 3 + internal/database/group.go | 83 ++++++++---- internal/database/monitor.go | 133 ++++++++++++++++++- internal/database/rbac.go | 136 +++++++++++++++++-- internal/database/rbac_access_test.go | 180 ++++++++++++++++++++++++++ 8 files changed, 601 insertions(+), 55 deletions(-) create mode 100644 internal/database/c2_payload.go create mode 100644 internal/database/chat_upload.go diff --git a/internal/database/audit.go b/internal/database/audit.go index 57d0347f..6f179209 100644 --- a/internal/database/audit.go +++ b/internal/database/audit.go @@ -9,25 +9,26 @@ import ( // AuditLog platform operation audit record. type AuditLog struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - Level string `json:"level"` - Category string `json:"category"` - Action string `json:"action"` - Result string `json:"result"` - Actor string `json:"actor"` - SessionHint string `json:"sessionHint,omitempty"` - ClientIP string `json:"clientIp,omitempty"` - UserAgent string `json:"userAgent,omitempty"` - ResourceType string `json:"resourceType,omitempty"` - ResourceID string `json:"resourceId,omitempty"` - ResourceAvailable *bool `json:"resourceAvailable,omitempty"` // API-only: whether linked resource still exists - Message string `json:"message"` - Detail map[string]interface{} `json:"detail,omitempty"` + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Level string `json:"level"` + Category string `json:"category"` + Action string `json:"action"` + Result string `json:"result"` + Actor string `json:"actor"` + SessionHint string `json:"sessionHint,omitempty"` + ClientIP string `json:"clientIp,omitempty"` + UserAgent string `json:"userAgent,omitempty"` + ResourceType string `json:"resourceType,omitempty"` + ResourceID string `json:"resourceId,omitempty"` + ResourceAvailable *bool `json:"resourceAvailable,omitempty"` // API-only: whether linked resource still exists + Message string `json:"message"` + Detail map[string]interface{} `json:"detail,omitempty"` } // ListAuditLogsFilter query parameters. type ListAuditLogsFilter struct { + Actor string Level string Category string Action string @@ -44,6 +45,10 @@ type ListAuditLogsFilter struct { func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) { conditions := []string{"1=1"} args := []interface{}{} + if filter.Actor != "" { + conditions = append(conditions, "actor = ?") + args = append(args, filter.Actor) + } if filter.Level != "" { conditions = append(conditions, "level = ?") args = append(args, filter.Level) diff --git a/internal/database/c2_payload.go b/internal/database/c2_payload.go new file mode 100644 index 00000000..0755b640 --- /dev/null +++ b/internal/database/c2_payload.go @@ -0,0 +1,30 @@ +package database + +import ( + "strings" + "time" +) + +func (db *DB) RecordC2PayloadArtifact(filename, payloadID, listenerID, ownerUserID string) error { + filename = strings.TrimSpace(filename) + if filename == "" || strings.TrimSpace(listenerID) == "" || strings.TrimSpace(ownerUserID) == "" { + return nil + } + _, err := db.Exec(` + INSERT INTO c2_payload_artifacts(filename, payload_id, listener_id, owner_user_id, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(filename) DO UPDATE SET payload_id=excluded.payload_id, listener_id=excluded.listener_id, owner_user_id=excluded.owner_user_id, created_at=excluded.created_at + `, filename, payloadID, listenerID, ownerUserID, time.Now()) + return err +} + +func (db *DB) UserCanAccessC2Payload(userID, scope, filename string) bool { + if scope == RBACScopeAll { + return true + } + var listenerID, ownerUserID string + if err := db.QueryRow(`SELECT listener_id, owner_user_id FROM c2_payload_artifacts WHERE filename = ?`, strings.TrimSpace(filename)).Scan(&listenerID, &ownerUserID); err != nil { + return false + } + return ownerUserID == strings.TrimSpace(userID) || db.UserCanAccessResource(userID, scope, "c2_listener", listenerID) +} diff --git a/internal/database/chat_upload.go b/internal/database/chat_upload.go new file mode 100644 index 00000000..5c27004c --- /dev/null +++ b/internal/database/chat_upload.go @@ -0,0 +1,56 @@ +package database + +import ( + "strings" + "time" +) + +func (db *DB) UpsertChatUploadArtifact(relativePath, conversationID, ownerUserID string) error { + relativePath = strings.TrimSpace(relativePath) + conversationID = strings.TrimSpace(conversationID) + ownerUserID = strings.TrimSpace(ownerUserID) + if relativePath == "" || conversationID == "" || ownerUserID == "" { + return nil + } + _, err := db.Exec(` + INSERT INTO chat_upload_artifacts(relative_path, conversation_id, owner_user_id, created_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(relative_path) DO UPDATE SET conversation_id=excluded.conversation_id, owner_user_id=excluded.owner_user_id + `, relativePath, conversationID, ownerUserID, time.Now()) + return err +} + +func (db *DB) GetChatUploadArtifact(relativePath string) (conversationID, ownerUserID string, ok bool) { + err := db.QueryRow(`SELECT conversation_id, owner_user_id FROM chat_upload_artifacts WHERE relative_path = ?`, strings.TrimSpace(relativePath)).Scan(&conversationID, &ownerUserID) + return conversationID, ownerUserID, err == nil +} + +func (db *DB) DeleteChatUploadArtifactPath(relativePath string) error { + path := strings.Trim(strings.TrimSpace(relativePath), "/") + if path == "" { + return nil + } + _, err := db.Exec(`DELETE FROM chat_upload_artifacts WHERE relative_path = ? OR relative_path LIKE ? ESCAPE '\'`, path, escapeLikePrefix(path)+"/%") + return err +} + +func (db *DB) RenameChatUploadArtifactPath(oldPath, newPath string) error { + oldPath = strings.Trim(strings.TrimSpace(oldPath), "/") + newPath = strings.Trim(strings.TrimSpace(newPath), "/") + if oldPath == "" || newPath == "" { + return nil + } + _, err := db.Exec(` + UPDATE chat_upload_artifacts + SET relative_path = CASE + WHEN relative_path = ? THEN ? + ELSE ? || substr(relative_path, length(?) + 1) + END + WHERE relative_path = ? OR relative_path LIKE ? ESCAPE '\' + `, oldPath, newPath, newPath, oldPath, oldPath, escapeLikePrefix(oldPath)+"/%") + return err +} + +func escapeLikePrefix(value string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value) +} diff --git a/internal/database/database.go b/internal/database/database.go index e41f23bb..5bca556b 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -225,6 +225,8 @@ func (db *DB) initTables() error { start_time DATETIME NOT NULL, end_time DATETIME, duration_ms INTEGER, + owner_user_id TEXT, + conversation_id TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );` @@ -300,6 +302,7 @@ func (db *DB) initTables() error { id TEXT PRIMARY KEY, name TEXT NOT NULL, icon TEXT, + owner_user_id TEXT, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL );` diff --git a/internal/database/group.go b/internal/database/group.go index a3d32106..0739ded4 100644 --- a/internal/database/group.go +++ b/internal/database/group.go @@ -10,20 +10,28 @@ import ( // ConversationGroup 对话分组 type ConversationGroup struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - Pinned bool `json:"pinned"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Pinned bool `json:"pinned"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + OwnerUserID string `json:"-"` } // GroupExistsByName 检查分组名称是否已存在 func (db *DB) GroupExistsByName(name string, excludeID string) (bool, error) { + return db.groupExistsByNameForOwner(name, excludeID, "") +} + +func (db *DB) groupExistsByNameForOwner(name, excludeID, ownerUserID string) (bool, error) { var count int var err error - - if excludeID != "" { + if ownerUserID != "" && excludeID != "" { + err = db.QueryRow("SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND owner_user_id = ? AND id != ?", name, ownerUserID, excludeID).Scan(&count) + } else if ownerUserID != "" { + err = db.QueryRow("SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND owner_user_id = ?", name, ownerUserID).Scan(&count) + } else if excludeID != "" { err = db.QueryRow( "SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND id != ?", name, excludeID, @@ -43,9 +51,13 @@ func (db *DB) GroupExistsByName(name string, excludeID string) (bool, error) { } // CreateGroup 创建分组 -func (db *DB) CreateGroup(name, icon string) (*ConversationGroup, error) { +func (db *DB) CreateGroup(name, icon string, owners ...string) (*ConversationGroup, error) { + ownerUserID := "" + if len(owners) > 0 { + ownerUserID = owners[0] + } // 检查名称是否已存在 - exists, err := db.GroupExistsByName(name, "") + exists, err := db.groupExistsByNameForOwner(name, "", ownerUserID) if err != nil { return nil, err } @@ -61,27 +73,39 @@ func (db *DB) CreateGroup(name, icon string) (*ConversationGroup, error) { } _, err = db.Exec( - "INSERT INTO conversation_groups (id, name, icon, pinned, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - id, name, icon, 0, now, now, + "INSERT INTO conversation_groups (id, name, icon, pinned, owner_user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + id, name, icon, 0, ownerUserID, now, now, ) if err != nil { return nil, fmt.Errorf("创建分组失败: %w", err) } return &ConversationGroup{ - ID: id, - Name: name, - Icon: icon, - Pinned: false, - CreatedAt: now, - UpdatedAt: now, + ID: id, + Name: name, + Icon: icon, + Pinned: false, + CreatedAt: now, + UpdatedAt: now, + OwnerUserID: ownerUserID, }, nil } // ListGroups 列出所有分组 func (db *DB) ListGroups() ([]*ConversationGroup, error) { + return db.ListGroupsForAccess("", RBACScopeAll) +} + +func (db *DB) ListGroupsForAccess(userID, scope string) ([]*ConversationGroup, error) { + query := "SELECT id, name, icon, COALESCE(pinned, 0), COALESCE(owner_user_id, ''), created_at, updated_at FROM conversation_groups" + args := []interface{}{} + if scope != RBACScopeAll { + query += " WHERE owner_user_id = ?" + args = append(args, userID) + } + query += " ORDER BY COALESCE(pinned, 0) DESC, created_at ASC" rows, err := db.Query( - "SELECT id, name, icon, COALESCE(pinned, 0), created_at, updated_at FROM conversation_groups ORDER BY COALESCE(pinned, 0) DESC, created_at ASC", + query, args..., ) if err != nil { return nil, fmt.Errorf("查询分组列表失败: %w", err) @@ -94,7 +118,7 @@ func (db *DB) ListGroups() ([]*ConversationGroup, error) { var createdAt, updatedAt string var pinned int - if err := rows.Scan(&group.ID, &group.Name, &group.Icon, &pinned, &createdAt, &updatedAt); err != nil { + if err := rows.Scan(&group.ID, &group.Name, &group.Icon, &pinned, &group.OwnerUserID, &createdAt, &updatedAt); err != nil { return nil, fmt.Errorf("扫描分组失败: %w", err) } @@ -131,9 +155,9 @@ func (db *DB) GetGroup(id string) (*ConversationGroup, error) { var pinned int err := db.QueryRow( - "SELECT id, name, icon, COALESCE(pinned, 0), created_at, updated_at FROM conversation_groups WHERE id = ?", + "SELECT id, name, icon, COALESCE(pinned, 0), COALESCE(owner_user_id, ''), created_at, updated_at FROM conversation_groups WHERE id = ?", id, - ).Scan(&group.ID, &group.Name, &group.Icon, &pinned, &createdAt, &updatedAt) + ).Scan(&group.ID, &group.Name, &group.Icon, &pinned, &group.OwnerUserID, &createdAt, &updatedAt) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("分组不存在") @@ -164,10 +188,23 @@ func (db *DB) GetGroup(id string) (*ConversationGroup, error) { return &group, nil } +func (db *DB) UserCanAccessGroup(userID, scope, groupID string) bool { + if scope == RBACScopeAll { + return true + } + var count int + err := db.QueryRow(`SELECT COUNT(*) FROM conversation_groups WHERE id = ? AND owner_user_id = ?`, groupID, userID).Scan(&count) + return err == nil && count > 0 +} + // UpdateGroup 更新分组 func (db *DB) UpdateGroup(id, name, icon string) error { + existing, err := db.GetGroup(id) + if err != nil { + return err + } // 检查名称是否已存在(排除当前分组) - exists, err := db.GroupExistsByName(name, id) + exists, err := db.groupExistsByNameForOwner(name, id, existing.OwnerUserID) if err != nil { return err } diff --git a/internal/database/monitor.go b/internal/database/monitor.go index e3bbf087..77f3006c 100644 --- a/internal/database/monitor.go +++ b/internal/database/monitor.go @@ -46,8 +46,8 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error { query := ` INSERT OR REPLACE INTO tool_executions - (id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, owner_user_id, conversation_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err = db.Exec(query, @@ -60,6 +60,8 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error { exec.StartTime, endTime, durationMs, + strings.TrimSpace(exec.OwnerUserID), + strings.TrimSpace(exec.ConversationID), time.Now(), ) @@ -90,6 +92,10 @@ func (db *DB) UpdateToolExecutionResult(id string, result *mcp.ToolResult) error // CountToolExecutions 统计工具执行记录总数 func (db *DB) CountToolExecutions(status, toolName string) (int, error) { + return db.CountToolExecutionsForAccess(status, toolName, RBACListAccess{Scope: RBACScopeAll}) +} + +func (db *DB) CountToolExecutionsForAccess(status, toolName string, access RBACListAccess) (int, error) { query := `SELECT COUNT(*) FROM tool_executions` args := []interface{}{} conditions := []string{} @@ -108,6 +114,7 @@ func (db *DB) CountToolExecutions(status, toolName string) (int, error) { query += ` AND ` + conditions[i] } } + query, args = appendToolExecutionAccessSQL(query, args, access, len(conditions) > 0) var count int err := db.QueryRow(query, args...).Scan(&count) if err != nil { @@ -135,7 +142,7 @@ func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status, toolNa } query := ` - SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms + SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, COALESCE(owner_user_id, ''), COALESCE(conversation_id, '') FROM tool_executions ` args := []interface{}{} @@ -183,6 +190,8 @@ func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status, toolNa &exec.StartTime, &endTime, &durationMs, + &exec.OwnerUserID, + &exec.ConversationID, ) if err != nil { db.logger.Warn("加载执行记录失败", zap.Error(err)) @@ -335,8 +344,62 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) { return result, nil } +func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*ToolStatsSummaryResult, error) { + if access.Scope == RBACScopeAll { + return db.LoadToolStatsSummary(topN) + } + if topN <= 0 { + topN = 6 + } + if topN > 100 { + topN = 100 + } + result := &ToolStatsSummaryResult{TopTools: make([]*mcp.ToolStats, 0, topN)} + fromSQL, args := appendToolExecutionAccessSQL(` FROM tool_executions`, nil, access, false) + var lastCall sql.NullString + err := db.QueryRow(`SELECT COUNT(*), + COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END), 0), + MAX(start_time), COUNT(DISTINCT tool_name)`+fromSQL, args...).Scan( + &result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls, + &lastCall, &result.Summary.ToolCount, + ) + if err != nil { + return nil, err + } + if lastCall.Valid { + parsed := parseDBTime(lastCall.String) + result.Summary.LastCallTime = &parsed + } + rows, err := db.Query(`SELECT tool_name, COUNT(*), + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), + SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END), MAX(start_time)`+ + fromSQL+` GROUP BY tool_name ORDER BY COUNT(*) DESC, tool_name ASC LIMIT ?`, append(args, topN)...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var stat mcp.ToolStats + var last sql.NullString + if err := rows.Scan(&stat.ToolName, &stat.TotalCalls, &stat.SuccessCalls, &stat.FailedCalls, &last); err != nil { + return nil, err + } + if last.Valid { + parsed := parseDBTime(last.String) + stat.LastCallTime = &parsed + } + result.TopTools = append(result.TopTools, &stat) + } + return result, rows.Err() +} + // LoadToolExecutionListPage 分页加载执行记录列表(不含 arguments/result,供监控列表使用) func (db *DB) LoadToolExecutionListPage(offset, limit int, status, toolName string) ([]*mcp.ToolExecution, error) { + return db.LoadToolExecutionListPageForAccess(offset, limit, status, toolName, RBACListAccess{Scope: RBACScopeAll}) +} + +func (db *DB) LoadToolExecutionListPageForAccess(offset, limit int, status, toolName string, access RBACListAccess) ([]*mcp.ToolExecution, error) { if limit <= 0 { limit = 20 } @@ -345,11 +408,13 @@ func (db *DB) LoadToolExecutionListPage(offset, limit int, status, toolName stri } query := ` - SELECT id, tool_name, status, start_time, end_time, duration_ms + SELECT id, tool_name, status, start_time, end_time, duration_ms, COALESCE(owner_user_id, ''), COALESCE(conversation_id, '') FROM tool_executions ` whereSQL, args := toolExecutionsFilterSQL(status, toolName) - query += whereSQL + ` ORDER BY start_time DESC LIMIT ? OFFSET ?` + query += whereSQL + query, args = appendToolExecutionAccessSQL(query, args, access, whereSQL != "") + query += ` ORDER BY start_time DESC LIMIT ? OFFSET ?` args = append(args, limit, offset) rows, err := db.Query(query, args...) @@ -371,6 +436,8 @@ func (db *DB) LoadToolExecutionListPage(offset, limit int, status, toolName stri &exec.StartTime, &endTime, &durationMs, + &exec.OwnerUserID, + &exec.ConversationID, ); err != nil { db.logger.Warn("加载执行记录列表失败", zap.Error(err)) continue @@ -387,10 +454,35 @@ func (db *DB) LoadToolExecutionListPage(offset, limit int, status, toolName stri return executions, nil } +func appendToolExecutionAccessSQL(query string, args []interface{}, access RBACListAccess, hasWhere bool) (string, []interface{}) { + if access.Scope == RBACScopeAll { + return query, args + } + userID := strings.TrimSpace(access.UserID) + joiner := " WHERE " + if hasWhere { + joiner = " AND " + } + if userID == "" { + return query + joiner + "1=0", args + } + query += joiner + `( + owner_user_id = ? + OR (conversation_id IS NOT NULL AND conversation_id <> '' AND ( + EXISTS (SELECT 1 FROM conversations c WHERE c.id = tool_executions.conversation_id AND c.owner_user_id = ?) + OR EXISTS (SELECT 1 FROM rbac_resource_assignments ra WHERE ra.user_id = ? AND ra.resource_type = 'conversation' AND ra.resource_id = tool_executions.conversation_id) + OR EXISTS (SELECT 1 FROM conversations c JOIN projects p ON p.id = c.project_id WHERE c.id = tool_executions.conversation_id AND p.owner_user_id = ?) + OR EXISTS (SELECT 1 FROM conversations c JOIN rbac_resource_assignments pra ON pra.resource_id = c.project_id WHERE c.id = tool_executions.conversation_id AND pra.user_id = ? AND pra.resource_type = 'project') + )) + )` + args = append(args, userID, userID, userID, userID, userID) + return query, args +} + // GetToolExecution 根据ID获取单条工具执行记录 func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) { query := ` - SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms + SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, COALESCE(owner_user_id, ''), COALESCE(conversation_id, '') FROM tool_executions WHERE id = ? ` @@ -414,6 +506,8 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) { &exec.StartTime, &endTime, &durationMs, + &exec.OwnerUserID, + &exec.ConversationID, ) if err != nil { return nil, err @@ -448,6 +542,29 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) { return &exec, nil } +// UserCanAccessToolExecution enforces ownership for monitor detail and mutation +// endpoints. Legacy records without an owner or conversation fail closed for +// non-global users. +func (db *DB) UserCanAccessToolExecution(userID, scope, executionID string) bool { + userID = strings.TrimSpace(userID) + executionID = strings.TrimSpace(executionID) + if userID == "" || executionID == "" { + return false + } + if scope == RBACScopeAll { + return true + } + var ownerUserID, conversationID sql.NullString + if err := db.QueryRow(`SELECT owner_user_id, conversation_id FROM tool_executions WHERE id = ?`, executionID).Scan(&ownerUserID, &conversationID); err != nil { + return false + } + if strings.TrimSpace(ownerUserID.String) == userID { + return true + } + conversation := strings.TrimSpace(conversationID.String) + return conversation != "" && db.UserCanAccessResource(userID, scope, "conversation", conversation) +} + // CancelOrphanedRunningToolExecutions 将仍为 running 的记录批量标记为 cancelled(如进程重启后无对应执行协程)。 func (db *DB) CancelOrphanedRunningToolExecutions(endTime time.Time, errMsg string) (int64, error) { errMsg = strings.TrimSpace(errMsg) @@ -584,7 +701,7 @@ func (db *DB) GetToolExecutionsByIds(ids []string) ([]*mcp.ToolExecution, error) } query := ` - SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms + SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, COALESCE(owner_user_id, ''), COALESCE(conversation_id, '') FROM tool_executions WHERE id IN (` + strings.Join(placeholders, ",") + `) ` @@ -614,6 +731,8 @@ func (db *DB) GetToolExecutionsByIds(ids []string) ([]*mcp.ToolExecution, error) &exec.StartTime, &endTime, &durationMs, + &exec.OwnerUserID, + &exec.ConversationID, ) if err != nil { db.logger.Warn("加载执行记录失败", zap.Error(err)) diff --git a/internal/database/rbac.go b/internal/database/rbac.go index a5c27486..de2ed328 100644 --- a/internal/database/rbac.go +++ b/internal/database/rbac.go @@ -76,10 +76,14 @@ type RBACResourceOption struct { // RBACAccess is the resolved authorization profile for one user. type RBACAccess struct { - User RBACUser `json:"user"` - Roles []RBACRole `json:"roles"` - Permissions map[string]bool `json:"permissions"` - Scope string `json:"scope"` + User RBACUser `json:"user"` + Roles []RBACRole `json:"roles"` + Permissions map[string]bool `json:"permissions"` + PermissionScopes map[string]string `json:"permissionScopes,omitempty"` + // Scope is retained as the broadest effective scope for UI compatibility. + // Authorization decisions must use PermissionScopes so a global read role + // cannot widen an unrelated write permission from another role. + Scope string `json:"scope"` } func (db *DB) initRBACTables() error { @@ -133,10 +137,27 @@ func (db *DB) initRBACTables() error { FOREIGN KEY (user_id) REFERENCES rbac_users(id) ON DELETE CASCADE, UNIQUE(user_id, resource_type, resource_id) );`, + `CREATE TABLE IF NOT EXISTS chat_upload_artifacts ( + relative_path TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + created_at DATETIME NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE + );`, + `CREATE TABLE IF NOT EXISTS c2_payload_artifacts ( + filename TEXT PRIMARY KEY, + payload_id TEXT NOT NULL, + listener_id TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + created_at DATETIME NOT NULL + );`, `CREATE INDEX IF NOT EXISTS idx_rbac_user_roles_user ON rbac_user_roles(user_id);`, `CREATE INDEX IF NOT EXISTS idx_rbac_role_permissions_role ON rbac_role_permissions(role_id);`, `CREATE INDEX IF NOT EXISTS idx_rbac_assignments_user_resource ON rbac_resource_assignments(user_id, resource_type, resource_id);`, `CREATE INDEX IF NOT EXISTS idx_rbac_assignments_resource ON rbac_resource_assignments(resource_type, resource_id);`, + `CREATE INDEX IF NOT EXISTS idx_chat_upload_artifacts_conversation ON chat_upload_artifacts(conversation_id);`, + `CREATE INDEX IF NOT EXISTS idx_chat_upload_artifacts_owner ON chat_upload_artifacts(owner_user_id);`, + `CREATE INDEX IF NOT EXISTS idx_c2_payload_artifacts_listener ON c2_payload_artifacts(listener_id);`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { @@ -158,11 +179,16 @@ func (db *DB) migrateRBACOwnershipColumns() error { {"webshell_connections", "owner_user_id", "ALTER TABLE webshell_connections ADD COLUMN owner_user_id TEXT"}, {"batch_task_queues", "owner_user_id", "ALTER TABLE batch_task_queues ADD COLUMN owner_user_id TEXT"}, {"c2_listeners", "owner_user_id", "ALTER TABLE c2_listeners ADD COLUMN owner_user_id TEXT"}, + {"conversation_groups", "owner_user_id", "ALTER TABLE conversation_groups ADD COLUMN owner_user_id TEXT"}, + {"tool_executions", "owner_user_id", "ALTER TABLE tool_executions ADD COLUMN owner_user_id TEXT"}, + {"tool_executions", "conversation_id", "ALTER TABLE tool_executions ADD COLUMN conversation_id TEXT"}, } { if err := db.addColumnIfMissing(col.table, col.name, col.stmt); err != nil { return err } } + _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_tool_executions_owner ON tool_executions(owner_user_id)`) + _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_tool_executions_conversation ON tool_executions(conversation_id)`) return nil } @@ -204,6 +230,35 @@ func (db *DB) BootstrapRBAC(adminPasswordHash string, permissions map[string]str return err } } + // Remove stale/unknown keys so a permission invented by an older build or + // manual database edit cannot become active automatically if a future route + // happens to reuse the same name. + permissionRows, err := tx.Query(`SELECT key FROM rbac_permissions`) + if err != nil { + return err + } + var stalePermissionKeys []string + for permissionRows.Next() { + var key string + if err := permissionRows.Scan(&key); err != nil { + _ = permissionRows.Close() + return err + } + if _, known := permissions[key]; !known { + stalePermissionKeys = append(stalePermissionKeys, key) + } + } + if err := permissionRows.Close(); err != nil { + return err + } + for _, key := range stalePermissionKeys { + if _, err := tx.Exec(`DELETE FROM rbac_role_permissions WHERE permission_key = ?`, key); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM rbac_permissions WHERE key = ?`, key); err != nil { + return err + } + } systemRoles := []RBACRole{ {ID: RBACSystemRoleAdmin, Name: "管理员", Description: "全局管理权限", Scope: RBACScopeAll, IsSystem: true}, @@ -250,19 +305,43 @@ func (db *DB) BootstrapRBAC(adminPasswordHash string, permissions map[string]str func grantSystemRolePermissions(tx *sql.Tx, permissions map[string]string) error { now := time.Now() + // System roles are immutable and owned by the application. Rebuild their + // grants deterministically so policy tightening also removes permissions + // seeded by older versions instead of leaving stale INSERT OR IGNORE rows. + if _, err := tx.Exec(`DELETE FROM rbac_role_permissions WHERE role_id IN (?, ?, ?, ?)`, RBACSystemRoleAdmin, RBACSystemRoleOperator, RBACSystemRoleAuditor, RBACSystemRoleViewer); err != nil { + return err + } for key := range permissions { if _, err := tx.Exec(`INSERT OR IGNORE INTO rbac_role_permissions (role_id, permission_key, created_at) VALUES (?, ?, ?)`, RBACSystemRoleAdmin, key, now); err != nil { return err } switch { - case strings.HasSuffix(key, ":read"), key == "auth:self": + case key == "auth:self": for _, roleID := range []string{RBACSystemRoleOperator, RBACSystemRoleAuditor, RBACSystemRoleViewer} { if _, err := tx.Exec(`INSERT OR IGNORE INTO rbac_role_permissions (role_id, permission_key, created_at) VALUES (?, ?, ?)`, roleID, key, now); err != nil { return err } } + case key == "audit:read": + if _, err := tx.Exec(`INSERT OR IGNORE INTO rbac_role_permissions (role_id, permission_key, created_at) VALUES (?, ?, ?)`, RBACSystemRoleAuditor, key, now); err != nil { + return err + } case strings.HasPrefix(key, "rbac:"), strings.HasPrefix(key, "config:"), strings.HasPrefix(key, "terminal:"), strings.HasPrefix(key, "audit:"): continue + case key == "mcp:write" || key == "mcp:external:execute": + continue + case key == "roles:write" || key == "roles:delete" || + key == "skills:write" || key == "skills:delete" || + key == "agents:write" || key == "agents:delete" || + key == "knowledge:write" || key == "knowledge:delete" || + key == "workflow:write" || key == "workflow:delete" || key == "robot:write": + continue + case strings.HasSuffix(key, ":read"): + for _, roleID := range []string{RBACSystemRoleOperator, RBACSystemRoleAuditor, RBACSystemRoleViewer} { + if _, err := tx.Exec(`INSERT OR IGNORE INTO rbac_role_permissions (role_id, permission_key, created_at) VALUES (?, ?, ?)`, roleID, key, now); err != nil { + return err + } + } default: if _, err := tx.Exec(`INSERT OR IGNORE INTO rbac_role_permissions (role_id, permission_key, created_at) VALUES (?, ?, ?)`, RBACSystemRoleOperator, key, now); err != nil { return err @@ -325,7 +404,9 @@ func (db *DB) ResolveRBACAccess(userID string) (*RBACAccess, error) { } defer rows.Close() - access := &RBACAccess{User: *u, Permissions: map[string]bool{}, Scope: RBACScopeOwn} + access := &RBACAccess{ + User: *u, Permissions: map[string]bool{}, PermissionScopes: map[string]string{}, Scope: RBACScopeOwn, + } for rows.Next() { var role RBACRole var isSystem int @@ -344,9 +425,10 @@ func (db *DB) ResolveRBACAccess(userID string) (*RBACAccess, error) { } prows, err := db.Query(` - SELECT DISTINCT rp.permission_key + SELECT rp.permission_key, r.scope FROM rbac_role_permissions rp JOIN rbac_user_roles ur ON ur.role_id = rp.role_id + JOIN rbac_roles r ON r.id = rp.role_id WHERE ur.user_id = ? `, userID) if err != nil { @@ -354,11 +436,16 @@ func (db *DB) ResolveRBACAccess(userID string) (*RBACAccess, error) { } defer prows.Close() for prows.Next() { - var key string - if err := prows.Scan(&key); err != nil { + var key, scope string + if err := prows.Scan(&key, &scope); err != nil { return nil, err } access.Permissions[key] = true + if existing, ok := access.PermissionScopes[key]; ok { + access.PermissionScopes[key] = mergeRBACScope(existing, scope) + } else { + access.PermissionScopes[key] = scope + } } return access, prows.Err() } @@ -531,6 +618,31 @@ func (db *DB) SetResourceOwner(resourceType, resourceID, userID string) error { return err } +func (db *DB) GetResourceOwner(resourceType, resourceID string) string { + table := "" + switch strings.TrimSpace(resourceType) { + case "project": + table = "projects" + case "conversation": + table = "conversations" + case "vulnerability": + table = "vulnerabilities" + case "webshell": + table = "webshell_connections" + case "batch_task": + table = "batch_task_queues" + case "c2_listener": + table = "c2_listeners" + default: + return "" + } + var owner sql.NullString + if err := db.QueryRow(`SELECT owner_user_id FROM `+table+` WHERE id = ?`, strings.TrimSpace(resourceID)).Scan(&owner); err != nil { + return "" + } + return strings.TrimSpace(owner.String) +} + func (db *DB) AssignResourceToUser(userID, resourceType, resourceID string) error { _, err := db.AssignResourcesToUser(userID, resourceType, []string{resourceID}) return err @@ -915,9 +1027,13 @@ func (db *DB) UpsertRBACRole(id, name, description, scope string, permissionKeys if key == "" { continue } - if _, err := tx.Exec(`INSERT OR IGNORE INTO rbac_permissions (key, description, created_at) VALUES (?, '', ?)`, key, now); err != nil { + var permissionExists int + if err := tx.QueryRow(`SELECT COUNT(*) FROM rbac_permissions WHERE key = ?`, key).Scan(&permissionExists); err != nil { return nil, err } + if permissionExists == 0 { + return nil, fmt.Errorf("unknown permission: %s", key) + } if _, err := tx.Exec(`INSERT OR IGNORE INTO rbac_role_permissions (role_id, permission_key, created_at) VALUES (?, ?, ?)`, id, key, now); err != nil { return nil, err } diff --git a/internal/database/rbac_access_test.go b/internal/database/rbac_access_test.go index 8e93067e..5499b53b 100644 --- a/internal/database/rbac_access_test.go +++ b/internal/database/rbac_access_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "cyberstrike-ai/internal/mcp" + "go.uber.org/zap" ) @@ -19,6 +21,184 @@ func newRBACTestDB(t *testing.T) *DB { return db } +func TestRBACToolExecutionOwnershipAccess(t *testing.T) { + db := newRBACTestDB(t) + for _, exec := range []*mcp.ToolExecution{ + {ID: "exec-u1", ToolName: "one", Status: "completed", StartTime: time.Now(), OwnerUserID: "u1"}, + {ID: "exec-u2", ToolName: "two", Status: "completed", StartTime: time.Now(), OwnerUserID: "u2"}, + {ID: "exec-legacy", ToolName: "legacy", Status: "completed", StartTime: time.Now()}, + } { + if err := db.SaveToolExecution(exec); err != nil { + t.Fatal(err) + } + } + access := RBACListAccess{UserID: "u1", Scope: RBACScopeAssigned} + rows, err := db.LoadToolExecutionListPageForAccess(0, 20, "", "", access) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].ID != "exec-u1" { + t.Fatalf("rows = %#v, want only exec-u1", rows) + } + summary, err := db.LoadToolStatsSummaryForAccess(10, access) + if err != nil { + t.Fatal(err) + } + if summary.Summary.TotalCalls != 1 || summary.Summary.ToolCount != 1 || len(summary.TopTools) != 1 || summary.TopTools[0].ToolName != "one" { + t.Fatalf("scoped summary = %#v", summary) + } + if !db.UserCanAccessToolExecution("u1", RBACScopeAssigned, "exec-u1") { + t.Fatal("owner could not access execution") + } + if db.UserCanAccessToolExecution("u1", RBACScopeAssigned, "exec-u2") { + t.Fatal("foreign execution was accessible") + } + if db.UserCanAccessToolExecution("u1", RBACScopeAssigned, "exec-legacy") { + t.Fatal("ownerless legacy execution did not fail closed") + } +} + +func TestRBACGroupAndUploadOwnership(t *testing.T) { + db := newRBACTestDB(t) + group1, err := db.CreateGroup("u1 group", "", "u1") + if err != nil { + t.Fatal(err) + } + group2, err := db.CreateGroup("u2 group", "", "u2") + if err != nil { + t.Fatal(err) + } + groups, err := db.ListGroupsForAccess("u1", RBACScopeAssigned) + if err != nil { + t.Fatal(err) + } + if len(groups) != 1 || groups[0].ID != group1.ID { + t.Fatalf("groups = %#v, want only %s (not %s)", groups, group1.ID, group2.ID) + } + if db.UserCanAccessGroup("u1", RBACScopeAssigned, group2.ID) { + t.Fatal("foreign group was accessible") + } + + conversation, err := db.CreateConversation("upload", ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + if err := db.UpsertChatUploadArtifact("2026-07-10/"+conversation.ID+"/a.txt", conversation.ID, "u1"); err != nil { + t.Fatal(err) + } + if conv, owner, ok := db.GetChatUploadArtifact("2026-07-10/" + conversation.ID + "/a.txt"); !ok || conv != conversation.ID || owner != "u1" { + t.Fatalf("artifact = conv=%q owner=%q ok=%v", conv, owner, ok) + } + if err := db.RenameChatUploadArtifactPath("2026-07-10/"+conversation.ID+"/a.txt", "2026-07-10/"+conversation.ID+"/b.txt"); err != nil { + t.Fatal(err) + } + if _, _, ok := db.GetChatUploadArtifact("2026-07-10/" + conversation.ID + "/b.txt"); !ok { + t.Fatal("renamed artifact metadata missing") + } +} + +func TestSystemRoleBootstrapDoesNotLeakManagementReadPermissions(t *testing.T) { + db := newRBACTestDB(t) + catalog := map[string]string{ + "auth:self": "self", "project:read": "projects", "project:write": "project writes", + "agent:local-execute": "local tools", + "rbac:read": "rbac", "config:read": "config", "audit:read": "audit", "terminal:execute": "terminal", + "mcp:execute": "invoke", "mcp:write": "manage", "mcp:external:execute": "external invoke", + "workflow:execute": "run", "workflow:write": "manage definitions", "knowledge:write": "manage knowledge", + } + if err := db.BootstrapRBAC("hash", catalog); err != nil { + t.Fatal(err) + } + viewer, err := db.CreateRBACUser("viewer-policy", "Viewer", "hash", true, []string{RBACSystemRoleViewer}) + if err != nil { + t.Fatal(err) + } + viewerAccess, err := db.ResolveRBACAccess(viewer.ID) + if err != nil { + t.Fatal(err) + } + if !viewerAccess.Permissions["project:read"] || viewerAccess.Permissions["rbac:read"] || viewerAccess.Permissions["config:read"] || viewerAccess.Permissions["audit:read"] { + t.Fatalf("unexpected viewer permissions: %#v", viewerAccess.Permissions) + } + auditor, err := db.CreateRBACUser("auditor-policy", "Auditor", "hash", true, []string{RBACSystemRoleAuditor}) + if err != nil { + t.Fatal(err) + } + auditorAccess, err := db.ResolveRBACAccess(auditor.ID) + if err != nil { + t.Fatal(err) + } + if !auditorAccess.Permissions["audit:read"] || auditorAccess.Permissions["config:read"] || auditorAccess.Permissions["rbac:read"] { + t.Fatalf("unexpected auditor permissions: %#v", auditorAccess.Permissions) + } + operator, err := db.CreateRBACUser("operator-policy", "Operator", "hash", true, []string{RBACSystemRoleOperator}) + if err != nil { + t.Fatal(err) + } + operatorAccess, err := db.ResolveRBACAccess(operator.ID) + if err != nil { + t.Fatal(err) + } + if !operatorAccess.Permissions["mcp:execute"] || operatorAccess.Permissions["mcp:write"] || operatorAccess.Permissions["mcp:external:execute"] { + t.Fatalf("unexpected operator MCP permissions: %#v", operatorAccess.Permissions) + } + if !operatorAccess.Permissions["workflow:execute"] || operatorAccess.Permissions["workflow:write"] || operatorAccess.Permissions["knowledge:write"] { + t.Fatalf("operator received global definition mutation permissions: %#v", operatorAccess.Permissions) + } + if !operatorAccess.Permissions["agent:local-execute"] { + t.Fatalf("operator is missing explicit local tool permission: %#v", operatorAccess.Permissions) + } +} + +func TestPermissionScopeDoesNotWidenAcrossUnrelatedRoles(t *testing.T) { + db := newRBACTestDB(t) + catalog := map[string]string{"auth:self": "self", "project:read": "read", "project:write": "write", "audit:read": "audit"} + if err := db.BootstrapRBAC("hash", catalog); err != nil { + t.Fatal(err) + } + ownWrite, err := db.UpsertRBACRole("", "own-writer", "", RBACScopeOwn, []string{"project:write"}) + if err != nil { + t.Fatal(err) + } + user, err := db.CreateRBACUser("mixed-scope", "Mixed", "hash", true, []string{RBACSystemRoleAuditor, ownWrite.ID}) + if err != nil { + t.Fatal(err) + } + access, err := db.ResolveRBACAccess(user.ID) + if err != nil { + t.Fatal(err) + } + if access.Scope != RBACScopeAll { + t.Fatalf("compatibility scope = %q, want all", access.Scope) + } + if got := access.PermissionScopes["project:read"]; got != RBACScopeAll { + t.Fatalf("project:read scope = %q, want all", got) + } + if got := access.PermissionScopes["project:write"]; got != RBACScopeOwn { + t.Fatalf("project:write scope widened to %q, want own", got) + } +} + +func TestRoleRejectsUnknownPermission(t *testing.T) { + db := newRBACTestDB(t) + if err := db.BootstrapRBAC("hash", map[string]string{"auth:self": "self"}); err != nil { + t.Fatal(err) + } + if _, err := db.UpsertRBACRole("", "future-role", "", RBACScopeAssigned, []string{"future:permission"}); err == nil { + t.Fatal("unknown permission was persisted") + } + if _, err := db.Exec(`INSERT INTO rbac_permissions (key, description, created_at) VALUES ('stale:permission', '', ?)`, time.Now()); err != nil { + t.Fatal(err) + } + if err := db.BootstrapRBAC("hash", map[string]string{"auth:self": "self"}); err != nil { + t.Fatal(err) + } + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM rbac_permissions WHERE key = 'stale:permission'`).Scan(&count); err != nil || count != 0 { + t.Fatalf("stale permission survived bootstrap: count=%d err=%v", count, err) + } +} + func TestRBACProjectAndConversationListAccess(t *testing.T) { db := newRBACTestDB(t) p1, _ := db.CreateProject(&Project{Name: "visible"})