Skip to content

Criar um tenant ​

Novo no Basalt? Começa aqui. Esta página ensina todas as formas de criar um tenant, desde a versão de uma linha que usas enquanto experimentas, até a um fluxo de registo real que enviarias para clientes. Cada secção começa com um "quando usar isto" simples, para poderes saltar para o nível que corresponde a onde estás.

O que é um tenant? ​

Um tenant é um cliente da tua app — normalmente uma empresa ou organização — cujos dados são mantidos completamente separados dos de toda a gente. Se construíres uma ferramenta de projetos usada tanto pela "Acme Inc" como pela "Globex", então a Acme é um tenant e a Globex é outro. A Acme nunca pode ver os projetos da Globex, e vice-versa. Essa separação é o objetivo central da multi-tenancy.

Algumas palavras que vais encontrar nesta página:

  • Source (fonte) — o sítio onde os teus tenants são guardados (uma lista em memória, uma tabela de base de dados, o que escolheres). O Basalt lê os tenants a partir de uma source.
  • Resolver um tenant — descobrir, para cada pedido que chega, a qual tenant pertence. O Basalt faz isto olhando para o pedido (um header, um subdomínio como acme.tuaapp.com, ou o URL) e associando-o a um tenant na tua source.
  • Membership (pertença) — o facto de um utilizador específico pertencer a um tenant específico. A Ada é membro da Acme. A membership é o que impede um utilizador com sessão de espreitar um tenant a que não pertence.

Aqui está a ideia importante, e que surpreende os principiantes: o Basalt não te dá um botão nem uma rota "criar tenant" já pronta. O trabalho do Basalt é resolver o tenant ativo em cada pedido. Criar tenants é o trabalho da tua app, porque só tu sabes quando um tenant deve existir (um cliente regista-se, um admin adiciona um, semeias alguns para uma demo). Esta página mostra-te como, de quatro formas, da mais simples à mais profissional.

Nível 1 — Em memória (dev / experimentar) ​

Quando usar isto: estás a aprender, a correr testes, ou a construir uma demo rápida, e não te importas que os tenants desapareçam quando a app reinicia.

A source mais simples possível é o MemoryTenantSource. Mantém os tenants numa lista simples em memória. Adicionas um tenant com .add({ id, ... }) e entregas a source ao tenancyPlugin. Um tenant é apenas um objeto — precisa de um id, e podes juntar-lhe quaisquer outros campos que queiras (name, plan, e assim por diante).

Isto liga dois tenants e deixa os pedidos escolher um via um header x-tenant-id:

ts
import { tenancyPlugin, MemoryTenantSource, headerResolver } from '@basaltkit/tenancy'

tenancyPlugin({
  source: new MemoryTenantSource()
    .add({ id: 'acme', name: 'Acme Inc' })
    .add({ id: 'globex', name: 'Globex' }),
  resolvers: [headerResolver()], // lê o header x-tenant-id → { id: 'acme' }
})

Esse é todo o passo "criar um tenant" neste nível: .add(...). Um pedido que traga x-tenant-id: acme corre agora como Acme.

Não guardado em lado nenhum

O MemoryTenantSource vive apenas em memória. Reinicia a app e cada tenant desaparece. Isso é perfeito para testes e demos, e errado para qualquer coisa real — para isso, continua a ler.

Nível 2 — Source Prisma nativa (código mínimo, pronta para produção) ​

Quando usar isto: queres tenants que sobrevivem a reinícios e são partilhados por todas as instâncias da tua app, e já usas (ou estás disposto a usar) uma base de dados SQL através do Prisma. Este é o ponto de partida recomendado para um produto real, e escreves quase nenhum código.

O Prisma é uma ferramenta popular para falar com uma base de dados SQL (PostgreSQL, MySQL, …) a partir de TypeScript. O Basalt traz uma source já pronta, PrismaTenantSource, que guarda os teus tenants nessa base de dados. Não implementas nada — apontas para a tua base de dados e chamas save().

Passo 1. Adiciona dois modelos ao teu schema.prisma. Copia-os de @basaltkit/tenancy-prisma/schema.prisma (ou corre basalt prisma:sync), depois corre prisma migrate dev && prisma generate. O Tenant guarda o registo do tenant como JSON; o TenantDomain deixa um tenant reclamar domínios próprios como app.acme.com:

prisma
model Tenant {
  id      String         @id
  data    Json           // o registo aberto do tenant ({ id, ...qualquer coisa })
  domains TenantDomain[]

  @@map("tenants")
}

model TenantDomain {
  domain   String @id
  tenantId String
  tenant   Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)

  @@index([tenantId])
  @@map("tenant_domains")
}

Passo 2. Envolve o teu cliente Prisma gerado com prismaTenantSource(...) e entrega-o ao tenancyPlugin — exatamente como no Nível 1, apenas uma source durável:

ts
import { PrismaClient } from '@prisma/client'
import { prismaTenantSource } from '@basaltkit/tenancy-prisma'
import { tenancyPlugin, subdomainResolver, domainResolver } from '@basaltkit/tenancy'

const tenants = prismaTenantSource(new PrismaClient())

tenancyPlugin({
  source: tenants,
  resolvers: [subdomainResolver({ base: 'tuaapp.com' }), domainResolver()],
})

Passo 3. Cria (ou atualiza) um tenant com save(). É um upsert — insere o tenant se for novo, ou atualiza-o se o id já existir. Qualquer campo extra que passes é guardado e volta sem alterações:

ts
await tenants.save({ id: 'acme', name: 'Acme Inc', plan: 'pro', domains: ['app.acme.com'] })

Quando queres apenas criar — sem nunca tocar num tenant que já existe — chama antes o create(). Ele insere, e um id existente lança TenantAlreadyExistsError (409), com o tenant guardado exatamente como estava.

É tudo. O tenant está agora na tua base de dados, acme.tuaapp.com (ou o domínio próprio app.acme.com) resolve para ele, e continua lá depois de um reinício.

Os domínios têm de ser únicos

O save() substitui o conjunto de domínios próprios do tenant. Se um domínio já pertencer a um tenant diferente, o save() recusa — o encaminhamento tem de ser inequívoco.

Nível 3 — O teu próprio repositório de tenants ​

Quando usar isto: os teus tenants já vivem numa tabela que desenhaste, com as tuas próprias colunas, e preferes lê-los diretamente de lá em vez de os copiar para a tabela Tenant do Basalt.

Uma source é definida por uma pequena interface chamada TenantSource. Podes implementá-la tu mesmo sobre qualquer armazenamento. Só um método é obrigatório:

  • find(id) — devolve o tenant com este id, ou null. Obrigatório.
  • findByDomain(domain) — devolve o tenant dono de um domínio próprio, ou null. Opcional; só necessário se usares domainResolver().
  • list() — devolve todos os tenants. Opcional; só necessário para jobs em massa como tenancy.forEach().

Aqui está uma source compacta sobre a tua própria tabela organization. O find lê uma linha e molda-a num objeto de tenant; o save é o teu próprio helper para criar um:

ts
import type { TenantSource, Tenant } from '@basaltkit/tenancy'
import { db } from './db' // o teu próprio acesso à base de dados

export const orgSource: TenantSource = {
  async find(id): Promise<Tenant | null> {
    const org = await db.organization.findUnique({ where: { id } })
    return org ? { id: org.id, name: org.name, plan: org.plan } : null
  },
  async list(): Promise<Tenant[]> {
    const orgs = await db.organization.findMany()
    return orgs.map((o) => ({ id: o.id, name: o.name, plan: o.plan }))
  },
}

// O teu próprio "criar um tenant" — escreve na tua tabela como quiseres:
export async function createOrg(input: { id: string; name: string }) {
  await db.organization.create({ data: input })
  return input
}

Passa orgSource ao tenancyPlugin como as outras. A lição: uma source é um contrato minúsculo, por isso o Basalt nunca dita o teu esquema. Recorre a isto quando as sources nativas não encaixam nos teus dados existentes.

Nível 4 — Fluxo de onboarding profissional ​

Quando usar isto: estás a construir um produto real. Um cliente regista-se, um tenant é criado para ele, e ele torna-se o primeiro owner — tudo num só pedido.

Esta é a versão adulta. Uma única rota POST /onboarding faz quatro coisas:

  1. Exige um utilizador com sessão. Sem criação anónima de tenants. A auth põe o utilizador no pedido como ctx().user.
  2. Persiste o tenant na tua source durável (Nível 2 ou 3).
  3. Torna o criador o owner via teams.addMember(tenantId, userId, 'owner') — para que, desde o primeiro momento, exista uma membership real a ligar este utilizador a este tenant. (@basaltkit/teams gere quem pertence a um tenant e o seu papel: owner, admin ou member.)
  4. Deixa os pedidos seguintes encontrar o tenant. Como o registo já existe na source, o resolver que já configuraste (subdomínio, domínio, …) encaminha o tráfego do novo tenant automaticamente — nada extra a ligar por tenant.

Aqui está a função de serviço que cria o tenant e semeia o seu owner:

ts
import { TEAMS } from '@basaltkit/teams'
import { TENANCY } from '@basaltkit/tenancy'

export async function onboard(app, userId: string, input: { id: string; name: string }) {
  // 2. cria o tenant ATRAVÉS DO SERVIÇO — ele persiste o registo, corre o
  //    `onProvision` (schema + migrações) e emite o `tenancy:created`, por esta
  //    ordem. Chamar `tenants.save()` aqui escreveria a linha e saltaria o
  //    provisionamento, deixando um tenant encaminhável e partido.
  const tenant = await app.container.get(TENANCY).create({ id: input.id, name: input.name })

  // 3. o criador torna-se o primeiro owner do tenant
  await app.container.get(TEAMS).addMember(tenant.id, userId, 'owner')

  return tenant
}

Declara o provisionamento uma vez, no plugin — vê No sign-up:

ts
tenancyPlugin({ source, resolvers, onProvision })

Se o provisionamento for mais longo que o pedido, acrescenta provision: 'deferred': o create() passa a retornar já, e o tenant responde 503 até um job chamar o tenancy.provision(id).

O create() recusa um id que já existe — seja qual for o estado — com TenantAlreadyExistsError (409), antes de escrever o que quer que seja ou de correr o onProvision. Um utilizador que submete o formulário duas vezes, ou escolhe um id que outra pessoa já tem, recebe um conflito em vez de sobrescrever esse tenant. Um tenant cujo provisionamento falhou também não se volta a criar: termina-se com tenancy.provision(id).

E aqui está a rota que exige um utilizador com sessão e a chama. O meta.auth diz ao Basalt que esta rota precisa de autenticação; ctx().user é o utilizador com sessão:

ts
import { route } from '@basaltkit/fastify'
import { ctx } from '@basaltkit/core'
import { onboard } from './onboarding'

route({
  method: 'POST',
  url: '/onboarding',
  meta: { auth: true }, // 1. rejeita callers anónimos
  async handler(req) {
    const user = ctx().user           // definido por @basaltkit/auth
    const { id, name } = req.body as { id: string; name: string }
    return onboard(req.server.app, user.id, { id, name })
  },
})

Depois desta chamada, acme.tuaapp.com resolve para o novo tenant, e o utilizador que o criou já é o seu owner — pronto para convidar colegas (ver Equipas).

Segurança: nunca confies num tenant vindo do cliente ​

Verifica a membership antes de confiar num id de tenant

Resolver um tenant a partir de um header ou subdomínio é conveniente, mas um pedido é controlado pelo atacante. Se leres x-tenant-id: acme e limitares a acme sem verificar que o utilizador com sessão pertence de facto a acme, então qualquer utilizador com sessão pode ler os dados de outro tenant. Confirma sempre a membership com teams.can(...):

ts
// Depois de o tenant ser resolvido, antes de confiares nele:
if (!(await teams.can(tenantId, ctx().user.id, 'member'))) {
  throw new ForbiddenError()
}

Liga a seleção de tenant a uma membership user↔tenant verificada, nunca apenas ao pedido em bruto. Vê Segurança para o quadro completo.

Qual devo usar? ​

A tua situaçãoUsa istoComo crias um tenant
Aprender, testes, uma demo rápidaMemoryTenantSource.add({ id, name })
Um produto real, caminho mais simplesPrismaTenantSource (@basaltkit/tenancy-prisma)TENANCY.create({ id, name })
Tenants já na tua própria tabelaA tua própria TenantSourceo teu insert + find/list
Registo real, criador torna-se ownerRota de onboarding + @basaltkit/teamsPOST /onboarding → TENANCY.create + addMember(..., 'owner')

Começa no Nível 1 para aprender, passa para o Nível 2 no momento em que precisas que os tenants sobrevivam a um reinício, e acrescenta o Nível 4 quando tiveres clientes reais a registarem-se sozinhos.

Publicado sob a licença MIT.