Merge pull request 'Implement audit logging functionality across services and middleware' (#46) from audit-log into develop
Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/46
This commit is contained in:
@@ -4,8 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
ai_summarizer "tm/pkg/ai_summarizer"
|
ai_summarizer "tm/pkg/ai_summarizer"
|
||||||
|
"tm/pkg/audit"
|
||||||
"tm/pkg/authorization"
|
"tm/pkg/authorization"
|
||||||
"tm/pkg/config"
|
"tm/pkg/config"
|
||||||
|
"tm/pkg/elasticsearch"
|
||||||
"tm/pkg/filestore"
|
"tm/pkg/filestore"
|
||||||
"tm/pkg/gorules"
|
"tm/pkg/gorules"
|
||||||
"tm/pkg/hcaptcha"
|
"tm/pkg/hcaptcha"
|
||||||
@@ -90,6 +92,7 @@ func InitHTTPServer(conf Config, log logger.Logger) *echo.Echo {
|
|||||||
// Add middleware
|
// Add middleware
|
||||||
e.Use(middleware.Recover())
|
e.Use(middleware.Recover())
|
||||||
e.Use(middleware.RequestID())
|
e.Use(middleware.RequestID())
|
||||||
|
e.Use(audit.RequestMetaMiddleware())
|
||||||
e.Use(middleware.Logger())
|
e.Use(middleware.Logger())
|
||||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
AllowOrigins: []string{"*"},
|
AllowOrigins: []string{"*"},
|
||||||
@@ -132,7 +135,7 @@ func InitHTTPServer(conf Config, log logger.Logger) *echo.Echo {
|
|||||||
e.GET("/swagger*", echoSwagger.WrapHandler)
|
e.GET("/swagger*", echoSwagger.WrapHandler)
|
||||||
|
|
||||||
log.Info("HTTP server initialized successfully", map[string]interface{}{
|
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
|
return e
|
||||||
@@ -402,3 +405,34 @@ func InitGoRulesClient(_ GoRulesConfig, log logger.Logger) gorules.Client {
|
|||||||
return 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Config struct {
|
|||||||
DocumentScraper DocumentScraperConfig
|
DocumentScraper DocumentScraperConfig
|
||||||
AISummarizer AISummarizerConfig
|
AISummarizer AISummarizerConfig
|
||||||
GoRules GoRulesConfig
|
GoRules GoRulesConfig
|
||||||
|
Elasticsearch config.ElasticsearchConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScraperConfig struct {
|
type ScraperConfig struct {
|
||||||
|
|||||||
+29
-6
@@ -62,6 +62,9 @@ package main
|
|||||||
// @tag.name Admin-AI-Pipeline
|
// @tag.name Admin-AI-Pipeline
|
||||||
// @tag.description Administrative Opplens AI pipeline operations including scrape, summarize, translate, sync, and background pipeline runs
|
// @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.name Authorization
|
||||||
// @tag.description Customer authentication and authorization operations for mobile application including login, logout, token refresh, and profile access
|
// @tag.description Customer authentication and authorization operations for mobile application including login, logout, token refresh, and profile access
|
||||||
|
|
||||||
@@ -100,8 +103,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
"tm/internal/assets"
|
|
||||||
"tm/internal/ai_pipeline"
|
"tm/internal/ai_pipeline"
|
||||||
|
"tm/internal/assets"
|
||||||
|
"tm/internal/auditlog"
|
||||||
"tm/internal/cms"
|
"tm/internal/cms"
|
||||||
"tm/internal/company"
|
"tm/internal/company"
|
||||||
"tm/internal/company_category"
|
"tm/internal/company_category"
|
||||||
@@ -116,7 +120,6 @@ import (
|
|||||||
"tm/internal/tender"
|
"tm/internal/tender"
|
||||||
"tm/internal/tender_approval"
|
"tm/internal/tender_approval"
|
||||||
"tm/internal/user"
|
"tm/internal/user"
|
||||||
"tm/pkg/audit"
|
|
||||||
"tm/pkg/filestore"
|
"tm/pkg/filestore"
|
||||||
"tm/pkg/security"
|
"tm/pkg/security"
|
||||||
|
|
||||||
@@ -217,7 +220,24 @@ func main() {
|
|||||||
_ = cms.NewValidationService() // Register mediaRef validator
|
_ = cms.NewValidationService() // Register mediaRef validator
|
||||||
|
|
||||||
// Initialize services with repositories
|
// 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)
|
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
|
||||||
categoryService := company_category.NewService(categoryRepository, logger)
|
categoryService := company_category.NewService(categoryRepository, logger)
|
||||||
companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, redisClient, conf.AISummarizer.RecommendationCacheTTL, 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)
|
dashboardRepository := dashboard.NewRepository(mongoManager, logger, procedureDocumentsLister)
|
||||||
dashboardService := dashboard.NewService(dashboardRepository, logger)
|
dashboardService := dashboard.NewService(dashboardRepository, logger)
|
||||||
aiPipelineService := ai_pipeline.NewService(aiPipelineClient, logger, tenderService)
|
aiPipelineService := ai_pipeline.NewService(aiPipelineClient, logger, tenderService)
|
||||||
|
auditLogRepository := auditlog.NewRepository(auditStore)
|
||||||
|
auditLogService := auditlog.NewService(auditLogRepository, logger)
|
||||||
logger.Info("Services initialized successfully", map[string]interface{}{
|
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
|
// Initialize handlers with services
|
||||||
@@ -257,8 +279,9 @@ func main() {
|
|||||||
documentScraperHandler := document_scraper.NewHandler(documentScraperService, logger)
|
documentScraperHandler := document_scraper.NewHandler(documentScraperService, logger)
|
||||||
dashboardHandler := dashboard.NewHandler(dashboardService)
|
dashboardHandler := dashboard.NewHandler(dashboardService)
|
||||||
aiPipelineHandler := ai_pipeline.NewHandler(aiPipelineService)
|
aiPipelineHandler := ai_pipeline.NewHandler(aiPipelineService)
|
||||||
|
auditLogHandler := auditlog.NewHandler(auditLogService)
|
||||||
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
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
|
// Initialize HTTP server
|
||||||
@@ -267,7 +290,7 @@ func main() {
|
|||||||
router.SetupCSPSecurity(e)
|
router.SetupCSPSecurity(e)
|
||||||
|
|
||||||
// Register routes
|
// 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.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy)
|
||||||
router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey)
|
router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"tm/internal/assets"
|
"tm/internal/assets"
|
||||||
"tm/internal/ai_pipeline"
|
"tm/internal/ai_pipeline"
|
||||||
|
"tm/internal/auditlog"
|
||||||
"tm/internal/cms"
|
"tm/internal/cms"
|
||||||
"tm/internal/company"
|
"tm/internal/company"
|
||||||
"tm/internal/company_category"
|
"tm/internal/company_category"
|
||||||
@@ -35,7 +36,7 @@ func SetupCSPSecurity(e *echo.Echo) {
|
|||||||
e.POST("/api/v1/csp-report", security.CSPReportHandler)
|
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")
|
adminV1 := e.Group("/admin/v1")
|
||||||
|
|
||||||
// Admin Users Routes
|
// 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())
|
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
|
// Admin user profile
|
||||||
profileGP := adminV1.Group("/profile")
|
profileGP := adminV1.Group("/profile")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.24
|
|||||||
toolchain go1.24.4
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/elastic/go-elasticsearch/v8 v8.17.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/labstack/echo/v4 v4.13.4
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/ghodss/yaml v1.0.0 // indirect
|
||||||
github.com/go-ini/ini v1.67.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/jsonpointer v0.21.1 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||||
github.com/go-openapi/spec 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/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||||
github.com/tinylib/msgp v1.3.0 // 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/mod v0.26.0 // indirect
|
||||||
golang.org/x/time v0.11.0 // indirect
|
golang.org/x/time v0.11.0 // indirect
|
||||||
golang.org/x/tools v0.35.0 // indirect
|
golang.org/x/tools v0.35.0 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
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 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
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 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
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=
|
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 h1:j2U/Qp+wvueSpqitLCSZPT/+ZpVc1xzuwdHWwl7d8ro=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.1/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
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 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -88,9 +88,11 @@ func (s *customerService) AdminResetPassword(ctx context.Context, actorID, targe
|
|||||||
|
|
||||||
s.auditLogger.Log(ctx, audit.Entry{
|
s.auditLogger.Log(ctx, audit.Entry{
|
||||||
ActorID: actorID,
|
ActorID: actorID,
|
||||||
|
ActorType: audit.ActorTypeAdmin,
|
||||||
TargetType: audit.TargetTypeCustomer,
|
TargetType: audit.TargetTypeCustomer,
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
Action: audit.ActionPasswordReset,
|
Action: audit.ActionPasswordReset,
|
||||||
|
Success: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
s.logger.Info("Customer password reset completed", map[string]interface{}{
|
s.logger.Info("Customer password reset completed", map[string]interface{}{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package customer
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"tm/pkg/audit"
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@@ -52,6 +53,8 @@ func (h *Handler) AuthMiddleware() echo.MiddlewareFunc {
|
|||||||
c.Set("company_id", validationResult.Claims.CompanyID)
|
c.Set("company_id", validationResult.Claims.CompanyID)
|
||||||
c.Set("access_token", tokenString)
|
c.Set("access_token", tokenString)
|
||||||
|
|
||||||
|
audit.EnrichEchoContext(c, audit.ActorTypeCustomer)
|
||||||
|
|
||||||
// Continue to next handler
|
// Continue to next handler
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,14 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm
|
|||||||
"company_ids": Companies,
|
"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{
|
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||||
Recipient: customer.Email,
|
Recipient: customer.Email,
|
||||||
Title: "Welcome to Opplens",
|
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)
|
customer, err := s.repository.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "customer not found" {
|
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")
|
return nil, errors.New("customer not found")
|
||||||
}
|
}
|
||||||
s.logger.Error("Failed to get customer by ID", map[string]interface{}{
|
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,
|
"email": customer.Email,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
s.auditLogger.Log(ctx, audit.Entry{
|
||||||
|
Action: audit.ActionCustomerRead,
|
||||||
|
TargetType: audit.TargetTypeCustomer,
|
||||||
|
TargetID: id,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
return customer.ToResponse(companies), nil
|
return customer.ToResponse(companies), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,12 +271,37 @@ func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm,
|
|||||||
customerResponses = append(customerResponses, customer.ToResponse(companies))
|
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{
|
return &CustomerListResponse{
|
||||||
Customers: customerResponses,
|
Customers: customerResponses,
|
||||||
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
||||||
}, nil
|
}, 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) {
|
func (s *customerService) SearchNotification(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]*CustomerNotificationResponse, error) {
|
||||||
page, err := s.repository.Search(ctx, form, pagination)
|
page, err := s.repository.Search(ctx, form, pagination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -452,6 +499,13 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus
|
|||||||
"email": customer.Email,
|
"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)
|
responseCompanies, err := s.loadCompaniesForCustomer(ctx, customer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to load companies for customer response", map[string]interface{}{
|
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,
|
"customer_id": id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
s.auditLogger.Log(ctx, audit.Entry{
|
||||||
|
Action: audit.ActionCustomerDelete,
|
||||||
|
TargetType: audit.TargetTypeCustomer,
|
||||||
|
TargetID: id,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,6 +579,14 @@ func (s *customerService) UpdateStatus(ctx context.Context, id string, form *Upd
|
|||||||
"status": form.Status,
|
"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{
|
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||||
Recipient: customer.Email,
|
Recipient: customer.Email,
|
||||||
Title: "Account Status Updated",
|
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{}{
|
s.logger.Warn("Customer login failed - username not found", map[string]interface{}{
|
||||||
"username": strings.ToLower(form.Username),
|
"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")
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,6 +767,15 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp
|
|||||||
"customer_id": customer.ID,
|
"customer_id": customer.ID,
|
||||||
"status": string(customer.Status),
|
"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")
|
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,
|
"customer_id": customer.ID,
|
||||||
"email": customer.Email,
|
"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")
|
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),
|
"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{
|
return &AuthResponse{
|
||||||
Customer: customer.ToResponse(companies),
|
Customer: customer.ToResponse(companies),
|
||||||
AccessToken: tokenPair.AccessToken,
|
AccessToken: tokenPair.AccessToken,
|
||||||
@@ -942,6 +1044,15 @@ func (s *customerService) Logout(ctx context.Context, customerID string, accessT
|
|||||||
"customer_id": customerID,
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package user
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"tm/pkg/audit"
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@@ -59,6 +60,8 @@ func (h *Handler) AuthMiddleware() echo.MiddlewareFunc {
|
|||||||
c.Set("company_id", validationResult.Claims.CompanyID)
|
c.Set("company_id", validationResult.Claims.CompanyID)
|
||||||
c.Set("access_token", tokenString)
|
c.Set("access_token", tokenString)
|
||||||
|
|
||||||
|
audit.EnrichEchoContext(c, audit.ActorTypeAdmin)
|
||||||
|
|
||||||
// Continue to next handler
|
// Continue to next handler
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,9 +93,11 @@ func (s *userService) AdminResetPassword(ctx context.Context, actorID, actorRole
|
|||||||
|
|
||||||
s.auditLogger.Log(ctx, audit.Entry{
|
s.auditLogger.Log(ctx, audit.Entry{
|
||||||
ActorID: actorID,
|
ActorID: actorID,
|
||||||
|
ActorType: audit.ActorTypeAdmin,
|
||||||
TargetType: audit.TargetTypeAdmin,
|
TargetType: audit.TargetTypeAdmin,
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
Action: audit.ActionPasswordReset,
|
Action: audit.ActionPasswordReset,
|
||||||
|
Success: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
s.logger.Info("Admin password reset completed", map[string]interface{}{
|
s.logger.Info("Admin password reset completed", map[string]interface{}{
|
||||||
|
|||||||
@@ -135,6 +135,14 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User
|
|||||||
"role": user.Role,
|
"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{
|
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||||
Recipient: form.Email,
|
Recipient: form.Email,
|
||||||
Title: "Welcome to Opplens",
|
Title: "Welcome to Opplens",
|
||||||
@@ -225,6 +233,13 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor
|
|||||||
"user_id": id,
|
"user_id": id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
s.auditLogger.Log(ctx, audit.Entry{
|
||||||
|
Action: audit.ActionUserUpdate,
|
||||||
|
TargetType: audit.TargetTypeUser,
|
||||||
|
TargetID: id,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
return user.ToResponse(), nil
|
return user.ToResponse(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +270,14 @@ func (s *userService) UpdateStatus(ctx context.Context, id string, form *UpdateU
|
|||||||
"status": form.Status,
|
"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{
|
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||||
Recipient: user.Email,
|
Recipient: user.Email,
|
||||||
Title: "Account Status Updated",
|
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)
|
user, err := s.repository.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "user not found" {
|
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")
|
return nil, errors.New("user not found")
|
||||||
}
|
}
|
||||||
s.logger.Error("Failed to get user by ID", map[string]interface{}{
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.auditLogger.Log(ctx, audit.Entry{
|
||||||
|
Action: audit.ActionUserRead,
|
||||||
|
TargetType: audit.TargetTypeUser,
|
||||||
|
TargetID: id,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
return user.ToResponse(), nil
|
return user.ToResponse(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,12 +354,33 @@ func (s *userService) Search(ctx context.Context, form *SearchUsersForm, paginat
|
|||||||
userResponses = append(userResponses, user.ToResponse())
|
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{
|
return &UserListResponse{
|
||||||
Users: userResponses,
|
Users: userResponses,
|
||||||
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
||||||
}, nil
|
}, 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)
|
// Delete deletes a user (hard delete)
|
||||||
func (s *userService) Delete(ctx context.Context, id string) error {
|
func (s *userService) Delete(ctx context.Context, id string) error {
|
||||||
err := s.repository.Delete(ctx, id)
|
err := s.repository.Delete(ctx, id)
|
||||||
@@ -338,6 +396,13 @@ func (s *userService) Delete(ctx context.Context, id string) error {
|
|||||||
"user_id": id,
|
"user_id": id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
s.auditLogger.Log(ctx, audit.Entry{
|
||||||
|
Action: audit.ActionUserDelete,
|
||||||
|
TargetType: audit.TargetTypeUser,
|
||||||
|
TargetID: id,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,11 +423,26 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse
|
|||||||
"email_or_username": form.Username,
|
"email_or_username": form.Username,
|
||||||
"error": err.Error(),
|
"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")
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is active
|
// Check if user is active
|
||||||
if user.Status != UserStatusActive {
|
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")
|
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,
|
"user_id": user.ID,
|
||||||
"email": user.Email,
|
"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")
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +498,16 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse
|
|||||||
"role": user.Role,
|
"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{
|
return &AuthResponse{
|
||||||
User: user.ToResponse(),
|
User: user.ToResponse(),
|
||||||
AccessToken: tokenPair.AccessToken,
|
AccessToken: tokenPair.AccessToken,
|
||||||
@@ -520,6 +618,15 @@ func (s *userService) ChangePassword(ctx context.Context, userID string, form *C
|
|||||||
"user_id": userID,
|
"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{
|
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||||
Recipient: user.Email,
|
Recipient: user.Email,
|
||||||
Title: "Password Reset Success",
|
Title: "Password Reset Success",
|
||||||
@@ -550,6 +657,15 @@ func (s *userService) ResetPassword(ctx context.Context, form *ResetPasswordForm
|
|||||||
"email": user.Email,
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,5 +815,14 @@ func (s *userService) Logout(ctx context.Context, userID string, accessToken str
|
|||||||
"user_id": userID,
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
+49
-9
@@ -7,21 +7,19 @@ import (
|
|||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ActionPasswordReset = "password.reset"
|
|
||||||
|
|
||||||
// TargetType values for audit entries.
|
|
||||||
const (
|
|
||||||
TargetTypeAdmin = "admin"
|
|
||||||
TargetTypeCustomer = "customer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Entry represents a security-relevant audit event.
|
// Entry represents a security-relevant audit event.
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ActorID string
|
ActorID string
|
||||||
|
ActorType string
|
||||||
TargetType string
|
TargetType string
|
||||||
TargetID string
|
TargetID string
|
||||||
Action string
|
Action string
|
||||||
At int64
|
At int64
|
||||||
|
IP string
|
||||||
|
RequestID string
|
||||||
|
UserAgent string
|
||||||
|
Success bool
|
||||||
|
Metadata map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger records audit events.
|
// Logger records audit events.
|
||||||
@@ -33,12 +31,13 @@ type auditLogger struct {
|
|||||||
logger logger.Logger
|
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 {
|
func NewLogger(log logger.Logger) Logger {
|
||||||
return &auditLogger{logger: log}
|
return &auditLogger{logger: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *auditLogger) Log(ctx context.Context, entry Entry) {
|
func (a *auditLogger) Log(ctx context.Context, entry Entry) {
|
||||||
|
entry = enrichEntry(ctx, entry)
|
||||||
if entry.At == 0 {
|
if entry.At == 0 {
|
||||||
entry.At = time.Now().Unix()
|
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{}{
|
a.logger.Info("audit event", map[string]interface{}{
|
||||||
"audit": true,
|
"audit": true,
|
||||||
"actor_id": entry.ActorID,
|
"actor_id": entry.ActorID,
|
||||||
|
"actor_type": entry.ActorType,
|
||||||
"target_type": entry.TargetType,
|
"target_type": entry.TargetType,
|
||||||
"target_id": entry.TargetID,
|
"target_id": entry.TargetID,
|
||||||
"action": entry.Action,
|
"action": entry.Action,
|
||||||
"at": entry.At,
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,17 @@ type NotificationConfig struct {
|
|||||||
UserAgent string `env:"NOTIFICATION_USER_AGENT"`
|
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
|
// OllamaConfig represents the configuration for the Ollama SDK
|
||||||
type OllamaConfig struct {
|
type OllamaConfig struct {
|
||||||
BaseURL string `env:"OLLAMA_BASE_URL" default:"http://localhost:11434"`
|
BaseURL string `env:"OLLAMA_BASE_URL" default:"http://localhost:11434"`
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user