Logosulfite.app
rafagazani/sulfite 999999

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 via namespace
  • share links: slug passa a ser único por tenant
  • refresh tokens: refresh/revogação são restritos ao tenant emissor
  • connections: o mesmo nome pode existir em tenants diferentes
  • jobs: cada job carrega tenantId
  • otps: chave + persistência isoladas por tenant (tenant_id)
  • fail2ban: bloqueio por tenant + IP em sys_ip_bans / sys_ip_requests
  • rate limiting: janela de requests também particionada por tenant
  • audit log: eventos emitidos pelos fluxos multi-tenant carregam tenant_id
  • admin 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:

  1. subdomain do Host
  2. X-Tenant-Id ou X-Tenant-Slug
  3. claim tnt do JWT

Se dois sinais divergirem, a resposta é 400 tenant_mismatch.

Rotas globais:

  • /health
  • /info
  • /api/v1/admin/* quando autenticadas com SUPER_ADMIN_SECRET ou token canAdmin

Habilitando#

Variáveis principais:

  • MULTI_TENANT_MODE=true
  • AUTH_SECRET=<segredo JWT>
  • SUPER_ADMIN_SECRET=<segredo admin>
  • DATABASE_URL=postgres://...
  • STORAGE_BACKEND=postgres
  • JOB_QUEUE_BACKEND=postgres
  • TOKEN_STORE_BACKEND=postgres
  • SHARE_LINK_STORE_BACKEND=postgres
  • CONNECTION_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.sql
  • 001_namespace.sql
  • 001_sys_tables.sql
  • 002_multi_tenancy.sql
  • 004_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:

NamespaceQuem vêQuem escreveUso 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/reports retorna templates globais e relatórios locais do tenant.
  • GET /api/v1/reports/:id pode resolver um template global ou um relatório local.
  • POST /api/v1/reports cria relatório local por padrão.
  • POST /api/v1/reports com "namespace": "global" exige can_edit_global.
  • PUT e DELETE em templates globais também exigem can_edit_global.
  • namespace é imutável depois da criação; tentar trocar retorna 422 namespace_immutable.
  • valores desconhecidos de namespace são normalizados para tenant.

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/quotas
  • PUT /api/v1/admin/tenants/:id/quotas/:metric
  • GET /api/v1/admin/tenants/:id/usage

Métricas suportadas hoje:

  • reports.count
  • storage.bytes
  • share.active
  • generate.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_URL configurado.