手记

如何在ASP.NET Core中用Entity Framework Core实现审计日志

在现代 web 应用中,出于监控、合规和调试等原因,可能需要追踪数据变更。这个过程被称为创建审计日志审计轨迹),允许开发人员看到是谁进行了更改、更改时间以及更改内容。审计日志审计轨迹)提供了数据更改的历史记录。

在这篇博客文章中,我将展示如何在ASP.NET Core应用程序中使用Entity Framework Core (EF Core) 实现审计日志。

在我的网站 我分享 .NET 和架构的最佳做法。
快来订阅 来成为一名更好的开发者。

应用审计:

我们将要审计的应用

今天我们将为“图书管理”应用程序实施审计追踪,该应用程序具有以下实体:

  • 书籍
  • 作者
  • 用户

我发现,在所有需要审计的实体中包含以下这些属性很有用。

    public interface IAuditableEntity  
    {  
        // 创建时间(UTC时间)
        DateTime CreatedAtUtc { get; set; }  

        // 更新时间(UTC时间,可空)
        DateTime? UpdatedAtUtc { get; set; }  

        // 创建人
        string CreatedBy { get; set; }  

        // 更新人(可空)
        string? UpdatedBy { get; set; }  
    }

我们应当让所有可审计的实体继承这个接口,例如,User 和 Book。

    public class User : IAuditableEntity  // 用户类,实现可审计实体接口
    {  
        public Guid Id { get; set; } // 唯一标识符
        public required string Email { get; set; } // 电子邮件

        public DateTime CreatedAtUtc { get; set; } // 创建时间(UTC)
        public DateTime? UpdatedAtUtc { get; set; } // 更新时间(UTC)
        public string CreatedBy { get; set; } = null!; // 创建人
        public string? UpdatedBy { get; set; } // 更新人
    }  

    public class Book : IAuditableEntity // 书籍类,实现可审计实体接口
    {  
        public required Guid Id { get; set; } // 唯一标识符
        public required string Title { get; set; } // 标题
        public required int Year { get; set; } // 年份
        public Guid AuthorId { get; set; } // 作者唯一标识符
        public Author Author { get; set; } = null!; // 书籍关联的作者

        public DateTime CreatedAtUtc { get; set; } // 创建时间(UTC)
        public DateTime? UpdatedAtUtc { get; set; } // 更新时间(UTC)
        public string CreatedBy { get; set; } = null!; // 创建人
        public string? UpdatedBy { get; set; } // 更新人
    }

我们现在有几种选择,可以为每个实体手动实现审计轨迹,或者实现一个自动应用于所有实体的解决方案。在这篇文章里,我会向您展示第二种方案,因为它更稳定且更易于维护。

配置(Configuring)EF Core 中的审计日志实体

实施审计追踪的第一步是创建一个用于存储审计日志的实体,该实体将审计日志存储在单独的数据库表中。该实体应捕获例如实体类型、主键、已更改属性及其旧值和新值,以及更改的时间戳信息。

    public class 审计跟踪  
    {  
        public required Guid Id { get; set; }  

        public Guid? UserId { get; set; }  

        public User? User { get; set; }  

        public TrailType 跟踪类型 { get; set; }  

        public DateTime Utc日期 { get; set; }  

        public required string 实体名称 { get; set; }  

        public string? 主键 { get; set; }  

        public Dictionary<string, object?> 旧值 { get; set; } = new Dictionary<string, object?>();  

        public Dictionary<string, object?> 新值 { get; set; } = new Dictionary<string, object?>();  

        public List<string> 更改列 { get; set; } = new List<string>();  
    }

在这里提到了一个User实体(即用户)的引用。根据你的应用需求,你可能需要也可能不需要这个引用。

审计追踪可以有以下几种类型:

  • 实体已创建完成
  • 实体已更新完毕
  • 实体已成功删除
public 枚举 TrailType : byte //定义轨迹类型枚举
{
    无 = 0, //表示无轨迹类型
    创建 = 1, //表示创建操作
    更新 = 2, //表示更新操作
    删除 = 3 //表示删除操作
}

我们来看看如何在EF Core中配置审计日志实体,

    public class 审计跟踪配置 : IEntityType配置<AuditTrail>  
    {  
        public void 配置(EntityTypeBuilder<审计跟踪项> 建筑师)  
        {  
            建筑师.ToTable("audit_trails");  
            建筑师.HasKey(e => e.Id);  

            建筑师.HasIndex(e => e.EntityName);  

            建筑师.Property(e => e.Id);  

            建筑师.Property(e => e.UserId);  
            建筑师.Property(e => e.EntityName).HasMaxLength(100).IsRequired();  
            建筑师.Property(e => e.DateUtc).IsRequired();  
            建筑师.Property(e => e.PrimaryKey).HasMaxLength(100);  

            建筑师.Property(e => e.TrailType).HasConversion<string>().转换为字符串();  

            建筑师.Property(e => e.ChangedColumns).HasColumnType("jsonb");  
            建筑师.Property(e => e.OldValues).HasColumnType("jsonb");  
            建筑师.Property(e => e.NewValues).HasColumnType("jsonb");  

            建筑师.HasOne(e => e.User)  
                .WithMany()  
                .HasForeignKey(e => e.UserId)  
                .IsRequired(false)  
                .OnDelete(DeleteBehavior.SetNull);  
        }  
    }

我喜欢使用JSON 字段来表示 ChangedColumnsOldValuesNewValues。在这篇博客里,我的代码示例中使用的是Postgres数据库。

如果你使用的是 SQLite 或其他数据库不支持 json 列,你可以将实体中的类型改为字符串类型,并创建一个 EF Core 转换,将对象序列化为字符串,然后保存到数据库中。当你从数据库中检索数据时,这个转换会将 JSON 字符串反序列化为相应的 .NET 类型。

在使用 NET 8 和 EF 8 时,你需开启 EnableDynamicJson 选项,以便在 'jsonb' 列中存储动态 JSON。

    var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);  // 创建一个新的Npgsql数据源构建器
    dataSourceBuilder.EnableDynamicJson();  // 启用动态JSON支持

    builder.Services.AddDbContext<ApplicationDbContext>((provider, options) =>  
    {  
        var interceptor = provider.GetRequiredService<AuditableInterceptor>();  // 获取AuditableInterceptor服务

        options.EnableSensitiveDataLogging()  // 启用敏感数据日志记录
            .UseNpgsql(dataSourceBuilder.Build(), npgsqlOptions =>  
            {  
                npgsqlOptions.MigrationsHistoryTable("__MyMigrationsHistory", "devtips_audit_trails");  // 设置迁移历史表
            })  
            .AddInterceptors(interceptor)  // 添加拦截器
            .UseSnakeCaseNamingConvention();  // 使用蛇形命名约定
    });
实施所有可审计实体的:审计轨迹

我们可以在EF Core DbContext中实现一个审计功能,该功能会自动应用于所有继承自 IAuditableEntity 的实体。但首先,我们需要找到一个正在执行创建、更新或删除操作的用户。

我们来定义一个 CurrentSessionProvider,接下来从当前的 HttpRequest 中的 ClaimsPrinciple 获取当前用户的标识符:

    public interface ICurrentSessionProvider  
    {  
        Guid? 获取用户ID();  
    }  

    public class CurrentSessionProvider : ICurrentSessionProvider  
    {  
        private readonly Guid? _用户Id;  

        public CurrentSessionProvider(IHttpContextAccessor 访问对象)  
        {  
            var 用户标识符 = 访问对象.HttpContext?.User.FindFirstValue("userid");  
            if (用户标识符 is null)  
            {  
                return;  
            }  

            _用户Id = Guid.TryParse(用户标识符, out Guid guid) ? guid : null;  
        }  

        public Guid? 获取用户ID() => _用户Id;  
    }

你需要做的是在依赖注入容器中注册服务提供者和 IHttpContextAccessor 接口。

builder.Services.AddHttpContextAccessor();  
builder.Services.AddScoped<ICurrentSessionProvider, CurrentSessionProvider>();

为了生成审计日志,我们可以使用EF Core Change Tracker的功能来跟踪被创建、更新或删除的实体。

我们需要将 ICurrentSessionProvider 注入到 DbContext 中,并重载 SaveChangesAsync 方法来创建审计日志。

    public class ApplicationDbContext(  
        DbContextOptions<ApplicationDbContext> options,  
        ICurrentSessionProvider currentSessionProvider)  
        : DbContext(options)  
    {  
        public ICurrentSessionProvider CurrentSessionProvider => currentSessionProvider;  

        public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new())  
        {  
            var userId = CurrentSessionProvider.GetUserId();  

            SetAuditableProperties(userId);  

            var auditEntries = HandleAuditingBeforeSaveChanges(userId).ToList();  
            if (auditEntries.Count > 0)  
            {  
                await AuditTrails.AddRangeAsync(auditEntries, cancellationToken);  
            }  

            return await base.SaveChangesAsync(cancellationToken);  
        }  
    }

这里要注意,我们在调用 base.SaveChangesAsync 之前,会创建审计轨迹(AuditTrails),以确保所有的更改会在同一个事务中保存。

在上面的代码里,我们进行了两个操作:

  • 为创建、更新或删除的记录设置可审计属性,
  • 创建审计跟踪记录

所有继承了 IAuditableEntity 的实体,我们会为这些实体设置 CreatedUpdated 字段。在某些情况下,这些更改不是由用户而是由系统代码触发的。在这种情况下,我们会标记为“系统”进行了更改。

比如说,这可以是一个后台作业,或者数据库种子数据填充等.

    private void SetAuditableProperties(Guid? userId)  
    {  
        const string systemSource = "系统"; // 系统来源
        foreach (var entry in ChangeTracker.Entries<IAuditableEntity>()) // ChangeTracker 跟踪实体的变化,IAuditableEntity 是可审计的实体接口
        {  
            switch (entry.State) // 实体状态
            {  
                case EntityState.Added:  
                    entry.Entity.CreatedAtUtc = DateTime.UtcNow;  
                    entry.Entity.CreatedBy = userId?.ToString() ?? systemSource; // 如果 userId 为 null,则使用 "系统" 作为创建者
                    break;  

                case EntityState.Modified:  
                    entry.Entity.UpdatedAtUtc = DateTime.UtcNow;  
                    entry.Entity.UpdatedBy = userId?.ToString() ?? systemSource; // 如果 userId 为 null,则使用 "系统" 作为更新者
                    break;  
            }  
        }  
    }

我们现在来看看如何创建审计轨迹记录。下面我们再遍历一遍 IAuditableEntity 实体,并选择那些被创建、更新或删除的。

private List<AuditTrail> 处理保存更改前的审计(Guid? userId)    
{    
    var 可审计条目 = ChangeTracker.Entries<IAuditableEntity>()        
        .Where(x => x.State is EntityState.Added or EntityState.Deleted or EntityState.Modified)        
        .Select(x => 创建跟踪条目(userId, x))        
        .ToList();      

    return 可审计条目;    
}    

private static AuditTrail 创建跟踪条目(Guid? userId, EntityEntry<IAuditableEntity> entry)    
{    
    var 跟踪条目 = new AuditTrail        
    {        
        Id = Guid.NewGuid(),        
        EntityName = entry.Entity.GetType().Name,        
        UserId = userId,        
        DateUtc = DateTime.UtcNow        
    };      

    设置审计跟踪属性值(entry, 跟踪条目);        
    设置审计跟踪导航值(entry, 跟踪条目);        
    设置审计跟踪引用值(entry, 跟踪条目);      

    return 跟踪条目;    
}

审计记录可以包含以下类型的属性,包括:

  • 普通属性(例如书的标题或出版年份),
  • 关联属性(例如书的作者),
  • 导航属性(例如作者的书籍),

我们来看看如何将属性添加到审计日志中

    private static void SetAuditTrailPropertyValues(EntityEntry entry, AuditTrail trailEntry)  
    {  
        // 这些字段将由EF Core引擎自动赋值,例如在插入实体时  
        foreach (var property in entry.Properties.Where(x => !x.IsTemporary))  
        {  
            if (property.Metadata.IsPrimaryKey())  
            {  
                trailEntry.PrimaryKey = property.CurrentValue?.ToString();  
                continue;  
            }  

            // 过滤不需要出现在审核列表中的属性  
            if (property.Metadata.Name.Equals("PasswordHash"))  
            {  
                continue;  
            }  

            SetAuditTrailPropertyValue(entry, trailEntry, property);  
        }  
    }  

    private static void SetAuditTrailPropertyValue(EntityEntry entry, AuditTrail trailEntry, PropertyEntry property)  
    {  
        var propertyName = property.Metadata.Name;  

        switch (entry.State)  
        {  
            case EntityState.Added:  
                trailEntry.TrailType = TrailType.Create;  
                trailEntry.NewValues[propertyName] = property.CurrentValue;  

                break;  

            case EntityState.Deleted:  
                trailEntry.TrailType = TrailType.Delete;  
                trailEntry.OldValues[propertyName] = property.OriginalValue;  

                break;  

            case EntityState.Modified:  
                if (property.IsModified && (property.OriginalValue is null || !property.OriginalValue.Equals(property.CurrentValue)))  
                {  
                    trailEntry.ChangedColumns.Add(propertyName);  
                    trailEntry.TrailType = TrailType.Update;  
                    trailEntry.OldValues[propertyName] = property.OriginalValue;  
                    trailEntry.NewValues[propertyName] = property.CurrentValue;  
                }  

                break;  
        }  

        if (trailEntry.ChangedColumns.Count > 0)  
        {  
            trailEntry.TrailType = TrailType.Update;  
        }  
    }

如果你想排除任何敏感字段,可以在这里进行操作。例如,我们正在排除 PasswordHash 属性。

现在我们来看看如何将引用和导航字段添加到审计记录里:

    // 设置审核跟踪引用值
    private static void SetAuditTrailReferenceValues(EntityEntry entry, AuditTrail 审核跟踪)  
    {  
        foreach (var reference in entry.References.Where(x => x.IsModified))  
        {  
            var referenceName = reference.EntityEntry.Entity.GetType().Name;  
            审核跟踪.ChangedColumns.Add(referenceName);  
        }  
    }  

    // 设置审核跟踪导航值
    private static void SetAuditTrailNavigationValues(EntityEntry entry, AuditTrail 审核跟踪)  
    {  
        foreach (var navigation in entry.Navigations.Where(x => x.Metadata.IsCollection && x.IsModified))  
        {  
            if (navigation.CurrentValue is not IEnumerable<object> enumerable)  
            {  
                continue;  
            }  

            var collection = enumerable.ToList();  
            if (collection.Count == 0)  
            {  
                continue;  
            }  

            var navigationName = collection.First().GetType().Name;  
            审核跟踪.ChangedColumns.Add(navigationName);  
        }  
    }

最后,我们可以运行一下应用程序,看看审计是怎么工作的。

这是系统和用户在 authors 表中设置属性的检查示例:

这里是 audit_trails 表的样子如下所示:

希望这篇博客对你有帮助,祝你编程愉快!

_原文发布于 https://antondevtips.com 于2024年8月16日.

在我的网站里 我分享关于 .NET 和架构的最佳实践。
订阅 以成为更好的开发者吧。

0人推荐
随时随地看视频
慕课网APP