This is part two of my series on Two Level Multi-Tenancy. You’ll probably want to read part one first.

Last time, we looked at how to use the subdomain as part of our multi-tenant MVC application and how to bring that together. Now, we’re going to look at adding the secondary layer.

For this project, we ended up with three base model classes. A base model that adds Id and DateAdded, a First Level base model that has the Level1Id, and now our Second Level base model that add an Id field for Level2 models.

Each of our EF models then inherit from the Level2 base model with the exception of that model class that inherits from the Level1 model. And so all models inherit from the Level1 base model, which will cause all of them to have our Level1 Id field.

Our routes at this point look like subdomain.domain.com/Level2/Thing/Create. The project has a helper method that pulls the Level2 value from the route.

I also added another base controller, which again inherits from our Level1 base controller we set up in part 1.

public class Level2BaseController : Level1BaseController
{
    protected string Level2Name => GetFromRoute();

    protected override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        if (string.IsNullOrEmpty(Level2Name))
        {
            filterContext.Result = RedirectToAction("Index", "Home");
            return;
        }

        var identity = Thread.CurrentPrincipal.Identity as ClaimsIdentity;
        if (!identity.IsAuthenticated) return;
        var claim = identity.Claims.FirstOrDefault(c => c.Type == CustomClaims.Level2Id);
        if (claim == null || (claim.Value != Sessions.Level2Id.ToString())) Users.AddL2Claim(Level2Name);
    }
}

The benefit here is that on each request we double check that the user has the claim intact and that if the user has access to more than one Level2, then the claim gets changed appropriately should they switch to a different Level2.

Going back to CommandInterceptor, we’ve got some additions to make.

var storeIdFromSubdomain = GetStoreIdFromClaim();

var storeParam =
    command.Parameters.Cast<DbParameter>()
        .FirstOrDefault(x => x.ParameterName == StoreAwareAttribute.StoreIdFilterParameterName);

if (storeParam != null) storeParam.Value = storeIdFromSubdomain;

With this addition, the code will also fill in the Level2 parameter for us.

In the CommandTreeInterceptor class we need to update each of the command interceptors and the TreeCreated method to include the Level2 columns:

TreeCreated:

var storeId = Stores.General.GetStoreIdFromClaim();
if (InterceptInsertCommand(interceptionContext, l1Id, storeId)) return;
///…

InterceptInsertCommand:

private static bool InterceptInsertCommand(DbCommandTreeInterceptionContext interceptionContext, int level1Id, int level2Id)
    {
        var insertCommand = interceptionContext.Result as DbInsertCommandTree;
        if (insertCommand == null) return false;
        var level2ColumnName = Level2AwareAttribute.GetLevel2ColumnName(insertCommand.Target.VariableType.EdmType);
        var level1ColumnName = Level1AwareAttribute.GetLevel1ColumnName(insertCommand.Target.VariableType.EdmType);
        if (string.IsNullOrEmpty(level2ColumnName) && string.IsNullOrEmpty(level1ColumnName)) return false;
        var variableReference = insertCommand.Target.VariableType.Variable(insertCommand.Target.VariableName);
        var level1Property = variableReference.Property(level1ColumnName);
        var level1SetClause = DbExpressionBuilder.SetClause(level1Property, DbExpression.FromInt32(level1Id));
        var filteredSetClauses = insertCommand.SetClauses.Cast<DbSetClause>().Where(sc => ((DbPropertyExpression)sc.Property).Property.Name != level1ColumnName);
        ReadOnlyCollection<DbModificationClause> finalSetClauses;
        if (!string.IsNullOrEmpty(level2ColumnName))
        {
            var level2Property = variableReference.Property(level2ColumnName);
            var level2SetClause = DbExpressionBuilder.SetClause(level2Property, DbExpression.FromInt32(level2Id));
            filteredSetClauses = filteredSetClauses.Where(sc => ((DbPropertyExpression)sc.Property).Property.Name != level2ColumnName);
            finalSetClauses = new ReadOnlyCollection<DbModificationClause>(new List<DbModificationClause>(filteredSetClauses)
            {
                level2SetClause,
                level1SetClause
            });
        }
        else
        {
            finalSetClauses = new ReadOnlyCollection<DbModificationClause>(new List<DbModificationClause>(filteredSetClauses)
            {
                level1SetClause
            });
        }
        var newInsertCommand = new DbInsertCommandTree(insertCommand.MetadataWorkspace, insertCommand.DataSpace, insertCommand.Target, finalSetClauses, insertCommand.Returning);
        interceptionContext.Result = newInsertCommand;
        return true;
    }

This section is what took me the most effort to get working like I wanted. Each interceptor is setup so that if we’re only dealing with a Level1 query it won’t try to inject or intercept Level2 parameters.

However, if the parameter is present, it will filter on both levels.

One of the caveats to this whole method is what to do when you actually need to include the Level2 (or even the Level1) Id columns manually? The interceptors will override Id columns in favor of the user’s claim token. We haven’t had to worry about it at this point so I don’t have a good solution for this, at the moment. I suppose the “simplest” thing to do would be to stuff another claim or something that you could check for in “TreeCreated”.

Overall, this method worked well and saved us from needing to double check that each tenant at both levels had access to a particular resource since it was handled automatically.

I’d love to hear from you if you have any ideas on how to improve this or if you use this in a project.

If I get some time I'll put all of this together in a GitHub project, which will probably increase the understanding of how this comes together.

Program responsibly.