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:

Flow: JWT → Tenant
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:

Flow: Site Key → Tenant
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)
💡If the site key is invalid or the JWT doesn't contain a 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:

DbContext Configuration
// 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.

🚨The global query filter is the primary security boundary. Even if a controller accidentally passes a conversation ID from another tenant, the query returns zero results because the filter eliminates rows that don't match the current tenant.

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

Isolation Scope
✓ 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-isolated

Site Key vs JWT

Comparison
┌──────────────────────────────────────────────────────────────┐
│ 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        │
└──────────────────────────────────────────────────────────────┘