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
| Symptom | Section |
|---|---|
| A query returns zero records | Query returns nothing |
Unexpected 403 for an actor who "has the permission" | Authorization denies unexpectedly |
| A manager sees their own record but not their team's | Population is narrower than expected |
| A grant exists but reaches nothing | Grant scope does not match the permission |
| A queued job behaves differently from the request | A job lost its context |
| A request hits the central domain instead of the tenant | Tenancy 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.
- Confirm the tenant was selected before any tenant-model access. A model touched outside tenant context reads the wrong connection.
- Create the Legal Entity membership and the Access Grant the action requires. A visibility scope with no grant correctly returns nothing.
- 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:
| Requirement | Why |
|---|---|
| Tenant context | Established before authorization is evaluated |
| A Role carrying the permission | Definitions alone grant nothing |
| An Access Grant at a scope the permission accepts | tenant, legal_entity, or operating_unit — and its dimension must appear in the route's target |
| Legal Entity membership | Required where the route resolves the Legal Entity from the actor rather than the URL |
| The record inside that scope | recordMatches() 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:
| Population | Reaches |
|---|---|
all | Internal protected/recovery wildcard; not a SubjectPopulation enum case |
self | The actor's own records |
direct_reports | Records of people reporting directly to the actor |
legal_entity | Records within the grant's Legal Entity |
operating_unit | Records 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:
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:
// 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.
Related
- Tenant and context — why context precedes authorization
- Permissions and roles — the four-part model behind every denial
- Fix test failures — when the cause is test infrastructure rather than context
- Handle events and jobs — restoring context in a consumer