Search code examples
c#asp.net-core.net-corerazor-pagesmulti-tenant

Configure Razor Pages for multi-tenancy, using IPageRouteModelConvention and separate tenant page folders


I'm trying to configure Razor Pages routing (not Razor Views) and make it support multi-tenancy via directory-structure...

So, I'd have a bunch of pages for different tenants, eg:

/Pages/1/About.cshtml
/Pages/2/About.cshtml
/Pages/2/Other.cshtml
... plus many many more ...

and an origin to tenantID lookup, ie:

"example.com" : 1,
"site.tld"    : 2,
...

Then when someone requests "example.com/About" it maps correctly to page in the tenant # 1 subfolder (since "example.com" maps to # 1 in example above), rather than a different tenant "About" page.

Discarded solutions...

  • There are a bunch of Razor View solutions but that's not what I'm looking for (I'm using Razor PAGES).
  • Also I've seen one person use url-rewriting, but this is a bit brute-force and inelegant and I'd like a proper routing solution.
  • Hardcoding routes would obviously work (either in a mapping or in page directives) but this is not scalable and is error-prone.

Possible solution?

Using IPageRouteModelConvention seems like the "correct" way of configuring Razor Pages routes?

Seems like I can modify the route selectors to strip off the tenant Id sub-dir and therefore make the pages available at the root path. However then I also need to make sure the appropriate tenant's page is requested rather than a different tenant's...

One way (I think) this could be done is using an ActionConstraint (which can also be configured in IPageRouteModelConvention). If the origin:tenantId dictionary was hard-coded then I think that would be easy... but my tenant lookup data needs to be pulled from the DB (I actually have a TenantCollection service added as a singleton in the .NET Core service collection already).

The problem is that I don't have access to the ServiceProvider (to get my TenantCollection) at builder.Services.Configure(...) call. So I can't create the ActionConstraint to restrict access to certain pages for certain origins since I don't have the tenant mapping data.

Here is some example code in-case it helps to illustrate...

builder.Services.AddSingleton<TenantCollection>();

builder.Services.AddRazorPages();
builder.Services.Configure<RazorPagesOptions>(options =>
{
    var tenantCollection = GET_MY_TENANT_COLLECTION; // Cant do?

    options.Conventions.Add(new MultiTenantPageRouteModelConvention(tenantCollection));
});

I feel like I'm missing something obvious, or attacking the problem from the wrong direction?


Solution

  • So, in the end I was "missing something obvious". In the ActionConstraint the ServiceProvider can be accessed via the ActionConstraintContext's RouteContext.HttpContext.RequestServices reference. This allows me to get the service I needed to do what I needed. Simple.

    Instead of leaving it at that, I figure I might as well make this post more worth while.. so I'll give a stripped down implementation of what I'm doing, just in case some future person finds it useful.

    Program.cs

    ...
    builder.Services.AddSingleton<MyTenantCollection>();
    builder.Services.AddScoped(MyTenant.ImplementationFactoryBasedOnRequestOrigin);
    
    builder.Services.Configure<RazorPagesOptions>(options =>
    {
       options.Conventions.Add(new MyPageRouteModelConvention());
    });
    ...
    

    MyPageRouteModelConvention.cs

    ...
    public class MyPageRouteModelConvention : IPageRouteModelConvention
    {
        public void Apply(PageRouteModel model)
        {
            // Only modify pages in the tenants folder.
            if (!model.ViewEnginePath.StartsWith("/Tenants/"))
                return;
    
            // Tenants/<num>/<page>...
            if (!validateAndParseTenantFolderNumFromPath(model.ViewEnginePath, out int tenantFolderNum))
                return;
    
            var constraint = new MyTenantActionConstraint(tenantFolderNum);
    
            foreach (var selector in model.Selectors)
            {
                // Change the selector route template so the page is
                // accessible as if it was in the root path.
                // Eg "Tenants/123/SomePage" changes to "SomePage"
                selector.AttributeRouteModel.Template =
                    stripOffTheTenantPath(selector.AttributeRouteModel.Template);
    
                // Note that this is directly modifying this selector's route template,
                // so it will no longer be accessible from the tenant sub folder path.
                // Alternatively one could create a new selector based on this
                // one, modify the template in the same way, and add it as a new
                // selector to the model.Selectors collection.
    
                // Add the constraint which will restrict the page from being
                // chosen unless the request's origin matches the tenant
                // (ie: folderNum == tenantId).
                selector.ActionConstraints.Add(constraint);
            }
        }
    }
    ...
    

    MyTenantActionConstraint.cs

    ...
    public class MyTenantActionConstraint : IActionConstraint
    {
        public int Order => 0;
    
        private readonly int _tenantID;
    
        public MyTenantActionConstraint(int tenantID)
        {
            _tenantID = tenantID;
        }
    
        public bool Accept(ActionConstraintContext context)
        {
            // Get the MyTenant that matches the current requests origin
            // using the MyTenant.ImplementationFactoryBasedOnRequestOrigin.
            // This is a 'scoped' service so it only needs to do it once per request.
            // Alternatively one could just get the MyTenantCollection and find the
            // tenant by _tenantID and then check that your tenant.ExpectedOrigin matches
            // the current HttpContext.Request.Host, but that would run 
            // every time MyTenantActionConstraint.Accept is invoked.
            var tenant =
                context.RouteContext.HttpContext.RequestServices.GetService(
                    typeof(MyTenant)) as MyTenant;
    
            // Return whether or not this ActionConstraint and more importantly
            // the Page/Route this ActionConstraint is attached to
            // is within the tenant folder (eg Pages/Tenants/123/About.cshtml)
            // which has the same number (eg 123) as the tenant Id that
            // corresponds to the tenant that matches the current request's
            // origin (ie tenantWithId123.DomainName == currentRequest.Host),
            // meaning.. true/false this page-route is for this tenant.
            return tenant?.Id == _tenantID;
        }
    }
    ...