Skip to content

fix(identity): load a root operator's permissions under their own tenant, so a cold cache can't 401 them - #1404

Merged
iammukeshm merged 3 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/root-operator-permissions-home-tenant
Sep 28, 2026
Merged

iammukeshm merged 3 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/root-operator-permissions-home-tenant

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1403.

Problem

A root operator who scopes a request to another tenant with the tenant header gets 401 Authentication failed on permission-gated endpoints whenever their cached permission set is cold. The set is cached under perm:u:{userId}, a key with no tenant. On a miss, UserPermissionService loaded it through the request-scoped IdentityDbContext, which the root-operator override in MultitenancyModule.ConfigureMiddleware had already moved to the target tenant. The root user does not exist there, so FindByIdAsync returned null and the loader threw UnauthorizedException. 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 tenant claim 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, through IMultiTenantStore and IMultiTenantContextSetter, the same pattern ConfigureJwtBearerOptions already uses. The child IdentityDbContext is 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 from IHttpContextAccessor because ICurrentUser is populated only after authorization.

Comment and docs correction

The comments in MultitenancyModule and 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 when User is not authenticated yet (ClaimStrategy.GetIdentifierAsync calls handler.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_BeIgnored already depends on that. The comments now describe it that way, which is also why the root override has to run after authentication.

Tests

  • New TenantHeaderOverrideTests.RootOperator_Should_TargetOtherTenant_When_PermissionCacheIsCold evicts the root admin's entry and sends the cross-tenant user search.
  • Mutation check: with UserPermissionService.cs reverted to main, the new test fails with 401 (1 of 5 in the class failed); with the fix, it passes. The file was restored byte-exact after the run (sha256).
  • Full local run with the fix, all green: Architecture 55, Framework 244, Identity 319, Multitenancy 92, Integration 765, Integration.Middleware 5, Auditing 66, Chat 36.

The intermittent CI failure of RootOperator_Should_TargetOtherTenant_When_HeaderProvided (401 instead of 200, green on rerun) is consistent with this bug, since the eviction repro produces the same 401 every time, but no CI failure body was captured to confirm it.

Docs: fullstackhero/docs#255.

… 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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@iammukeshm iammukeshm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@iammukeshm
iammukeshm merged commit b5eb851 into fullstackhero:main Sep 28, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Root operator gets a random 401 on cross-tenant requests when its permission cache entry is cold

2 participants