Skip to content

Commit

Permalink
Finish audit fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
aritchie committed May 15, 2024
1 parent cd2be1e commit 24d98d9
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 114 deletions.
3 changes: 1 addition & 2 deletions src/Shiny.Extensions.EntityFramework/Auditing/AuditEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ public class AuditEntry
{
public int Id { get; set; }
public string EntityId { get; set; }

Check warning on line 8 in src/Shiny.Extensions.EntityFramework/Auditing/AuditEntry.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'EntityId' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

Check warning on line 8 in src/Shiny.Extensions.EntityFramework/Auditing/AuditEntry.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'EntityId' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
public string EntityType { get; set; }
public string TableName { get; set; }

Check warning on line 9 in src/Shiny.Extensions.EntityFramework/Auditing/AuditEntry.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'TableName' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

Check warning on line 9 in src/Shiny.Extensions.EntityFramework/Auditing/AuditEntry.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'TableName' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

public string? UserIdentifier { get; set; }
public string? Tenant { get; set; }
public string? AppLocation { get; set; }
public string? UserIpAddress { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Linq;
using System.Text.Json;

namespace Shiny.Auditing;

Expand All @@ -11,114 +7,32 @@ namespace Shiny.Auditing;
// TODO: catch ExecuteUpdate & ExecuteDelete - how? ExecuteDelete isn't something I believe in with audited tables anyhow - So only ExecuteUpdate
public class AuditSaveChangesInterceptor(IAuditInfoProvider provider) : SaveChangesInterceptor
{
AuditScope? auditScope;

public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
var entries = this.GetAuditEntries(eventData);
eventData.Context!.AddRange(entries);
this.auditScope = AuditScope.Create(provider, eventData);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}

public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
var entries = this.GetAuditEntries(eventData);
eventData.Context!.AddRange(entries);
this.auditScope = AuditScope.Create(provider, eventData);
return base.SavingChanges(eventData, result);
}


static DbOperation ToOperation(EntityState state)
{
if (state == EntityState.Added)
return DbOperation.Insert;

if (state == EntityState.Deleted)
return DbOperation.Delete;

return DbOperation.Update;
}


protected virtual List<AuditEntry> GetAuditEntries(DbContextEventData eventData)
{
var entries = new List<AuditEntry>();
var changeTracker = eventData.Context!.ChangeTracker;
changeTracker.DetectChanges();

foreach (var entry in changeTracker.Entries())
{
// Dot not audit entities that are not tracked, not changed, or not of type IAuditable
if (entry.State != EntityState.Detached &&
entry.State != EntityState.Unchanged &&
entry.Entity is IAuditable auditable)
{
if (entry.State == EntityState.Modified)
{
entry.CurrentValues[nameof(IAuditable.DateUpdated)] = DateTimeOffset.UtcNow;
}
else if (entry.State == EntityState.Added)
{
entry.CurrentValues[nameof(IAuditable.DateUpdated)] = DateTimeOffset.UtcNow;
entry.CurrentValues[nameof(IAuditable.DateCreated)] = DateTimeOffset.UtcNow;
}

entry.CurrentValues[nameof(IAuditable.LastEditUserIdentifier)] = provider.UserIdentifier;
var auditEntry = new AuditEntry
{
Operation = ToOperation(entry.State),
EntityId = entry.Properties.Single(p => p.Metadata.IsPrimaryKey()).CurrentValue!.ToString()!,
EntityType = entry.Metadata.ClrType.Name,
Timestamp = DateTime.UtcNow,
ChangeSet = this.CalculateChangeSet(entry), // TODO: NULL on add

UserIdentifier = provider.UserIdentifier,
UserIpAddress = provider.UserIpAddress,
Tenant = provider.Tenant,
AppLocation = provider.AppLocation
};
entries.Add(auditEntry);
}
}
return entries;
}


protected virtual JsonDocument CalculateChangeSet(EntityEntry entry)
{
// TODO: if I'm deleting, I want all the original values (even ignored?)
var dict = new Dictionary<string, object>();
foreach (var property in entry.Properties)
{
if (this.IsAuditedProperty(property))
{
dict.Add(property.Metadata.Name, property.OriginalValue ?? "NULL");
}
}

var json = JsonSerializer.SerializeToDocument(dict);
return json;
}


protected virtual bool IsAuditedProperty(PropertyEntry entry)
public override async ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
{
if (!entry.IsModified)
return false;

if (entry.OriginalValue is byte[])
return false;

if (this.IsPropertyIgnored(entry.Metadata.Name))
return false;
if (this.auditScope != null)
await this.auditScope.Commit(cancellationToken);

return true;
return await base.SavedChangesAsync(eventData, result, cancellationToken);
}


protected virtual bool IsPropertyIgnored(string propertyName) => propertyName switch
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
nameof(IAuditable.LastEditUserIdentifier) => true,
nameof(IAuditable.DateCreated) => true,
nameof(IAuditable.DateUpdated) => true,
_ => false
};
this.auditScope?.Commit();
return base.SavedChanges(eventData, result);
}
}
170 changes: 170 additions & 0 deletions src/Shiny.Extensions.EntityFramework/Auditing/AuditScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Linq;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Shiny.Auditing;

public class AuditScope
{
readonly List<EntityAuditContext> entries;
readonly DbContext data;


AuditScope(DbContext data, List<EntityAuditContext> entries)
{
this.data = data;
this.entries = entries;
}


public static AuditScope Create(IAuditInfoProvider provider, DbContextEventData eventData)
{
var state = AuditScope.BuildState(provider, eventData);
var scope = new AuditScope(eventData.Context!, state);
return scope;
}


public async ValueTask Commit(CancellationToken cancellationToken)
{
if (this.entries.Count == 0)
return;

this.CompleteAudit();
await this.data.SaveChangesAsync(cancellationToken);
}


public void Commit()
{
if (this.entries.Count == 0)
return;

this.CompleteAudit();
this.data.SaveChanges();
}


void CompleteAudit()
{
foreach (var entry in this.entries)
{
entry.CurrentAudit.EntityId = GetPrimaryKey(entry.Entry);
this.data.Add(entry.CurrentAudit);
}
}


static string GetPrimaryKey(EntityEntry entry)
{
var meta = entry.Properties.Where(x => x.Metadata.IsPrimaryKey()).ToList();
if (meta.Count == 1)
return meta.First().CurrentValue!.ToString()!;

var primaryKeys = new string[meta.Count];
for (var i = 0; i < meta.Count; i++)
{
var key = meta[i];
primaryKeys[i] = $"{key.Metadata.Name}-{key.CurrentValue!}";
}

var result = String.Join('_', primaryKeys);
return result;
}

static DbOperation ToOperation(EntityState state)
{
if (state == EntityState.Added)
return DbOperation.Insert;

if (state == EntityState.Deleted)
return DbOperation.Delete;

return DbOperation.Update;
}


static List<EntityAuditContext> BuildState(IAuditInfoProvider provider, DbContextEventData eventData)
{
var entries = new List<EntityAuditContext>();
var changeTracker = eventData.Context!.ChangeTracker;
changeTracker.DetectChanges();

foreach (var entry in changeTracker.Entries())
{
if (entry.State != EntityState.Detached &&
entry.State != EntityState.Unchanged &&
entry.Entity is IAuditable)
{
if (entry.State == EntityState.Modified)
{
entry.CurrentValues[nameof(IAuditable.DateUpdated)] = DateTimeOffset.UtcNow;
}
else if (entry.State == EntityState.Added)
{
entry.CurrentValues[nameof(IAuditable.DateUpdated)] = DateTimeOffset.UtcNow;
entry.CurrentValues[nameof(IAuditable.DateCreated)] = DateTimeOffset.UtcNow;
}
entry.CurrentValues[nameof(IAuditable.LastEditUserIdentifier)] = provider.UserIdentifier;

var auditEntry = new AuditEntry
{
Operation = ToOperation(entry.State),
TableName = entry.Metadata.GetTableName()!,
Timestamp = DateTime.UtcNow,
ChangeSet = CalculateChangeSet(entry), // what about post values?

UserIdentifier = provider.UserIdentifier,
UserIpAddress = provider.UserIpAddress,
AppLocation = provider.AppLocation
};
entries.Add(new EntityAuditContext(entry, auditEntry));
}
}
return entries;
}


static JsonDocument CalculateChangeSet(EntityEntry entry)
{
var dict = new Dictionary<string, object>();
foreach (var property in entry.Properties)
{
if (IsAuditedProperty(property) && (entry.State == EntityState.Deleted || property.IsModified))
{
dict.Add(property.Metadata.Name, property.OriginalValue ?? "NULL");
}
}

var json = JsonSerializer.SerializeToDocument(dict);
return json;
}


static bool IsAuditedProperty(PropertyEntry entry)
{
if (entry.OriginalValue is byte[])
return false;

if (IsPropertyIgnored(entry.Metadata.Name))
return false;

return true;
}


static bool IsPropertyIgnored(string propertyName) => propertyName switch
{
nameof(IAuditable.LastEditUserIdentifier) => true,
nameof(IAuditable.DateCreated) => true,
nameof(IAuditable.DateUpdated) => true,
_ => false
};
}

public record EntityAuditContext(
EntityEntry Entry,
AuditEntry CurrentAudit
);
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@ public interface IAuditInfoProvider
/// </summary>
string? AppLocation { get; }

/// <summary>
/// For multi-tenanted apps if available
/// </summary>
string? Tenant { get; }

/// <summary>
/// Your user ID or name if available
/// </summary>
Expand All @@ -21,4 +16,7 @@ public interface IAuditInfoProvider
/// The IP address of the remote user if available
/// </summary>
string? UserIpAddress { get; }


// IDictionary<string, object> AdditionalProperties { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,13 @@ public static ModelBuilder MapAuditing(this ModelBuilder modelBuilder)
map.HasKey(x => x.Id);
map.Property(x => x.Id).ValueGeneratedOnAdd();
map.Property(x => x.EntityId).HasMaxLength(100);
map.Property(x => x.EntityType).HasMaxLength(255);
map.Property(x => x.TableName).HasMaxLength(255);
map.Property(x => x.Operation);
map.Property(x => x.Timestamp);
map.Property(x => x.ChangeSet);

map.Property(x => x.UserIdentifier).HasMaxLength(50);
map.Property(x => x.UserIpAddress).HasMaxLength(39);
map.Property(x => x.Tenant).HasMaxLength(50);
map.Property(x => x.AppLocation).HasMaxLength(1024);

return modelBuilder;
Expand Down
18 changes: 11 additions & 7 deletions tests/Shiny.Extensions.EntityFramework.Tests/AuditTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ public AuditTests()
this.auditProvider = new();

var services = new ServiceCollection();
services.AddDbContext<TestDbContext>(x => x
services.AddSingleton<IAuditInfoProvider>(this.auditProvider);
services.AddScoped<AuditSaveChangesInterceptor>();
services.AddDbContext<TestDbContext>((sp, opts) =>
{
// .UseSqlite("Data Source=test.db")
.UseNpgsql(new NpgsqlDataSourceBuilder("User ID=sa;Password=Blargh911!;Host=localhost;Port=5432;Database=AuditUnitTests;Pooling=true;Connection Lifetime=30;").Build())
.AddInterceptors(new AuditSaveChangesInterceptor(this.auditProvider))
);
opts.UseNpgsql(new NpgsqlDataSourceBuilder("User ID=sa;Password=Blargh911!;Host=localhost;Port=5432;Database=AuditUnitTests;Pooling=true;Connection Lifetime=30;").Build());
var interceptor = sp.GetRequiredService<AuditSaveChangesInterceptor>();
opts.AddInterceptors(interceptor);
});
this.serviceProvider = services.BuildServiceProvider();

using var scope = this.serviceProvider.CreateScope();
Expand Down Expand Up @@ -75,8 +79,8 @@ await this.DoDb(async data =>
var audit = await data.AuditEntries.FirstOrDefaultAsync(x => x.Operation == DbOperation.Delete);
audit.Should().NotBeNull("No Delete Audit Found");
AssertAudit(audit!, DbOperation.Delete);
// TODO: check changeset
audit!.ChangeSet.RootElement.GetProperty("Name").GetString().Should().Be("Cadillac");
});
}

Expand All @@ -97,7 +101,7 @@ await this.DoDb(async data =>
audit.Should().NotBeNull("No Delete Audit Found");
AssertAudit(audit!, DbOperation.Update);
// TODO: check changeset
audit!.ChangeSet.RootElement.GetProperty("Name").GetString().Should().Be("Cadillac");
});
}

Expand Down

0 comments on commit 24d98d9

Please sign in to comment.