At Build last week, the possibility to have global filters applied to a context opened the possibility to support multi-tenancy scenarios directly. Multi-tenancy is the concept of having specific users, or “tenants”, having access to, or ownership of, only their data. In the past, I have simply checked a user’s role and conditionally added filters. Pushing this into a global filter, though, seems a bit more practical.
One demo that I sat through was a bit contrived with a “using” statement creating a DbContext and passing in a filterable value in the constructor. In a (more) real-world scenario with Dependency Injection, this isn’t very practical. Another problem with the example I saw was that it didn’t allow for the tenant filtering to be conditional. As in, what if the user has access to every tenant’s data? Let’s take a look.
My DbContext inherits from a “base context” which utilizes a user service that will inspect claims for the currently logged-in user. The user service isn’t overly involved or important to this scenario, but behind the scenes, it’s encapsulating accessing Roles and UserName from the current ClaimsIdentity/Principal. The service itself only knows the user’s username and does not know how it relants to any sort of data tenancy. Since the service is passed into the DbContext through DI upon instantiation, it makes it pretty straight-forward to determine the tenant, by username, and apply a filter based how the user’s identity relates to their data.
With EF 2.0, global filters are defined based on specific entity types in the ModelBuilder “OnModelCreating” initialization. For my example, I used a data system in which TenantData are tied to tenants and tenants within specific roles can only see their data. To facilitate this scenario, I make the Expression<TenantData, bool>> conditionally formed.
To start off, in my application, each piece of data owned by a tenant has the tenant’s EmployeeId. There is a table from which EmployeeId can be determined from the currently logged in user’s username. So, you can see, this requires a round-trip to the database. In my context, I define a method to handle this based on the injected user service.
public object _lockObj; public int _employeeId = -1; public bool _isFiltered = false; public MyContext(DbContextOptions<MyContext> options, ICurrentUserService currentUserService) : base(options, currentUserService) { } public void GetFilteredEmployeeId() { if (_currentUserService.IsInRole("ThisRoleIsFiltered")) { if (_employeeId == -1) { lock (_lockObj) { _isFiltered = true; _employeeId = Employees.FirstOrDefaultAsync(x => x.UserName == _currentUserService.UserName)?.Id ?? -1; } } } }
That code alone doesn’t do anything. But you can see the intention. Something will call “GetFilteredEmployeeId” to set “_employeeId” and this will be the basis of our tenancy filtering. With the ModelBuilder, we will add a filter on a specific entity using the new EF 2.0 “HasQueryFilter” extension:
protected override void OnModelCreating(ModelBuilder modelBuilder) { var assembly = typeof(MyContext).GetTypeInfo().Assembly; modelBuilder.AddAssemblyConfiguration<MyContext>(typeof(TenantDataConfiguration).Namespace); modelBuilder.Entity<TenantData>().HasQueryFilter(TenantDataFilter); }
If you don’t recall what “AddAssemblyConfiguration” is doing, it’s in one of my other blog posts detailing a way to have EntityTypeConfigurations in external files similarly to Ef 6.x.
Take note of the HasQueryFilter usage. Rather than passing a statically defined query, I’m passing a reference to a property that defines an expression. I’m going to use this so that I can conditionally filter the data based on user role.
In my case, I only want to filter the data if the user is in a specific role. If the user is not in that role, then “true” can be returned for the expression to effectively not add any additionally filtering to EF’s generated query.
public Expression<Func<TenantData, bool>> TenantDataFilter { get { GetFilteredEmployeeId(); var expr = _isFiltered ? (Expression<Func<TenantData, bool>>)(x => x.EmployeeId == _employeeId) : x => true; return expr; } }
And there we have it. Anytime that a query is performed in EF for the “TenantData,” and the user is in role “ThisRoleIsFiltered,” then they will only see their own data. This makes it so that, for example, administrators can see data across the system.