Implementing Audit Trail in an ASP.NET Core Application with Entity Framework Plus

7 minute read

Introduction

An audit trail is a chronological record of changes or activities performed in a system, providing a detailed history of events. In the context of software applications, including ASP.NET, an audit trail captures and logs various actions such as data modifications, user interactions, and system activities.

Why it is important? It helps organizations maintain a record of user activity within their systems, which can be used

  • To monitor user actions and detect suspicious activities
  • To track changes and identify the root cause of issues
  • To ensure compliance with regulatory requirements
  • Records changes to critical data elements, ensuring accuracy and consistency
  • Provides a detailed history of events for forensic analysis
  • Meets legal and regulatory requirements by maintaining a detailed audit trail
  • Offers historical data for reporting and analysis, supporting decision-making
  • Frud detection etc.

In this article, we will discuss how you can implement an audit trail feature using Entity Framework plus

Tools and Technology Used

  • ASP.net core Web API
  • Visual C#
  • Entity Framework Plus
  • Entity Framework
  • SQL Server

Step 1: Create a asp.net core web api project name AuditLog.API

Step 2: Install the following nuget packages in the project.

Z.EntityFramework.Extensions.EFCore
Z.EntityFramework.Plus.EFCore
Microsoft.EntityFrameworkCore
Microsoft.EntityFrameworkCore.Design
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools

Step 3: Create three Model class name Customer, CustomAuditEntry, CustomAuditEntryProperty in Models folder.

  • Customer class to store Customer Information
  • CustomAuditEntry class to store Audit Entry Information
  • CustomAuditEntryProperty class to store Audit Entry Property Information - means each properties new and old values

Customer.cs

  namespace AuditLog.API.Models
{
    public class Customer
    {
        public int Id { get; set; }
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
    }
}

CustomAuditEntry.cs

using Z.EntityFramework.Plus;

namespace AuditLog.API.Models
{
    // CustomAuditEntry is a custom class that inherits from AuditEntry
    // It is used to add additional properties to the AuditEntry class
    public class CustomAuditEntry : AuditEntry
    {
        // ApplicationName is a custom property that will be added to the AuditEntry table for storing the application name
        public string AppplicationName { get; set; }
    }
}

CustomAuditEntryProperty.cs

using Z.EntityFramework.Plus;

namespace AuditLog.API.Models
{
    public class CustomAuditEntryProperty : AuditEntryProperty
    {
        // ApplicationName is a custom property that will be added to the AuditEntryProperty table
        // for storing the application name
        public string AppplicationName { get; set; }
    }
}

Step 4: Create a Context class name AuditLogDBContext in Persistence folder.

AuditLogDBContext.cs

using AuditLog.API.Models;
using Microsoft.EntityFrameworkCore;
using Z.EntityFramework.Plus;

namespace AuditLog.API.Persistence
{
    public class AuditLogDBContext : DbContext
    {
        // AuditEntries and AuditEntryProperties are the tables that will be used to store the audit logs
        public DbSet<CustomAuditEntry> AuditEntries { get; set; }
        public DbSet<CustomAuditEntryProperty> AuditEntryProperties { get; set; }

        // Customers is the table that will be used to store the customer data
        public DbSet<Customer> Customers { get; set; }

        // AuditLogDBContext is the constructor that will be used to configure the audit log tables
        public AuditLogDBContext(DbContextOptions<AuditLogDBContext> options) : base(options)
        {
            // AutoSavePreAction is a delegate that will be used to save the audit logs to the database
            // It configures a pre-action to execute before the SaveChanges method is executed
            AuditManager.DefaultConfiguration.AutoSavePreAction = (context, audit) =>
            {
                ((AuditLogDBContext)context).AuditEntries.AddRange(audit.Entries.Cast<CustomAuditEntry>());
            };
        }


    }
}


Step 5: Add connection string in appsettings.json file

appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=AuditLogEFPlusDB;User Id=sa;Password=myPassword;TrustServerCertificate=True;"
  },
  "AllowedHosts": "*"
}

Step 6: Create two Interface ICustomerRepository and IReportingRepository in Repositories/Interfaces folder

ICustomerRepository.cs

using AuditLog.API.Models;

namespace AuditLog.API.Repositories.Interfaces
{
    public interface ICustomerRepository
    {
        Task<IEnumerable<Customer>> GetCustomers();
        Task<Customer> GetCustomer(int id);
        Task<Customer> AddCustomer(Customer customer);
        Task<Customer> UpdateCustomer(Customer customer);
        Task<Customer> DeleteCustomer(int id);
    }
}

IReportingRepository.cs

namespace AuditLog.API.Repositories.Interfaces
{
    public interface IReportingRepositry
    {
        // Get all the changes for a particular entity response using Dynamic type
        Task<IEnumerable<dynamic>> GetChangeLogDynamic(string EntityName);
    }
}

Step 7: Create two Repository class name CustomerRepository and ReportingRepository in Repositories/Implementations folder

CustomerRepository.cs

using AuditLog.API.Models;
using AuditLog.API.Persistence;
using AuditLog.API.Repositories.Interfaces;
using Microsoft.EntityFrameworkCore;
using Z.EntityFramework.Plus;

namespace AuditLog.API.Repositories.Implementations
{

    public class CustomerRepository : ICustomerRepository
    {
        private readonly AuditLogDBContext _context;

        public CustomerRepository(AuditLogDBContext context)
        {
            _context = context;
        }

        public async Task<IEnumerable<Customer>> GetCustomers()
        {
            return await _context.Customers.ToListAsync();
        }

        public async Task<Customer> GetCustomer(int id)
        {
            return await _context.Customers.FindAsync(id);
        }

        public async Task<Customer> AddCustomer(Customer customer)
        {
            // Used for audit log
            var audit = new Z.EntityFramework.Plus.Audit();
            audit.CreatedBy = "mahedee";

            _context.Customers.Add(customer);
            try
            {
                // SaveChangesAsync(audit) will commit the changes to the database and save the audit logs
                await _context.SaveChangesAsync(audit);
            }
            catch (DbUpdateException)
            {
                if (CustomerExists(customer.Id))
                {
                    return null;
                }
                else
                {
                    throw;
                }
            }

            return customer;
        }

        public async Task<Customer> UpdateCustomer(Customer customer)
        {
            var audit = new Z.EntityFramework.Plus.Audit();
            audit.CreatedBy = "mahedee";
            var customerToUpdate = await _context.Customers.FindAsync(customer.Id);
            _context.Entry(customerToUpdate).CurrentValues.SetValues(customer);

            try
            {
                // SaveChangesAsync(audit) will commit the changes to the database and save the audit logs
                await _context.SaveChangesAsync(audit);
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!CustomerExists(customer.Id))
                {
                    return null;
                }
                else
                {
                    throw;
                }
            }
            return customer;
        }

        public async Task<Customer> DeleteCustomer(int id)
        {
            var audit = new Z.EntityFramework.Plus.Audit();
            audit.CreatedBy = "mahedee";

            var customer = await _context.Customers.FindAsync(id);
            if (customer == null)
            {
                return null;
            }

            try
            {
                _context.Customers.Remove(customer);
                await _context.SaveChangesAsync(audit);
            }
            catch (Exception)
            {
                throw;
            }

            return customer;
        }

        private bool CustomerExists(int id)
        {
            return _context.Customers.Any(e => e.Id == id);
        }
    }

}

ReportingRepository.cs

using AuditLog.API.Persistence;
using AuditLog.API.Repositories.Interfaces;
using Microsoft.EntityFrameworkCore;

namespace AuditLog.API.Repositories.Implementations
{
    public class ReportingRepository : IReportingRepositry
    {
        private readonly AuditLogDBContext _context;

        public ReportingRepository(AuditLogDBContext context)
        {
            _context = context;
        }

        public async Task<IEnumerable<dynamic>> GetChangeLogDynamic(string EntityName)
        {
            String EntityTypeName = EntityName;

            var result = _context.AuditEntries
            .Where(a => a.EntityTypeName == EntityTypeName)
            .Join(_context.AuditEntryProperties,
            entry => entry.AuditEntryID,
            property => property.AuditEntryID,
                (entry, property) => new
                {
                    AuditEntryId = entry.AuditEntryID,
                    EntityTypeName = entry.EntityTypeName,
                    State = entry.State,
                    StateName = entry.StateName,
                    PropertyName = property.PropertyName,
                    OldValue = property.OldValue,
                    NewValue = property.NewValue,
                    CreatedBy = entry.CreatedBy,
                    CreatedDate = entry.CreatedDate
                }
                                                                                       )
            .OrderBy(result => result.CreatedDate)
            .Select(result => new
            {
                result.AuditEntryId,
                result.EntityTypeName,
                result.State,
                result.StateName,
                result.PropertyName,
                result.OldValue,
                result.NewValue,
                result.CreatedBy,
                result.CreatedDate
            });

            return await result.ToListAsync();
        }
    }
}

Step 8: Create two interfaces name ICustomerService and IReportingService in Services/Interfaces folder

ICustomerService.cs

using AuditLog.API.Models;

namespace AuditLog.API.Services.Interfaces
{
    public interface ICustomerService
    {
        Task<IEnumerable<Customer>> GetCustomers();
        Task<Customer> GetCustomer(int id);
        Task<Customer> AddCustomer(Customer customer);
        Task<Customer> UpdateCustomer(Customer customer);
        Task<Customer> DeleteCustomer(int id);
    }
}

IReportingService.cs


namespace AuditLog.API.Services.Interfaces
{
    public interface IReportingService
    {
        
        // Get all the changes for a particular entity response using Dynamic type
        Task<IEnumerable<dynamic>> GetChangeLogDynamic(string EntityName);
    }
}

Step 9: Create 2 service class name CustomerService and ReportingService in Services/Implementation folder

CustomerService.cs

using AuditLog.API.Models;
using AuditLog.API.Repositories.Interfaces;
using AuditLog.API.Services.Interfaces;

namespace AuditLog.API.Services.Implementations
{
    public class CustomerService : ICustomerService
    {
        private readonly ICustomerRepository _customerRepository;

        public CustomerService(ICustomerRepository customerRepository)
        {
            _customerRepository = customerRepository;
        }

        public async Task<IEnumerable<Customer>> GetCustomers()
        {
            return await _customerRepository.GetCustomers();
        }

        public async Task<Customer> GetCustomer(int id)
        {
            return await _customerRepository.GetCustomer(id);
        }

        public async Task<Customer> AddCustomer(Customer customer)
        {
            var addedCustomer = await _customerRepository.AddCustomer(customer);
            return addedCustomer;
        }

        public async Task<Customer> UpdateCustomer(Customer customer)
        {
            var updatedCustomer = await _customerRepository.UpdateCustomer(customer);
            return updatedCustomer;
        }

        public async Task<Customer> DeleteCustomer(int id)
        {
            var deletedCustomer = await _customerRepository.DeleteCustomer(id);
            return deletedCustomer;
        }
    }
}

ReportingService.cs

using AuditLog.API.Repositories.Interfaces;
using AuditLog.API.Services.Interfaces;

namespace AuditLog.API.Services.Implementations
{
    public class ReportingService : IReportingService
    {
        private readonly IReportingRepositry _reportingRepositry;

        public ReportingService(IReportingRepositry reportingRepositry)
        {
            _reportingRepositry = reportingRepositry;
        }

        public async Task<IEnumerable<dynamic>> GetChangeLogDynamic(string EntityName)
        {
            return await _reportingRepositry.GetChangeLogDynamic(EntityName);
        }
    }
}

Step 10: Configure Program class as follows.

Program.cs


using AuditLog.API.Models;
using AuditLog.API.Persistence;
using AuditLog.API.Repositories.Implementations;
using AuditLog.API.Repositories.Interfaces;
using AuditLog.API.Services.Implementations;
using AuditLog.API.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Z.EntityFramework.Plus;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();


builder.Services.AddDbContext<AuditLogDBContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection"))
);


// For Audit Log

AuditManager.DefaultConfiguration
.AuditEntryFactory = args =>
   new CustomAuditEntry() { AppplicationName = "AuditLogApp" };

AuditManager.DefaultConfiguration
            .AuditEntryPropertyFactory = args =>
                new CustomAuditEntryProperty() { AppplicationName = "AuditLogApp" };

AuditManager.DefaultConfiguration.AutoSavePreAction = (context, audit) => {
    ((AuditLogDBContext)context).AuditEntries.AddRange(audit.Entries.Cast<CustomAuditEntry>());
};


string connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
//builder.Services.AddTransient(_ => new DBConnector(connectionString));

builder.Services.AddTransient<ICustomerRepository, CustomerRepository>();
builder.Services.AddTransient<ICustomerService, CustomerService>();
builder.Services.AddTransient<IReportingRepositry, ReportingRepository>();
builder.Services.AddTransient<IReportingService, ReportingService>();


var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();


Step 11: Create two Controller class CustomersController and ReportingController in Controllers folder.

CustomersController.cs


using AuditLog.API.Models;
using AuditLog.API.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace AuditLog.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class CustomersController : ControllerBase
    {
        private readonly ICustomerService _customerService;

        public CustomersController(ICustomerService customerService)
        {
            _customerService = customerService;
        }

        // GET: api/Customers
        [HttpGet]
        public async Task<IEnumerable<Customer>> GetCustomers()
        {
            return await _customerService.GetCustomers();
        }

        // GET: api/Customers/5
        [HttpGet("{id}")]
        public async Task<Customer> GetCustomer(int id)
        {
            return await _customerService.GetCustomer(id);
        }

        // PUT: api/Customers/5
        [HttpPut("{id}")]
        public async Task<Customer?> PutCustomer(int id, Customer customer)
        {
            if (id != customer.Id)
            {
               return null;
            }
            try
            {
                return await _customerService.UpdateCustomer(customer);
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!CustomerExists(id))
                {
                    return null;
                }
                else
                {
                    throw;
                }
            }
        }

        // POST: api/Customers
        [HttpPost]
        public async Task<ActionResult<Customer>> PostCustomer(Customer customer)
        {
            return await _customerService.AddCustomer(customer);
        }

        // DELETE: api/Customers/5
        [HttpDelete("{id}")]
        public async Task<Customer> DeleteCustomer(int id)
        {
            return await _customerService.DeleteCustomer(id);
        }

        private bool CustomerExists(int id)
        {
            return _customerService.GetCustomer(id) == null ? false : true;
        }
    }
}


ReportingController.cs

using AuditLog.API.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace AuditLog.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ReportingController : ControllerBase
    {
        private readonly IReportingService _reportingService;

        public ReportingController(IReportingService reportingService)
        {
            _reportingService = reportingService;
        }

        // GET: api/AuditTrailReport
        [HttpGet]
        public async Task<IActionResult> Index()
        {
            return NoContent();
        }

        // GET: api/AuditTrailReport/{EntityName}
        [HttpGet("GetChangeLog/{EntityName}")]
        public async Task<IActionResult> GetChangeLog(string EntityName)
        {
            return Ok(await _reportingService.GetChangeLogDynamic(EntityName));
        }
    }
}

Step 12: Create migration and update database

  • Open Package Manager Console (PMC).
  • Select the project name AuditLog.API
  • Run the following command in PMC
Add-Migration InitialCreate

This will create a new migration file named “InitialCreate” under the Migrations folder of your

  • Run the following command in PMC
Update-Database
  • You will see three table name Customers, AuditEntry, AuditEntryProperty are created in the database

Step 13: Run the application and check the swagger.

  • Now run application create, update and delete Customer entity using swagger
  • You will see audit trail logs in AuditEntry and AuditEntryProperty table.
  • You can also view a sample report using Reporting controller. In GetChangeLog end point use “Customer” as entity name.

Source code