package handlers import ( "database/sql" "encoding/json" "net/http" "strconv" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" ) // AuditLog inserts an audit entry. Called from mutating handlers. // Non-blocking: errors are logged but don't break the caller. func AuditLog(c *gin.Context, action, resourceType, resourceID string, metadata map[string]interface{}) { if database.DB == nil { return } actorID := getUserID(c) if actorID == "" { return } ip := c.ClientIP() ua := c.GetHeader("User-Agent") metaJSON := "{}" if metadata != nil { if b, err := json.Marshal(metadata); err == nil { metaJSON = string(b) } } _, _ = database.DB.Exec(` INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7) `, actorID, action, resourceType, resourceID, metaJSON, ip, ua) } // AuditLogAnon inserts an audit entry without a gin context (e.g. system actions). func AuditLogAnon(action, resourceType, resourceID string, metadata map[string]interface{}) { if database.DB == nil { return } metaJSON := "{}" if metadata != nil { if b, err := json.Marshal(metadata); err == nil { metaJSON = string(b) } } _, _ = database.DB.Exec(` INSERT INTO audit_log (action, resource_type, resource_id, metadata) VALUES ($1, $2, $3, $4::jsonb) `, action, resourceType, resourceID, metaJSON) } // AuditLogWithActor inserts an audit entry with an explicit actor ID (e.g. pre-auth flows). func AuditLogWithActor(actorID string, c *gin.Context, action, resourceType, resourceID string, metadata map[string]interface{}) { if database.DB == nil || actorID == "" { return } ip := c.ClientIP() ua := c.GetHeader("User-Agent") metaJSON := "{}" if metadata != nil { if b, err := json.Marshal(metadata); err == nil { metaJSON = string(b) } } _, _ = database.DB.Exec(` INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7) `, actorID, action, resourceType, resourceID, metaJSON, ip, ua) } // ── Admin Audit Viewer ────────────────────── type auditEntry struct { ID string `json:"id"` ActorID *string `json:"actor_id"` ActorName *string `json:"actor_name"` Action string `json:"action"` ResourceType string `json:"resource_type"` ResourceID *string `json:"resource_id"` Metadata string `json:"metadata"` IPAddress *string `json:"ip_address"` CreatedAt string `json:"created_at"` } // ListAuditLog returns paginated audit entries with optional filters. // GET /api/v1/admin/audit?page=1&per_page=50&action=user.create&actor_id=...&resource_type=... func (h *AdminHandler) ListAuditLog(c *gin.Context) { page, perPage, offset := parsePagination(c) // Build filter clauses where := "WHERE 1=1" args := []interface{}{} argN := 1 if action := c.Query("action"); action != "" { where += " AND al.action = $" + strconv.Itoa(argN) args = append(args, action) argN++ } if actorID := c.Query("actor_id"); actorID != "" { where += " AND al.actor_id = $" + strconv.Itoa(argN) args = append(args, actorID) argN++ } if rt := c.Query("resource_type"); rt != "" { where += " AND al.resource_type = $" + strconv.Itoa(argN) args = append(args, rt) argN++ } // Count var total int countArgs := make([]interface{}, len(args)) copy(countArgs, args) err := database.DB.QueryRow(`SELECT COUNT(*) FROM audit_log al `+where, countArgs...).Scan(&total) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"}) return } // Query query := ` SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name, al.action, al.resource_type, al.resource_id, COALESCE(al.metadata::text, '{}'), al.ip_address, al.created_at FROM audit_log al LEFT JOIN users u ON al.actor_id = u.id ` + where + ` ORDER BY al.created_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1) args = append(args, perPage, offset) rows, err := database.DB.Query(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } defer rows.Close() entries := make([]auditEntry, 0) for rows.Next() { var e auditEntry var actorName sql.NullString if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action, &e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, &e.CreatedAt); err != nil { continue } if actorName.Valid { e.ActorName = &actorName.String } entries = append(entries, e) } c.JSON(http.StatusOK, gin.H{ "data": entries, "total": total, "page": page, "per_page": perPage, }) } // ListAuditActions returns distinct action names for filter dropdowns. // GET /api/v1/admin/audit/actions func (h *AdminHandler) ListAuditActions(c *gin.Context) { rows, err := database.DB.Query(` SELECT DISTINCT action FROM audit_log ORDER BY action ASC `) if err != nil { c.JSON(http.StatusOK, gin.H{"actions": []string{}}) return } defer rows.Close() var actions []string for rows.Next() { var a string if rows.Scan(&a) == nil { actions = append(actions, a) } } if actions == nil { actions = []string{} } c.JSON(http.StatusOK, gin.H{"actions": actions}) }