- Add 100 blog posts covering AI, development, and tech topics - Add .env.example for environment configuration - Add accessibility and lighthouse audit scripts - Remove obsolete SEO reports and temporary files - Remove dev-dist build artifacts and backup files - Remove unused portrait images (moved/consolidated elsewhere) - Update contact form and component improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
620 lines
18 KiB
Markdown
620 lines
18 KiB
Markdown
# Multi-Tenancy Architecture
|
||
|
||
**Meta-Description:** Multi-Tenant SaaS Architektur. Database Isolation, Tenant Context, Row-Level Security und Scaling Strategien.
|
||
|
||
**Keywords:** Multi-Tenancy, SaaS Architecture, Database Isolation, Row-Level Security, Tenant, B2B, Scaling
|
||
|
||
---
|
||
|
||
## Einführung
|
||
|
||
**Multi-Tenancy** ermöglicht es, eine Anwendung für viele Kunden (Tenants) zu betreiben. Von **Database per Tenant** bis **Row-Level Security** – die richtige Strategie hängt von Sicherheit, Kosten und Skalierung ab.
|
||
|
||
---
|
||
|
||
## Multi-Tenancy Overview
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ MULTI-TENANCY STRATEGIES │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 1. DATABASE PER TENANT: │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ [App] → [Tenant A DB] [Tenant B DB] [Tenant C DB] │ │
|
||
│ │ │ │
|
||
│ │ ✓ Maximum isolation │ │
|
||
│ │ ✓ Easy data migration │ │
|
||
│ │ ✗ Higher infrastructure cost │ │
|
||
│ │ ✗ Complex connection management │ │
|
||
│ │ Use case: Enterprise, regulated industries │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 2. SCHEMA PER TENANT: │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ [Database] │ │
|
||
│ │ ├── schema_tenant_a │ │
|
||
│ │ ├── schema_tenant_b │ │
|
||
│ │ └── schema_tenant_c │ │
|
||
│ │ │ │
|
||
│ │ ✓ Good isolation │ │
|
||
│ │ ✓ Easier backup per tenant │ │
|
||
│ │ ✗ Schema migrations complexity │ │
|
||
│ │ Use case: Mid-size SaaS │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 3. SHARED DATABASE (Row-Level): │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ [Database] │ │
|
||
│ │ └── [Tables with tenant_id column] │ │
|
||
│ │ ├── users (tenant_id, ...) │ │
|
||
│ │ ├── projects (tenant_id, ...) │ │
|
||
│ │ └── documents (tenant_id, ...) │ │
|
||
│ │ │ │
|
||
│ │ ✓ Cost efficient │ │
|
||
│ │ ✓ Simple deployment │ │
|
||
│ │ ✗ Requires careful query design │ │
|
||
│ │ Use case: SMB SaaS, high tenant count │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 4. HYBRID APPROACH: │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ - Shared DB for small tenants │ │
|
||
│ │ - Dedicated DB for enterprise tenants │ │
|
||
│ │ ✓ Flexible pricing tiers │ │
|
||
│ │ ✓ Meet enterprise requirements │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Tenant Context
|
||
|
||
```typescript
|
||
// lib/tenant/context.ts
|
||
import { AsyncLocalStorage } from 'async_hooks';
|
||
|
||
export interface TenantContext {
|
||
tenantId: string;
|
||
tenantSlug: string;
|
||
plan: string;
|
||
features: string[];
|
||
limits: Record<string, number>;
|
||
}
|
||
|
||
const tenantStorage = new AsyncLocalStorage<TenantContext>();
|
||
|
||
export function getTenantContext(): TenantContext {
|
||
const context = tenantStorage.getStore();
|
||
if (!context) {
|
||
throw new Error('Tenant context not initialized');
|
||
}
|
||
return context;
|
||
}
|
||
|
||
export function runWithTenant<T>(
|
||
tenant: TenantContext,
|
||
fn: () => T
|
||
): T {
|
||
return tenantStorage.run(tenant, fn);
|
||
}
|
||
|
||
// Optional: Get tenant without throwing
|
||
export function getTenantContextSafe(): TenantContext | null {
|
||
return tenantStorage.getStore() || null;
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// middleware.ts - Tenant Resolution
|
||
import { NextResponse } from 'next/server';
|
||
import type { NextRequest } from 'next/server';
|
||
|
||
export async function middleware(request: NextRequest) {
|
||
const response = NextResponse.next();
|
||
|
||
// Strategy 1: Subdomain-based
|
||
const hostname = request.headers.get('host') || '';
|
||
const subdomain = hostname.split('.')[0];
|
||
|
||
if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
|
||
response.headers.set('x-tenant-slug', subdomain);
|
||
}
|
||
|
||
// Strategy 2: Path-based (/org/tenant-slug/...)
|
||
const pathname = request.nextUrl.pathname;
|
||
const pathMatch = pathname.match(/^\/org\/([^\/]+)/);
|
||
|
||
if (pathMatch) {
|
||
response.headers.set('x-tenant-slug', pathMatch[1]);
|
||
}
|
||
|
||
// Strategy 3: Header-based (for API)
|
||
const headerTenant = request.headers.get('x-tenant-id');
|
||
if (headerTenant) {
|
||
response.headers.set('x-tenant-id', headerTenant);
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
// lib/tenant/resolver.ts
|
||
import { db } from '@/lib/db';
|
||
import { TenantContext } from './context';
|
||
|
||
export async function resolveTenant(
|
||
identifier: string,
|
||
type: 'slug' | 'id' = 'slug'
|
||
): Promise<TenantContext | null> {
|
||
const tenant = await db.tenant.findUnique({
|
||
where: type === 'slug' ? { slug: identifier } : { id: identifier },
|
||
include: {
|
||
subscription: true,
|
||
features: true
|
||
}
|
||
});
|
||
|
||
if (!tenant) return null;
|
||
|
||
return {
|
||
tenantId: tenant.id,
|
||
tenantSlug: tenant.slug,
|
||
plan: tenant.subscription?.plan || 'free',
|
||
features: tenant.features.map(f => f.name),
|
||
limits: {
|
||
users: tenant.subscription?.userLimit || 5,
|
||
storage: tenant.subscription?.storageLimit || 1024,
|
||
projects: tenant.subscription?.projectLimit || 10
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Database with Row-Level Security
|
||
|
||
```typescript
|
||
// prisma/schema.prisma
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
previewFeatures = ["multiSchema"]
|
||
}
|
||
|
||
model Tenant {
|
||
id String @id @default(cuid())
|
||
slug String @unique
|
||
name String
|
||
createdAt DateTime @default(now())
|
||
|
||
// Relations
|
||
users User[]
|
||
projects Project[]
|
||
subscription Subscription?
|
||
}
|
||
|
||
model User {
|
||
id String @id @default(cuid())
|
||
email String
|
||
name String?
|
||
|
||
// Tenant relation
|
||
tenantId String
|
||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||
|
||
@@unique([tenantId, email])
|
||
@@index([tenantId])
|
||
}
|
||
|
||
model Project {
|
||
id String @id @default(cuid())
|
||
name String
|
||
description String?
|
||
|
||
// Tenant relation
|
||
tenantId String
|
||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||
|
||
// Other relations
|
||
documents Document[]
|
||
|
||
@@index([tenantId])
|
||
}
|
||
|
||
model Document {
|
||
id String @id @default(cuid())
|
||
title String
|
||
content String?
|
||
|
||
// Both tenant and project for redundant filtering
|
||
tenantId String
|
||
projectId String
|
||
project Project @relation(fields: [projectId], references: [id])
|
||
|
||
@@index([tenantId])
|
||
@@index([projectId])
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// lib/db/tenant-client.ts
|
||
import { PrismaClient } from '@prisma/client';
|
||
import { getTenantContext } from '@/lib/tenant/context';
|
||
|
||
// Extend Prisma with tenant filtering
|
||
function createTenantPrismaClient() {
|
||
const prisma = new PrismaClient();
|
||
|
||
return prisma.$extends({
|
||
query: {
|
||
$allModels: {
|
||
async findMany({ model, operation, args, query }) {
|
||
const context = getTenantContext();
|
||
|
||
// Models that need tenant filtering
|
||
const tenantModels = ['User', 'Project', 'Document', 'Team'];
|
||
|
||
if (tenantModels.includes(model)) {
|
||
args.where = {
|
||
...args.where,
|
||
tenantId: context.tenantId
|
||
};
|
||
}
|
||
|
||
return query(args);
|
||
},
|
||
|
||
async findFirst({ model, operation, args, query }) {
|
||
const context = getTenantContext();
|
||
const tenantModels = ['User', 'Project', 'Document', 'Team'];
|
||
|
||
if (tenantModels.includes(model)) {
|
||
args.where = {
|
||
...args.where,
|
||
tenantId: context.tenantId
|
||
};
|
||
}
|
||
|
||
return query(args);
|
||
},
|
||
|
||
async create({ model, operation, args, query }) {
|
||
const context = getTenantContext();
|
||
const tenantModels = ['User', 'Project', 'Document', 'Team'];
|
||
|
||
if (tenantModels.includes(model)) {
|
||
args.data = {
|
||
...args.data,
|
||
tenantId: context.tenantId
|
||
};
|
||
}
|
||
|
||
return query(args);
|
||
},
|
||
|
||
async update({ model, operation, args, query }) {
|
||
const context = getTenantContext();
|
||
const tenantModels = ['User', 'Project', 'Document', 'Team'];
|
||
|
||
if (tenantModels.includes(model)) {
|
||
args.where = {
|
||
...args.where,
|
||
tenantId: context.tenantId
|
||
};
|
||
}
|
||
|
||
return query(args);
|
||
},
|
||
|
||
async delete({ model, operation, args, query }) {
|
||
const context = getTenantContext();
|
||
const tenantModels = ['User', 'Project', 'Document', 'Team'];
|
||
|
||
if (tenantModels.includes(model)) {
|
||
args.where = {
|
||
...args.where,
|
||
tenantId: context.tenantId
|
||
};
|
||
}
|
||
|
||
return query(args);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
export const tenantDb = createTenantPrismaClient();
|
||
```
|
||
|
||
---
|
||
|
||
## PostgreSQL Row-Level Security
|
||
|
||
```sql
|
||
-- Enable RLS on tables
|
||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
|
||
|
||
-- Create policies
|
||
CREATE POLICY tenant_isolation_users ON users
|
||
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
||
|
||
CREATE POLICY tenant_isolation_projects ON projects
|
||
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
||
|
||
CREATE POLICY tenant_isolation_documents ON documents
|
||
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
||
|
||
-- Function to set tenant context
|
||
CREATE OR REPLACE FUNCTION set_tenant_context(tenant_id uuid)
|
||
RETURNS void AS $$
|
||
BEGIN
|
||
PERFORM set_config('app.current_tenant_id', tenant_id::text, false);
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
```
|
||
|
||
```typescript
|
||
// lib/db/rls-client.ts
|
||
import { Pool } from 'pg';
|
||
import { getTenantContext } from '@/lib/tenant/context';
|
||
|
||
const pool = new Pool({
|
||
connectionString: process.env.DATABASE_URL
|
||
});
|
||
|
||
export async function query<T>(
|
||
sql: string,
|
||
params?: unknown[]
|
||
): Promise<T[]> {
|
||
const client = await pool.connect();
|
||
const context = getTenantContext();
|
||
|
||
try {
|
||
// Set tenant context for RLS
|
||
await client.query(
|
||
"SELECT set_config('app.current_tenant_id', $1, true)",
|
||
[context.tenantId]
|
||
);
|
||
|
||
const result = await client.query(sql, params);
|
||
return result.rows as T[];
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
|
||
// Usage
|
||
const projects = await query<Project>(
|
||
'SELECT * FROM projects WHERE status = $1',
|
||
['active']
|
||
);
|
||
// RLS automatically filters by tenant!
|
||
```
|
||
|
||
---
|
||
|
||
## API Routes with Tenant
|
||
|
||
```typescript
|
||
// app/api/projects/route.ts
|
||
import { NextResponse } from 'next/server';
|
||
import { headers } from 'next/headers';
|
||
import { resolveTenant, runWithTenant } from '@/lib/tenant';
|
||
import { tenantDb } from '@/lib/db/tenant-client';
|
||
|
||
export async function GET(request: Request) {
|
||
const headersList = await headers();
|
||
const tenantSlug = headersList.get('x-tenant-slug');
|
||
|
||
if (!tenantSlug) {
|
||
return NextResponse.json({ error: 'Tenant not found' }, { status: 400 });
|
||
}
|
||
|
||
const tenant = await resolveTenant(tenantSlug);
|
||
if (!tenant) {
|
||
return NextResponse.json({ error: 'Invalid tenant' }, { status: 404 });
|
||
}
|
||
|
||
// Run with tenant context
|
||
const projects = await runWithTenant(tenant, async () => {
|
||
return tenantDb.project.findMany({
|
||
orderBy: { createdAt: 'desc' }
|
||
});
|
||
});
|
||
|
||
return NextResponse.json(projects);
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
const headersList = await headers();
|
||
const tenantSlug = headersList.get('x-tenant-slug');
|
||
|
||
const tenant = await resolveTenant(tenantSlug!);
|
||
if (!tenant) {
|
||
return NextResponse.json({ error: 'Invalid tenant' }, { status: 404 });
|
||
}
|
||
|
||
const body = await request.json();
|
||
|
||
// Check limits
|
||
const projectCount = await runWithTenant(tenant, async () => {
|
||
return tenantDb.project.count();
|
||
});
|
||
|
||
if (tenant.limits.projects !== -1 && projectCount >= tenant.limits.projects) {
|
||
return NextResponse.json(
|
||
{ error: 'Project limit reached. Please upgrade your plan.' },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
const project = await runWithTenant(tenant, async () => {
|
||
return tenantDb.project.create({
|
||
data: {
|
||
name: body.name,
|
||
description: body.description
|
||
}
|
||
});
|
||
});
|
||
|
||
return NextResponse.json(project, { status: 201 });
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Tenant Provisioning
|
||
|
||
```typescript
|
||
// lib/tenant/provisioning.ts
|
||
import { db } from '@/lib/db';
|
||
import { stripe } from '@/lib/stripe';
|
||
|
||
interface CreateTenantInput {
|
||
name: string;
|
||
slug: string;
|
||
ownerEmail: string;
|
||
ownerName: string;
|
||
plan?: string;
|
||
}
|
||
|
||
export async function provisionTenant(input: CreateTenantInput) {
|
||
// Validate slug
|
||
const existingTenant = await db.tenant.findUnique({
|
||
where: { slug: input.slug }
|
||
});
|
||
|
||
if (existingTenant) {
|
||
throw new Error('Tenant slug already exists');
|
||
}
|
||
|
||
// Create Stripe customer
|
||
const stripeCustomer = await stripe.customers.create({
|
||
email: input.ownerEmail,
|
||
name: input.name,
|
||
metadata: {
|
||
tenantSlug: input.slug
|
||
}
|
||
});
|
||
|
||
// Create tenant with owner in transaction
|
||
const tenant = await db.$transaction(async (tx) => {
|
||
// Create tenant
|
||
const newTenant = await tx.tenant.create({
|
||
data: {
|
||
name: input.name,
|
||
slug: input.slug,
|
||
stripeCustomerId: stripeCustomer.id,
|
||
settings: {
|
||
create: {
|
||
theme: 'light',
|
||
timezone: 'Europe/Berlin'
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Create owner user
|
||
await tx.user.create({
|
||
data: {
|
||
email: input.ownerEmail,
|
||
name: input.ownerName,
|
||
tenantId: newTenant.id,
|
||
role: 'owner'
|
||
}
|
||
});
|
||
|
||
// Create default resources
|
||
await tx.project.create({
|
||
data: {
|
||
name: 'My First Project',
|
||
tenantId: newTenant.id
|
||
}
|
||
});
|
||
|
||
return newTenant;
|
||
});
|
||
|
||
// Send welcome email
|
||
await sendWelcomeEmail(input.ownerEmail, tenant.slug);
|
||
|
||
return tenant;
|
||
}
|
||
|
||
// Tenant deletion (with data cleanup)
|
||
export async function deprovisionTenant(tenantId: string) {
|
||
const tenant = await db.tenant.findUnique({
|
||
where: { id: tenantId },
|
||
include: { subscription: true }
|
||
});
|
||
|
||
if (!tenant) {
|
||
throw new Error('Tenant not found');
|
||
}
|
||
|
||
// Cancel Stripe subscription
|
||
if (tenant.subscription?.stripeSubscriptionId) {
|
||
await stripe.subscriptions.cancel(tenant.subscription.stripeSubscriptionId);
|
||
}
|
||
|
||
// Delete all tenant data in transaction
|
||
await db.$transaction([
|
||
db.document.deleteMany({ where: { tenantId } }),
|
||
db.project.deleteMany({ where: { tenantId } }),
|
||
db.user.deleteMany({ where: { tenantId } }),
|
||
db.subscription.deleteMany({ where: { tenantId } }),
|
||
db.tenantSettings.deleteMany({ where: { tenantId } }),
|
||
db.tenant.delete({ where: { id: tenantId } })
|
||
]);
|
||
|
||
// Archive data (optional)
|
||
await archiveTenantData(tenantId);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Strategy Comparison
|
||
|
||
| Strategy | Isolation | Cost | Complexity | Best For |
|
||
|----------|-----------|------|------------|----------|
|
||
| **DB per Tenant** | Highest | High | High | Enterprise |
|
||
| **Schema per Tenant** | High | Medium | Medium | Mid-market |
|
||
| **Shared + RLS** | Medium | Low | Medium | SMB/Startup |
|
||
| **Hybrid** | Flexible | Variable | High | Mixed segments |
|
||
|
||
---
|
||
|
||
## Fazit
|
||
|
||
Multi-Tenancy Architektur erfordert:
|
||
|
||
1. **Isolation Strategy**: Passend zum Use Case wählen
|
||
2. **Tenant Context**: Durchgängig in der Anwendung
|
||
3. **Security**: RLS oder Application-Level Filtering
|
||
4. **Provisioning**: Automatisierte Tenant-Erstellung
|
||
|
||
Die richtige Strategie hängt von Kunden und Skalierung ab.
|
||
|
||
---
|
||
|
||
## Bildprompts
|
||
|
||
1. "Multi-tenant architecture diagram, database isolation strategies"
|
||
2. "Tenant provisioning flow, signup to activation"
|
||
3. "Row-level security visualization, data filtering per tenant"
|
||
|
||
---
|
||
|
||
## Quellen
|
||
|
||
- [Microsoft Multi-Tenancy Patterns](https://docs.microsoft.com/en-us/azure/architecture/guide/multitenant/overview)
|
||
- [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
|
||
- [Prisma Multi-Tenancy](https://www.prisma.io/docs/guides/other/multi-tenancy)
|
||
- [AWS Multi-Tenant SaaS](https://aws.amazon.com/blogs/apn/building-a-multi-tenant-saas-solution-using-aws-serverless-services/)
|