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"
|
||||
"time"
|
||||
ai_summarizer "tm/pkg/ai_summarizer"
|
||||
"tm/pkg/audit"
|
||||
"tm/pkg/authorization"
|
||||
"tm/pkg/config"
|
||||
"tm/pkg/elasticsearch"
|
||||
"tm/pkg/filestore"
|
||||
"tm/pkg/gorules"
|
||||
"tm/pkg/hcaptcha"
|
||||
@@ -90,6 +92,7 @@ func InitHTTPServer(conf Config, log logger.Logger) *echo.Echo {
|
||||
// Add middleware
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.RequestID())
|
||||
e.Use(audit.RequestMetaMiddleware())
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
@@ -132,7 +135,7 @@ func InitHTTPServer(conf Config, log logger.Logger) *echo.Echo {
|
||||
e.GET("/swagger*", echoSwagger.WrapHandler)
|
||||
|
||||
log.Info("HTTP server initialized successfully", map[string]interface{}{
|
||||
"middleware": []string{"recover", "request_id", "logger", "cors", "custom_logging"},
|
||||
"middleware": []string{"recover", "request_id", "audit_meta", "logger", "cors", "custom_logging"},
|
||||
})
|
||||
|
||||
return e
|
||||
@@ -402,3 +405,34 @@ func InitGoRulesClient(_ GoRulesConfig, log logger.Logger) gorules.Client {
|
||||
return client
|
||||
*/
|
||||
}
|
||||
|
||||
// InitElasticsearch creates an Elasticsearch client for audit log storage.
|
||||
func InitElasticsearch(conf config.ElasticsearchConfig, log logger.Logger) elasticsearch.Client {
|
||||
esConfig := elasticsearch.Config{
|
||||
URL: conf.URL,
|
||||
Username: conf.Username,
|
||||
Password: conf.Password,
|
||||
IndexPrefix: conf.IndexPrefix,
|
||||
Enabled: conf.Enabled,
|
||||
RequestTimeout: conf.RequestTimeout,
|
||||
BulkFlushPeriod: conf.BulkFlushPeriod,
|
||||
QueueSize: conf.QueueSize,
|
||||
}
|
||||
|
||||
client, err := elasticsearch.NewClient(esConfig, log)
|
||||
if err != nil {
|
||||
log.Warn("Elasticsearch unavailable, audit logs will be file-only", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"url": conf.URL,
|
||||
})
|
||||
return elasticsearch.NewNoopClient()
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// InitAuditLogger creates a composite audit logger with optional Elasticsearch persistence.
|
||||
func InitAuditLogger(log logger.Logger, esClient elasticsearch.Client) (audit.Logger, audit.Store) {
|
||||
store := elasticsearch.NewAuditStore(esClient)
|
||||
return audit.NewCompositeLogger(log, store), store
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type Config struct {
|
||||
DocumentScraper DocumentScraperConfig
|
||||
AISummarizer AISummarizerConfig
|
||||
GoRules GoRulesConfig
|
||||
Elasticsearch config.ElasticsearchConfig
|
||||
}
|
||||
|
||||
type ScraperConfig struct {
|
||||
|
||||
+29
-6
@@ -62,6 +62,9 @@ package main
|
||||
// @tag.name Admin-AI-Pipeline
|
||||
// @tag.description Administrative Opplens AI pipeline operations including scrape, summarize, translate, sync, and background pipeline runs
|
||||
|
||||
// @tag.name Admin-AuditLogs
|
||||
// @tag.description Administrative user action audit log search with filtering by actor, action, target, and time range
|
||||
|
||||
// @tag.name Authorization
|
||||
// @tag.description Customer authentication and authorization operations for mobile application including login, logout, token refresh, and profile access
|
||||
|
||||
@@ -100,8 +103,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"tm/internal/assets"
|
||||
"tm/internal/ai_pipeline"
|
||||
"tm/internal/assets"
|
||||
"tm/internal/auditlog"
|
||||
"tm/internal/cms"
|
||||
"tm/internal/company"
|
||||
"tm/internal/company_category"
|
||||
@@ -116,7 +120,6 @@ import (
|
||||
"tm/internal/tender"
|
||||
"tm/internal/tender_approval"
|
||||
"tm/internal/user"
|
||||
"tm/pkg/audit"
|
||||
"tm/pkg/filestore"
|
||||
"tm/pkg/security"
|
||||
|
||||
@@ -217,7 +220,24 @@ func main() {
|
||||
_ = cms.NewValidationService() // Register mediaRef validator
|
||||
|
||||
// Initialize services with repositories
|
||||
auditLogger := audit.NewLogger(logger)
|
||||
esClient := bootstrap.InitElasticsearch(conf.Elasticsearch, logger)
|
||||
defer func() {
|
||||
if err := esClient.Close(); err != nil {
|
||||
logger.Error("Failed to close Elasticsearch client", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
auditLogger, auditStore := bootstrap.InitAuditLogger(logger, esClient)
|
||||
defer func() {
|
||||
if err := auditStore.Close(); err != nil {
|
||||
logger.Error("Failed to close audit store", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
|
||||
categoryService := company_category.NewService(categoryRepository, logger)
|
||||
companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, redisClient, conf.AISummarizer.RecommendationCacheTTL, logger)
|
||||
@@ -235,8 +255,10 @@ func main() {
|
||||
dashboardRepository := dashboard.NewRepository(mongoManager, logger, procedureDocumentsLister)
|
||||
dashboardService := dashboard.NewService(dashboardRepository, logger)
|
||||
aiPipelineService := ai_pipeline.NewService(aiPipelineClient, logger, tenderService)
|
||||
auditLogRepository := auditlog.NewRepository(auditStore)
|
||||
auditLogService := auditlog.NewService(auditLogRepository, logger)
|
||||
logger.Info("Services initialized successfully", map[string]interface{}{
|
||||
"services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper", "dashboard", "ai_pipeline"},
|
||||
"services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper", "dashboard", "ai_pipeline", "auditlog"},
|
||||
})
|
||||
|
||||
// Initialize handlers with services
|
||||
@@ -257,8 +279,9 @@ func main() {
|
||||
documentScraperHandler := document_scraper.NewHandler(documentScraperService, logger)
|
||||
dashboardHandler := dashboard.NewHandler(dashboardService)
|
||||
aiPipelineHandler := ai_pipeline.NewHandler(aiPipelineService)
|
||||
auditLogHandler := auditlog.NewHandler(auditLogService)
|
||||
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
||||
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper", "dashboard", "ai_pipeline"},
|
||||
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper", "dashboard", "ai_pipeline", "auditlog"},
|
||||
})
|
||||
|
||||
// Initialize HTTP server
|
||||
@@ -267,7 +290,7 @@ func main() {
|
||||
router.SetupCSPSecurity(e)
|
||||
|
||||
// Register routes
|
||||
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, aiPipelineHandler, xssPolicy)
|
||||
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, aiPipelineHandler, auditLogHandler, xssPolicy)
|
||||
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy)
|
||||
router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"tm/internal/assets"
|
||||
"tm/internal/ai_pipeline"
|
||||
"tm/internal/auditlog"
|
||||
"tm/internal/cms"
|
||||
"tm/internal/company"
|
||||
"tm/internal/company_category"
|
||||
@@ -35,7 +36,7 @@ func SetupCSPSecurity(e *echo.Echo) {
|
||||
e.POST("/api/v1/csp-report", security.CSPReportHandler)
|
||||
}
|
||||
|
||||
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, aiPipelineHandler *ai_pipeline.Handler, xssPolicy *security.XSSPolicy) {
|
||||
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, aiPipelineHandler *ai_pipeline.Handler, auditLogHandler *auditlog.Handler, xssPolicy *security.XSSPolicy) {
|
||||
adminV1 := e.Group("/admin/v1")
|
||||
|
||||
// Admin Users Routes
|
||||
@@ -51,6 +52,13 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
|
||||
adminUsersGP.POST("/:id/reset-password", userHandler.ResetUserPassword, userHandler.AdminMiddleware())
|
||||
}
|
||||
|
||||
// Admin Audit Logs Routes
|
||||
auditLogsGP := adminV1.Group("/audit-logs")
|
||||
{
|
||||
auditLogsGP.Use(userHandler.AuthMiddleware(), userHandler.AdminMiddleware())
|
||||
auditLogsGP.GET("", auditLogHandler.Search)
|
||||
}
|
||||
|
||||
// Admin user profile
|
||||
profileGP := adminV1.Group("/profile")
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.24
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/elastic/go-elasticsearch/v8 v8.17.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/labstack/echo/v4 v4.13.4
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
@@ -28,8 +29,11 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elastic/elastic-transport-go/v8 v8.6.1 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
@@ -48,6 +52,9 @@ require (
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
go.opentelemetry.io/otel v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.28.0 // indirect
|
||||
golang.org/x/mod v0.26.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
|
||||
@@ -16,10 +16,19 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.6.1 h1:h2jQRqH6eLGiBSN4eZbQnJLtL4bC5b4lfVFRjw2R4e4=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.6.1/go.mod h1:YLHer5cj0csTzNFXoNQ8qhtGY1GTvSqPnKWKaqQE3Hk=
|
||||
github.com/elastic/go-elasticsearch/v8 v8.17.1 h1:bOXChDoCMB4TIwwGqKd031U8OXssmWLT3UrAr9EGs3Q=
|
||||
github.com/elastic/go-elasticsearch/v8 v8.17.1/go.mod h1:MVJCtL+gJJ7x5jFeUmA20O7rvipX8GcQmo5iBcmaJn4=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
@@ -108,6 +117,14 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.1 h1:j2U/Qp+wvueSpqitLCSZPT/+ZpVc1xzuwdHWwl7d8ro=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.1/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
|
||||
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
|
||||
go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8=
|
||||
go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E=
|
||||
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
|
||||
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
|
||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
|
||||
@@ -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{
|
||||
ActorID: actorID,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: targetID,
|
||||
Action: audit.ActionPasswordReset,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
s.logger.Info("Customer password reset completed", map[string]interface{}{
|
||||
|
||||
@@ -3,6 +3,7 @@ package customer
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"tm/pkg/audit"
|
||||
"tm/pkg/response"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -52,6 +53,8 @@ func (h *Handler) AuthMiddleware() echo.MiddlewareFunc {
|
||||
c.Set("company_id", validationResult.Claims.CompanyID)
|
||||
c.Set("access_token", tokenString)
|
||||
|
||||
audit.EnrichEchoContext(c, audit.ActorTypeCustomer)
|
||||
|
||||
// Continue to next handler
|
||||
return next(c)
|
||||
}
|
||||
|
||||
@@ -181,6 +181,14 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm
|
||||
"company_ids": Companies,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerCreate,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customer.ID.Hex(),
|
||||
Success: true,
|
||||
Metadata: map[string]string{"role": string(customer.Role)},
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: customer.Email,
|
||||
Title: "Welcome to Opplens",
|
||||
@@ -198,6 +206,13 @@ func (s *customerService) GetByID(ctx context.Context, id string) (*CustomerResp
|
||||
customer, err := s.repository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err.Error() == "customer not found" {
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerRead,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: false,
|
||||
Metadata: map[string]string{"reason": "not_found"},
|
||||
})
|
||||
return nil, errors.New("customer not found")
|
||||
}
|
||||
s.logger.Error("Failed to get customer by ID", map[string]interface{}{
|
||||
@@ -223,6 +238,13 @@ func (s *customerService) GetByID(ctx context.Context, id string) (*CustomerResp
|
||||
"email": customer.Email,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerRead,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return customer.ToResponse(companies), nil
|
||||
}
|
||||
|
||||
@@ -249,12 +271,37 @@ func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm,
|
||||
customerResponses = append(customerResponses, customer.ToResponse(companies))
|
||||
}
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerList,
|
||||
Success: true,
|
||||
Metadata: audit.MergeMetadata(
|
||||
customerSearchFilterMetadata(form),
|
||||
audit.PaginationMetadata(pagination.Limit, pagination.Offset, page.TotalCount),
|
||||
),
|
||||
})
|
||||
|
||||
return &CustomerListResponse{
|
||||
Customers: customerResponses,
|
||||
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func customerSearchFilterMetadata(form *SearchCustomersForm) map[string]string {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return audit.MergeMetadata(
|
||||
audit.FlagMetadata("has_search", form.Search != nil && *form.Search != ""),
|
||||
audit.FlagMetadata("has_full_name", form.FullName != nil && *form.FullName != ""),
|
||||
audit.FlagMetadata("has_email_filter", form.Email != nil && *form.Email != ""),
|
||||
audit.OptionalStringMetadata("status", form.Status),
|
||||
audit.OptionalStringMetadata("role", form.Role),
|
||||
audit.OptionalStringMetadata("type", form.Type),
|
||||
audit.OptionalStringMetadata("company_id", form.CompanyID),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *customerService) SearchNotification(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]*CustomerNotificationResponse, error) {
|
||||
page, err := s.repository.Search(ctx, form, pagination)
|
||||
if err != nil {
|
||||
@@ -452,6 +499,13 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus
|
||||
"email": customer.Email,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerUpdate,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
responseCompanies, err := s.loadCompaniesForCustomer(ctx, customer)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to load companies for customer response", map[string]interface{}{
|
||||
@@ -490,6 +544,13 @@ func (s *customerService) Delete(ctx context.Context, id string) error {
|
||||
"customer_id": id,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerDelete,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -518,6 +579,14 @@ func (s *customerService) UpdateStatus(ctx context.Context, id string, form *Upd
|
||||
"status": form.Status,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerStatusChange,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
Metadata: map[string]string{"status": form.Status},
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: customer.Email,
|
||||
Title: "Account Status Updated",
|
||||
@@ -682,6 +751,12 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp
|
||||
s.logger.Warn("Customer login failed - username not found", map[string]interface{}{
|
||||
"username": strings.ToLower(form.Username),
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
Success: false,
|
||||
})
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
@@ -692,6 +767,15 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp
|
||||
"customer_id": customer.ID,
|
||||
"status": string(customer.Status),
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: customer.ID.Hex(),
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customer.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
Success: false,
|
||||
Metadata: map[string]string{"reason": "inactive"},
|
||||
})
|
||||
return nil, errors.New("account is not active")
|
||||
}
|
||||
|
||||
@@ -702,6 +786,14 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp
|
||||
"customer_id": customer.ID,
|
||||
"email": customer.Email,
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: customer.ID.Hex(),
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customer.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
Success: false,
|
||||
})
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
@@ -747,6 +839,16 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp
|
||||
"customer_type": string(customer.Type),
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: customer.ID.Hex(),
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customer.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginSuccess,
|
||||
Success: true,
|
||||
Metadata: map[string]string{"role": string(customer.Role)},
|
||||
})
|
||||
|
||||
return &AuthResponse{
|
||||
Customer: customer.ToResponse(companies),
|
||||
AccessToken: tokenPair.AccessToken,
|
||||
@@ -942,6 +1044,15 @@ func (s *customerService) Logout(ctx context.Context, customerID string, accessT
|
||||
"customer_id": customerID,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: customerID,
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customerID,
|
||||
Action: audit.ActionAuthLogout,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package user
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"tm/pkg/audit"
|
||||
"tm/pkg/response"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -59,6 +60,8 @@ func (h *Handler) AuthMiddleware() echo.MiddlewareFunc {
|
||||
c.Set("company_id", validationResult.Claims.CompanyID)
|
||||
c.Set("access_token", tokenString)
|
||||
|
||||
audit.EnrichEchoContext(c, audit.ActorTypeAdmin)
|
||||
|
||||
// Continue to next handler
|
||||
return next(c)
|
||||
}
|
||||
|
||||
@@ -93,9 +93,11 @@ func (s *userService) AdminResetPassword(ctx context.Context, actorID, actorRole
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: actorID,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: targetID,
|
||||
Action: audit.ActionPasswordReset,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
s.logger.Info("Admin password reset completed", map[string]interface{}{
|
||||
|
||||
@@ -135,6 +135,14 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User
|
||||
"role": user.Role,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserCreate,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: user.ID.Hex(),
|
||||
Success: true,
|
||||
Metadata: map[string]string{"role": string(user.Role)},
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: form.Email,
|
||||
Title: "Welcome to Opplens",
|
||||
@@ -225,6 +233,13 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor
|
||||
"user_id": id,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserUpdate,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return user.ToResponse(), nil
|
||||
}
|
||||
|
||||
@@ -255,6 +270,14 @@ func (s *userService) UpdateStatus(ctx context.Context, id string, form *UpdateU
|
||||
"status": form.Status,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserStatusChange,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
Metadata: map[string]string{"status": form.Status},
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: user.Email,
|
||||
Title: "Account Status Updated",
|
||||
@@ -272,6 +295,13 @@ func (s *userService) GetUserByID(ctx context.Context, id string) (*UserResponse
|
||||
user, err := s.repository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserRead,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: false,
|
||||
Metadata: map[string]string{"reason": "not_found"},
|
||||
})
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
s.logger.Error("Failed to get user by ID", map[string]interface{}{
|
||||
@@ -281,6 +311,13 @@ func (s *userService) GetUserByID(ctx context.Context, id string) (*UserResponse
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserRead,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return user.ToResponse(), nil
|
||||
}
|
||||
|
||||
@@ -317,12 +354,33 @@ func (s *userService) Search(ctx context.Context, form *SearchUsersForm, paginat
|
||||
userResponses = append(userResponses, user.ToResponse())
|
||||
}
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserList,
|
||||
Success: true,
|
||||
Metadata: audit.MergeMetadata(
|
||||
userSearchFilterMetadata(form),
|
||||
audit.PaginationMetadata(pagination.Limit, pagination.Offset, page.TotalCount),
|
||||
),
|
||||
})
|
||||
|
||||
return &UserListResponse{
|
||||
Users: userResponses,
|
||||
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userSearchFilterMetadata(form *SearchUsersForm) map[string]string {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return audit.MergeMetadata(
|
||||
audit.FlagMetadata("has_search", form.Search != nil && *form.Search != ""),
|
||||
audit.OptionalStringMetadata("status", form.Status),
|
||||
audit.OptionalStringMetadata("role", form.Role),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete deletes a user (hard delete)
|
||||
func (s *userService) Delete(ctx context.Context, id string) error {
|
||||
err := s.repository.Delete(ctx, id)
|
||||
@@ -338,6 +396,13 @@ func (s *userService) Delete(ctx context.Context, id string) error {
|
||||
"user_id": id,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserDelete,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -358,11 +423,26 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse
|
||||
"email_or_username": form.Username,
|
||||
"error": err.Error(),
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
Success: false,
|
||||
})
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if user.Status != UserStatusActive {
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: user.ID.Hex(),
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: user.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
Success: false,
|
||||
Metadata: map[string]string{"reason": "inactive"},
|
||||
})
|
||||
return nil, errors.New("account is inactive")
|
||||
}
|
||||
|
||||
@@ -373,6 +453,14 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: user.ID.Hex(),
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: user.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
Success: false,
|
||||
})
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
@@ -410,6 +498,16 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse
|
||||
"role": user.Role,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: user.ID.Hex(),
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: user.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginSuccess,
|
||||
Success: true,
|
||||
Metadata: map[string]string{"role": string(user.Role)},
|
||||
})
|
||||
|
||||
return &AuthResponse{
|
||||
User: user.ToResponse(),
|
||||
AccessToken: tokenPair.AccessToken,
|
||||
@@ -520,6 +618,15 @@ func (s *userService) ChangePassword(ctx context.Context, userID string, form *C
|
||||
"user_id": userID,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: userID,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: userID,
|
||||
Action: audit.ActionAuthPasswordChange,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: user.Email,
|
||||
Title: "Password Reset Success",
|
||||
@@ -550,6 +657,15 @@ func (s *userService) ResetPassword(ctx context.Context, form *ResetPasswordForm
|
||||
"email": user.Email,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: user.ID.Hex(),
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: user.ID.Hex(),
|
||||
Action: audit.ActionAuthPasswordResetRequest,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -699,5 +815,14 @@ func (s *userService) Logout(ctx context.Context, userID string, accessToken str
|
||||
"user_id": userID,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: userID,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: userID,
|
||||
Action: audit.ActionAuthLogout,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
const ActionPasswordReset = "password.reset"
|
||||
|
||||
// TargetType values for audit entries.
|
||||
const (
|
||||
TargetTypeAdmin = "admin"
|
||||
TargetTypeCustomer = "customer"
|
||||
)
|
||||
|
||||
// Entry represents a security-relevant audit event.
|
||||
type Entry struct {
|
||||
ActorID string
|
||||
ActorType string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Action string
|
||||
At int64
|
||||
IP string
|
||||
RequestID string
|
||||
UserAgent string
|
||||
Success bool
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// Logger records audit events.
|
||||
@@ -33,12 +31,13 @@ type auditLogger struct {
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewLogger creates an audit logger backed by structured application logs.
|
||||
// NewLogger creates an audit logger backed by structured application logs only.
|
||||
func NewLogger(log logger.Logger) Logger {
|
||||
return &auditLogger{logger: log}
|
||||
}
|
||||
|
||||
func (a *auditLogger) Log(ctx context.Context, entry Entry) {
|
||||
entry = enrichEntry(ctx, entry)
|
||||
if entry.At == 0 {
|
||||
entry.At = time.Now().Unix()
|
||||
}
|
||||
@@ -46,9 +45,50 @@ func (a *auditLogger) Log(ctx context.Context, entry Entry) {
|
||||
a.logger.Info("audit event", map[string]interface{}{
|
||||
"audit": true,
|
||||
"actor_id": entry.ActorID,
|
||||
"actor_type": entry.ActorType,
|
||||
"target_type": entry.TargetType,
|
||||
"target_id": entry.TargetID,
|
||||
"action": entry.Action,
|
||||
"at": entry.At,
|
||||
"ip": entry.IP,
|
||||
"request_id": entry.RequestID,
|
||||
"user_agent": entry.UserAgent,
|
||||
"success": entry.Success,
|
||||
"metadata": entry.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
type compositeLogger struct {
|
||||
fileLogger Logger
|
||||
store Store
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewCompositeLogger writes audit events to structured logs and an optional store.
|
||||
func NewCompositeLogger(log logger.Logger, store Store) Logger {
|
||||
if store == nil {
|
||||
store = NoopStore()
|
||||
}
|
||||
return &compositeLogger{
|
||||
fileLogger: NewLogger(log),
|
||||
store: store,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *compositeLogger) Log(ctx context.Context, entry Entry) {
|
||||
entry = enrichEntry(ctx, entry)
|
||||
if entry.At == 0 {
|
||||
entry.At = time.Now().Unix()
|
||||
}
|
||||
|
||||
c.fileLogger.Log(ctx, entry)
|
||||
|
||||
doc := entryToDocument(entry)
|
||||
if err := c.store.Save(ctx, doc); err != nil {
|
||||
c.logger.Error("Failed to persist audit event", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"action": entry.Action,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
type ElasticsearchConfig struct {
|
||||
URL string `env:"ELASTICSEARCH_URL"`
|
||||
Username string `env:"ELASTICSEARCH_USERNAME"`
|
||||
Password string `env:"ELASTICSEARCH_PASSWORD"`
|
||||
IndexPrefix string `env:"ELASTICSEARCH_INDEX_PREFIX"`
|
||||
Enabled bool `env:"ELASTICSEARCH_ENABLED"`
|
||||
RequestTimeout time.Duration `env:"ELASTICSEARCH_REQUEST_TIMEOUT"`
|
||||
BulkFlushPeriod time.Duration `env:"ELASTICSEARCH_BULK_FLUSH_PERIOD"`
|
||||
QueueSize int `env:"ELASTICSEARCH_QUEUE_SIZE"`
|
||||
}
|
||||
|
||||
// OllamaConfig represents the configuration for the Ollama SDK
|
||||
type OllamaConfig struct {
|
||||
BaseURL string `env:"OLLAMA_BASE_URL" default:"http://localhost:11434"`
|
||||
|
||||
@@ -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