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
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
HttpCode,
|
|
HttpStatus,
|
|
NotFoundException,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
UseGuards,
|
|
} from "@nestjs/common";
|
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
|
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, UpdateExtensionDto } from "./dto/create-extension.dto";
|
|
|
|
function toPublicExtension(ext: {
|
|
id: string;
|
|
number: string;
|
|
name: string;
|
|
domain: string;
|
|
sipPasswordEnc: string;
|
|
callerIdName: string | null;
|
|
callerIdNumber: string | null;
|
|
context: string;
|
|
sofiaProfile: string;
|
|
codecs: string;
|
|
callGroup: string | null;
|
|
maxRegistrations: number;
|
|
enabled: boolean;
|
|
createdAt: Date;
|
|
}) {
|
|
// sipPasswordEnc NUNCA sai daqui — agente.md secao 39: "Nunca mostrar
|
|
// novamente a senha inteira". Destructuring explícito (não spread) pra
|
|
// garantir que o campo é de fato removido, não só "esquecido" no tipo.
|
|
const { sipPasswordEnc: _sipPasswordEnc, ...rest } = ext;
|
|
return rest;
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
|
@Controller("extensions")
|
|
export class ExtensionsController {
|
|
@RequirePermission("extensions.manage")
|
|
@Post()
|
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateExtensionDto) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
|
|
const tenant = await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.tenant.findUniqueOrThrow({ where: { id: tenantId } }),
|
|
);
|
|
if (!tenant.telephonyDomain) {
|
|
throw new BadRequestException(
|
|
"Tenant ainda nao tem telephony_domain configurado — necessario antes de criar ramais",
|
|
);
|
|
}
|
|
|
|
const activeCount = await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.extension.count({ where: { tenantId, deletedAt: null } }),
|
|
);
|
|
await assertQuota(tenantId, "maxExtensions", activeCount);
|
|
|
|
const plainPassword = generateStrongPassword();
|
|
|
|
const extension = await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.extension.create({
|
|
data: {
|
|
tenantId,
|
|
number: dto.number,
|
|
name: dto.name,
|
|
domain: tenant.telephonyDomain!,
|
|
sipPasswordEnc: encryptSecret(plainPassword),
|
|
callerIdName: dto.callerIdName,
|
|
callerIdNumber: dto.callerIdNumber,
|
|
context: dto.context ?? "default",
|
|
sofiaProfile: dto.sofiaProfile ?? "internal",
|
|
maxRegistrations: dto.maxRegistrations ?? 1,
|
|
callGroup: dto.callGroup,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await recordAuditEvent(prisma, {
|
|
action: "EXTENSION_CREATE",
|
|
tenantId,
|
|
userId: user.sub,
|
|
entityType: "extension",
|
|
entityId: extension.id,
|
|
after: { number: extension.number, name: extension.name },
|
|
});
|
|
|
|
return {
|
|
...toPublicExtension(extension),
|
|
// Só aqui, uma unica vez, na resposta da criacao.
|
|
sipPassword: plainPassword,
|
|
};
|
|
}
|
|
|
|
@RequirePermission("extensions.view")
|
|
@Get()
|
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
const extensions = await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.extension.findMany({ where: { deletedAt: null }, orderBy: { number: "asc" } }),
|
|
);
|
|
return extensions.map(toPublicExtension);
|
|
}
|
|
|
|
@RequirePermission("extensions.view")
|
|
@Get(":id")
|
|
async get(@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, deletedAt: null } }),
|
|
);
|
|
if (!extension) {
|
|
throw new NotFoundException();
|
|
}
|
|
return toPublicExtension(extension);
|
|
}
|
|
|
|
/**
|
|
* Redefine a senha SIP (agente.md secao 39: nunca reexpor a senha
|
|
* existente — a única forma de "editar" é gerar uma nova e mostrar
|
|
* ela UMA vez, mesmo caminho da criação). `b2bcall-fs-config` resolve
|
|
* o directory ao vivo por request (sem arquivo/sync intermediário,
|
|
* diferente de trunks/queues) — um `UPDATE` aqui já é o suficiente,
|
|
* o próximo REGISTER do ramal usa a senha nova automaticamente.
|
|
*/
|
|
@RequirePermission("extensions.manage")
|
|
@Post(":id/reset-password")
|
|
async resetPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
const plainPassword = generateStrongPassword();
|
|
|
|
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.extension.updateMany({
|
|
where: { id, tenantId, deletedAt: null },
|
|
data: { sipPasswordEnc: encryptSecret(plainPassword) },
|
|
}),
|
|
);
|
|
if (result.count === 0) {
|
|
throw new NotFoundException();
|
|
}
|
|
|
|
await recordAuditEvent(prisma, {
|
|
action: "EXTENSION_RESET_PASSWORD",
|
|
tenantId,
|
|
userId: user.sub,
|
|
entityType: "extension",
|
|
entityId: id,
|
|
});
|
|
|
|
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)
|
|
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
|
|
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.extension.updateMany({
|
|
where: { id, deletedAt: null },
|
|
data: { deletedAt: new Date(), enabled: false },
|
|
}),
|
|
);
|
|
if (result.count === 0) {
|
|
throw new NotFoundException();
|
|
}
|
|
|
|
await recordAuditEvent(prisma, {
|
|
action: "EXTENSION_DELETE",
|
|
tenantId,
|
|
userId: user.sub,
|
|
entityType: "extension",
|
|
entityId: id,
|
|
});
|
|
}
|
|
}
|