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, comtenant_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 isolaraipor tenant, é preciso a estratégia Bridge.
Bridge — 1 banco por cliente
Identidade compartilhada, dados de cada empresa num banco próprio.
-
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_acmeO 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 gravaconnection_nameedb_configemmad_iam_tenant. É idempotente: re-provisionar só cria o que falta.Drivers suportados:
sqlite(arquivo emdatabase/tenants/<slug>.sqlite),mysql/mariadbepgsql(CREATE DATABASE), além demadcloudquando 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 -
Registro durável — não é preciso declarar a conexão no
config/database.php. O locator da conexão é persistido emmad_iam_tenant.db_config(JSON) e reinjetado emdatabase.connections.<connection_name>no boot, porApp\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_tenantnão existe (web installer, banco fresco) e memoiza por processo — toda escrita (provisionar/salvar/excluir) chamaTenantConnectionRegistrar::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"). - sqlite → o array de conexão completo (sem segredo) vai no
-
No login, a aplicação resolve o tenant da empresa do usuário e grava
session('tenant_database'). O middlewareApp\Http\Middleware\SetTenantConnectionrepointa o grupo de conexões de dados a cada request, viaTenantConnectionResolver::applyTenant():// App\Service\Tenant\TenantConnectionResolver (resumo) TenantConnectionResolver::applyTenant('tenant_acme'); // repointa business/comm/ged/ai TenantConnectionResolver::resetToDefault(); // volta ao banco defaultO control-plane (
iam,log) nunca é tocado — login, permissão e fila continuam sempre no banco compartilhado. -
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_namefoi REMOVIDO. O banco pertence à empresa, não à filial: ofillabledeUnité hoje['name', 'tenant_id', 'active']e o login resolvesession('tenant_database')exclusivamente por$unit->tenant?->connection_name. Código antigo que lia/gravavaconnection_namena unidade precisa ser reapontado para o tenant.
Segurança fail-closed.
TenantConnectionResolversó aceita umconnection_nameque já exista emdatabase.connections— e, semad.tenant.allowed_connectionsestiver configurado (envMAD_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 viaTenantConnectionRegistrar::lookup()— que lê odb_configpersistido, 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
LoginFormabre o modallogin-tenantantes 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 contragetSystemUserTenants()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 umtenant_idarbitrário vindo do request. Ele gravasession('tenant_id')(usado pelo Pool) e, emmulti_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\ChangeTenantFormlista só as empresas permitidas e delega a troca aosetTenant. 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 emMAD_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,
SetTenantConnectionreseta 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,TenantContextlê a sessão a cada request (web = sem risco), mas não tem hook de reset automático: umTenantContext::set()manual em job/console precisa sempre ser seguido declear(). - 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) usaDB_TENANT_ADMIN_*; vazio, herda a conexão-template. Nem a credencial elevada nem a de runtime são persistidas emdb_config— só o nome da base e o template. - Processos de vida longa (Octane). O
TenantConnectionRegistrarmemoiza o mapa por processo; escritas chamamflush(), e um worker desatualizado se auto-cura pelolookup()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_iddo 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.