Skip to main content

Overview

Trending Society is a white-label multi-tenant platform. Each tenant (organization) has completely isolated data while sharing the same infrastructure.
Current Status: 42 of 112 tenant-scoped tables have tenant_id columns (37.5% complete). Migration tracked in ENG-206.

Core Concepts

Tenant

An organization with isolated data (e.g., “Golf Insider”, “Trending Society”)

Tenant Member

A user belonging to one or more tenants

RLS

PostgreSQL Row Level Security enforcing data isolation

White-Label

Custom branding per tenant (subdomain, colors, logo)

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                    MULTI-TENANT ARCHITECTURE                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   Tenant A (Golf Insider)        Tenant B (Trending Society)   │
│   ────────────────────────       ─────────────────────────────  │
│   golf.trendingsociety.com       app.trendingsociety.com       │
│   Custom branding                Default branding               │
│   tenant_id = 'golf-123'         tenant_id = 'ts-456'          │
│                                                                 │
│   ┌──────────────────────────────────────────────────────────┐ │
│   │              SHARED DATABASE (with RLS)                   │ │
│   │                                                           │ │
│   │  SELECT * FROM content WHERE tenant_id = current_tenant() │ │
│   │  ↓                                                        │ │
│   │  Returns ONLY current tenant's data                       │ │
│   └──────────────────────────────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Database Schema

Core Tenant Tables

-- Root tenant table
CREATE TABLE public.tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  domain TEXT,
  branding JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT now()
);

-- User-to-tenant mapping
CREATE TABLE public.tenant_members (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID REFERENCES tenants(id),
  user_id UUID REFERENCES auth.users(id),
  role TEXT DEFAULT 'member',
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE(tenant_id, user_id)
);

Adding tenant_id to Tables

1

Add Column

ALTER TABLE public.your_table
ADD COLUMN tenant_id UUID REFERENCES tenants(id);
2

Backfill Data

UPDATE public.your_table
SET tenant_id = 'default-tenant-uuid'
WHERE tenant_id IS NULL;
3

Make Required

ALTER TABLE public.your_table
ALTER COLUMN tenant_id SET NOT NULL;
4

Add RLS Policy

ALTER TABLE public.your_table ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Tenant isolation"
ON public.your_table
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::uuid);

Table Classification

Every table falls into one of these categories:
Classificationtenant_id?RLS?Example
TENANT_SCOPEDYesYescontent, orders, users
GLOBAL_REFERENCENoNoplatforms, ai_model_config
SYSTEM_INFRANoNocircuit_breakers, checkpoints
ROOT_TENANTNoNotenants, tenant_members
IDENTITYNoRLSauth.users (Supabase managed)
112 tables are classified as TENANT_SCOPED and should have tenant_id. Currently only 42 do. This is a known gap.

Getting Tenant Context

Server Components (Next.js)

import { getTenantId } from '@/lib/tenant/server';

export default async function Page() {
  const tenantId = await getTenantId();

  const { data } = await supabase
    .from('content')
    .select('*');
  // RLS automatically filters by tenant_id

  return <ContentList data={data} />;
}

API Routes

import { getTenantId } from '@/lib/tenant/server';

export async function GET() {
  const tenantId = await getTenantId();

  // Use tenantId for logging, metrics, etc.
  // RLS handles data filtering

  return Response.json({ tenantId });
}

Edge Functions

// In Edge Functions, set tenant context explicitly
const { data, error } = await supabase.rpc('set_tenant_context', {
  p_tenant_id: tenantId
});

// Now queries are scoped
const { data: content } = await supabase
  .from('content')
  .select('*');

Handler Factory Pattern

For shared API logic, use the handler factory pattern:
// packages/api/src/shopify/handlers.ts
export function createKPIsHandler(getTenantId: () => Promise<string>) {
  return async function handler(request: Request) {
    const tenantId = await getTenantId();

    // Get tenant's Shopify credentials
    const { data: integration } = await supabase
      .from('tenant_integrations')
      .select('credentials')
      .eq('tenant_id', tenantId)
      .eq('platform', 'shopify')
      .single();

    // Fetch from Shopify using tenant's credentials
    const kpis = await fetchShopifyKPIs(integration.credentials);

    return Response.json(kpis);
  };
}

// apps/dashboard/src/app/api/shopify/kpis/route.ts
import { createKPIsHandler } from '@trendingsociety/api/shopify';
import { getTenantId } from '@/lib/tenant/server';

export const GET = createKPIsHandler(getTenantId);
This pattern keeps business logic in packages/ while apps only contain thin wrappers that inject tenant context.

Schema Security Boundaries

These rules are non-negotiable. Security incidents start here.
SchemaPurposeAccessRLS
publicProduct dataauth + anon + service_roleRequired
systemInfrastructureservice_role ONLYNone
eventsAudit logsservice_role ONLYNone

Invariants

  1. No user access to system schema. Ever. No exceptions.
  2. No user access to events schema. Create filtered VIEWs in public if needed.
  3. events tables are append-only. No UPDATE. No DELETE.
  4. Every public table has RLS enabled. Default deny.

Tenant Integrations

Each tenant can have their own integrations:
CREATE TABLE public.tenant_integrations (
  id UUID PRIMARY KEY,
  tenant_id UUID REFERENCES tenants(id),
  platform TEXT NOT NULL, -- 'shopify', 'linear', etc.
  credentials JSONB NOT NULL, -- encrypted
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE(tenant_id, platform)
);
Credentials are stored encrypted using Supabase Vault (pgsodium).

White-Label Branding

Tenants can customize their experience:
{
  "branding": {
    "logo": "/uploads/tenant-logo.svg",
    "primaryColor": "#1a73e8",
    "accentColor": "#4285f4",
    "favicon": "/uploads/tenant-favicon.ico"
  }
}
Applied via CSS variables:
:root {
  --primary: var(--tenant-primary, #667eea);
  --accent: var(--tenant-accent, #764ba2);
}

Testing Multi-Tenant

describe('Multi-tenant isolation', () => {
  it('should only return current tenant data', async () => {
    // Create data for tenant A
    await supabase.rpc('set_tenant_context', { p_tenant_id: tenantA });
    await supabase.from('content').insert({ title: 'Tenant A Content' });

    // Switch to tenant B
    await supabase.rpc('set_tenant_context', { p_tenant_id: tenantB });
    const { data } = await supabase.from('content').select('*');

    // Tenant B should not see Tenant A's data
    expect(data).toHaveLength(0);
  });
});