Route Based Multitenancy in ASP.NET Core

I've been working on a hobby project at home using ASP.NET Core 1.0. In my app there can be multiple tenants, each with their own set of products and contacts.

SaasKit is a great little Nuget package that makes it easy to define your own scheme for how tenants are identified. For example, you might identify them by subdomain (so customer1.example.com is a different tenant than customer2.example.com). I wanted a way to identify the tenant by the route. So in my app, example.com/customer1/products will return just the products for customer1.

The first step is to define a custom TenantResolver. Here's my attempt at route-based tenant resolution:

public class TenantResolver : MemoryCacheTenantResolver<Tenant>
{
    public TenantResolver(IMemoryCache cache, ILoggerFactory loggerFactory, DbContext db)
        : base(cache, loggerFactory)
    {
        _db = db;
    }

    DbContext _db;

    protected override string GetContextIdentifier(HttpContext context)
    {
        if (!context.Request.Path.HasValue || context.Request.Path.Value == "/")
        {
            if (!context.User.Identity.IsAuthenticated) return null;
            // hitting the root so the only way we can identify the tenant is from the authenticated user's claim, if one exists.
            return context.User.FindFirst("Tenant")?.Value;
        }

        var path = context.Request.Path.Value.TrimStart('/');

        var nextSlash = path.IndexOf('/');
        if (nextSlash < 0) return path;

        return path.Substring(0, nextSlash);
    }

    protected override IEnumerable<string> GetTenantIdentifiers(TenantContext<Tenant> context)
    {
        return new[] { context.Tenant.Code };
    }

    protected override Task<TenantContext<Tenant>> ResolveAsync(HttpContext context)
    {
        TenantContext<Tenant> result = null;

        var id = GetContextIdentifier(context);
        if (!string.IsNullOrEmpty(id))
        {
            var tenant = _db.Set<Tenant>().FirstOrDefault(t => t.Code == id.ToUpper());
            if (tenant != null)
            {
                result = new TenantContext<Tenant>(tenant);
            }
        }
        return Task.FromResult(result);
    }
}

So basically I'm grabbing the first part of the path (e.g. "customer1" if you're hitting /customer1/products) and looking it up in my database to see if there's a matching tenant.

You'll also notice I'm using SaasKit's memory-cached tenant resolver so that I don't necessarily have to hit the database every time. It'll cache the results for an hour at a time.

The one tricky bit here is that I don't really know what to do if the user hits a route that isn't tenant-specific. For example, if I have an /about page. There's an argument to be made that those routes shouldn't need to know the tenant anyway, but something tells me I'm going to hit a use case where it does need to know and I'm not sure what to do about that. You can see in the code above that I've added a special case for the root path ("/") where it resolves the tenant using a claim from the user. I might have to extend that somehow to other known paths.

The final step was to double check in the controller itself that the tenant was located and that the current authenticated user is a member of that tenant. To facilitate that I've defined an abstract TenantController class:

public abstract class TenantController : Controller
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        base.OnActionExecuting(context);

        var tenant = context.HttpContext.GetTenant<Tenant>();
        if (tenant == null)
        {
            context.Result = NotFound();
            return;
        }

        var claim = context.HttpContext.User.FindFirst("Tenant");
        if (claim == null)
        {
            context.Result = Unauthorized();
            return;
        }

        if (string.Compare(tenant.Code, claim.Value, true) != 0)
        {
            context.Result = Unauthorized();
            return;
        }
    }
}

This checks that a tenant was resolved (and returns a 404 if not), and then checks that you are a user for that tenant (and returns a 401 if not). This means you can't visit /customer3/products if there's no "customer3" tenant, and likewise you can't visit /customer2/products if you're a user from customer1.

As I said, there are still some challenges to surmount here, especially around routes that aren't tenant-specific. But that's half the fun, right? :)

Posted by: Matt Hamilton
Last revised: 20 Feb, 2017 08:20 PM History

Comments

Jeff
Jeff
28 Jan, 2017 07:42 AM

Nice post Matt, but how does the TenantResolver get configured in the Startup.cs? I am taking a similar approach, but not using EntityFramework and would like to pass in a custom repository instead.

29 Jan, 2017 11:49 PM

Hi Jeff,

You can configure SaasKit to use your own custom tenant resolver (in the ConfigureServices method) like this:

services.AddMultitenancy<Tenant, MyTenantResolver>();

And then ASP.NET Core's dependency injection framework handles the rest - you can accept whichever kind of repository you like in the tenant resolver's constructor.

No new comments are allowed on this post.