package service import ( "context" "encoding/json" "errors" "log" "strings" "time" "wucher/internal/domain/audit" ) type AuditLogInput struct { RequestID string ActorUserID []byte Layer string Module string Action string Method string Path string StatusCode int Success bool IP string UserAgent string Message string Metadata map[string]any } type AuditLogService struct { repo audit.Repository queue chan *audit.AuditLog ipProtector SecretProtector } type AuditLogServiceDependencies struct { QueueSize int Workers int IPEncryptionKey string } type auditLogListRepository interface { List(ctx context.Context, filter string, methods []string, modules []string, actions []string, sort string, limit, offset int) ([]audit.AuditLog, int64, error) } func NewAuditLogService(repo audit.Repository, deps AuditLogServiceDependencies) *AuditLogService { queueSize := deps.QueueSize workers := deps.Workers if queueSize <= 0 { queueSize = 1024 } if workers <= 0 { workers = 2 } key := strings.TrimSpace(deps.IPEncryptionKey) svc := &AuditLogService{ repo: repo, queue: make(chan *audit.AuditLog, queueSize), ipProtector: newIPSecretProtector(key), } for i := 0; i < workers; i++ { go svc.runWorker() } return svc } func (s *AuditLogService) Log(ctx context.Context, in AuditLogInput) { if s == nil || s.repo == nil { return } entry := &audit.AuditLog{ RequestID: truncate(strings.TrimSpace(in.RequestID), 64), ActorUserID: safeUserID(in.ActorUserID), Layer: truncate(strings.TrimSpace(in.Layer), 32), Module: truncate(strings.TrimSpace(in.Module), 64), Action: truncate(strings.TrimSpace(in.Action), 128), Method: truncate(strings.TrimSpace(in.Method), 12), Path: truncate(strings.TrimSpace(in.Path), 255), StatusCode: in.StatusCode, Success: in.Success, IP: sanitizeIP(in.IP, s.ipProtector), UserAgent: truncate(strings.TrimSpace(in.UserAgent), 255), Message: truncate(strings.TrimSpace(in.Message), 512), CreatedAt: time.Now().UTC(), } if len(in.Metadata) > 0 { if b, err := json.Marshal(in.Metadata); err == nil { entry.Metadata = truncateBytes(b, 8192) } } select { case s.queue <- entry: default: log.Printf("audit queue_full layer=%s action=%s", entry.Layer, entry.Action) } } func (s *AuditLogService) List(ctx context.Context, filter string, methods []string, modules []string, actions []string, sort string, limit, offset int) ([]audit.AuditLog, int64, error) { if s == nil || s.repo == nil { return nil, 0, errors.New("audit service not configured") } lister, ok := s.repo.(auditLogListRepository) if !ok { return nil, 0, errors.New("audit listing not supported by repository") } if limit < 0 { limit = 0 } if limit > 200 { limit = 200 } if offset < 0 { offset = 0 } methods, actions = enforceAuditMutationFilters(methods, actions) rows, total, err := lister.List( ctx, strings.TrimSpace(filter), normalizeAuditMethods(methods), normalizeAuditTokens(modules), normalizeAuditTokens(actions), normalizeAuditSort(sort), limit, offset, ) if err != nil { return nil, 0, err } for i := range rows { rows[i].IP = decryptStoredIP(rows[i].IP, s.ipProtector) } return rows, total, nil } func enforceAuditMutationFilters(methods, actions []string) ([]string, []string) { allowedMethods := []string{"POST", "PUT", "PATCH", "DELETE"} allowedActions := []string{"create", "update", "delete"} methods = enforceAllowedMethods(methods, allowedMethods) actions = enforceAllowedTokens(actions, allowedActions) return methods, actions } func enforceAllowedMethods(values []string, allowed []string) []string { if len(values) == 0 { return append([]string(nil), allowed...) } allowedSet := make(map[string]struct{}, len(allowed)) for _, value := range allowed { allowedSet[strings.ToUpper(strings.TrimSpace(value))] = struct{}{} } out := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { v := strings.ToUpper(strings.TrimSpace(value)) if v == "UPDATE" { if _, ok := allowedSet["PUT"]; ok { if _, exists := seen["PUT"]; !exists { seen["PUT"] = struct{}{} out = append(out, "PUT") } } if _, ok := allowedSet["PATCH"]; ok { if _, exists := seen["PATCH"]; !exists { seen["PATCH"] = struct{}{} out = append(out, "PATCH") } } continue } if _, ok := allowedSet[v]; !ok { continue } if _, exists := seen[v]; exists { continue } seen[v] = struct{}{} out = append(out, v) } if len(out) == 0 { return append([]string(nil), allowed...) } return out } func enforceAllowedTokens(values []string, allowed []string) []string { if len(values) == 0 { return append([]string(nil), allowed...) } allowedSet := make(map[string]struct{}, len(allowed)) for _, value := range allowed { allowedSet[strings.ToLower(strings.TrimSpace(value))] = struct{}{} } out := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { v := strings.ToLower(strings.TrimSpace(value)) if _, ok := allowedSet[v]; !ok { continue } if _, exists := seen[v]; exists { continue } seen[v] = struct{}{} out = append(out, v) } if len(out) == 0 { return append([]string(nil), allowed...) } return out } func (s *AuditLogService) runWorker() { for entry := range s.queue { if entry == nil { continue } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) err := s.repo.Create(ctx, entry) cancel() if err != nil { log.Printf("audit persist_failed layer=%s action=%s err=%v", entry.Layer, entry.Action, err) } } } func safeUserID(id []byte) []byte { if len(id) != 16 { return nil } dst := make([]byte, 16) copy(dst, id) return dst } func truncateBytes(v []byte, max int) []byte { if max <= 0 || len(v) <= max { return v } out := make([]byte, max) copy(out, v[:max]) return out } func normalizeAuditSort(sort string) string { if normalized := normalizeSortByRules(strings.TrimSpace(sort), sortField("created_at"), sortField("action"), sortField("module"), sortField("layer"), sortField("status_code"), sortField("success"), ); normalized != "" { return normalized } return "created_at DESC" } func normalizeAuditTokens(values []string) []string { if len(values) == 0 { return nil } seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { v := strings.ToLower(strings.TrimSpace(value)) switch v { case "", "all", "*": return nil default: if _, ok := seen[v]; !ok { seen[v] = struct{}{} out = append(out, v) } } } if len(out) == 0 { return nil } return out } func normalizeAuditMethods(methods []string) []string { if len(methods) == 0 { return nil } seen := make(map[string]struct{}, len(methods)) out := make([]string, 0, len(methods)) for _, method := range methods { v := strings.ToUpper(strings.TrimSpace(method)) switch v { case "", "ALL", "*": return nil case "GET", "POST", "PUT", "PATCH", "DELETE": if _, ok := seen[v]; !ok { seen[v] = struct{}{} out = append(out, v) } case "UPDATE": if _, ok := seen["PUT"]; !ok { seen["PUT"] = struct{}{} out = append(out, "PUT") } if _, ok := seen["PATCH"]; !ok { seen["PATCH"] = struct{}{} out = append(out, "PATCH") } } } if len(out) == 0 { return nil } return out }