MultiTenancy with DbContext and TenantId - Interceptors, Filters, EF Code-First
Asked Answered
W

3

18

My organization needs to have a shared database, shared schema multitenant database. We will be querying based on TenantId. We will have very few tenants (less than 10) and all will share the same database schema with no support for tenant-specific changes or functionality. Tenant metadata will be stored in memory, not in the DB (static members).

This means all entities will now need a TenantId, and DbContext needs to know to filter on this by default.

The TenantId will likely be identified by a header value or the originating domain, unless there's a more advisable approach.

I've seen various samples leveraging interceptors for this but haven't seen a clearcut example on a TenantId implementation.


The problems we need to solve:

  1. How do we modify the current schema to support this (simple I think, just add TenantId)
  2. How do we detect the tenant (simple as well - base it on the originating request's domain or header value - pulling from a BaseController)
  3. How do we propagate this to service methods (a little trickier... we use DI to hydrate via constructors... want to avoid peppering all of the method signatures with tenantId)
  4. How do we modify DbContext to filter on this tenantId once we have it (no idea)
  5. How do we optimize for performance. What indexes do we need, how can we ensure that query caching isn't doing anything funky with the tenantId isolation, etc (no idea)
  6. Authentication - using SimpleMembership, how can we isolate Users, somehow associating them with a tenant.

I think the biggest question there is 4 - modifying DbContext.


I like how this article leverages RLS, but I'm not sure how to handle this in a code-first, dbContext manner:

https://azure.microsoft.com/en-us/documentation/articles/web-sites-dotnet-entity-framework-row-level-security/

I'd say what I'm looking for is a way to - with performance in mind - selectively query tenantId-isolated resources using DbContext without peppering my calls with "AND TenantId = 1" etc.


Update - I found some options, but I'm not sure what the pros and cons are for each, or whether or not there's some "better" approach altogether. My evaluation of options comes down to:

  • Ease of implementation
  • Performance

APPROACH A

This seems "expensive" since every time we new up a dbContext, we have to re-initialize filters:

https://blogs.msdn.microsoft.com/mvpawardprogram/2016/02/09/row-level-security-in-entityframework-6-ef6/

First, I set up my tenants and interface:

public static class Tenant {

    public static int TenantA {
        get { return 1; }
    }
    public static int TenantB
    {
        get { return 2; }
    }

}

public interface ITenantEntity {
    int TenantId { get; set; }
}

I implement that interface on any entities:

 public class Photo : ITenantEntity
 {

    public Photo()
    {
        DateProcessed = (DateTime) SqlDateTime.MinValue;
    }

    [Key]
    public int PhotoId { get; set; }

    [Required]
    public int TenantId { get; set; }
 }

And then I update my DbContext implementation:

  public AppContext(): base("name=ProductionConnection")
    {
        Init();
    }

  protected internal virtual void Init()
    {
        this.InitializeDynamicFilters();
    }

    int? _currentTenantId = null;

    public void SetTenantId(int? tenantId)
    {
        _currentTenantId = tenantId;
        this.SetFilterScopedParameterValue("TenantEntity", "tenantId", _currentTenantId);
        this.SetFilterGlobalParameterValue("TenantEntity", "tenantId", _currentTenantId);
        var test = this.GetFilterParameterValue("TenantEntity", "tenantId");
    }

    public override int SaveChanges()
    {
        var createdEntries = GetCreatedEntries().ToList();
        if (createdEntries.Any())
        {
            foreach (var createdEntry in createdEntries)
            {
                var isTenantEntity = createdEntry.Entity as ITenantEntity;
                if (isTenantEntity != null && _currentTenantId != null)
                {
                    isTenantEntity.TenantId = _currentTenantId.Value;
                }
                else
                {
                    throw new InvalidOperationException("Tenant Id Not Specified");
                }
            }

        }
    }

    private IEnumerable<DbEntityEntry> GetCreatedEntries()
    {
        var createdEntries = ChangeTracker.Entries().Where(V => EntityState.Added.HasFlag(V.State));
        return createdEntries;
    }

   protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Filter("TenantEntity", (ITenantEntity tenantEntity, int? tenantId) => tenantEntity.TenantId == tenantId.Value, () => null);

        base.OnModelCreating(modelBuilder);
    }

Finally, in my calls to DbContext, I use this:

     using (var db = new AppContext())
     {
          db.SetTenantId(someValueDeterminedElsewhere);
     }

I have a problem with this because I new up my AppContext in about a million places (some service methods need it, some don't) - so this bloats my code a bit. There are also questions about tenant determination - do I pass in the HttpContext, do I force my controllers to pass the TenantId into all service method calls, how do I handle cases where I don't have an originating domain (webjob calls etc).


APPROACH B

Found here: http://howtoprogram.eu/question/n-a,28158

Seems similar, but simple:

 public interface IMultiTenantEntity {
      int TenantID { get; set; }
 }

 public partial class YourEntity : IMultiTenantEntity {}

 public partial class YourContext : DbContext
 {
 private int _tenantId;
 public override int SaveChanges() {
    var addedEntities = this.ChangeTracker.Entries().Where(c => c.State == EntityState.Added)
        .Select(c => c.Entity).OfType<IMultiTenantEntity>();

    foreach (var entity in addedEntities) {
        entity.TenantID = _tenantId;
    }
    return base.SaveChanges();
}

public IQueryable<Code> TenantCodes => this.Codes.Where(c => c.TenantID == _tenantId);
}

public IQueryable<YourEntity> TenantYourEntities => this.YourEntities.Where(c => c.TenantID == _tenantId);

Although this just seems like a dumb version of A with the same concerns.

I figure by this point in time, there has to be a mature, advisable configuration/architecture to suit this need. How should we go about this?

Wonacott answered 5/11, 2016 at 17:32 Comment(11)
I prefer the schema per tenant approach. You can instantiate contexts with a schema name.Chela
@GertArnold my business need is to leverage one database for multiple tenants - I just assumed that TenantId was the simplest approach. Do you have any recommended reading or samples that address the issues I've outlined above (1-5)?Wonacott
msdn.microsoft.com/en-us/library/aa479086.aspx, See Shared Database, Separate Schemas.Chela
@GertArnold After reviewing, we're going to stick to the tenantId approach so I'll leave this up. Thank youWonacott
So you have your context instantiated directly and not injected in some way from DI container?Baines
@Baines I'm playing with the DI option right now - either way is fine.Wonacott
Asking because if you inject it, you can easily set TenantID there during that process and the problem of "I instantiate AppContext in about a million places" will be solved. Also see my answer here: https://mcmap.net/q/741822/-convert-ef-based-app-to-multi-tenant-by-way-of-context-overridesBaines
@Baines I'm implementing that now! I didn't know that was you. Of course, don't replace references where you use DbSet to modify entities - so really just add and delete, right? You can still Get and then Update? For each entity I'm implementing DbSet NonTenantThing (for code-first migration, adds, deletes) and IQueryable TenantThing for everything else. Does that sound right?Wonacott
Yes, to reduce code changes you can change DbSet YourEntities to IQueryable YourEntities (filtered by TenantID), and then add DbSet NonTenantEntities (better find another name :)). The reason is that then in existing codebase, your queries will now automatically be "filtered" by tenant id, while your modification statements (Add, Remove) will now produce compile error (IQueryable does not contain those methods), which is good (easy to find and fix). After you do this, you might as well rename entities back and apply rename refactoring.Baines
And don't pass HttpContext anywhere in your DI container. You might have some interface like IUserInfo {int TenantID}. Then implementaiton will indeed take that value from HttpContext, but all users of this interface will have no idea about that. For example, DbContext constructor then will be something like "public YourContext(IUserInfo userInfo)", where userInfo is injected.Baines
@Baines thank you very much.Wonacott
S
6

I would like to suggest the following approach, 1. Create a column with the name tenant ID for each of the table that contains core business data this is not required for any mapping table.

  1. Use the approach B, by creating an extension method that returns an IQueryable. This method can be an extension of the dbset so that anyone writing a filter clause, can just call this extension method followed by the predicate. This would make the task easier for developers to write code without bothering about tenant ID filter. This particular method will have the code to apply the filter condition for the tenant ID column based on the tenant context in which this query is being executed.

Sample ctx.TenantFilter().Where(....)

  1. Instead of relying upon the http context you can have tenant ID passed in all of your service methods so that it will be easy for handling the tenant contacts in both the web and the web job applications. This makes a call free from contacts and more easily testable. The multi tenant entity interface approach looks good and we do have a similar limitation in our application which works fine so far.

  2. Regarding adding index you would be required to add an index for tenant ID column in the tables that have tenant ID and that should take care of the DB side query indexing part.

  3. Regarding the authentication part, I would recommend to use asp.net identity 2.0 with the owin pipeline. The system is very extensible customisable and easy to integrate with any external identity providers if need be in future.

  4. Please do take a look at the repository pattern for entity framework which enables you to write lesser code in a generic fashion. This would help us get rid of code duplication and redundancy and very easy to test from unit test cases

Set answered 7/11, 2016 at 16:5 Comment(6)
Thank you for this. My only concern is 3 - that's going to wreak havoc on our service layer.Wonacott
in that case, you can have the tenant id taken from the user context. For example, we have a user identity context that is setup when the user logs in and then it gets used within the service layer. This approach can avoid the breaking or more changes in the service layersSet
Hi how would I add this to the EF Add Save Remove Update functionsOleta
Please take a look at the template code from github.com/d-saravanan/multitenant.dev.template. This can give you good startSet
@Set nice sample, could you add a snippet on showing support the TenantId, context filtered tables ? for e.g. is the tenantId set when the user logs in and disposed at time/logout. And is it possible to ensure only his records are show filtered by his tenantId - assuming there is tenantId added to AspUsersTable and the other tables have an Int TenantId I also see you used claims. Can you educate us with, on how claims is different from adding property.Fathom
@aggie: I have added some documentation and explanation with source code. HTH.Set
D
5

The question is about EF, but I think it is worth mentioning EF Core here. In EF Core you can use Global Query Filters

Such filters are automatically applied to any LINQ queries involving those Entity Types, including Entity Types referenced indirectly, such as through the use of Include or direct navigation property references

An example:

public class Blog
{
    private string _tenantId;

    public int BlogId { get; set; }
    public string Name { get; set; }
    public string Url { get; set; }

    public List<Post> Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public bool IsDeleted { get; set; }

    public int BlogId { get; set; }
    public Blog Blog { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>().Property<string>("TenantId").HasField("_tenantId");

    // Configure entity filters
    modelBuilder.Entity<Blog>().HasQueryFilter(b => EF.Property<string>(b, "TenantId") == _tenantId);
    modelBuilder.Entity<Post>().HasQueryFilter(p => !p.IsDeleted);
}
Definiendum answered 12/7, 2019 at 14:1 Comment(3)
This is cool. For both examples - multitenancy and also the "soft delete" filtering.. very usefulNatheless
One question though: how to obtain the tenantId co it is available in DbContext.. tenantId is determined from request (cookie, logged user,..) but that is not available on DbContext..Natheless
@Natheless I'd suggest creating a separate question for that. It shouldn't be hard, however, as you have full control of the dbcontext object and how it is injected (and how the tenantId value is set).Middlebrow
A
4

I think the biggest question there is 4 - modifying DbContext.

Don't modify the Context...

You shouldn't have to mix tenant-filtering code with your business code.

I think all you need is a repository the returns filtered data
This repository will return filtered data based on an Id you'll get from a TenantIdProvider.
Then, your Service doesn't have to know anything about tenants

using System;
using System.Data.Entity;
using System.Linq;

namespace SqlServerDatabaseBackup
{
    public class Table
    {
        public int TenantId { get; set; }
        public int TableId { get; set; }
    }

    public interface ITentantIdProvider
    {
        int TenantId();
    }

    public class TenantRepository : ITenantRepositoty
    {
        private int tenantId;
        private ITentantIdProvider _tentantIdProvider;
        private TenantContext context = new TenantContext(); //You can abstract this if you want
        private DbSet<Table> filteredTables;

        public IQueryable<Table> Tables
        {
            get
            {
                return filteredTables.Where(t => t.TenantId == tenantId);
            }
        }

        public TenantRepository(ITentantIdProvider tentantIdProvider)
        {
            _tentantIdProvider = tentantIdProvider;
            tenantId = _tentantIdProvider.TenantId();
            filteredTables = context.Tables;
        }

        public Table Find(int id)
        {
            return filteredTables.Find(id);
        }
    }

    public interface ITenantRepositoty
    {
        IQueryable<Table> Tables { get; }
        Table Find(int id);
    }

    public class TenantContext : DbContext
    {
        public DbSet<Table> Tables { get; set; }
    }

    public interface IService
    {
        void DoWork();
    }

    public class Service : IService
    {
        private ITenantRepositoty _tenantRepositoty;

        public Service(ITenantRepositoty tenantRepositoty)
        {
            _tenantRepositoty = tenantRepositoty;
        }

        public void DoWork()
        {
            _tenantRepositoty.Tables.ToList();//These are filtered records
        }
    }  
}
Archfiend answered 15/11, 2016 at 17:39 Comment(13)
Hi how would I add this to the EF Add Save Remove Update functionsOleta
@transformer For Remove/Update I think the safest choice is to load the entity first (you can use a Find() on the already filtered IQueryable) . The Save could override any TenantId your already has (if any)Archfiend
Thanks George, can show me a sample snippet on how to override and expose that find function. I found this filtering lib which seems like quite a bit of overkill/head for my app for simply providing a filter based on one col one where the filter kicks in at global scope when the user login to logout, like I would take the tenId from his usertable or session and filter on login.Oleta
@transformer I updated ITenantRepositoty and TenantRepositoryArchfiend
appreciate update @GeorgeVovos . Please correct my understanding. as I apply this into my app, - how use/consume this in my Actions and Viewmodels. So, in my ASP scaffolded controllers I usually have the dbContext.Oleta
Now with this TenantRepository class, do I replace all the DBcontext or use repository? should use 1) TenantContext , 2) DoWork() or 3) how can I set the tenant per user session at login() and dispose at signOut/logout in ASP MVC 5 (not webapi or core). Can you please post a sample usage on set/inject the Tenant Context at login () & also simple CRUD like Save list/by tenant. My assumption was that, once the context was set, the same old EF calls to DB.Employees.Save(model) - and the context is auto filtered underneath, correct? or do I need to check on every call.Oleta
@transformer You should probably ask a question about this.short answer :Your controllers use the services and you need to implement ITenantProvider where you can check the current session and return the current user IDArchfiend
I have been testing it, just working how to use it, if you can update with a simple code use snippet that would help, ignore the rest. thanksOleta
@transformer - can you show me how to use this, I think its simpler than other implementations!Fathom
Hi the update helps me with usage, thanks! some helpful suggestions 1) is to make the Table Argument of Generic Entity Type, so I can be called as an extension to any table public class TenantContext : DbContext { public DbSet<T> Tables { get; set; } // is it possible to make this generric so it works with any/all tables in context? } 2) where is the context set or passed in?Fathom
@Fathom 1)it doesn't make sense to have the dbset generic,you won't be able to use it...2)you can create it yourself in the repository as in my example or you can introduce another dependency on the repository like ICOntextProvider that has a method that returns a new contextArchfiend
ok thanks -do you have a github repo? I create a T4 around thisFathom
@Fathom I didn't get that.. Why do ask about a github repo?Archfiend

© 2022 - 2024 — McMap. All rights reserved.