Template base para proyectos SaaS multi-tenant. Estructura inicial: - apps/backend (NestJS API) - apps/frontend (React/Vite) - apps/database (PostgreSQL DDL) - docs/ (Documentación) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
16 KiB
16 KiB
ARQUITECTURA MULTI-TENANT
Proyecto: template-saas Version: 1.0.0 Tipo: Especificacion Tecnica
Modelo de Multi-Tenancy
Este template implementa multi-tenancy a nivel de base de datos usando PostgreSQL Row-Level Security (RLS).
Estrategia: Shared Database, Shared Schema
┌─────────────────────────────────────────────────────────────┐
│ PostgreSQL Instance │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Database: saas_db │ │
│ │ ┌──────────┬──────────┬──────────┬──────────────────┐ │ │
│ │ │ Tenant A │ Tenant B │ Tenant C │ ... Tenant N │ │ │
│ │ │ (RLS) │ (RLS) │ (RLS) │ (RLS) │ │ │
│ │ └──────────┴──────────┴──────────┴──────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Ventajas
- Simplicidad operacional - Una sola base de datos
- Eficiencia de recursos - Conexiones compartidas
- Mantenimiento facil - Migraciones unicas
- Escalabilidad - Cientos de tenants sin overhead
Consideraciones
- RLS DEBE estar habilitado en TODAS las tablas con datos de tenant
- Cada query DEBE incluir el
tenant_iden el contexto - Backups incluyen todos los tenants (encriptacion requerida)
Implementacion RLS
Estructura Base de Tablas
-- Todas las tablas de tenant heredan esta estructura
CREATE TABLE tenants.base_entity (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID,
updated_by UUID
);
-- Indice obligatorio en tenant_id
CREATE INDEX idx_base_entity_tenant ON tenants.base_entity(tenant_id);
Politicas RLS Estandar
-- Habilitar RLS
ALTER TABLE {schema}.{table} ENABLE ROW LEVEL SECURITY;
-- Politica de lectura
CREATE POLICY "{table}_tenant_isolation_select" ON {schema}.{table}
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Politica de insercion
CREATE POLICY "{table}_tenant_isolation_insert" ON {schema}.{table}
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Politica de actualizacion
CREATE POLICY "{table}_tenant_isolation_update" ON {schema}.{table}
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Politica de eliminacion
CREATE POLICY "{table}_tenant_isolation_delete" ON {schema}.{table}
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
Configuracion de Contexto
-- Funcion para establecer tenant actual
CREATE OR REPLACE FUNCTION auth.set_current_tenant(p_tenant_id UUID)
RETURNS VOID AS $$
BEGIN
PERFORM set_config('app.current_tenant_id', p_tenant_id::TEXT, FALSE);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Funcion para obtener tenant actual
CREATE OR REPLACE FUNCTION auth.get_current_tenant()
RETURNS UUID AS $$
BEGIN
RETURN current_setting('app.current_tenant_id', TRUE)::UUID;
END;
$$ LANGUAGE plpgsql STABLE;
Arquitectura Backend
Middleware de Tenant
// src/shared/middleware/tenant.middleware.ts
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(
private readonly tenantService: TenantService,
private readonly dataSource: DataSource,
) {}
async use(req: Request, res: Response, next: NextFunction) {
const tenantId = this.extractTenantId(req);
if (!tenantId) {
throw new UnauthorizedException('Tenant not identified');
}
// Validar que el tenant existe y esta activo
const tenant = await this.tenantService.findById(tenantId);
if (!tenant || tenant.status !== 'active') {
throw new ForbiddenException('Tenant inactive or not found');
}
// Establecer en request para uso posterior
req['tenant'] = tenant;
// Establecer en PostgreSQL para RLS
await this.dataSource.query(
`SELECT auth.set_current_tenant($1)`,
[tenantId]
);
next();
}
private extractTenantId(req: Request): string | null {
// 1. Desde JWT token
if (req.user?.tenantId) {
return req.user.tenantId;
}
// 2. Desde header
const headerTenant = req.headers['x-tenant-id'];
if (headerTenant) {
return headerTenant as string;
}
// 3. Desde subdominio
const host = req.headers.host;
if (host) {
const subdomain = host.split('.')[0];
// Lookup tenant by subdomain
}
return null;
}
}
Guard de Tenant
// src/shared/guards/tenant.guard.ts
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
if (!request.tenant) {
throw new ForbiddenException('Tenant context required');
}
return true;
}
}
Decorador de Tenant
// src/shared/decorators/tenant.decorator.ts
export const CurrentTenant = createParamDecorator(
(data: unknown, ctx: ExecutionContext): Tenant => {
const request = ctx.switchToHttp().getRequest();
return request.tenant;
},
);
// Uso en controlador
@Get('products')
@UseGuards(TenantGuard)
async getProducts(@CurrentTenant() tenant: Tenant) {
return this.productService.findAll(tenant.id);
}
Arquitectura de Schemas
┌─────────────────────────────────────────────────────────────┐
│ DATABASE SCHEMAS │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ auth │ │ tenants │ │ users │ │
│ │ │ │ │ │ │ │
│ │ - sessions │ │ - tenants │ │ - users │ │
│ │ - tokens │ │ - settings │ │ - roles │ │
│ │ - oauth │ │ - domains │ │ - permissions │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ billing │ │ plans │ │ audit │ │
│ │ │ │ │ │ │ │
│ │ - invoices │ │ - plans │ │ - audit_logs │ │
│ │ - payments │ │ - features │ │ - activity_logs │ │
│ │ - subscript │ │ - limits │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │notifications│ │ storage │ │ webhooks │ │
│ │ │ │ │ │ │ │
│ │ - templates │ │ - files │ │ - endpoints │ │
│ │ - queue │ │ - metadata │ │ - deliveries │ │
│ │ - logs │ │ │ │ - logs │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Flujo de Autenticacion
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │────▶│ Login │────▶│ Verify │────▶│ Issue │
│ │ │ Request │ │ Creds │ │ JWT │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│
▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Response │◀────│ Set │◀────│ Create │◀────│ Token │
│ + JWT │ │ Tenant │ │ Session │ │ contains │
│ │ │ Context │ │ │ │ tenantId │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
JWT Payload
interface JwtPayload {
sub: string; // user_id
tenantId: string; // tenant_id
email: string;
roles: string[];
permissions: string[];
iat: number;
exp: number;
}
Modelo de Datos Principal
Tenant
CREATE TABLE tenants.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
domain VARCHAR(255) UNIQUE,
status VARCHAR(50) DEFAULT 'active',
plan_id UUID REFERENCES plans.plans(id),
settings JSONB DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT valid_status CHECK (status IN ('active', 'suspended', 'cancelled'))
);
User (pertenece a Tenant)
CREATE TABLE users.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id),
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255),
first_name VARCHAR(100),
last_name VARCHAR(100),
status VARCHAR(50) DEFAULT 'active',
email_verified BOOLEAN DEFAULT FALSE,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_email_per_tenant UNIQUE (tenant_id, email)
);
-- RLS
ALTER TABLE users.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_tenant_isolation" ON users.users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
Plan
CREATE TABLE plans.plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
price_monthly DECIMAL(10,2),
price_yearly DECIMAL(10,2),
stripe_price_id_monthly VARCHAR(255),
stripe_price_id_yearly VARCHAR(255),
features JSONB DEFAULT '[]',
limits JSONB DEFAULT '{}',
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Ejemplo de limits JSONB
-- {
-- "max_users": 5,
-- "max_storage_gb": 10,
-- "max_api_calls_month": 10000,
-- "max_products": 100
-- }
Integracion Stripe
Flujo de Suscripcion
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Tenant │────▶│ Select │────▶│ Stripe │────▶│ Webhook │
│ Signup │ │ Plan │ │ Checkout │ │ Received │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│
▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Tenant │◀────│ Update │◀────│ Create │◀────│ Process │
│ Active │ │ Status │ │ Invoice │ │ Event │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
Webhooks de Stripe
| Evento | Accion |
|---|---|
checkout.session.completed |
Activar suscripcion |
customer.subscription.updated |
Actualizar plan |
customer.subscription.deleted |
Cancelar suscripcion |
invoice.paid |
Registrar pago |
invoice.payment_failed |
Notificar y suspender |
Seguridad
Capas de Seguridad
- Network - HTTPS obligatorio, rate limiting
- Authentication - JWT, OAuth, MFA
- Authorization - RBAC, tenant isolation
- Database - RLS, conexiones encriptadas
- Application - Input validation, OWASP
Headers de Seguridad
// Helmet configuration
app.use(helmet({
contentSecurityPolicy: true,
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: true,
dnsPrefetchControl: true,
frameguard: true,
hidePoweredBy: true,
hsts: true,
ieNoOpen: true,
noSniff: true,
originAgentCluster: true,
permittedCrossDomainPolicies: true,
referrerPolicy: true,
xssFilter: true,
}));
Escalabilidad
Horizontal
- Backend stateless (multiples instancias)
- Redis para sesiones y cache
- PostgreSQL read replicas
- CDN para assets estaticos
Vertical
- Connection pooling (PgBouncer)
- Query optimization
- Indices estrategicos
- Particionamiento de tablas grandes
Por Tenant
- Rate limiting por tenant
- Cuotas de almacenamiento
- Limites de API calls
- Fair usage policies
Monitoreo
Metricas Clave
- Requests por tenant
- Latencia de API
- Errores por tenant
- Uso de recursos
- Conexiones de DB
Alertas
- Tenant excede cuota
- Error rate alto
- Latencia degradada
- DB connections altas
Referencias
- Vision:
VISION-TEMPLATE-SAAS.md - Database:
../../database/README.md - Inventarios:
../../orchestration/inventarios/
Creado: 2026-01-07 Actualizado: 2026-01-07