package handlers import ( "database/sql" "encoding/json" "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/google/uuid" "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) } } if database.IsSQLite() { _, _ = database.DB.Exec(` INSERT INTO audit_log (id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, uuid.New().String(), actorID, action, resourceType, resourceID, metaJSON, ip, ua) } else { _, _ = 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) } } if database.IsSQLite() { _, _ = database.DB.Exec(` INSERT INTO audit_log (id, action, resource_type, resource_id, metadata) VALUES (?, ?, ?, ?, ?) `, uuid.New().String(), action, resourceType, resourceID, metaJSON) } else { _, _ = 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) } } if database.IsSQLite() { _, _ = database.DB.Exec(` INSERT INTO audit_log (id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, uuid.New().String(), actorID, action, resourceType, resourceID, metaJSON, ip, ua) } else { _, _ = 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 with ? placeholders, convert for Postgres later where := "WHERE 1=1" args := []interface{}{} if action := c.Query("action"); action != "" { where += " AND al.action = ?" args = append(args, action) } if actorID := c.Query("actor_id"); actorID != "" { where += " AND al.actor_id = ?" args = append(args, actorID) } if rt := c.Query("resource_type"); rt != "" { where += " AND al.resource_type = ?" args = append(args, rt) } // Count var total int countArgs := make([]interface{}, len(args)) copy(countArgs, args) countQ := convertPlaceholders(`SELECT COUNT(*) FROM audit_log al ` + where) err := database.DB.QueryRow(countQ, countArgs...).Scan(&total) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"}) return } // Query — metadata column needs ::text on Postgres only metadataCol := "COALESCE(al.metadata::text, '{}')" if database.IsSQLite() { metadataCol = "COALESCE(al.metadata, '{}')" } query := fmt.Sprintf(` SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name, al.action, al.resource_type, al.resource_id, %s, al.ip_address, al.created_at FROM audit_log al LEFT JOIN users u ON al.actor_id = u.id %s ORDER BY al.created_at DESC LIMIT ? OFFSET ?`, metadataCol, where) args = append(args, perPage, offset) query = convertPlaceholders(query) 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}) }