fix: domínio SIP único por tenant + grupo de captura + revelar senha do ramal

Achado real, detalhado pelo usuário testando o PABX de verdade: TenantsController
.create() gravava telephonyDomain="b2bcall.local" fixo pra TODO tenant novo —
b2bcall-fs-config decide qual tenant é dono de um REGISTER só pelo domínio
(Tenant.findFirst({telephonyDomain})), então com todo tenant no mesmo domínio o
isolamento de PABX (ramais, call groups, filas, IVR) não tinha como funcionar de
verdade. Investigado direto no container antes de mudar qualquer coisa: o sofia
profile "internal" (vanilla) já vem com <domain name="all" alias="true".../> — o
FreeSWITCH sempre aceitou domínio dinâmico por REGISTER, o bug era só a aplicação.
Nenhuma mudança de infra foi necessária.

Tenant.telephonyDomain agora é obrigatório e @unique (migration com backfill:
tenants existentes ganharam {code}.b2bcall.net, e os Extension.domain já criados
foram atualizados junto). Tela de criação de tenant sugere {code}.b2bcall.net ao
digitar o código, editável.

Extension.callGroup (novo) — ramais no mesmo grupo podem capturar a chamada um do
outro (*8), fora do grupo não. Vira a variable call-group no directory XML; a regra
de dialplan do *8 em si fica pra configurar em Telefonia > Dialplan (editor já
existe). Editável na criação e depois (PATCH /extensions/:id, novo).

POST /extensions/:id/reveal-password (novo) — achado real: "show once" puro não
funciona no dia a dia (reconfigurar um telefone/softphone precisa da senha de novo;
forçar reset toda vez derruba outro aparelho já configurado). sipPasswordEnc sempre
foi criptografia reversível, nunca hash — só não estava exposto. Auditado
(EXTENSION_PASSWORD_REVEALED) por ser sensível mesmo sem escrita.

apps/freeswitch-config reconstruído e reiniciado. Testado ponta a ponta contra o
container REAL via docker exec: senha revelada bate com a gerada na criação,
call-group aparece no XML, e o mesmo número de ramal em domínios diferentes nunca
se confunde (isolamento cross-tenant confirmado de verdade).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 12:05:25 -03:00
parent 7bc4b2e819
commit 56f73bdb8f
24 changed files with 498 additions and 30 deletions

View File

@@ -33,4 +33,37 @@ export class CreateExtensionDto {
@Min(1)
@Max(10)
maxRegistrations?: number;
/** Grupo de captura (secao 178) — ramais no mesmo grupo podem capturar
* a chamada um do outro (*8 no dialplan); ramais fora do grupo, não.
* Sem grupo (null) = ninguém mais captura essa chamada. */
@IsOptional()
@IsString()
@MaxLength(60)
@Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" })
callGroup?: string;
}
export class UpdateExtensionDto {
@IsOptional()
@IsString()
@MaxLength(80)
callerIdName?: string;
@IsOptional()
@IsString()
@Matches(/^[0-9]{2,20}$/)
callerIdNumber?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
maxRegistrations?: number;
@IsOptional()
@IsString()
@MaxLength(60)
@Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" })
callGroup?: string;
}

View File

@@ -8,18 +8,19 @@ import {
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { generateStrongPassword, encryptSecret } from "@b2bcall/shared";
import { generateStrongPassword, encryptSecret, decryptSecret } from "@b2bcall/shared";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { assertQuota } from "@b2bcall/entitlements";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateExtensionDto } from "./dto/create-extension.dto";
import { CreateExtensionDto, UpdateExtensionDto } from "./dto/create-extension.dto";
function toPublicExtension(ext: {
id: string;
@@ -32,6 +33,7 @@ function toPublicExtension(ext: {
context: string;
sofiaProfile: string;
codecs: string;
callGroup: string | null;
maxRegistrations: number;
enabled: boolean;
createdAt: Date;
@@ -81,6 +83,7 @@ export class ExtensionsController {
context: dto.context ?? "default",
sofiaProfile: dto.sofiaProfile ?? "internal",
maxRegistrations: dto.maxRegistrations ?? 1,
callGroup: dto.callGroup,
},
}),
);
@@ -162,6 +165,72 @@ export class ExtensionsController {
return { sipPassword: plainPassword };
}
/**
* Revela a senha SIP atual (achado real reportado pelo usuário: "show
* once" puro não funciona no dia a dia — reconfigurar um telefone físico
* ou um softphone precisa da senha de novo, e forçar reset toda vez
* derruba o registro de qualquer aparelho já configurado com a senha
* antiga). Diferente de `resetPassword`: não gera senha nova, só
* decifra a que já existe (`sipPasswordEnc` é criptografia reversível
* AES-256-GCM, não hash — sempre foi possível decifrar, só não estava
* exposto). Cada chamada fica no audit log — ver a senha de novo é uma
* ação sensível, mesmo sem trocar nada.
*/
@RequirePermission("extensions.manage")
@Post(":id/reveal-password")
async revealPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const extension = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.findFirst({ where: { id, tenantId, deletedAt: null } }),
);
if (!extension) throw new NotFoundException();
await recordAuditEvent(prisma, {
action: "EXTENSION_PASSWORD_REVEALED",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
return { sipPassword: decryptSecret(extension.sipPasswordEnc) };
}
@RequirePermission("extensions.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateExtensionDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.updateMany({
where: { id, tenantId, deletedAt: null },
data: {
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}),
...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}),
...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
},
}),
);
if (result.count === 0) throw new NotFoundException();
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.extension.findFirstOrThrow({ where: { id } }));
await recordAuditEvent(prisma, {
action: "EXTENSION_UPDATE",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
after: { ...dto },
});
return toPublicExtension(updated);
}
@RequirePermission("extensions.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -25,6 +25,22 @@ export class CreateTenantDto {
@IsUUID()
planId!: string;
/** Domínio SIP deste tenant (secao 178, docs/EXTENSIONS.md) — achado
* real reportado pelo usuário: antes disto todo tenant nascia com o
* mesmo domínio fixo ("b2bcall.local"), quebrando isolamento de PABX
* (call groups, filas, IVR não conseguem distinguir um tenant do
* outro). A tela sugere `{code}.b2bcall.net`, mas quem cria pode
* digitar outro — só precisa ser único (constraint no banco) e ter
* cara de domínio (não precisa resolver de verdade por DNS: o
* telefone/softphone aponta pro IP do servidor via `proxy=`, este
* campo só serve pra identificar o tenant no REGISTER). */
@IsString()
@MaxLength(120)
@Matches(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/, {
message: "telephonyDomain precisa ter formato de domínio (ex.: minhaempresa.b2bcall.net)",
})
telephonyDomain!: string;
/** Cria o primeiro usuário (tenant_admin) na mesma transação — sem essa
* conta o tenant fica inacessível (secao 141: "Tenant Admin é criado
* junto com o tenant"). Senha gerada e devolvida uma única vez, mesmo

View File

@@ -14,8 +14,14 @@ import { CreateTenantDto, UpdateTenantDto } from "./dto/create-tenant.dto";
* direto. `tenants` não tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md),
* então lida direto com `getPrismaClient()`, nunca `withTenantContext`.
*
* `telephonyDomain` fixo em "b2bcall.local" pra todo tenant novo — decisão
* já tomada na PHASE 08 (multi-domínio real por tenant não existe ainda).
* `telephonyDomain` (secao 178, docs/EXTENSIONS.md) vem do `dto` agora,
* único por tenant (constraint no banco) — achado real reportado pelo
* usuário testando: até a PHASE 50 todo tenant novo nascia com o mesmo
* domínio fixo ("b2bcall.local"), quebrando isolamento de PABX de verdade
* (call groups, filas, IVR não tinham como distinguir um tenant do outro
* no REGISTER). O FreeSWITCH em si já aceita qualquer domínio dinamicamente
* (`<domain name="all" alias="true".../>` no sofia profile vanilla,
* confirmado lendo o container) — o bug era só este valor fixo aqui.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("tenants")
@@ -28,12 +34,14 @@ export class TenantsController {
}
const prisma = getPrismaClient();
const [existingCode, existingEmail, plan] = await Promise.all([
const [existingCode, existingDomain, existingEmail, plan] = await Promise.all([
prisma.tenant.findFirst({ where: { code: dto.code } }),
prisma.tenant.findFirst({ where: { telephonyDomain: dto.telephonyDomain } }),
prisma.user.findUnique({ where: { email: dto.adminEmail } }),
prisma.plan.findUnique({ where: { id: dto.planId } }),
]);
if (existingCode) throw new ForbiddenException(`Ja existe um tenant com code "${dto.code}"`);
if (existingDomain) throw new ForbiddenException(`Ja existe um tenant usando o dominio "${dto.telephonyDomain}"`);
if (existingEmail) throw new ForbiddenException(`Ja existe um usuario com o e-mail "${dto.adminEmail}"`);
if (!plan) throw new ForbiddenException("Plano nao encontrado");
@@ -49,7 +57,7 @@ export class TenantsController {
tradeName: dto.tradeName,
taxId: dto.taxId,
planId: dto.planId,
telephonyDomain: "b2bcall.local",
telephonyDomain: dto.telephonyDomain,
},
});