SIVO
← Back to blog
#multi-tenant#webrtc#postgres#freeswitch

Multi-tenant WebRTC PBX: real isolation with SIVO

How to isolate agents, data and SIP traffic across customers in the same PBX without sacrificing latency or GDPR compliance. Lessons from production.

Iván Jerez ·

“Multi-tenant” means different things to different vendors. For some it’s a column prefix, for others it’s one DB per customer. Here is how we built it in SIVO and why we believe it’s the only defensible model for a cloud PBX touching recordings, transcripts and PII of third parties (the callers).

The four layers of isolation

1. Data isolation: PostgreSQL Row-Level Security

Each table with customer data carries tenant_id and an RLS policy:

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+ tables in SIVO. The engine enforces filtering, not the app. Even if a dev forgets a WHERE tenant_id = ?, Postgres drops the rows of other tenants.

FORCE ROW LEVEL SECURITY applies the policy even to the table owner. Without it, the role running migrations bypasses filtering.

2. Credential isolation: JWT with signed context

The JWT always carries tenantSlug and sipDomain signed RS256:

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

Each Express middleware reads the JWT, validates the signature and runs:

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

From that point on, every query in the request is auto-filtered by RLS. It is impossible for an agent of tenant A to read tenant B data via the API, not even with a SQL injection on a poorly written endpoint.

3. Voice isolation: separate SIP domains

Each tenant has its own SIP domain:

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

FreeSWITCH uses directory/<tenant>.xml as registration scope. Extensions 1000@acme and 1000@globex are two distinct entities that can neither see nor call each other. Extension collision across tenants is physically impossible.

Internally, mod_callcenter is also partitioned: a support@acme queue has no visibility into support@globex even though they share an FS instance.

4. Traffic isolation: dynamic ACL per tenant

Each sip_trunk carries allowed_cidrs (JSONB array of allowed CIDRs). fsSyncService regenerates acl.conf.xml automatically:

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

INVITEs from unauthorized IPs are dropped at the network level before touching the DB. SIP scanner bots (hundreds a day) generate zero junk events.

The superadmin role: power with traceability

Cloudtree operates SIVO. We need to look inside a tenant for support. We solved it like this:

  1. The superadmin role can use the X-Tenant-Id: <slug> header to impersonate a tenant on any API call.
  2. Every use of that header is logged in audit_logs with cross_tenant: true flag.
  3. The customer is shown a “Cloudtree accesses to your account” log in their backoffice.
  4. RLS remains active: superadmin does not bypass the policies. To see acme’s data, they must contextualize the session.

Auditable transparency, not hidden superpower.

Feature flags per tenant

Each optional feature (AI agents, live transcription, Salesforce SCV, premium recording) has a per-tenant flag in 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)
);

The superadmin backoffice toggles them, every change goes to audit_logs. Enable/disable is synchronous — no deploy, no restart.

What did NOT work

Schema-per-tenant in Postgres

We tried early. Reasons to drop it:

  • Migrations become N times more expensive (N = number of tenants).
  • Cross-tenant reports for Cloudtree impossible without chaotic manual unions.
  • pg_dump of a cluster with 200 schemas takes ages.
  • Idle connections multiply (each schema needs its own warm pool).

RLS is better for our case. Schema-per-tenant only makes sense if your cheapest plan is >€1000/month and you can amortize the operational complexity.

One database per tenant

Only Enterprise justifies it, and it’s offered as “Dedicated instance” as a separate tier, not the default. Each dedicated cluster carries its own backup, retention and SLA.

The critical test

When someone asks “but is this REALLY isolated?” the proof goes like this:

// Customer thinks they are admin of tenant A. Their JWT carries tenantSlug='A'.
// They try to fetch a call from tenant B by direct ID.
GET /api/calls/<uuid-from-B>
404 Not Found

// PostgreSQL RLS filters the row before the app even knows it exists.
// The controller never sees the row. Audit log doesn't either — it never happened.

If this returns the row, it’s not multi-tenant. It’s an app with if (user.tenantId === row.tenantId) scattered everywhere.

→ If you’re curious how this works in production, try it free or talk to sales for a demo focused on isolation.

Your call center with AI superpowers, in minutes.

Start a 14-day free trial. No card. No lock-in.