Implementation of the Global Query Filters, Multitenant and Soft Delete, in EF Core.

If Global Query Filters is a new thing for you check this out and then come back to this page : https://docs.microsoft.com/en-us/ef/core/querying/filters

There are various ways in which you can implement filtering in .NET Core 3.1 app and I tell you why we haven’t chosen neither of them.

1. Filtering in the OnModelCreating method

It surely works, but in DB First projects, this approach is very time consuming because you will have to modify it every time you are doing a scaffold, so we said pass.

2. Azure SQL DB RLS ( Row-Level Security )

This one works also, and it is highly recommend in a DB First approach, but we had a scenario in which the cardinality between users and tenants is N:N and this might cause some issues. If you don’t have a N:N cardinality, check it out, it might be what you need ( find more about this here : https://docs.microsoft.com/en-us/sql/relational-databases/security/row-level-security?view=sql-server-ver15 )

3. Filtering in every controller

You might consider this if you have a really small app with just a few controllers, but it might come a time in which you will regret this decision ?.

So, we’ve ended up with the Global Query Filters which, from our perspective, does the job app wise and performance wise, filtering the based on the tenant field and also based on a soft delete field.

Following the scenario in which we are ( if you have landed on this page, check out the rest of the story just to have a little bit of a context ), we have extended the DBContext, again, so we can run away from the scaffold, again.

The snippet below checks if there are models which have the fields TenantId and IsDeleted and filters the objects based on that.

This is a dynamically approach so, from the filtering point of view, only this section takes cares of the magic:

public partial class MasterContext : DbContext
{
	public Guid TenantId { get; set; }
	public List<Guid> _tenantIds;
	public bool isAllActive; // this is for the scenario in which the tenant wants all the data , softdeleted rows and active rows
	public int hashCode;

	partial void OnModelCreatingFilters(ModelBuilder modelBuilder)
	{
		// this is a global filter filter to check if has IsDeleted and TenantIds columns
		foreach (var entityType in modelBuilder.Model.GetEntityTypes())
		{
			var isDeletedProperty = entityType.FindProperty("IsDeleted");
			var tenantIdProperty = entityType.FindProperty("TenantId");

			// if we have both IsDeleted and TenantId columns ( with IsDeleted not being supressed )
			if (((isDeletedProperty != null) && (tenantIdProperty != null))
					&& isDeletedProperty.ClrType == typeof(bool) && !isAllActive)
			{
				var parameter = Expression.Parameter(entityType.ClrType, "p");

				//is deleted
				var filterIsDeleted = Expression.Equal(Expression.Property(parameter, isDeletedProperty.PropertyInfo)
													  , Expression.Constant(false, typeof(bool)));

				// check if there are any tenants 
				if (_tenantIds != null)
				{
					// we are parsing the first element from the list just pe sure we have some
					var tenantid = _tenantIds.First();
					var filterTenantId = Expression.Equal(Expression.Property(parameter, tenantIdProperty.PropertyInfo)
												, Expression.Constant(tenantid, typeof(Guid)));

					//move to a different List so we dont affect the main list
					//beware of the shallow copy
					List<Guid> localtenantIds = new List<Guid>(_tenantIds);
					localtenantIds.Remove(tenantid);

					//loop interation for all the remaining tenantids
					foreach (var tenantidloop in localtenantIds)
					{
						filterTenantId = Expression.Or(filterTenantId, Expression.Equal(Expression.Property(parameter, tenantIdProperty.PropertyInfo)
											   , Expression.Constant(tenantidloop, typeof(Guid))));

					}

					//combine both filters
					MutableEntityTypeExtensions.SetQueryFilter(entityType, Expression.Lambda(Expression.And(filterIsDeleted, filterTenantId), parameter));
				}
				//dummy filtering so we exclude all other tenants
				else
				{
					var tenantid = new Guid("00000000-0000-0000-0000-000000000000");
					var filterTenantId = Expression.Equal(Expression.Property(parameter, tenantIdProperty.PropertyInfo)
												, Expression.Constant(tenantid, typeof(Guid)));

					//combine both filters
					MutableEntityTypeExtensions.SetQueryFilter(entityType, Expression.Lambda(Expression.And(filterIsDeleted, filterTenantId), parameter));
				}
			}
			else
			{
				// this is a global filter to check if the object has IsDeleted column
				if (((isDeletedProperty != null) && (tenantIdProperty == null)) // delete without tenant
				 && isDeletedProperty.ClrType == typeof(bool))
				{
					var parameter = Expression.Parameter(entityType.ClrType, "p");

					//is deleted only
					var filterIsDeleted = Expression.Equal(Expression.Property(parameter, isDeletedProperty.PropertyInfo)
														  , Expression.Constant(false, typeof(bool)));

					MutableEntityTypeExtensions.SetQueryFilter(entityType, Expression.Lambda(filterIsDeleted, parameter));
				}
				else
				{
					// this is a global filter to check if the object has TenantId column 
					if ((((isDeletedProperty == null) && (tenantIdProperty != null)) // tenant without delete
					 && isDeletedProperty.ClrType == typeof(bool)) ||
					 (((tenantIdProperty != null)) && isAllActive))  // tenant with supressed delete 

					{
						var parameter = Expression.Parameter(entityType.ClrType, "p");
						// check if there are any tenants
						if (_tenantIds.Any())
						{
							// we are parsing the first element from the list just pe sure we have some
							var tenantid = _tenantIds.First();
							var filterTenantId = Expression.Equal(Expression.Property(parameter, tenantIdProperty.PropertyInfo)
														, Expression.Constant(tenantid, typeof(Guid)));
							_tenantIds.Remove(tenantid);

							//loop interation for all the remaining tenantids
							foreach (var tenantidloop in _tenantIds)
							{
								filterTenantId = Expression.Or(filterTenantId, Expression.Equal(Expression.Property(parameter, tenantIdProperty.PropertyInfo)
													   , Expression.Constant(tenantidloop, typeof(Guid))));

							}

							//combine both filters
							MutableEntityTypeExtensions.SetQueryFilter(entityType, Expression.Lambda(filterTenantId, parameter));
						}
					}
				}
			}
		}
	}
}

Also, this kind implementation can accommodate different kind of scenarios like:

  • Multiple tenants, soft delete
  • Multiple tenants, no soft delete (getting all the rows even if they are soft deleted)
  • No tenant, soft delete
  • No tenant, no soft delete (getting all the rows)

Chapter 6 : Modifications of the controllers to embed EF Core Global Query Filters functionality