SIVO
← Volver al blog
#multi-tenant#webrtc#postgres#freeswitch

Multi-tenant WebRTC PBX: aislamiento real con SIVO

Cómo aislar agentes, datos y tráfico SIP entre clientes en una misma centralita sin sacrificar latencia ni cumplimiento GDPR. Lecciones de producción.

Iván Jerez ·

“Multi-tenant” significa cosas distintas para distintos vendors. Para algunos es un prefijo en la tabla, para otros es una BD por cliente. Aquí va cómo lo construimos en SIVO y por qué creemos que es el único modelo defensible para una centralita cloud que toca grabaciones, transcripciones y datos de PII de terceros (los llamantes).

Las cuatro capas del aislamiento

1. Aislamiento de datos: PostgreSQL Row-Level Security

Cada tabla con datos del cliente lleva tenant_id y una policy RLS:

CREATE POLICY tenant_isolation ON call_records
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

ALTER TABLE call_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE call_records FORCE ROW LEVEL SECURITY;

23+ tablas en SIVO. El motor garantiza el filtrado, no la aplicación. Aunque un dev olvide un WHERE tenant_id = ?, Postgres descarta las filas de otros tenants.

FORCE ROW LEVEL SECURITY aplica la policy incluso al owner de la tabla. Sin él, el rol que ejecuta migraciones se salta el filtrado.

2. Aislamiento de credenciales: JWT con contexto firmado

El JWT lleva siempre tenantSlug y sipDomain cifrados RS256:

{
  "sub": "agent-1234",
  "tenantSlug": "acme-corp",
  "sipDomain": "acme.sip.sivocenter.com",
  "role": "agent",
  "iat": 1715000000,
  "exp": 1715003600
}

Cada middleware Express lee el JWT, valida la firma y hace:

await db.query("SELECT set_config('app.current_tenant', $1, true)", [tenantId]);

A partir de ahí, todas las queries del request van filtradas por RLS automáticamente. Es imposible que un agente de tenant A consulte datos de tenant B vía API, ni siquiera con un SQL injection en un endpoint mal escrito.

3. Aislamiento de voz: SIP domains separados

Cada tenant tiene su propio dominio SIP:

acme.sip.sivocenter.com
globex.sip.sivocenter.com
soylent.sip.sivocenter.com

FreeSWITCH usa directory/<tenant>.xml como ámbito de registro. Las extensiones 1000@acme y 1000@globex son dos entidades distintas que ni se ven ni se pueden llamar entre sí. Una colisión de extensiones entre tenants es físicamente imposible.

Internamente, mod_callcenter también está particionado: una cola support@acme no tiene visibilidad sobre support@globex aunque compartan instancia FS.

4. Aislamiento de tráfico: ACL dinámica por tenant

Cada sip_trunk tiene su allowed_cidrs (JSONB array de CIDRs permitidos). fsSyncService regenera el acl.conf.xml automáticamente:

<list name="sip-trunks-acme" default="deny">
  <node type="allow" cidr="185.45.152.0/24"/>  <!-- Zadarma para acme -->
</list>
<list name="sip-trunks-globex" default="deny">
  <node type="allow" cidr="54.172.60.0/23"/>   <!-- Twilio para globex -->
</list>

INVITE de IPs no autorizadas se descartan a nivel de red, antes de tocar la BD. Los bots SIP que escanean internet (cientos al día) no generan ni un evento basura.

El rol superadmin: poder con trazabilidad

Cloudtree opera SIVO. Necesitamos poder mirar dentro de un tenant para soporte. Resolvimos así:

  1. Rol superadmin puede usar el header X-Tenant-Id: <slug> para impersonar un tenant en cualquier llamada API.
  2. Cada uso de ese header se registra en audit_logs con flag cross_tenant: true.
  3. Al cliente le mostramos un log de “accesos de Cloudtree a tu cuenta” en su backoffice.
  4. RLS sigue activa: superadmin no salta las policies. Si quiere ver datos de acme, debe contextualizar la sesión.

Transparencia auditable, no superpoder oculto.

Feature flags por tenant

Cada feature opcional (AI agents, transcripción en vivo, Salesforce SCV, recording premium) tiene un flag por tenant en tenant_features:

CREATE TABLE tenant_features (
  tenant_id UUID NOT NULL,
  feature_key TEXT NOT NULL,
  enabled BOOLEAN NOT NULL DEFAULT false,
  config JSONB DEFAULT '{}',
  PRIMARY KEY (tenant_id, feature_key)
);

El backoffice de superadmin permite habilitar/deshabilitar con un toggle, todo cambio queda en audit_logs. Activar/desactivar es síncrono — no hay deploy, no hay reinicio.

Lo que NO funcionó

Schema-per-tenant en Postgres

Probamos al principio. Razones para descartarlo:

  • Migraciones se vuelven N veces más caras (N = nº de tenants).
  • Reportes cross-tenant para Cloudtree imposibles sin uniones manuales caóticas.
  • pg_dump de un cluster con 200 schemas tarda eternidades.
  • Conexiones idle se multiplican (cada schema necesita su pool warm).

RLS es mejor para nuestro caso. Schema-per-tenant solo tiene sentido si tu plan más barato vale >1000 €/mes y puedes amortizar la complejidad operativa.

Base de datos por tenant

Solo Enterprise lo justifica, y se ofrece como “Instancia dedicada” como tier separado, no como modelo default. Cada cluster dedicado lleva su backup, su retention y su SLA propios.

El test crítico

Cuando alguien pregunta “¿pero esto está REALMENTE aislado?” la prueba se hace así:

// Cliente cree que es admin del tenant A. Su JWT lleva tenantSlug='A'.
// Intenta consultar una llamada del tenant B vía ID directo.
GET /api/calls/<uuid-de-llamada-de-B>
404 Not Found

// La RLS de PostgreSQL filtra la fila antes de que la app sepa que existe.
// El controller nunca ve la row. El log de auditoría tampoco — nunca pasó.

Si esto te devuelve la fila, no es multi-tenant. Es un app con if (user.tenantId === row.tenantId) esparcido por todas partes.

→ Si te interesa cómo funciona en producción, pruébalo gratis o habla con ventas para una demo enfocada en aislamiento.

Tu centralita con superpoderes IA, en minutos.

Empieza con 14 días gratis. Sin tarjeta. Sin permanencia.