Multi-Tenancy#
O sulfite_server agora suporta isolamento lógico por tenant no mesmo deploy.
O objetivo prático é permitir um único cluster atendendo múltiplos clientes
sem misturar relatórios, links de share, refresh tokens, conexões e quotas.
O que está isolado#
reports: leitura por tenant + templates globais vianamespaceshare links: slug passa a ser único por tenantrefresh tokens: refresh/revogação são restritos ao tenant emissorconnections: o mesmo nome pode existir em tenants diferentesjobs: cada job carregatenantIdotps: chave + persistência isoladas por tenant (tenant_id)-
fail2ban: bloqueio por tenant + IP emsys_ip_bans/sys_ip_requests rate limiting: janela de requests também particionada por tenantaudit log: eventos emitidos pelos fluxos multi-tenant carregamtenant_idadmin API: CRUD de tenants, quotas e usage via/api/v1/admin/tenants/*
Diagrama de fluxo#
flowchart LR
A["HTTP Request"] --> B["tenantMiddleware"]
B --> C["authMiddleware"]
C --> D["rateLimitMiddleware"]
D --> E["Handlers"]
E --> F["Postgres Stores (tenant_id + namespace scoped)"]
Resolução do tenant#
Com MULTI_TENANT_MODE=true, o servidor resolve o tenant nesta ordem:
- subdomain do
Host X-Tenant-IdouX-Tenant-Slug- claim
tntdo JWT
Se dois sinais divergirem, a resposta é 400 tenant_mismatch.
Rotas globais:
/health/info-
/api/v1/admin/*quando autenticadas comSUPER_ADMIN_SECRETou tokencanAdmin
Habilitando#
Variáveis principais:
MULTI_TENANT_MODE=trueAUTH_SECRET=<segredo JWT>SUPER_ADMIN_SECRET=<segredo admin>DATABASE_URL=postgres://...STORAGE_BACKEND=postgresJOB_QUEUE_BACKEND=postgresTOKEN_STORE_BACKEND=postgresSHARE_LINK_STORE_BACKEND=postgresCONNECTION_REGISTRY_BACKEND=postgres
Para cluster horizontal, combine com as recomendações de horizontal scaling.
Migração do banco#
Rode as migrations do server em ordem:
cd packages/sulfite_server
dart run bin/migrate.dart
As migrations relevantes para multi-tenancy são:
000_reports.sql001_namespace.sql001_sys_tables.sql002_multi_tenancy.sql004_multi_tenant_hardening.sql
001_namespace.sql adiciona a coluna namespace em sulfite_reports
e cria o índice idx_sulfite_reports_namespace_tenant. Ela também renomeia
a tabela legada reports para sulfite_reports, quando necessário.
002_multi_tenancy.sql cria sys_tenants, sys_tenant_quotas,
sys_tenant_usage, adiciona tenant_id nas tabelas compartilhadas e remove
a unicidade global de sys_share_links.slug.
004_multi_tenant_hardening.sql completa o isolamento de tabelas legadas
(sys_otps, sys_ip_bans, sys_ip_requests, sys_rate_limits) com
tenant_id + índices compostos por tenant.
Relatórios federados#
A FRC-40 separa o escopo de relatórios em dois namespaces:
| Namespace | Quem vê | Quem escreve | Uso típico |
|---|---|---|---|
tenant |
Apenas o tenant da requisição | usuários com can_insert, can_edit e can_delete |
relatórios próprios do cliente |
global |
Todos os tenants | operadores com can_edit_global |
templates compartilhados |
O namespace é a fonte de verdade do escopo. O tenant_id continua existindo
para isolamento físico e compatibilidade, mas uma linha só é tratada como
template global quando namespace = 'global'. Linhas com namespace = 'tenant'
armazenadas no tenant default não ficam visíveis para outros tenants.
No servidor real, o repositório de reports é federado:
GET /api/v1/reportsretorna templates globais e relatórios locais do tenant.GET /api/v1/reports/:idpode resolver um template global ou um relatório local.POST /api/v1/reportscria relatório local por padrão.-
POST /api/v1/reportscom"namespace": "global"exigecan_edit_global. -
PUTeDELETEem templates globais também exigemcan_edit_global. -
namespaceé imutável depois da criação; tentar trocar retorna422 namespace_immutable. - valores desconhecidos de
namespacesão normalizados paratenant.
Templates globais não consomem quota dos tenants. As métricas reports.count
e storage.bytes contam apenas relatórios locais (namespace = 'tenant') do
tenant avaliado.
Provisionando tenants#
Criar tenant:
curl -X POST http://localhost:8080/api/v1/admin/tenants \
-H "Authorization: Bearer $SUPER_ADMIN_SECRET" \
-H "Content-Type: application/json" \
-d '{
"id": "tenant-acme",
"slug": "acme",
"name": "Acme Inc.",
"allowedHosts": ["acme.example.com"],
"shareBaseUrl": "https://acme.example.com"
}'
Suspender tenant:
curl -X POST http://localhost:8080/api/v1/admin/tenants/tenant-acme/suspend \
-H "Authorization: Bearer $SUPER_ADMIN_SECRET"
Tenant suspenso recebe 403 tenant_suspended em rotas normais.
Quotas e usage#
Endpoints administrativos:
GET /api/v1/admin/tenants/:id/quotasPUT /api/v1/admin/tenants/:id/quotas/:metricGET /api/v1/admin/tenants/:id/usage
Métricas suportadas hoje:
reports.countstorage.bytesshare.activegenerate.per_day
Exemplo de quota:
curl -X PUT http://localhost:8080/api/v1/admin/tenants/tenant-acme/quotas/reports.count \
-H "Authorization: Bearer $SUPER_ADMIN_SECRET" \
-H "Content-Type: application/json" \
-d '{"limit": 100}'
Quando excedida, a API responde:
{
"error": "quota_exceeded",
"tenantId": "tenant-acme",
"metric": "reports.count",
"limit": 100,
"current": 100
}
Fluxo recomendado em dev#
Sem wildcard DNS local, use headers explícitos:
X-Tenant-Id: tenant-acme- ou
X-Tenant-Slug: acme
Para shares públicos, o header também funciona em dev:
curl http://localhost:8080/s/<slug> -H "X-Tenant-Id: tenant-acme"
Observações operacionais#
- O modo single-tenant continua compatível com
MULTI_TENANT_MODE=false. - O tenant
defaulté provisionado automaticamente para instalações legadas. -
Quotas usam estado compartilhado em Postgres quando o deploy já está com
DATABASE_URLconfigurado.