Skip to content

Refuse suspended tenants at resolution (#7) - #34

Merged
suchait007 merged 3 commits into
tenantlayer-io:mainfrom
rajdeephere:feature/54-suspend-activate
Sep 12, 2026
Merged

suchait007 merged 3 commits into
tenantlayer-io:mainfrom
rajdeephere:feature/54-suspend-activate

Conversation

@rajdeephere

@rajdeephere rajdeephere commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Closes #7. The registry's status column has existed since 0.1.0 and, by its own javadoc,
was "carried and reported, not yet used to reject requests". This makes it mean something:
when a TenantRegistry bean exists, TenantFilter looks the resolved tenant up — after
membership verification, before the tenant is bound — and refuses any status other than
ACTIVE with a 403.

That is the same rule forEachTenant has always applied, so a suspended tenant is off for
its users and for the nightly job at the same moment. A tenant is never off for one and on
for the other; there is a test that asserts exactly that.

Two boundaries drawn deliberately:

  • A tenant the registry does not contain is not refused. Existence is a different
    control from status, and enforcing it here would turn every deployment with an
    unpopulated registry into one that rejects all traffic.
  • There is no opt-in flag. A registry bean is enough. Per CONTRIBUTING, a setting whose
    insecure value is the default is a rejection; the way to say "I don't want this" is
    tenantlayer.registry.enabled=false, which is a decision someone has to type.

Mutation test

Three mutations, each reverted after:

Broke Result
if (false) in place of the status check — never refuse 5 failures across all three nested classes, incl. suspendedTenantIsRefused, refusedRequestBindsNoTenant, filterAndForEachTenantAgreeOnWhoIsServed
Inverted the check — refuse ACTIVE, serve SUSPENDED 6 failures, incl. activeTenantIsServed
Moved TenantContext.enter() above the status check 2 failures: refusedRequestBindsNoTenant, registryFailureIsNotFailOpen

The second one is the one I care about. A filter that rejects everything satisfies
"a suspended tenant is refused" trivially, so activeTenantIsServed exists to catch it and
its assertion message says so. The third confirms the "before any connection is bound"
ordering is load-bearing rather than merely asserted in a comment — with the binding moved
one line earlier, a refused request leaves the tenant on the thread.

  • I broke the implementation and confirmed the new test fails
  • Every "cannot see the other tenant" assertion is paired with "can see its own rows"
    — the analogue here is suspendedTenantIsRefused paired with activeTenantIsServed
  • Tests connect as a least-privileged role, not a superuser — n/a, these tests use an
    in-memory registry and MockHttpServletRequest, no database

Public API

  • This does not change TenantResolver, TenantContextStorage, TenantScope,
    TenantMembershipVerifier, TenantRegistry, or anything in io.tenantlayer.test

TenantFilter gained a fifth constructor argument, the registry. It is not in the list
above, and both existing constructors still compile and behave exactly as before — they
delegate with a null registry, which skips the check.

Behaviour change worth flagging

The registry is autoconfigured whenever a DataSource exists, so with this change every
scoped request performs one registry lookup. Two consequences, both in the changelog:

  1. An application with a DataSource but no tenantlayer_tenants table will now fail
    requests
    with a registry error rather than serve them. Loud on purpose, but it is a
    behaviour change. The DDL is in TenantRegistrySchema.DDL; the opt-out is
    tenantlayer.registry.enabled=false.
  2. JdbcTenantRegistry.find() is an uncached SELECT per scoped request. I have not
    added caching, because a cached suspension that takes effect "eventually" is a worse
    thing to ship than a query — and reactivationTakesEffectImmediately pins that. But it
    does soften the "no runtime dependency on the registry for request handling" line in
    architecture.md, which this PR rewrites accordingly. Happy to take direction here: a
    short-TTL cache with a documented staleness window is the obvious alternative, and it is
    your call whether that trade is worth making.

Checks

  • All commits are signed off (git commit -s)
  • mvn test passes — 159 tests, 0 failures, 0 errors
  • mvn test -Phibernate7 passes — 159 tests, 0 failures. Nothing here touches
    persistence, but I ran it anyway and confirmed the profile really does put
    Hibernate 7.0.2 and Jakarta Persistence 3.2 on the classpath (6.6.18 / 3.1.0 by
    default), rather than trusting a green build
  • No new non-optional dependency — the registry was already a core type; nothing new
    is on the classpath
  • Docs updated — architecture.md, configuration.md, recipes.md,
    securing-resolution.md, tenant-registry.md, and a changelog entry

The recipe in recipes.md and securing-resolution.md that told people to check status by
hand inside a TenantMembershipVerifier has been removed rather than left to rot, since
the library now does it.

@suchait007

Copy link
Copy Markdown
Contributor

Thanks for this, and for the comments — the reasoning in them is the part I'd have had to ask about otherwise. Two things I checked rather than took on trust.

The tests are real. I mutation-tested them instead of reading them: removing the suspension check fails five, and making it also refuse tenants the registry has never heard of fails another — because you wrote a test asserting exactly that case is still served. That is a defended decision rather than an accident, and it is the right one. Turning every deployment with an unpopulated registry into one that rejects all traffic would be a bad surprise.

Also worth knowing: this now composes correctly with the PROVISIONING status that landed in #37. A tenant that is still being provisioned is not ACTIVE, so your check refuses it — which is what should happen to a half-built tenant.

One thing needs changing before this can merge. The comment says:

Switching the registry off (tenantlayer.registry.enabled=false) is the way to say "I do not want this"

That property disables the whole of TenantRegistryAutoConfiguration, which as of #37 also provides TenantProvisioning, and which forEachTenant, migrateAll() and database-per-tenant routing all depend on. So declining this feature currently costs a user onboarding, scheduled jobs, per-tenant migrations and tenant-to-database routing. In practice that means there is no way to decline it.

The cost is not nothing, either. JdbcTenantRegistry.find() has no cache — it opens a connection and runs a query every call — so this adds a database round trip to every tenant-scoped request, borrowing from the same pool the request itself needs. At a thousand requests a second that is a thousand extra queries a second to answer a question that changes about twice a year.

Two changes and I am happy to merge it:

  1. A dedicated property, say tenantlayer.registry.enforce-status, defaulting to true. That keeps your default — and I agree with your default, suspended should mean suspended without anyone opting in — while giving a real way out that does not take the registry with it.
  2. A short-lived cache on the status lookup. Thirty seconds would make the cost negligible and suspension still effectively immediate. If you would rather keep that out of this PR, a note in the docs about the per-request query would do and we can do the cache separately.

Nothing here is a redesign, and the shape of the change is right. Happy to talk through either if you would rather do it differently.

@rajdeephere rajdeephere closed this Sep 9, 2026
@rajdeephere

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, and for mutation-testing rather than reading. The unknown-tenant case was
deliberate, so I'm glad it held up. Good to know it composes with PROVISIONING too.

You're right about tenantlayer.registry.enabled=false. When I wrote that comment the registry did much less, and
I hadn't re-checked what #37 added to the same auto-configuration. I'll make both changes in this PR:

  1. A tenantlayer.registry.enforce-status property, defaulting to true, and I'll fix the comment and docs to
    point at it.
  2. A cache on the status lookup with a configurable TTL, defaulting to thirty seconds.

One question on the cache: I plan to keep it on the enforcement path only, leaving JdbcTenantRegistry.find()
uncached for other callers. That keeps the PR focused. Shout if you'd rather have a caching decorator around the
whole registry instead.

@rajdeephere rajdeephere reopened this Sep 9, 2026
@suchait007

Copy link
Copy Markdown
Contributor

Enforcement path only, and for a reason beyond keeping the PR focused.

TenantProvisioning.onboard calls registry.find to decide whether a tenant is already active, which is how it stays idempotent. Behind a thirty-second cache, a retried signup could read PROVISIONING for a tenant that just became ACTIVE and re-run every hook. Hooks are required to tolerate that, so it would not corrupt anything — but it is the kind of quiet wrongness that is much easier to design out now than to debug later.

The two paths genuinely want different things. Enforcement is per-request, hot, and perfectly happy with an answer that is a few seconds stale. Provisioning and forEachTenant are infrequent and want the truth. Cache the one, leave the other alone.

Thirty seconds sounds right for the default. Worth a line in the docs saying suspension takes effect within the TTL rather than instantly, so nobody is surprised by a suspended tenant being served for a moment after the switch is flipped.

@rajdeephere
rajdeephere force-pushed the feature/54-suspend-activate branch from dad20a1 to eed698b Compare September 10, 2026 13:04
Signed-off-by: rajdeephere <rajdeep.workx@gmail.com>
Signed-off-by: rajdeephere <rajdeep.workx@gmail.com>
…io#7)

Signed-off-by: rajdeephere <rajdeep.workx@gmail.com>
@rajdeephere
rajdeephere force-pushed the feature/54-suspend-activate branch from eed698b to 4173373 Compare September 10, 2026 13:13
@suchait007
suchait007 merged commit 53d5350 into tenantlayer-io:main Sep 12, 2026
5 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.

Suspend / activate

2 participants