Audit Trail And Data Versioning With C# And MVC

Introduction

In certain domains, there is a frequent requirement to implement an audit trail of data. A good example of this would be a medical records application, where the data is critical, and any changes to it could have not only legal implications on the business, but also health consequences on the patient. This article describes a simple but effective implementation of an audit trail and data versioning system, using C# reflection with data stored in an SQL database.

Screenshot showing the final result.

Setting things up

SQL setup

To setup and test, we create two database tables. One stores simple "Person" information while the other stores "Audit trail / version" information.

Person "SampleData"

  1. ID    int      
  2. FirstName    nvarchar(10)      
  3. LastName    nvarchar(10)    
  4. DateOfBirth    date    
  5. Deleted    bit    

In the sample data table, we indicate if a core record is live, or "deleted" using the "deleted" field. From a Data Management point of view, it can be cleaner to only flag critical records as deleted - we will see an implementation of this later in the article.

"Audit trail" data

  1. ID    int      
  2. KeyFieldID    int      
  3. AuditActionTypeENUM    int     
  4. DateTimeStamp    datetime      
  5. DataModel    nvarchar(100)     
  6. Changes    nvarchar(MAX)      
  7. ValueBefore    nvarchar(MAX)     
  8. ValueAfter    nvarchar(MAX)    

In our audit trail table, we use the fields as follows.

  • "KeyFieldID" stores a link between the Person-SampleData.ID field.
  • "AuditActionTypeENUM" tells us what type of audit record this is (create,edit,delete..).
  • "DateTimeStamp" gives us a point in time when the event occurred.
  • "DataModel" is the name of the Data-Model/View-Model that the change occurred in, that we are logging.
  • "Changes" is an XML/JSON representation of the delta/diff between the previous data-state and the change.
  • "ValueBefore/ValueAfter" stores an XML/JSON snapshot of the DataModel data before/after the change event.

The ValueBefore/ValueAfter is optional. Depending on the complexity of the system, it may be useful to have a before/after snapshot to enable you to rebuild the data on a granular level.

Basic scaffolding

To test the system as designed, I created a simple MVC application that uses Entity Framework. I setup very basic controllers and data model methods to serve the index data up, and allow the crud process. There are also supporting ViewModels.

ViewModel

  1. public class SampleDataModel  
  2. {  
  3.         public int ID { getset; }  
  4.         public string FirstName { getset; }  
  5.         public string lastname { getset; }  
  6.         public DateTime DateOfBirth { getset; }  
  7.         public bool Deleted { getset; }  
  8. ...  
  9. }  
Controllers
  1. public ActionResult Edit(int id)  
  2. {  
  3.     SampleDataModel SD = new SampleDataModel();  
  4.     return View(SD.GetData(id));  
  5. }  
  6.   
  7. public ActionResult Create()  
  8. {  
  9.     SampleDataModel SD = new SampleDataModel();  
  10.     SD.ID = -1; // indicates record not yet saved  
  11.     SD.DateOfBirth = DateTime.Now.AddYears(-25);  
  12.     return View("Edit", SD);  
  13. }  
  14.   
  15. public void Delete(int id)  
  16. {  
  17.     SampleDataModel SD = new SampleDataModel();  
  18.     SD.DeleteRecord(id);  
  19. }  
  20.   
  21. public ActionResult Save(SampleDataModel Rec)  
  22. {  
  23.     SampleDataModel SD = new SampleDataModel();  
  24.     if (Rec.ID == -1)  
  25.     {  
  26.         SD.CreateRecord(Rec);  
  27.     }  
  28.     else  
  29.     {  
  30.         SD.UpdateRecord(Rec);  
  31.     }  
  32.     return Redirect("/");  
  33. }  
CRUD methods
  1. public void CreateRecord(SampleDataModel Rec)  
  2. {  
  3.   
  4.     AuditTestEntities ent = new AuditTestEntities();  
  5.     SampleData dbRec = new SampleData();  
  6.     dbRec.FirstName = Rec.FirstName;  
  7.     dbRec.LastName = Rec.lastname;  
  8.     dbRec.DateOfBirth = Rec.DateOfBirth;  
  9.     ent.SampleData.Add(dbRec);  
  10.     ent.SaveChanges(); // save first so we get back the dbRec.ID for audit tracking  
    }  
  11.   
  12. public bool UpdateRecord(SampleDataModel Rec)  
  13. {  
  14.     bool rslt = false;  
  15.     AuditTestEntities ent = new AuditTestEntities();  
  16.     var dbRec = ent.SampleData.FirstOrDefault(s => s.ID == Rec.ID);  
  17.     if (dbRec != null) {  
  18.         dbRec.FirstName = Rec.FirstName;  
  19.         dbRec.LastName = Rec.lastname;  
  20.         dbRec.DateOfBirth = Rec.DateOfBirth;  
  21.         ent.SaveChanges();  
  22.   
  23.         rslt = true;  
  24.   
  25.     }  
  26.     return rslt;  
  27. }  
  28.   
  29. public void DeleteRecord(int ID)  
  30. {  
  31.     AuditTestEntities ent = new AuditTestEntities();  
  32.     SampleData rec = ent.SampleData.FirstOrDefault(s => s.ID == ID);  
  33.     if (rec != null)  
  34.     {  
  35.         rec.Deleted = true;  
  36.         ent.SaveChanges();  
  37.     }  
  38. }  

For the UI example, I have tweaked the MVC default bootstrap giving a very basic EDIT and Index View.

The index view is built using MVC Razor syntax on a table, that is styled with boostrap. There are also three action buttons to show, "Live records" (ie: non-deleted), "all records", and "to create a new record".

You will recall the "Deleted" field for the SampleData table. When we call the Controller and subsequent Model to load the data, we send back a list of records where the "deleted" flag is either true or false.

  1. public List<SampleDataModel> GetAllData(bool ShowDeleted)  
  2. {  
  3.     List<SampleDataModel> rslt = new List<SampleDataModel>();  
  4.     AuditTestEntities ent = new AuditTestEntities();  
  5.     List<SampleData> SearchResults = new List<SampleData>();  
  6.   
  7.     if (ShowDeleted)  
  8.         SearchResults = ent.SampleData.ToList();  
  9.     else SearchResults = ent.SampleData.Where(s => s.Deleted == false).ToList();  
  10.   
  11.     foreach (var record in SearchResults)  
  12.     {  
  13.         SampleDataModel rec = new SampleDataModel();  
  14.         rec.ID = record.ID;  
  15.         rec.FirstName = record.FirstName;  
  16.         rec.lastname = record.LastName;  
  17.         rec.DateOfBirth = record.DateOfBirth;  
  18.         rec.Deleted = record.Deleted;  
  19.         rslt.Add(rec);  
  20.     }  
  21.     return rslt;  
  22. }  
Using Razor syntax, when creating the index view, we can set the color of a table row to highlight the deleted records.
  1. <table  class='table table-condensed' >  
  2.         <thead></thead>  
  3.                    @foreach (var rec in Model)  
  4.                    {  
  5.                   <tr id="@rec.ID" @(rec.Deleted == false ? String.Empty : "class=alert-danger" )>                      
  6.   
  7.                        <td><a href="/home/edit/@rec.ID">Edit</a>   
  8.                        <a href="#" onClick="DeleteRecord(@rec.ID)">Delete</a> </td>  
  9.                             <td>  
  10.                                 @rec.FirstName  
  11.                             </td>  
  12.                             <td>  
  13.                                 @rec.lastname  
  14.                             </td>  
  15.                             <td>  
  16.                                 @rec.DateOfBirth.ToShortDateString()  
  17.                             </td>  
  18.                             <td><a href="#" onClick="GetAuditHistory(@rec.ID)">Audit</a></td>  
  19.                         </tr>  
  20.                 }  
  21. </table>  

This outputs highlighting the record in a red color.


Auditing

Once we have the scaffolding implemented, we can implement the auditing. The concept is simple - before we post a change to the database, we have a "before" and "after" knowledge of the state of the data. Since we are in C#, we can use reflection to examine the data object we have in the database, and compare it to the one we are about to post, and view the differences between the two.

I looked at writing my own reflection code to examine the before/after object state, and found numerous good starting points on slack. Having tried a few, and my own version, I decided to utilize an existing NuGet package Compare net objects. It compares objects recursively so can handle quite complex object structures. This package is extremely useful and provides everything we need. It is open source and saved me time #JobDone.

Using CompareObjects, here is the core code that generates the audit information and inserts it into the database.

In the "CreateAuditTrail" method, we send in the following parameters.

  • AuditActionType = Create/Delete/Update...
  • KeyFieldID = Link to the table record this audit belongs to
  • OldObject / NewObject = the existing (database) and new (ViewModel) states of the data before saving the update to the database.
  1. public void CreateAuditTrail (AuditActionType Action, int KeyFieldID, Object OldObject, Object NewObject)  
The first thing we do in the method is to compare the objects and get the difference between them. The first time I used the class, I thought it was not working as only one difference was returned but I had sent in numerous. It turns out that by default, the class only sends back one difference (for testing), so we need to explicitly define a max number of differences to find. I set this to 99, but the value is up to your own needs.
  1. // get the differance  
  2. CompareLogic compObjects = new CompareLogic();  
  3. compObjects.Config.MaxDifferences = 99;  
The next step is to compare the objects, and iterate through the differences identified.
  1. ComparisonResult compResult = compObjects.Compare(OldObject, NewObject);  
  2. List<AuditDelta> DeltaList = new List<AuditDelta>();  

In order to store the changes (deltas), I have created two helper classes. "AuditDelta" gives the individual difference between two field-level-value states (before and after), and "AuditChange" is the overall sequence of changes. For example, let's say we have a record with the following changes.

Field name Value before Value after
First name Fred Frederick
Last name Flintstone Forsyth

In this case, we would have one AuditChange (the main change event), with a DateTimeStamp of now, and two change deltas - one with the firstname, changing from Fred to Frederick, the other with the Last name, changing from Flintstone to Forsyth.

The following classes represent the change and deltas.

  1. public class AuditChange {  
  2.    public string DateTimeStamp { getset; }  
  3.     public AuditActionType AuditActionType { getset; }  
  4.     public string AuditActionTypeName { getset; }  
  5.     public List<AuditDelta> Changes { getset; }  
  6.     public AuditChange()  
  7.     {  
  8.         Changes = new List<AuditDelta>();  
  9.     }  
  10. }  
  11.   
  12. public class AuditDelta {  
  13.     public string FieldName { getset; }  
  14.     public string ValueBefore { getset; }  
  15.     public string ValueAfter { getset; }  
  16. }  
Once CompareObjects has used its internal reflection code to compare the before/after objects, we can examine the results, and extract the detail we require. (nb: CompareObjects places a field delimiter "." in front of field/property names .. I didn't want this so I removed it).
  1. foreach (var change in compResult.Differences)  
  2. {  
  3.     AuditDelta delta = new AuditDelta();  
  4.     if (change.PropertyName.Substring(0, 1) == ".")  
  5.         delta.FieldName = change.PropertyName.Substring(1, change.PropertyName.Length - 1);  
  6.     delta.ValueBefore = change.Object1Value;  
  7.     delta.ValueAfter = change.Object2Value;  
  8.     DeltaList.Add(delta);  
  9. }  
Once we have our list of deltas, we can then save to our database, serializing the list of change deltas to the "changes" field. In this example, we are using JSON.net to serialize.
  1. AuditTable audit = new AuditTable();  
  2.  audit.AuditActionTypeENUM = (int)Action;  
  3.  audit.DataModel = this.GetType().Name;  
  4.  audit.DateTimeStamp = DateTime.Now;  
  5.  audit.KeyFieldID = KeyFieldID;  
  6.  audit.ValueBefore = JsonConvert.SerializeObject(OldObject);  
  7.  audit.ValueAfter = JsonConvert.SerializeObject(NewObject);  
  8.  audit.Changes = JsonConvert.SerializeObject(DeltaList);  
  9.   
  10.  AuditTestEntities ent = new AuditTestEntities();  
  11.  ent.AuditTable.Add(audit);  
  12.  ent.SaveChanges();  

Every time we make a change to the data, we just need to call the CreateAuditTrail method, sending in the type of action (Create/Delete/Update) and the before/after values.

In UpdateRecord, we send in the *New* record (Rec) as a parameter, and retrieve the old record from the database, then send both into our CreateAuditTrail method as generic objects.

  1. public bool UpdateRecord(SampleDataModel Rec)  
  2. {  
  3.     bool rslt = false;  
  4.     AuditTestEntities ent = new AuditTestEntities();  
  5.     var dbRec = ent.SampleData.FirstOrDefault(s => s.ID == Rec.ID);  
  6.     if (dbRec != null) {  
  7.         // audit process 1 - gather old values  
  8.         SampleDataModel OldRecord = new SampleDataModel();  
  9.         OldRecord.ID = dbRec.ID; // copy data from DB to "OldRecord" ViewModel  
  10.         OldRecord.FirstName = dbRec.FirstName;  
  11.         OldRecord.lastname = dbRec.LastName;  
  12.         OldRecord.DateOfBirth = dbRec.DateOfBirth;  
  13.         // update the live record  
  14.         dbRec.FirstName = Rec.FirstName;  
  15.         dbRec.LastName = Rec.lastname;  
  16.         dbRec.DateOfBirth = Rec.DateOfBirth;  
  17.         ent.SaveChanges();  
  18.   
  19.         CreateAuditTrail(AuditActionType.Update, Rec.ID, OldRecord, Rec);  
  20.   
  21.         rslt = true;  
  22.     }  
  23.     return rslt;  
  24. }  
In situations where we don't have either a before or an after value (eg: in create, we have no prior data state, and in delete, we have no after state), we send in an empty object.
  1. public void CreateRecord(SampleDataModel Rec)  
  2. {  
  3.   
  4.     AuditTestEntities ent = new AuditTestEntities();  
  5.     SampleData dbRec = new SampleData();  
  6.     dbRec.FirstName = Rec.FirstName;  
  7.     dbRec.LastName = Rec.lastname;  
  8.     dbRec.DateOfBirth = Rec.DateOfBirth;  
  9.     ent.SampleData.Add(dbRec);  
  10.     ent.SaveChanges(); // save first so we get back the dbRec.ID for audit tracking  
  11.     SampleData DummyObject = new SampleData();   
  12.   
  13.     CreateAuditTrail(AuditActionType.Create, dbRec.ID, DummyObject, dbRec);  
  14.   
  15. }  
  1. public void DeleteRecord(int ID)  
  2. {  
  3.     AuditTestEntities ent = new AuditTestEntities();  
  4.     SampleData rec = ent.SampleData.FirstOrDefault(s => s.ID == ID);  
  5.     if (rec != null)  
  6.     {  
  7.         SampleData DummyObject = new SampleData();  
  8.         rec.Deleted = true;  
  9.         ent.SaveChanges();  
  10.         CreateAuditTrail(AuditActionType.Delete, ID, rec, DummyObject);  
  11.     }  
  12. }  

Hansel and Gretel

So, we have our audit trail going into the database. Now, like the fairytale, we need to get those breadcrumbs out and show them to the user (but hopefully, our breadcrumbs will stay put!).

Server-side, we create a method that, for a given record-id, extracts the audit-history, and orders the data with the latest change first.

  1. public List<AuditChange> GetAudit(int ID)  
  2. {  
  3.     List<AuditChange> rslt = new List<AuditChange>();  
  4.     AuditTestEntities ent = new AuditTestEntities();  
  5.     var AuditTrail = ent.AuditTable.Where(s => s.KeyFieldID == ID).OrderByDescending(s => s.DateTimeStamp);  
  6.     var serializer = new XmlSerializer(typeof(AuditDelta));  
  7.     foreach (var record in AuditTrail)  
  8.     {  
  9.         AuditChange Change = new AuditChange();  
  10.         Change.DateTimeStamp = record.DateTimeStamp.ToString();  
  11.         Change.AuditActionType = (AuditActionType)record.AuditActionTypeENUM;  
  12.         Change.AuditActionTypeName = Enum.GetName(typeof(AuditActionType),record.AuditActionTypeENUM);  
  13.         List<AuditDelta> delta = new List<AuditDelta>();  
  14.         delta = JsonConvert.DeserializeObject<List<AuditDelta>>(record.Changes);  
  15.         Change.Changes.AddRange(delta);  
  16.         rslt.Add(Change);  
  17.     }  
  18.     return rslt;  
  19. }  
We also implement a Controller method to send this data back as a JSON result.
  1. public JsonResult Audit(int id)  
  2. {  
  3.     SampleDataModel SD = new SampleDataModel();  
  4.     var AuditTrail = SD.GetAudit(id);  
  5.     return Json(AuditTrail, JsonRequestBehavior.AllowGet);  
  6. }  
Client-side, we create a Modal popup form in bootstrap with a DIV called "audit" that we will inject with the audit-trail data.
  1. <div id="myModal" class="modal fade">  
  2.     <div class="modal-dialog">  
  3.         <div class="modal-content">  
  4.             <div class="modal-header">  
  5.                 <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>  
  6.                 <h4 class="modal-title">Audit history</h4>  
  7.             </div>  
  8.             <div class="modal-body">  
  9.                 <div id="audit"></div>  
  10.             </div>  
  11.             <div class="modal-footer">  
  12.                 <button type="button" class="btn btn-primary" data-dismiss="modal">Close</button>  
  13.             </div>  
  14.         </div>  
  15.     </div>  
  16. </div>  
Attached to each data-row, we have a JS function that calls the server-side code using AJAX.
  1. <a href="#" onClick="GetAuditHistory(@rec.ID)">Audit</a>  
The JavaScript code calls the server-side controller, passing in the record ID of the table row selected, and receives back a JSON array. It iterates through the array, building up a nicely formatted HTML table that gets displayed in the modal form.
  1. function GetAuditHistory(recordID) {  
  2.     $("#audit").html("");  
  3.   
  4.     var AuditDisplay = "<table class='table table-condensed' cellpadding='5'>";  
  5.     $.getJSON( "/home/audit/"+ recordID, function( AuditTrail ) {  
  6.   
  7.         for(var i = 0; i < AuditTrail.length; i++ )  
  8.         {  
  9.             AuditDisplay = AuditDisplay + "<tr class='active'><td colspan='2'>Event date: " + AuditTrail[i].DateTimeStamp + "</td>";  
  10.             AuditDisplay = AuditDisplay + "<td>Action type: " + AuditTrail[i].AuditActionTypeName + "</td></tr>";  
  11.             AuditDisplay = AuditDisplay + "<tr class='text-warning'><td>Field name</td><td>Before change</td><td>After change</td></tr>";  
  12.             for(var j = 0; j < AuditTrail[i].Changes.length; j++ )  
  13.             {  
  14.                 AuditDisplay = AuditDisplay + "<tr>";  
  15.                 AuditDisplay = AuditDisplay + "<td>" + AuditTrail[i].Changes[j].FieldName + "</td>";  
  16.                 AuditDisplay = AuditDisplay + "<td>" + AuditTrail[i].Changes[j].ValueBefore + "</td>";  
  17.                 AuditDisplay = AuditDisplay + "<td>" + AuditTrail[i].Changes[j].ValueAfter + "</td>";  
  18.                 AuditDisplay = AuditDisplay + "</tr>";  
  19.             }  
  20.         }  
  21.         AuditDisplay = AuditDisplay + "</table>">  
  22.   
  23.         $("#audit").html(AuditDisplay);  
  24.         $("#myModal").modal('show');  
  25.   
  26.   
  27.     });  
  28. }  

Here is the final result showing the progression from create, to update, and finally delete of a record.

Summary

This article has described useful functionality for implementing an audit-trail system within a C# based system. It is based on the assumption that its primary use is for user/security audit, and includes enough snapshot information to enable you (depending on detail needed), to re-create a snapshot of a data record at a single point of time. Try it out yourself by downloading the SQL script and code.

If you find the article useful, please take a few seconds now to give it a vote at the top of the page!

Points of Interest / considerations

  1. I have implemented this example using JSON - if you used XML instead, you could have more control over how the data is stored and how the fields are named (for displaying to the user) by using XML attribute decoration. This would be a good improvement on the implementation in this article.

  2. The example in SQL is implemented with all of the changes in one field "Changes" - this could be implemented instead, with another relational table between AuditChanges and Deltas, giving further flexibility for audit history searching if it was to be a frequently used part of your solution.

  3. Where the example shows manual mapping between database record and ViewModel record, it would be more efficient to use something like AutoMapper, to achieve the same result in less code.

  4. Where I have a field "AuditActionTypeName" - this is auto-mapped to the Model/Object name passed into the create audit method. This is used to track the user-view of data being stored. You could, however, choose to implement in some other manner, storing table name, class name, etc.

  5. This implementation only caters for create/update/delete actions - it may also be useful for you to implement and audit of what user has viewed a particular record for security reasons. In this case, you would also need to record the UserID and perhaps other information such as IP-address, machine name, etc.
There we go - Happy auditing !

Up Next
    Ebook Download
    View all
    Learn
    View all