line-gestao-api/Services/AuditLogBuilder.cs

281 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.IdentityModel.Tokens.Jwt;
using line_gestao_api.Dtos;
using line_gestao_api.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace line_gestao_api.Services;
public class AuditLogBuilder : IAuditLogBuilder
{
private static readonly Dictionary<string, string> PageByEntity = new(StringComparer.OrdinalIgnoreCase)
{
{ nameof(MobileLine), "Geral" },
{ nameof(MuregLine), "Mureg" },
{ nameof(BillingClient), "Faturamento" },
{ nameof(ParcelamentoLine), "Parcelamentos" },
{ nameof(ParcelamentoMonthValue), "Parcelamentos" },
{ nameof(UserData), "Dados e Usuários" },
{ nameof(ApplicationUser), "Dados e Usuários" },
{ nameof(VigenciaLine), "Vigência" },
{ nameof(ChipVirgemLine), "Chips Virgens e Recebidos" },
{ nameof(ControleRecebidoLine), "Chips Virgens e Recebidos" },
{ nameof(TrocaNumeroLine), "Troca de número" }
};
private static readonly HashSet<string> IgnoredProperties = new(StringComparer.OrdinalIgnoreCase)
{
"TenantId",
"CreatedAt",
"UpdatedAt",
"PasswordHash",
"SecurityStamp",
"ConcurrencyStamp",
"NormalizedEmail",
"NormalizedUserName",
"LockoutEnd",
"AccessFailedCount",
"LockoutEnabled",
"EmailConfirmed",
"PhoneNumberConfirmed",
"TwoFactorEnabled"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ITenantProvider _tenantProvider;
public AuditLogBuilder(IHttpContextAccessor httpContextAccessor, ITenantProvider tenantProvider)
{
_httpContextAccessor = httpContextAccessor;
_tenantProvider = tenantProvider;
}
public List<AuditLog> BuildAuditLogs(ChangeTracker changeTracker)
{
var tenantId = _tenantProvider.TenantId;
if (tenantId == null)
{
return new List<AuditLog>();
}
changeTracker.DetectChanges();
var httpContext = _httpContextAccessor.HttpContext;
var userInfo = ResolveUserInfo(httpContext?.User);
var request = httpContext?.Request;
var requestPath = request?.Path.Value;
var requestMethod = request?.Method;
var ipAddress = httpContext?.Connection?.RemoteIpAddress?.ToString();
var logs = new List<AuditLog>();
foreach (var entry in changeTracker.Entries())
{
if (entry.State == EntityState.Detached || entry.State == EntityState.Unchanged)
continue;
if (entry.Entity is AuditLog)
continue;
var entityName = entry.Metadata.ClrType.Name;
if (!PageByEntity.TryGetValue(entityName, out var page))
continue;
var changes = BuildChanges(entry);
if (changes.Count == 0)
continue;
logs.Add(new AuditLog
{
TenantId = tenantId.Value,
OccurredAtUtc = DateTime.UtcNow,
UserId = userInfo.UserId,
UserName = userInfo.UserName,
UserEmail = userInfo.UserEmail,
Action = ResolveAction(entry.State),
Page = page,
EntityName = entityName,
EntityId = BuildEntityId(entry),
EntityLabel = BuildEntityLabel(entry),
ChangesJson = JsonSerializer.Serialize(changes, JsonOptions),
RequestPath = requestPath,
RequestMethod = requestMethod,
IpAddress = ipAddress
});
}
return logs;
}
private static string ResolveAction(EntityState state)
=> state switch
{
EntityState.Added => "CREATE",
EntityState.Modified => "UPDATE",
EntityState.Deleted => "DELETE",
_ => "UNKNOWN"
};
private static List<AuditFieldChangeDto> BuildChanges(EntityEntry entry)
{
var changes = new List<AuditFieldChangeDto>();
foreach (var property in entry.Properties)
{
if (IgnoredProperties.Contains(property.Metadata.Name))
continue;
if (entry.State == EntityState.Added)
{
var newValue = FormatValue(property.CurrentValue);
if (newValue == null)
continue;
changes.Add(new AuditFieldChangeDto
{
Field = property.Metadata.Name,
ChangeType = "added",
NewValue = newValue
});
}
else if (entry.State == EntityState.Deleted)
{
var oldValue = FormatValue(property.OriginalValue);
if (oldValue == null)
continue;
changes.Add(new AuditFieldChangeDto
{
Field = property.Metadata.Name,
ChangeType = "removed",
OldValue = oldValue
});
}
else if (entry.State == EntityState.Modified)
{
if (!property.IsModified)
continue;
var oldValue = FormatValue(property.OriginalValue);
var newValue = FormatValue(property.CurrentValue);
if (string.Equals(oldValue, newValue, StringComparison.Ordinal))
continue;
changes.Add(new AuditFieldChangeDto
{
Field = property.Metadata.Name,
ChangeType = "modified",
OldValue = oldValue,
NewValue = newValue
});
}
}
return changes;
}
private static string? BuildEntityId(EntityEntry entry)
{
var key = entry.Metadata.FindPrimaryKey();
if (key == null)
return null;
var parts = new List<string>();
foreach (var keyProperty in key.Properties)
{
var property = entry.Property(keyProperty.Name);
var value = FormatValue(property.CurrentValue ?? property.OriginalValue);
if (value == null)
continue;
parts.Add($"{keyProperty.Name}={value}");
}
return parts.Count == 0 ? null : string.Join(";", parts);
}
private static string? BuildEntityLabel(EntityEntry entry)
{
var entityName = entry.Metadata.ClrType.Name;
return entityName switch
{
nameof(MobileLine) => GetValue(entry, "Linha") ?? GetValue(entry, "Item"),
nameof(MuregLine) => GetValue(entry, "LinhaAntiga") ?? GetValue(entry, "LinhaNova") ?? GetValue(entry, "Item"),
nameof(TrocaNumeroLine) => GetValue(entry, "LinhaAntiga") ?? GetValue(entry, "LinhaNova") ?? GetValue(entry, "Item"),
nameof(ChipVirgemLine) => GetValue(entry, "NumeroDoChip") ?? GetValue(entry, "Item"),
nameof(ControleRecebidoLine) => GetValue(entry, "NotaFiscal") ?? GetValue(entry, "Serial") ?? GetValue(entry, "Item"),
nameof(BillingClient) => GetValue(entry, "Cliente") ?? GetValue(entry, "Item"),
nameof(ParcelamentoLine) => GetValue(entry, "Linha") ?? GetValue(entry, "Cliente") ?? GetValue(entry, "Item"),
nameof(ParcelamentoMonthValue) => GetValue(entry, "Competencia") ?? GetValue(entry, "ParcelamentoLineId"),
nameof(UserData) => GetValue(entry, "Linha") ?? GetValue(entry, "Cliente") ?? GetValue(entry, "Item"),
nameof(VigenciaLine) => GetValue(entry, "Linha") ?? GetValue(entry, "Cliente") ?? GetValue(entry, "Item"),
nameof(ApplicationUser) => GetValue(entry, "Email") ?? GetValue(entry, "Name") ?? GetValue(entry, "Id"),
_ => null
};
}
private static string? GetValue(EntityEntry entry, string propertyName)
{
var property = entry.Properties.FirstOrDefault(p => p.Metadata.Name == propertyName);
return property == null ? null : FormatValue(property.CurrentValue ?? property.OriginalValue);
}
private static string? FormatValue(object? value)
{
if (value == null)
return null;
switch (value)
{
case DateTime dt:
var normalized = dt.Kind == DateTimeKind.Utc
? dt
: (dt.Kind == DateTimeKind.Local ? dt.ToUniversalTime() : DateTime.SpecifyKind(dt, DateTimeKind.Utc));
return normalized.ToString("O", CultureInfo.InvariantCulture);
case DateTimeOffset dto:
return dto.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
case IFormattable formattable:
return formattable.ToString(null, CultureInfo.InvariantCulture);
default:
return value.ToString();
}
}
private static (Guid? UserId, string? UserName, string? UserEmail) ResolveUserInfo(ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true)
{
return (null, "SYSTEM", null);
}
var idValue = user.FindFirstValue(ClaimTypes.NameIdentifier)
?? user.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? user.FindFirstValue("sub");
var name = user.FindFirstValue("name")
?? user.FindFirstValue(ClaimTypes.Name)
?? user.Identity?.Name;
var email = user.FindFirstValue(ClaimTypes.Email)
?? user.FindFirstValue(JwtRegisteredClaimNames.Email)
?? user.FindFirstValue("email");
var userId = Guid.TryParse(idValue, out var parsed) ? parsed : (Guid?)null;
var userName = string.IsNullOrWhiteSpace(name) ? email : name;
return (userId, userName, email);
}
}