You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

221 lines
7.9 KiB

package controlplane
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
func backfillAuditEventFields(db *sql.DB) error {
_, err := db.Exec(`UPDATE audit_events SET
operator = CASE WHEN operator = '' THEN actor ELSE operator END,
result = CASE WHEN result = '' THEN outcome ELSE result END,
security_detail = CASE WHEN security_detail = '' THEN detail ELSE security_detail END,
resource_type = CASE WHEN resource_type = '' OR resource_type = 'system' THEN
CASE WHEN action LIKE 'agent.%' THEN 'agent' WHEN action LIKE 'pairing.%' THEN 'pairing'
WHEN action LIKE 'llm.%' THEN 'llm' WHEN action LIKE 'skill.%' THEN 'skill'
WHEN action LIKE 'mcp.%' THEN 'mcp' WHEN action LIKE 'task.%' THEN 'task' ELSE 'system' END
ELSE resource_type END,
target_agent_id = CASE WHEN target_agent_id != '' THEN target_agent_id
WHEN actor LIKE 'agent:%' THEN substr(actor, 7)
WHEN target LIKE 'agent:%' THEN CASE WHEN instr(substr(target, 7), ':') = 0 THEN substr(target, 7) ELSE substr(substr(target, 7), 1, instr(substr(target, 7), ':') - 1) END
ELSE '' END`)
if err != nil {
return fmt.Errorf("无法升级审计记录: %w", err)
}
return nil
}
// AuditEvent is a safe local operational record. Legacy fields remain for
// compatibility; unified fields power the new server-filtered audit view.
type AuditEvent struct {
ID int64 `json:"id"`
CreatedAt string `json:"createdAt"`
Operator string `json:"operator"`
TargetAgentID string `json:"targetAgentID"`
ResourceType string `json:"resourceType"`
Action string `json:"action"`
Result string `json:"result"`
SecurityDetail string `json:"securityDetail"`
Actor string `json:"actor"`
Target string `json:"target"`
Outcome string `json:"outcome"`
Detail string `json:"detail"`
}
type AuditFilter struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
AgentID string `json:"agentID"`
ResourceType string `json:"resourceType"`
Result string `json:"result"`
FromAt string `json:"fromAt"`
ToAt string `json:"toAt"`
}
type AuditPage struct {
Items []AuditEvent `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
}
var auditResourceTypes = map[string]struct{}{
"agent": {}, "pairing": {}, "llm": {}, "skill": {}, "mcp": {}, "task": {},
"template": {}, "schedule": {}, "delegation": {}, "plugin": {}, "remote-operation": {}, "system": {},
}
func (c *ControlPlane) recordAudit(action, actor, target, outcome, detail string) {
resourceType := auditResourceType(action)
targetAgentID := auditTargetAgentID(actor, target)
safeDetail := limit(sanitizeDisplayText(detail), 1000)
operator := limit(sanitizeDisplayText(actor), 120)
result := limit(sanitizeDisplayText(outcome), 40)
_, _ = c.db.Exec(`INSERT INTO audit_events(
action, actor, target, outcome, detail, created_at,
operator, target_agent_id, resource_type, result, security_detail
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
limit(action, 80), operator, limit(sanitizeDisplayText(target), 160), result, safeDetail, time.Now().UTC().Format(time.RFC3339Nano),
operator, targetAgentID, resourceType, result, safeDetail,
)
}
func auditResourceType(action string) string {
prefix, _, found := strings.Cut(strings.TrimSpace(action), ".")
if !found {
return "system"
}
switch prefix {
case "agent":
return "agent"
case "pairing":
return "pairing"
case "llm":
return "llm"
case "skill":
return "skill"
case "mcp":
return "mcp"
case "task":
return "task"
case "template":
return "template"
case "schedule":
return "schedule"
case "delegation":
return "delegation"
case "plugin":
return "plugin"
case "remote":
return "remote-operation"
default:
return "system"
}
}
func auditTargetAgentID(actor, target string) string {
for _, value := range []string{target, actor} {
if !strings.HasPrefix(value, "agent:") {
continue
}
value = strings.TrimPrefix(value, "agent:")
if agentID, _, _ := strings.Cut(value, ":"); agentID != "" {
return limit(agentID, 80)
}
}
return ""
}
func normalizeAuditFilter(filter AuditFilter) (AuditFilter, error) {
filter.AgentID, filter.ResourceType = strings.TrimSpace(filter.AgentID), strings.TrimSpace(filter.ResourceType)
filter.Result, filter.FromAt, filter.ToAt = strings.TrimSpace(filter.Result), strings.TrimSpace(filter.FromAt), strings.TrimSpace(filter.ToAt)
if filter.Page < 1 {
filter.Page = 1
}
if filter.PageSize < 1 {
filter.PageSize = 25
}
if filter.PageSize > 100 {
filter.PageSize = 100
}
if filter.ResourceType != "" {
if _, ok := auditResourceTypes[filter.ResourceType]; !ok {
return filter, errors.New("审计资源类型无效")
}
}
if len(filter.AgentID) > 80 || len(filter.Result) > 40 {
return filter, errors.New("审计筛选条件过长")
}
for _, value := range []string{filter.FromAt, filter.ToAt} {
if value != "" {
if _, err := time.Parse(time.RFC3339, value); err != nil {
return filter, errors.New("审计时间必须使用 RFC3339 格式")
}
}
}
if filter.FromAt != "" && filter.ToAt != "" && filter.FromAt > filter.ToAt {
return filter, errors.New("审计起始时间不能晚于结束时间")
}
return filter, nil
}
func auditWhere(filter AuditFilter) (string, []any) {
clauses, args := make([]string, 0, 5), make([]any, 0, 5)
for _, criterion := range []struct{ column, value string }{{"target_agent_id", filter.AgentID}, {"resource_type", filter.ResourceType}, {"result", filter.Result}} {
if criterion.value != "" {
clauses, args = append(clauses, criterion.column+" = ?"), append(args, criterion.value)
}
}
if filter.FromAt != "" {
clauses, args = append(clauses, "created_at >= ?"), append(args, filter.FromAt)
}
if filter.ToAt != "" {
clauses, args = append(clauses, "created_at <= ?"), append(args, filter.ToAt)
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// AuditTrail provides a paged, server-filtered unified audit view.
func (c *ControlPlane) AuditTrail(filter AuditFilter) (AuditPage, error) {
filter, err := normalizeAuditFilter(filter)
if err != nil {
return AuditPage{}, err
}
where, args := auditWhere(filter)
page := AuditPage{Items: make([]AuditEvent, 0), Page: filter.Page, PageSize: filter.PageSize}
if err := c.db.QueryRow("SELECT COUNT(*) FROM audit_events"+where, args...).Scan(&page.TotalItems); err != nil {
return page, fmt.Errorf("无法统计审计记录: %w", err)
}
page.TotalPages = (page.TotalItems + filter.PageSize - 1) / filter.PageSize
if page.TotalPages > 0 && page.Page > page.TotalPages {
page.Page = page.TotalPages
}
queryArgs := append(append([]any{}, args...), page.PageSize, (page.Page-1)*page.PageSize)
rows, err := c.db.Query(`SELECT event_id, created_at, operator, target_agent_id, resource_type, action, result, security_detail,
actor, target, outcome, detail FROM audit_events`+where+" ORDER BY event_id DESC LIMIT ? OFFSET ?", queryArgs...)
if err != nil {
return page, fmt.Errorf("无法读取审计记录: %w", err)
}
defer rows.Close()
for rows.Next() {
var event AuditEvent
if err := rows.Scan(&event.ID, &event.CreatedAt, &event.Operator, &event.TargetAgentID, &event.ResourceType, &event.Action, &event.Result, &event.SecurityDetail, &event.Actor, &event.Target, &event.Outcome, &event.Detail); err != nil {
return page, err
}
event.SecurityDetail, event.Detail = sanitizeDisplayText(event.SecurityDetail), sanitizeDisplayText(event.Detail)
page.Items = append(page.Items, event)
}
return page, rows.Err()
}
// RecentAuditEvents is retained for existing callers and returns the newest 100 safe events.
func (c *ControlPlane) RecentAuditEvents() ([]AuditEvent, error) {
page, err := c.AuditTrail(AuditFilter{Page: 1, PageSize: 100})
return page.Items, err
}