Multi-Tenant Event Delivery: Isolation Patterns That Work
Multi-tenant delivery sounds simple until a routing bug sends Customer A's data to Customer B. The patterns that make isolation safe by construction.
Multi-tenant event delivery is a class of problems where the bugs are catastrophic. A wrong WHERE clause sends one customer's data to another customer's destination. A leaked credential lets one tenant write to another tenant's bucket. A scoped-wrong query in an audit log shows a customer another customer's history. None of these are theoretical, they show up in real outage post-mortems with regularity.
The solution isn't "be careful." It's architectural. The patterns that work make isolation enforced by construction, not by code review.
The two tenancy levels
Most platform-style products have two tenancy levels:
- Vendor tenant, your customer in the platform. The SaaS team using Pushrail (or building their own delivery layer on top of one).
- Sub-tenant, the vendor's customer. The end customer whose data flows through.
Events have a sub-tenant identity (customerExternalId). Destinations belong to a sub-tenant. Credentials belong to a sub-tenant's destination. Routing rules belong to a sub-tenant's destination. Audit log entries are scoped to a sub-tenant.
Every row of platform data carries the sub-tenant ID. Every query filters on it. Every authorization check derives from the request's sub-tenant scope.
Pattern 1: row-level tenant ID on everything
Every table (destinations, credentials, routing rules, deliveries, attempts, audit) has a non-nullable sub_tenant_id column. Every query that reads or writes the table includes a WHERE clause filtering on it.
The mistake to avoid: relying on application-level scoping. "Our service always filters by tenant ID" is a promise that breaks the first time someone forgets it. The right pattern is row-level security (Postgres RLS or equivalent) that enforces the filter at the database layer, regardless of what the application code does.
CREATE POLICY destinations_tenant_isolation ON destinations
USING (sub_tenant_id = current_setting('app.sub_tenant_id'));
The application sets app.sub_tenant_id once per request (from the session token); the database enforces the filter on every query against the table. A bug in application code can't bypass it.
Pattern 2: credentials scoped per sub-tenant
Credentials are the highest-stakes data. A leaked credential lets one tenant write to another tenant's bucket, a class-A incident.
The pattern: credentials have a sub-tenant ID. The encryption key per credential is derived from a master key plus the sub-tenant ID. A credential created by Tenant A literally cannot be decrypted with Tenant B's tenant-derived key.
Even if a bug returns Tenant A's encrypted credential blob in a Tenant B context, the decryption fails. Belt and suspenders.
Pattern 3: session tokens carry one piece of state
The session token (or API token) used to authorize a request carries the sub-tenant ID and nothing else. No role, no permission list, no destination set.
{
"sub_tenant_id": "acct_8K2zRq",
"iat": 1748540468,
"exp": 1748541368
}
Authorization is derived from the sub-tenant scope on every request. The token is a pointer to the tenant boundary, not a permission grant.
This pattern eliminates a whole class of bugs: a token can't accidentally have "read all tenants" permission because there's no permission field to set.
Pattern 4: routing rules scoped at the platform layer
Routing decisions reference the event's customerExternalId and the destination's sub_tenant_id. The platform enforces: an event with customerExternalId = X can only route to destinations with sub_tenant_id = X.
This isn't a customer-configurable rule. It's a platform-level invariant. The customer can't write a routing rule that sends their events to another tenant's destination, because the platform refuses such configurations.
The mistake: making cross-tenant routing user-configurable. The instant you let customers write rules that reference other customers' destinations, you've created a data-leak vector.
Pattern 5: audit log scoping
The audit log records every config change, credential rotation, delivery attempt, and replay trigger. Every row carries the sub-tenant ID. Every query (customer-facing exports, operator searches, compliance reviews) filters on it.
The trap: aggregating across tenants for internal metrics. "How many credential rotations happened this week?" feels like an admin query, but if it leaks per-tenant rotation patterns into an internal dashboard, you've created a small cross-tenant data exposure. Keep aggregate metrics tenant-scoped or properly anonymized.
Pattern 6: testing isolation as a first-class concern
Tenant isolation needs tests that specifically verify it. Not "the API works" tests, but "a Tenant B-scoped session cannot read Tenant A's data" tests. Every API endpoint should have at least one test that exercises this boundary.
The test pattern:
1. Create Tenant A. Create a destination owned by A.
2. Create Tenant B. Get a session token for B.
3. With B's token, call every endpoint that could touch A's destination.
4. Verify every call returns 403 or 404, never A's data.
These tests catch the WHERE-clause-missing bugs before they ship. They also serve as living documentation of the isolation guarantees.
What goes wrong without this
The failure modes when isolation isn't architectural:
-
A bug in a routing rule leaks Tenant A's events to Tenant B's destination. Found by Tenant B's data team during a routine query. P0 incident, regulatory notification required.
-
A credential migration script forgets to scope by tenant, encrypts Tenant A's credential with Tenant B's key. Tenant A's deliveries start failing en masse. Recovery requires re-entry of every customer's credentials.
-
An admin debugging query forgets the tenant filter, joins a customer-facing view with a cross-tenant table, surfaces another tenant's audit history. Customer noticed. Loss of trust. Possible legal action.
These aren't hypothetical. They're recurring incidents at companies that built multi-tenant systems with application-level scoping.
How Pushrail does it
Pushrail implements all six patterns. Row-level tenant scoping at the database layer. Per-tenant credential encryption. Session tokens carry only sub-tenant scope. Routing rules enforce tenant boundaries. Audit logs are tenant-scoped end-to-end. Isolation tests are part of the test suite.
The tenant-scoped delivery use case covers the customer-facing view of this, what your customers see when they're operating inside their own tenant boundary.
This closes out the SEO launch slate. The Reliability, Beyond-webhooks, Customer-integrations, and Build-vs-buy clusters are complete. Future content will follow the same pattern: focused, practical, no fluff.