diff --git a/internal/database/audit.go b/internal/database/audit.go new file mode 100644 index 00000000..062d3f36 --- /dev/null +++ b/internal/database/audit.go @@ -0,0 +1,209 @@ +package database + +import ( + "encoding/json" + "errors" + "strings" + "time" +) + +// 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"` + Message string `json:"message"` + Detail map[string]interface{} `json:"detail,omitempty"` +} + +// ListAuditLogsFilter query parameters. +type ListAuditLogsFilter struct { + Level string + Category string + Action string + Result string + Query string + ResourceType string + ResourceID string + Since *time.Time + Until *time.Time + Limit int + Offset int +} + +func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) { + conditions := []string{"1=1"} + args := []interface{}{} + if filter.Level != "" { + conditions = append(conditions, "level = ?") + args = append(args, filter.Level) + } + if filter.Category != "" { + conditions = append(conditions, "category = ?") + args = append(args, filter.Category) + } + if filter.Action != "" { + conditions = append(conditions, "action = ?") + args = append(args, filter.Action) + } + if filter.Result != "" { + conditions = append(conditions, "result = ?") + args = append(args, filter.Result) + } + if filter.ResourceType != "" { + conditions = append(conditions, "resource_type = ?") + args = append(args, filter.ResourceType) + } + if filter.ResourceID != "" { + conditions = append(conditions, "resource_id = ?") + args = append(args, filter.ResourceID) + } + if filter.Since != nil { + conditions = append(conditions, "created_at >= ?") + args = append(args, *filter.Since) + } + if filter.Until != nil { + conditions = append(conditions, "created_at <= ?") + args = append(args, *filter.Until) + } + if q := strings.TrimSpace(filter.Query); q != "" { + like := "%" + q + "%" + conditions = append(conditions, "(message LIKE ? OR resource_id LIKE ? OR action LIKE ? OR category LIKE ?)") + args = append(args, like, like, like, like) + } + return strings.Join(conditions, " AND "), args +} + +// AppendAuditLog inserts one audit row. +func (db *DB) AppendAuditLog(row *AuditLog) error { + if row == nil { + return errors.New("audit log is nil") + } + if strings.TrimSpace(row.ID) == "" { + return errors.New("audit id is required") + } + if row.CreatedAt.IsZero() { + row.CreatedAt = time.Now() + } + if strings.TrimSpace(row.Level) == "" { + row.Level = "info" + } + detailJSON := "" + if len(row.Detail) > 0 { + if b, err := json.Marshal(row.Detail); err == nil { + detailJSON = string(b) + } + } + query := ` + INSERT INTO audit_logs ( + id, created_at, level, category, action, result, actor, session_hint, + client_ip, user_agent, resource_type, resource_id, message, detail_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + _, err := db.Exec(query, + row.ID, row.CreatedAt, row.Level, row.Category, row.Action, row.Result, + row.Actor, row.SessionHint, row.ClientIP, row.UserAgent, + row.ResourceType, row.ResourceID, row.Message, detailJSON, + ) + return err +} + +// GetAuditLogByID returns one row. +func (db *DB) GetAuditLogByID(id string) (*AuditLog, error) { + id = strings.TrimSpace(id) + if id == "" { + return nil, errors.New("id is required") + } + query := ` + SELECT id, created_at, level, category, action, result, actor, + COALESCE(session_hint, ''), COALESCE(client_ip, ''), COALESCE(user_agent, ''), + COALESCE(resource_type, ''), COALESCE(resource_id, ''), message, COALESCE(detail_json, '') + FROM audit_logs WHERE id = ? + ` + var row AuditLog + var detailJSON string + err := db.QueryRow(query, id).Scan( + &row.ID, &row.CreatedAt, &row.Level, &row.Category, &row.Action, &row.Result, &row.Actor, + &row.SessionHint, &row.ClientIP, &row.UserAgent, + &row.ResourceType, &row.ResourceID, &row.Message, &detailJSON, + ) + if err != nil { + return nil, err + } + if detailJSON != "" { + _ = json.Unmarshal([]byte(detailJSON), &row.Detail) + } + return &row, nil +} + +// CountAuditLogs counts rows matching filter. +func (db *DB) CountAuditLogs(filter ListAuditLogsFilter) (int64, error) { + where, args := buildAuditLogsWhere(filter) + query := `SELECT COUNT(*) FROM audit_logs WHERE ` + where + var n int64 + err := db.QueryRow(query, args...).Scan(&n) + return n, err +} + +// ListAuditLogs lists audit rows newest first. +func (db *DB) ListAuditLogs(filter ListAuditLogsFilter) ([]*AuditLog, error) { + where, args := buildAuditLogsWhere(filter) + limit := filter.Limit + if limit <= 0 || limit > 500 { + limit = 50 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + query := ` + SELECT id, created_at, level, category, action, result, actor, + COALESCE(session_hint, ''), COALESCE(client_ip, ''), COALESCE(user_agent, ''), + COALESCE(resource_type, ''), COALESCE(resource_id, ''), message, COALESCE(detail_json, '') + FROM audit_logs + WHERE ` + where + ` + ORDER BY created_at DESC + LIMIT ? OFFSET ? + ` + args = append(args, limit, offset) + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*AuditLog + for rows.Next() { + var row AuditLog + var detailJSON string + if err := rows.Scan( + &row.ID, &row.CreatedAt, &row.Level, &row.Category, &row.Action, &row.Result, &row.Actor, + &row.SessionHint, &row.ClientIP, &row.UserAgent, + &row.ResourceType, &row.ResourceID, &row.Message, &detailJSON, + ); err != nil { + continue + } + if detailJSON != "" { + _ = json.Unmarshal([]byte(detailJSON), &row.Detail) + } + list = append(list, &row) + } + return list, rows.Err() +} + +// DeleteAuditLogsBefore removes rows older than cutoff. +func (db *DB) DeleteAuditLogsBefore(cutoff time.Time) (int64, error) { + res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < ?`, cutoff) + if err != nil { + return 0, err + } + return res.RowsAffected() +} diff --git a/internal/database/database.go b/internal/database/database.go index 6321e1a5..26d44fd6 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -387,6 +387,24 @@ func (db *DB) initTables() error { created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );` + createAuditLogsTable := ` + CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + category TEXT NOT NULL, + action TEXT NOT NULL, + result TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT 'admin', + session_hint TEXT, + client_ip TEXT, + user_agent TEXT, + resource_type TEXT, + resource_id TEXT, + message TEXT NOT NULL, + detail_json TEXT + );` + createC2ProfilesTable := ` CREATE TABLE IF NOT EXISTS c2_profiles ( id TEXT PRIMARY KEY, @@ -445,6 +463,10 @@ func (db *DB) initTables() error { CREATE INDEX IF NOT EXISTS idx_c2_events_created_at ON c2_events(created_at); CREATE INDEX IF NOT EXISTS idx_c2_events_category ON c2_events(category); CREATE INDEX IF NOT EXISTS idx_c2_events_session ON c2_events(session_id); + CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_audit_logs_category ON audit_logs(category); + CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action); + CREATE INDEX IF NOT EXISTS idx_audit_logs_result ON audit_logs(result); ` if _, err := db.Exec(createConversationsTable); err != nil { @@ -514,6 +536,10 @@ func (db *DB) initTables() error { return fmt.Errorf("创建webshell_connection_states表失败: %w", err) } + if _, err := db.Exec(createAuditLogsTable); err != nil { + return fmt.Errorf("创建audit_logs表失败: %w", err) + } + for tableName, ddl := range map[string]string{ "c2_listeners": createC2ListenersTable, "c2_sessions": createC2SessionsTable,