From 5eca5b0c99c3e41ca41cd444984116be0a16b452 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Mon, 23 Feb 2026 00:21:27 +0100 Subject: [PATCH] Add audit logging for sensitive operations and configurable Centrifuge log level Add slog.InfoContext audit logs after successful login, logout, config save, sentiment reset, and overlay UUID rotation. Wire correlation ID middleware into the Echo middleware chain so all HTTP request logs include a correlation_id. Make Centrifuge respect the app's LOG_LEVEL setting instead of always logging at Info level, reducing noise in production. --- cmd/server/main.go | 6 +++--- internal/adapter/httpserver/handlers_api.go | 4 ++++ internal/adapter/httpserver/handlers_auth.go | 7 +++++++ .../adapter/httpserver/handlers_dashboard.go | 3 +++ internal/adapter/httpserver/middleware.go | 9 +++++++++ internal/adapter/httpserver/routes.go | 1 + internal/adapter/websocket/node.go | 17 +++++++++++++++-- 7 files changed, 42 insertions(+), 5 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index f9d1c2b..1b14e3d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -125,8 +125,8 @@ type realtimeInfra struct { presence *websocket.PresenceChecker } -func setupWebSocket(streamerRepo domain.StreamerRepository, redisAddr string, configCache domain.ConfigSource, wsMetrics *metrics.WebSocketMetrics) (realtimeInfra, error) { - node, err := websocket.NewNode(streamerRepo, wsMetrics) +func setupWebSocket(streamerRepo domain.StreamerRepository, redisAddr string, configCache domain.ConfigSource, wsMetrics *metrics.WebSocketMetrics, logLevel string) (realtimeInfra, error) { + node, err := websocket.NewNode(streamerRepo, wsMetrics, logLevel) if err != nil { return realtimeInfra{}, fmt.Errorf("create Centrifuge node: %w", err) } @@ -236,7 +236,7 @@ func main() { ovl := app.NewOverlay(rc.configCache, rc.sentiment, rc.debouncer) - ws, err := setupWebSocket(db.streamerRepo, rc.client.Options().Addr, rc.configCache, wsMetrics) + ws, err := setupWebSocket(db.streamerRepo, rc.client.Options().Addr, rc.configCache, wsMetrics, cfg.LogLevel) if err != nil { slog.Error("WebSocket setup failed", "error", err) os.Exit(1) diff --git a/internal/adapter/httpserver/handlers_api.go b/internal/adapter/httpserver/handlers_api.go index 53ceef0..8440485 100644 --- a/internal/adapter/httpserver/handlers_api.go +++ b/internal/adapter/httpserver/handlers_api.go @@ -52,6 +52,8 @@ func (s *Server) handleResetSentiment(c echo.Context) error { return apperrors.InternalError("failed to reset sentiment", err).WithField("overlay_uuid", overlayUUID.String()) } + slog.InfoContext(ctx, "Sentiment reset", "streamer_id", streamerID, "overlay_uuid", overlayUUID) + if err := c.JSON(http.StatusOK, map[string]string{"status": "ok"}); err != nil { return fmt.Errorf("failed to send JSON response: %w", err) } @@ -97,6 +99,8 @@ func (s *Server) handleRotateOverlayUUID(c echo.Context) error { return apperrors.InternalError("failed to rotate overlay UUID", err).WithField("streamer_id", streamerID.String()) } + slog.InfoContext(ctx, "Overlay UUID rotated", "streamer_id", streamerID, "new_overlay_uuid", newUUID) + newURL := fmt.Sprintf("%s/overlay/%s", s.getBaseURL(c), newUUID) response := map[string]string{ "status": "ok", diff --git a/internal/adapter/httpserver/handlers_auth.go b/internal/adapter/httpserver/handlers_auth.go index de3676e..bf94eb3 100644 --- a/internal/adapter/httpserver/handlers_auth.go +++ b/internal/adapter/httpserver/handlers_auth.go @@ -173,6 +173,8 @@ func (s *Server) handleOAuthCallback(c echo.Context) error { return apperrors.InternalError("failed to save session", err) } + slog.InfoContext(ctx, "Streamer logged in", "streamer_id", streamer.ID, "broadcaster_id", result.UserID, "twitch_username", result.Username) + if err := c.Redirect(http.StatusFound, "/dashboard"); err != nil { return fmt.Errorf("failed to redirect: %w", err) } @@ -180,6 +182,9 @@ func (s *Server) handleOAuthCallback(c echo.Context) error { } func (s *Server) handleLogout(c echo.Context) error { + ctx := c.Request().Context() + streamerID, _ := c.Get("userID").(uuid.UUID) + session, err := s.sessionStore.Get(c.Request(), sessionName) if err != nil { slog.Error("Failed to get session during logout", "error", err) @@ -194,6 +199,8 @@ func (s *Server) handleLogout(c echo.Context) error { return apperrors.InternalError("failed to save logout session", err) } + slog.InfoContext(ctx, "Streamer logged out", "streamer_id", streamerID) + if err := c.Redirect(http.StatusFound, "/auth/login"); err != nil { return fmt.Errorf("failed to redirect: %w", err) } diff --git a/internal/adapter/httpserver/handlers_dashboard.go b/internal/adapter/httpserver/handlers_dashboard.go index f82931d..7bb78b2 100644 --- a/internal/adapter/httpserver/handlers_dashboard.go +++ b/internal/adapter/httpserver/handlers_dashboard.go @@ -3,6 +3,7 @@ package httpserver import ( "errors" "fmt" + "log/slog" "net/http" "strconv" "strings" @@ -170,6 +171,8 @@ func (s *Server) handleSaveConfig(c echo.Context) error { WithField("broadcaster_id", streamer.TwitchUserID) } + slog.InfoContext(ctx, "Config updated", "streamer_id", streamerID, "broadcaster_id", streamer.TwitchUserID) + // AJAX requests get a 204; browser form submissions get a redirect if c.Request().Header.Get("X-Requested-With") == "XMLHttpRequest" { if err := c.NoContent(http.StatusNoContent); err != nil { diff --git a/internal/adapter/httpserver/middleware.go b/internal/adapter/httpserver/middleware.go index 29c960f..b31956f 100644 --- a/internal/adapter/httpserver/middleware.go +++ b/internal/adapter/httpserver/middleware.go @@ -7,9 +7,18 @@ import ( "net/http" "github.com/labstack/echo/v4" + "github.com/pscheid92/chatpulse/internal/platform/correlation" apperrors "github.com/pscheid92/chatpulse/internal/platform/errors" ) +func correlationMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := correlation.WithID(c.Request().Context(), correlation.NewID()) + c.SetRequest(c.Request().WithContext(ctx)) + return next(c) + } +} + func ErrorHandlingMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { diff --git a/internal/adapter/httpserver/routes.go b/internal/adapter/httpserver/routes.go index eb26df1..1c2f8fd 100644 --- a/internal/adapter/httpserver/routes.go +++ b/internal/adapter/httpserver/routes.go @@ -9,6 +9,7 @@ import ( ) func (s *Server) registerRoutes() { + s.echo.Use(correlationMiddleware) if s.httpMetrics != nil { s.echo.Use(s.httpMetrics.Middleware()) } diff --git a/internal/adapter/websocket/node.go b/internal/adapter/websocket/node.go index c75531c..85d5d39 100644 --- a/internal/adapter/websocket/node.go +++ b/internal/adapter/websocket/node.go @@ -11,8 +11,8 @@ import ( "github.com/pscheid92/uuid" ) -func NewNode(userRepo domain.StreamerRepository, wsMetrics *metrics.WebSocketMetrics) (*centrifuge.Node, error) { - conf := centrifuge.Config{LogLevel: centrifuge.LogLevelInfo, LogHandler: slogHandler} +func NewNode(userRepo domain.StreamerRepository, wsMetrics *metrics.WebSocketMetrics, logLevel string) (*centrifuge.Node, error) { + conf := centrifuge.Config{LogLevel: parseCentrifugeLogLevel(logLevel), LogHandler: slogHandler} node, err := centrifuge.New(conf) if err != nil { return nil, fmt.Errorf("create centrifuge node: %w", err) @@ -123,6 +123,19 @@ func slogHandler(entry centrifuge.LogEntry) { } } +func parseCentrifugeLogLevel(level string) centrifuge.LogLevel { + switch level { + case "debug": + return centrifuge.LogLevelDebug + case "warn": + return centrifuge.LogLevelWarn + case "error": + return centrifuge.LogLevelError + default: + return centrifuge.LogLevelInfo + } +} + type PresenceChecker struct { node *centrifuge.Node }