SEMTRAS · Santarém · Multi-tenant
O tenant é o município. Todos usam o mesmo sistema e o mesmo banco PostgreSQL, mas cada um tem os seus módulos, o seu fluxo de autorização e os seus dados. O isolamento tem duas barreiras: o código filtra por tenant_id e o próprio banco recusa o resto (Row-Level Security). Abaixo, Santarém e um segundo município fictício lado a lado. Desligue as barreiras e veja o que vaza.
As linhas destacadas são as que diferem. Tudo isto vive em tenants.configuracoes e tenant_modulos, sem uma linha de código diferente por município.
| Configuração | |
|---|---|
|
|
Menu de quem entra em
telasDesativar um módulo esconde menu, rotas e API, mas não apaga nada. Os municípios do exemplo são fictícios, e Vila Nova só usa itens de consumo porque não tem Patrimônio. Veja 06-mapa-de-modulos.html.
As tabelas abaixo guardam os dois municípios misturados, com a coluna tenant_id. Escolha de onde a consulta parte e quais barreiras estão ligadas.
A consulta parte de
Tabela
Atalhos
| id | tenant_id | Para quem consulta | |
|---|---|---|---|
| vazou visível barrada pelo scope barrada pelo RLS |
Repare no que se repete sem conflito: o papel A4 (PAP-001) existe nos dois catálogos, e João Silva tem o mesmo e-mail nos dois municípios, como duas contas separadas. Os índices únicos incluem o tenant_id, por exemplo (tenant_id, codigo) e (tenant_id, email).
Segue as mesmas chaves da seção anterior. Cada tentativa é fechada pela primeira barreira ligada que a alcança.
select set_config('app.tenant_id', ?, true).BelongsToTenant filtra pelo mesmo id. É a primeira barreira.app.tenant_id definido, a comparação resulta em nulo e nenhuma linha aparece.BYPASSRLS e que não é dono das tabelas. Por isso o FORCE ROW LEVEL SECURITY.audits também leva tenant_id e só aceita inserção.As 26 tabelas e o SQL de cada uma estão em 04-modelo-de-dados.html.
// 1ª barreira: leitura filtrada e gravação com o tenant certo
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
$query->where($query->qualifyColumn('tenant_id'), Tenant::atualOuFalha()->id);
});
static::creating(function (Model $model) {
$model->tenant_id = Tenant::atualOuFalha()->id;
});
}
}
// Middleware: resolve o município e avisa o banco
public function handle(Request $request, Closure $next)
{
$subdominio = explode('.', $request->getHost())[0];
$tenant = Tenant::porSubdominio($subdominio); // ver ponto 1 abaixo
abort_if(! $tenant, 404);
Tenant::definirAtual($tenant);
return DB::transaction(function () use ($tenant, $request, $next) {
DB::select("select set_config('app.tenant_id', ?, true)", [$tenant->id]);
return $next($request);
});
}
-- 2ª barreira: no PostgreSQL, uma policy por tabela
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;
ALTER TABLE pedidos FORCE ROW LEVEL SECURITY;
CREATE POLICY isolamento_tenant ON pedidos
USING (tenant_id = current_setting('app.tenant_id', true)::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::bigint);
De cada município (leva tenant_id)
team_id do Spatie é o tenant).tenant_modulos).De todos (um só para o sistema)
permissions), definido pelo código. O que é por município é quem recebe cada permissão.Se um município grande exigir isolamento total, ele pode migrar depois para um banco próprio, sem mudar o código das telas.
tenants pelo subdomínio, mas essa tabela tem RLS por id e ainda não há app.tenant_id. Falta decidir como: uma função SECURITY DEFINER só para esse lookup, ou uma policy de leitura restrita ao subdomínio.Município Vila Nova, usuários, pedidos, itens e subdomínios fictícios · fonte: docs/requisitos/analise-requisitos.md, seções 5 e 6. Tabelas e RLS em docs/fluxos/04-modelo-de-dados.html e módulos em docs/fluxos/06-mapa-de-modulos.html.