Initial commit: Portfolio Website
Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,619 @@
|
||||
# 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/)
|
||||
Reference in New Issue
Block a user