Sunday, 20 August 2017

ASP.NET MVC Multitenancy, Part 1 - Routing with OWIN

This blog post was rewritten on 29/11/2017.

Recently I have been migrating one of my multitenant ASP.NET MVC application's to the OWIN middleware. This has presented me with an opportunity to change my initial tenant resolution and pipeline registration implementation.

I've started by doing some research to see if I could find something I could just download and use. However, I could not find anything that would meet the following requirements:
  • ASP.NET Core and ASP.NET compatibility
  • Optional multitenant processing i.e. all requests don't have be multitenant
  • Lifecycle delegates for missing tenant identifier and tenant record, and tenant context creation
  • Runtime tenant context dependency injection registration
  • Compatible with multitenant URL paths mysite.com/sometenant/ and could work with subdomains sometenant.mysite.com

After much deliberation, I have decided to go with my own implementation. However this implementation was massively influenced by Ben Foster's Building multi-tenant applications with ASP.NET Core (ASP.NET 5) and OpenID Connect Authentication Middleware designs.

Example Usage

public class Startup
{
    public void Configuration(IAppBuilder app)
    {
        //..
        app.UseMultitenancy(new MultitenancyNotifications<YourTenantRecord>
        {
            TenantIdentifierNotFound = context =>
            {
                throw new HttpException(404, "Tenant identifier must be provided");
            },
            TenantRecordNotFound = context =>
            {
                context.Response.Redirect("/signup/tenant/");
                return Task.FromResult(0);
            },
            CreateTenantContext = (context, tenantRecord) =>
            {
                ITenantContextFactory tenantContextFactory = ServiceLocator.Resolve<ITenantContextFactory>();
                TenantContext tenantContext = tenantContextFactory.Create(tenantRecord.Id, tenantRecord.NameFriendly, tenantRecord.AuthClientId, tenantRecord.AuthAuthority);
                return Task.FromResult(tenantContext);
            }
        });
    }
}
Please note that "CreateTenantContext" is a factory delegate. It allows you to control tenant record to tenant context mapping. It also allows you to perform other actions such as runtime tenant context dependency injection registration. By doing this, downstream objects such as controllers will get tenant context injected in to them.
public class SomeController : Controller
{
    public SomeController(TenantContext tenantContext)
    {
        //..
    }
}

Implementation


Tenant Identifier Extraction

In my implementation have decided to use MVC routing with data tokens for tenant discrimination.

RouteConfig.cs
   
public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        //...
        routes.MapRoute(
            "multi",
            "{tenant}/{controller}/{action}/{id}",
            new {controller = "Home", action = "Index", id = UrlParameter.Optional}
        ).DataTokens.Add("name", "webclient_multitenancy");
    }
}
By using data tokens, I am able to check the request route. In this case if request route has data token of name "webclient_multitenancy" I will know that I will be able to extract tenant identifier from the route. As you have probably gathered, in this case I am getting multitenancy identifier from the URL path mysite.com/sometenant/.

ITenantIdentifierExtractor.cs
   
public interface ITenantIdentifierExtractor
{
    bool CanExtract(IOwinContext context);
    string GetIdentifier(IOwinContext context);
}

RouteDataTokensTenantIdentifierExtractor.cs
   
public class RouteDataTokensTenantIdentifierExtractor : ITenantIdentifierExtractor
{
    public bool CanExtract(IOwinContext context)
    {
        RouteData routeData = this.getRouteData(context);
        return routeData != null && routeData.DataTokens.ContainsValue("webclient_multitenancy");
    }

    public string GetIdentifier(IOwinContext context)
    {
        if (!this.CanExtract(context))
            return null;

        RouteData routeData = this.getRouteData(context);
        return routeData.GetRequiredString("tenant");
    }

    private RouteData getRouteData(IOwinContext context)
    {
        HttpContextBase httpContext = (HttpContextBase)context.Environment["System.Web.HttpContextBase"];
        return RouteTable.Routes.GetRouteData(httpContext);
    }
}

You don’t have to use data token approach in your application, you can get multitenancy data out of the HTTP request manually.

Tenant Record Resolution

Resolution is where you call service or repository to obtain tenant record, this tenant record will be used later on to create tenant context.

ITenantRecordResolver.cs
   
public interface ITenantRecordResolver<TTenantRecord>
{
    TTenantRecord GetTenant(string tenantIdentifier);
}

In this implementation, I have decided to call the ITenantService to get the TenantDto object.

TenantRecordResolver.cs
   
public class TenantRecordResolver : ITenantRecordResolver<TenantDto>
{
    readonly ITenantService tenantService;

    public TenantRecordResolver(ITenantService tenantService)
    {
        this.tenantService = tenantService;
    }

    public TenantDto GetTenant(string tenantIdentifier)
    {
        return this.tenantService.Get(tenantIdentifier);
    }
}

Most of the time on each request you will be resolving tenant record. At scale this will become very inefficient so it is highly likely that you will want to cache tenant record, to do this you can use decorator pattern.

TenantRecordResolverCacheDecorator.cs
   
public class TenantRecordResolverCacheDecorator : ITenantRecordResolver<TenantDto>
{
    readonly TenantRecordResolver tenantRecordResolver;

    public TenantRecordResolverCacheDecorator(TenantRecordResolver tenantRecordResolver)
    {
        this.tenantRecordResolver = tenantRecordResolver;
    }

    public TenantDto GetTenant(string tenantIdentifier)
    {
        string cacheKey = $"tenantIdentifier:{tenantIdentifier}";

        TenantDto tenant = (TenantDto)MemoryCache.Default[cacheKey];

        if (tenant != null)
        {
            return tenant;
        }

        tenant = this.tenantRecordResolver.GetTenant(tenantIdentifier);
        if (tenant == null)
        {
            return null;
        }

        MemoryCache.Default.Set(cacheKey, tenant, new CacheItemPolicy
        {
            AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(10)
        });
    
        return tenant;
    }
}

Middleware & Extensions

We now have all the key components in place and it is time to implement the actual middleware.

MultitenancyMiddleware.cs
   
public class MultitenancyMiddleware<TTenantRecord>
    where TTenantRecord : class
{
    readonly ITenantIdentifierExtractor tenantIdentifierExtractor;
    readonly ITenantRecordResolver<TTenantRecord> tenantRecordResolver;
    readonly MultitenancyNotifications<TTenantRecord> notifications;
    readonly Func<Task> next;

    public MultitenancyMiddleware(Func<Task> next, ITenantIdentifierExtractor tenantIdentifierExtractor, 
        ITenantRecordResolver<TTenantRecord> tenantRecordResolver,  MultitenancyNotifications<TTenantRecord> notifications)
    {
        this.next = next;
        this.tenantIdentifierExtractor = tenantIdentifierExtractor;
        this.tenantRecordResolver = tenantRecordResolver;
        this.notifications = notifications;
    }

    public async Task Invoke(IOwinContext context)
    {
        if (!this.tenantIdentifierExtractor.CanExtract(context))
        {
            await this.next();
            return;
        }

        string identifier = this.tenantIdentifierExtractor.GetIdentifier(context);

        if (string.IsNullOrEmpty(identifier))
        {
            await this.notifications.TenantIdentifierNotFound(context);
            return;
        }

        TTenantRecord tenantRecord = this.tenantRecordResolver.GetTenant(identifier);
        if (tenantRecord == null)
        {
            await this.notifications.TenantRecordNotFound(context);
            return;
        }

        TenantContext tenantContext = await this.notifications.CreateTenantContext(context, tenantRecord);

        context.SetTenantContext(tenantContext);

        await this.next();
    }
}

MultitenancyNotifications.cs
   
public class MultitenancyNotifications<TTenantRecord>
    where TTenantRecord : class
{
    public Func<IOwinContext, Task> TenantIdentifierNotFound { get; set; }
    public Func<IOwinContext, Task> TenantRecordNotFound { get; set; }
    public Func<IOwinContext, TTenantRecord, Task<TenantContext>> CreateTenantContext { get; set; }

    public MultitenancyNotifications()
    {
        this.TenantIdentifierNotFound = context => Task.FromResult(0);
        this.TenantRecordNotFound = context => Task.FromResult(0);
        this.CreateTenantContext = (context, tenantRecord) => Task.FromResult<TenantContext>(null);
    }
}

OwinContextExtensions.cs
   
public static class OwinContextExtensions
{
    static string tenantContextKey = "tenantcontext";

    public static void SetTenantContext(this IOwinContext context, TenantContext tenantContext)
    {
        context.Environment.Add(tenantContextKey, tenantContext);
    }
}
AppBuilderExtensions.cs
   
public static class AppBuilderExtensions
{
    public static IAppBuilder UseMultitenancy<TTenantRecord>(this IAppBuilder app, 
        MultitenancyNotifications<TTenantRecord> notifications)
        where TTenantRecord : class
    {
        return app.Use((context, next) =>
        {
            MultitenancyMiddleware<TTenantRecord> multitenancyMiddleware = 
                ServiceLocator.Resolve<MultitenancyMiddleware<TTenantRecord>>(new { next, notifications });
            return multitenancyMiddleware.Invoke(context);
        });
    }
}


*Note: Code in this article is not production ready and is used for prototyping purposes only. If you have suggestions or feedback please do comment.

No comments:

Post a comment