513 lines
20 KiB
C#
513 lines
20 KiB
C#
using System.ComponentModel.DataAnnotations;
|
||
using System.ComponentModel.DataAnnotations.Schema;
|
||
using System.Linq.Dynamic.Core;
|
||
using System.Linq.Expressions;
|
||
using System.Reflection;
|
||
using e_suite.Database.Audit.Attributes;
|
||
using e_suite.Database.Audit.Extensions;
|
||
using e_suite.Database.Audit.Tables.Audit;
|
||
using eSuite.Core.Clock;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||
|
||
namespace e_suite.Database.Audit.AuditEngine;
|
||
|
||
public class AuditEngineCore
|
||
{
|
||
private readonly AuditableEntityContext _auditableEntityContext;
|
||
private readonly IClock _clock;
|
||
|
||
public AuditEngineCore(AuditableEntityContext auditableEntityContext, IClock clock)
|
||
{
|
||
_auditableEntityContext = auditableEntityContext;
|
||
_clock = clock;
|
||
}
|
||
|
||
#pragma warning disable IDE0060 // Remove unused parameter
|
||
public List<AuditRecorder> OnBeforeSaveChanges(AuditableEntityContext dbContext, AuditUserDetails auditUserDetails, CancellationToken cancellationToken)
|
||
#pragma warning restore IDE0060 // Remove unused parameter
|
||
{
|
||
if (!_auditableEntityContext.ChangeTracker.AutoDetectChangesEnabled)
|
||
{
|
||
_auditableEntityContext.ChangeTracker.DetectChanges();
|
||
}
|
||
|
||
var auditEntries = new List<AuditRecorder>();
|
||
foreach (var entry in _auditableEntityContext.ChangeTracker.Entries())
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
if (HasNoAuditAttribute(entry.Entity) || entry.State == EntityState.Detached
|
||
|| entry.State == EntityState.Unchanged)
|
||
continue;
|
||
|
||
var auditEntry = new AuditRecorder(entry, auditUserDetails, _clock);
|
||
|
||
auditEntry.AuditType = entry.State switch
|
||
{
|
||
EntityState.Added => AuditType.Create,
|
||
EntityState.Deleted => AuditType.Purge,
|
||
EntityState.Modified => AuditType.Update,
|
||
_ => auditEntry.AuditType
|
||
};
|
||
|
||
foreach (var property in entry.Properties)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
|
||
if (property.IsTemporary)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var propertyName = property.Metadata.Name;
|
||
|
||
var propertyInfo = entry.Entity.GetType().GetProperties().Single(x => x.Name == propertyName);
|
||
|
||
var isLastUpdated = propertyInfo.CustomAttributes.Any(x => x.AttributeType == typeof(AuditLastUpdatedAttribute));
|
||
if (isLastUpdated)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// Value converter + comparer
|
||
var converter = property.Metadata.GetValueConverter(); //todo need a unit test for when a value converter is present on the table.
|
||
var comparer = property.Metadata.GetValueComparer();
|
||
|
||
// Raw CLR values
|
||
var currentClr = property.CurrentValue;
|
||
var oldClr = property.OriginalValue;
|
||
|
||
// Convert to provider (JSON for Tasks)
|
||
var currentValue = converter?.ConvertToProvider(currentClr) ?? currentClr;
|
||
var oldValue = converter?.ConvertToProvider(oldClr) ?? oldClr;
|
||
|
||
var isSoftDelete =
|
||
propertyInfo.CustomAttributes.SingleOrDefault(x =>
|
||
x.AttributeType == typeof(AuditSoftDeleteAttribute));
|
||
if (isSoftDelete != null && auditEntry.AuditType == AuditType.Update)
|
||
{
|
||
if (property.IsModified)
|
||
{
|
||
if (!currentValue!.Equals(oldValue))
|
||
{
|
||
var deletedValue = isSoftDelete.ConstructorArguments[0];
|
||
auditEntry.AuditType = deletedValue.Value!.Equals(currentValue)
|
||
? AuditType.Delete
|
||
: AuditType.Restore;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
var isRedacted =
|
||
propertyInfo.CustomAttributes.Any(x => x.AttributeType == typeof(RedactAuditAttribute));
|
||
|
||
var currentDisplayName = GetNewName(property) ?? GetEnumAsString(currentValue);
|
||
var oldDisplayName = GetOldName(property) ?? GetEnumAsString(oldValue);
|
||
|
||
// Compare using EF’s own comparer if available
|
||
var valuesMatch = comparer != null
|
||
? comparer.Equals(currentClr, oldClr)
|
||
: Equals(currentValue, oldValue);
|
||
|
||
if (isRedacted)
|
||
{
|
||
const string redacted = "<Redacted>";
|
||
|
||
currentValue = redacted;
|
||
oldValue = redacted;
|
||
|
||
currentDisplayName = null;
|
||
oldDisplayName = null;
|
||
}
|
||
|
||
switch (entry.State)
|
||
{
|
||
case EntityState.Added:
|
||
auditEntry.Fields[propertyName] = new Change
|
||
{
|
||
NewValue = currentValue,
|
||
NewDisplayName = currentDisplayName
|
||
};
|
||
break;
|
||
case EntityState.Deleted:
|
||
auditEntry.Fields[propertyName] = new Change
|
||
{
|
||
OldValue = oldValue,
|
||
OldDisplayName = oldDisplayName
|
||
};
|
||
break;
|
||
case EntityState.Modified:
|
||
if (!valuesMatch)
|
||
{
|
||
auditEntry.Fields[propertyName] = new Change
|
||
{
|
||
OldValue = oldValue,
|
||
OldDisplayName = oldDisplayName,
|
||
NewValue = currentValue,
|
||
NewDisplayName = currentDisplayName
|
||
};
|
||
}
|
||
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (auditEntry.Fields.Count != 0 | auditEntry.AuditType != AuditType.Update )
|
||
auditEntries.Add(auditEntry);
|
||
}
|
||
|
||
return auditEntries;
|
||
}
|
||
|
||
public void OnAfterSaveChanges(AuditableEntityContext dbContext, List<AuditRecorder> auditEntries, CancellationToken cancellationToken)
|
||
{
|
||
foreach (var auditEntry in auditEntries)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
if (auditEntry.Entry != null)
|
||
auditEntry.DrillUp = TraceDrillUp(dbContext, auditEntry.Entry.Entity, cancellationToken);
|
||
|
||
AddAuditEntry(auditEntry, cancellationToken);
|
||
}
|
||
}
|
||
|
||
private void AddAuditEntry(AuditRecorder auditEntry, CancellationToken cancellationToken)
|
||
{
|
||
var auditLog = _auditableEntityContext.AuditDetails.Add(auditEntry.ToAudit());
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var childAuditDrillUp = auditEntry.DrillUp;
|
||
|
||
var childDrillDownEntity = new AuditEntry
|
||
{
|
||
AuditLog = auditLog.Entity,
|
||
DisplayName = childAuditDrillUp.EntryName,
|
||
EntityName = childAuditDrillUp.EntityName,
|
||
PrimaryKey = childAuditDrillUp.ToPrimaryKeyJson(),
|
||
};
|
||
_auditableEntityContext.AuditEntries.Add(childDrillDownEntity);
|
||
|
||
childDrillDownEntity.IsPrimary = true;
|
||
|
||
foreach (var parent in childAuditDrillUp.Parents)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
AddParent(auditLog.Entity, childDrillDownEntity, parent, cancellationToken);
|
||
}
|
||
}
|
||
|
||
private void AddParent(AuditDetail auditLog, AuditEntry childDrillDownEntity, AuditDrillUp parent, CancellationToken cancellationToken)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var parentDrillDownEntity = new AuditEntry
|
||
{
|
||
AuditLog = auditLog,
|
||
DisplayName = parent.EntryName,
|
||
EntityName = parent.EntityName,
|
||
PrimaryKey = parent.ToPrimaryKeyJson(),
|
||
};
|
||
_auditableEntityContext.AuditEntries.Add(childDrillDownEntity);
|
||
|
||
_auditableEntityContext.AuditDrillHierarchies.Add(new AuditDrillHierarchy
|
||
{
|
||
ParentAuditDrillDownEntity = parentDrillDownEntity,
|
||
ChildAuditDrillDownEntity = childDrillDownEntity
|
||
});
|
||
|
||
foreach (var grandparent in parent.Parents)
|
||
AddParent(auditLog, parentDrillDownEntity, grandparent, cancellationToken);
|
||
}
|
||
|
||
private AuditDrillUp TraceDrillUp(AuditableEntityContext dbContext, object entity, CancellationToken cancellationToken)
|
||
{
|
||
var type = entity.GetType();
|
||
var drillUp = new AuditDrillUp
|
||
{
|
||
EntityName = type.FullName ?? type.Name,
|
||
};
|
||
|
||
try
|
||
{
|
||
drillUp.EntryName = GetAuditName(entity) ?? string.Empty;
|
||
}
|
||
catch
|
||
{
|
||
LoadForeignKeysIfNotAlreadyLoaded(dbContext, entity, cancellationToken);
|
||
drillUp.EntryName = GetAuditName(entity) ?? string.Empty;
|
||
}
|
||
|
||
var properties = type.GetProperties();
|
||
foreach (var property in properties)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var isPrimaryKey = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(KeyAttribute)) != null;
|
||
if (isPrimaryKey)
|
||
{
|
||
drillUp.PrimaryKey[property.Name] = property.GetValue(entity);
|
||
}
|
||
|
||
var isLastUpdated = property.CustomAttributes.Any(x => x.AttributeType == typeof(AuditLastUpdatedAttribute));
|
||
if (isLastUpdated)
|
||
{
|
||
property.SetValue(entity, _clock.GetNow);
|
||
}
|
||
|
||
var isParent = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(AuditParentAttribute)) != null;
|
||
if (isParent)
|
||
{
|
||
LoadParentIfNotAlreadyLoaded(dbContext, property, entity, cancellationToken);
|
||
|
||
var parent = property.GetValue(entity);
|
||
if (parent != null)
|
||
{
|
||
var parentDrillUp = TraceDrillUp(dbContext, parent, cancellationToken);
|
||
drillUp.Parents.Add(parentDrillUp);
|
||
}
|
||
}
|
||
}
|
||
|
||
return drillUp;
|
||
}
|
||
|
||
private static void LoadForeignKeysIfNotAlreadyLoaded(AuditableEntityContext dbContext, object entity, CancellationToken cancellationToken)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var type = entity.GetType();
|
||
var properties = type.GetProperties();
|
||
|
||
foreach (var property in properties)
|
||
{
|
||
var foreignKeyAttribute = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(ForeignKeyAttribute));
|
||
var lookupFieldName = foreignKeyAttribute?.ConstructorArguments[0].Value?.ToString();
|
||
if (lookupFieldName == null)
|
||
continue;
|
||
|
||
var lookupField = type.GetProperty(lookupFieldName);
|
||
var lookupValue = lookupField?.GetValue(entity);
|
||
if (lookupValue == null)
|
||
continue;
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var dbSetProperty = FindDbSet(dbContext, property.PropertyType);
|
||
if (dbSetProperty == null)
|
||
continue;
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
if (dbSetProperty.GetValue(dbContext) is not IQueryable dbSet)
|
||
continue;
|
||
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var primaryKeyProperty = GetPrimaryKeyProperty(dbSet.ElementType);
|
||
if (primaryKeyProperty == null)
|
||
continue;
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var result = FindObjectByProperty(dbSet, primaryKeyProperty, lookupValue);
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
property.SetValue(entity, result);
|
||
}
|
||
}
|
||
|
||
private static void LoadParentIfNotAlreadyLoaded(AuditableEntityContext dbContext, PropertyInfo property, object entity, CancellationToken cancellationToken)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var parent = property.GetValue(entity);
|
||
if (parent != null)
|
||
return;
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var foreignKeyAttribute = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(ForeignKeyAttribute));
|
||
var lookupFieldName = foreignKeyAttribute?.ConstructorArguments[0].Value?.ToString();
|
||
if (lookupFieldName == null)
|
||
return;
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var entityType = entity.GetType();
|
||
var lookupField = entityType.GetProperty(lookupFieldName);
|
||
var lookupValue = lookupField?.GetValue(entity);
|
||
if (lookupValue == null)
|
||
return;
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var dbSetProperty = FindDbSet(dbContext, property.PropertyType);
|
||
if (dbSetProperty == null)
|
||
return;
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
if (dbSetProperty.GetValue(dbContext) is not IQueryable dbSet)
|
||
return;
|
||
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var primaryKeyProperty = GetPrimaryKeyProperty(dbSet.ElementType);
|
||
if (primaryKeyProperty == null)
|
||
return;
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var result = FindObjectByProperty(dbSet, primaryKeyProperty, lookupValue);
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
property.SetValue(entity, result);
|
||
}
|
||
|
||
private static object FindObjectByProperty(IQueryable query, PropertyInfo primaryKeyProperty, object lookupValue)
|
||
{
|
||
var parameter = Expression.Parameter(query.ElementType);
|
||
var propExpr = Expression.Property(parameter, primaryKeyProperty);
|
||
var lambdaBody = Expression.Equal(propExpr, Expression.Constant(lookupValue, primaryKeyProperty.PropertyType));
|
||
var filterEFn = Expression.Lambda(lambdaBody, parameter);
|
||
return query.Where(filterEFn).FirstOrDefault();
|
||
}
|
||
|
||
private static PropertyInfo? GetPrimaryKeyProperty(Type entityType)
|
||
{
|
||
var entityProperties = entityType.GetProperties();
|
||
|
||
foreach (var entityProperty in entityProperties)
|
||
{
|
||
if (entityProperty.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(KeyAttribute)) != null)
|
||
{
|
||
return entityProperty;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static PropertyInfo? FindDbSet(AuditableEntityContext dbContext, Type type)
|
||
{
|
||
var thisProperties = dbContext.GetType().GetProperties();
|
||
foreach (var propertyInfo in thisProperties)
|
||
{
|
||
if (!propertyInfo.PropertyType.IsGenericType)
|
||
continue;
|
||
|
||
if (propertyInfo.PropertyType.GetGenericTypeDefinition() != typeof(DbSet<>))
|
||
continue;
|
||
|
||
if (propertyInfo.PropertyType.GenericTypeArguments[0] == type)
|
||
return propertyInfo;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static string? GetEnumAsString(object? enumValue)
|
||
{
|
||
if (enumValue == null || !enumValue.GetType().IsEnum)
|
||
return null;
|
||
|
||
var displayAttribute = ((Enum)enumValue).GetAttribute<DisplayAttribute>();
|
||
return displayAttribute != null ? displayAttribute.Name : enumValue.ToString();
|
||
}
|
||
|
||
private static string? GetNewName(PropertyEntry property)
|
||
{
|
||
var foreignKeyPropertyName = GetForeignKeyPropertyName(property);
|
||
if (foreignKeyPropertyName == null)
|
||
return null;
|
||
|
||
var foreignKeyPropertyType = GetForeignKeyPropertyType(property, foreignKeyPropertyName);
|
||
|
||
if (foreignKeyPropertyType == null)
|
||
return null;
|
||
|
||
var value = property.EntityEntry.Entity.GetType().GetProperty(foreignKeyPropertyType.Name)
|
||
?.GetValue(property.EntityEntry.Entity, null);
|
||
|
||
return GetAuditName(value);
|
||
}
|
||
|
||
private static string? GetAuditName(object? value)
|
||
{
|
||
if (value == null)
|
||
return null;
|
||
|
||
var properties = value.GetType().GetProperties();
|
||
foreach (var propertyInfo in properties)
|
||
{
|
||
var auditNameProperty =
|
||
propertyInfo.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(AuditNameAttribute));
|
||
if (auditNameProperty == null)
|
||
continue;
|
||
|
||
var actualValue = propertyInfo.GetValue(value, null);
|
||
{
|
||
return actualValue?.ToString();
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private string? GetOldName(PropertyEntry property)
|
||
{
|
||
if (property.OriginalValue == null)
|
||
return null;
|
||
|
||
var foreignKeyPropertyName = GetForeignKeyPropertyName(property);
|
||
if (foreignKeyPropertyName == null)
|
||
return null;
|
||
|
||
var foreignKeyPropertyType = GetForeignKeyPropertyType(property, foreignKeyPropertyName);
|
||
|
||
if (foreignKeyPropertyType == null)
|
||
return null;
|
||
|
||
var dbSetProperty = FindDbSet(_auditableEntityContext, foreignKeyPropertyType.PropertyType)!;
|
||
var dbSet = (dbSetProperty.GetValue(_auditableEntityContext) as IQueryable)!;
|
||
var primaryKeyProperty = GetPrimaryKeyProperty(dbSet.ElementType)!;
|
||
var value = FindObjectByProperty(dbSet, primaryKeyProperty, property.OriginalValue!);
|
||
|
||
return GetAuditName(value);
|
||
}
|
||
|
||
private static PropertyInfo? GetForeignKeyPropertyType(PropertyEntry property, string foreignKeyPropertyName)
|
||
{
|
||
var entityProperties = property.EntityEntry.Entity.GetType().GetProperties();
|
||
foreach (var entityProperty in entityProperties)
|
||
{
|
||
if (entityProperty.Name == foreignKeyPropertyName)
|
||
{
|
||
return entityProperty;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static string? GetForeignKeyPropertyName(PropertyEntry property)
|
||
{
|
||
var propertyInfo = property.EntityEntry.Entity.GetType().GetProperties();
|
||
foreach (var entityProperty in propertyInfo)
|
||
{
|
||
var foreignKeyAttributeData = entityProperty.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(ForeignKeyAttribute));
|
||
if (foreignKeyAttributeData != null)
|
||
{
|
||
if (foreignKeyAttributeData.ConstructorArguments.Any(x => x.Value!.Equals(property.Metadata.Name)))
|
||
{
|
||
return entityProperty.Name;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static bool HasNoAuditAttribute(object entity)
|
||
{
|
||
var type = entity.GetType();
|
||
|
||
foreach (var attribute in type.CustomAttributes)
|
||
{
|
||
if (attribute.AttributeType == typeof(NoAuditAttribute))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
} |