@chrono-os/tenancy
v0.2.1
Published
Isolamento por tenant para apps Prisma multi-tenant: extensão de escopo, contexto por request, escolha determinística de membership e gate de cobertura schema x lista. Zero dependências de runtime.
Downloads
610
Maintainers
Readme
@chrono-os/tenancy
Isolamento por tenant para apps Prisma genuinamente multi-tenant (Legaris, MeResponda, nairio-os-api). Zero dependências de runtime; Prisma 5, 6 ou 7.
Regra da casa: app single-tenant nasce tenant-ready (um seam
resolveTenantId), não multi-tenant. Este pacote é para quem já tem mais de um tenant no mesmo banco.
import { tenantScope, criarContextoTenant, escolherMembership, ORDEM_MEMBERSHIPS } from '@chrono-os/tenancy'
export const TENANT_MODELS = new Set(['Proposta', 'Familia' /* … */])
export const tenant = criarContextoTenant<{ tenantId: string; userId: string; role: string }>()
export const tenantPrisma = prisma.$extends(
tenantScope({ field: 'tenantId', models: TENANT_MODELS, getTenantId: () => tenant.obter()?.tenantId }),
)
// main.ts — middleware global, ANTES dos guards: abre o contexto da request
app.use((req, res, next) => tenant.abrir(next))
// no guard (async, com await no banco): preenche o contexto já aberto
const memberships = await prisma.membership.findMany({ where: { userId }, orderBy: [...ORDEM_MEMBERSHIPS] })
const escolha = escolherMembership(memberships, req.headers['x-tenant-id'], 'tenantId')
if (!escolha.ok) throw escolha.motivo === 'sem-vinculo' ? new ForbiddenException() : new NotFoundException()
tenant.definir({ tenantId: escolha.membership.tenantId, userId, role: escolha.membership.role })Não use
enterWith(nem oentrar()da 0.1.0) num guard async. Depois de umawait, oenterWithsó vale para a promise do próprio guard: o handler não herda, e toda query cai em "tenant ausente". O contexto é aberto no middleware (abrir) e só preenchido no guard (definir).
O que a extensão faz
- Espalha o campo de tenant no
wherede toda operação (inclusivefindUnique,update,delete— oextendedWhereUniquedo Prisma 5+ sustenta) e nodatadecreate/createMany. O do contexto sempre vence um valor forjado pelo chamador. update/updateMany/upsert.updatedescartam o campo de tenant dodata: registro não muda de tenant por escrita.- Sem tenant no contexto: lança (fail-closed).
onMissingTenanttroca a exceção. softDeleteModels+baseClient: leituras filtramdeletedAt: null;delete/deleteManyviram update dedeletedAt.- Model fora da lista passa intacto — por isso o gate abaixo.
Não cobre: escrita aninhada (connect para registro de outro tenant — só a FK segura), $queryRaw/$executeRaw, include de relação para model fora da lista.
Gate de cobertura
"typecheck": "chrono-tenancy-coverage --schema prisma/schema.prisma --source src/tenancy/tenant-prisma.ts --field tenantId && tsc --noEmit"Falha quando um model do schema tem a coluna de tenant e não está no Set da fonte, ou quando a lista cita model sem a coluna. --exclude A,B declara exceções de propósito.
Membership em ordem determinística
escolherMembership escolhe o vínculo mais antigo (desempate por id) quando a request não pede tenant. Os guards antigos pegavam memberships[0] de um findMany sem orderBy: para quem tinha dois vínculos, o tenant da sessão podia variar entre requests.
