Pular para o conteúdo

Multi-tenancy (opt-in)

O kit nasce single-tenant. Um comando liga o modo multi-tenant — e quem não precisa não paga nada por ele:

Janela do terminal
php artisan kit:tenancy # liga o modo
php artisan kit:tenancy --demo # liga + cria um cenário de demonstração
php artisan kit:tenancy --force # confirma a recriação do banco sem perguntar

O --demo também escreve KIT_DEMO=true no .env. É essa chave que faz o resource de exemplo Projetos aparecer no /app — sem ela o painel de negócio continua vazio, que é o desenho do kit. Para tirar a demo da vista sem apagar nada, KIT_DEMO=false; para removê-la de vez, apague os arquivos que o comando lista ao final.

Painel Com o modo ligado
App vira /app/{tenant}. O usuário só enxerga os tenants a que está vinculado, e ganha a administração da própria organização
Admin ganha o cadastro de tenants e o vínculo de usuários — não é escopado, quem administra vê todos
Infra inalterado: saúde, filas e logs são da instalação, não de um cliente

Quem administra uma organização não administra a instalação

Os cinco papéis do kit, e o que cada um significa com o modo ligado:

Papel Painel Contexto da atribuição O que faz
master_global todos global vence qualquer permissão, por Gate::before
admin /admin global usuários, papéis e permissões da instalação
infra /infra global saúde, filas, logs, auditoria, comandos
admin_app /app a organização usuários e convites da organização dele
panel_user /app a organização usa o negócio; não vê a administração

admin_app é a persona que o modo multi-tenant cria: alguém que administra uma organização sem administrar o sistema. Dentro de /app/{slug} ele ganha Usuários e Convites, recortados àquela organização — e nada além disso. Ele não entra em /admin nem /infra, leva 404 no painel de outra organização, não alcança usuário de fora nem por URL direta, não cria nem edita papéis (só atribui, e só papéis do painel /app), não exclui usuário — o delete apagaria a pessoa de todas as organizações — e o convite que ele cria nasce carimbado com a organização dele, ignorando o formulário. E ele não vê nem edita quem governa a instalação: master_global, admin, infra ou qualquer papel de painel sem tenancy somem da lista, da busca e do badge, e a URL direta responde 404 — mesmo que a pessoa esteja vinculada à organização.

O papel só existe com a tenancy ligada, e a concessão é em /admin → organizações → Usuários vinculados → Papéis nesta organização. Não pelo cadastro do usuário: ali a atribuição vai para o contexto global e a pessoa entra no /app sem enxergar nada. A receita completa, com o sintoma, está em wikis/receitas.md.

O atalho para o painel de cada organização

As três telas de organização no /admin — listagem, ficha e edição — mostram o endereço do painel de negócio daquela organização, clicável, abrindo em nova aba. Na listagem é uma coluna, Painel, com o endereço visível: você sabe para onde vai antes de clicar, e comparar duas organizações é abrir duas abas.

O endereço sai do gerador de URL do próprio painel, e não de /app/ + slug escrito à mão — então ele acompanha o path do painel, o APP_URL da instalação e o atributo de slug configurado. Trocar o slug de uma organização move o link na hora, o que é a outra metade do aviso que o campo já dá: mudar invalida os links já compartilhados.

O link navega; ele não autoriza. É de propósito que ele apareça sempre, para todo mundo que consegue abrir a tela de administração — esconder o link esconderia também a informação de que a organização tem um painel. Quem seguir sem acesso recebe a recusa dos dois portões que já existem, e os códigos são diferentes:

Situação de quem clica Resposta Quem barra
Tem papel do /app e vínculo com a organização (ou é master_global) abre o painel —
Não tem papel do painel /app — o caso típico de quem só administra a instalação 403 canAccessPanel()
Tem papel do /app mas não está vinculado a essa organização 404 canAccessTenant()

O 404 do segundo caso é deliberado: um 403 confirmaria que a organização existe, e bastaria varrer slugs para enumerar os clientes da instalação. Ele fica registrado no log do canal tenancy, com o motivo sem_vinculo.

No cadastro de uma organização nova o link não aparece: enquanto o registro não está gravado, não existe endereço para apontar — e um link para um slug ainda não salvo seria 404 garantido.

Um seletor para quem não tem o que trocar

No topo da barra lateral do /app fica o seletor de organização — quem tem acesso a duas ou mais troca de contexto por ele. Para quem tem acesso a uma só ele não tem utilidade: é só o nome da organização repetido (o cabeçalho já o mostra). O interruptor Ocultar o seletor quando houver uma organização só, na aba Aplicação das configurações, esconde o bloco inteiro nesse caso; com duas ou mais organizações acessíveis o seletor continua lá.

A contagem é a mesma do próprio seletor: organizações ativas acessíveis — e, para o master_global, todas as ativas. Esconder é decisão de aparência: /app/{tenant}, link direto e canAccessTenant() não mudam. Nasce desligado — KIT_TENANCY_OCULTAR_SELETOR_UNICO=false no .env.example — e vale no próximo F5, porque quem decide é uma avaliação por request (App\Support\SeletorDeOrganizacao), não o boot do painel.

O seletor visível e oculto, na mesma tela

Insights das organizações no /admin

A listagem de organizações traz quatro widgets para operação global — a visão geral acima da tabela e os três de detalhe abaixo dela:

  • visão geral com totais e organizações ativas;
  • usuários únicos por organização na janela recente — organizações sem acesso elegível continuam visíveis com zero;
  • acessos bem-sucedidos separados por painel;
  • timeline das alterações recentes.

Ao abrir uma organização, o cabeçalho mostra seus usuários vinculados, usuários ativos e taxa de ativação, além dos acessos recentes dos membros. Os números respeitam soft deletes e contam apenas logins bem-sucedidos; o log de autenticação registra o painel de origem para sustentar o breakdown.

Código em inglês, interface no seu idioma

O código segue o vocabulário da API do Filament — model Tenant, tabela tenants, getTenants(), canAccessTenant() — para que a documentação oficial se leia sem tradução mental. O que o usuário vê é configurável, e nasce como “Organização”:

config/kit.php
'tenancy' => [
'label' => 'Empresa', // Organização · Cliente · Escola · Unidade · Loja
'label_plural' => 'Empresas',
'slug' => 'empresas', // /admin/empresas
],

As mesmas quatro entradas existem no .env, como semente e plano B: KIT_TENANCY (a flag, escrita pelo kit:tenancy), KIT_TENANCY_LABEL, KIT_TENANCY_LABEL_PLURAL e KIT_TENANCY_SLUG.

Nas suas models

Toda model do negócio usa a trait do kit:

use App\Traits\BelongsToTenant;
class Projeto extends Model
{
use BelongsToTenant;
protected $fillable = ['nome']; // `tenant_id` fora: a trait preenche
}

Ela dá a relação tenant(), um escopo global e o preenchimento automático de tenant_id. O escopo importa porque o Filament só recorta o que passa por um Resource — job, comando, listener e API ficariam de fora, e é aí que dado de um cliente vaza para outro.

⚠️ kit:tenancy recria o banco. Ele liga permission.teams, e a migration do spatie só cria as colunas de tenant se a flag estiver ativa antes do migrate. Por isso exige árvore git limpa, confirmação explícita e roda migrate:fresh --seed. A hora de rodar é o dia 1 do projeto. O caminho detalhado — inclusive papéis globais × por tenant e scopedUnique() — está em wikis/arquitetura.md.

Por dentro do kit:tenancy, e a requisição em /app/{tenant}

KitTenancy::handle() (app/Console/Commands/KitTenancy.php:handle:50) faz, nesta ordem: pré-voo — git limpo (app/Console/Commands/KitTenancy.php:preVoo:103) — confirmação explícita, ou --force (app/Console/Commands/KitTenancy.php:confirmarDestruicao:143), KIT_TENANCY=true no .env (app/Console/Commands/KitTenancy.php:ligarFlagNoEnv:165, app/Support/AtivadorDeTenancy.php:escreverEnv:35), permission.teams ligado por organização (app/Console/Commands/KitTenancy.php:ligarPapeisPorTenant:172, app/Support/AtivadorDeTenancy.php:ligarPapeisPorTenant:66), migrate:fresh --seed — destrutivo — seguido da conferência do schema (app/Console/Commands/KitTenancy.php:recriarBanco:179, :migrate:fresh:187, :conferirSchema:189, definida em :198) e, só com --demo, o cenário de demonstração (app/Console/Commands/KitTenancy.php:semearDemo:224).

Com o modo ligado, toda requisição a /app/{tenant} passa por dois middlewares do Filament antes de chegar à tela: IdentifyTenant resolve o tenant da rota e já decide o 404 chamando canAccessTenant() (vendor/filament/filament/src/Http/Middleware/IdentifyTenant.php:canAccessTenant:40, app/Models/User.php:canAccessTenant:838); só depois disso o DefinirTenantDePermissoes — o tenantMiddleware do kit — fixa o contexto de papéis por tenant (app/Providers/Filament/AppPanelProvider.php:tenantMiddleware:622, app/Http/Middleware/DefinirTenantDePermissoes.php:setPermissionsTeamId:46).

sequenceDiagram
%% DG-20
accTitle: Requisição em /app/{tenant}
accDescr: O IdentifyTenant resolve o tenant da rota e consulta canAccessTenant(); organização inativa, ou sem vínculo e não é master_global, responde 404, e só então o DefinirTenantDePermissoes fixa o contexto de papéis por tenant.
  participant visitante as Visitante
  participant identify_tenant as IdentifyTenant
  participant can_access_tenant as canAccessTenant()
  participant definir_tenant as DefinirTenantDePermissoes
  Note over identify_tenant,definir_tenant: só existe com KIT_TENANCY=true (hasTenancy())
  visitante->>identify_tenant: GET /app/{tenant}
  identify_tenant->>can_access_tenant: organização ativa e vinculada?
  alt organização inativa, ou sem vínculo e não é master_global
    can_access_tenant-->>visitante: nega (404)
  else acesso permitido
    can_access_tenant-->>identify_tenant: permite
    identify_tenant->>definir_tenant: Filament::setTenant()
    definir_tenant-->>visitante: contexto de papéis fixado (team_id)
  end

A organização inativa nega para todo mundo, inclusive o master_global — a checagem vem antes de isMasterGlobal() (linha 813); só depois disso o master_global sempre entra (linha 826). É a mesma assimetria corrigida que a tabela de 403×404 desta página já descreve.