5

I have created a .net core 2.1 MVC application using the template in Visual Studio with the Identity preset (user accounts stored in the application) and I am trying to automate some auditing fields.

Basically what I'm trying to do is overriding the SaveChangesAsync() method so that whenever changes are made to an entity the current logged in user ID is set to the auditing property of CreatedBy or ModifiedBy properties that are created as shadow properties on the entity.

I have looked at what seems to be tons of answers and surprisingly none of them work for me. I have tried injecting IHttpContext, HttpContext, UserManager, and I either can't seem to access a method that returns the user ID or I get a circular dependency error which I don't quite understand why it is happening.

I'm really running desperate with this one. I think something like this should be really straightforward to do, but I'm having a real hard time figuring out how to do it. There seem to be well documented solutions for web api controllers or for MVC controllers but not for use inside the ApplicationDbContext.

If someone can help me or at least point me into the right direction I'd be really grateful, thanks.

João Paiva
  • 1,937
  • 3
  • 19
  • 41
  • why exactly cant you use the `ClaimsPrincipal` which is `User` from your controllers? `ApplicationDbContext` essentially is a `DbContext` that comes with pre-defined tables... – Niklas Oct 19 '18 at 23:13
  • in addition to my answer below see my following post https://stackoverflow.com/q/51687042/5374333 – Alex Herman Oct 20 '18 at 01:03

2 Answers2

9

Let's call it DbContextWithUserAuditing

public class DBContextWithUserAuditing : IdentityDbContext<ApplicationUser, ApplicationRole, string>
{
    public string UserId { get; set; }
    public int? TenantId { get; set; }

    public DBContextWithUserAuditing(DbContextOptions<DBContextWithUserAuditing> options) : base(options) { }

    // here we declare our db sets

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.NamesToSnakeCase(); // PostgreSQL
        modelBuilder.EnableSoftDelete();
    }

    public override int SaveChanges()
    {
        ChangeTracker.DetectChanges();
        ChangeTracker.ProcessModification(UserId);
        ChangeTracker.ProcessDeletion(UserId);
        ChangeTracker.ProcessCreation(UserId, TenantId);

        return base.SaveChanges();
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken))
    {
        ChangeTracker.DetectChanges();
        ChangeTracker.ProcessModification(UserId);
        ChangeTracker.ProcessDeletion(UserId);
        ChangeTracker.ProcessCreation(UserId, TenantId);

        return (await base.SaveChangesAsync(true, cancellationToken));
    }
}

Then you have request pipeline and what you need - is a filter hook where you set your UserID

public class AppInitializerFilter : IAsyncActionFilter
{
    private DBContextWithUserAuditing _dbContext;

    public AppInitializerFilter(
        DBContextWithUserAuditing dbContext
        )
    {
        _dbContext = dbContext;
    }

    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next
        )
    {
        string userId = null;
        int? tenantId = null;

        var claimsIdentity = (ClaimsIdentity)context.HttpContext.User.Identity;

        var userIdClaim = claimsIdentity.Claims.SingleOrDefault(c => c.Type == ClaimTypes.NameIdentifier);
        if (userIdClaim != null)
        {
            userId = userIdClaim.Value;
        }

        var tenantIdClaim = claimsIdentity.Claims.SingleOrDefault(c => c.Type == CustomClaims.TenantId);
        if (tenantIdClaim != null)
        {
            tenantId = !string.IsNullOrEmpty(tenantIdClaim.Value) ? int.Parse(tenantIdClaim.Value) : (int?)null;
        }

        _dbContext.UserId = userId;
        _dbContext.TenantId = tenantId;

        var resultContext = await next();
    }
}

You activate this filter in the following way (Startup.cs file)

services
    .AddMvc(options =>
    {
        options.Filters.Add(typeof(OnRequestInit));
    })

Your app is then able to automatically set UserID & TenantID to newly created records

public static class ChangeTrackerExtensions
{
    public static void ProcessCreation(this ChangeTracker changeTracker, string userId, int? tenantId)
    {
        foreach (var item in changeTracker.Entries<IHasCreationTime>().Where(e => e.State == EntityState.Added))
        {
            item.Entity.CreationTime = DateTime.Now;
        }

        foreach (var item in changeTracker.Entries<IHasCreatorUserId>().Where(e => e.State == EntityState.Added))
        {
            item.Entity.CreatorUserId = userId;
        }

        foreach (var item in changeTracker.Entries<IMustHaveTenant>().Where(e => e.State == EntityState.Added))
        {
            if (tenantId.HasValue)
            {
                item.Entity.TenantId = tenantId.Value;
            }
        }
    }

I wouldn't recommend injecting HttpContext, UserManager or anything into your DbContext class because this way you violate Single Responsibility Principle.

Alex Herman
  • 2,708
  • 4
  • 32
  • 53
  • 1
    This worked for me where `options.Filters.Add(typeof(OnRequestInit));` should be `options.Filters.Add(typeof(AppInitializerFilter));`. – iamdlm Mar 21 '20 at 01:10
  • This solution still violates SRP because you need Asp.NET MVC to make this work (IAsyncActionFilter is in the MVC library) while the issue is basically inside the data layer (entity framework). This solution couples MVC with entityframework whilst you only want to get an user, where it comes from does not matter) – Wilko van der Veen Dec 17 '21 at 09:23
3

Thanks to all the answers. In the end I decided to create a UserResolveService that receives through DI the HttpContextAccessor and can then get the current user's name. With the name I can then query the database to get whatever information I may need. I then inject this service on the ApplicationDbContext.

IUserResolveService.cs

public interface IUserResolveService
{
    Task<string> GetCurrentSessionUserId(IdentityDbContext dbContext);
}

UserResolveService.cs

public class UserResolveService : IUserResolveService
{
    private readonly IHttpContextAccessor httpContextAccessor;

    public UserResolveService(IHttpContextAccessor httpContextAccessor)
    {
        this.httpContextAccessor = httpContextAccessor;
    }

    public async Task<string> GetCurrentSessionUserId(IdentityDbContext dbContext)
    {
        var currentSessionUserEmail = httpContextAccessor.HttpContext.User.Identity.Name;

        var user = await dbContext.Users                
            .SingleAsync(u => u.Email.Equals(currentSessionUserEmail));
        return user.Id;
    }
}

You have to register the service on startup and inject it on the ApplicationDbContext and you can use it like this:

ApplicationDbContext.cs

var dbContext = this;

var currentSessionUserId = await userResolveService.GetCurrentSessionUserId(dbContext);
João Paiva
  • 1,937
  • 3
  • 19
  • 41
  • 1
    Hi, I'm having the exact same problem. Could you please explain why you didn't use Alex Herman's answer? It seems a better way to do to me, but maybe I'm wrong (or there is something you don't manage to do) – Edouard Berthe May 18 '19 at 18:33
  • Hi, I think I went with my approach because I thought it was simpler and easier to read and it was good enough for what I wanted to do. I don't think his approach has any disadvantage, so you should be fine with either. – João Paiva May 18 '19 at 19:06
  • 1
    Ok, thx for your reply. I found great answers on [this post](https://stackoverflow.com/questions/36641338/how-get-current-user-in-asp-net-core). Actually, I think a merge of Alex Herman's answer (using the ActionFilter) and [this particular answer](https://stackoverflow.com/a/42493106/4773759) could be the better way to do. – Edouard Berthe May 19 '19 at 19:18