Pular para o conteúdo
🍳 COOKBOOK

SaaS B2B multi-tenant do zero.

Do template de banco ao onboarding automático de clientes — receita completa para ISVs com isolamento real de dados.

O cenário

Você está construindo um ERP para clínicas médicas. Cada clínica é uma empresa diferente — os dados de pacientes da Clínica A nunca podem se misturar com os da Clínica B. Você precisa:

  • Isolamento físico de dados (LGPD, HIPAA-like)
  • Onboarding automático quando uma nova clínica se cadastra
  • API keys separadas por cliente para as queries do frontend
  • Capacidade de apagar todos os dados de um cliente com uma operação

Este é o caso perfeito para o SuperDB Groups.

💡

Tempo para implementar: 2-4 horas para um dev familiarizado com Next.js/Node.js. O provisioning de tenant leva ~1-2 segundos.

Passo a passo

1Criar o projeto template

No dashboard (app.superdb.com.br), crie um novo projeto chamado erp_clinica_template. Este projeto é o molde — você nunca o usará diretamente com clientes reais.

Nomeie com sufixo _template para diferenciar visualmente dos projetos de produção.

dashboard
app.superdb.com.br/projects
  └── + Novo projeto
        Nome: erp_clinica_template
        Slug: erp_clinica_template (auto)

2Modelar o schema no SQL Editor

Abra o SQL Editor do projeto template e crie as tabelas do domínio. Inclua RLS — ela será clonada para cada tenant.

Este schema é copiado integralmente para cada novo tenant provisionado.

SQL Editor — erp_clinica_template
-- Tabela de pacientes
CREATE TABLE pacientes (
  id          uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  nome        text NOT NULL,
  cpf         text UNIQUE,
  data_nasc   date,
  email       text,
  telefone    text,
  created_at  timestamptz DEFAULT now(),
  created_by  uuid REFERENCES auth_users(id)
);

-- Tabela de médicos
CREATE TABLE medicos (
  id          uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  nome        text NOT NULL,
  crm         text NOT NULL UNIQUE,
  especialidade text,
  user_id     uuid REFERENCES auth_users(id)
);

-- Tabela de atendimentos
CREATE TABLE atendimentos (
  id           uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  paciente_id  uuid REFERENCES pacientes(id) ON DELETE CASCADE,
  medico_id    uuid REFERENCES medicos(id),
  data_hora    timestamptz NOT NULL,
  observacoes  text,
  created_at   timestamptz DEFAULT now()
);

-- RLS: médicos só veem seus próprios atendimentos
ALTER TABLE atendimentos ENABLE ROW LEVEL SECURITY;
CREATE POLICY atendimentos_medico ON atendimentos
  USING (
    medico_id IN (
      SELECT id FROM medicos WHERE user_id = auth.uid()
    )
  );

-- RLS: pacientes visíveis para qualquer autenticado da clínica
ALTER TABLE pacientes ENABLE ROW LEVEL SECURITY;
CREATE POLICY pacientes_authenticated ON pacientes
  FOR ALL USING (auth.role() = 'authenticated');

3Criar o Group no Admin UI

Acesse admin.superdb.com.brGroups+ Novo Group.

Selecione o projeto erp_clinica_template como template. Após criar, vá em API Keys e gere a Group API Key.

⚠️

Copie a API Key agora. Ela só aparece uma vez. Cole no .env do seu backend imediatamente.

admin.superdb.com.br
Platform → Groups → + Novo Group
  Nome: ERP Clínicas
  Template: erp_clinica_template
  [Criar]

→ Aba "API Keys" → [Gerar API Key]
  ⚠️  Copie agora: grp_key_a1b2c3d4...

4Configurar variáveis de ambiente

No backend do seu SaaS (Node.js/Next.js), configure as variáveis de ambiente com as credenciais do Group.

.env (backend do SEU SaaS)
SUPERDB_BASE_URL=https://auth.superdb.com.br
SUPERDB_GROUP_ID=grp_a1b2c3d4e5f6
SUPERDB_GROUP_API_KEY=grp_key_a1b2c3d4e5f6g7h8i9j0...

# Banco do SEU app (onde você guarda dados das empresas clientes)
DATABASE_URL=postgresql://user:pass@host/meusaas

5Função de provisioning

Crie o módulo que seu backend vai chamar quando uma nova clínica se cadastrar. Esta função chama a API do SuperDB e retorna as keys do tenant.

lib/superdb-groups.ts
interface TenantProvisionResult {
  tenant: {
    id: string
    slug: string
    schema_name: string
    created_at: string
  }
  anon_key: string
  service_role_key: string
}

export async function provisionTenant(params: {
  slug: string
  externalId: string
  metadata?: Record<string, unknown>
}): Promise<TenantProvisionResult> {
  const url = `${process.env.SUPERDB_BASE_URL}/groups/v1/${process.env.SUPERDB_GROUP_ID}/tenants`

  const resp = await fetch(url, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.SUPERDB_GROUP_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      slug: params.slug,
      external_id: params.externalId,
      metadata: params.metadata,
    }),
  })

  if (!resp.ok) {
    const body = await resp.text()
    throw new Error(`Provisioning falhou (${resp.status}): ${body}`)
  }

  return resp.json() as Promise<TenantProvisionResult>
}

export async function deleteTenant(tenantId: string): Promise<void> {
  const url = `${process.env.SUPERDB_BASE_URL}/groups/v1/${process.env.SUPERDB_GROUP_ID}/tenants/${tenantId}`

  const resp = await fetch(url, {
    method: 'DELETE',
    headers: {
      'Authorization': `Bearer ${process.env.SUPERDB_GROUP_API_KEY}`,
    },
  })

  if (!resp.ok && resp.status !== 204) {
    throw new Error(`Deleção do tenant falhou: ${resp.status}`)
  }
}

6Rota de onboarding

Integre o provisioning na rota de cadastro do seu SaaS. Quando uma nova empresa se cadastra, chame provisionTenant e salve as keys no banco do seu app.

As keys ficam no banco do SEU app — você as fornece ao frontend da clínica para que ela faça queries autenticadas.

app/api/onboarding/route.ts (Next.js)
import { NextResponse } from 'next/server'
import { provisionTenant } from '@/lib/superdb-groups'
import { db } from '@/lib/db'  // seu banco de dados (Prisma, Drizzle, etc.)

export async function POST(request: Request) {
  const { companyName, cnpj, adminEmail, plan } = await request.json()

  // 1. Validar dados
  if (!companyName || !cnpj || !adminEmail) {
    return NextResponse.json({ error: 'Dados incompletos' }, { status: 400 })
  }

  // 2. Criar empresa no banco do SEU app primeiro
  const company = await db.companies.create({
    data: {
      name: companyName,
      cnpj,
      admin_email: adminEmail,
      plan,
      status: 'provisioning',
    }
  })

  try {
    // 3. Provisionar tenant no SuperDB
    const slug = companyName
      .toLowerCase()
      .normalize('NFD')
      .replace(/[̀-ͯ]/g, '')  // remover acentos
      .replace(/[^a-z0-9]/g, '_')
      .replace(/_+/g, '_')
      .slice(0, 50)

    const result = await provisionTenant({
      slug,
      externalId: company.id,
      metadata: { cnpj, plan, admin_email: adminEmail },
    })

    // 4. Salvar keys no banco do SEU app
    // ⚠️  service_role_key deve ser encriptada em repouso
    await db.companies.update({
      where: { id: company.id },
      data: {
        superdb_tenant_id: result.tenant.id,
        superdb_tenant_slug: result.tenant.slug,
        superdb_anon_key: result.anon_key,
        superdb_service_key: encrypt(result.service_role_key), // criptografar!
        status: 'active',
      }
    })

    return NextResponse.json({
      ok: true,
      company_id: company.id,
      superdb_url: process.env.SUPERDB_BASE_URL,
      anon_key: result.anon_key,  // pode ir pro frontend
    })

  } catch (error) {
    // Rollback: marcar empresa como failed
    await db.companies.update({
      where: { id: company.id },
      data: { status: 'provisioning_failed' }
    })
    throw error
  }
}

7Frontend da clínica usa as keys do tenant

Quando um usuário da Clínica A faz login no seu app, você fornece as keys do tenant A para o SDK. As queries ficam isoladas no schema da Clínica A — o RLS e o isolamento de schema garantem isso em duas camadas.

app/api/session/route.ts — retorna keys do tenant ao login
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'

export async function GET(request: Request) {
  // Usuário já autenticado no SEU SaaS (sessão do seu app, não do SuperDB)
  const session = await getMyAppSession(request)
  if (!session) return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })

  const company = await db.companies.findUnique({
    where: { id: session.companyId },
    select: {
      superdb_anon_key: true,
      superdb_tenant_slug: true,
    }
  })

  return NextResponse.json({
    superdb_url: process.env.NEXT_PUBLIC_SUPERDB_URL,
    anon_key: company.superdb_anon_key,  // seguro — é a anon key
    tenant_slug: company.superdb_tenant_slug,
  })
}
Frontend — buscar dados do tenant via HTTP direto
// Buscar keys do tenant do servidor (após login no SEU app)
const { anon_key, tenant_slug } = await fetch('/api/session').then(r => r.json())

// Dados via HTTP direto — api.superdb.com.br (sem /rest/v1/)
// Header Accept-Profile seleciona o schema do tenant
// O PostgREST também resolve via sub+lookup automaticamente
const res = await fetch('https://api.superdb.com.br/pacientes?select=id,nome,cpf,data_nasc&order=nome', {
  headers: {
    'Authorization': `Bearer ${anon_key}`,
    'apikey': anon_key,
    'Accept-Profile': `proj_${tenant_slug}`,  // schema do tenant
  },
})
const pacientes = await res.json()

// Esta query só retorna pacientes DA CLÍNICA DO USUÁRIO LOGADO
// Isolamento em duas camadas: schema separado + RLS

8Testar o fluxo completo

Com o servidor rodando, faça uma chamada de teste para o endpoint de onboarding e verifique se o tenant foi criado no Admin UI.

terminal — teste de onboarding
curl -X POST http://localhost:3000/api/onboarding \
  -H "Content-Type: application/json" \
  -d '{
    "companyName": "Clínica São Lucas",
    "cnpj": "12.345.678/0001-99",
    "adminEmail": "dr.pedro@saolucas.com.br",
    "plan": "professional"
  }'

# Resposta esperada (~1-2s):
{
  "ok": true,
  "company_id": "clj_abc123",
  "superdb_url": "https://auth.superdb.com.br",
  "anon_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
verificar no Admin UI
admin.superdb.com.br
  → Groups → ERP Clínicas
  → Tenants (1)
    └── clinica_sao_lucas
          schema: proj_clinica_sao_lucas
          created: agora

Considerações de segurança

  • Service Role Key: criptografe em repouso no banco do seu app (AES-256-GCM). Nunca logue, nunca exponha no frontend.
  • Group API Key: rotacione periodicamente. Revogue imediatamente se suspeitar de vazamento.
  • Anon Key: pode ir ao frontend — só acessa dados via RLS do tenant específico.
  • LGPD — Art. 18: para apagar todos os dados de um cliente: chame DELETE /groups/v1/:group_id/tenants/:id. O SuperDB executa DROP SCHEMA CASCADE atomicamente.

Próximos passos

Essa página ajudou?