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:
m.nazemi
2026-06-30 13:04:22 +03:30
30 changed files with 1436 additions and 17 deletions
+35 -1
View File
@@ -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
}
+1
View File
@@ -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
View File
@@ -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)
+9 -1
View File
@@ -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")
{ {
+7
View File
@@ -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
+17
View File
@@ -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=
+31
View File
@@ -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"`
}
+12
View File
@@ -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"`
}
+69
View File
@@ -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")
}
+25
View File
@@ -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)
}
+88
View File
@@ -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
View File
@@ -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)
} }
+111
View File
@@ -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
View File
@@ -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)
} }
+2
View File
@@ -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{}{
+125
View File
@@ -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
} }
+37
View File
@@ -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
View File
@@ -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,
})
}
}
+73
View File
@@ -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
}
+35
View File
@@ -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")
}
}
+52
View File
@@ -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),
}
}
+28
View File
@@ -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)
}
}
+47
View File
@@ -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))
}
+76
View File
@@ -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,
}
}
+11
View File
@@ -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"`
+48
View File
@@ -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()
}
+333
View File
@@ -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
}
+18
View File
@@ -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 }
+60
View File
@@ -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
}