Multi-Tenant SaaS Helpdesk: Django, Litestar, Redis, and Postgres
A helpdesk has two faces, and they’re shaped nothing alike.
One face is administrative: tenants, agents, tickets, billing, an admin panel someone in support actually uses every day. It’s relational, transactional, and low-throughput. The other face is ingestion: a firehose of webhooks from email providers, Slack, and WhatsApp, arriving in bursts, needing a fast 200 and no drama.
Add “multi-tenant” and the rules tighten. Every request belongs to exactly one tenant. Isolation must be enforced below the application, so a missing WHERE tenant_id = ? can’t leak another customer’s data. And one noisy tenant flooding the webhook endpoint must not slow down everyone else.
This is the split I’d build:
- Django is for admin for tenant management, ticket CRUD, billing, and auth.
- Litestar is for high-throughput webhook receiver for email, Slack, and WhatsApp integrations.
- Redis is for queue for incoming messages, agent presence, cached tenant configs.
- Postgres is for row-level security for tenant isolation, JSONB for custom fields.
- Celery is for workers turning queued webhook events into tickets and messages.
The One Diagram
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Email hook │ │ Slack Events │ │ WhatsApp API │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
└──────────────────┼──────────────────┘
│ HTTPS webhooks (must ack fast)
┌──────────▼───────────┐
│ Litestar │
│ verify signature │
│ dedupe by event id │
│ XADD → Redis stream │
└──────────┬───────────┘
│ (ack in ~1 ms, work later)
┌──────────▼───────────┐
│ Redis │
│ ingest:events stream│
│ presence, tenant cfg│
└──────────┬───────────┘
│ XREADGROUP
┌──────────▼───────────┐
│ Celery workers │
│ parse → ticket upsert│
└──────────┬───────────┘
│ ORM / SQL (tenant context)
┌──────────▼───────────┐
│ PostgreSQL │
│ RLS: every row has │
│ tenant_id │
│ JSONB custom fields │
└──────────▲───────────┘
│
┌──────────────────────────┴──────────────────────────┐
│ Django │
│ admin, auth, ticket CRUD, billing (tenant-scoped) │
└───────────────────────────────────────────────────────┘
Two write paths meet in one database: webhooks arrive through Litestar → Redis → Celery, and humans act through Django. Postgres row-level security is the fence that keeps every query inside its own tenant.
First, Pick Your Isolation Model
“Multi-tenant” is three very different architectures, and the choice dictates everything else.
| Model | Isolation | Ops cost | Best when |
|---|---|---|---|
Shared schema + tenant_id + RLS |
Row-level, enforced by Postgres | Low | Many small-to-mid tenants (the default here) |
| Schema-per-tenant | Stronger; separate namespaces | Medium | Hundreds of tenants, per-tenant migrations |
| Database-per-tenant | Strongest; separate everything | High | Regulated customers, noisy-neighbor guarantees |
This post uses shared schema + RLS. It’s the cheapest to operate and scales to a lot of tenants, and RLS moves the isolation guarantee out of application code and into the database. The price is that a single forgotten tenant_id filter is no longer merely a bug — RLS turns it into an empty result instead of a cross-tenant leak. That trade is worth it.
Django: Admin, Billing, Auth — and the Tenant Model
Django’s job is the boring, high-value surface: a usable admin, real auth, real billing, real CRUD. Start with the tenant model everything hangs off.
# tenants/models.py
import uuid
from django.db import models
class Tenant(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
slug = models.SlugField(unique=True)
name = models.CharField(max_length=120)
plan = models.CharField(max_length=20, default="free")
# Branding, SLA targets, business hours, feature flags, webhook secrets.
settings = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Membership(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="members")
user = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="memberships")
role = models.CharField(max_length=20, default="agent") # owner|admin|agent
class Meta:
constraints = [
models.UniqueConstraint(fields=["tenant", "user"], name="uniq_membership"),
]
Tickets and messages carry tenant_id on every row — not as a convention, but as the column RLS keys on. Custom fields are tenant-defined, so they live in JSONB instead of a migration per customer.
# helpdesk/models.py
import uuid
from django.db import models
class Ticket(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
tenant = models.ForeignKey("tenants.Tenant", on_delete=models.CASCADE, related_name="tickets")
subject = models.CharField(max_length=300, blank=True)
status = models.CharField(max_length=20, default="open")
channel = models.CharField(max_length=20) # email|slack|whatsapp
# Per-tenant fields (priority, product area, contract tier...) live here.
custom_fields = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [models.Index(fields=["tenant", "status", "-created_at"])]
class Message(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
tenant = models.ForeignKey("tenants.Tenant", on_delete=models.CASCADE)
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="messages")
direction = models.CharField(max_length=3) # in|out
author = models.CharField(max_length=200)
body = models.TextField()
# The provider's event id: the idempotency key for webhook retries.
provider_event_id = models.CharField(max_length=200, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["tenant", "provider_event_id"],
condition=models.Q(provider_event_id__isnull=False),
name="uniq_provider_event",
),
]
Register them in the admin and Django gives you a tenant-aware back office for free as long as the admin itself runs under a tenant context (see RLS below).
Postgres: Row-Level Security Done Right
RLS is the reason this architecture is safe. The policy reads the tenant from a session variable and filters every row.
-- Enable and FORCE RLS. FORCE is the important part: without it, the
-- table *owner* (often your app role) bypasses RLS and isolation is a lie.
ALTER TABLE helpdesk_ticket ENABLE ROW LEVEL SECURITY;
ALTER TABLE helpdesk_ticket FORCE ROW LEVEL SECURITY;
CREATE POLICY ticket_isolation ON helpdesk_ticket
USING ("tenant_id" = current_setting('app.tenant_id', true)::uuid)
WITH CHECK ("tenant_id" = current_setting('app.tenant_id', true)::uuid);
current_setting('app.tenant_id', true) returns NULL when unset. NULL = anything is NULL, which is not true, so an unauthenticated query returns zero rows instead of everything. Fail closed, by default.
WITH CHECK is what stops an INSERT/UPDATE from writing a row into a tenant it doesn’t own — the half of RLS people forget.
The tenant variable is set per transaction with SET LOCAL semantics, so it can’t leak across requests sharing a pooled connection:
# tenants/db.py
from contextlib import contextmanager
from django.db import connection, transaction
@contextmanager
def tenant_context(tenant_id):
"""Bind the connection to one tenant for the duration of a transaction.
set_config(..., is_local=True) is the SQL form of SET LOCAL: the value
resets automatically when the transaction ends, so pooled connections
never carry one tenant's identity into another tenant's request.
"""
with transaction.atomic():
with connection.cursor() as cur:
cur.execute("SELECT set_config('app.tenant_id', %s, true)", [str(tenant_id)])
yield
Use it around every block of database work in Django and Celery:
from tenants.db import tenant_context
def open_ticket(tenant_id, subject, channel, custom_fields):
with tenant_context(tenant_id):
return Ticket.objects.create(
tenant_id=tenant_id,
subject=subject,
channel=channel,
custom_fields=custom_fields,
)
Indexes must lead with tenant_id. Every query is tenant-scoped, so the tenant column belongs first — and your per-tenant uniqueness belongs in partial unique indexes, not a global constraint.
CREATE INDEX ticket_tenant_status_created_idx
ON helpdesk_ticket (tenant_id, status, created_at DESC);
-- Unique per tenant, not globally.
CREATE UNIQUE INDEX msg_tenant_event_idx
ON helpdesk_message (tenant_id, provider_event_id)
WHERE provider_event_id IS NOT NULL;
JSONB for custom fields
Tenants want their own ticket fields (“region”, “contract tier”, “patient ID”). You can’t run a migration per customer, so store them in jsonb and query with containment.
CREATE INDEX ticket_custom_fields_gin
ON helpdesk_ticket USING gin (custom_fields jsonb_path_ops);
-- "tenant's urgent tickets" — uses the GIN index for the containment query
SELECT id, subject
FROM helpdesk_ticket
WHERE tenant_id = current_setting('app.tenant_id')::uuid
AND custom_fields @> '{"priority": "urgent"}';
Validate each tenant’s custom fields against a schema kept in Tenant.settings, and reject unknown keys on write. JSONB is schemaless storage, not permission to skip validation — otherwise the field you indexed last quarter quietly fills with three different spellings of “High”.
Litestar: The Webhook Firehose
The ingestion side has one job: verify, dedupe, enqueue, ack — fast. Providers like Slack give you about three seconds before they retry, and retries are how you get duplicate tickets if you’re not careful.
A single route parameterized by provider and a tenant-scoped token keeps it tidy; the token is a signed, rotatable identifier, never a raw tenant id from the client.
# webhooks/app.py
import hashlib
import hmac
import orjson
from litestar import Request, post
from litestar.exceptions import HTTPException
from litestar.response import Response
from .streams import enqueue_event
def _verify_hmac(secret: str, body: bytes, signature: str) -> None:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
# Constant-time compare: never use == for signatures.
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="invalid signature")
@post("/webhooks/{provider:str}/{tenant_token:str}")
async def receive(provider: str, tenant_token: str, request: Request) -> Response:
# Read the RAW body first: signatures are computed over raw bytes, and
# parsing before verifying is a classic "it works until it doesn't" bug.
body = await request.body()
tenant_id = await resolve_tenant(tenant_token)
secret = await get_tenant_secret(tenant_id, provider)
if provider == "slack":
ts = request.headers.get("x-slack-request-timestamp", "")
sig = request.headers.get("x-slack-signature", "")
_verify_slack(secret, ts, body, sig)
elif provider == "whatsapp":
sig = request.headers.get("x-hub-signature-256", "").removeprefix("sha256=")
_verify_hmac(secret, body, sig)
else: # email (Postmark/SendGrid/etc.)
_verify_hmac(secret, body, request.headers.get("x-signature", ""))
event = orjson.loads(body)
# Slack's one-time URL verification handshake must be answered inline.
if provider == "slack" and event.get("type") == "url_verification":
return Response({"challenge": event["challenge"]})
event_id = extract_event_id(provider, event, request.headers)
accepted = await enqueue_event(tenant_id, provider, event_id, body)
# 202 in ~1 ms; the real work happens in Celery.
return Response({"ok": True, "duplicate": not accepted}, status_code=202)
The enqueue is where idempotency lives. A provider retrying the same event must not create a second message, so dedupe on the provider’s event id before it ever reaches the queue.
# webhooks/streams.py
import redis.asyncio as redis
STREAM = "ingest:events"
_redis = redis.from_url("redis://localhost:6379")
async def enqueue_event(tenant_id, provider: str, event_id: str, body: bytes) -> bool:
"""Return False if this event was already seen (provider retry)."""
dedupe_key = f"dedupe:{provider}:{event_id}"
# SETNX is atomic: the first writer wins, everyone else gets False.
first = await _redis.set(dedupe_key, "1", nx=True, ex=86400)
if not first:
return False
await _redis.xadd(
STREAM,
{
"tenant": tenant_id,
"provider": provider,
"event_id": event_id,
"body": body,
},
# Bound the stream so a flood can't grow Redis without limit.
maxlen=1_000_000,
approximate=True,
)
return True
A few provider-specific traps worth writing down once:
- Slack signs
v0:{timestamp}:{raw_body}and rejects requests older than five minutes — validate the timestamp to prevent replay. Also, theurl_verificationchallenge must be returned immediately, synchronously, before you’ve set up anything. - WhatsApp (Cloud API) uses
X-Hub-Signature-256and does aGETverification handshake withhub.challengewhen you register the webhook — handle that on a separate route. - Email providers differ wildly (Postmark’s
X-Postmark-Signature, SendGrid’s ECDSAX-Twilio-Email-Event-Webhook-Signature). Store a per-tenant verification strategy, not a hardcoded branch.
Redis: Queue, Presence, and Tenant Config
Redis plays three roles here, and conflating them is how you end up debugging the wrong thing.
1. The ingest queue — Redis Streams, not a list
A list (LPUSH/BRPOP) is a queue. A Stream (XADD/XREADGROUP) is a queue with consumer groups, acks, replay, and pending-entry recovery — which is what you want when a worker dies mid-processing. Assume webhooks are at-least-once; design for duplicates and crashes.
# worker/consumer.py
import redis as redis_sync
from celery import Celery
app = Celery("helpdesk", broker="redis://localhost:6379/0")
STREAM = "ingest:events"
GROUP = "workers"
DEAD = "ingest:dead"
@app.task
def consume_forever(consumer_name: str) -> None:
r = redis_sync.Redis.from_url("redis://localhost:6379")
try:
r.xgroup_create(STREAM, GROUP, id="0", mkstream=True)
except redis_sync.ResponseError:
pass # group already exists
while True:
# ">" = never-delivered messages. block=5000 avoids a busy loop.
batches = r.xreadgroup(GROUP, consumer_name, {STREAM: ">"}, count=50, block=5000)
for _stream, entries in batches or []:
for entry_id, fields in entries:
try:
handle_event(fields) # parse → ticket upsert
except Exception:
r.xadd(DEAD, fields) # park it; alert on DEAD
finally:
r.xack(STREAM, GROUP, entry_id) # always ack: never wedgie
Reading never-delivered messages with > means a crashed worker’s in-flight entries sit in the pending entries list and can be reclaimed with XAUTOCLAIM. That’s the property a plain list doesn’t give you.
2. Agent presence
Presence is ephemeral by definition. Model it as a heartbeat with a TTL, not a row you have to garbage-collect.
import time
# Heartbeat every ~15s from each connected agent.
r.setex(f"presence:tenant:{tenant_id}:agent:{agent_id}", 30, str(time.time()))
To list who’s online, keep a per-tenant sorted set of last-seen timestamps and trim stale members on read:
r.zadd(f"presence:tenant:{tenant_id}", {agent_id: time.time()})
r.zremrangebyscore(f"presence:tenant:{tenant_id}", 0, time.time() - 30)
online = r.zrange(f"presence:tenant:{tenant_id}", 0, -1)
Never key anything in Redis without the tenant id in the key. A shared Redis across tenants means a missing prefix is the same class of bug as a missing WHERE clause — except no RLS is going to save you here.
3. Tenant config cache
Branding, SLA targets, business hours, and feature flags are read on nearly every request and change rarely. Perfect cache candidate — with a namespace and an invalidation path.
import orjson
import redis.asyncio as redis
_redis = redis.from_url("redis://localhost:6379")
async def get_tenant_config(tenant_id: str) -> dict:
key = f"tenant:{tenant_id}:config"
if cached := await _redis.get(key):
return orjson.loads(cached)
config = await load_settings_from_db(tenant_id) # Tenant.settings JSON
await _redis.set(key, orjson.dumps(config), ex=300) # 5-minute safety net
return config
def invalidate_tenant_config(tenant_id: str) -> None:
# Call this from the Django admin's save for Tenant.settings.
_redis.delete(f"tenant:{tenant_id}:config")
The TTL is a backstop, not the strategy: invalidate explicitly on write, and keep the TTL short enough that a missed invalidation self-heals within minutes.
Data Flow, End to End
Inbound email/Slack/WhatsApp message
│ HTTPS POST
▼
Litestar: verify signature → dedupe by event id → XADD "ingest:events" → 202
│
▼
Redis Stream ──XREADGROUP──► Celery worker
│ │ resolve tenant, parse provider payload
│ │ find-or-create ticket (by thread/conversation)
│ │ append Message, merge custom_fields
│ ▼
└────────────────────► PostgreSQL under tenant RLS
▲
Agent replies in the UI ──HTTP──► Django (auth, billing, ticket CRUD)
│ writes outbound Message, enqueues delivery
▼
Redis (outbound queue) → Celery → provider send API
Two things to notice. First, the webhook path never blocks on the database — it verifies and enqueues, and Celery does the slow work. Second, both the automated and the human paths converge on the same tenant-scoped tables, so RLS covers both without special-casing either.
Ordering is a trap. Providers deliver out of order and retry, so arrival order means nothing. Tie-break on the provider’s own timestamp, dedupe on event id, and never assume the message that arrived last is the message that happened last.
How to Wire Them Together
Two frameworks, one product. Four seams hold them together: the database, Redis, auth, and the reverse proxy. In a multi-tenant system each seam also has to carry the tenant — and the tenant must always be derived server-side, never sent by the client.
Shared Postgres: One Schema, One Migration Authority — and RLS on Both Sides
Both apps point at one database. Django owns migrations and the relational tables; Litestar reads (and writes only the tables it’s explicitly granted) through SQLAlchemy/asyncpg. Never import Django models into Litestar or call django.setup() there — that couples deploys and drags a sync ORM into an async loop.
The multi-tenant twist: Litestar is subject to the same RLS policies as Django, and must set app.tenant_id per transaction or it will read back nothing. That’s the fail-closed behavior doing its job.
# settings.py (Django)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "helpdesk",
"USER": "django_app", # runtime role, subject to RLS
"PASSWORD": env("DB_PASSWORD"),
"HOST": "postgres",
"PORT": 5432,
"CONN_MAX_AGE": 60,
}
}
Litestar uses an async engine and the same transaction-local GUC Django uses:
# webhooks/db.py
from contextlib import asynccontextmanager
from sqlalchemy import MetaData, Table, Column, Text, UUID, JSONB, TIMESTAMP
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
engine = create_async_engine(
"postgresql+asyncpg://litestar_app:...@postgres:5432/helpdesk",
pool_size=20, max_overflow=10, pool_pre_ping=True,
)
Session = async_sessionmaker(engine, expire_on_commit=False)
metadata = MetaData()
helpdesk_message = Table(
"helpdesk_message", metadata, # mirrors the Django migration
Column("id", UUID, primary_key=True),
Column("tenant_id", UUID), # the RLS key
Column("ticket_id", UUID),
Column("body", Text),
Column("custom_fields", JSONB),
Column("created_at", TIMESTAMP(timezone=True)),
)
@asynccontextmanager
async def tenant_session(tenant_id: str):
"""Pin a connection to one tenant for exactly one transaction.
set_config(..., true) is transaction-local, so a pooled connection never
carries one tenant's identity into the next tenant's query.
"""
async with Session() as session:
async with session.begin(): # one transaction
await session.execute(
text("SELECT set_config('app.tenant_id', :tid, true)"),
{"tid": str(tenant_id)},
)
yield session
Three database roles enforce the boundary at the database, not by convention:
-- 1) migrator: owns DDL and has BYPASSRLS; used only by `manage.py migrate`.
-- 2) django_app: runtime DML, subject to RLS.
-- 3) litestar_app: runtime DML on a few tables, subject to RLS.
CREATE ROLE migrator LOGIN BYPASSRLS;
CREATE ROLE django_app LOGIN; -- NOT owner, NOT bypassrls
CREATE ROLE litestar_app LOGIN; -- NOT owner, NOT bypassrls
GRANT USAGE ON SCHEMA public TO django_app, litestar_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA public TO django_app;
GRANT SELECT, INSERT, UPDATE ON helpdesk_message TO litestar_app;
ENABLE ROW LEVEL SECURITY makes the policies apply to non-owner roles — your two runtime roles. FORCE ROW LEVEL SECURITY additionally applies them to the owner, which covers any accidental owner connection. Because neither app role owns the tables nor holds BYPASSRLS, both are fully fenced, and because the migrator does hold BYPASSRLS, migrations don’t fight the policies. This is the clean version of the migration/RLS tension flagged in the gotchas.
Run manage.py migrate once per deploy as the migrator role (a one-shot Job) before either service rolls. Litestar never runs DDL, so there’s a single schema history and no Alembic-versus-Django conflict.
Shared Redis: The Event Bridge (Never Import Each Other)
Redis is how the two runtimes talk without a shared package. Version the envelope and carry the tenant:
{ "v": 1, "type": "ticket.created", "tenant_id": "b2f...", "ts": "2026-10-08T12:00:00Z",
"payload": { "ticket_id": "9c1...", "channel": "slack" } }
Django publishes control events only after the transaction commits, so it never announces a change that rolled back:
# tenants/events.py
import json
import redis
from django.db import transaction
r = redis.Redis.from_url("redis://redis:6379")
def publish_event(event_type: str, tenant_id, payload: dict) -> None:
message = json.dumps(
{"v": 1, "type": event_type, "tenant_id": str(tenant_id), "payload": payload}
)
transaction.on_commit(lambda: r.publish(f"evt:{event_type}", message))
Litestar subscribes and reacts — invalidate a cached tenant config, refresh presence, or push a live “new ticket” notice to the agents already on a socket:
# webhooks/events.py
import orjson
import redis.asyncio as redis
async def consume_domain_events() -> None:
client = redis.from_url("redis://redis:6379")
pubsub = client.pubsub()
await pubsub.psubscribe("evt:*") # evt:ticket.created, evt:tenant.settings.changed
async for msg in pubsub.listen():
if msg["type"] != "pmessage":
continue
event = orjson.loads(msg["data"])
await handle_domain_event(event["type"], event["tenant_id"], event["payload"])
This app already uses Redis in the other direction: the ingest Stream carries heavy webhook payloads (Litestar → Celery). Keep the two mentally separate. Streams are for durable work you must not lose; pub/sub is for lightweight control events where a missed message is harmless (a config refresh self-heals via its TTL). Both use the same versioned, tenant-namespaced envelope, so “who publishes” is a per-event choice, not an architectural one. And every consumer stays idempotent, because at-least-once means the same event can arrive twice.
Never publish raw webhook bodies over pub/sub, and never key a channel without the tenant id.
Auth Bridging: Django Issues, Litestar Verifies — and the Tenant Comes From the Token
This app has two auth paths, and they don’t overlap:
- Humans (agents/admins) authenticate against Django and carry a JWT to Litestar for
/apiand/ws. - Machines (email/Slack/WhatsApp) never see a JWT; they’re verified by provider HMAC + a signed tenant token on the webhook URL, as covered earlier.
For the human path, issue JWTs from Django with djangorestframework-simplejwt:
# settings.py (Django)
INSTALLED_APPS += ["rest_framework", "rest_framework_simplejwt"]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
}
from datetime import timedelta
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ALGORITHM": "HS256",
"SIGNING_KEY": env("JWT_SIGNING_KEY"), # shared with Litestar
"AUDIENCE": "api",
"ISSUER": "django",
}
# urls.py (Django)
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns += [
path("app/api/token/", TokenObtainPairView.as_view()),
path("app/api/token/refresh/", TokenRefreshView.as_view()),
]
Litestar verifies the token with the same key, pinning algorithm and claims:
# webhooks/auth.py
import os
import jwt
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException
from litestar.middleware import AbstractAuthenticationMiddleware, AuthenticationResult
JWT_SECRET = os.environ["JWT_SIGNING_KEY"]
class JWTAuth(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
scheme, _, token = connection.headers.get("authorization", "").partition(" ")
if scheme.lower() != "bearer" or not token:
raise NotAuthorizedException("missing bearer token")
try:
claims = jwt.decode(
token,
JWT_SECRET,
algorithms=["HS256"], # never trust the token's own `alg`
audience="api",
issuer="django",
)
except jwt.PyJWTError as exc:
raise NotAuthorizedException("invalid token") from exc
return AuthenticationResult(user=claims, auth=claims)
Here’s the tenant rule that makes this safe: the token’s tenant_id claim is set server-side, at login or when the user switches their active tenant, after verifying that user’s Membership. Litestar then trusts the claim — not a URL or body parameter — and uses it to set app.tenant_id for every query. That’s the difference between “the client told us which tenant it is” and “the server decided which tenant this user may act as.”
# webhooks/deps.py — the claim drives the RLS context
async def current_tenant_id(claims: dict) -> str:
tenant_id = claims.get("tenant_id")
if not tenant_id:
raise NotAuthorizedException("no active tenant in token")
return tenant_id
If you’d rather Litestar never hold a key that can mint tokens, use RS256/EdDSA: Django signs with a private key, Litestar verifies with the public key (or a JWKS endpoint). Same code, algorithms=["RS256"].
Two practical notes:
- Access tokens are stateless. For logout/tenant-switch revocation, put the token’s
jtion a short Redis denylist and check it during verification. Keep access tokens short; make refresh tokens revocable. - WebSocket handshakes can’t carry an
Authorizationheader from the browser. Pass the JWT viaSec-WebSocket-Protocol(preferred) or a query param (beware logs), validate it inon_accept, and only then register the agent’s presence under the tenant from the token.
The alternative — Redis-backed sessions — is lighter when both apps are same-origin: set SESSION_ENGINE = "django.contrib.sessions.backends.cache" with django-redis, then Litestar reads the sessionid cookie and loads the session blob from Redis. Less code, but it couples Litestar to Django’s internal session format and makes each auth check a Redis round-trip. For cross-origin APIs and agent WebSockets, JWT is the cleaner seam.
Deployment: Nginx/Traefik, One Front Door
Expose one hostname and route by path: Django on /admin and /app, Litestar on /api, /ws, and /webhooks. Give the webhook route a bigger body limit and no session middleware.
upstream django { server django:8000; }
upstream litestar { server litestar:8000; }
server {
listen 443 ssl;
server_name app.example.com;
location /admin/ { proxy_pass http://django; }
location /app/ { proxy_pass http://django; }
location /api/ { proxy_pass http://litestar; }
# Machine-facing webhooks: large bodies, no cookies needed.
location /webhooks/ {
proxy_pass http://litestar;
client_max_body_size 10m; # inbound email can carry attachments
}
# Agent presence over WebSocket.
location /ws/ {
proxy_pass http://litestar;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
The same split in Traefik, driven by Docker labels:
services:
django:
labels:
- "traefik.enable=true"
- "traefik.http.routers.django.rule=Host(`app.example.com`) && (PathPrefix(`/admin`) || PathPrefix(`/app`))"
- "traefik.http.services.django.loadbalancer.server.port=8000"
litestar:
labels:
- "traefik.enable=true"
- "traefik.http.routers.litestar.rule=Host(`app.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/ws`) || PathPrefix(`/webhooks`))"
- "traefik.http.services.litestar.loadbalancer.server.port=8000"
Scale Litestar independently of Django — the webhook endpoint is stateless, so replicas are trivial — and scale Celery consumers by adding workers to the consumer group. Django stays small, because admin and billing traffic is nothing compared to ingestion. Run migrations once as the migrator role before rollout, give each service a /healthz, and let the edge apply a coarse per-tenant rate limit before the app even sees the request.
That’s the whole contract: Postgres for tenant-fenced truth (Django migrates, both roles are RLS-subject), Redis for events (either side publishes, always tenant-namespaced), JWT for identity (Django issues, Litestar verifies, the tenant is a server-side claim), one reverse proxy for routing. Four seams — and the tenant is enforced on every one of them.
The Gotchas Nobody Mentions First
Row-level Security (RLS) is off for table owners until you FORCE it. ENABLE ROW LEVEL SECURITY alone leaves the owner exempt, and your app role is often the owner. FORCE ROW LEVEL SECURITY is not optional; without it, you have the appearance of isolation.
SET leaks across a pooled connection; SET LOCAL doesn’t. If you set app.tenant_id with a session-level SET and the connection goes back to a pooler, the next unlucky request inherits the previous tenant’s identity. Always set it inside a transaction (set_config(..., true)) so it resets on commit/rollback. If you use PgBouncer in transaction mode, this is the only safe pattern.
Migrations and RLS fight each other. A migration running as the owner under FORCE RLS can’t see rows it needs to backfill. Run migrations as a separate role with BYPASSRLS, and keep the app role unprivileged.
Verify signatures before parsing, and compare in constant time. Read the raw bytes, verify the HMAC with compare_digest, then parse JSON. And validate the signature timestamp on providers that send one, or a leaked old payload can be replayed forever.
Idempotency is not optional. Webhooks are at-least-once. Dedupe on the provider’s event id at the edge (SETNX), and belt-and-suspenders it with a per-tenant unique index on provider_event_id. Providers will retry, sometimes minutes later.
The noisy neighbor is a real tenant. One tenant’s webhook flood shouldn’t degrade everyone. Per-tenant rate limits in Redis, MAXLEN caps on the stream, and per-tenant queue depth limits are what keep isolation meaningful at the infrastructure layer, not just the data layer.
Cache keys must be tenant-namespaced, always. tenant:{id}:config, presence:tenant:{id}:…, dedupe:{tenant}:{event}. On shared Redis, a missing prefix is a cross-tenant leak with no RLS to catch it.
JSONB drifts. Store a per-tenant custom-field schema in Tenant.settings, validate on write, and reject unknown keys. Otherwise your GIN index is fast and correct about a field nobody can query reliably.
Never trust a client-supplied tenant id. Derive the tenant from the authenticated session (Django) or a signed, rotatable token plus provider signature (Litestar). The tenant is a server-side decision, never a request parameter.
TL;DR
- Django is the back office: tenant management, ticket CRUD, billing, auth, and an admin you didn’t have to write.
- Litestar is the wire: verify signatures, dedupe on the provider event id,
XADDto a Redis Stream, return202— never block the webhook on the database. - Redis is three tools: a Stream (with consumer groups) for the ingest queue, heartbeats with TTL for presence, and a tenant-namespaced cache for configs. Always prefix keys with the tenant.
- Postgres enforces the boundary:
ENABLEandFORCERLS, policies that readapp.tenant_id,SET LOCALinside a transaction so pooled connections never leak identity, and JSONB + GIN for tenant-defined fields. - Celery does the slow work: parse provider payloads, find-or-create tickets, append messages, and recover from crashes via the stream’s pending-entry list.
- The invariant that makes it safe: isolation lives below the application — RLS in the database, tenant-namespaced keys in Redis, and the tenant id derived server-side, never sent by the client.
Get those three layers right and “multi-tenant” stops being a constant source of cross-tenant bugs and becomes just… a column, with teeth.
Comments
Leave a comment