Skip to content

GH-4586: adopt DisabledTenantException, and actually refuse a disabled tenant - #4600

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-4586-disabled-tenant-exception
Sep 24, 2026
Merged

jeremydmiller merged 1 commit into
mainfrom
gh-4586-disabled-tenant-exception

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #4586. Takes JasperFx 2.74.0.

JasperFx 2.74.0 lifted DisabledTenantException (a subclass of UnknownTenantIdException), and Marten 9.39.0, Polecat 5.31.0 and Fisher 1.12.0 already throw it. Wolverine's own two tenant sources did not move, so an operator who had just run CritterWatch's disable_tenant read "Unknown tenant id" about a tenant that is still sitting in the table.

Because the new type subclasses the old one, every existing catch (UnknownTenantIdException) and OnException<UnknownTenantIdException>() policy keeps working unchanged.

EF Core conjoined tenancy — a straight swap

ConjoinedTenantSource.FindAsync and TenantStampingInterceptor.applyTenancyRules both consult ConjoinedTenancy.IsTenantDisabled, so they knew the answer and threw the wrong exception. Two lines.

MasterTenantSource — the exception type was the smaller half

Writing the test turned up two defects underneath it.

A disabled tenant was not refused at all. TryFindTenantConnectionString does not filter on the disabled flag — only LoadAllTenantConnectionStrings does. So FindAsync resolved a disabled tenant on any lookup that missed the in-memory cache, which is every lookup after DisableTenantAsync, because that drops the cached entry. Disabling a tenant removed it from AllActive() and then handed out its connection string on the next request.

The "unknown tenant" branch was dead code. The old order was:

connectionString = await _tenantRegistry.TryFindTenantConnectionString(tenantId);
connectionString = _tenantRegistry.Provider.AddApplicationNameToConnectionString(connectionString, ...);

if (connectionString.IsEmpty())   // never true
{
    throw new UnknownTenantIdException(tenantId);
}

AddApplicationNameToConnectionString builds a connection string from the null it was handed, so the result is Application Name=... — non-empty. An unregistered tenant id got a connection string with no server in it rather than an exception, and failed later and less usefully. The new test a_tenant_with_no_row_at_all_is_still_unknown_rather_than_disabled fails on main for that reason.

The source now keeps the disabled set beside its connection strings — refreshed in RefreshAsync, maintained by DisableTenantAsync / EnableTenantAsync / AddTenantAsync (an upsert re-enables) / RemoveTenantAsync — checks it on the cold path, and tests the stored value before decorating it. The cold-path check costs one extra query on the first resolve of a tenant this source has not seen, not per call.

RefreshAsync loads the disabled ids after LoadAllTenantConnectionStrings, deliberately: that call is what seeds and creates the tenants table, and RefreshAsync runs during resource setup against a database that may not have one yet.

ProblemDetails

DisabledTenantException reaches UnknownTenantMiddleware too. The status stays 404 — from outside, a tenant that was switched off is not addressable, and I would rather not leak "this tenant exists but is off" to an unauthenticated caller — but the title no longer says "Unknown tenant" for it. If a disabled tenant should answer 403 instead, that is a separate call and a behavior change worth its own issue.

Tests

SqlServerTests/disabled_tenant_is_refused_4586.cs, five tests, including a second MasterTenantSource over the same tenants table standing in for every other node in the cluster — that is the one that takes the cold path. On main, three of the five fail. ConjoinedTenantRegistryCompliance is tightened from UnknownTenantIdException to the specific type.

Green: CoreTests 3077/3079 (2 skipped), EfCoreTests.MultiTenancy 200/200, SqlServer + Postgres master-table tenancy, Wolverine.Http.Tests unknown-tenant 5/5. Clean dotnet build wolverine.slnx -c Release -f net9.0 on JasperFx 2.74.0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj

…ly refuse one

JasperFx 2.74.0 lifted DisabledTenantException (a subclass of UnknownTenantIdException) and
Marten, Polecat and Fisher all throw it now. Wolverine's own two tenant sources did not move,
so an operator who had just disabled a tenant read "Unknown tenant id" about a tenant that is
still sitting in the table.

The EF Core conjoined source and its stamping interceptor both track the disabled state
explicitly, so those are a straight swap.

MasterTenantSource was worse than a wrong exception type. TryFindTenantConnectionString does
not filter on the disabled flag, so a disabled tenant resolved on any lookup that missed the
cache -- which is every lookup after DisableTenantAsync, because that drops the cached entry.
And the "no row at all" branch was dead: AddApplicationNameToConnectionString ran BEFORE the
IsEmpty() check and returned a non-empty connection string for a null one, so an unregistered
tenant got a connection string with no server in it rather than UnknownTenantIdException. The
source now keeps the disabled set beside its connection strings, checks it on the cold path,
and tests the stored value before decorating it.

ProblemDetails keeps the 404 -- from outside, a tenant that was switched off is not
addressable -- but no longer titles it "Unknown tenant".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj
@jeremydmiller
jeremydmiller merged commit 01e533e into main Sep 24, 2026
44 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.

Wolverine's own tenant sources still report a disabled tenant as UnknownTenantIdException; adopt DisabledTenantException (JasperFx 2.74.0)

1 participant