vdi-cloud/platform/internal/server/audits.go

151 lines
5.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package server
import (
"bytes"
"database/sql"
"encoding/csv"
"fmt"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/gin-gonic/gin"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
const auditExportLimit = 10000
// Filters are shared by the paginated view and export. No implicit history cap.
func (s *Server) auditFilter(c *gin.Context) (string, []any, bool) {
where, args := " FROM audit_logs WHERE true", []any{}
add := func(expr string, value any) {
args = append(args, value)
where += fmt.Sprintf(" AND "+expr, len(args))
}
var from, to time.Time
for _, key := range []string{"from", "to"} {
if raw := c.Query(key); raw != "" {
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
s.fail(c, 400, "invalid_range", "时间格式无效,请使用含时区的时间")
return "", nil, false
}
if key == "from" {
from = parsed
add("created_at >= $%d", parsed)
} else {
to = parsed
add("created_at <= $%d", parsed)
}
}
}
if !from.IsZero() && !to.IsZero() && !to.After(from) {
s.fail(c, 400, "invalid_range", "结束时间必须晚于开始时间")
return "", nil, false
}
for _, key := range []string{"keyword", "actor", "action", "result"} {
value := c.Query(key)
if utf8.RuneCountInString(value) > 200 || strings.IndexByte(value, 0) >= 0 {
s.fail(c, 400, "invalid_request", "筛选条件最长 200 字,且不能包含空字符")
return "", nil, false
}
if value == "" {
continue
}
if key == "action" || key == "result" {
add(key+" = $%d", value)
continue
}
// Search terms are literal, including SQL LIKE wildcards supplied by users.
pattern := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(value) + "%"
if key == "actor" {
add("actor ILIKE $%d", pattern)
} else {
args = append(args, pattern)
p := fmt.Sprintf("$%d", len(args))
where += " AND (id ILIKE " + p + " OR actor ILIKE " + p + " OR action ILIKE " + p + " OR target ILIKE " + p + " OR result ILIKE " + p + " OR request_id ILIKE " + p + ")"
}
}
return where, args, true
}
func (s *Server) audits(c *gin.Context) {
where, args, ok := s.auditFilter(c)
if !ok {
return
}
page, size := pageParams(c)
tx, err := s.DB.BeginTxx(c.Request.Context(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var total int
if err = tx.GetContext(c.Request.Context(), &total, "SELECT count(*)"+where, args...); err != nil {
s.dbError(c, err)
return
}
items := []model.Audit{}
query := "SELECT *" + where + fmt.Sprintf(" ORDER BY created_at DESC,id DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
args = append(args, size, (page-1)*size)
if err = tx.SelectContext(c.Request.Context(), &items, query, args...); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"items": items, "total": total, "page": page, "pageSize": size, "requestId": c.GetString("requestId")})
}
func safeCSVCell(value string) string {
trimmed := strings.TrimLeftFunc(value, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) || r == '\ufeff' })
if len(trimmed) > 0 && strings.ContainsRune("=+-@", rune(trimmed[0])) {
return "'" + value
}
return value
}
func (s *Server) exportAudits(c *gin.Context) {
where, args, ok := s.auditFilter(c)
if !ok {
return
}
// An extra row detects oversized exports without silently truncating evidence.
items := []model.Audit{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT *"+where+" ORDER BY created_at DESC,id DESC LIMIT 10001", args...); err != nil {
s.dbError(c, err)
return
}
if len(items) > auditExportLimit {
s.fail(c, 413, "export_too_large", "匹配记录超过 10000 条,请缩小时间范围或增加筛选条件后导出")
return
}
var content bytes.Buffer
content.WriteString("\ufeff")
w := csv.NewWriter(&content)
w.UseCRLF = true
_ = w.Write([]string{"时间(UTC)", "操作者", "动作", "对象", "结果", "请求标识", "审计标识"})
for _, row := range items {
cells := []string{row.CreatedAt.UTC().Format(time.RFC3339Nano), row.Actor, row.Action, row.Target, row.Result, row.RequestID, row.ID}
for i := range cells {
cells[i] = safeCSVCell(cells[i])
}
_ = w.Write(cells)
}
w.Flush()
if err := w.Error(); err != nil {
s.fail(c, 500, "export_failed", "导出生成失败,请重试")
return
}
// Record preparation, without storing credentials or the user's search terms.
if _, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,'export_audit_logs',$3,'success',$4)", store.ID("audit_"), s.admin(c).Username, fmt.Sprintf("rows=%d", len(items)), c.GetString("requestId")); err != nil {
s.dbError(c, err)
return
}
c.Header("Content-Disposition", `attachment; filename="audit-logs-`+time.Now().UTC().Format("20060102-150405")+`.csv"`)
c.Header("X-Export-Count", fmt.Sprint(len(items)))
c.Header("X-Content-Type-Options", "nosniff")
c.Data(200, "text/csv; charset=utf-8", content.Bytes())
}