diff --git a/cmd/web/bootstrap/bootstrap.go b/cmd/web/bootstrap/bootstrap.go index 90285bd..791fdac 100644 --- a/cmd/web/bootstrap/bootstrap.go +++ b/cmd/web/bootstrap/bootstrap.go @@ -4,8 +4,10 @@ import ( "fmt" "time" ai_summarizer "tm/pkg/ai_summarizer" + "tm/pkg/audit" "tm/pkg/authorization" "tm/pkg/config" + "tm/pkg/elasticsearch" "tm/pkg/filestore" "tm/pkg/gorules" "tm/pkg/hcaptcha" @@ -90,6 +92,7 @@ func InitHTTPServer(conf Config, log logger.Logger) *echo.Echo { // Add middleware e.Use(middleware.Recover()) e.Use(middleware.RequestID()) + e.Use(audit.RequestMetaMiddleware()) e.Use(middleware.Logger()) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"*"}, @@ -132,7 +135,7 @@ func InitHTTPServer(conf Config, log logger.Logger) *echo.Echo { e.GET("/swagger*", echoSwagger.WrapHandler) log.Info("HTTP server initialized successfully", map[string]interface{}{ - "middleware": []string{"recover", "request_id", "logger", "cors", "custom_logging"}, + "middleware": []string{"recover", "request_id", "audit_meta", "logger", "cors", "custom_logging"}, }) return e @@ -402,3 +405,34 @@ func InitGoRulesClient(_ GoRulesConfig, log logger.Logger) gorules.Client { return client */ } + +// InitElasticsearch creates an Elasticsearch client for audit log storage. +func InitElasticsearch(conf config.ElasticsearchConfig, log logger.Logger) elasticsearch.Client { + esConfig := elasticsearch.Config{ + URL: conf.URL, + Username: conf.Username, + Password: conf.Password, + IndexPrefix: conf.IndexPrefix, + Enabled: conf.Enabled, + RequestTimeout: conf.RequestTimeout, + BulkFlushPeriod: conf.BulkFlushPeriod, + QueueSize: conf.QueueSize, + } + + client, err := elasticsearch.NewClient(esConfig, log) + if err != nil { + log.Warn("Elasticsearch unavailable, audit logs will be file-only", map[string]interface{}{ + "error": err.Error(), + "url": conf.URL, + }) + return elasticsearch.NewNoopClient() + } + + return client +} + +// InitAuditLogger creates a composite audit logger with optional Elasticsearch persistence. +func InitAuditLogger(log logger.Logger, esClient elasticsearch.Client) (audit.Logger, audit.Store) { + store := elasticsearch.NewAuditStore(esClient) + return audit.NewCompositeLogger(log, store), store +} diff --git a/cmd/web/bootstrap/config.go b/cmd/web/bootstrap/config.go index 0d1afb4..1092cc0 100644 --- a/cmd/web/bootstrap/config.go +++ b/cmd/web/bootstrap/config.go @@ -23,6 +23,7 @@ type Config struct { DocumentScraper DocumentScraperConfig AISummarizer AISummarizerConfig GoRules GoRulesConfig + Elasticsearch config.ElasticsearchConfig } type ScraperConfig struct { diff --git a/cmd/web/main.go b/cmd/web/main.go index b10d9f6..382b936 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -62,6 +62,9 @@ package main // @tag.name Admin-AI-Pipeline // @tag.description Administrative Opplens AI pipeline operations including scrape, summarize, translate, sync, and background pipeline runs +// @tag.name Admin-AuditLogs +// @tag.description Administrative user action audit log search with filtering by actor, action, target, and time range + // @tag.name Authorization // @tag.description Customer authentication and authorization operations for mobile application including login, logout, token refresh, and profile access @@ -100,8 +103,9 @@ import ( "fmt" "net/http" "time" - "tm/internal/assets" "tm/internal/ai_pipeline" + "tm/internal/assets" + "tm/internal/auditlog" "tm/internal/cms" "tm/internal/company" "tm/internal/company_category" @@ -116,7 +120,6 @@ import ( "tm/internal/tender" "tm/internal/tender_approval" "tm/internal/user" - "tm/pkg/audit" "tm/pkg/filestore" "tm/pkg/security" @@ -217,7 +220,24 @@ func main() { _ = cms.NewValidationService() // Register mediaRef validator // Initialize services with repositories - auditLogger := audit.NewLogger(logger) + esClient := bootstrap.InitElasticsearch(conf.Elasticsearch, logger) + defer func() { + if err := esClient.Close(); err != nil { + logger.Error("Failed to close Elasticsearch client", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + + auditLogger, auditStore := bootstrap.InitAuditLogger(logger, esClient) + defer func() { + if err := auditStore.Close(); err != nil { + logger.Error("Failed to close audit store", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger) categoryService := company_category.NewService(categoryRepository, logger) companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, redisClient, conf.AISummarizer.RecommendationCacheTTL, logger) @@ -235,8 +255,10 @@ func main() { dashboardRepository := dashboard.NewRepository(mongoManager, logger, procedureDocumentsLister) dashboardService := dashboard.NewService(dashboardRepository, logger) aiPipelineService := ai_pipeline.NewService(aiPipelineClient, logger, tenderService) + auditLogRepository := auditlog.NewRepository(auditStore) + auditLogService := auditlog.NewService(auditLogRepository, logger) logger.Info("Services initialized successfully", map[string]interface{}{ - "services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper", "dashboard", "ai_pipeline"}, + "services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper", "dashboard", "ai_pipeline", "auditlog"}, }) // Initialize handlers with services @@ -257,8 +279,9 @@ func main() { documentScraperHandler := document_scraper.NewHandler(documentScraperService, logger) dashboardHandler := dashboard.NewHandler(dashboardService) aiPipelineHandler := ai_pipeline.NewHandler(aiPipelineService) + auditLogHandler := auditlog.NewHandler(auditLogService) logger.Info("Handlers initialized successfully", map[string]interface{}{ - "handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper", "dashboard", "ai_pipeline"}, + "handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper", "dashboard", "ai_pipeline", "auditlog"}, }) // Initialize HTTP server @@ -267,7 +290,7 @@ func main() { router.SetupCSPSecurity(e) // Register routes - router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, aiPipelineHandler, xssPolicy) + router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, aiPipelineHandler, auditLogHandler, xssPolicy) router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy) router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey) diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 91e15c9..996e312 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -3,6 +3,7 @@ package router import ( "tm/internal/assets" "tm/internal/ai_pipeline" + "tm/internal/auditlog" "tm/internal/cms" "tm/internal/company" "tm/internal/company_category" @@ -35,7 +36,7 @@ func SetupCSPSecurity(e *echo.Echo) { e.POST("/api/v1/csp-report", security.CSPReportHandler) } -func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, aiPipelineHandler *ai_pipeline.Handler, xssPolicy *security.XSSPolicy) { +func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, aiPipelineHandler *ai_pipeline.Handler, auditLogHandler *auditlog.Handler, xssPolicy *security.XSSPolicy) { adminV1 := e.Group("/admin/v1") // Admin Users Routes @@ -51,6 +52,13 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler adminUsersGP.POST("/:id/reset-password", userHandler.ResetUserPassword, userHandler.AdminMiddleware()) } + // Admin Audit Logs Routes + auditLogsGP := adminV1.Group("/audit-logs") + { + auditLogsGP.Use(userHandler.AuthMiddleware(), userHandler.AdminMiddleware()) + auditLogsGP.GET("", auditLogHandler.Search) + } + // Admin user profile profileGP := adminV1.Group("/profile") { diff --git a/go.mod b/go.mod index f34fcef..d352352 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24 toolchain go1.24.4 require ( + github.com/elastic/go-elasticsearch/v8 v8.17.1 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/labstack/echo/v4 v4.13.4 github.com/microcosm-cc/bluemonday v1.0.27 @@ -28,8 +29,11 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elastic/elastic-transport-go/v8 v8.6.1 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-ini/ini v1.67.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect @@ -48,6 +52,9 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/swaggo/files/v2 v2.0.2 // indirect github.com/tinylib/msgp v1.3.0 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.35.0 // indirect diff --git a/go.sum b/go.sum index 26f27f3..eceec4f 100644 --- a/go.sum +++ b/go.sum @@ -16,10 +16,19 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elastic/elastic-transport-go/v8 v8.6.1 h1:h2jQRqH6eLGiBSN4eZbQnJLtL4bC5b4lfVFRjw2R4e4= +github.com/elastic/elastic-transport-go/v8 v8.6.1/go.mod h1:YLHer5cj0csTzNFXoNQ8qhtGY1GTvSqPnKWKaqQE3Hk= +github.com/elastic/go-elasticsearch/v8 v8.17.1 h1:bOXChDoCMB4TIwwGqKd031U8OXssmWLT3UrAr9EGs3Q= +github.com/elastic/go-elasticsearch/v8 v8.17.1/go.mod h1:MVJCtL+gJJ7x5jFeUmA20O7rvipX8GcQmo5iBcmaJn4= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -108,6 +117,14 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver/v2 v2.5.1 h1:j2U/Qp+wvueSpqitLCSZPT/+ZpVc1xzuwdHWwl7d8ro= go.mongodb.org/mongo-driver/v2 v2.5.1/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= diff --git a/internal/auditlog/entity.go b/internal/auditlog/entity.go new file mode 100644 index 0000000..76dbe99 --- /dev/null +++ b/internal/auditlog/entity.go @@ -0,0 +1,31 @@ +package auditlog + +// Entry represents an audit log record returned by the API. +type Entry struct { + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + At int64 `json:"at"` + IP string `json:"ip,omitempty"` + RequestID string `json:"request_id,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Success bool `json:"success"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ListResponse is the paginated audit log list response. +type ListResponse struct { + Logs []*Entry `json:"logs"` + Meta *ListMeta `json:"meta,omitempty"` +} + +// ListMeta contains pagination metadata. +type ListMeta struct { + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Page int `json:"page"` + Pages int `json:"pages"` +} diff --git a/internal/auditlog/form.go b/internal/auditlog/form.go new file mode 100644 index 0000000..75a5b61 --- /dev/null +++ b/internal/auditlog/form.go @@ -0,0 +1,12 @@ +package auditlog + +// SearchForm defines query parameters for audit log search. +type SearchForm struct { + ActorID string `query:"actor_id" valid:"optional"` + ActorType string `query:"actor_type" valid:"optional,in(admin|customer|system)"` + Action string `query:"action" valid:"optional"` + TargetType string `query:"target_type" valid:"optional"` + TargetID string `query:"target_id" valid:"optional"` + From int64 `query:"from" valid:"optional"` + To int64 `query:"to" valid:"optional"` +} diff --git a/internal/auditlog/handler.go b/internal/auditlog/handler.go new file mode 100644 index 0000000..ba5845a --- /dev/null +++ b/internal/auditlog/handler.go @@ -0,0 +1,69 @@ +package auditlog + +import ( + "tm/pkg/response" + + "github.com/asaskevich/govalidator" + "github.com/labstack/echo/v4" +) + +// Handler handles HTTP requests for audit log operations. +type Handler struct { + service Service +} + +// NewHandler creates an audit log handler. +func NewHandler(service Service) *Handler { + return &Handler{service: service} +} + +// Search returns paginated audit logs with optional filters. +// @Summary Search audit logs +// @Description Retrieve paginated user action audit logs with optional filtering by actor, action, target, and time range +// @Tags Admin-AuditLogs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param actor_id query string false "Filter by actor ID" +// @Param actor_type query string false "Filter by actor type" Enums(admin,customer,system) +// @Param action query string false "Filter by action (e.g. auth.login.success)" +// @Param target_type query string false "Filter by target type" +// @Param target_id query string false "Filter by target ID" +// @Param from query int64 false "Filter by timestamp from (Unix seconds)" +// @Param to query int64 false "Filter by timestamp to (Unix seconds)" +// @Param limit query int false "Number of items per page (default: 10, max: 100)" +// @Param offset query int false "Number of items to skip (default: 0)" +// @Success 200 {object} response.APIResponse{data=ListResponse} "Audit logs retrieved successfully" +// @Failure 400 {object} response.APIResponse "Bad request" +// @Failure 401 {object} response.APIResponse "Unauthorized" +// @Failure 403 {object} response.APIResponse "Forbidden" +// @Failure 500 {object} response.APIResponse "Internal server error" +// @Router /admin/v1/audit-logs [get] +func (h *Handler) Search(c echo.Context) error { + form, err := response.Parse[SearchForm](c) + if err != nil { + return response.ValidationError(c, "Invalid request data", err.Error()) + } + + if _, err := govalidator.ValidateStruct(form); err != nil { + return response.ValidationError(c, "Validation failed", err.Error()) + } + + pagination, err := response.NewPagination(c) + if err != nil { + return response.PaginationBadRequest(c, err) + } + + result, err := h.service.Search(c.Request().Context(), *form, pagination) + if err != nil { + return response.InternalServerError(c, "Failed to retrieve audit logs") + } + + return response.SuccessWithMeta(c, result, &response.Meta{ + Total: int(result.Meta.Total), + Limit: result.Meta.Limit, + Offset: result.Meta.Offset, + Page: result.Meta.Page, + Pages: result.Meta.Pages, + }, "audit logs retrieved successfully") +} diff --git a/internal/auditlog/repository.go b/internal/auditlog/repository.go new file mode 100644 index 0000000..0d1a436 --- /dev/null +++ b/internal/auditlog/repository.go @@ -0,0 +1,25 @@ +package auditlog + +import ( + "context" + + "tm/pkg/audit" +) + +// Repository defines audit log data access. +type Repository interface { + Search(ctx context.Context, filter audit.SearchFilter) (*audit.SearchResult, error) +} + +type repository struct { + store audit.Store +} + +// NewRepository creates an audit log repository. +func NewRepository(store audit.Store) Repository { + return &repository{store: store} +} + +func (r *repository) Search(ctx context.Context, filter audit.SearchFilter) (*audit.SearchResult, error) { + return r.store.Search(ctx, filter) +} diff --git a/internal/auditlog/service.go b/internal/auditlog/service.go new file mode 100644 index 0000000..4fd0266 --- /dev/null +++ b/internal/auditlog/service.go @@ -0,0 +1,88 @@ +package auditlog + +import ( + "context" + "math" + + "tm/pkg/audit" + "tm/pkg/logger" + "tm/pkg/response" +) + +// Service defines audit log query operations. +type Service interface { + Search(ctx context.Context, form SearchForm, pagination *response.Pagination) (*ListResponse, error) +} + +type service struct { + repository Repository + logger logger.Logger +} + +// NewService creates an audit log service. +func NewService(repository Repository, log logger.Logger) Service { + return &service{ + repository: repository, + logger: log, + } +} + +func (s *service) Search(ctx context.Context, form SearchForm, pagination *response.Pagination) (*ListResponse, error) { + filter := audit.SearchFilter{ + ActorID: form.ActorID, + ActorType: form.ActorType, + Action: form.Action, + TargetType: form.TargetType, + TargetID: form.TargetID, + From: form.From, + To: form.To, + Limit: pagination.Limit, + Offset: pagination.Offset, + } + + result, err := s.repository.Search(ctx, filter) + if err != nil { + s.logger.Error("Failed to search audit logs", map[string]interface{}{ + "error": err.Error(), + }) + return nil, err + } + + entries := make([]*Entry, 0, len(result.Items)) + for _, item := range result.Items { + entries = append(entries, &Entry{ + ActorID: item.ActorID, + ActorType: item.ActorType, + Action: item.Action, + TargetType: item.TargetType, + TargetID: item.TargetID, + At: item.At, + IP: item.IP, + RequestID: item.RequestID, + UserAgent: item.UserAgent, + Success: item.Success, + Metadata: item.Metadata, + }) + } + + total := result.TotalCount + pages := 0 + if pagination.Limit > 0 { + pages = int(math.Ceil(float64(total) / float64(pagination.Limit))) + } + page := 1 + if pagination.Limit > 0 { + page = (pagination.Offset / pagination.Limit) + 1 + } + + return &ListResponse{ + Logs: entries, + Meta: &ListMeta{ + Total: total, + Limit: pagination.Limit, + Offset: pagination.Offset, + Page: page, + Pages: pages, + }, + }, nil +} diff --git a/internal/customer/admin_reset_password.go b/internal/customer/admin_reset_password.go index f887e6d..c36f6fa 100644 --- a/internal/customer/admin_reset_password.go +++ b/internal/customer/admin_reset_password.go @@ -88,9 +88,11 @@ func (s *customerService) AdminResetPassword(ctx context.Context, actorID, targe s.auditLogger.Log(ctx, audit.Entry{ ActorID: actorID, + ActorType: audit.ActorTypeAdmin, TargetType: audit.TargetTypeCustomer, TargetID: targetID, Action: audit.ActionPasswordReset, + Success: true, }) s.logger.Info("Customer password reset completed", map[string]interface{}{ diff --git a/internal/customer/middleware.go b/internal/customer/middleware.go index 8e6859f..bd6b78e 100644 --- a/internal/customer/middleware.go +++ b/internal/customer/middleware.go @@ -3,6 +3,7 @@ package customer import ( "net/http" "strings" + "tm/pkg/audit" "tm/pkg/response" "github.com/labstack/echo/v4" @@ -52,6 +53,8 @@ func (h *Handler) AuthMiddleware() echo.MiddlewareFunc { c.Set("company_id", validationResult.Claims.CompanyID) c.Set("access_token", tokenString) + audit.EnrichEchoContext(c, audit.ActorTypeCustomer) + // Continue to next handler return next(c) } diff --git a/internal/customer/service.go b/internal/customer/service.go index 8818064..5d1521d 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -181,6 +181,14 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm "company_ids": Companies, }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionCustomerCreate, + TargetType: audit.TargetTypeCustomer, + TargetID: customer.ID.Hex(), + Success: true, + Metadata: map[string]string{"role": string(customer.Role)}, + }) + go s.notification.SendEmail(context.Background(), notification.Model{ Recipient: customer.Email, Title: "Welcome to Opplens", @@ -198,6 +206,13 @@ func (s *customerService) GetByID(ctx context.Context, id string) (*CustomerResp customer, err := s.repository.GetByID(ctx, id) if err != nil { if err.Error() == "customer not found" { + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionCustomerRead, + TargetType: audit.TargetTypeCustomer, + TargetID: id, + Success: false, + Metadata: map[string]string{"reason": "not_found"}, + }) return nil, errors.New("customer not found") } s.logger.Error("Failed to get customer by ID", map[string]interface{}{ @@ -223,6 +238,13 @@ func (s *customerService) GetByID(ctx context.Context, id string) (*CustomerResp "email": customer.Email, }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionCustomerRead, + TargetType: audit.TargetTypeCustomer, + TargetID: id, + Success: true, + }) + return customer.ToResponse(companies), nil } @@ -249,12 +271,37 @@ func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm, customerResponses = append(customerResponses, customer.ToResponse(companies)) } + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionCustomerList, + Success: true, + Metadata: audit.MergeMetadata( + customerSearchFilterMetadata(form), + audit.PaginationMetadata(pagination.Limit, pagination.Offset, page.TotalCount), + ), + }) + return &CustomerListResponse{ Customers: customerResponses, Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } +func customerSearchFilterMetadata(form *SearchCustomersForm) map[string]string { + if form == nil { + return nil + } + + return audit.MergeMetadata( + audit.FlagMetadata("has_search", form.Search != nil && *form.Search != ""), + audit.FlagMetadata("has_full_name", form.FullName != nil && *form.FullName != ""), + audit.FlagMetadata("has_email_filter", form.Email != nil && *form.Email != ""), + audit.OptionalStringMetadata("status", form.Status), + audit.OptionalStringMetadata("role", form.Role), + audit.OptionalStringMetadata("type", form.Type), + audit.OptionalStringMetadata("company_id", form.CompanyID), + ) +} + func (s *customerService) SearchNotification(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]*CustomerNotificationResponse, error) { page, err := s.repository.Search(ctx, form, pagination) if err != nil { @@ -452,6 +499,13 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus "email": customer.Email, }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionCustomerUpdate, + TargetType: audit.TargetTypeCustomer, + TargetID: id, + Success: true, + }) + responseCompanies, err := s.loadCompaniesForCustomer(ctx, customer) if err != nil { s.logger.Error("Failed to load companies for customer response", map[string]interface{}{ @@ -490,6 +544,13 @@ func (s *customerService) Delete(ctx context.Context, id string) error { "customer_id": id, }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionCustomerDelete, + TargetType: audit.TargetTypeCustomer, + TargetID: id, + Success: true, + }) + return nil } @@ -518,6 +579,14 @@ func (s *customerService) UpdateStatus(ctx context.Context, id string, form *Upd "status": form.Status, }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionCustomerStatusChange, + TargetType: audit.TargetTypeCustomer, + TargetID: id, + Success: true, + Metadata: map[string]string{"status": form.Status}, + }) + go s.notification.SendEmail(context.Background(), notification.Model{ Recipient: customer.Email, Title: "Account Status Updated", @@ -682,6 +751,12 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp s.logger.Warn("Customer login failed - username not found", map[string]interface{}{ "username": strings.ToLower(form.Username), }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionAuthLoginFailure, + ActorType: audit.ActorTypeCustomer, + TargetType: audit.TargetTypeCustomer, + Success: false, + }) return nil, errors.New("invalid credentials") } @@ -692,6 +767,15 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp "customer_id": customer.ID, "status": string(customer.Status), }) + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: customer.ID.Hex(), + ActorType: audit.ActorTypeCustomer, + TargetType: audit.TargetTypeCustomer, + TargetID: customer.ID.Hex(), + Action: audit.ActionAuthLoginFailure, + Success: false, + Metadata: map[string]string{"reason": "inactive"}, + }) return nil, errors.New("account is not active") } @@ -702,6 +786,14 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp "customer_id": customer.ID, "email": customer.Email, }) + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: customer.ID.Hex(), + ActorType: audit.ActorTypeCustomer, + TargetType: audit.TargetTypeCustomer, + TargetID: customer.ID.Hex(), + Action: audit.ActionAuthLoginFailure, + Success: false, + }) return nil, errors.New("invalid credentials") } @@ -747,6 +839,16 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp "customer_type": string(customer.Type), }) + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: customer.ID.Hex(), + ActorType: audit.ActorTypeCustomer, + TargetType: audit.TargetTypeCustomer, + TargetID: customer.ID.Hex(), + Action: audit.ActionAuthLoginSuccess, + Success: true, + Metadata: map[string]string{"role": string(customer.Role)}, + }) + return &AuthResponse{ Customer: customer.ToResponse(companies), AccessToken: tokenPair.AccessToken, @@ -942,6 +1044,15 @@ func (s *customerService) Logout(ctx context.Context, customerID string, accessT "customer_id": customerID, }) + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: customerID, + ActorType: audit.ActorTypeCustomer, + TargetType: audit.TargetTypeCustomer, + TargetID: customerID, + Action: audit.ActionAuthLogout, + Success: true, + }) + return nil } diff --git a/internal/user/middleware.go b/internal/user/middleware.go index 7cd19a3..81ef9b9 100644 --- a/internal/user/middleware.go +++ b/internal/user/middleware.go @@ -3,6 +3,7 @@ package user import ( "net/http" "strings" + "tm/pkg/audit" "tm/pkg/response" "github.com/labstack/echo/v4" @@ -59,6 +60,8 @@ func (h *Handler) AuthMiddleware() echo.MiddlewareFunc { c.Set("company_id", validationResult.Claims.CompanyID) c.Set("access_token", tokenString) + audit.EnrichEchoContext(c, audit.ActorTypeAdmin) + // Continue to next handler return next(c) } diff --git a/internal/user/reset_password.go b/internal/user/reset_password.go index 75d626d..304e3db 100644 --- a/internal/user/reset_password.go +++ b/internal/user/reset_password.go @@ -93,9 +93,11 @@ func (s *userService) AdminResetPassword(ctx context.Context, actorID, actorRole s.auditLogger.Log(ctx, audit.Entry{ ActorID: actorID, + ActorType: audit.ActorTypeAdmin, TargetType: audit.TargetTypeAdmin, TargetID: targetID, Action: audit.ActionPasswordReset, + Success: true, }) s.logger.Info("Admin password reset completed", map[string]interface{}{ diff --git a/internal/user/service.go b/internal/user/service.go index e3d3b92..2092b14 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -135,6 +135,14 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User "role": user.Role, }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionUserCreate, + TargetType: audit.TargetTypeUser, + TargetID: user.ID.Hex(), + Success: true, + Metadata: map[string]string{"role": string(user.Role)}, + }) + go s.notification.SendEmail(context.Background(), notification.Model{ Recipient: form.Email, Title: "Welcome to Opplens", @@ -225,6 +233,13 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor "user_id": id, }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionUserUpdate, + TargetType: audit.TargetTypeUser, + TargetID: id, + Success: true, + }) + return user.ToResponse(), nil } @@ -255,6 +270,14 @@ func (s *userService) UpdateStatus(ctx context.Context, id string, form *UpdateU "status": form.Status, }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionUserStatusChange, + TargetType: audit.TargetTypeUser, + TargetID: id, + Success: true, + Metadata: map[string]string{"status": form.Status}, + }) + go s.notification.SendEmail(context.Background(), notification.Model{ Recipient: user.Email, Title: "Account Status Updated", @@ -272,6 +295,13 @@ func (s *userService) GetUserByID(ctx context.Context, id string) (*UserResponse user, err := s.repository.GetByID(ctx, id) if err != nil { if err.Error() == "user not found" { + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionUserRead, + TargetType: audit.TargetTypeUser, + TargetID: id, + Success: false, + Metadata: map[string]string{"reason": "not_found"}, + }) return nil, errors.New("user not found") } s.logger.Error("Failed to get user by ID", map[string]interface{}{ @@ -281,6 +311,13 @@ func (s *userService) GetUserByID(ctx context.Context, id string) (*UserResponse return nil, err } + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionUserRead, + TargetType: audit.TargetTypeUser, + TargetID: id, + Success: true, + }) + return user.ToResponse(), nil } @@ -317,12 +354,33 @@ func (s *userService) Search(ctx context.Context, form *SearchUsersForm, paginat userResponses = append(userResponses, user.ToResponse()) } + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionUserList, + Success: true, + Metadata: audit.MergeMetadata( + userSearchFilterMetadata(form), + audit.PaginationMetadata(pagination.Limit, pagination.Offset, page.TotalCount), + ), + }) + return &UserListResponse{ Users: userResponses, Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } +func userSearchFilterMetadata(form *SearchUsersForm) map[string]string { + if form == nil { + return nil + } + + return audit.MergeMetadata( + audit.FlagMetadata("has_search", form.Search != nil && *form.Search != ""), + audit.OptionalStringMetadata("status", form.Status), + audit.OptionalStringMetadata("role", form.Role), + ) +} + // Delete deletes a user (hard delete) func (s *userService) Delete(ctx context.Context, id string) error { err := s.repository.Delete(ctx, id) @@ -338,6 +396,13 @@ func (s *userService) Delete(ctx context.Context, id string) error { "user_id": id, }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionUserDelete, + TargetType: audit.TargetTypeUser, + TargetID: id, + Success: true, + }) + return nil } @@ -358,11 +423,26 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse "email_or_username": form.Username, "error": err.Error(), }) + s.auditLogger.Log(ctx, audit.Entry{ + Action: audit.ActionAuthLoginFailure, + ActorType: audit.ActorTypeAdmin, + TargetType: audit.TargetTypeAdmin, + Success: false, + }) return nil, errors.New("invalid credentials") } // Check if user is active if user.Status != UserStatusActive { + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: user.ID.Hex(), + ActorType: audit.ActorTypeAdmin, + TargetType: audit.TargetTypeAdmin, + TargetID: user.ID.Hex(), + Action: audit.ActionAuthLoginFailure, + Success: false, + Metadata: map[string]string{"reason": "inactive"}, + }) return nil, errors.New("account is inactive") } @@ -373,6 +453,14 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse "user_id": user.ID, "email": user.Email, }) + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: user.ID.Hex(), + ActorType: audit.ActorTypeAdmin, + TargetType: audit.TargetTypeAdmin, + TargetID: user.ID.Hex(), + Action: audit.ActionAuthLoginFailure, + Success: false, + }) return nil, errors.New("invalid credentials") } @@ -410,6 +498,16 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse "role": user.Role, }) + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: user.ID.Hex(), + ActorType: audit.ActorTypeAdmin, + TargetType: audit.TargetTypeAdmin, + TargetID: user.ID.Hex(), + Action: audit.ActionAuthLoginSuccess, + Success: true, + Metadata: map[string]string{"role": string(user.Role)}, + }) + return &AuthResponse{ User: user.ToResponse(), AccessToken: tokenPair.AccessToken, @@ -520,6 +618,15 @@ func (s *userService) ChangePassword(ctx context.Context, userID string, form *C "user_id": userID, }) + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: userID, + ActorType: audit.ActorTypeAdmin, + TargetType: audit.TargetTypeAdmin, + TargetID: userID, + Action: audit.ActionAuthPasswordChange, + Success: true, + }) + go s.notification.SendEmail(context.Background(), notification.Model{ Recipient: user.Email, Title: "Password Reset Success", @@ -550,6 +657,15 @@ func (s *userService) ResetPassword(ctx context.Context, form *ResetPasswordForm "email": user.Email, }) + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: user.ID.Hex(), + ActorType: audit.ActorTypeAdmin, + TargetType: audit.TargetTypeAdmin, + TargetID: user.ID.Hex(), + Action: audit.ActionAuthPasswordResetRequest, + Success: true, + }) + return nil } @@ -699,5 +815,14 @@ func (s *userService) Logout(ctx context.Context, userID string, accessToken str "user_id": userID, }) + s.auditLogger.Log(ctx, audit.Entry{ + ActorID: userID, + ActorType: audit.ActorTypeAdmin, + TargetType: audit.TargetTypeAdmin, + TargetID: userID, + Action: audit.ActionAuthLogout, + Success: true, + }) + return nil } diff --git a/pkg/audit/actions.go b/pkg/audit/actions.go new file mode 100644 index 0000000..840affd --- /dev/null +++ b/pkg/audit/actions.go @@ -0,0 +1,37 @@ +package audit + +const ActionPasswordReset = "auth.password.reset" + +const ( + ActorTypeAdmin = "admin" + ActorTypeCustomer = "customer" + ActorTypeSystem = "system" +) + +const ( + TargetTypeAdmin = "admin" + TargetTypeCustomer = "customer" + TargetTypeUser = "user" +) + +const ( + ActionAuthLoginSuccess = "auth.login.success" + ActionAuthLoginFailure = "auth.login.failure" + ActionAuthLogout = "auth.logout" + ActionAuthPasswordChange = "auth.password.change" + ActionAuthPasswordResetRequest = "auth.password.reset.request" + + ActionUserCreate = "user.create" + ActionUserUpdate = "user.update" + ActionUserDelete = "user.delete" + ActionUserStatusChange = "user.status.change" + ActionUserRead = "user.read" + ActionUserList = "user.list" + + ActionCustomerCreate = "customer.create" + ActionCustomerUpdate = "customer.update" + ActionCustomerDelete = "customer.delete" + ActionCustomerStatusChange = "customer.status.change" + ActionCustomerRead = "customer.read" + ActionCustomerList = "customer.list" +) diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index a15083b..77eca9b 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -7,21 +7,19 @@ import ( "tm/pkg/logger" ) -const ActionPasswordReset = "password.reset" - -// TargetType values for audit entries. -const ( - TargetTypeAdmin = "admin" - TargetTypeCustomer = "customer" -) - // Entry represents a security-relevant audit event. type Entry struct { ActorID string + ActorType string TargetType string TargetID string Action string At int64 + IP string + RequestID string + UserAgent string + Success bool + Metadata map[string]string } // Logger records audit events. @@ -33,12 +31,13 @@ type auditLogger struct { logger logger.Logger } -// NewLogger creates an audit logger backed by structured application logs. +// NewLogger creates an audit logger backed by structured application logs only. func NewLogger(log logger.Logger) Logger { return &auditLogger{logger: log} } func (a *auditLogger) Log(ctx context.Context, entry Entry) { + entry = enrichEntry(ctx, entry) if entry.At == 0 { entry.At = time.Now().Unix() } @@ -46,9 +45,50 @@ func (a *auditLogger) Log(ctx context.Context, entry Entry) { a.logger.Info("audit event", map[string]interface{}{ "audit": true, "actor_id": entry.ActorID, + "actor_type": entry.ActorType, "target_type": entry.TargetType, "target_id": entry.TargetID, "action": entry.Action, "at": entry.At, + "ip": entry.IP, + "request_id": entry.RequestID, + "user_agent": entry.UserAgent, + "success": entry.Success, + "metadata": entry.Metadata, }) } + +type compositeLogger struct { + fileLogger Logger + store Store + logger logger.Logger +} + +// NewCompositeLogger writes audit events to structured logs and an optional store. +func NewCompositeLogger(log logger.Logger, store Store) Logger { + if store == nil { + store = NoopStore() + } + return &compositeLogger{ + fileLogger: NewLogger(log), + store: store, + logger: log, + } +} + +func (c *compositeLogger) Log(ctx context.Context, entry Entry) { + entry = enrichEntry(ctx, entry) + if entry.At == 0 { + entry.At = time.Now().Unix() + } + + c.fileLogger.Log(ctx, entry) + + doc := entryToDocument(entry) + if err := c.store.Save(ctx, doc); err != nil { + c.logger.Error("Failed to persist audit event", map[string]interface{}{ + "error": err.Error(), + "action": entry.Action, + }) + } +} diff --git a/pkg/audit/context.go b/pkg/audit/context.go new file mode 100644 index 0000000..3c969e3 --- /dev/null +++ b/pkg/audit/context.go @@ -0,0 +1,73 @@ +package audit + +import ( + "context" + "time" +) + +type contextKey int + +const ( + keyRequestMeta contextKey = iota + keyActor +) + +type RequestMeta struct { + IP string + RequestID string + UserAgent string +} + +type Actor struct { + ID string + Type string +} + +func WithRequestMeta(ctx context.Context, meta RequestMeta) context.Context { + return context.WithValue(ctx, keyRequestMeta, meta) +} + +func WithActor(ctx context.Context, actor Actor) context.Context { + return context.WithValue(ctx, keyActor, actor) +} + +func RequestMetaFromContext(ctx context.Context) (RequestMeta, bool) { + meta, ok := ctx.Value(keyRequestMeta).(RequestMeta) + return meta, ok +} + +func ActorFromContext(ctx context.Context) (Actor, bool) { + actor, ok := ctx.Value(keyActor).(Actor) + return actor, ok +} + +func enrichEntry(ctx context.Context, entry Entry) Entry { + if entry.At == 0 { + entry.At = time.Now().Unix() + } + + if meta, ok := RequestMetaFromContext(ctx); ok { + if entry.IP == "" { + entry.IP = meta.IP + } + if entry.RequestID == "" { + entry.RequestID = meta.RequestID + } + if entry.UserAgent == "" { + entry.UserAgent = meta.UserAgent + } + } + + if entry.ActorID == "" || entry.ActorType == "" { + if actor, ok := ActorFromContext(ctx); ok { + if entry.ActorID == "" { + entry.ActorID = actor.ID + } + if entry.ActorType == "" { + entry.ActorType = actor.Type + } + } + } + + return entry +} diff --git a/pkg/audit/context_test.go b/pkg/audit/context_test.go new file mode 100644 index 0000000..eb5c056 --- /dev/null +++ b/pkg/audit/context_test.go @@ -0,0 +1,35 @@ +package audit + +import ( + "context" + "testing" +) + +func TestEnrichEntryFromContext(t *testing.T) { + ctx := WithRequestMeta(context.Background(), RequestMeta{ + IP: "127.0.0.1", + RequestID: "req-1", + UserAgent: "test-agent", + }) + ctx = WithActor(ctx, Actor{ID: "actor-1", Type: ActorTypeAdmin}) + + entry := enrichEntry(ctx, Entry{ + Action: ActionAuthLoginSuccess, + TargetType: TargetTypeAdmin, + TargetID: "actor-1", + Success: true, + }) + + if entry.ActorID != "actor-1" { + t.Fatalf("expected actor_id actor-1, got %s", entry.ActorID) + } + if entry.IP != "127.0.0.1" { + t.Fatalf("expected ip 127.0.0.1, got %s", entry.IP) + } + if entry.RequestID != "req-1" { + t.Fatalf("expected request_id req-1, got %s", entry.RequestID) + } + if entry.At == 0 { + t.Fatal("expected timestamp to be set") + } +} diff --git a/pkg/audit/metadata.go b/pkg/audit/metadata.go new file mode 100644 index 0000000..b1a522c --- /dev/null +++ b/pkg/audit/metadata.go @@ -0,0 +1,52 @@ +package audit + +import "strconv" + +// MergeMetadata combines multiple metadata maps into one. +func MergeMetadata(parts ...map[string]string) map[string]string { + merged := make(map[string]string) + for _, part := range parts { + for key, value := range part { + if value != "" { + merged[key] = value + } + } + } + if len(merged) == 0 { + return nil + } + return merged +} + +// FlagMetadata returns a single boolean flag metadata entry when condition is true. +func FlagMetadata(key string, condition bool) map[string]string { + if !condition { + return nil + } + return map[string]string{key: "true"} +} + +// StringMetadata returns metadata for a non-empty string value. +func StringMetadata(key, value string) map[string]string { + if value == "" { + return nil + } + return map[string]string{key: value} +} + +// OptionalStringMetadata returns metadata when a pointer string is set and non-empty. +func OptionalStringMetadata(key string, value *string) map[string]string { + if value == nil || *value == "" { + return nil + } + return map[string]string{key: *value} +} + +// PaginationMetadata returns safe pagination metadata for list audit events. +func PaginationMetadata(limit, offset int, totalCount int64) map[string]string { + return map[string]string{ + "limit": strconv.Itoa(limit), + "offset": strconv.Itoa(offset), + "result_count": strconv.FormatInt(totalCount, 10), + } +} diff --git a/pkg/audit/metadata_test.go b/pkg/audit/metadata_test.go new file mode 100644 index 0000000..495b3f0 --- /dev/null +++ b/pkg/audit/metadata_test.go @@ -0,0 +1,28 @@ +package audit + +import ( + "testing" +) + +func TestMergeMetadata(t *testing.T) { + merged := MergeMetadata( + map[string]string{"status": "active"}, + map[string]string{"role": "admin"}, + nil, + ) + + if merged["status"] != "active" || merged["role"] != "admin" { + t.Fatalf("unexpected merged metadata: %#v", merged) + } +} + +func TestFlagMetadata(t *testing.T) { + if FlagMetadata("has_search", false) != nil { + t.Fatal("expected nil metadata for false flag") + } + + meta := FlagMetadata("has_search", true) + if meta["has_search"] != "true" { + t.Fatalf("unexpected flag metadata: %#v", meta) + } +} diff --git a/pkg/audit/middleware.go b/pkg/audit/middleware.go new file mode 100644 index 0000000..a50b9f0 --- /dev/null +++ b/pkg/audit/middleware.go @@ -0,0 +1,47 @@ +package audit + +import ( + "github.com/labstack/echo/v4" +) + +// RequestMetaMiddleware injects request metadata into the request context for audit logging. +func RequestMetaMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + requestID := c.Response().Header().Get(echo.HeaderXRequestID) + if requestID == "" { + requestID = c.Request().Header.Get(echo.HeaderXRequestID) + } + + ctx := WithRequestMeta(c.Request().Context(), RequestMeta{ + IP: c.RealIP(), + RequestID: requestID, + UserAgent: c.Request().UserAgent(), + }) + c.SetRequest(c.Request().WithContext(ctx)) + + return next(c) + } + } +} + +// EnrichEchoContext copies Echo actor values into the Go context when present. +func EnrichEchoContext(c echo.Context, actorType string) { + actorID := "" + if actorType == ActorTypeAdmin { + if id, ok := c.Get("user_id").(string); ok { + actorID = id + } + } + if actorType == ActorTypeCustomer { + if id, ok := c.Get("customer_id").(string); ok { + actorID = id + } + } + + ctx := c.Request().Context() + if actorID != "" { + ctx = WithActor(ctx, Actor{ID: actorID, Type: actorType}) + } + c.SetRequest(c.Request().WithContext(ctx)) +} diff --git a/pkg/audit/store.go b/pkg/audit/store.go new file mode 100644 index 0000000..396b0ac --- /dev/null +++ b/pkg/audit/store.go @@ -0,0 +1,76 @@ +package audit + +import "context" + +// Document is the persisted audit log shape stored in Elasticsearch. +type Document struct { + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + At int64 `json:"at"` + IP string `json:"ip,omitempty"` + RequestID string `json:"request_id,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Success bool `json:"success"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// SearchFilter defines query filters for audit log search. +type SearchFilter struct { + ActorID string + ActorType string + Action string + TargetType string + TargetID string + From int64 + To int64 + Limit int + Offset int +} + +// SearchResult holds paginated audit log search results. +type SearchResult struct { + Items []Document + TotalCount int64 +} + +// Store persists and queries audit documents. +type Store interface { + Save(ctx context.Context, doc Document) error + Search(ctx context.Context, filter SearchFilter) (*SearchResult, error) + Close() error +} + +// noopStore discards audit documents when Elasticsearch is disabled. +type noopStore struct{} + +func (noopStore) Save(context.Context, Document) error { return nil } + +func (noopStore) Search(context.Context, SearchFilter) (*SearchResult, error) { + return &SearchResult{Items: []Document{}}, nil +} + +func (noopStore) Close() error { return nil } + +// NoopStore returns a store that performs no persistence. +func NoopStore() Store { + return noopStore{} +} + +func entryToDocument(entry Entry) Document { + return Document{ + ActorID: entry.ActorID, + ActorType: entry.ActorType, + Action: entry.Action, + TargetType: entry.TargetType, + TargetID: entry.TargetID, + At: entry.At, + IP: entry.IP, + RequestID: entry.RequestID, + UserAgent: entry.UserAgent, + Success: entry.Success, + Metadata: entry.Metadata, + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ec1ae44..45341e2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -74,6 +74,17 @@ type NotificationConfig struct { UserAgent string `env:"NOTIFICATION_USER_AGENT"` } +type ElasticsearchConfig struct { + URL string `env:"ELASTICSEARCH_URL"` + Username string `env:"ELASTICSEARCH_USERNAME"` + Password string `env:"ELASTICSEARCH_PASSWORD"` + IndexPrefix string `env:"ELASTICSEARCH_INDEX_PREFIX"` + Enabled bool `env:"ELASTICSEARCH_ENABLED"` + RequestTimeout time.Duration `env:"ELASTICSEARCH_REQUEST_TIMEOUT"` + BulkFlushPeriod time.Duration `env:"ELASTICSEARCH_BULK_FLUSH_PERIOD"` + QueueSize int `env:"ELASTICSEARCH_QUEUE_SIZE"` +} + // OllamaConfig represents the configuration for the Ollama SDK type OllamaConfig struct { BaseURL string `env:"OLLAMA_BASE_URL" default:"http://localhost:11434"` diff --git a/pkg/elasticsearch/audit_store.go b/pkg/elasticsearch/audit_store.go new file mode 100644 index 0000000..b14ab0c --- /dev/null +++ b/pkg/elasticsearch/audit_store.go @@ -0,0 +1,48 @@ +package elasticsearch + +import ( + "context" + + "tm/pkg/audit" +) + +// AuditStore persists audit documents to Elasticsearch. +type AuditStore struct { + client Client +} + +// NewAuditStore creates an audit store backed by Elasticsearch. +func NewAuditStore(client Client) audit.Store { + if client == nil { + return audit.NoopStore() + } + return &AuditStore{client: client} +} + +func (s *AuditStore) Save(ctx context.Context, doc audit.Document) error { + return s.client.Index(ctx, AuditDocument(doc)) +} + +func (s *AuditStore) Search(ctx context.Context, filter audit.SearchFilter) (*audit.SearchResult, error) { + result, err := s.client.Search(ctx, SearchFilter(filter)) + if err != nil { + return nil, err + } + + items := make([]audit.Document, 0, len(result.Items)) + for _, item := range result.Items { + items = append(items, audit.Document(item)) + } + + return &audit.SearchResult{ + Items: items, + TotalCount: result.TotalCount, + }, nil +} + +func (s *AuditStore) Close() error { + if s.client == nil { + return nil + } + return s.client.Close() +} diff --git a/pkg/elasticsearch/client.go b/pkg/elasticsearch/client.go new file mode 100644 index 0000000..e55990e --- /dev/null +++ b/pkg/elasticsearch/client.go @@ -0,0 +1,333 @@ +package elasticsearch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/elastic/go-elasticsearch/v8" + "tm/pkg/logger" +) + +type client struct { + es *elasticsearch.Client + config Config + logger logger.Logger + queue chan AuditDocument + done chan struct{} + wg sync.WaitGroup +} + +// NewClient creates an Elasticsearch client for audit log indexing and search. +func NewClient(config Config, log logger.Logger) (Client, error) { + if !config.Enabled || strings.TrimSpace(config.URL) == "" { + return NewNoopClient(), nil + } + + if config.RequestTimeout == 0 { + config.RequestTimeout = 10 * time.Second + } + if config.BulkFlushPeriod == 0 { + config.BulkFlushPeriod = 5 * time.Second + } + if config.QueueSize == 0 { + config.QueueSize = 1000 + } + if config.IndexPrefix == "" { + config.IndexPrefix = "tm-audit-logs" + } + + esCfg := elasticsearch.Config{ + Addresses: []string{config.URL}, + Username: config.Username, + Password: config.Password, + } + + es, err := elasticsearch.NewClient(esCfg) + if err != nil { + return nil, fmt.Errorf("create elasticsearch client: %w", err) + } + + c := &client{ + es: es, + config: config, + logger: log, + queue: make(chan AuditDocument, config.QueueSize), + done: make(chan struct{}), + } + + ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout) + defer cancel() + if err := c.ping(ctx); err != nil { + return nil, err + } + + c.wg.Add(1) + go c.runBulkWorker() + + log.Info("Successfully connected to Elasticsearch", map[string]interface{}{ + "url": config.URL, + "index_prefix": config.IndexPrefix, + }) + + return c, nil +} + +func (c *client) ping(ctx context.Context) error { + res, err := c.es.Ping(c.es.Ping.WithContext(ctx)) + if err != nil { + return fmt.Errorf("ping elasticsearch: %w", err) + } + defer res.Body.Close() + + if res.IsError() { + return fmt.Errorf("elasticsearch ping failed: %s", readErrorBody(res.Body)) + } + + return nil +} + +func (c *client) Index(_ context.Context, doc AuditDocument) error { + select { + case c.queue <- doc: + return nil + default: + c.logger.Warn("Audit log queue full, dropping entry", map[string]interface{}{ + "action": doc.Action, + }) + return nil + } +} + +func (c *client) Search(ctx context.Context, filter SearchFilter) (*SearchResult, error) { + indexPattern := c.config.IndexPrefix + "-*" + query := buildSearchQuery(filter) + + payload, err := json.Marshal(query) + if err != nil { + return nil, fmt.Errorf("marshal search query: %w", err) + } + + res, err := c.es.Search( + c.es.Search.WithContext(ctx), + c.es.Search.WithIndex(indexPattern), + c.es.Search.WithBody(bytes.NewReader(payload)), + c.es.Search.WithTrackTotalHits(true), + ) + if err != nil { + return nil, fmt.Errorf("search audit logs: %w", err) + } + defer res.Body.Close() + + if res.IsError() { + return nil, fmt.Errorf("elasticsearch search error: %s", readErrorBody(res.Body)) + } + + return decodeSearchResponse(res.Body) +} + +func (c *client) Close() error { + close(c.done) + c.wg.Wait() + return nil +} + +func (c *client) runBulkWorker() { + defer c.wg.Done() + + ticker := time.NewTicker(c.config.BulkFlushPeriod) + defer ticker.Stop() + + batch := make([]AuditDocument, 0, 64) + + flush := func() { + if len(batch) == 0 { + return + } + if err := c.flushBatch(batch); err != nil { + c.logger.Error("Failed to flush audit logs to Elasticsearch", map[string]interface{}{ + "error": err.Error(), + "count": len(batch), + }) + } + batch = batch[:0] + } + + for { + select { + case <-c.done: + for { + select { + case doc := <-c.queue: + batch = append(batch, doc) + default: + flush() + return + } + } + case doc := <-c.queue: + batch = append(batch, doc) + if len(batch) >= 64 { + flush() + } + case <-ticker.C: + flush() + } + } +} + +func (c *client) flushBatch(batch []AuditDocument) error { + if len(batch) == 0 { + return nil + } + + var buf bytes.Buffer + for _, doc := range batch { + indexName := indexNameFor(c.config.IndexPrefix, doc.At) + meta, err := marshalBulkIndexAction(indexName) + if err != nil { + return fmt.Errorf("marshal bulk index action: %w", err) + } + buf.Write(meta) + buf.WriteByte('\n') + + body, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("marshal audit document: %w", err) + } + buf.Write(body) + buf.WriteByte('\n') + } + + ctx, cancel := context.WithTimeout(context.Background(), c.config.RequestTimeout) + defer cancel() + + res, err := c.es.Bulk(bytes.NewReader(buf.Bytes()), c.es.Bulk.WithContext(ctx)) + if err != nil { + return fmt.Errorf("bulk index audit logs: %w", err) + } + defer res.Body.Close() + + if res.IsError() { + return fmt.Errorf("elasticsearch bulk error: %s", readErrorBody(res.Body)) + } + + return nil +} + +type bulkIndexAction struct { + Index struct { + Index string `json:"_index"` + } `json:"index"` +} + +func marshalBulkIndexAction(indexName string) ([]byte, error) { + action := bulkIndexAction{} + action.Index.Index = indexName + return json.Marshal(action) +} + +// readErrorBody best-effort reads an Elasticsearch error response for diagnostics. +// Read errors are intentionally ignored because the HTTP call already failed. +func readErrorBody(body io.Reader) string { + responseBody, _ := io.ReadAll(body) + return string(responseBody) +} + +func indexNameFor(prefix string, at int64) string { + t := time.Unix(at, 0).UTC() + return fmt.Sprintf("%s-%04d.%02d", prefix, t.Year(), int(t.Month())) +} + +func buildSearchQuery(filter SearchFilter) map[string]interface{} { + must := make([]map[string]interface{}, 0) + filterClauses := make([]map[string]interface{}, 0) + + addTerm := func(field, value string) { + if value == "" { + return + } + filterClauses = append(filterClauses, map[string]interface{}{ + "term": map[string]interface{}{field: value}, + }) + } + + addTerm("actor_id", filter.ActorID) + addTerm("actor_type", filter.ActorType) + addTerm("action", filter.Action) + addTerm("target_type", filter.TargetType) + addTerm("target_id", filter.TargetID) + + if filter.From > 0 || filter.To > 0 { + rangeQuery := map[string]interface{}{} + if filter.From > 0 { + rangeQuery["gte"] = filter.From + } + if filter.To > 0 { + rangeQuery["lte"] = filter.To + } + filterClauses = append(filterClauses, map[string]interface{}{ + "range": map[string]interface{}{"at": rangeQuery}, + }) + } + + boolQuery := map[string]interface{}{} + if len(must) > 0 { + boolQuery["must"] = must + } + if len(filterClauses) > 0 { + boolQuery["filter"] = filterClauses + } + + limit := filter.Limit + if limit <= 0 { + limit = 20 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + + return map[string]interface{}{ + "query": map[string]interface{}{ + "bool": boolQuery, + }, + "sort": []map[string]interface{}{ + {"at": map[string]interface{}{"order": "desc"}}, + }, + "from": offset, + "size": limit, + } +} + +func decodeSearchResponse(body io.Reader) (*SearchResult, error) { + var parsed struct { + Hits struct { + Total struct { + Value int64 `json:"value"` + } `json:"total"` + Hits []struct { + Source AuditDocument `json:"_source"` + } `json:"hits"` + } `json:"hits"` + } + + if err := json.NewDecoder(body).Decode(&parsed); err != nil { + return nil, fmt.Errorf("decode search response: %w", err) + } + + items := make([]AuditDocument, 0, len(parsed.Hits.Hits)) + for _, hit := range parsed.Hits.Hits { + items = append(items, hit.Source) + } + + return &SearchResult{ + Items: items, + TotalCount: parsed.Hits.Total.Value, + }, nil +} diff --git a/pkg/elasticsearch/noop.go b/pkg/elasticsearch/noop.go new file mode 100644 index 0000000..1bda218 --- /dev/null +++ b/pkg/elasticsearch/noop.go @@ -0,0 +1,18 @@ +package elasticsearch + +import "context" + +type noopClient struct{} + +// NewNoopClient returns a client that performs no Elasticsearch operations. +func NewNoopClient() Client { + return noopClient{} +} + +func (noopClient) Index(context.Context, AuditDocument) error { return nil } + +func (noopClient) Search(context.Context, SearchFilter) (*SearchResult, error) { + return &SearchResult{Items: []AuditDocument{}}, nil +} + +func (noopClient) Close() error { return nil } diff --git a/pkg/elasticsearch/types.go b/pkg/elasticsearch/types.go new file mode 100644 index 0000000..fe316b3 --- /dev/null +++ b/pkg/elasticsearch/types.go @@ -0,0 +1,60 @@ +package elasticsearch + +import ( + "context" + "time" +) + +// Config holds Elasticsearch connection settings. +type Config struct { + URL string + Username string + Password string + IndexPrefix string + Enabled bool + RequestTimeout time.Duration + BulkFlushBytes int + BulkFlushPeriod time.Duration + QueueSize int +} + +// AuditDocument is the indexed audit log document. +type AuditDocument struct { + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + At int64 `json:"at"` + IP string `json:"ip,omitempty"` + RequestID string `json:"request_id,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Success bool `json:"success"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// SearchFilter defines audit log search parameters. +type SearchFilter struct { + ActorID string + ActorType string + Action string + TargetType string + TargetID string + From int64 + To int64 + Limit int + Offset int +} + +// SearchResult holds paginated search output. +type SearchResult struct { + Items []AuditDocument + TotalCount int64 +} + +// Client indexes and searches audit documents. +type Client interface { + Index(ctx context.Context, doc AuditDocument) error + Search(ctx context.Context, filter SearchFilter) (*SearchResult, error) + Close() error +}