SEMTRAS · Santarém · Multi-tenant

Um sistema, vários municípios, nenhum dado misturado

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.

1 · Dois municípios, duas configurações

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

Desativar 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.

2 · O mesmo banco visto de cada município

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



      
Vazamento: de linhas aparecem para quem parte de , incluindo dados de outro município. Quem parte de vê de linhas, todas do próprio município. Sem município definido, a consulta não devolve nenhuma linha.
idtenant_id Para quem consulta

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).

3 · Tentativas de acesso entre municípios

Segue as mesmas chaves da seção anterior. Cada tentativa é fechada pela primeira barreira ligada que a alcança.

Como o tenant chega ao banco

  1. O middleware lê o subdomínio e descobre o tenant. Se não existe, responde 404.
  2. Dentro da transação, grava o id na conexão: select set_config('app.tenant_id', ?, true).
  3. O global scope do trait BelongsToTenant filtra pelo mesmo id. É a primeira barreira.
  4. A policy de RLS é a segunda: mesmo uma query sem o scope só enxerga as linhas do município.
  5. Sem o app.tenant_id definido, a comparação resulta em nulo e nenhuma linha aparece.

Cuidados

  • A aplicação conecta com um usuário sem BYPASSRLS e que não é dono das tabelas. Por isso o FORCE ROW LEVEL SECURITY.
  • Jobs de fila e comandos do Artisan recebem o id do tenant e o definem antes de tocar no banco.
  • A tabela 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.

Como fica no código

// 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);

O que é de cada município e o que é de todos

De cada município (leva tenant_id)

  • Usuários, perfis e a ligação entre eles (team_id do Spatie é o tenant).
  • Secretaria e setores.
  • Catálogo de itens, com código único por município.
  • Módulos ativos e vigência (tenant_modulos).
  • Fluxo de autorização: quantas etapas e quem aprova cada uma.
  • Numeração de pedidos e termos, logo e cabeçalho dos termos.
  • Todos os dados dos módulos e a auditoria.

De todos (um só para o sistema)

  • O código e o esquema do banco: um deploy e um conjunto de migrations para todos.
  • O catálogo de permissões (permissions), definido pelo código. O que é por município é quem recebe cada permissão.
  • O servidor, a fila e o usuário de conexão ao banco.

Se um município grande exigir isolamento total, ele pode migrar depois para um banco próprio, sem mudar o código das telas.

Pontos assumidos nesta página, a confirmar

  1. Achar o município antes de ele existir na conexão. O middleware precisa consultar 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.
  2. Operador da plataforma. Os documentos não preveem alguém que veja todos os municípios. Aqui ninguém cruza municípios pela interface, e a manutenção entra por comando com o tenant definido. Se existir um super-administrador, ele precisa de um papel fora do tenant e de uma policy própria.
  3. Uma pessoa em dois municípios tem duas contas, com o mesmo e-mail. O e-mail é único por município, não no sistema.
  4. Subdomínio como identificação. Domínio próprio de cada município não está previsto.
  5. Forma de assinatura do termo. A regra de autorização de Santarém está definida (uma etapa, basta um veredito da NSUAS 1 ou da NSUAS 2). A configuração de Vila Nova, com duas etapas em sequência, é só um exemplo.

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.