Skip to content
Troubleshooting

Fix tenant context problems

Diagnose a query that returns nothing, an unexpected denial, or a job that behaves differently from the request.

Tenant context problems

Tenant context is established before authorization. When something returns nothing or refuses unexpectedly, check context and grant before changing the code.

Never work around a failing isolation assertion by disabling tenant scope. The assertion is usually correct and the setup is incomplete. Weakening the scope turns a caught bug into a shipped one.

Symptom index

SymptomSection
A query returns zero recordsQuery returns nothing
Unexpected 403 for an actor who "has the permission"Authorization denies unexpectedly
A manager sees their own record but not their team'sPopulation is narrower than expected
A grant exists but reaches nothingGrant scope does not match the permission
A queued job behaves differently from the requestA job lost its context
A request hits the central domain instead of the tenantTenancy initialize does not switch the host

Query returns nothing

The tenant-scoped query returns zero records.

Cause. One of three, in order of likelihood: the tenant was never selected; the actor has no grant, so the visibility scope excludes everything; or the rows were created in a different tenant.

Fix.

  1. Confirm the tenant was selected before any tenant-model access. A model touched outside tenant context reads the wrong connection.
  2. Create the Legal Entity membership and the Access Grant the action requires. A visibility scope with no grant correctly returns nothing.
  3. Confirm the fixture rows were created inside the same tenant.

Confirm. The query returns rows for a granted actor and still returns nothing for an ungranted one.

Prevent. An empty result is the correct answer to "no grant". Treat it as a setup gap, not a scope bug.

Authorization denies unexpectedly

403 Forbidden for an actor who has the permission key.

Cause. A permission alone is not access. The Policy checks the permission and that the record is in scope, so a record in another Legal Entity is refused even with the permission.

Fix. Build the whole chain:

RequirementWhy
Tenant contextEstablished before authorization is evaluated
A Role carrying the permissionDefinitions alone grant nothing
An Access Grant at a scope the permission acceptstenant, legal_entity, or operating_unit — and its dimension must appear in the route's target
Legal Entity membershipRequired where the route resolves the Legal Entity from the actor rather than the URL
The record inside that scoperecordMatches() checks it

Confirm. The allowed case succeeds; the denied case still returns 403.

Prevent. Keep an unprivileged actor in every fixture. A suite that only exercises an administrator cannot detect a missing check.

Population is narrower than expected

A manager sees their own record but not their team's records.

Cause. The grant's subject population may be narrower than the intended task. A self grant reaches the actor's own records; managing direct reports requires the supported population and its relationship evidence. Do not replace it with the internal all wildcard to make the query pass.

Fix. Check the grant's population against what the operation needs:

PopulationReaches
allInternal protected/recovery wildcard; not a SubjectPopulation enum case
selfThe actor's own records
direct_reportsRecords of people reporting directly to the actor
legal_entityRecords within the grant's Legal Entity
operating_unitRecords within the grant's Operating Unit

Confirm. The actor reaches exactly the intended set — not more.

Prevent. reporting_tree and assigned_records appear in doctrine but not in SubjectPopulation. The enum is what runs; do not design around the others.

Grant scope does not match the permission

An Access Grant exists, but the protected action still reaches no records.

Cause. AssignmentScope ranks scopes, and a grant must be coarser or equal to the permission's declared scope:

Code example
PHP
public function supportsGrantAt(self $grantScope): bool
{
    return $grantScope->rank() <= $this->rank();   // Tenant 0, LegalEntity 1, OperatingUnit 2
}

A permission declared at Tenant accepts only a Tenant grant — a Legal Entity grant is finer and does not satisfy it. This is the reverse of most people's intuition.

Fix. Check the permission's intended assignment scope before changing a grant. Have an access administrator assign the correct role and scope; change the permission declaration only when the business authorization contract itself is wrong. See Add a protected API.

Confirm. The action succeeds for the intended grant and still fails for a grant that should not qualify.

Prevent. For a subtree, core.operating_unit is the only scope type supporting include_descendants. Legal Entity relationships do not.

A job lost its context

A queued job behaves differently from the equivalent HTTP request.

Cause. HTTP middleware establishes the tenant and signed-in user, but that request state does not carry into asynchronous execution. Every asynchronous operation must run inside its owning tenant. Only work that performs a protected operation on a user's behalf must resolve that user again and evaluate their authority at execution time.

Fix. Queue or scheduler infrastructure may call TenantRunner::runFor($tenantKey, $callback) with a trusted tenant key. It returns false when that tenant no longer exists. The platform already invokes an event consumer inside its owning tenant, so the consumer only verifies that the envelope tenant matches the current tenant:

Code example
PHP
// tenantId is a non-nullable string — create() casts a missing tenant to ''.
if (trim($envelope->tenantId) === '' || $envelope->tenantId !== (string) tenant()?->getTenantKey()) {
    throw new RuntimeException('Refusing to consume an envelope outside the current tenant.');
}

Register a user-independent system projection with AppEventListeners::listen(). For a protected operation performed on a user's behalf, use ActorDelegatedAppEventListeners::listenDelegated() with an EventActorAuthorizer. The platform resolves and verifies the envelope user and rechecks current authority immediately before execution; the handler must not install actorId into authentication state itself. An actorId is evidence for delegation, not a login session.

Confirm. A system projection runs without a user, while a delegated listener does not run when its user is unavailable or no longer authorized. Neither kind runs without a tenant or when the envelope tenant does not match.

Prevent. Falling back to a default tenant after restoration fails is a doctrine violation (TB-3). Missing or ambiguous context must fail safely.

Tenancy initialize does not switch the host

The request resolves on the central host after tenancy initialization.

Cause. tenancy()->initialize() switches app-level context only. HTTP requests still target the central domain.

Fix. Set HTTP_HOST explicitly on the request when it must resolve to the tenant domain.

Confirm. The request resolves to tenant routes rather than central ones.

Prevent. App context and request host are two separate facts.

Source of truth: docs/developers/content/en/troubleshooting/tenant-context.md