blog post image
Andrew Lock avatar

Andrew Lock

~11 min read

Forking the pipeline - adding tenant-specific files with SaasKit in ASP.NET Core

This is another in a series of posts looking at how to add multi-tenancy to ASP.NET Core applications using SaasKit. SaasKit is an open source project, created by Ben Foster, to make adding multi-tenancy to your application easier.

In the last two posts I looked at how you can load your tenants from the database, and cache the TenantContext<AppTenant> between requests. Once you have a tenant context being correctly resolved as part of your middleware pipeline, you can start to add additional tenant-specific features on top of this.

Theming and static files

One very common feature in multi-tenant applications is the ability to add theming, so that different tenants can have a custom look-and feel, while keeping the same overall functionality. Ben described a way to do this on his blog using custom Views per tenant, and a custom IViewLocationExpander for resolving them at run time.

This approach works well for what it is trying to achieve - a tenant can have a highly customised view of the same underlying functionality by customising the view templates per tenant. Similarly, the custom _layout.cshtml files reference different css files located at, for example /themes/THEME_NAME/assets, so the look of the site can be customised per tenant. However this is relatively complicated if all you want to do is, for example, serve a different file for each tenant - it requires you to create a custom theme and view for each tenant.

Also, in this approach there is no isolation between the different themes, the templates just reference different files. It is perfectly possible to reference the files of one theme from another, by just including the appropriate path. This approach assumes there is no harm with a tenant using theme A accessing files from theme B. This is a safe bet when just used for theming, but what if we were serving some semi-sensitive file, say a site logo. It may be that we don't want Tenant A to be able to view the logo of Tenant B, without explicitly being within the Tenant B context.

To demonstrate the problem, I created a simple MVC multi-tenant application using the default template and added SaasKit. I added my AppTenant model shown below, and configured the tenant to be loaded by hostname from configuration for simplicity. You can find the full code on GitHub.

public class AppTenant
{
    public string Name { get; set; }
    public string Hostname { get; set; }
    public string Folder { get; set; }
}

Note that the AppTenant class has a Folder property. This will be the name of the subfolder in which tenant specific assets live. Static files are served by default from the wwwroot folder; we will store our tenant specific files in a sub folder of this as indicated by the Folder property. For example. for Tenant 1, we store our files in /wwwroot/tenants/tenant1:

Tenant specific folder layout

Inside of each of the tenant-specific folders I have created an images/banner.svg file which will we show on the homepage for each tenant. The key thing to keep in mind is we don't want tenants to be able to access the banner of another tenant.

First attempt - direct serving of static files

The easiest way to show the tenant specific banner on the homepage is to just update the image path to include AppTenant.Folder. To do this we first inject the current AppTenant into our View as described in a previous post, and use the property directly in the image path:

@inject AppTenant Tenant;
@{
    ViewData["Title"] = "Home Page";
}

<div id="myCarousel" class="carousel slide">
    <div class="carousel-inner" role="listbox">
        <div class="item active">
            <img src="~/tenant/@Tenant.Folder/images/banner.svg" alt="ASP.NET" class="img-responsive" />
        </div>
    </div>
</div>

Here you can see we are creating a banner header containing just one image, and injecting the AppTenant.Folder property to ensure we get the right banner. The result is that different images are displayed per tenant

Tenant 1 (localhost:5001): Tenant 1 with blue banner

Tenant 2 (localhost:5002): Tenant 2 with purple banner

This satisfies our first requirement of having tenant-specific files, but it fails at the second - we can access the Tenant 2 banner from the Tenant 1 hostname (localhost:5001):

Accessing the Tenant 2 purple banner from the Tenant 1 hostname

This is the specific problem we are trying to address, so we will need a new approach.

Forking the middleware pipeline

The technique we are going to use here is to fork the middleware pipeline. As explained in my previous post on creating custom middleware, middleware is essentially everything that sits between the raw request constructed by the web server and your application behaviour.

In ASP.NET Core the middleware effectively sits in a sequential pipe. Each piece of middleware can perform some operation on the HttpContext, and then either return, or call the next middleware in the pipe. Finally it gets another chance to modify the HttpContext on the way 'back through'.

A typical middleware pipeline

When you use SaasKit in your application, you add a piece of TenantResolutionMiddleware into the pipeline. It is also possible, as described in Ben Foster's post, to split the middleware pipeline per tenant. In that way you can have different middleware for each tenant, before the pipeline merges again, to continue with the remainder of the middleware:

Tenant specific middleware showing fork and merge of pipeline

To achieve our requirements, we are going to be doing something slightly different again - we are going to fork the pipeline completely such that requests to our tenant specific files go down one branch, while all other requests continue down the pipeline as usual.

Tenant specific static files forking pipeline completely

Building the middleware

Before we go about building the required custom middleware, it's worth noting that there are actually lots of different ways to achieve what I'm aiming for here. The approach I'm going to show is just one of them.

  • Tenant resolution should happen at the start of the pipeline
  • Requests for tenant specific static files should arrive at the static file path, with the AppTenant.Folder segment removed. e.g. from the example above, a request for the banner image for tenant 1 should go to /tenant/images/banner.svg.
  • Register a route which matches paths starting with the /tenant/ segment.
  • If the route is not matched, continue on the pipeline as usual.
  • If the route is matched, fork the pipeline. Insert the appropriate AppTenant.Folder segment into the path and serve the file using the standard static file middleware.

UseRouter to match path and fork the pipeline

The first step in processing a tenant-specific file, is identifying when a tenant-specific static file is requested. We can achieve this using the IRouter interface from the ASP.NET Core library, and configuring it to look for our path prefix.

We know that any requests to our files should start with the folder name /tenant/ so we configure our router to fork the pipeline whenever it is matched. We can do this using a RouteBuilder and MapRoute in the Startup.Configure method:

var routeBuilder = new RouteBuilder(app);
var routeTemplate = "tenant/{*filePath}";
routeBuilder.MapRoute(routeTemplate, (IApplicationBuilder fork) =>
    {
        //Add middleware to rewrite our path for tenant specific files
        fork.UseMiddleware<TenantSpecificPathRewriteMiddleware>();
        fork.UseStaticFiles();
    });
var router = routeBuilder.Build();
app.UseRouter(router);

We are mapping a single route as required, and also specifying a catch-all route parameter which will match everything after the first segment, and assign it to the filePath route parameter.

It is also here that the middleware pipeline is forked when the route is matched. We have added the static file middleware to the end of the pipeline fork, and our custom middleware just before that. As the static file middleware just sees a path that contains our tenant-specific files, it acts exactly like normal - if the file exists, it serves it, otherwise it returns a 404.

Rewriting the path for tenant-specific files

In order to rewrite the path we will use a small piece of middleware which is called before we attempt to resolve our tenant-specific static files.

public class TenantSpecificPathRewriteMiddleware
{
    private readonly RequestDelegate _next;

    public TenantSpecificPathRewriteMiddleware(
        RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        var tenantContext = context.GetTenantContext<AppTenant>();

        if (tenantContext != null)
        {
            //remove the prefix portion of the path
            var originalPath = context.Request.Path;
            var tenantFolder = tenantContext.Tenant.Folder;
            var filePath = context.GetRouteValue("filePath");
            var newPath = new PathString($"/tenant/{tenantFolder}/{filePath}");

            context.Request.Path = newPath;

            await _next(context);

            //replace the original url after the remaining middleware has finished processing
            context.Request.Path = originalPath;
        }
    }
}

This middleware just does one thing - it inserts the AppTenant.Folder segment into the path, and replaces the value of HttpContext.Request.Path. It then calls the remaining downstream middleware (in our case, just the static file handler). Once the remaining middleware has finished processing, it restores the original request path. That way, any upstream middleware which looks at the path on the return journey through will be unaware any change happened.

It is worth noting that this setup makes it impossible to access files from another tenant's folder. For example, if I am Tenant 1, attempting to access the banner of Tenant 2, I might try a path like /tenant/tenant2/images/banner.svg. However, our rewriting middleware will alter the path to be /tenant/tenant1/tenant2/images/banner.svg - which likely does not exist, but in any case resides in the tenant1 folder and so is by definition acceptable for serving to Tenant 1.

Referencing a tenant specific file

Now we have the relevant infrastructure in place we just need to reference the tenant-specific banner file in our view:

@{
    ViewData["Title"] = "Home Page";
}

<div id="myCarousel" class="carousel slide">
    <div class="carousel-inner" role="listbox">
        <div class="item active">
            <img src="~/tenant/images/banner.svg" alt="ASP.NET" class="img-responsive" />
        </div>
    </div>
</div>

As an added bonus, we no longer need to inject the tenant into the view in order to build the full path to the tenant-specific file. We just reference the path without the AppTenant.Folder segment in the knowledge it'll be added later.

Testing it out

And that's it, we're all done! To test it out we verify that localhost:5001 and localhost:5002 return their appropriate banners as before.

Tenant 1 (localhost:5001): Tenant 1 with blue banner

Tenant 2 (localhost:5002): Tenant 2 with purple banner

So that still works, but what about if we try and access the purple banner of Tenant 2 from Tenant 1?

Accessing the Tenant 2 purple banner from the Tenant 1 hostname giving a 404

Success - looking at the developer tools we can see that the request returned a 404. This was because the actual path tested by the static file middleware, /tenant/tenant1/tenant2/images/banner.svg, does not exist.

Tidying things up

Now we've seen that our implementation works, we can tidy things up a little. As a convention, middleware is typically added to the pipeline with a Use extension method, in the same way UseStaticFiles was added to our fork earlier. We can easily wrap our router in an extension method to give the same effect

public static IApplicationBuilder UsePerTenantStaticFiles<TTenant>(
    this IApplicationBuilder app,
    string pathPrefix,
    Func<TTenant, string> tenantFolderResolver)
{
    var routeBuilder = new RouteBuilder(app);
    var routeTemplate = pathPrefix + "/{*filePath}";
    routeBuilder.MapRoute(routeTemplate, (IApplicationBuilder fork) =>
        {
            fork.UseMiddleware<TenantSpecificPathRewriteMiddleware<TTenant>>(pathPrefix, tenantFolderResolver);
            fork.UseStaticFiles();
        });
    var router = routeBuilder.Build();
    app.UseRouter(router);

    return app;
}

As well as wrapping the route builder in an IApplicationBuilder extension method, I've done a couple of extra things too. First, I've made the method (and our TenantSpecificPathRewriteMiddleware) generic, so that we can reuse it in apps with other AppTenant implementations. As part of that, you need to pass in a Func<TTenant, string> to indicate how to obtain the tenant-specific folder name. Finally, you can pass in the tenant/ routing template prefix, so you can name the tenant-specific folder in wwwroot anything you like.

To use the extension method , we just call it in Startup.Configure, after the tenant resolution middleware:

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    //other configuration
    app.UseMultitenancy<AppTenant>();

    app.UsePerTenantStaticFiles<AppTenant>("tenant", x => x.Folder);

    app.UseStaticFiles();
    //app.UseMvc(); etc
}

Considerations

As always with middleware, the order is important. Obviously we cannot use tenant specific static files if we have not yet run the tenant resolution middleware. Also, it's critical for this design that the UseStaticFiles call comes after both UseMultitenancy and UsePerTenantStaticFiles. This is in contrast to the usual pattern where you would have UseStaticFiles very early in the pipeline.

The reason for this is that we need to make sure we fork the pipeline as early as possible when resolving paths of the form /tenant/REST_OF_THE_PATH. If the static file handler was first in the pipeline then we would be back to square one in serving files from other tenants!

Another point I haven't addressed is how we handle the case when the tenant context cannot be resolved. There are many different ways to handle this, which Ben covers in detail in his post on handling unresolved tenants. These include adding a default tenant (so a context always exists), adding additional middleware to redirect, or returning a 404 if the tenant cannot be resolved.

With respect to our fork of the pipeline, we are explicitly checking for a tenant context in the TenantSpecificPathRewriteMiddleware, and if one is not found, we are just returning immediately. Note however that we are no setting a status code, which means that the response sent to the browser will be the default 200, but with no content. The result is essentially undefined at this point, so it is probably wise to handle the unresolved context issue immediately after the call to UseMultitenancy, before calling our tenant-specific static file middleware.

As I mentioned previously, there are a number of different ways we could achieve the end result we're after here. For example, we could have used the Map extension on IApplicationBuilder to fork the pipeline instead of using an IRouter. The Map method looks for a path prefix (/tenant in our case) and forks the pipeline at this point, in a similar way to the IRouter implementation shown. It's worth nothing there's also a basic url-rewriting middleware in development which may be useful for this sort of requirement in the near future.

Summary

Adding multi-tenancy to an ASP.NET Core application is made a lot simpler thanks to the open source SaasKit. Depending on your requirements, it can be used to enable data partitioning by using different databases per client, to provide different themes and styling across tenants, or to wholesale swap out portions of the middleware pipeline depending on the tenant.

In this post I showed how we can create a fork of the ASP.NET Core middleware pipeline and to use it to map generic urls of the form PREFIX/path/to/file.txt, to a tenant-specific folder such as PREFIX/TENANT/path/to/file.txt. This allows us to isolate static files between tenants where necessary.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?