Multi-tenancy web application with filtered dbContext
Asked Answered
E

2

6

I am new to ASP.Net MVC and multi-tenancy web application. I have done lots of reading, but being a beginner I just follow what I understand. So I managed to built a sample scenario web application and need to solve the ending part of it. Hope this scenario will be useful for some other beginners as well, but would welcome any other approach. Thanks in advance

1) Database in SQLServer 2008.

enter image description here

2) Data layer: C# class library project called MyApplication.Data

public class AppUser
{
    [Key]
    public virtual int AppUserID { get; set; }

    [Required]
    public virtual int TenantID { get; set; }

    [Required]
    public virtual int EmployeeID { get; set; }

    [Required]
    public virtual string Login { get; set; }

    [Required]
    public virtual string Password { get; set; }
}

public class Employee
{
    [Key]
    public virtual int EmployeeID { get; set; }

    [Required]
    public virtual int TenantID { get; set; }

    [Required]
    public virtual string FullName { get; set; }

}

public class Tenant_SYS
{
    //this is an autonumber starting from 1
    [Key]
    public virtual int TenantID { get; set; }

    [Required]
    public virtual string TenantName { get; set; }
}

3). Business Layer: class library MyApplication.Business Following FilteredDbSet Class courtesy: Zoran Maksimovic

public class FilteredDbSet<TEntity> : IDbSet<TEntity>, IOrderedQueryable<TEntity>, IOrderedQueryable, IQueryable<TEntity>, IQueryable, IEnumerable<TEntity>, IEnumerable, IListSource
    where TEntity : class
    {
        private readonly DbSet<TEntity> _set;
        private readonly Action<TEntity> _initializeEntity;
        private readonly Expression<Func<TEntity, bool>> _filter;

        public FilteredDbSet(DbContext context)
            : this(context.Set<TEntity>(), i => true, null)
        {
        }

        public FilteredDbSet(DbContext context, Expression<Func<TEntity, bool>> filter)
            : this(context.Set<TEntity>(), filter, null)
        {
        }

        public FilteredDbSet(DbContext context, Expression<Func<TEntity, bool>> filter, Action<TEntity> initializeEntity)
            : this(context.Set<TEntity>(), filter, initializeEntity)
        {
        }

        public Expression<Func<TEntity, bool>> Filter
        {
            get { return _filter; }
        }

        public IQueryable<TEntity> Include(string path)
        {
            return _set.Include(path).Where(_filter).AsQueryable();
        }

        private FilteredDbSet(DbSet<TEntity> set, Expression<Func<TEntity, bool>> filter, Action<TEntity> initializeEntity)
        {
            _set = set;
            _filter = filter;
            MatchesFilter = filter.Compile();
            _initializeEntity = initializeEntity;
        }

        public Func<TEntity, bool> MatchesFilter
        {
            get;
            private set;
        }

        public IQueryable<TEntity> Unfiltered()
        {
            return _set;
        }

        public void ThrowIfEntityDoesNotMatchFilter(TEntity entity)
        {
            if (!MatchesFilter(entity))
                throw new ArgumentOutOfRangeException();
        }

        public TEntity Add(TEntity entity)
        {
            DoInitializeEntity(entity);
            ThrowIfEntityDoesNotMatchFilter(entity);
            return _set.Add(entity);
        }

        public TEntity Attach(TEntity entity)
        {
            ThrowIfEntityDoesNotMatchFilter(entity);
            return _set.Attach(entity);
        }

        public TDerivedEntity Create<TDerivedEntity>() where TDerivedEntity : class, TEntity
        {
            var entity = _set.Create<TDerivedEntity>();
            DoInitializeEntity(entity);
            return (TDerivedEntity)entity;
        }

        public TEntity Create()
        {
            var entity = _set.Create();
            DoInitializeEntity(entity);
            return entity;
        }

        public TEntity Find(params object[] keyValues)
        {
            var entity = _set.Find(keyValues);
            if (entity == null)
                return null;
            // If the user queried an item outside the filter, then we throw an error.
            // If IDbSet had a Detach method we would use it...sadly, we have to be ok with the item being in the Set.
            ThrowIfEntityDoesNotMatchFilter(entity);
            return entity;
        }

        public TEntity Remove(TEntity entity)
        {
            ThrowIfEntityDoesNotMatchFilter(entity);
            return _set.Remove(entity);
        }

        /// <summary>
        /// Returns the items in the local cache
        /// </summary>
        /// <remarks>
        /// It is possible to add/remove entities via this property that do NOT match the filter.
        /// Use the <see cref="ThrowIfEntityDoesNotMatchFilter"/> method before adding/removing an item from this collection.
        /// </remarks>
        public ObservableCollection<TEntity> Local
        {
            get { return _set.Local; }
        }

        IEnumerator<TEntity> IEnumerable<TEntity>.GetEnumerator()
        {

            return _set.Where(_filter).GetEnumerator();
        }

        IEnumerator IEnumerable.GetEnumerator()
        {
            return _set.Where(_filter).GetEnumerator();
        }

        Type IQueryable.ElementType
        {
            get { return typeof(TEntity); }
        }

        Expression IQueryable.Expression
        {
            get
            {
                return _set.Where(_filter).Expression;
            }
        }

        IQueryProvider IQueryable.Provider
        {
            get
            {
                return _set.AsQueryable().Provider;
            }
        }

        bool IListSource.ContainsListCollection
        {
            get { return false; }
        }

        IList IListSource.GetList()
        {
            throw new InvalidOperationException();
        }

        void DoInitializeEntity(TEntity entity)
        {
            if (_initializeEntity != null)
                _initializeEntity(entity);
        }

       public DbSqlQuery<TEntity> SqlQuery(string sql, params object[] parameters)
       {
            return _set.SqlQuery(sql, parameters);
       }
    }

public class EFDbContext : DbContext
{
    public IDbSet<AppUser> AppUser { get; set; }
    public IDbSet<Tenant_SYS> Tenant { get; set; }
    public IDbSet<Employee> Employee { get; set; }

    ///this makes sure the naming convention does not have to be plural
    ///tables can be anything we name them to be
    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
    }

    public EFDbContext(int tenantID = 0)    //Constructor of the class always expect a tenantID
    {
        //Here, the Dbset can expose the unfiltered data            
        AppUser = new FilteredDbSet<AppUser>(this);
        Tenant = new FilteredDbSet<Tenant_SYS>(this);

        //From here, add all the multitenant dbsets with filtered data
        Employee = new FilteredDbSet<Employee>(this, d => d.TenantID == tenantID);
    }
}

public interface IEmployeeRepository
{
    IQueryable<Employee> Employees { get; }
    void SaveEmployee(Employee Employee);
    void DeleteEmployee(Employee Employee);
    List<Employee> GetEmployeesSorted();
}

public class EFEmployeeRepository : IEmployeeRepository
{
    private EFDbContext context;

    public EFEmployeeRepository(int tenantID = 0)  
    {
        context = new EFDbContext(tenantID);
    }

    IQueryable<Employee> IEmployeeRepository.Employees
    {
        get
        {
            return context.Employee;
        }
    }

    public void SaveEmployee(Employee Employee)
    {
        if (Employee.EmployeeID == 0)
        {
            context.Employee.Add(Employee);
        }

        context.SaveChanges();
    }

    public void DeleteEmployee(Employee Employee)
    {
        context.Employee.Remove(Employee);
        context.SaveChanges();
    }

    public List<Employee> GetEmployeesSorted()
    {
        //This is just a function to see the how the results are fetched. 
        return context.Employee.OrderBy(m => m.FullName)
                                    .ToList();
        //I haven't used where condition to filter the employees since it should be handled by the filtered context
    }
}

4) WEB Layer: ASP.NET MVC 4 Internet Application with Ninject DI

public class NinjectControllerFactory : DefaultControllerFactory
{
    private IKernel ninjectKernel;
    public NinjectControllerFactory()
    {
        ninjectKernel = new StandardKernel();
        AddBindings();
    }
    protected override IController GetControllerInstance(RequestContext requestContext,
    Type controllerType)
    {
        return controllerType == null
        ? null
        : (IController)ninjectKernel.Get(controllerType);
    }
    private void AddBindings()
    {
        ninjectKernel.Bind<IAppUserRepository>().To<EFAppUserRepository>();
        ninjectKernel.Bind<IEmployeeRepository>().To<EFEmployeeRepository>();

    }
}

5) Controller. Here is the Problem

public class HomeController : Controller
{
   IEmployeeRepository repoEmployee;

   public HomeController(IEmployeeRepository empRepository)
   {
       //How can I make sure that the employee is filtered globally by supplying a session variable of tenantID
       //Please assume that session variable has been initialized from Login modules after authentication.
       //There will be lots of Controllers like this in the application which need to use these globally filtered object
        repoEmployee = empRepository;
    }

    public ActionResult Index()
    {
        //The list of employees fetched must belong to the tenantID supplied by session variable
        //Why this is needed is to secure one tenant's data being exposed to another tenants accidently like,  if programmer fails to put where condition

        List<Employee> Employees = repoEmployee.Employees.ToList();
        return View();
    }
}
Enthusiast answered 11/3, 2013 at 22:21 Comment(4)
I can think of simpler alternatives, one database per tenant, or one schema per tenant. This is what you want, though?Banderole
If it were me I would make your API (controller actions) require the tenant ID then validate in the controller action against the session. That way you are just authorizing at the request level.Nerveracking
one database per tenant doesn't scale particularly well to be honest due to a number of things like connection pool fragmentation, multiple schemas etc. a single database for all tenants that you can shard internally, in my opinion, scales much better.Cladding
@Cladding In my experience, One-DB-per-tenant has scaled fine up to hundreds of tenants, and by then you'd add additional hardware anyway. I haven't experienced any problems with fragmentation in my applications.Cranium
S
6

NInject DI can do the magic !! Provided you will have a login routine which creates the session variable "thisTenantID".

In the Web Layer:

private void AddBindings()
{
    //Modified to inject session variable
    ninjectKernel.Bind<EFDbContext>().ToMethod(c => new EFDbContext((int)HttpContext.Current.Session["thisTenantID"]));

    ninjectKernel.Bind<IAppUserRepository>().To<EFAppUserRepository>();
    ninjectKernel.Bind<IEmployeeRepository>().To<EFEmployeeRepository>().WithConstructorArgument("tenantID", c => (int)HttpContext.Current.Session["thisTenantID"]);
}
Sheri answered 12/3, 2013 at 4:1 Comment(0)
F
1

The way you have designed your repository follows a very clear design, but the parameter that you are passing in the constructor makes things a bit more complicated when using dependency injection.

What I propose here below, is perhaps not the best design, but it will allow you to progress without doing too much changes to your existing code.

The catch in this solution is that you have to call the "Initialise" method when creating the controller, which potentially you might not like, but it is quite effective.

Here are the steps:

  • Create a new method in your IEmployeeRepository
public interface IEmployeeRepository
{
    //leave everything else as it is
    void Initialise(int tenantId);
}
  • Implement that method in the EFEmployeeRepository
public class EFEmployeeRepository
{
    //leave everything else as it is

    public void Initialise(int tenantID = 0)
    {
        context = new EFDbContext(tenantID);
    }
}
  • In the HomeController, you would need to call "Initialise" in the constructor
public HomeController(IEmployeeRepository empRepository)
{
    repoEmployee = empRepository;
    repoEmployee.Initialise(/* use your method to pass the Tenant ID here*/);
}

An alternative to this approach could be to create a RepositoryFactory that would return the Repository filled out with all the filters you need. In that case you will inject the Factory rather than the Repository to the Controller.

Footwall answered 12/3, 2013 at 0:57 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.