">
 

Resolve the tenant from the user, not the request

Iniciado por joomlamz, Hoje at 02:25

Respostas: 0   |   Visualizações: 3

Tópico anterior - Tópico seguinte

0 Membros e 1 Visitante estão a ver este tópico.

Resolve the tenant from the user, not the request



Tópico: Resolve the tenant from the user, not the request
Categoria: Tutoriais | Programação & Tecnologia
Idioma Principal: Português (Conteúdo de Tecnologia)

Descrição do Conteúdo / Informações:
-------------------------------------------------------------------------


TL;DR


• A multi-tenant app was resolving the active tenant from the request (subdomain/header) instead of the authenticated user.

• That makes the client the source of truth for "which tenant am I" — the wrong place for it.

• Fix: derive the tenant from the user's organization membership, enforce it in middleware, and fail closed. One test locks the behaviour.



The bug, in one sentence


The request was telling the app which tenant to load, and the app believed it.

In a multi-tenant SaaS, every query is implicitly scoped: "give me this tenant's dashboards." If the tenant ID comes from something the client controls — a subdomain, a header, a route param — then the scoping is only as trustworthy as the client. That's a leak waiting to happen.



Where the trust should live


Think of it like a building pass. The request is someone saying "I'm here for floor 9." The membership record is the pass that says which floors you're actually allowed on. You check the pass, not the claim.

Before
After

Source of truth
request (subdomain / header)
user's organization membership

Who decides the tenant
the client
the server

Failure mode
user can land in a tenant they don't belong to
resolution fails closed

Testable?
hard — depends on request shape
yes — depends on the user



The shape of the fix


Resolve the tenant from the authenticated user's organization, in one middleware, before anything tenant-scoped runs:

final class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
$org = $request->user()?->currentOrganization();

// No org, no tenant context. Fail closed, never guess.
abort_if($org === null, 403, 'No organization context.');

Tenancy::setCurrent($org->tenant);   // server-derived, not request-derived

return $next($request);
}
}

The key line isn't the setCurrent() — it's that the value comes from $request->user(), not from $request. The user is authenticated; the subdomain is not.

request ──> [auth] ──> [SetTenantContext] ──> tenant-scoped routes

└── tenant = user's org  (NOT the URL/header)



Lock it with a test


A leak like this is exactly the kind of thing that silently regresses. So the fix isn't done until a test would scream if someone reintroduces it:

it('never resolves a tenant the user does not belong to', function () {
$userA = User::factory()->inOrganization($orgA = Organization::factory()->create())->create();
$orgB  = Organization::factory()->create();

// Even if the request "asks" for org B, user A must stay in org A.
actingAs($userA)
->withHeader('X-Tenant', $orgB->tenant->getKey())
->get('/dashboards')
->assertOk();

expect(Tenancy::current()->is($orgA->tenant))->toBeTrue();
});



Takeaway


If a value scopes data, it has to come from something the client can't forge. For tenant resolution that means the authenticated user's membership — verified server-side, failing closed when it's missing. Resolve identity from who you are, not from what you ask for.


Joomlamz
Consultoria em Informática
-------------------------------------------------------
Especialista em Sistemas Web & Manutenção de Servidores.
A desenvolver o novo AplPortal com suporte a PHP 8.
Precisa de ajuda profissional? Contacte-me.

Tags: