feat: implement Extensions with real FreeSWITCH directory integration
- extensions table (tenant-scoped, RLS): number, sip_password_enc
(AES-256-GCM via packages/shared/src/crypto.ts), caller_id, context,
sofia_profile, codecs, max_registrations
- apps/api/src/extensions: CRUD (POST/GET/GET:id/DELETE), protected by a
new generic PermissionGuard (@RequirePermission decorator), tenant
resolved only from the JWT (never trusted from the client)
- SIP password is returned in plaintext only once, in the create response;
toPublicExtension() explicitly destructures the encrypted field out
(not a spread) so it can't leak by accident
- b2bcall-fs-config now resolves real directory data: Tenant.telephonyDomain
-> Extension.number, decrypts the password, builds proper directory XML
including a dial-string param (missing it caused originate to fail with
MANDATORY_IE_MISSING instead of the expected USER_NOT_REGISTERED)
- pinned FreeSWITCH's 357737{domain} to a stable value (b2bcall.local) via a
vars.xml patch in the Dockerfile -- it previously used the container's
dynamic IP, which could never match a stored telephony_domain
- added HTTP Basic auth between FreeSWITCH and fs-config
(gateway-credentials, timingSafeEqual comparison) now that the service
returns real secret data, closing the gap flagged as pending in the XML
Curl phase instead of leaving it open
- found and fixed: PermissionGuard's constructor-injected Reflector came
back undefined at runtime under tsx/esbuild (unreliable cross-file
decorator metadata emission) -- fixed with an explicit @Inject(Reflector);
worth watching for in future guards/services run via tsx
- verified end-to-end: create extension -> originate user/<ext> reports
USER_NOT_REGISTERED (found, not registered) -> delete -> back to
SUBSCRIBER_ABSENT (not found); password never reappears in any GET;
unauthenticated fs-config requests get 401
- docs/EXTENSIONS.md
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { ExtensionsModule } from "./extensions/extensions.module";
|
||||
|
||||
@Module({
|
||||
imports: [HealthModule, AuthModule],
|
||||
imports: [HealthModule, AuthModule, ExtensionsModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const PERMISSION_KEY = "permission";
|
||||
|
||||
/** Marca uma rota com a permission necessária (agente.md secao 145). */
|
||||
export const RequirePermission = (permissionKey: string) => SetMetadata(PERMISSION_KEY, permissionKey);
|
||||
43
apps/api/src/common/guards/permission.guard.ts
Normal file
43
apps/api/src/common/guards/permission.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { userHasPermission } from "@b2bcall/auth";
|
||||
import { PERMISSION_KEY } from "../decorators/require-permission.decorator";
|
||||
import type { AuthenticatedRequest } from "./jwt-auth.guard";
|
||||
|
||||
/**
|
||||
* Roda depois do JwtAuthGuard. Exige que a rota tenha um tenant selecionado
|
||||
* (agente.md secao 31: nunca confiar em tenant_id do frontend — aqui vem só
|
||||
* do JWT, nunca do body/query) e que o usuário tenha a permission marcada
|
||||
* via @RequirePermission() (secao 145/146).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
// @Inject explicito: a metadata de tipos emitida via tsx/esbuild nao e'
|
||||
// sempre confiavel o suficiente pra resolucao automatica de DI do Nest
|
||||
// (esbuild nao faz checagem de tipos cross-file completa) — sem isso,
|
||||
// `reflector` chega undefined em runtime.
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const permissionKey = this.reflector.get<string | undefined>(PERMISSION_KEY, context.getHandler());
|
||||
if (!permissionKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new ForbiddenException("Nao autenticado");
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException("Nenhum tenant selecionado (use /auth/select-tenant)");
|
||||
}
|
||||
|
||||
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Permissao necessaria: ${permissionKey}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
36
apps/api/src/extensions/dto/create-extension.dto.ts
Normal file
36
apps/api/src/extensions/dto/create-extension.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator";
|
||||
|
||||
export class CreateExtensionDto {
|
||||
@IsString()
|
||||
@Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" })
|
||||
number!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
callerIdName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[0-9]{2,20}$/)
|
||||
callerIdNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
context?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["internal"])
|
||||
sofiaProfile?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
maxRegistrations?: number;
|
||||
}
|
||||
148
apps/api/src/extensions/extensions.controller.ts
Normal file
148
apps/api/src/extensions/extensions.controller.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { generateStrongPassword, encryptSecret } from "@b2bcall/shared";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
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";
|
||||
|
||||
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;
|
||||
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 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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@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,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
apps/api/src/extensions/extensions.module.ts
Normal file
7
apps/api/src/extensions/extensions.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ExtensionsController } from "./extensions.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [ExtensionsController],
|
||||
})
|
||||
export class ExtensionsModule {}
|
||||
Reference in New Issue
Block a user