package audit import ( "context" "database/sql" "encoding/json" "strings" "time" ) type Entry struct { ID int64 `json:"id"` UserID *int64 `json:"user_id,omitempty"` Actor string `json:"actor"` Action string `json:"action"` Resource string `json:"resource"` Detail map[string]any `json:"detail,omitempty"` IP string `json:"ip"` UserAgent string `json:"user_agent"` Status int `json:"status"` CreatedAt int64 `json:"created_at"` } type Service struct{ db *sql.DB } func New(db *sql.DB) *Service { return &Service{db: db} } func (s *Service) Log(ctx context.Context, e Entry) error { if e.CreatedAt == 0 { e.CreatedAt = time.Now().Unix() } b, _ := json.Marshal(e.Detail) _, err := s.db.ExecContext(ctx, `INSERT INTO audit_log(user_id,actor,action,resource,detail_json,ip,user_agent,status,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, e.UserID, e.Actor, e.Action, e.Resource, string(b), e.IP, e.UserAgent, e.Status, e.CreatedAt) return err } func (s *Service) List(ctx context.Context, limit, offset int, action string) ([]Entry, error) { if limit < 1 { limit = 100 } if limit > 500 { limit = 500 } if offset < 0 { offset = 0 } q := `SELECT id,user_id,actor,action,resource,detail_json,ip,user_agent,status,created_at FROM audit_log` args := []any{} if strings.TrimSpace(action) != "" { q += ` WHERE action LIKE ?` args = append(args, "%"+strings.TrimSpace(action)+"%") } q += ` ORDER BY id DESC LIMIT ? OFFSET ?` args = append(args, limit, offset) rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() out := []Entry{} for rows.Next() { var e Entry var uid sql.NullInt64 var raw string if err := rows.Scan(&e.ID, &uid, &e.Actor, &e.Action, &e.Resource, &raw, &e.IP, &e.UserAgent, &e.Status, &e.CreatedAt); err != nil { return nil, err } if uid.Valid { v := uid.Int64 e.UserID = &v } _ = json.Unmarshal([]byte(raw), &e.Detail) out = append(out, e) } return out, rows.Err() } // Run periodically prunes old audit records. A retention of 0 keeps the audit // trail indefinitely. func (s *Service) Run(ctx context.Context, retentionDays int) { if retentionDays <= 0 { <-ctx.Done() return } cleanup := func() { cut := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour).Unix() _, _ = s.db.ExecContext(ctx, `DELETE FROM audit_log WHERE created_at