Docs›Multi-tenancy›Estratégias de isolamento
Multi-tenancy

Estratégias de isolamento

Single, Pool, Bridge e Hybrid — planos control/data, config/mad.php, mad_iam_tenant (connection_name + db_config), TenantContext, TenantConnectionRegistrar e o middleware mad.api.

Estratégias de multi-tenancy

O MAD organiza o schema em 6 conexões lógicas (iam, log, business, comm, ged, ai), declaradas em config/database.php e descritas em Conexões de banco. Por padrão todas apontam para o mesmo app/database/mad.sqlite — um único banco, zero configuração. Multi-tenancy é o que acontece quando você precisa isolar dados de clientes diferentes dentro dessa mesma estrutura, sem tocar em model nem em query.

Tenant é a empresa (mad_iam_tenant), não a unidade/filial. Unidades (mad_iam_unit, com tenant_id) da mesma empresa compartilham o banco de dados da empresa.

Os dois planos

As 6 conexões se agrupam em dois planos com comportamento bem diferente:

Plano Conexões Conteúdo Roteado por tenant?
Control iam, log identidade (usuários, unidades, tenants, programas, grupos, permissões), auditoria, fila (jobs/failed_jobs), agendamento ❌ nunca — sempre o banco compartilhado
Data business, comm, ged, ai dados de negócio, comunicação (chat, correio, notificações), documentos (GED), conversas do agente de IA ✅ sim, conforme a estratégia

Identidade, fila e auditoria são sempre compartilhadas. Só o plano de dados varia por tenant. Como não existe foreign key entre conexões diferentes (uma invariante de schema verificada em CI), separar fisicamente o plano de dados é seguro — a integridade identidade↔dado é resolvida na aplicação, não no banco.

As 4 estratégias

A escolha é puramente de configuração — nenhuma muda código de model ou de tela.

Estratégia Isolamento Custo / escala Indicada para
Single (default) nenhum — 1 banco para todo mundo mais barata 1 cliente, app interno
Pool lógico — coluna tenant_id por linha barata, muitos tenants num banco só muitos clientes pequenos
Bridge físico — 1 banco por cliente mais cara, blast radius mínimo poucos clientes grandes, compliance
Hybrid Pool para uns, Bridge para outros misto base de clientes heterogênea

As estratégias não são mutuamente exclusivas — Pool e Bridge podem coexistir (é exatamente o que "Hybrid" significa: grupos de tenants pequenos isolados por linha no mesmo banco, clientes grandes com banco dedicado).

Single (default)

Nada a configurar. As 6 conexões usam app/database/mad.sqlite, php artisan migrate + seed e pronto.

Pool — N tenants num banco só, isolado por linha

Liga o global scope BelongsToTenant (Mad\Database\Concerns\BelongsToTenant) nos models do plano de dados: toda leitura ganha WHERE tenant_id = <tenant corrente> e todo create() grava o tenant_id automaticamente.

MAD_TENANT_ROW_SCOPE_ENABLED=true

O tenant corrente vem de session('tenant_id') (carregado no login) ou, fora de um request HTTP (jobs, comandos artisan), de Mad\Database\TenantContext::set():

use Mad\Database\TenantContext;

TenantContext::set(42);   // escopo do tenant 42 nesta execução
// ... trabalho que usa Eloquent normalmente ...
TenantContext::clear();   // sempre limpar fora do ciclo web — sem reset automático

Com a flag desligada (default), o trait não filtra nada — comportamento idêntico a uma instalação single-tenant. Models que usam o trait recebem o tenant_id automaticamente; nenhuma query precisa declarar o filtro à mão.

A conexão ai é o ressalvo. O trait só roda sobre models Eloquent; as conversas do agente (ConversationStore, schema sem model) ficam fora do filtro por linha. Para isolar ai por tenant, é preciso a estratégia Bridge.

Bridge — 1 banco por cliente

Identidade compartilhada, dados de cada empresa num banco próprio.

  1. Provisione o banco do tenant — pela tela de Empresas (botão Provisionar) ou pela CLI. Os dois caminhos chamam a mesma camada, App\Service\Tenant\TenantProvisioningService:

    php artisan mad:tenant:provision acme
    php artisan mad:tenant:provision acme --database=/path/acme.sqlite --connection=tenant_acme
    

    O serviço valida o slug (guard anti-traversal) e o nome de conexão contra a allowlist, cria o alvo físico conforme o driver, replica só as tabelas do plano de dados (business/comm/ged/ai + as tabelas dos modelos de dados do app — nunca as de identidade) e grava connection_name e db_config em mad_iam_tenant. É idempotente: re-provisionar só cria o que falta.

    Drivers suportados: sqlite (arquivo em database/tenants/<slug>.sqlite), mysql/mariadb e pgsql (CREATE DATABASE), além de madcloud quando o app roda hospedado. Sem --connection, o nome convencional é tenant_<slug> (TenantProvisioningService::connectionNameFor()).

    // config/mad.php → mad.tenant.provisioning
    'template'   => env('MAD_TENANT_TEMPLATE_CONNECTION', 'business'), // herda host/credencial de runtime
    'admin_host' => env('DB_TENANT_ADMIN_HOST'),   // credencial ELEVADA p/ CREATE DATABASE
    'admin_port' => env('DB_TENANT_ADMIN_PORT'),   // vazio => herda o template
    
  2. Registro durável — não é preciso declarar a conexão no config/database.php. O locator da conexão é persistido em mad_iam_tenant.db_config (JSON) e reinjetado em database.connections.<connection_name> no boot, por App\Providers\TenantServiceProvider → App\Service\Tenant\TenantConnectionRegistrar::registerAll().

    • sqlite → o array de conexão completo (sem segredo) vai no db_config.
    • servidor (mysql/pgsql) → locator esparso {driver, database, template}, mergeado sobre a conexão-template: host e credencial vêm do .env, nunca são gravados no control-plane.

    O Registrar é no-op resiliente enquanto mad_iam_tenant não existe (web installer, banco fresco) e memoiza por processo — toda escrita (provisionar/salvar/excluir) chama TenantConnectionRegistrar::flush(). Antes disso, o provisionamento só registrava a conexão em memória e a request seguinte estourava fail-closed ("conexão não definida").

  3. No login, a aplicação resolve o tenant da empresa do usuário e grava session('tenant_database'). O middleware App\Http\Middleware\SetTenantConnection repointa o grupo de conexões de dados a cada request, via TenantConnectionResolver::applyTenant():

    // App\Service\Tenant\TenantConnectionResolver (resumo)
    TenantConnectionResolver::applyTenant('tenant_acme'); // repointa business/comm/ged/ai
    TenantConnectionResolver::resetToDefault();            // volta ao banco default
    

    O control-plane (iam, log) nunca é tocado — login, permissão e fila continuam sempre no banco compartilhado.

  4. Sincronizar schema novo em todos os tenants já provisionados:

    php artisan mad:tenant:migrate              # todos os tenants
    php artisan mad:tenant:migrate --tenant=acme
    

Filiais (mad_iam_unit) da mesma empresa compartilham o connection_name do tenant — e portanto o mesmo banco de dados. Empresas diferentes ficam em bancos isolados.

mad_iam_unit.connection_name foi REMOVIDO. O banco pertence à empresa, não à filial: o fillable de Unit é hoje ['name', 'tenant_id', 'active'] e o login resolve session('tenant_database') exclusivamente por $unit->tenant?->connection_name. Código antigo que lia/gravava connection_name na unidade precisa ser reapontado para o tenant.

Segurança fail-closed. TenantConnectionResolver só aceita um connection_name que já exista em database.connections — e, se mad.tenant.allowed_connections estiver configurado (env MAD_TENANT_ALLOWED_CONNECTIONS, CSV), só o que estiver na allowlist. Nunca interpola host/caminho cru vindo do banco. Se a conexão não estiver na config (worker que bootou antes do provisionamento), há um fallback lazy via TenantConnectionRegistrar::lookup() — que lê o db_config persistido, depois de a allowlist já ter sido validada.

Empresas na UI: CRUD + provisionamento

O tenant deixou de ser só uma linha de banco: existe a tela de Empresas (controllers TenantList, TenantForm e TenantProvisionForm, do grupo Administrador). O TenantForm/TenantList expõem a ação Provisionar, que chama a mesma TenantProvisioningService da CLI e mostra antes um plano sem efeito colateral (TenantProvisioningService::plan()): driver, nome da conexão, alvo físico e a lista de tabelas que serão replicadas.

Escolher a empresa: login e troca em runtime

  • No login. Quando o usuário pertence a mais de uma empresa, o LoginForm abre o modal login-tenant antes da unidade (a empresa determina o banco; a unidade vem depois). Com uma empresa só, ela é selecionada sozinha.
  • Fail-closed. AuthenticationService::setTenant($tenantId) valida o tenant contra getSystemUserTenants() do usuário logado e lança "Unauthorized access to that tenant" se o id não estiver na lista — não existe caminho que aceite um tenant_id arbitrário vindo do request. Ele grava session('tenant_id') (usado pelo Pool) e, em multi_database, session('tenant_database') (usado pelo Bridge); também descarta o token do embed em sessão, que carrega o contexto de empresa da emissão.
  • Trocar de empresa sem deslogar. App\Control\Iam\ChangeTenantForm lista só as empresas permitidas e delega a troca ao setTenant. Trocar de empresa reseta a unidade para uma da nova empresa (senão a unidade corrente fica órfã de outro tenant).

API pública: tenant vem do token

Requests da camada de API não têm sessão. O middleware mad.api (App\Http\Middleware\MadApiTenantMiddleware) resolve tudo num passo: o token Bearer está amarrado a uma unidade → empresa → banco. Ele aplica TenantContext/UnitContext explicitamente (o Pool continua funcionando), repointa o data-plane em multi_database, faz resetToDefault() quando o tenant não tem connection_name — paridade com o login web — e responde 403 fail-closed quando a unidade do token não tem tenant_id em modo de isolamento. O estado é limpo em dois pontos (início do handle() e no terminate()), defesa em profundidade contra worker de vida longa/Octane.

Hybrid

Pool e Bridge combinados: grupos de tenants pequenos isolados por tenant_id no mesmo banco, clientes grandes com banco próprio via Bridge. Ligue as duas flags/fluxos acima — eles são independentes e não conflitam, porque atuam em camadas diferentes (linha vs. conexão física).

Jobs e filas com contexto de tenant

Fila e agendamento vivem na conexão iam (control) e nunca são repontados. Um job que precisa rodar no contexto de um tenant Bridge declara a propriedade pública e usa o middleware do job:

use App\Jobs\Middleware\TenantAware;

class RecalcularEstoque implements ShouldQueue
{
    public ?string $tenantDatabase = null;

    public function middleware(): array
    {
        return [new TenantAware()];
    }
}

TenantAware aplica/restaura a conexão do tenant em volta do handle() do job, do mesmo jeito que SetTenantConnection faz por request HTTP.

Modelo Tenant (mad_iam_tenant)

namespace App\Models\Iam;

class Tenant extends Model
{
    protected $connection = 'iam';
    protected $table      = 'mad_iam_tenant';
    protected $fillable   = ['name', 'slug', 'connection_name', 'db_config', 'active'];

    public function units(): HasMany
    {
        return $this->hasMany(Unit::class, 'tenant_id');
    }
}

connection_name vazio = tenant em modo single/pool (sem banco próprio). Preenchido = tenant em modo Bridge, apontando para a conexão que TenantConnectionResolver deve aplicar no login dessa empresa. db_config guarda o locator dessa conexão (JSON) — é o que torna o registro durável entre requests, lido no boot pelo TenantConnectionRegistrar. O Registrar só considera tenants com active = 'Y', connection_name preenchido e db_config não-nulo.

Modelos de dados do app também são roteados. mad.tenant.data_connections é ['business','comm','ged','ai'] mais a conexão de cada modelo de dados declarado em MAD_DATA_MODELS (env CSV) — sem isso, o negócio do app ficaria no banco compartilhado e o isolamento do Bridge seria só aparente. O provisionamento replica as duas famílias de tabelas.

Caveats operacionais

  • Workers de longa duração (filas persistentes, processos sempre-ativos). No Bridge, SetTenantConnection reseta o plano de dados no início e no fim de cada request (handle() + terminate()) — sem vazamento entre tenants mesmo num processo PHP reaproveitado entre requests. No Pool, TenantContext lê a sessão a cada request (web = sem risco), mas não tem hook de reset automático: um TenantContext::set() manual em job/console precisa sempre ser seguido de clear().
  • Sem FK entre conexões. Um teste de arquitetura falha o CI se alguém introduzir uma foreign key cruzando conexões — é o que garante que separar fisicamente o plano de dados (Bridge) nunca quebra um relacionamento.
  • Pool não cobre a conexão ai (ver acima) — combine com Bridge se precisar isolar conversas do agente por cliente.
  • Credencial de provisionamento ≠ credencial de runtime. CREATE DATABASE (mysql/pgsql) usa DB_TENANT_ADMIN_*; vazio, herda a conexão-template. Nem a credencial elevada nem a de runtime são persistidas em db_config — só o nome da base e o template.
  • Processos de vida longa (Octane). O TenantConnectionRegistrar memoiza o mapa por processo; escritas chamam flush(), e um worker desatualizado se auto-cura pelo lookup() lazy do resolver.

Ver também

  • Empresas: CRUD, provisionamento e troca — o passo-a-passo operacional do Bridge (tela, command, db_config, login).
  • Row-scope vs MCP-scope — não confunda o tenant_id do Pool com o escopo por usuário/unidade do agente de IA; são duas camadas diferentes.
  • CLI — referência completa dos comandos mad:tenant:*.
  • Conexões de banco — as 6 conexões lógicas e como apontá-las para engines/arquivos diferentes.