Multi-Tenant Platform Skill
When to Use
Invoke this skill when the task involves any of the following:
- Architecture design for multi-tenant SaaS platforms
- Tenant hierarchy modeling (platform, partner/reseller, customer)
- Data isolation strategy (RLS, schema-per-tenant, DB-per-tenant)
- White-label / co-branded platforms with theming autonomy
- Security architecture for tenant-scoped access control and audit
- New project scaffolding that needs tenant-aware foundations
Even single-tenant or "simple" projects use this model with dormant tiers -- a hardcoded tenant_id at T2 with T0 and T1 collapsed. This ensures every project can grow into multi-tenancy without a rewrite.
The 3-Tier Model
T0 Platform Operator
|
T1 Partner / Reseller (one or many)
|
T2 Customer / End-User Org (many per T1)
| Tier | Role | Examples |
|---|---|---|
| T0 | Platform operator. Owns infrastructure, defines global policies, manages T1 onboarding. | SyncUpSuite, the platform operator |
| T1 | Partner or reseller. Operates a branded instance, manages their own T2 customers. | A consulting firm reselling the platform |
| T2 | Customer organization. End-users belong here. All application data lives at this tier. | A law firm, a brand team, a small business |
Governance Rules
- T0 can see and manage everything (god mode, audit-logged).
- T1 can see its own data and all its T2 children. Never sibling T1s.
- T2 can see only its own data. Never its parent T1, never sibling T2s.
- Delegation flows downward only: T0 delegates to T1, T1 delegates to T2.
- A tenant's
parent_idis immutable after creation (reparenting requires migration).
See references/governance-rules.md for the full governance specification.
Non-Negotiable Rules
These rules apply to every project that uses this skill, regardless of complexity:
Every entity has
tenant_id. No exceptions. Even lookup tables get atenant_idcolumn (or are explicitly marked as global/platform-owned withtenant_id = T0_ID).Defense-in-depth isolation. Never rely on a single layer. Combine: application-level filtering + PostgreSQL RLS + connection-level context (
SET app.tenant_id). If one layer fails, the others catch it.Semantic tokens only. UI components should not reference raw colors or hardcoded values. All theming goes through semantic design tokens (
--color-surface-primary, not--blue-500).Protected tokens. Certain tokens (accessibility contrast ratios, minimum font sizes, spacing rhythm) are platform-controlled and cannot be overridden by T1 or T2 themes. This exists for compliance and accessibility: status colors must remain distinguishable for colorblind users, focus indicators must meet WCAG 2.1 requirements, and spacing rhythm must preserve readability. Without protection, a single tenant's brand override could break accessibility for their entire user base.
WCAG AA minimum. All tenant themes must pass WCAG AA contrast checks. The platform enforces this at token generation time, not at runtime.
Audit everything. Every data mutation, every auth event, every tenant context switch is written to an append-only audit log with
actor_id,tenant_id,action,timestamp, andmetadata.Version all changes. Tenant settings, themes, and configuration changes are versioned. Rollback is always possible.
Workflow
When designing or implementing a multi-tenant feature:
1. Gather Requirements
- How many tiers are active? (All three, or dormant T0/T1?)
- What isolation mode per tier? (See below)
- What data crosses tenant boundaries? (Shared catalogs, marketplace, etc.)
- What branding autonomy does each tier have?
- Are there data residency requirements?
2. Generate Architecture Document
Produce a structured architecture doc covering:
- Tenant hierarchy diagram
- Data isolation strategy per tier
- Auth and authorization model
- Token/theming architecture
- Migration and onboarding flows
3. Generate Diagrams (Mermaid)
All architecture docs include Mermaid diagrams for:
- Tenant hierarchy (entity relationship)
- Request flow (sequence diagram showing tenant resolution)
- Data isolation boundaries (deployment diagram)
- Token inheritance chain
4. Implement
Use the templates in this skill as starting points:
templates/tenant-schema.sql-- PostgreSQL schematemplates/rls-policies.sql-- Row Level Securitytemplates/auth-middleware.ts-- Cloudflare Workers middleware
Data Isolation Modes
| Mode | Mechanism | Best For | Trade-offs |
|---|---|---|---|
| Shared Schema + RLS | Single schema, tenant_id column, PostgreSQL RLS policies |
T2 customers (high volume, low isolation need) | Lowest cost, highest density; noisy-neighbor risk |
| Schema per Tenant | Separate PostgreSQL schema per tenant, same database | Mid-tier isolation needs | Moderate isolation; schema migration complexity |
| DB per Tenant | Separate Neon branch or database per tenant | T1 partners (strong isolation, regulatory) | Strongest isolation; higher operational cost |
Default Strategy: DB-per-T1, RLS-per-T2
T0 Platform DB (management plane)
|
+-- T1-alpha DB (Neon branch)
| +-- T2 customers (RLS within this DB)
|
+-- T1-beta DB (Neon branch)
+-- T2 customers (RLS within this DB)
Each T1 partner gets their own Neon database branch. Within that branch, T2 customers share the schema and are isolated via RLS. This gives T1 partners strong isolation (separate connection strings, independent scaling, easy data export) while keeping T2 management lightweight.
See references/isolation-modes.md for detailed comparison and migration paths.
Security Model
Authentication
- Protocol: OIDC via Better Auth
- Session: JWT-based, 7-day expiry, stored in httpOnly cookie
- Multi-tenant: Session token includes
tenant_idclaim; user-tenant mapping validated on every request
Authorization: RBAC + ABAC Hybrid
Permission = Role(user, tenant) + Attributes(resource, context)
- RBAC handles coarse-grained access:
platform_admin,partner_admin,org_admin,member,viewer - ABAC handles fine-grained rules: resource ownership, time-based access, data classification level
- Roles are scoped to a tenant. A user can have different roles in different tenants.
- Permission checks always include tenant context. No "global" permissions except for T0 platform admins.
Audit Requirements
Every auditable event includes:
interface AuditEntry {
id: string;
timestamp: string; // ISO 8601
actor_id: string; // Who performed the action
tenant_id: string; // Which tenant context
action: string; // e.g., "user.create", "tenant.settings.update"
resource_type: string; // e.g., "user", "document", "tenant_settings"
resource_id: string; // ID of the affected resource
metadata: Record<string, unknown>; // Action-specific details
ip_address: string;
user_agent: string;
}
Audit logs are append-only, partitioned by tenant, and retained for a minimum of 7 years for compliance.
Theming Performance Targets
| Metric | Target |
|---|---|
| Core token payload (critical CSS variables) | < 20 KB gzipped |
| Theme switch latency (T2 theme applied) | < 200 ms |
| First Contentful Paint with tenant theme | < 1.8 s |
| Token inheritance resolution | < 50 ms |
Token Loading Strategy
Tokens load in four phases to optimize perceived performance:
Immediate (inline in HTML
<head>): Platform base tokens + tenant's critical overrides (brand colors, typography scale). Blocks render but is < 5 KB.Fast (preloaded CSS, loaded before FCP): Full semantic token set for the resolved tenant. Loaded via
<link rel="preload">.Lazy (loaded after FCP): Component-specific token extensions, dark mode variants, density options. Loaded on demand.
Background (loaded after idle): Analytics tokens, A/B test variants, seasonal overrides. Loaded via
requestIdleCallback.
Standard Stack
All projects using this skill target:
| Layer | Technology |
|---|---|
| Frontend | React 19 + TypeScript + Tailwind CSS 4 |
| Build | Vite 7 |
| Edge Runtime | Cloudflare Workers |
| Database | Neon PostgreSQL |
| ORM | Drizzle ORM |
| Auth | Better Auth |
| Secrets | Doppler |
| Design Tokens | Style Dictionary (or custom pipeline) |
Why This Structure
The 3-tier model maps to a specific cost structure. Understanding this helps with capacity planning.
- Shared Neon database: One database project with branch isolation for dev/preview environments. You pay for one database, not one per tenant. Neon branches are copy-on-write, so storage is shared for unchanged data.
- Hyperdrive connection pooling: One Hyperdrive configuration serves all tenants at the edge. Workers reuse pooled TCP connections instead of opening new ones per request. This avoids per-tenant connection overhead.
- Single Worker deployment: One codebase deployed to Cloudflare Workers. Tenant identity is resolved at request time from the hostname or session. There is no per-tenant deployment step.
- Token CSS via CDN: Theme CSS files are static assets served from the edge cache. Switching tenants swaps a CSS import, not a server-side render.
The result: infrastructure costs stay roughly flat as tenant count grows. Adding a new T2 tenant is a database row and a DNS record, not a new deployment. The cost scales with traffic and storage, not with tenant count.
Multi-Domain Tenant Resolution
When tenants operate under their own domains (white-label), the platform resolves tenant identity from the incoming request hostname.
Domain Mappings
Each tenant can have multiple domains with different roles:
// Domain type enum
type DomainType = 'primary' | 'alias' | 'vanity';
// Domain mapping record
interface DomainMapping {
domain: string; // e.g., "app.clientbrand.com"
tenant_id: string; // Resolved tenant
domain_type: DomainType;
verified: boolean; // DNS ownership confirmed
ssl_status: string; // Certificate provisioning state
}
| Type | Purpose | Example |
|---|---|---|
primary |
The canonical domain for the tenant | app.clientbrand.com |
alias |
Alternative domains that resolve to the same tenant | clientbrand.example.com |
vanity |
Short or branded URLs for specific features | go.clientbrand.com |
Tenant Resolution Middleware
On every incoming request, the Worker resolves the tenant before any application logic runs:
- Extract hostname from the request
- Look up
domain_mappingsfor a matching, verified domain - If found, set
tenant_idin the request context - If not found, fall back to the platform's default domain handling (show landing page or 404)
// Simplified resolution flow
async function resolveTenant(request: Request, db: DrizzleClient): Promise<string | null> {
const hostname = new URL(request.url).hostname;
const mapping = await db.select()
.from(domainMappings)
.where(and(
eq(domainMappings.domain, hostname),
eq(domainMappings.verified, true)
))
.limit(1);
return mapping[0]?.tenant_id ?? null;
}
Domain verification uses a DNS TXT record: the tenant adds a _syncup-verify.domain.com TXT record containing a platform-issued token. The platform checks this before marking the domain as verified.
Reporting Issues
If RLS policies don't apply as expected or tenant isolation fails during testing, use the feedback command to capture the issue for the platform team.
Related Skills
theme-inspired-tokens-- Design token generation and cultural compliancegraduated-auth-- Progressive authentication complexityneon-multi-tenant-- Neon-specific branch isolation patterns