Add files via upload

This commit is contained in:
公明
2026-07-10 18:49:42 +08:00
committed by GitHub
parent cd87a0b965
commit 49d2175872
8 changed files with 601 additions and 55 deletions
+20 -15
View File
@@ -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)
+30
View File
@@ -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)
}
+56
View File
@@ -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)
}
+3
View File
@@ -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
);`
+60 -23
View File
@@ -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
}
+126 -7
View File
@@ -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))
+126 -10
View File
@@ -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
}
+180
View File
@@ -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"})