Before anybody says anything, I'm aware that this is a reasonably ridiculous requirement, however it's what the client wants.
Background
We have a brownfield system for data management that allows access for two different types of user, we'll call them Tenant
and Admin
.
- A
Tenant
user can only access their own data and gains their TenantContext
from the TenantId
claim in their identity.
- An
Admin
user can access all data for all Tenants
, along with a
handful of admin only views, and acquires the TenantContext
of a particular Tenant
by means of a mechanism driven by session state.
- There are also several views that do not require
TenantContext
(typically static info etc.) that can be accessed by both types of user, and some that can be accessed by anonymous users also.
We want to move away from this stateful implementation in favour of an entirely stateless system.
Problem
The issue we're faced with is how to identify which TenantContext
an Admin
user is working within without storing this in session state. The obvious solution would be to prefix all urls which require a TenantContext
with the TenantId
, for example https://example.com/{tenantId}/somecontroller/someaction
, however doing so would of course affect the urls for Tenant
users also, who currently have no awareness or need to be aware of their TenantId
, as a Tenant
user can only be assigned to a single TenantId
. Unfortunately this is something which the client has firmly stated is unacceptable.
So the question that needs to be answered is, "how to build a website that is both single tenanted and multi-tenanted, dependent on the user that is currently making the request?"
My first thoughts turned to a bit of url-rewriting middleware, which I've semi-successfully implemented:
public enum UserRole
{
Tenant,
Admin
}
public class IdentityBasedMultiTenantMiddleware
{
private readonly RequestDelegate _next;
public IdentityBasedMultiTenantMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext httpContext)
{
if (!(httpContext.User?.Identity?.IsAuthenticated ?? false))
{
await _next(httpContext);
return;
}
if (!Enum.TryParse<UserRole>(httpContext.User.FindFirst("Role")?.Value, out var role))
{
await _next(httpContext);
return;
}
if (role != UserRole.Admin)
{
await _next(httpContext);
return;
}
if (httpContext.Request.Path.Value == "/"
|| httpContext.Request.Path.Value.StartsWith("/admin"))
{
await _next(httpContext);
return;
}
var pathSegments = httpContext.Request.Path.Value.Split("/", StringSplitOptions.RemoveEmptyEntries);
if (!Guid.TryParse(pathSegments.FirstOrDefault(), out var tenantId))
{
httpContext.Response.Redirect("/admin/tenantsearch");
return;
}
httpContext.Items.Add("TenantId", tenantId);
httpContext.Request.Path = httpContext.Request.Path.Value.Remove(0, tenantId.ToString().Length + 1);
httpContext.Request.PathBase = $"/{tenantId}";
await _next(httpContext);
}
}
NB. I'm adding the tenantId
to the httpContext.Items
collection as a lazy way to demonstrate the concept.
However, there are problems with this approach:
- As you can see, I've had to prefix all of the admin routes with
/admin
and hard-code a condition to handle this. It would also be required for all views that don't require a TenantContext
, leaving a rather nasty maintenance overhead for managing any route that doesn't require TenantContext
.
- When viewing any page as an
Admin
user, once TenantContext
has been established by adding it to the url, for example https://example.com/{tenantId}/somecontroller/someaction
, because of the way the url-rewriting is working, all links throughout the site (not just the ones that require TenantContext
) are prefixed with {tenantId}
- although I suspect this specific problem could be overcome with a custom implementation of IUrlHelperFactory
, but of course would require mirroring the same conditions and incur the same maintenance overhead for admin pages etc. also.
I then went on to reverse the problem whilst still using url-rewriting. What if the routes that required TenantContext
always included the TenantId
, and we rewrote those routes for the Tenant
users only? Unfortunately this incurred the same problem as the first url-rewriting solution, so far as routes that required TenantContext
would need to be identified in the middleware and conditions to handle them hard-coded.
Ultimately I believe this to be a limitation of the url-rewriting approach, as we're working at a HttpContext
level and not a Routing
level, where we'd potentially have more context about the mapped controller/action to make a decision.
I've gone on to investigate how this might be achieved using the MVC routing engine but I can't see a way forward - is there anything here that I've missed? Perhaps dynamic controller routing? All thoughts would be appreciated.
A couple of other gotchas:
- We're using a mix of attribute routing (
[Route("someroute")]
/[HttpGet("someroute")]
) and convention based routing for the existing site. We're in the process of moving to purely attribute based routing.
- We've also looked into building a tag helper that appends a querystring containing the
TenantId
to links for Admin
users - this approach works but I think it's dirty, difficult to maintain (easy to break) and ultimately the incorrect way to achieve this.
question from:
https://stackoverflow.com/questions/65602792/asp-net-core-mvc-different-routing-based-on-request-identity-single-tenant-mu