fix(agents): POST /agents nunca verificava tenant de userId/extensionId

Achado numa revisão de segurança sobre o trabalho da PHASE 29: um FK do
Postgres só checa que a linha referenciada existe, não que ela é visível
sob a RLS da sessão atual — então um userId/extensionId de outro tenant
seria aceito silenciosamente no create do Agent (violação do princípio já
seguido em todo o resto do código: nunca confiar em id vindo do client
sem checar contra o tenant do JWT). O frontend já só oferece opções do
próprio tenant, mas isso é conveniência de UI, não autorização.

Corrigido com uma checagem explícita de TenantMembership/Extension antes
do create (400 se não pertencer). Reverificado ponta a ponta: criação
legítima continua funcionando igual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-29 16:37:32 -03:00
parent 75212cafe6
commit 7f56c96454
6 changed files with 38 additions and 0 deletions

17
TODO.md
View File

@@ -1006,6 +1006,23 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71,
`bridge(${destination_number})` → gerar v1 (DRAFT) → ativar → badge `bridge(${destination_number})` → gerar v1 (DRAFT) → ativar → badge
"Ativa" confirmado. Smoke test de regressão nas 13 telas anteriores "Ativa" confirmado. Smoke test de regressão nas 13 telas anteriores
do tenant + platform, todas 200 sem quebrar nada. do tenant + platform, todas 200 sem quebrar nada.
- [x] **Bug real de segurança, achado numa revisão dedicada depois desta
fase (PHASE 31)**: `POST /agents` nunca verificava que
`dto.userId`/`dto.extensionId` pertencem ao tenant de quem está
chamando — um FK do Postgres só checa que a linha existe, não que
ela é visível sob a RLS da sessão atual, então um `userId`/
`extensionId` de **outro tenant** seria aceito silenciosamente
(secao 31/146: nunca confiar em id vindo do client sem checar
contra o tenant do JWT — o frontend já só oferece opções do próprio
tenant, mas isso é conveniência de UI, o backend é a autoridade).
Corrigido com uma checagem explícita de `TenantMembership`/
`Extension` antes do create, 400 se não pertencer. Impacto prático
limitado (precisaria adivinhar um UUID de outro tenant, e o agente
criado ficaria órfão/inerte — o usuário referenciado nunca teria
`tenantId` ativo igual ao do agente sem ter membership de verdade),
mas corrigido de qualquer forma por princípio arquitetural.
Reverificado ponta a ponta depois da correção: criação legítima
continua funcionando igual.
- [ ] Sem paginação/uniqueness real em `Agent.userId`/`extensionId` — o - [ ] Sem paginação/uniqueness real em `Agent.userId`/`extensionId` — o
"só quem não é agente ainda" é conveniência de UI, não constraint de "só quem não é agente ainda" é conveniência de UI, não constraint de
banco (mesma classe de lacuna sistêmica da PHASE 12, `@@unique` + banco (mesma classe de lacuna sistêmica da PHASE 12, `@@unique` +

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
@@ -36,6 +37,26 @@ export class AgentsController {
); );
await assertQuota(tenantId, "maxAgents", activeCount); await assertQuota(tenantId, "maxAgents", activeCount);
// Bug real, achado construindo a tela de Agentes (PHASE 29): nada
// aqui verificava que `dto.userId`/`dto.extensionId` pertencem a este
// tenant antes do create — um FK constraint no Postgres não passa
// pela RLS de visibilidade (só checa existência da linha), então um
// id de outro tenant seria aceito silenciosamente. O frontend já só
// oferece opções do próprio tenant, mas o backend é a autoridade
// (secao 31/146), nunca confia só no que o client filtrou.
const [membership, extension] = await withTenantContext(prisma, tenantId, (tx) =>
Promise.all([
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: dto.userId } } }),
dto.extensionId ? tx.extension.findFirst({ where: { id: dto.extensionId, tenantId } }) : Promise.resolve(null),
]),
);
if (!membership) {
throw new BadRequestException("userId nao pertence a este tenant");
}
if (dto.extensionId && !extension) {
throw new BadRequestException("extensionId nao pertence a este tenant");
}
const agent = await withTenantContext(prisma, tenantId, (tx) => const agent = await withTenantContext(prisma, tenantId, (tx) =>
tx.agent.create({ tx.agent.create({
data: { data: {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB