Multi-tenant architecture
UCRM is multi-tenant at the database layer via Postgres Row-Level Security (RLS). Every tenant-scoped table has an RLS policy that filters by current_setting('app.tenant_id') — set per-request from the authenticated session. Cross-tenant data leakage is impossible without explicit SET ROLE to a privileged role (used only by webhook routing helpers + admin platform tools).
What's tenant-isolated
Every domain table:
players+player_wallets+player_tier_history+kyc_historysegments+messaging_providers+message_templatescampaigns+campaign_variants+message_sendsjourneys+journey_runs+journey_node_runsbonus_templates+bonus_grants+wagering_events+bonus_campaignsvip_tierscurrencies+fx_ratesaudit_logsadapter_configs
Cross-tenant tables (no RLS):
tenants(the SSOT for tenant rows)users+tenant_memberships(auth wiring)webhook_events_processed(idempotency ledger — pre-auth dedupe)__ucrm_migrations(schema version)
How requests are scoped
- Admin user authenticates via Clerk → middleware resolves
(user_id, tenant_id)from thetenant_membershipsrow. - The request handler wraps DB access in
withTenant(tenantId, async (tx) => …), whichBEGIN+SET LOCAL ROLE ucrm_app+SET LOCAL app.tenant_id = $tenant. - Every query inside the closure is RLS-filtered automatically.
For server-to-server endpoints (/v1/internal/*), the caller passes tenant_id in the body + the handler uses withTenant(body.tenant_id, …). The internal-secret middleware authenticates that the caller is a legit UCRM service.
Why this matters
- No accidental cross-tenant exposure — even a SQL injection that bypassed Drizzle wouldn't leak data outside the tenant context.
- Per-tenant disaster recovery — drop a single tenant via
DELETE FROM tenants WHERE id = ?cascades through every domain table. - Self-host friendly — operators can run UCRM with one tenant + still benefit from the isolation pattern (futureproofing for resellers / white-label).