package services import ( "sync" "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" "gorm.io/gorm" "github.com/treytartt/honeydue-api/internal/models" ) // Audit event type constants const ( AuditEventLogin = "auth.login" AuditEventLoginFailed = "auth.login_failed" AuditEventRegister = "auth.register" AuditEventLogout = "auth.logout" AuditEventPasswordReset = "auth.password_reset" AuditEventPasswordChanged = "auth.password_changed" AuditEventAccountDeleted = "auth.account_deleted" ) // AuditService handles audit logging for security-relevant events. // It writes audit log entries asynchronously via a buffered channel to avoid // blocking request handlers. type AuditService struct { db *gorm.DB logChan chan *models.AuditLog done chan struct{} stopOnce sync.Once } // NewAuditService creates a new audit service with a buffered channel for async writes. // Call Stop() when shutting down to flush remaining entries. func NewAuditService(db *gorm.DB) *AuditService { s := &AuditService{ db: db, logChan: make(chan *models.AuditLog, 256), done: make(chan struct{}), } go s.processLogs() return s } // processLogs drains the log channel and writes entries to the database. func (s *AuditService) processLogs() { defer close(s.done) for entry := range s.logChan { if err := s.db.Create(entry).Error; err != nil { log.Error().Err(err). Str("event_type", entry.EventType). Msg("Failed to write audit log entry") } } } // Stop closes the log channel and waits for all pending entries to be written. // It is safe to call Stop multiple times. func (s *AuditService) Stop() { s.stopOnce.Do(func() { close(s.logChan) }) <-s.done } // LogEvent records an audit event. It extracts the client IP and User-Agent from // the Echo context and sends the entry to the background writer. If the channel // is full the entry is dropped and an error is logged (non-blocking). func (s *AuditService) LogEvent(c echo.Context, userID *uint, eventType string, details map[string]interface{}) { var ip, ua string if c != nil { ip = c.RealIP() ua = c.Request().Header.Get("User-Agent") } entry := &models.AuditLog{ UserID: userID, EventType: eventType, IPAddress: ip, UserAgent: ua, Details: models.JSONMap(details), } select { case s.logChan <- entry: // sent default: log.Warn(). Str("event_type", eventType). Msg("Audit log channel full, dropping entry") } }