init push
This commit is contained in:
315
internal/service/audit_log_service.go
Normal file
315
internal/service/audit_log_service.go
Normal file
@@ -0,0 +1,315 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user