Sistema NEXUS v3.4 migrado con: Estructura principal: - core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles) - core/catalog: Catalogo de funcionalidades reutilizables - shared/knowledge-base: Base de conocimiento compartida - devtools/scripts: Herramientas de desarrollo - control-plane/registries: Control de servicios y CI/CD - orchestration/: Configuracion de orquestacion de agentes Proyectos incluidos (11): - gamilit (submodule -> GitHub) - trading-platform (OrbiquanTIA) - erp-suite con 5 verticales: - erp-core, construccion, vidrio-templado - mecanicas-diesel, retail, clinicas - betting-analytics - inmobiliaria-analytics - platform_marketing_content - pos-micro, erp-basico Configuracion: - .gitignore completo para Node.js/Python/Docker - gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git) - Sistema de puertos estandarizado (3005-3199) Generated with NEXUS v3.4 Migration System EPIC-010: Configuracion Git y Repositorios
435 lines
9.8 KiB
Markdown
435 lines
9.8 KiB
Markdown
# Soporte Multi-Tenant
|
|
|
|
**Versión:** 1.0.0
|
|
**Origen:** projects/gamilit
|
|
**Estado:** Producción
|
|
**Última actualización:** 2025-12-08
|
|
|
|
---
|
|
|
|
## Descripción
|
|
|
|
Sistema de multi-tenancy para aplicaciones SaaS:
|
|
- Aislamiento de datos por organización
|
|
- Usuarios pueden pertenecer a múltiples tenants
|
|
- Roles específicos por tenant (owner, admin, member)
|
|
- Configuración y personalización por tenant
|
|
- Límites de recursos (usuarios, storage)
|
|
- Niveles de suscripción
|
|
|
|
---
|
|
|
|
## Características
|
|
|
|
| Característica | Descripción |
|
|
|----------------|-------------|
|
|
| Aislamiento | Datos separados por tenant |
|
|
| Multi-membresía | Usuario en múltiples tenants |
|
|
| Roles por tenant | owner, admin, member, viewer |
|
|
| Suscripciones | free, basic, pro, enterprise |
|
|
| Personalización | Theme, logo, dominio custom |
|
|
| Límites | Máx usuarios, storage |
|
|
| RLS (opcional) | Row Level Security en PostgreSQL |
|
|
|
|
---
|
|
|
|
## Stack Tecnológico
|
|
|
|
```yaml
|
|
backend:
|
|
framework: NestJS
|
|
orm: TypeORM
|
|
database: PostgreSQL
|
|
|
|
patterns:
|
|
- Tenant discriminator (tenant_id en tablas)
|
|
- Middleware de tenant context
|
|
- Optional: RLS para seguridad extra
|
|
```
|
|
|
|
---
|
|
|
|
## Tablas Requeridas
|
|
|
|
| Tabla | Propósito |
|
|
|-------|-----------|
|
|
| auth_management.tenants | Organizaciones/empresas |
|
|
| auth_management.memberships | Relación usuario-tenant |
|
|
| auth_management.profiles | Perfil extendido con tenant_id |
|
|
|
|
---
|
|
|
|
## Estructura del Módulo
|
|
|
|
```
|
|
multi-tenancy/
|
|
├── entities/
|
|
│ ├── tenant.entity.ts
|
|
│ └── membership.entity.ts
|
|
├── services/
|
|
│ ├── tenant.service.ts
|
|
│ └── membership.service.ts
|
|
├── middleware/
|
|
│ └── tenant-context.middleware.ts
|
|
├── guards/
|
|
│ └── tenant-member.guard.ts
|
|
├── decorators/
|
|
│ └── current-tenant.decorator.ts
|
|
└── dto/
|
|
├── create-tenant.dto.ts
|
|
└── membership.dto.ts
|
|
```
|
|
|
|
---
|
|
|
|
## Modelos de Datos
|
|
|
|
### Tenant (Organización)
|
|
|
|
```typescript
|
|
interface Tenant {
|
|
id: string; // UUID
|
|
name: string; // "Empresa XYZ"
|
|
slug: string; // "empresa-xyz" (único)
|
|
domain?: string; // "xyz.app.com"
|
|
logo_url?: string;
|
|
subscription_tier: 'free' | 'basic' | 'pro' | 'enterprise';
|
|
max_users: number; // Límite de usuarios
|
|
max_storage_gb: number; // Límite de storage
|
|
is_active: boolean;
|
|
trial_ends_at?: Date;
|
|
settings: { // Configuración JSONB
|
|
theme: string;
|
|
features: Record<string, boolean>;
|
|
language: string;
|
|
timezone: string;
|
|
};
|
|
metadata: Record<string, any>;
|
|
}
|
|
```
|
|
|
|
### Membership (Usuario-Tenant)
|
|
|
|
```typescript
|
|
interface Membership {
|
|
id: string;
|
|
user_id: string;
|
|
tenant_id: string;
|
|
role: 'owner' | 'admin' | 'member' | 'viewer';
|
|
status: 'pending' | 'active' | 'suspended';
|
|
invited_by?: string;
|
|
joined_at: Date;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Flujo de Multi-Tenancy
|
|
|
|
```
|
|
1. Usuario autenticado
|
|
│
|
|
▼
|
|
2. Middleware extrae tenant de:
|
|
- Header: X-Tenant-ID
|
|
- Subdomain: xyz.app.com → "xyz"
|
|
- Query param: ?tenant=xyz
|
|
│
|
|
▼
|
|
3. Verificar membresía activa
|
|
│
|
|
▼
|
|
4. Inyectar tenant_id en contexto
|
|
│
|
|
▼
|
|
5. Queries filtran por tenant_id
|
|
```
|
|
|
|
---
|
|
|
|
## Uso Rápido
|
|
|
|
### 1. Middleware de Tenant
|
|
|
|
```typescript
|
|
// src/common/middleware/tenant-context.middleware.ts
|
|
@Injectable()
|
|
export class TenantContextMiddleware implements NestMiddleware {
|
|
constructor(
|
|
private readonly membershipService: MembershipService,
|
|
) {}
|
|
|
|
async use(req: Request, res: Response, next: NextFunction) {
|
|
const tenantId = this.extractTenantId(req);
|
|
|
|
if (!tenantId) {
|
|
return next(); // Rutas sin tenant
|
|
}
|
|
|
|
// Verificar membresía si hay usuario
|
|
if (req.user?.id) {
|
|
const membership = await this.membershipService.findByUserAndTenant(
|
|
req.user.id,
|
|
tenantId,
|
|
);
|
|
|
|
if (!membership || membership.status !== 'active') {
|
|
throw new ForbiddenException('No tienes acceso a este tenant');
|
|
}
|
|
|
|
req.tenantContext = {
|
|
tenantId,
|
|
role: membership.role,
|
|
};
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
private extractTenantId(req: Request): string | null {
|
|
// Opción 1: Header
|
|
const headerTenant = req.headers['x-tenant-id'] as string;
|
|
if (headerTenant) return headerTenant;
|
|
|
|
// Opción 2: Subdomain
|
|
const host = req.hostname;
|
|
const subdomain = host.split('.')[0];
|
|
if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
|
|
return subdomain;
|
|
}
|
|
|
|
// Opción 3: Query param
|
|
return req.query.tenant as string;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Guard de Membresía
|
|
|
|
```typescript
|
|
// src/common/guards/tenant-member.guard.ts
|
|
@Injectable()
|
|
export class TenantMemberGuard implements CanActivate {
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const req = context.switchToHttp().getRequest();
|
|
|
|
if (!req.tenantContext) {
|
|
throw new ForbiddenException('Tenant context required');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Guard con rol específico
|
|
@Injectable()
|
|
export class TenantAdminGuard implements CanActivate {
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const req = context.switchToHttp().getRequest();
|
|
|
|
if (!req.tenantContext) {
|
|
throw new ForbiddenException('Tenant context required');
|
|
}
|
|
|
|
const allowedRoles = ['owner', 'admin'];
|
|
if (!allowedRoles.includes(req.tenantContext.role)) {
|
|
throw new ForbiddenException('Admin role required');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Decorador para obtener tenant
|
|
|
|
```typescript
|
|
// src/common/decorators/current-tenant.decorator.ts
|
|
export const CurrentTenant = createParamDecorator(
|
|
(data: unknown, ctx: ExecutionContext) => {
|
|
const request = ctx.switchToHttp().getRequest();
|
|
return request.tenantContext;
|
|
},
|
|
);
|
|
|
|
// Uso en controller
|
|
@Get('data')
|
|
@UseGuards(JwtAuthGuard, TenantMemberGuard)
|
|
getData(@CurrentTenant() tenant: TenantContext) {
|
|
return this.service.findByTenant(tenant.tenantId);
|
|
}
|
|
```
|
|
|
|
### 4. Service con filtro de tenant
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class ProjectService {
|
|
async findAll(tenantId: string): Promise<Project[]> {
|
|
return this.projectRepository.find({
|
|
where: { tenant_id: tenantId },
|
|
});
|
|
}
|
|
|
|
async create(tenantId: string, dto: CreateProjectDto): Promise<Project> {
|
|
const project = this.projectRepository.create({
|
|
...dto,
|
|
tenant_id: tenantId, // Siempre asignar tenant
|
|
});
|
|
return this.projectRepository.save(project);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Crear Nuevo Tenant
|
|
|
|
```typescript
|
|
async createTenant(dto: CreateTenantDto, ownerId: string): Promise<Tenant> {
|
|
// 1. Crear tenant
|
|
const tenant = this.tenantRepository.create({
|
|
name: dto.name,
|
|
slug: this.generateSlug(dto.name),
|
|
subscription_tier: 'free',
|
|
max_users: 10,
|
|
max_storage_gb: 1,
|
|
settings: {
|
|
theme: 'default',
|
|
features: { analytics: true },
|
|
language: 'es',
|
|
},
|
|
});
|
|
await this.tenantRepository.save(tenant);
|
|
|
|
// 2. Crear membresía de owner
|
|
const membership = this.membershipRepository.create({
|
|
user_id: ownerId,
|
|
tenant_id: tenant.id,
|
|
role: 'owner',
|
|
status: 'active',
|
|
joined_at: new Date(),
|
|
});
|
|
await this.membershipRepository.save(membership);
|
|
|
|
return tenant;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Invitar Usuario a Tenant
|
|
|
|
```typescript
|
|
async inviteUser(
|
|
tenantId: string,
|
|
inviterId: string,
|
|
email: string,
|
|
role: string,
|
|
): Promise<void> {
|
|
// 1. Buscar usuario por email
|
|
const user = await this.userRepository.findOne({ where: { email } });
|
|
|
|
if (!user) {
|
|
// Enviar invitación por email para registro
|
|
await this.mailService.sendInvitation(email, tenantId, role);
|
|
return;
|
|
}
|
|
|
|
// 2. Verificar si ya es miembro
|
|
const existing = await this.membershipRepository.findOne({
|
|
where: { user_id: user.id, tenant_id: tenantId },
|
|
});
|
|
|
|
if (existing) {
|
|
throw new ConflictException('Usuario ya es miembro');
|
|
}
|
|
|
|
// 3. Crear membresía
|
|
const membership = this.membershipRepository.create({
|
|
user_id: user.id,
|
|
tenant_id: tenantId,
|
|
role,
|
|
status: 'active',
|
|
invited_by: inviterId,
|
|
joined_at: new Date(),
|
|
});
|
|
await this.membershipRepository.save(membership);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Row Level Security (Opcional)
|
|
|
|
Para seguridad adicional a nivel de base de datos:
|
|
|
|
```sql
|
|
-- Habilitar RLS en tabla
|
|
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Policy: usuarios solo ven proyectos de sus tenants
|
|
CREATE POLICY tenant_isolation ON projects
|
|
USING (
|
|
tenant_id IN (
|
|
SELECT tenant_id FROM memberships
|
|
WHERE user_id = current_setting('app.current_user_id')::uuid
|
|
AND status = 'active'
|
|
)
|
|
);
|
|
|
|
-- Antes de cada query, setear el user_id
|
|
SET app.current_user_id = 'user-uuid';
|
|
```
|
|
|
|
---
|
|
|
|
## Variables de Entorno
|
|
|
|
```env
|
|
# Multi-tenancy
|
|
ENABLE_MULTITENANCY=true
|
|
DEFAULT_TENANT_SLUG=main
|
|
|
|
# Límites por defecto
|
|
DEFAULT_MAX_USERS=100
|
|
DEFAULT_MAX_STORAGE_GB=5
|
|
```
|
|
|
|
---
|
|
|
|
## Endpoints Principales
|
|
|
|
| Método | Ruta | Descripción |
|
|
|--------|------|-------------|
|
|
| GET | /tenants | Listar tenants del usuario |
|
|
| POST | /tenants | Crear nuevo tenant |
|
|
| GET | /tenants/:id | Obtener detalle de tenant |
|
|
| PUT | /tenants/:id | Actualizar tenant (admin) |
|
|
| POST | /tenants/:id/invite | Invitar usuario |
|
|
| GET | /tenants/:id/members | Listar miembros |
|
|
| PUT | /tenants/:id/members/:userId | Cambiar rol |
|
|
| DELETE | /tenants/:id/members/:userId | Remover miembro |
|
|
|
|
---
|
|
|
|
## Adaptaciones Necesarias
|
|
|
|
1. **Método de detección**: Header, subdomain, o query param
|
|
2. **Roles**: Ajustar según necesidades (owner, admin, etc.)
|
|
3. **Suscripciones**: Definir tiers y límites
|
|
4. **Settings**: Estructura de configuración por tenant
|
|
5. **RLS**: Implementar si se requiere seguridad extra
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- [NestJS Multi-Tenancy Patterns](https://docs.nestjs.com/)
|
|
- [PostgreSQL Row Level Security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
|
|
|
|
---
|
|
|
|
**Mantenido por:** Sistema NEXUS
|
|
**Proyecto origen:** Gamilit Platform
|