Entity Framework 5 Using SaveChanges to add audit log

user1538467 picture user1538467 · Sep 25, 2012 · Viewed 10.3k times · Source

Seems straight forward override SaveChanges in EF to add an audit logger. See the ApplyAuditLogging method to set the audit properties (created, createdby, updated, updatedby) below.

   public override int SaveChanges()
    {
        var autoDetectChanges = Configuration.AutoDetectChangesEnabled;

        try
        {
            Configuration.AutoDetectChangesEnabled = false;
            ChangeTracker.DetectChanges();
            var errors = GetValidationErrors().ToList();
            if(errors.Any())
            {
                throw new DbEntityValidationException("Validation errors were found during save: " + errors);
            }

            foreach (var entry in ChangeTracker.Entries().Where(e => e.State == EntityState.Added || e.State == EntityState.Modified))
            {
                ApplyAuditLogging(entry);
            }

            ChangeTracker.DetectChanges();

            Configuration.ValidateOnSaveEnabled = false;

            return base.SaveChanges();
        }
        finally
        {
            Configuration.AutoDetectChangesEnabled = autoDetectChanges;
        }
    }

    private static void ApplyAuditLogging(DbEntityEntry entityEntry)
    {

        var logger = entityEntry.Entity as IAuditLogger;
        if (logger == null) return;

        var currentValue = entityEntry.Cast<IAuditLogger>().Property(p => p.Audit).CurrentValue;
        if (currentValue == null) currentValue = new Audit();
        currentValue.Updated = DateTime.Now;
        currentValue.UpdatedBy = "???????????????????????";
        if(entityEntry.State == EntityState.Added)
        {
            currentValue.Created = DateTime.Now;
            currentValue.CreatedBy = "????????????????????????";
        }
    }

The problem is that how to get the windows user logon/username to set the UpdatedBy and CreatedBy properties of the object? I could therefore not use this!

Also, in another case I wanted to automatically add a new CallHistory record to my Contact; whenever the contact is modified, a new record needs to be added to the child table CallHistory. So I did it in InsertOrUpdate of the Repository but it feels dirty, would be nice if I could do it at a higher level as now I have to set the current user from the database. Again here the problem is that I need to fetch the user from the database to create a CallHistory record (SalesRep = User).

The code in my Repository does 2 things now, 1, it created an audit entry on the object when it is created or updated and, 2, it also created a CallHistory entry whenever the Contact is updated:

ContactRepository.SetCurrentUser(User).InsertOrUpdate(contact)

In order to have the user in the Repository context for:

    var prop = typeof(T).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);

    if (prop.GetValue(entity, null).ToString() == "0")
    {
        // New entity
        _context.Set<T>().Add(entity);
        var auditLogger = entity as IAuditLogger;
        if (auditLogger != null)
            auditLogger.Audit = new Audit(true, _principal.Identity.Name);
    }
    else
    {
        // Existing entity
        _context.Entry(entity).State = EntityState.Modified;
        var auditLogger = entity as IAuditLogger;
        if (auditLogger != null && auditLogger.Audit != null)
        {
            (entity as IAuditLogger).Audit.Updated = DateTime.Now;
            (entity as IAuditLogger).Audit.UpdatedBy = _principal.Identity.Name;
        }

        var contact = entity as Contact;
        if (_currentUser != null)
            contact.CallHistories.Add(new CallHistory
                {
                    CallTime = DateTime.Now,
                    Contact = contact,
                    Created = DateTime.Now,
                    CreatedBy = _currentUser.Logon,
                    SalesRep = _currentUser
                });
    }
}

Is there a way to somehow inject the windows user into the SaveChanges override in the DbContext and is there also a way to fetch a User from the database based on windows logon id so I can set the SalesRep on my CallHistory (see above code)?

Here is my Action on controller on MVC app:

[HttpPost]
public ActionResult Create([Bind(Prefix = "Contact")]Contact contact, FormCollection collection)
{
    SetupVOs(collection, contact, true);
    SetupBuyingProcesses(collection, contact, true);

    var result = ContactRepository.Validate(contact);

    Validate(result);

    if (ModelState.IsValid)
    {
        ContactRepository.SetCurrentUser(User).InsertOrUpdate(contact);
        ContactRepository.Save();
        return RedirectToAction("Edit", "Contact", new {id = contact.Id});
    }

    var viewData = LoadContactControllerCreateViewModel(contact);

    SetupPrefixDropdown(viewData, contact);

    return View(viewData);
}

Answer

Erik Funkenbusch picture Erik Funkenbusch · Sep 25, 2012

Well, the simple and lazy way to do it is to simply access the HttpContext.Current.User.Identity.Name from within your audit code. However, this will create a dependency on System.Web.*, which is probably not what you want if you have a nicely tiered application (and it wouldn't work if you were using actual seperate tiers).

One option would be, instead of overriding SaveChanges, just create an overload that takes your username. Then you do your work, and call the real SaveChanges afterwards. The disadvantage is that someone could call SaveChanges() (the real one) by mistake (or on purpose) and bypass the auditing.

A better way would be to simply add a _currentUser property to your DbContext and uase a constructor to pass it in. Then when you create the context, you just pass the user in at that time. Unfortunately, you can't really look up the user in the database from the constructor.

But you can simply save the ContactID and add that instead of the entire contact. You're Contact should already exist.