In this article, we are going to look at different ways of implementing an audit trail in ASP.NET Core.

To download the source code for this article, you can visit our GitHub repository.

So let’s get going.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

What Is Audit Trail?

An audit trail refers to the record of all activities that the users perform in an application. We use it to identify who accessed the application, what changes were made, etc. 

An audit trail typically includes the following information:

  • The author of the change
  • The date and time of the change
  • The type of change
  • The changed data

We can store audit trail data in databases, files, storage services, etc. The most common way is to store this kind of data in the application database and we are going to learn how to do that.

Implementing an audit trail is very important for ensuring data integrity in our application. With an audit trail, we can:

  • Track changes to data
  • Identify unauthorized access
  • Investigate security breaches

Many industry regulations require organizations to implement audit trails for their software applications. For instance, while implementing card transactions, we should comply with the Payment Card Industry Data Security Standard (PCI DSS). Similarly, the applications that deal with health care data in the US should comply with the Health Insurance Portability and Accountability Act (HIPAA). These standards require us to implement a proper audit trail in our application.

Setting Up the Application

Let’s begin by creating an API application using either the dotnet new webapi command or the Visual Studio templates.

Next, let’s add a Product model class:

public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}

Along with that, let’s create an AuditLog model class: 

public class AuditLog
{
    public int Id { get; set; }
    public string UserEmail => "[email protected]";
    public required string EntityName { get; set; }
    public required string Action { get; set; }
    public DateTime Timestamp { get; set; }
    public required string Changes { get; set; }
}

The AuditLog entity has UserEmail, EntityName, Action, Timestamp, and Changes properties. Ideally, once we implement authentication, we should get the user details such as name, email, etc. from the User object in the context. However, since we are not planning to implement authentication for this example, let’s hardcode the UserEmail value. Of course, if we wish to authenticate the users, we can implement authentication in ASP.NET Core Web API. 

Once we implement authentication, we can replace the hard-coded UserEmail value with the respective value from ClaimsPrincipal by getting the current user with claims.

Let’s add an API Controller to carry out CRUD operations. To keep things simple, we’ll create a DbContext and Controller using the Visual Studio Scaffolding option.

To do this, right-click on the Controllers folder and select Add – New Scaffolded Item option. From here, we can choose the option API Controller with actions using Entity Framework. After that, we can choose the Product model class and choose to create a new data context by providing a name: 

 

Visual Studio will then create the data context for us and add API controller action methods. If we want to create everything manually, we can do so as well.

Audit Trail With EF Core Change Tracking

It is possible to configure the Entity Framework Core’s change tracking to automatically capture all changes made to data into a table. Let’s see how to do that.

First, let’s add the AuditLog entity to the ProductsDbContext class:

public DbSet<AuditLog> AuditLogs { get; set; } = default!;

After that, let’s override the SaveChangesAsync() method of the ProductsDbContext class and add the functionality to capture the changes to the AuditLog table:

public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    var modifiedEntities = ChangeTracker.Entries()
        .Where(e => e.State == EntityState.Added
        || e.State == EntityState.Modified
        || e.State == EntityState.Deleted)
        .ToList();

    foreach (var modifiedEntity in modifiedEntities)
    {
        var auditLog = new AuditLog
        {                    
            EntityName = modifiedEntity.Entity.GetType().Name,
            Action = modifiedEntity.State.ToString(),
            Timestamp = DateTime.UtcNow,
            Changes = GetChanges(modifiedEntity)
        };

        AuditLogs.Add(auditLog);
    }

    return base.SaveChangesAsync(cancellationToken);
}

private static string GetChanges(EntityEntry entity)
{
    var changes = new StringBuilder();

    foreach (var property in entity.OriginalValues.Properties)
    {
        var originalValue = entity.OriginalValues[property];
        var currentValue = entity.CurrentValues[property];

        if (!Equals(originalValue, currentValue))
        {
            changes.AppendLine($"{property.Name}: From '{originalValue}' to '{currentValue}'");
        }
    }

    return changes.ToString();
}

First, we find the modified entities from the EF Core change tracker. After that, for each of the modified entities, we find the properties that have changed using the GetChanges() method and write that to the AuditLog table.

This will store the changes in an audit log table whenever we create, update, or delete an entity. 

Next, we can add database migrations and update our database. Along with that, let’s update the database connection string in the appsettings file.

Let’s first create a migration:

add-migration initial

After that, let’s apply the changes to the database:

update-database

This will generate the migration files and create the database tables.

Now let’s check the ProductsController class.

The POST method just creates a new product entry:

[HttpPost]
public async Task<ActionResult<Product>> PostProduct(Product product)
{
    if (_context.Products == null)
    {
        return Problem("Entity set 'ProductsDbContext.Product' is null.");
    }
    _context.Products.Add(product);
    await _context.SaveChangesAsync();

    return CreatedAtAction("GetProduct", new { id = product.Id }, product);
}

Let’s change the PUT method slightly so that it updates only the changed properties:

[HttpPut("{id}")]
public async Task<IActionResult> PutProduct(int id, Product product)
{
    if (id != product.Id)
    {
        return BadRequest();
    }

    var oldProduct = await _context.Products.FindAsync(id);
    if (oldProduct is null)
    {
        return NotFound();
    }

    _context.Entry(oldProduct).CurrentValues.SetValues(product);

    try
    {
        await _context.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException)
    {
        if (!ProductExists(id))
        {
            return NotFound();
        }
        else
        {
            throw;
        }
    }

    return NoContent();
}

Here we get the original record by using the FindAsync() method. Then we set the values of this object by copying values from the current object by using the SetValues() method. The advantage of this method is that it will ignore the unchanged properties and generate the SQL UPDATE statement with just the changed properties.

Testing Audit Trail

Now let’s test the audit trail functionality.

First, let’s try creating a new product using the POST endpoint:

curl -X 'POST' \
  'https://localhost:7004/api/Products' \
  -H 'accept: text/plain' \
  -H 'Content-Type: application/json' \
  -d '{

  "name": "Milk",
  "price": 5,
  "quantity": 50
}'

This will create a new product record in the database:

{
  "id": 1,
  "name": "Milk",
  "price": 5,
  "quantity": 50
}

Additionally, this will create an entry in the AuditLog table:

Next, let’s update the product record using the PUT endpoint:

curl -X 'PUT' \
  'https://localhost:7004/api/Products/1' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "id": 1,
  "name": "Milk",
  "price": 5,
  "quantity": 55
}'

This time we can see a second entry in the audit trail corresponding to the update operation:

Notice how the Changes field correctly captures the property value that changed.

While implementing the audit trail with EF Core change tracking, we just need to override the SaveChangesAsync() method of the DbContext class and it automatically captures all the modifications that we make to data along with the changed values. So while using EF Core for data access, this is an excellent approach for implementing audit trail. 

Audit Trail in API Controllers

Another way of implementing an audit trail is by capturing all events at the individual API controller action level. Since this is done at the controller level, this approach works with any database and data access technique.

Let’s modify the POST method to implement this:

[HttpPost]
public async Task<ActionResult<Product>> PostProduct(Product product)
{
    if (_context.Products == null)
    {
        return Problem("Entity set 'ProductsDbContext.Product'  is null.");
    }

    _context.Products.Add(product); 

    var auditLog = new AuditLog
    {        
        Action = "Product Created",
        Timestamp = DateTime.UtcNow,
        EntityName = typeof(Product).Name,
        Changes = JsonSerializer.Serialize(product)
    };
    _context.AuditLogs.Add(auditLog);

    await _context.SaveChangesAsync();

    return CreatedAtAction("GetProduct", new { id = product.Id }, product);
}

Here, after adding the product to the Products DbSet, we create a new AuditLog object with the change details and add it to the _context. For tracking the current object values, we serialize the Product object and add it in the Changes column.

With this approach in place, there is no need to capture the audit trail with EF Core change tracking in the ProductsDbContext class as it will be a duplicate entry. So feel free to remove the SaveChangesAsync() method from the ProductsDbContext class.

Now let’s create a new product record using the POST endpoint:

curl -X 'POST' \
  'https://localhost:7004/api/Products' \
  -H 'accept: text/plain' \
  -H 'Content-Type: application/json' \
  -d '{

  "name": "Bread",
  "price": 4,
  "quantity": 25
}'

This will capture the audit trail entry:

With this approach, we have full control over where to add audits, what data to capture, etc. However, we will have to repeat the audit logging functionality in multiple places. To solve this, we are going to create a middleware so that we can plug it into the .NET Core pipeline without the need to repeat this code.

Audit Trail Using Middleware

Instead of adding an audit trail for every controller action, we can create an audit trail middleware and hook it to the ASP.NET Core pipeline.

For creating the middleware, first, let’s create an AuditLogMiddleware class:

public class AuditLogMiddleware
{
    private const string ControllerKey = "controller";
    private const string IdKey = "id";

    private readonly RequestDelegate _next;

    public AuditLogMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, ProductsDbContext dbContext)
    {
        await _next(context);

        var request = context.Request;

        if (request.Path.StartsWithSegments("/api"))
        {
            request.RouteValues.TryGetValue(ControllerKey, out var controllerValue);
            var controllerName = (string)(controllerValue ?? string.Empty);

            var changedValue = await GetChangedValues(request).ConfigureAwait(false);

            var auditLog = new AuditLog
            {
                EntityName = controllerName,
                Action = request.Method,
                Timestamp = DateTime.UtcNow,
                Changes = changedValue
            };

            dbContext.AuditLogs.Add(auditLog);
            await dbContext.SaveChangesAsync();
        }
    }
}

Here, first, we define the constants for the Controller and Id keys. After that, we write the middleware functionality in the InvokeAsync() method. Inside the method, we first check the request path to make sure we process only the API endpoints. We get the controller name and assign it to the Entity property and the request method to the Action property. Similarly, we call the GetChangedValues() method to get the change values:

private static async Task<string> GetChangedValues(HttpRequest request)
{
    var changedValue = string.Empty; 

    switch (request.Method)
    {
        case "POST":
        case "PUT":
            changedValue = await ReadRequestBody(request, Encoding.UTF8).ConfigureAwait(false);
            break;

        case "DELETE":
            request.RouteValues.TryGetValue(IdKey, out var idValueObj);
            changedValue = (string?)idValueObj ?? string.Empty;
            break;

        default:
            break;
    }

    return changedValue;
}

private static async Task<string> ReadRequestBody(HttpRequest request, Encoding? encoding = null)
{
    request.Body.Position = 0;
    var reader = new StreamReader(request.Body, encoding ?? Encoding.UTF8);
    var requestBody = await reader.ReadToEndAsync().ConfigureAwait(false);
    request.Body.Position = 0;

    return requestBody;
}

In the GetChangedValues() method, we check the request method. In the case of POST and PUT requests, we read the request body with the ReadRequestBody() method and assign it to the changedValue variable. For the DELETE method, we get the Id value and assign it to the changedValue variable.

In the ReadRequestBody() method, we read the request body using a StreamReader. Note that we reset the current position of the Stream before and after we read it. Apart from that, to ensure that we can read the request body multiple times, we need to enable buffering for the request.

Let’s add a middleware for that in the Program class:

var builder = WebApplication.CreateBuilder(args);

// code removed for brevity

app.UseAuthorization();

    app.Use(next => context => {
        context.Request.EnableBuffering();
        return next(context);
    });

app.UseMiddleware<AuditLogMiddleware>();

app.MapControllers();
app.Run();

Here, we add a middleware to enable buffering of the request. After that, we add the AuditLogMiddleware.

Now let’s create a new record using the POST endpoint:

curl -X 'POST' \
  'https://localhost:7004/api/Products' \
  -H 'accept: text/plain' \
  -H 'Content-Type: application/json' \
  -d '{

  "name": "Eggs",
  "price": 10,
  "quantity": 100
}'

After that, let’s update the quantity value using the PUT endpoint: 

curl -X 'PUT' \
  'https://localhost:7004/api/Products/3' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "id": 4,
  "name": "Eggs",
  "price": 10,
  "quantity": 110
}'

Finally, let’s delete the record using the DELETE endpoint:

curl -X 'DELETE' \
  'https://localhost:7004/api/Products/4' \
  -H 'accept: */*'

This will create three entries in the AuditLog table:

We can see that it captures the request body as changes for the POST and PUT actions. In the case of the DELETE action, it captures the Id as changes.

Conditional Middleware

With the middleware approach, we can add the audit functionality centrally without the need to repeat it in every action method. However, we might still want to have some control over this. For example, we might want to execute this middleware only for some controllers. For that purpose, we can write conditional middleware using the UseWhen() method:

app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), appBuilder =>
{
    appBuilder.UseMiddleware<AuditLogMiddleware>();
});

Here it will execute the AuditLog middleware only if the request path starts with /api. Of course, we can give different conditions as the predicate and it will create a branch in the request pipeline based on it. By using this approach, we can conditionally limit our middleware to just certain requests.

Conclusion

In this article, we learned different ways of implementing audit trails in an ASP.NET Core Web API. We started by looking at how to do that using EF Core change tracking. After that, we learned to implement an audit trail at the individual controller action level and hook it as a middleware.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!