Multi-Tenancy
How ZapTicket isolates workspace data using global query filters, tenant resolution middleware, and strict access boundaries.
Overview
ZapTicket is a multi-tenant SaaS application. Every workspace is a tenant — a fully isolated environment with its own conversations, tickets, agents, and settings. The architecture ensures that data from one workspace can never leak into another, regardless of bugs in application code.
Tenant Resolution
The system resolves the current tenant at the middleware layer, before application code runs. There are two mechanisms depending on the request type:
1. JWT-based Resolution (Agent Requests)
When an agent makes an API request using a workspace token, the middleware extracts the tenantId claim from the JWT and injects it into the request context:
Request arrives
→ Authorization header extracted
→ JWT validated and decoded
→ tenantId claim read from payload
→ TenantContext.CurrentTenantId set
→ Global query filter activated
→ Controller/handler executes (already scoped)2. Site-Key Resolution (Widget Requests)
Widget requests don't carry a JWT. Instead, the x-site-key header identifies the workspace:
Request arrives
→ x-site-key header extracted
→ Key looked up in database
→ Matching workspace found → tenantId resolved
→ TenantContext.CurrentTenantId set
→ Global query filter activated
→ Controller/handler executes (already scoped)tenantId claim, the request is rejected with 401 Unauthorized before any application code runs.Global Query Filter
The most critical piece of the isolation strategy is Entity Framework Core's global query filter. Every entity that belongs to a tenant has a TenantId column, and a filter is applied at the ORM level:
// Applied in OnModelCreating for every tenant-scoped entity
modelBuilder.Entity<Conversation>()
.HasQueryFilter(c => c.TenantId == _tenantContext.CurrentTenantId);
modelBuilder.Entity<Ticket>()
.HasQueryFilter(t => t.TenantId == _tenantContext.CurrentTenantId);
modelBuilder.Entity<Message>()
.HasQueryFilter(m => m.TenantId == _tenantContext.CurrentTenantId);
modelBuilder.Entity<Agent>()
.HasQueryFilter(a => a.TenantId == _tenantContext.CurrentTenantId);This filter is always active. Every LINQ query, every FindAsync(), every navigation property load is automatically scoped. Application code never needs to add .Where(x => x.TenantId == ...) — it's impossible to forget.
Data Isolation Guarantees
- Database-level — Global query filter on every tenant-scoped entity. No cross-tenant reads or writes possible through EF Core.
- API-level — Tenant resolved from JWT/site-key before handlers run. 404 returned for any resource not in the current tenant.
- Real-time (SignalR) — Agents join
tenant:{id}groups. Events only broadcast to matching groups. - Storage-level — File uploads and exports are prefixed with tenant ID in blob storage paths.
What Can't Leak
✓ Conversations — scoped to tenant
✓ Messages — scoped to tenant
✓ Tickets — scoped to tenant
✓ Agents — scoped to tenant (same user can be in multiple tenants)
✓ Settings/Branding — scoped to tenant
✓ API Keys — scoped to tenant
✓ Ticket references (ZT-XXXX) — unique per tenant, not globally
✓ File uploads — tenant-prefixed storage paths
✓ SignalR events — group-isolatedSite Key vs JWT
┌──────────────────────────────────────────────────────────────┐
│ Aspect │ Site Key │ JWT (Workspace Token) │
├────────────────┼───────────────────────┼───────────────────────┤
│ Used by │ Widget (visitors) │ Dashboard (agents) │
│ Resolves │ Tenant only │ Tenant + Agent + Role │
│ Passed via │ x-site-key header │ Authorization header │
│ Rotation │ Manual (admin action) │ Auto-expires (1 hour) │
│ Public/Secret │ Public (safe in JS) │ Secret (never expose) │
│ Rate limit │ 60/min per key │ 100/min per IP │
└──────────────────────────────────────────────────────────────┘