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.
“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:
- The
superadminrole can use theX-Tenant-Id: <slug>header to impersonate a tenant on any API call. - Every use of that header is logged in
audit_logswithcross_tenant: trueflag. - The customer is shown a “Cloudtree accesses to your account” log in their backoffice.
- 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_dumpof 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.