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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user