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) { var method = invocation.MethodInvocationTarget ?? invocation.Method; var auditAttr = method.GetCustomAttribute() ?? invocation.Method.GetCustomAttribute(); 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); public void InterceptAsynchronous(IInvocation invocation) => invocation.ReturnValue = InternalInterceptAsynchronous(invocation); private async Task InternalInterceptAsynchronous(IInvocation invocation) { var method = invocation.MethodInvocationTarget ?? invocation.Method; var auditAttr = method.GetCustomAttribute() ?? invocation.Method.GetCustomAttribute(); if (auditAttr == null) { 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 InternalInterceptAsynchronous(IInvocation invocation) { var method = invocation.MethodInvocationTarget ?? invocation.Method; var auditAttr = method.GetCustomAttribute() ?? invocation.Method.GetCustomAttribute(); if (auditAttr == null) { invocation.Proceed(); var t = (Task)invocation.ReturnValue; return await t.ConfigureAwait(false); } TResult resultObj; try { invocation.Proceed(); var task = (Task)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, error: errorDetails, tenantId: tenantId).ConfigureAwait(false); return; } 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(); 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) => methodName.EndsWith("Async", StringComparison.Ordinal) ? methodName[..^5] : methodName; private static string ResolveEntityName(MethodInfo method, object? resultObj, object[] args) { var returnType = method.ReturnType; Type? innerType = null; if (returnType.IsGenericType) { var genericDef = returnType.GetGenericTypeDefinition(); if (genericDef == typeof(Task<>)) innerType = returnType.GetGenericArguments()[0]; } if (innerType != null && innerType.IsGenericType && typeof(IResultBase).IsAssignableFrom(innerType)) { var valueType = innerType.GetGenericArguments().FirstOrDefault(); if (valueType != null && valueType != typeof(bool) && valueType != typeof(int) && valueType != typeof(string)) return valueType.Name; } foreach (var arg in args) { if (arg == null || arg is CancellationToken or IFormFile) continue; var argTypeName = arg.GetType().Name; if (argTypeName.EndsWith("Request", StringComparison.Ordinal)) { var trimmed = argTypeName[..^7]; return trimmed.Replace("Create", "").Replace("Update", ""); } } return ResolveActionName(method.Name); } private static string ResolveEntityId(object? resultObj, object[] args) { if (resultObj != null) { var valueProp = resultObj.GetType().GetProperty("Value"); if (valueProp != null) { var value = valueProp.GetValue(resultObj); if (value != null) { var idProp = value.GetType().GetProperty("Id") ?? value.GetType().GetProperty("id"); if (idProp != null) { var idVal = idProp.GetValue(value); if (idVal != null) return idVal.ToString()!; } } } } foreach (var arg in args) { if (arg is long or int or Guid or string) return arg.ToString()!; if (arg != null && arg is not CancellationToken and not IFormFile) { var idProp = arg.GetType().GetProperty("Id") ?? arg.GetType().GetProperty("id"); if (idProp != null) { var idVal = idProp.GetValue(arg); if (idVal != null) return idVal.ToString()!; } } } return "N/A"; } private static string[] BuildPayloadSummary(ParameterInfo[] parameters, object[] args) { var entries = new List(); for (var i = 0; i < parameters.Length; i++) { var param = parameters[i]; var arg = args[i]; if (param.ParameterType == typeof(CancellationToken)) continue; if (arg == null) { entries.Add($"{param.Name}: null"); continue; } if (arg is IFormFile file) { entries.Add($"{param.Name}: [File] Name='{file.FileName}', Size={file.Length} bytes, Type='{file.ContentType}'"); continue; } if (arg is IEnumerable files) { var fileSummaries = files.Select(f => $"'{f.FileName}' ({f.Length} bytes)"); entries.Add($"{param.Name}: [Files] count={fileSummaries.Count()} [{string.Join(", ", fileSummaries)}]"); continue; } if (arg is Stream stream) { entries.Add($"{param.Name}: [Stream] Length={(stream.CanSeek ? stream.Length : -1)} bytes"); continue; } try { var json = JsonSerializer.Serialize(arg); entries.Add($"{param.Name}: {json}"); } catch { entries.Add($"{param.Name}: [Unserializable]"); } } return [.. entries]; } private Guid ResolveUserId() { var user = httpContextAccessor.HttpContext?.User; var sub = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user?.FindFirst("sub")?.Value; return Guid.TryParse(sub, out var guid) ? guid : Guid.Empty; } }