316 lines
7.5 KiB
Go
316 lines
7.5 KiB
Go
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
|
|
}
|