fix(identity): load a root operator's permissions under their own tenant, so a cold cache can't 401 them - #1404
Conversation
… cold cache can't 401 a root operator
The permission set is cached under perm:u:{userId}, a key with no tenant, and on
a miss it was loaded through the request-scoped IdentityDbContext. For a root
operator whose request the tenant-header override had moved to another tenant,
that context could not see the root user, so FindByIdAsync returned null and the
check threw UnauthorizedException: 401 on every permission-gated endpoint,
whenever the entry happened to be cold (every 2 minutes without Redis, after an
hour, a startup sync or a role/group invalidation with it).
The loader now reads the user's TenantId ignoring query filters and, when it is
not the resolved tenant, runs the unchanged load in a child scope set to the
user's own tenant. Same-tenant requests take the old path.
The regression test evicts the root admin's entry and sends the cross-tenant
search: 401 before this change, 200 after. It is also why
RootOperator_Should_TargetOtherTenant_When_HeaderProvided failed at random in CI.
…s not a pre-auth no-op Finbuckle 10.1.0's ClaimStrategy runs the default scheme's handler itself when User is not authenticated yet, so an authenticated caller resolves to its tenant claim and the tenant header only decides for anonymous requests. TenantAdmin_HeaderOverride_Should_BeIgnored depends on exactly that. Three comments in MultitenancyModule and the architecture and multitenancy rules said the opposite.
…ot an extra query The previous commit read every user's TenantId with IgnoreQueryFilters through the request-scoped context on each cache miss. That added a query to every miss, and with a per-tenant connection string (IdentityDbContext.OnConfiguring) the request context points at the target tenant's database, where the root row is absent, so the 401 stayed. Now the child scope opens only when the checked user is the caller and the caller's tenant claim differs from the resolved tenant; the scope is set to the claim's tenant, so it reaches that tenant's connection. The caller comes from IHttpContextAccessor because ICurrentUser is populated after authorization. Every other call takes the old path with no extra query.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
iammukeshm
left a comment
There was a problem hiding this comment.
Good diagnosis. The set belongs to the user, so loading it under the caller's home tenant is the right fix. Scoping the switch to 'checked user == caller && claim != resolved' keeps the common path unchanged with no extra query.
I checked that the tenant switch inside the loader can't leak: the Finbuckle context is AsyncLocal, so the set in LoadPermissionsAsync is undone when that async method returns, and the child scope gives a DbContext built for the right tenant. The comment and rule corrections about ClaimStrategy authenticating on its own also match what we'd seen empirically. Thanks, this likely also explains the HeaderProvided CI flake.
Fixes #1403.
Problem
A root operator who scopes a request to another tenant with the
tenantheader gets401 Authentication failedon permission-gated endpoints whenever their cached permission set is cold. The set is cached underperm:u:{userId}, a key with no tenant. On a miss,UserPermissionServiceloaded it through the request-scopedIdentityDbContext, which the root-operator override inMultitenancyModule.ConfigureMiddlewarehad already moved to the target tenant. The root user does not exist there, soFindByIdAsyncreturnednulland the loader threwUnauthorizedException. The entry goes cold every 2 minutes without Redis (LocalCacheExpiration), and after an hour, a startup permission sync or a role or group invalidation with Redis.Fix
When the user being checked is the caller and the caller's
tenantclaim differs from the tenant the request resolved to, the unchanged load now runs in a child scope whose tenant context is set to the claim's tenant, throughIMultiTenantStoreandIMultiTenantContextSetter, the same patternConfigureJwtBearerOptionsalready uses. The childIdentityDbContextis built for that tenant, so it uses that tenant's connection string when one is configured (IdentityDbContext.OnConfiguring); the tests run on a shared database and do not exercise that path. Every other call, including every request that stays in the caller's tenant, takes the old path with no extra query. The caller is read fromIHttpContextAccessorbecauseICurrentUseris populated only after authorization.Comment and docs correction
The comments in
MultitenancyModuleand the rule files said the claim strategy is a pre-auth no-op and the header is the primary resolver. In Finbuckle 10.1.0 the claim strategy runs the default authentication scheme's handler itself whenUseris not authenticated yet (ClaimStrategy.GetIdentifierAsynccallshandler.AuthenticateAsync()behind its bypass item), so an authenticated caller always resolves to its own tenant claim and the header only decides for anonymous requests.TenantAdmin_HeaderOverride_Should_BeIgnoredalready depends on that. The comments now describe it that way, which is also why the root override has to run after authentication.Tests
TenantHeaderOverrideTests.RootOperator_Should_TargetOtherTenant_When_PermissionCacheIsColdevicts the root admin's entry and sends the cross-tenant user search.UserPermissionService.csreverted tomain, the new test fails with401(1 of 5 in the class failed); with the fix, it passes. The file was restored byte-exact after the run (sha256).The intermittent CI failure of
RootOperator_Should_TargetOtherTenant_When_HeaderProvided(401instead of200, green on rerun) is consistent with this bug, since the eviction repro produces the same401every time, but no CI failure body was captured to confirm it.Docs: fullstackhero/docs#255.