mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-14 07:57:26 +02:00
Add files via upload
This commit is contained in:
+20
-15
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user