# The Tenant You Suspended Is Still Running Jobs

Published 2025-09-26. Updated 2026-09-17.
Canonical URL https://log.delaa.dev/blog/2025-09-26-suspended-tenant-still-resolvable

> A status field changed. A boolean next to it didn't. The HTTP layer enforced the difference. Nothing else did.

---

<Image
  src="/images/suspended-tenant-still-resolvable/hero.png"
  alt="A closed padlock beside an open padlock with a gear icon, representing one enforced state next to an unenforced one"
  width={2000}
  height={1125}
  className="rounded-lg"
/>

Suspending a tenant sounds like a single action. Flip a switch, access
stops. In a multi-tenant system with more than one entry point, an HTTP
layer, a CLI, a queue of background workers, "suspended" is not one
fact. It's however many facts your code actually checks, at however many
places it checks them. Miss one, and a tenant you believe is locked out
is still fully operational everywhere you didn't look.

## Two fields, one meaning, two different lifecycles

A tenant suspension usually starts as a status change:

```php
$tenant->setSetupStatus(SetupStatus::Suspended);
$entityManager->flush();
```

That's the field a billing dashboard shows. It's also, on its own,
almost decorative. The code that actually decides whether a
tenant exists for the purposes of routing requests to it doesn't ask
about `setupStatus` at all:

```php
$tenant = $this->tenantRepository->findOneBy(['active' => true]);
```

`active` and `setupStatus` are two different fields, updated by two
different code paths, with no enforced relationship between them. If
suspension only ever sets `setupStatus`, the tenant keeps resolving
normally through `active`. It hasn't been suspended in any sense the
resolver cares about, only in the sense a status badge in an admin
panel cares about.

## Where enforcement actually lived

In practice, the gap gets papered over at the layer where the effects
are most visible. An HTTP subscriber checks the tenant's status on every
web request and blocks it there.

```mermaid
flowchart TD
    A[Suspend tenant] --> B["setupStatus = Suspended<br/>(active untouched)"]
    B --> C{Entry point}
    C -->|HTTP request| D[TenantSuspensionSubscriber<br/>checks setupStatus]
    D --> E[403 — blocked correctly]
    C -->|Console command| F[TenantResolverListener<br/>checks active only]
    C -->|Background worker| F
    F --> G[Tenant resolves normally<br/>— fully operational]
```

That subscriber does exactly what it's supposed to do, reject the
request. The problem is everything to its left in the diagram that never
goes through it. A scheduled job that resolves its tenant list from
`active = true` finds the suspended tenant sitting right there,
indistinguishable from every tenant in good standing. A console command
run manually against that tenant's data works without complaint. Nothing
in either path was ever wired to ask the question the HTTP subscriber
asks.

## Why this isn't a niche architecture problem

This isn't specific to one framework or one field-naming choice. It's
a structural risk in any multi-tenant system where enforcement is
attached to a specific transport rather than to tenant resolution
itself. Background workers are, in general, one of the more common
places for cross-tenant issues to surface precisely because they're the
layer furthest from the request/response cycle developers instinctively
reason about when they think "access control." A worker dequeues a job,
resolves a tenant, and runs. The same suspicion that makes you check
`$this->isGranted(...)` on a controller rarely makes it into a worker's
`process()` method, because a worker doesn't feel like a place that
needs an authorization check. It resolves tenants the same way
everything else does, which is exactly the problem.

## The fix is boring on purpose

```php
public function suspend(): void
{
    $this->setSetupStatus(SetupStatus::Suspended);
    $this->setActive(false);
}
```

Pairing the two fields at the single point where suspension happens
means every consumer of `active`, the resolver, a worker's tenant
lookup, a scheduled job's tenant list, becomes correct automatically,
without needing its own awareness of suspension as a concept. The fix
isn't "add more checks to more places." It's collapsing two facts that
were accidentally allowed to drift apart back into one, at the one place
that sets them.

## What to actually audit

If your system suspends, disables, or soft-deletes tenants (or
accounts, or any top-level scoping entity), the question worth asking
isn't "does suspension work?" A quick manual test through the UI will
usually say yes, because the UI goes through HTTP. The question is, how
many other paths resolve that same entity, and does every one of them
ask the same question the HTTP layer asks? Background workers, console
commands, and scheduled jobs are the paths most likely to have been
built before suspension existed as a requirement, and least likely to
be covered by whatever test convinced everyone the feature was done.
