package handlers import ( "crypto/rand" "database/sql" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" ) type AuditEntry struct { ID string `json:"id"` UserID string `json:"user_id"` Action string `json:"action"` Resource string `json:"resource"` IP string `json:"ip"` Timestamp time.Time `json:"timestamp"` Details string `json:"details"` } type AuditListResponse struct { Data []AuditEntry `json:"data"` Total int `json:"total"` Page int `json:"page"` Limit int `json:"limit"` } // AuditHandler returns a handler for GET /api/v1/audit. // Query params: page (default 1), limit (default 20, max 100), // user (user_id filter), action, resource. func AuditHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } q := r.URL.Query() page, err := parsePositiveInt(q.Get("page"), 1) if err != nil { http.Error(w, "invalid page", http.StatusBadRequest) return } limit, err := parsePositiveInt(q.Get("limit"), 20) if err != nil { http.Error(w, "invalid limit", http.StatusBadRequest) return } if limit > 100 { limit = 100 } userFilter := strings.TrimSpace(q.Get("user")) actionFilter := strings.TrimSpace(q.Get("action")) resourceFilter := strings.TrimSpace(q.Get("resource")) var conditions []string var args []interface{} idx := 1 if userFilter != "" { conditions = append(conditions, fmt.Sprintf("user_id = $%d", idx)) args = append(args, userFilter) idx++ } if actionFilter != "" { conditions = append(conditions, fmt.Sprintf("action = $%d", idx)) args = append(args, actionFilter) idx++ } if resourceFilter != "" { conditions = append(conditions, fmt.Sprintf("resource = $%d", idx)) args = append(args, resourceFilter) idx++ } where := "" if len(conditions) > 0 { where = "WHERE " + strings.Join(conditions, " AND ") } var total int if err := db.QueryRow( fmt.Sprintf("SELECT COUNT(*) FROM audit_logs %s", where), args..., ).Scan(&total); err != nil { http.Error(w, fmt.Sprintf("db count error: %v", err), http.StatusInternalServerError) return } dataArgs := append(args, limit, (page-1)*limit) rows, err := db.Query( fmt.Sprintf( `SELECT id, user_id, action, resource, ip, timestamp, details FROM audit_logs %s ORDER BY timestamp DESC LIMIT $%d OFFSET $%d`, where, idx, idx+1, ), dataArgs..., ) if err != nil { http.Error(w, fmt.Sprintf("db query error: %v", err), http.StatusInternalServerError) return } defer rows.Close() entries := []AuditEntry{} for rows.Next() { var e AuditEntry if err := rows.Scan(&e.ID, &e.UserID, &e.Action, &e.Resource, &e.IP, &e.Timestamp, &e.Details); err != nil { http.Error(w, fmt.Sprintf("db scan error: %v", err), http.StatusInternalServerError) return } entries = append(entries, e) } if err := rows.Err(); err != nil { http.Error(w, fmt.Sprintf("db rows error: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AuditListResponse{ Data: entries, Total: total, Page: page, Limit: limit, }) } } // LogAudit inserts one audit record. Non-fatal: caller may log the returned error. func LogAudit(db *sql.DB, userID, action, resource, ip, details string) error { id, err := auditUUID() if err != nil { return fmt.Errorf("generate id: %w", err) } _, err = db.Exec( `INSERT INTO audit_logs (id, user_id, action, resource, ip, timestamp, details) VALUES ($1, $2, $3, $4, $5, $6, $7)`, id, userID, action, resource, ip, time.Now().UTC(), details, ) return err } func auditUUID() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", err } b[6] = (b[6] & 0x0f) | 0x40 // version 4 b[8] = (b[8] & 0x3f) | 0x80 // variant return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil } func parsePositiveInt(s string, defaultVal int) (int, error) { if s == "" { return defaultVal, nil } v, err := strconv.Atoi(s) if err != nil || v < 1 { return 0, fmt.Errorf("must be a positive integer") } return v, nil }