Implemented audit logging
This commit is contained in:
@@ -1,11 +1,39 @@
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Castle.DynamicProxy;
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
|
||||
namespace PostFundManagement.Application.Interceptors;
|
||||
|
||||
public sealed class AuditInterceptor(AuditService auditService, IHttpContextAccessor httpContextAccessor) : IAsyncInterceptor
|
||||
{
|
||||
public void InterceptSynchronous(IInvocation invocation) => invocation.Proceed();
|
||||
public void InterceptSynchronous(IInvocation invocation)
|
||||
{
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||
|
||||
if (auditAttr == null)
|
||||
{
|
||||
invocation.Proceed();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
invocation.Proceed();
|
||||
AuditAction(invocation, invocation.ReturnValue, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AuditAction(invocation, null, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void InterceptAsynchronous(IInvocation invocation) =>
|
||||
invocation.ReturnValue = InternalInterceptAsynchronous(invocation);
|
||||
@@ -13,49 +41,177 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
||||
public void InterceptAsynchronous<TResult>(IInvocation invocation) =>
|
||||
invocation.ReturnValue = InternalInterceptAsynchronous<TResult>(invocation);
|
||||
|
||||
private static async Task InternalInterceptAsynchronous(IInvocation invocation)
|
||||
private async Task InternalInterceptAsynchronous(IInvocation invocation)
|
||||
{
|
||||
invocation.Proceed();
|
||||
|
||||
var task = (Task)invocation.ReturnValue;
|
||||
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
|
||||
{
|
||||
invocation.Proceed();
|
||||
|
||||
var task = (Task<TResult>)invocation.ReturnValue;
|
||||
var resultObj = await task.ConfigureAwait(false);
|
||||
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||
|
||||
if (auditAttr == null) return resultObj;
|
||||
|
||||
if (resultObj is IResultBase result && result.IsSuccess)
|
||||
if (auditAttr == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = ResolveActionName(method.Name);
|
||||
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
|
||||
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
|
||||
var changes = BuildPayloadSummary(invocation.Method.GetParameters(), invocation.Arguments);
|
||||
var performedBy = ResolveUserId();
|
||||
invocation.Proceed();
|
||||
var t = (Task)invocation.ReturnValue;
|
||||
await t.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
invocation.Proceed();
|
||||
var task = (Task)invocation.ReturnValue;
|
||||
await task.ConfigureAwait(false);
|
||||
await AuditActionAsync(invocation, null, null).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
|
||||
{
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||
|
||||
if (auditAttr == null)
|
||||
{
|
||||
invocation.Proceed();
|
||||
var t = (Task<TResult>)invocation.ReturnValue;
|
||||
return await t.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
TResult resultObj;
|
||||
try
|
||||
{
|
||||
invocation.Proceed();
|
||||
var task = (Task<TResult>)invocation.ReturnValue;
|
||||
resultObj = await task.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
await AuditActionAsync(invocation, resultObj, null).ConfigureAwait(false);
|
||||
return resultObj;
|
||||
}
|
||||
|
||||
private async Task AuditActionAsync(IInvocation invocation, object? resultObj, Exception? exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var action = ResolveActionName(method.Name);
|
||||
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
|
||||
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
|
||||
var changes = BuildPayloadSummary(method.GetParameters(), invocation.Arguments);
|
||||
var performedBy = ResolveUserId();
|
||||
var tenantId = ResolveTenantId(invocation.Arguments);
|
||||
|
||||
if (exception != null)
|
||||
{
|
||||
var errorDetails = $"[Exception] {exception.GetType().Name}: {exception.Message}\n{exception.StackTrace}";
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy).ConfigureAwait(false);
|
||||
performedBy: performedBy,
|
||||
error: errorDetails,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch { /* eat all exceptions, don't interrupt program flow */ }
|
||||
}
|
||||
|
||||
return resultObj;
|
||||
if (resultObj is IResultBase result)
|
||||
{
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: null,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
}
|
||||
else if (result.IsFailed)
|
||||
{
|
||||
var errorMessages = new List<string>();
|
||||
if (result.Reasons != null)
|
||||
{
|
||||
foreach (var reason in result.Reasons)
|
||||
{
|
||||
if (reason is IExceptionalError exceptionalError)
|
||||
errorMessages.Add($"{exceptionalError.Message} ({exceptionalError.Exception.GetType().Name}: {exceptionalError.Exception.Message})\n{exceptionalError.Exception.StackTrace}");
|
||||
else if (reason is IError error)
|
||||
errorMessages.Add(error.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var rawErrorType = result.Reasons?.FirstOrDefault()?.GetType().Name.Replace("Error", string.Empty);
|
||||
var errorType = string.IsNullOrWhiteSpace(rawErrorType) || rawErrorType == "Reason" ? "Error" : rawErrorType;
|
||||
var errorsString = string.Join("; ", errorMessages);
|
||||
var errorDetails = $"[{errorType}] {errorsString}";
|
||||
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: errorDetails,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: null,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* eat all exceptions, don't interrupt program flow */
|
||||
}
|
||||
}
|
||||
|
||||
private void AuditAction(IInvocation invocation, object? resultObj, Exception? exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
AuditActionAsync(invocation, resultObj, exception).GetAwaiter().GetResult();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* eat all exceptions, don't interrupt program flow */
|
||||
}
|
||||
}
|
||||
|
||||
private static long? ResolveTenantId(object[] args)
|
||||
{
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg == null || arg is CancellationToken or IFormFile) continue;
|
||||
var prop = arg.GetType().GetProperty("TenantId");
|
||||
if (prop != null)
|
||||
{
|
||||
var val = prop.GetValue(arg);
|
||||
if (val is long l && l > 0) return l;
|
||||
if (val is int i && i > 0) return i;
|
||||
if (val is string s && long.TryParse(s, out var parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ResolveActionName(string methodName) =>
|
||||
@@ -73,7 +229,7 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
||||
{
|
||||
var genericDef = returnType.GetGenericTypeDefinition();
|
||||
|
||||
if (genericDef == typeof(Task<>) || genericDef == typeof(Task<>))
|
||||
if (genericDef == typeof(Task<>))
|
||||
innerType = returnType.GetGenericArguments()[0];
|
||||
}
|
||||
|
||||
@@ -208,4 +364,4 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
||||
|
||||
return Guid.TryParse(sub, out var guid) ? guid : Guid.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
@@ -10,27 +10,22 @@ namespace PostFundManagement.Application.Services.Shared;
|
||||
public sealed class AuditService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContextService) : IService
|
||||
{
|
||||
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var pagination = new Pagination { Page = 1, PageSize = 500, Index = 1 };
|
||||
|
||||
var logs = await context.AuditLogs
|
||||
.OrderByDescending(o => o.Id)
|
||||
.Skip((pagination.Page - 1) * pagination.PageSize)
|
||||
.Take(pagination.PageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(logs.Select(p => p.Map()).ToArray());
|
||||
}));
|
||||
await GetAuditLogsAsync(new Pagination { Page = 1, PageSize = 500, Index = 1 }, cancellationToken);
|
||||
|
||||
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var logs = await context.AuditLogs
|
||||
var query = context.AuditLogs.AsQueryable();
|
||||
|
||||
if (!tenantContextService.IsAdmin)
|
||||
{
|
||||
var currentTenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L;
|
||||
query = query.Where(o => o.TenantId == currentTenantId);
|
||||
}
|
||||
|
||||
var logs = await query
|
||||
.OrderByDescending(o => o.Id)
|
||||
.Skip((pagination.Page - 1) * pagination.PageSize)
|
||||
.Take(pagination.PageSize)
|
||||
@@ -39,11 +34,23 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
|
||||
return Result.Ok(logs.Select(p => p.Map()).ToArray());
|
||||
}));
|
||||
|
||||
public async Task<Result> LogActionAsync(string entityId, string entityName, string action, string[] changes, Guid performedBy, CancellationToken cancellationToken = default) =>
|
||||
public async Task<Result> LogActionAsync(
|
||||
string entityId,
|
||||
string entityName,
|
||||
string action,
|
||||
string[] changes,
|
||||
Guid performedBy,
|
||||
string? error = null,
|
||||
long? tenantId = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var resolvedTenantId = (tenantId.HasValue && tenantId.Value > 0)
|
||||
? tenantId.Value
|
||||
: (tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L);
|
||||
|
||||
context.Add(new Domain.Entities.AuditLog
|
||||
{
|
||||
EntityId = entityId,
|
||||
@@ -52,7 +59,8 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
|
||||
EntityName = entityName,
|
||||
PerformedBy = performedBy,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
TenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : null,
|
||||
TenantId = resolvedTenantId,
|
||||
Error = error,
|
||||
});
|
||||
|
||||
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New);
|
||||
|
||||
Reference in New Issue
Block a user