Initially I set out to support a scenario where an user could be part of multiple tenants and have different permissions to each tenant. This has since changed and an user can now only belong to one tenant. The authentication part is taken care of by an external provider, but the user accesses are handled "locally" (more info in previous post). My case involves a Blazor WASM app and an ASP.NET Core API. The external identity provider (IDP), provides, the app, with an id-token and an access-token. The API verifies the access-token using data from the IDP via the Microsoft.AspNetCore.Authentication.JwtBearer
package, so there's nothing special there. Then there's a custom implementation of IClaimsTransformation
. Where accesses (cached) are looked up. These are then added as claims to the authenticated user's ClaimsPrincipal
. Currently it's using role claims, meaning we can use roles via the AuthorizeAttribute(Roles="Abc")
. If custom permission claims had been used, those could be verified e.g. in a custom filter or a custom AuthorizationHandler<TRequirement>
implementation, which then would be used via a certain authorization policy. But... 👉 How do you determine what tenant the request is for? And are there any risks in doing so? 👈
❗Ensure to read the "Warning ⚠️" section below.
Single tenant access
Lets have a quick look at how this could look in a scenario where an user only belongs to one tenant. This implementation of IClaimsTransformation
has a dependency on an IUserAccessProvider
that provides cached user accesses. The current requirement relies on roles and not permissions. One solution could look something like this:
public class TenantClaimsTransformation : IClaimsTransformation
{
private readonly IUserAccessProvider _userAccessProvider;
public TenantClaimsTransformation(IUserAccessProvider userAccessProvider)
=> _userAccessProvider = userAccessProvider;
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity identity)
return principal;
var userId = principal.GetUsername();
if (string.IsNullOrWhiteSpace(userId))
return principal;
var access = await _userAccessProvider
.GetUserAccessAsync(userId);
if (access == null)
return principal;
identity.AddClaim(
new Claim(ClaimTypes.Tenant, access.TenantId));
foreach (var role in access.Roles)
identity.AddClaim(new Claim(ClaimTypes.Role, role));
return principal;
}}
Then we just need to register it to the services:
builder.Services
.AddScoped<IClaimsTransformation, CustomClaimsTransformation>()
We now have roles for use with e.g. the AuthorizeAttribute(Roles="Abc")
and there's also a TenantId
added as a claim, that can be used to e.g. add resources to the correct "bucket" etc. If we had a solution where the app passed a TenantId
via e.g. HTTP-Headers
, we could use the value from the principal to verify that the user has access to the requested tenant.
Multi-tenancy access 🤔
My initial case had the requirement of allowing one user to have different roles per tenant, and it's a bit trickier. It's not one set of roles that should be added. It's one set of roles for the requested/current tenant.
If we can live without AuthorizeAttribute(Roles="Abc")
support and be fine with e.g. writing our own filter attribute, we could perhaps transform all accesses and add e.g. roles with a convention, e.g {tenant}:{role}
, and then verify this in the custom filter attribute. Adding claims would then look somthing like this:
var accesses = await _userAccessProvider.GetUserAccessesAsync(userId);
foreach(var access in accesses)
{
identity.AddClaim(new Claim(ClaimTypes.Tenant, access.TenantId));
foreach (var role in access.Roles)
identity.AddClaim(
new Claim(ClaimTypes.Role, $"{access.TenantId}:{role}"));
}
The Custom filter attribute would then need to look at the current/requested tenant and required role to determine if the user has access or not and then take action on whether to allow or deny the request.
What if I instead took a dependency on e.g. IHttpContextAccessor
in the claims transformation (can I do that) and looked at "the requested tenant" and loaded only the permissions for that? 🫣
foreach(var access in accesses.Where(a => a.TenantId == reqTenantId)
{
identity.AddClaim(new Claim(ClaimTypes.Tenant, access.TenantId));
foreach (var role in access.Roles)
identity.AddClaim(
new Claim(ClaimTypes.Role, $"{access.TenantId}:{role}"));
}
Conceptually, it looks like these solutions could work. But it feels wrong. And what if the requested tenant is tampered with?
Warning ⚠️
Both options in the multi-tenant access solutions rely on that a "current/requested tenant" is provided. It's not coming via a verified and secured access-token or similar. So what if that is somehow spoofed? E.g. if someone affects a custom HTTP-header construct or similar?
Accesses:
UserId: 1
TenantId: 1
Roles: ["event-admin"]
TenantId: 2
Roles: ["payout-admin"]
Two simple requets to demonstrate "valid" calls:
POST https://.../events
HTTP-HEADERS: X-TenantId=1
BODY: { "title": "Event 1" }
=> 200 OK
POST https://.../payouts
HTTP-HEADERS: X-TenantId=1
BODY: { "account": "123", "amount": 100 }
=> 403
But what if the tenant header is changed? If the user is changing the X-TenantId HTTP-Header
to "2"
in the last case:
POST https://.../payouts
HTTP-HEADERS: X-TenantId=2
BODY: { "account": "123", "amount": 100 }
=> 200
It will succeed as he/she indeed has access to do so within that tenant. So given that there are accounts matching, the payout will be registered in that tenant.
At least the payout will be within the correct tenant. A more severe situation is if you accept the following:
POST https://.../payouts
HTTP-HEADERS: X-TenantId=2
BODY: { "tenantId": 823794, "account": "123", "amount": 100 }
=> 200
We now authorize via the headers, but apply the transaction on a completely different tenant. This could just as well happen if the token carried the tenantId
. Meaning, even if the IDP determined the tenant upon login and passes that via the token, you can trick it, if you accept the tenantId
via the payload.
Mitigations?
As we have seen, don't allow the payload to override authorization data. You could also look at procedural mitigations. E.g. attests, audits of audit logs, etc. In the scenario I'm currently facing, I'm not allowing authorizational data to be in conflict. Meaning, you can't specify a X-TenantId: 2
header and then { "tenantId": 823794, ... }
as part of the payload. I'm currently contemplating about requiring the tenantId
to be determined via the IDP login and not allow it via e.g HTTP-headers
. The initial requirement of supporting access to multiple tenants has currently been re-assessed and for now, one user-account will only be associated with one tenant.
Decisions, decisions, decisions...
//Daniel