Skip to content
This repository was archived by the owner on Feb 20, 2025. It is now read-only.

Commit 24d98d9

Browse files
committed
Finish audit fixes
1 parent cd2be1e commit 24d98d9

File tree

6 files changed

+198
-114
lines changed

6 files changed

+198
-114
lines changed

src/Shiny.Extensions.EntityFramework/Auditing/AuditEntry.cs

+1-2
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ public class AuditEntry
66
{
77
public int Id { get; set; }
88
public string EntityId { get; set; }
9-
public string EntityType { get; set; }
9+
public string TableName { get; set; }
1010

1111
public string? UserIdentifier { get; set; }
12-
public string? Tenant { get; set; }
1312
public string? AppLocation { get; set; }
1413
public string? UserIpAddress { get; set; }
1514

Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
using Microsoft.EntityFrameworkCore;
2-
using Microsoft.EntityFrameworkCore.ChangeTracking;
31
using Microsoft.EntityFrameworkCore.Diagnostics;
4-
using System.Linq;
5-
using System.Text.Json;
62

73
namespace Shiny.Auditing;
84

@@ -11,114 +7,32 @@ namespace Shiny.Auditing;
117
// TODO: catch ExecuteUpdate & ExecuteDelete - how? ExecuteDelete isn't something I believe in with audited tables anyhow - So only ExecuteUpdate
128
public class AuditSaveChangesInterceptor(IAuditInfoProvider provider) : SaveChangesInterceptor
139
{
10+
AuditScope? auditScope;
11+
1412
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
1513
{
16-
var entries = this.GetAuditEntries(eventData);
17-
eventData.Context!.AddRange(entries);
14+
this.auditScope = AuditScope.Create(provider, eventData);
1815
return base.SavingChangesAsync(eventData, result, cancellationToken);
1916
}
2017

2118
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
2219
{
23-
var entries = this.GetAuditEntries(eventData);
24-
eventData.Context!.AddRange(entries);
20+
this.auditScope = AuditScope.Create(provider, eventData);
2521
return base.SavingChanges(eventData, result);
2622
}
27-
28-
29-
static DbOperation ToOperation(EntityState state)
30-
{
31-
if (state == EntityState.Added)
32-
return DbOperation.Insert;
33-
34-
if (state == EntityState.Deleted)
35-
return DbOperation.Delete;
36-
37-
return DbOperation.Update;
38-
}
39-
40-
41-
protected virtual List<AuditEntry> GetAuditEntries(DbContextEventData eventData)
42-
{
43-
var entries = new List<AuditEntry>();
44-
var changeTracker = eventData.Context!.ChangeTracker;
45-
changeTracker.DetectChanges();
46-
47-
foreach (var entry in changeTracker.Entries())
48-
{
49-
// Dot not audit entities that are not tracked, not changed, or not of type IAuditable
50-
if (entry.State != EntityState.Detached &&
51-
entry.State != EntityState.Unchanged &&
52-
entry.Entity is IAuditable auditable)
53-
{
54-
if (entry.State == EntityState.Modified)
55-
{
56-
entry.CurrentValues[nameof(IAuditable.DateUpdated)] = DateTimeOffset.UtcNow;
57-
}
58-
else if (entry.State == EntityState.Added)
59-
{
60-
entry.CurrentValues[nameof(IAuditable.DateUpdated)] = DateTimeOffset.UtcNow;
61-
entry.CurrentValues[nameof(IAuditable.DateCreated)] = DateTimeOffset.UtcNow;
62-
}
63-
64-
entry.CurrentValues[nameof(IAuditable.LastEditUserIdentifier)] = provider.UserIdentifier;
65-
var auditEntry = new AuditEntry
66-
{
67-
Operation = ToOperation(entry.State),
68-
EntityId = entry.Properties.Single(p => p.Metadata.IsPrimaryKey()).CurrentValue!.ToString()!,
69-
EntityType = entry.Metadata.ClrType.Name,
70-
Timestamp = DateTime.UtcNow,
71-
ChangeSet = this.CalculateChangeSet(entry), // TODO: NULL on add
72-
73-
UserIdentifier = provider.UserIdentifier,
74-
UserIpAddress = provider.UserIpAddress,
75-
Tenant = provider.Tenant,
76-
AppLocation = provider.AppLocation
77-
};
78-
entries.Add(auditEntry);
79-
}
80-
}
81-
return entries;
82-
}
83-
8423

85-
protected virtual JsonDocument CalculateChangeSet(EntityEntry entry)
86-
{
87-
// TODO: if I'm deleting, I want all the original values (even ignored?)
88-
var dict = new Dictionary<string, object>();
89-
foreach (var property in entry.Properties)
90-
{
91-
if (this.IsAuditedProperty(property))
92-
{
93-
dict.Add(property.Metadata.Name, property.OriginalValue ?? "NULL");
94-
}
95-
}
96-
97-
var json = JsonSerializer.SerializeToDocument(dict);
98-
return json;
99-
}
10024

101-
102-
protected virtual bool IsAuditedProperty(PropertyEntry entry)
25+
public override async ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
10326
{
104-
if (!entry.IsModified)
105-
return false;
106-
107-
if (entry.OriginalValue is byte[])
108-
return false;
109-
110-
if (this.IsPropertyIgnored(entry.Metadata.Name))
111-
return false;
27+
if (this.auditScope != null)
28+
await this.auditScope.Commit(cancellationToken);
11229

113-
return true;
30+
return await base.SavedChangesAsync(eventData, result, cancellationToken);
11431
}
11532

116-
117-
protected virtual bool IsPropertyIgnored(string propertyName) => propertyName switch
33+
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
11834
{
119-
nameof(IAuditable.LastEditUserIdentifier) => true,
120-
nameof(IAuditable.DateCreated) => true,
121-
nameof(IAuditable.DateUpdated) => true,
122-
_ => false
123-
};
35+
this.auditScope?.Commit();
36+
return base.SavedChanges(eventData, result);
37+
}
12438
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
using System.Linq;
2+
using System.Text.Json;
3+
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.EntityFrameworkCore.ChangeTracking;
5+
using Microsoft.EntityFrameworkCore.Diagnostics;
6+
7+
namespace Shiny.Auditing;
8+
9+
public class AuditScope
10+
{
11+
readonly List<EntityAuditContext> entries;
12+
readonly DbContext data;
13+
14+
15+
AuditScope(DbContext data, List<EntityAuditContext> entries)
16+
{
17+
this.data = data;
18+
this.entries = entries;
19+
}
20+
21+
22+
public static AuditScope Create(IAuditInfoProvider provider, DbContextEventData eventData)
23+
{
24+
var state = AuditScope.BuildState(provider, eventData);
25+
var scope = new AuditScope(eventData.Context!, state);
26+
return scope;
27+
}
28+
29+
30+
public async ValueTask Commit(CancellationToken cancellationToken)
31+
{
32+
if (this.entries.Count == 0)
33+
return;
34+
35+
this.CompleteAudit();
36+
await this.data.SaveChangesAsync(cancellationToken);
37+
}
38+
39+
40+
public void Commit()
41+
{
42+
if (this.entries.Count == 0)
43+
return;
44+
45+
this.CompleteAudit();
46+
this.data.SaveChanges();
47+
}
48+
49+
50+
void CompleteAudit()
51+
{
52+
foreach (var entry in this.entries)
53+
{
54+
entry.CurrentAudit.EntityId = GetPrimaryKey(entry.Entry);
55+
this.data.Add(entry.CurrentAudit);
56+
}
57+
}
58+
59+
60+
static string GetPrimaryKey(EntityEntry entry)
61+
{
62+
var meta = entry.Properties.Where(x => x.Metadata.IsPrimaryKey()).ToList();
63+
if (meta.Count == 1)
64+
return meta.First().CurrentValue!.ToString()!;
65+
66+
var primaryKeys = new string[meta.Count];
67+
for (var i = 0; i < meta.Count; i++)
68+
{
69+
var key = meta[i];
70+
primaryKeys[i] = $"{key.Metadata.Name}-{key.CurrentValue!}";
71+
}
72+
73+
var result = String.Join('_', primaryKeys);
74+
return result;
75+
}
76+
77+
static DbOperation ToOperation(EntityState state)
78+
{
79+
if (state == EntityState.Added)
80+
return DbOperation.Insert;
81+
82+
if (state == EntityState.Deleted)
83+
return DbOperation.Delete;
84+
85+
return DbOperation.Update;
86+
}
87+
88+
89+
static List<EntityAuditContext> BuildState(IAuditInfoProvider provider, DbContextEventData eventData)
90+
{
91+
var entries = new List<EntityAuditContext>();
92+
var changeTracker = eventData.Context!.ChangeTracker;
93+
changeTracker.DetectChanges();
94+
95+
foreach (var entry in changeTracker.Entries())
96+
{
97+
if (entry.State != EntityState.Detached &&
98+
entry.State != EntityState.Unchanged &&
99+
entry.Entity is IAuditable)
100+
{
101+
if (entry.State == EntityState.Modified)
102+
{
103+
entry.CurrentValues[nameof(IAuditable.DateUpdated)] = DateTimeOffset.UtcNow;
104+
}
105+
else if (entry.State == EntityState.Added)
106+
{
107+
entry.CurrentValues[nameof(IAuditable.DateUpdated)] = DateTimeOffset.UtcNow;
108+
entry.CurrentValues[nameof(IAuditable.DateCreated)] = DateTimeOffset.UtcNow;
109+
}
110+
entry.CurrentValues[nameof(IAuditable.LastEditUserIdentifier)] = provider.UserIdentifier;
111+
112+
var auditEntry = new AuditEntry
113+
{
114+
Operation = ToOperation(entry.State),
115+
TableName = entry.Metadata.GetTableName()!,
116+
Timestamp = DateTime.UtcNow,
117+
ChangeSet = CalculateChangeSet(entry), // what about post values?
118+
119+
UserIdentifier = provider.UserIdentifier,
120+
UserIpAddress = provider.UserIpAddress,
121+
AppLocation = provider.AppLocation
122+
};
123+
entries.Add(new EntityAuditContext(entry, auditEntry));
124+
}
125+
}
126+
return entries;
127+
}
128+
129+
130+
static JsonDocument CalculateChangeSet(EntityEntry entry)
131+
{
132+
var dict = new Dictionary<string, object>();
133+
foreach (var property in entry.Properties)
134+
{
135+
if (IsAuditedProperty(property) && (entry.State == EntityState.Deleted || property.IsModified))
136+
{
137+
dict.Add(property.Metadata.Name, property.OriginalValue ?? "NULL");
138+
}
139+
}
140+
141+
var json = JsonSerializer.SerializeToDocument(dict);
142+
return json;
143+
}
144+
145+
146+
static bool IsAuditedProperty(PropertyEntry entry)
147+
{
148+
if (entry.OriginalValue is byte[])
149+
return false;
150+
151+
if (IsPropertyIgnored(entry.Metadata.Name))
152+
return false;
153+
154+
return true;
155+
}
156+
157+
158+
static bool IsPropertyIgnored(string propertyName) => propertyName switch
159+
{
160+
nameof(IAuditable.LastEditUserIdentifier) => true,
161+
nameof(IAuditable.DateCreated) => true,
162+
nameof(IAuditable.DateUpdated) => true,
163+
_ => false
164+
};
165+
}
166+
167+
public record EntityAuditContext(
168+
EntityEntry Entry,
169+
AuditEntry CurrentAudit
170+
);

src/Shiny.Extensions.EntityFramework/Auditing/IAuditInfoProvider.cs

+3-5
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,6 @@ public interface IAuditInfoProvider
77
/// </summary>
88
string? AppLocation { get; }
99

10-
/// <summary>
11-
/// For multi-tenanted apps if available
12-
/// </summary>
13-
string? Tenant { get; }
14-
1510
/// <summary>
1611
/// Your user ID or name if available
1712
/// </summary>
@@ -21,4 +16,7 @@ public interface IAuditInfoProvider
2116
/// The IP address of the remote user if available
2217
/// </summary>
2318
string? UserIpAddress { get; }
19+
20+
21+
// IDictionary<string, object> AdditionalProperties { get; }
2422
}

src/Shiny.Extensions.EntityFramework/EntityFrameworkExtensions.cs

+1-2
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,13 @@ public static ModelBuilder MapAuditing(this ModelBuilder modelBuilder)
3636
map.HasKey(x => x.Id);
3737
map.Property(x => x.Id).ValueGeneratedOnAdd();
3838
map.Property(x => x.EntityId).HasMaxLength(100);
39-
map.Property(x => x.EntityType).HasMaxLength(255);
39+
map.Property(x => x.TableName).HasMaxLength(255);
4040
map.Property(x => x.Operation);
4141
map.Property(x => x.Timestamp);
4242
map.Property(x => x.ChangeSet);
4343

4444
map.Property(x => x.UserIdentifier).HasMaxLength(50);
4545
map.Property(x => x.UserIpAddress).HasMaxLength(39);
46-
map.Property(x => x.Tenant).HasMaxLength(50);
4746
map.Property(x => x.AppLocation).HasMaxLength(1024);
4847

4948
return modelBuilder;

tests/Shiny.Extensions.EntityFramework.Tests/AuditTests.cs

+11-7
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ public AuditTests()
1818
this.auditProvider = new();
1919

2020
var services = new ServiceCollection();
21-
services.AddDbContext<TestDbContext>(x => x
21+
services.AddSingleton<IAuditInfoProvider>(this.auditProvider);
22+
services.AddScoped<AuditSaveChangesInterceptor>();
23+
services.AddDbContext<TestDbContext>((sp, opts) =>
24+
{
2225
// .UseSqlite("Data Source=test.db")
23-
.UseNpgsql(new NpgsqlDataSourceBuilder("User ID=sa;Password=Blargh911!;Host=localhost;Port=5432;Database=AuditUnitTests;Pooling=true;Connection Lifetime=30;").Build())
24-
.AddInterceptors(new AuditSaveChangesInterceptor(this.auditProvider))
25-
);
26+
opts.UseNpgsql(new NpgsqlDataSourceBuilder("User ID=sa;Password=Blargh911!;Host=localhost;Port=5432;Database=AuditUnitTests;Pooling=true;Connection Lifetime=30;").Build());
27+
var interceptor = sp.GetRequiredService<AuditSaveChangesInterceptor>();
28+
opts.AddInterceptors(interceptor);
29+
});
2630
this.serviceProvider = services.BuildServiceProvider();
2731

2832
using var scope = this.serviceProvider.CreateScope();
@@ -75,8 +79,8 @@ await this.DoDb(async data =>
7579
var audit = await data.AuditEntries.FirstOrDefaultAsync(x => x.Operation == DbOperation.Delete);
7680
audit.Should().NotBeNull("No Delete Audit Found");
7781
AssertAudit(audit!, DbOperation.Delete);
78-
79-
// TODO: check changeset
82+
83+
audit!.ChangeSet.RootElement.GetProperty("Name").GetString().Should().Be("Cadillac");
8084
});
8185
}
8286

@@ -97,7 +101,7 @@ await this.DoDb(async data =>
97101
audit.Should().NotBeNull("No Delete Audit Found");
98102
AssertAudit(audit!, DbOperation.Update);
99103

100-
// TODO: check changeset
104+
audit!.ChangeSet.RootElement.GetProperty("Name").GetString().Should().Be("Cadillac");
101105
});
102106
}
103107

0 commit comments

Comments
 (0)