Click here to Skip to main content
15,879,535 members
Articles / Web Development / HTML

Audit Trail and Data Versioning with C# and MVC

Rate me:
Please Sign up or sign in to vote.
4.81/5 (53 votes)
17 Aug 2015CPOL8 min read 154.7K   7K   102   44
Method for implementing an audit-trail

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:

Image 1

Setting Things Up

SQL Setup

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

Person "SampleData"

SQL
ID    int    
FirstName    nvarchar(10)    
LastName    nvarchar(10)  
DateOfBirth    date  
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

SQL
ID    int    
KeyFieldID    int    
AuditActionTypeENUM    int   
DateTimeStamp    datetime    
DataModel    nvarchar(100)   
Changes    nvarchar(MAX)    
ValueBefore    nvarchar(MAX)   
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/After is optional - depending on the complexity of the system, it may be useful to have a before/after snapshot to enable you to rebuild 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

C#
    public class SampleDataModel
    {
        public int ID { get; set; }
        public string FirstName { get; set; }
        public string lastname { get; set; }
        public DateTime DateOfBirth { get; set; }
        public bool Deleted { get; set; }
...
}

Controllers

C#
 public ActionResult Edit(int id)
        {
            SampleDataModel SD = new SampleDataModel();
            return View(SD.GetData(id));
        }

public ActionResult Create()
        {
            SampleDataModel SD = new SampleDataModel();
            SD.ID = -1; // indicates record not yet saved
            SD.DateOfBirth = DateTime.Now.AddYears(-25);
            return View("Edit", SD);
        }

public void Delete(int id)
        {
            SampleDataModel SD = new SampleDataModel();
            SD.DeleteRecord(id);
        }

public ActionResult Save(SampleDataModel Rec)
        {
            SampleDataModel SD = new SampleDataModel();
            if (Rec.ID == -1)
            {
                SD.CreateRecord(Rec);
            }
            else
            {
                SD.UpdateRecord(Rec);
            }
            return Redirect("/");
        }

CRUD Methods

C#
public void CreateRecord(SampleDataModel Rec)
        {

            AuditTestEntities ent = new AuditTestEntities();
            SampleData dbRec = new SampleData();
            dbRec.FirstName = Rec.FirstName;
            dbRec.LastName = Rec.lastname;
            dbRec.DateOfBirth = Rec.DateOfBirth;
            ent.SampleData.Add(dbRec);
            ent.SaveChanges(); // save first so we get back the dbRec.ID for audit tracking
       }

public bool UpdateRecord(SampleDataModel Rec)
        {
            bool rslt = false;
            AuditTestEntities ent = new AuditTestEntities();
            var dbRec = ent.SampleData.FirstOrDefault(s => s.ID == Rec.ID);
            if (dbRec != null) {
                dbRec.FirstName = Rec.FirstName;
                dbRec.LastName = Rec.lastname;
                dbRec.DateOfBirth = Rec.DateOfBirth;
                ent.SaveChanges();

                rslt = true;

            }
            return rslt;
        }

public void DeleteRecord(int ID)
        {
            AuditTestEntities ent = new AuditTestEntities();
            SampleData rec = ent.SampleData.FirstOrDefault(s => s.ID == ID);
            if (rec != null)
            {
                rec.Deleted = true;
                ent.SaveChanges();
            }
        }

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

Image 2

Image 3

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" (i.e.: 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 true or false.

C#
public List<SampleDataModel> GetAllData(bool ShowDeleted)
        {
            List<SampleDataModel> rslt = new List<SampleDataModel>();
            AuditTestEntities ent = new AuditTestEntities();
            List<SampleData> SearchResults = new List<SampleData>();

            if (ShowDeleted)
                SearchResults = ent.SampleData.ToList();
            else SearchResults = ent.SampleData.Where(s => s.Deleted == false).ToList();

            foreach (var record in SearchResults)
            {
                SampleDataModel rec = new SampleDataModel();
                rec.ID = record.ID;
                rec.FirstName = record.FirstName;
                rec.lastname = record.LastName;
                rec.DateOfBirth = record.DateOfBirth;
                rec.Deleted = record.Deleted;
                rslt.Add(rec);
            }
            return rslt;
        }

Using Razor syntax, when creating the index view, we can set the colour of a table row to highlight deleted records:

HTML
<table  class='table table-condensed' >
        <thead></thead>
                   @foreach (var rec in Model)
                   {
                  <tr id="@rec.ID" @(rec.Deleted == false ? 
                          String.Empty : "class=alert-danger" )>        

                       <td><a href="/home/edit/@rec.ID">Edit</a> 
                       <a href="#" 
                           onClick="DeleteRecord(@rec.ID)">Delete</a> </td>
                            <td>
                                @rec.FirstName
                            </td>
                            <td>
                                @rec.lastname
                            </td>
                            <td>
                                @rec.DateOfBirth.ToShortDateString()
                            </td>
                            <td><a href="#" onClick="GetAuditHistory(@rec.ID)">Audit</a></td>
                        </tr>
                }
</table>

This outputs highlighting the record in a red colour:

Image 4

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 utilise 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, its 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
C#
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 differeance 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.

C#
// get the differance
CompareLogic compObjects = new CompareLogic();
compObjects.Config.MaxDifferences = 99;

The next step is to compare the objects, and iterate through the differences identified.

C#
ComparisonResult compResult = compObjects.Compare(OldObject, NewObject);
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:

C#
public class AuditChange {
   public string DateTimeStamp { get; set; }
    public AuditActionType AuditActionType { get; set; }
    public string AuditActionTypeName { get; set; }
    public List<AuditDelta> Changes { get; set; }
    public AuditChange()
    {
        Changes = new List<AuditDelta>();
    }
}

public class AuditDelta {
    public string FieldName { get; set; }
    public string ValueBefore { get; set; }
    public string ValueAfter { get; set; }
}

Once CompareObjects has used its internal reflection code to compare the before/after objects, we can examine the results, and extract the details we require. (NB: CompareObjects places a field delimiter "." in front of field/property names .. I didn't want this so I remove it).

C#
foreach (var change in compResult.Differences)
            {
                AuditDelta delta = new AuditDelta();
                if (change.PropertyName.Substring(0, 1) == ".")
                    delta.FieldName = change.PropertyName.Substring(1, change.PropertyName.Length - 1);
                delta.ValueBefore = change.Object1Value;
                delta.ValueAfter = change.Object2Value;
                DeltaList.Add(delta);
            }         

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.

C#
AuditTable audit = new AuditTable();
audit.AuditActionTypeENUM = (int)Action;
audit.DataModel = this.GetType().Name;
audit.DateTimeStamp = DateTime.Now;
audit.KeyFieldID = KeyFieldID;
audit.ValueBefore = JsonConvert.SerializeObject(OldObject);
audit.ValueAfter = JsonConvert.SerializeObject(NewObject);
audit.Changes = JsonConvert.SerializeObject(DeltaList);

AuditTestEntities ent = new AuditTestEntities();
ent.AuditTable.Add(audit);
ent.SaveChanges();

Every time we make a change to 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.

C#
public bool UpdateRecord(SampleDataModel Rec)
{
    bool rslt = false;
    AuditTestEntities ent = new AuditTestEntities();
    var dbRec = ent.SampleData.FirstOrDefault(s => s.ID == Rec.ID);
    if (dbRec != null) {
        // audit process 1 - gather old values
        SampleDataModel OldRecord = new SampleDataModel();
        OldRecord.ID = dbRec.ID; // copy data from DB to "OldRecord" ViewModel
        OldRecord.FirstName = dbRec.FirstName;
        OldRecord.lastname = dbRec.LastName;
        OldRecord.DateOfBirth = dbRec.DateOfBirth;
        // update the live record
        dbRec.FirstName = Rec.FirstName;
        dbRec.LastName = Rec.lastname;
        dbRec.DateOfBirth = Rec.DateOfBirth;
        ent.SaveChanges();

        CreateAuditTrail(AuditActionType.Update, Rec.ID, OldRecord, Rec);

        rslt = true;
    }
    return rslt;
}

In situations where we don't have either a before or an after value (e.g.: in create, we have no prior data state, and in delete, we have no after state), we send in an empty object.

C#
public void CreateRecord(SampleDataModel Rec)
{
    AuditTestEntities ent = new AuditTestEntities();
    SampleData dbRec = new SampleData();
    dbRec.FirstName = Rec.FirstName;
    dbRec.LastName = Rec.lastname;
    dbRec.DateOfBirth = Rec.DateOfBirth;
    ent.SampleData.Add(dbRec);
    ent.SaveChanges(); // save first so we get back the dbRec.ID for audit tracking
    SampleData DummyObject = new SampleData(); 

    CreateAuditTrail(AuditActionType.Create, dbRec.ID, DummyObject, dbRec);
}
C#
public void DeleteRecord(int ID)
{
    AuditTestEntities ent = new AuditTestEntities();
    SampleData rec = ent.SampleData.FirstOrDefault(s => s.ID == ID);
    if (rec != null)
    {
        SampleData DummyObject = new SampleData();
        rec.Deleted = true;
        ent.SaveChanges();
        CreateAuditTrail(AuditActionType.Delete, ID, rec, DummyObject);
    }
}

Hansel and Gretel

So we have our audit trail going into the database - now like the fairytale, we need to get those bread-crumbs 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.

C#
public List<AuditChange> GetAudit(int ID)
{
    List<AuditChange> rslt = new List<AuditChange>();
    AuditTestEntities ent = new AuditTestEntities();
    var AuditTrail = ent.AuditTable.Where(s => s.KeyFieldID == ID).
                         OrderByDescending(s => s.DateTimeStamp);
    var serializer = new XmlSerializer(typeof(AuditDelta));
    foreach (var record in AuditTrail)
    {
        AuditChange Change = new AuditChange();
        Change.DateTimeStamp = record.DateTimeStamp.ToString();
        Change.AuditActionType = (AuditActionType)record.AuditActionTypeENUM;
        Change.AuditActionTypeName = Enum.GetName(typeof(AuditActionType),
                                     record.AuditActionTypeENUM);
        List<AuditDelta> delta = new List<AuditDelta>();
        delta = JsonConvert.DeserializeObject<List<AuditDelta>>(record.Changes);
        Change.Changes.AddRange(delta);
        rslt.Add(Change);
    }
    return rslt;
}

We also implement a controller method to send this data back as a JSON result.

C#
public JsonResult Audit(int id)
{
    SampleDataModel SD = new SampleDataModel();
    var AuditTrail = SD.GetAudit(id);
    return Json(AuditTrail, JsonRequestBehavior.AllowGet);
}

Client-side, we create a modal popup form in bootstrap with a DIV called "audit" that we will inject with the audit-trail data:

HTML
<div id="myModal" class="modal fade">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <button type="button" class="close"
                data-dismiss="modal" aria-hidden="true">
                &times;</button>
                <h4 class="modal-title">Audit history</h4>
            </div>
            <div class="modal-body">
                <div id="audit"></div>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-primary"
                data-dismiss="modal">Close</button>
            </div>
        </div>
    </div>
</div>

Attached to each data-row, we have a JS function that calls the server-side code using AJAX:

HTML
<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.

JavaScript
function GetAuditHistory(recordID) {
    $("#audit").html("");

    var AuditDisplay = "<table class='table table-condensed' cellpadding='5'>";
    $.getJSON( "/home/audit/"+ recordID, function( AuditTrail ) {

        for(var i = 0; i < AuditTrail.length; i++ )
        {
            AuditDisplay = AuditDisplay + "<tr class='active'>
                  <td colspan='2'>Event date: " + AuditTrail[i].DateTimeStamp + "</td>";
            AuditDisplay = AuditDisplay + "<td>Action type: " +
                  AuditTrail[i].AuditActionTypeName + "</td></tr>";
            AuditDisplay = AuditDisplay + "<tr class='text-warning'>
                  <td>Field name</td><td>Before change</td><td>After change</td></tr>";
            for(var j = 0; j < AuditTrail[i].Changes.length; j++ )
            {
                AuditDisplay = AuditDisplay + "<tr>";
                AuditDisplay = AuditDisplay + "<td>" +
                        AuditTrail[i].Changes[j].FieldName + "</td>";
                AuditDisplay = AuditDisplay + "<td>" +
                        AuditTrail[i].Changes[j].ValueBefore + "</td>";
                AuditDisplay = AuditDisplay + "<td>" +
                        AuditTrail[i].Changes[j].ValueAfter + "</td>";
                AuditDisplay = AuditDisplay + "</tr>";
            }
        }
        AuditDisplay = AuditDisplay + "</table>">

        $("#audit").html(AuditDisplay);
        $("#myModal").modal('show');
    });
}

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

Image 5

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 snapshop of a data record at a single point in time. Try it out yourself by downloading the SQL script and code.

If you find the article useful, please take a few seconds now and 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 fields are named (for display 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 efficent 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 to create/update/delete actions - it may also be useful for you to implement and audit 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.

History

  • 17th August, 2015 - Version 1 published

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Chief Technology Officer SocialVoice.AI
Ireland Ireland
Allen is CTO of SocialVoice (https://www.socialvoice.ai), where his company analyses video data at scale and gives Global Brands Knowledge, Insights and Actions never seen before! Allen is a chartered engineer, a Fellow of the British Computing Society, a Microsoft mvp and Regional Director, and C-Sharp Corner Community Adviser and MVP. His core technology interests are BigData, IoT and Machine Learning.

When not chained to his desk he can be found fixing broken things, playing music very badly or trying to shape things out of wood. He currently completing a PhD in AI and is also a ball throwing slave for his dogs.

Comments and Discussions

 
QuestionField Name showing null Pin
rishit1627-Dec-19 21:46
rishit1627-Dec-19 21:46 
QuestionPlagiarism Pin
User 465405129-Nov-16 6:00
User 465405129-Nov-16 6:00 
AnswerRe: Plagiarism Pin
DataBytzAI29-Nov-16 11:57
professionalDataBytzAI29-Nov-16 11:57 
QuestionThank you Pin
kyrie6-Oct-16 4:50
kyrie6-Oct-16 4:50 
AnswerRe: Thank you Pin
DataBytzAI11-Oct-16 2:56
professionalDataBytzAI11-Oct-16 2:56 
SuggestionCan you provide the same with ASP.NET Web Forms? Pin
gandhichintan19-Jul-16 2:52
professionalgandhichintan19-Jul-16 2:52 
GeneralRe: Can you provide the same with ASP.NET Web Forms? Pin
DataBytzAI31-Jul-16 6:06
professionalDataBytzAI31-Jul-16 6:06 
GeneralRe: Can you provide the same with ASP.NET Web Forms? Pin
gandhichintan31-Jan-17 3:02
professionalgandhichintan31-Jan-17 3:02 
QuestionHi Pin
Member 115567339-Feb-16 20:35
Member 115567339-Feb-16 20:35 
AnswerRe: Hi Pin
DataBytzAI10-Feb-16 0:55
professionalDataBytzAI10-Feb-16 0:55 
GeneralRe: Hi Pin
Member 1155673311-Feb-16 0:50
Member 1155673311-Feb-16 0:50 
GeneralRe: Hi Pin
DataBytzAI11-Feb-16 1:54
professionalDataBytzAI11-Feb-16 1:54 
QuestionNice Article Pin
Santhakumar Munuswamy @ Chennai11-Sep-15 21:57
professionalSanthakumar Munuswamy @ Chennai11-Sep-15 21:57 
GeneralMy vote of 5 Pin
Humayun Kabir Mamun27-Aug-15 1:05
Humayun Kabir Mamun27-Aug-15 1:05 
QuestionIt was nice and one more suggestion Pin
Tridip Bhattacharjee24-Aug-15 2:38
professionalTridip Bhattacharjee24-Aug-15 2:38 
AnswerRe: It was nice and one more suggestion Pin
DataBytzAI24-Aug-15 2:45
professionalDataBytzAI24-Aug-15 2:45 
GeneralRe: It was nice and one more suggestion Pin
Tridip Bhattacharjee24-Aug-15 21:16
professionalTridip Bhattacharjee24-Aug-15 21:16 
GeneralRe: It was nice and one more suggestion Pin
DataBytzAI24-Aug-15 22:50
professionalDataBytzAI24-Aug-15 22:50 
GeneralMy vote of 5 Pin
D V L23-Aug-15 19:21
professionalD V L23-Aug-15 19:21 
GeneralMy vote of 5 Pin
Duncan Edwards Jones20-Aug-15 7:52
professionalDuncan Edwards Jones20-Aug-15 7:52 
AnswerRe: My vote of 5 Pin
DataBytzAI20-Aug-15 23:02
professionalDataBytzAI20-Aug-15 23:02 
QuestionAudit Not Refreshing Pin
Member 1078148719-Aug-15 5:18
Member 1078148719-Aug-15 5:18 
AnswerRe: Audit Not Refreshing Pin
DataBytzAI19-Aug-15 5:32
professionalDataBytzAI19-Aug-15 5:32 
GeneralRe: Audit Not Refreshing Pin
Member 1078148719-Aug-15 5:42
Member 1078148719-Aug-15 5:42 
AnswerRe: Audit Not Refreshing Pin
DataBytzAI19-Aug-15 5:47
professionalDataBytzAI19-Aug-15 5:47 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.