3

We're working on building a multitenant application, and we're using Entity Framework Core as our base data provider. With EF Core, you can use Global Query Filters to define custom filters that apply to all queries generated by EF.

These filters work great and appear to serve most of our purposes, but we begin running into issues when we attempt to add our multitenancy filters to the application. To better separate our concerns, we've created various classes to serve as our tenant identifiers and handles. However, this begins to cause problems when we attempt to use our tenant identification/security classes as the whole project breaks down as these security classes have a dependency on the data context itself.

A simplified model of our application would look something like the following.

In our application project, you'd find:

public class TenantService : ITenantService
{
    private IHttpContextAccessor _accessor;
    private ITenantIdentifier _identifier;
    private ITenantSecurityService _security;

    public TenantService(IHttpContextAccessor accessor,
                         ITenantIdentifier identifier,
                         ITenantSecurityService security) 
    {
        _accessor = accessor;
        _identifier = identifier;
        _security = security;
    }

    public Guid GetCurrentTenant() {
        var tenant = _identifier.GetTenant(_accessor.HttpContext);
        if (!_security.CanAccess(tenant))
            throw new TenantAuthorizationException("User cannot access tenant", tenant);

        return tenant.TenantId;
    }
}

public class TenantSecurityService : ITenantSecurityService
{
    private readonly IDbContext _dbContext;
    private readonly ICurrentUserService _userService
    public TenantSecurityService(IDbContext dbContext,
                                 ICurrentUserService userService)
    {
        _dbContext = dbContext;     
        _userService = userService;
    }

    public bool CanAccess(Tenant tenant) 
    {
        var currentUser = _userService.GetCurrentUser();
        // do various checks with dbContext data...

        return result;
    }
}

And in our persistence project, you'd see something like:

public class ApplicationDbContext : DbContext, IDbContext
{
    private readonly ITenantService _tenantService;
    public ApplicationDbContext(ITenantService tenantService) 
    {
        _tenantService = tenantService;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        ConfigureTenantFilter(modelBuilder);
    }

    private void ConfigureTenantFilter(ModelBuilder modelBuilder)
    {
        var tenant = _tenantService?.GetCurrentTenant();
        foreach (var entity in modelBuilder.Model.GetEntityTypes().Where(x =>
            typeof(ITenantEntity).IsAssignableFrom(x.ClrType)))
        {
            entity.AddProperty(nameof(Tenant), typeof(Guid));
            if (!string.IsNullOrEmpty(tenant))
                modelBuilder
                    .Entity(entity.ClrType)
                    .HasQueryFilter(IsTenantRestriction(entity.ClrType, tenant));
        }
    }
}

As you may note, the above code actually fails to run due to the circular dependency, but I cannot seem to find any better ways to structure the application aside from having 2 separate dbContexts (which seems a bit hacky).

What would be a better approach to handling these query filters without needing to tightly couple my security to the dbContext?

1 Answer 1

5

The question doesn't mention it, but you actually have two circular issues here: circular project references (which I surmise is the issue you're asking about) and circular dependency graphs (which I suspect you may not have noticed yet).


Circular project references

From the question, I assume your project structure is as follows:

enter image description here

One thing that is crucial to notice is that the circular dependency is between TenantSecurityService and the persistence layer, not between ITenantSecurityService and the persistence layer (since an interface doesn't have an implementation that depends on the data layer).

The trick here is to separate the contract from the implementation. I often create a contracts library specifically so I can put all my (DI) interfaces in there. Every project references the contracts library, and this is how you can have dependency graphs that have "circular" dependency layers like you're faced with now.

In essence, you want to structure your project dependencies like so:

enter image description here

Correction The top-level project should also reference the contracts. I already deleted the image I created so I couldn't fix it.

  • The contracts contain all DI interfaces
  • The middle projects (persistence, business logic, and all others) provide the concrete implementations of the DI interfaces
  • The top-level application connects the interfaces to the implementations (by registering them in the DI container).

Since the middle projects do not reference each other directly, you logically can never run into circular references between these projects, which is what you want.

Note that this is just a basic example. In the architecture I most commonly use, there are multiple contract libraries, specifically to avoid bloating the contracts library in very large applications where there are heaps of interfaces. But the overall point remains the same: separate your contracts.

Using this setup also helps you keep things clean by ensuring that your public interfaces don't expose any layer-specific types. For example, if the interface is housed in the persistence project, your interface can have a method that returns (or takes in) a persistence type (e.g. MyDbContext), which you shouldn't do for obvious reasons.

By having the contracts in their own project that depends on nothing (because depending on any other project would create a circular reference), your interfaces are forced to only use either commonly known types from the language/framework, or DTOs you've explicitly created in the contracts library.


Circular dependency graphs

This is always a matter of conflating two concepts, in this case IDbContext. The circular graph you end up with is:

IDbContext (1)
  --> ITenantService
    --> ITenantSecurityService
      --> IDbContext (2)
        --> ITenantService
          --> ITenantSecurityService
            --> and so on...

Note: just to be clear: infinite recursion exceptions can be avoided if at least one of these dependencies is registered as a singleton or scoped dependency. But the point remains that the dependency graph itself is circular. If your setup works and you're not worried about making this distinction, you can skip the rest of the answer.

Does IDbContext (1) require a security layer? Yes, because you want the rest of your application to have to pass through this security layer (in this case for implementing multi-tenancy safeguards).

Does IDbContext (2) require a security layer? No, because it is part of the security layer. At this point in time, IDbContext (2) cannot be a tenanted context yet, because you're still setting up the multi-tenancy filter for IDbContext (1).

You have two different roles for your db context, and representing them as a single concept is causing you to run into unwanted circular dependencies.

I cannot seem to find any better ways to structure the application aside from having 2 separate dbContexts (which seems a bit hacky)

I disagree. It's IMHO more "hacky" to foist two separate-but-similar roles on a single class than it is to create separate classes for the separate roles.

It's not so much a matter of having to make two completely unrelated db contexts, but rather making two variations built on the same context.
Given your situation, I'm inclined to call these ITenantContext/TenantContext (i.e. with the security filter) and IDbContext/ApplicationDbContext (non tenant-specific).

ITenantContext derives from IDbContext and acts as a marker interface to separate the two interfaces so your DI container can distinguish between them.

public interface ITenantContext : IDbContext { }

TenantContext has a dependency on ITenantService (which the defining difference between it and ApplicationDbContext).

public class TenantContext : ApplicationDbContext, ITenantContext
{
    // The same content as your ApplicationDbContext in the question
}

Note: OnModelCreating is a difficult one because it happens during the construction of the context. In general scenarios, it would be possible to use composition-over-inheritance here (where TenantContext doesn't derive from ApplicationDbContext); but this is much more complicated given that we're trying to override the OnModelCreating behavior.
If there is a way to set the filter on the ApplicationDbContext after it has been created, then you should favor composition over inheritance here. But I currently can't think of a way to do this.

ApplicationDbContext is essentially an unfiltered tenantcontext. This means that it loses the ITenantService dependency and the custom OnModelCreating.

public class ApplicationDbContext : DbContext, IDbContext
{
    public ApplicationDbContext() { }
}

This cleans up the unnecessary recursion in your dependency graph:

ITenantContext
  --> ITenantService
    --> ITenantSecurityService
      --> IDbContext

Note: If you really want to prevent anyone except the persistence layer from accessing the untenanted context, that is possible if you use composition over inheritance. In that case, you can simple keep the untenanted context as an internal class. But that sadly isn't possible in the current example as a base class must have the same (minimum) accessor as its derived classes, you can't make the base class internal if the derived class isn't.
You can still refuse to register IDbContext in the DI container so it can't be used as a dependency, but it will lead to exceptions if your consuming classes try to depend on IDbContext (which they aren't allowed to). That's not as good as having a compile time error when ApplicationDbContext is internal.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.