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
1055 lines
27 KiB
Markdown
1055 lines
27 KiB
Markdown
# Guía de Implementación: Multi-Tenancy
|
|
|
|
**Versión:** 1.0.0
|
|
**Tiempo estimado:** 2-4 horas
|
|
**Complejidad:** Media-Alta
|
|
|
|
---
|
|
|
|
## Pre-requisitos
|
|
|
|
- [ ] Proyecto NestJS existente
|
|
- [ ] TypeORM configurado
|
|
- [ ] PostgreSQL como base de datos
|
|
- [ ] Sistema de autenticación funcionando
|
|
|
|
---
|
|
|
|
## Paso 1: Crear Estructura de Directorios
|
|
|
|
```bash
|
|
mkdir -p src/modules/tenants/entities
|
|
mkdir -p src/modules/tenants/services
|
|
mkdir -p src/modules/tenants/controllers
|
|
mkdir -p src/modules/tenants/dto
|
|
mkdir -p src/common/middleware
|
|
mkdir -p src/common/guards
|
|
mkdir -p src/common/decorators
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 2: Crear Entidad Tenant
|
|
|
|
```typescript
|
|
// src/modules/tenants/entities/tenant.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
CreateDateColumn,
|
|
UpdateDateColumn,
|
|
OneToMany,
|
|
} from 'typeorm';
|
|
import { Membership } from './membership.entity';
|
|
|
|
export type SubscriptionTier = 'free' | 'basic' | 'pro' | 'enterprise';
|
|
|
|
export interface TenantSettings {
|
|
theme?: string;
|
|
features?: Record<string, boolean>;
|
|
language?: string;
|
|
timezone?: string;
|
|
}
|
|
|
|
@Entity({ schema: 'auth_management', name: 'tenants' })
|
|
export class Tenant {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ length: 255 })
|
|
name: string;
|
|
|
|
@Column({ length: 100, unique: true })
|
|
slug: string;
|
|
|
|
@Column({ length: 255, nullable: true })
|
|
domain: string;
|
|
|
|
@Column({ name: 'logo_url', nullable: true })
|
|
logoUrl: string;
|
|
|
|
@Column({
|
|
name: 'subscription_tier',
|
|
type: 'varchar',
|
|
length: 20,
|
|
default: 'free',
|
|
})
|
|
subscriptionTier: SubscriptionTier;
|
|
|
|
@Column({ name: 'max_users', default: 10 })
|
|
maxUsers: number;
|
|
|
|
@Column({ name: 'max_storage_gb', default: 1 })
|
|
maxStorageGb: number;
|
|
|
|
@Column({ name: 'is_active', default: true })
|
|
isActive: boolean;
|
|
|
|
@Column({ name: 'trial_ends_at', type: 'timestamp', nullable: true })
|
|
trialEndsAt: Date;
|
|
|
|
@Column({ type: 'jsonb', default: {} })
|
|
settings: TenantSettings;
|
|
|
|
@Column({ type: 'jsonb', default: {} })
|
|
metadata: Record<string, any>;
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at' })
|
|
updatedAt: Date;
|
|
|
|
@OneToMany(() => Membership, (membership) => membership.tenant)
|
|
memberships: Membership[];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 3: Crear Entidad Membership
|
|
|
|
```typescript
|
|
// src/modules/tenants/entities/membership.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
CreateDateColumn,
|
|
ManyToOne,
|
|
JoinColumn,
|
|
Unique,
|
|
} from 'typeorm';
|
|
import { Tenant } from './tenant.entity';
|
|
import { User } from '../../auth/entities/user.entity';
|
|
|
|
export type MembershipRole = 'owner' | 'admin' | 'member' | 'viewer';
|
|
export type MembershipStatus = 'pending' | 'active' | 'suspended';
|
|
|
|
@Entity({ schema: 'auth_management', name: 'memberships' })
|
|
@Unique(['userId', 'tenantId'])
|
|
export class Membership {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ name: 'user_id', type: 'uuid' })
|
|
userId: string;
|
|
|
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
|
tenantId: string;
|
|
|
|
@Column({ type: 'varchar', length: 20, default: 'member' })
|
|
role: MembershipRole;
|
|
|
|
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
|
status: MembershipStatus;
|
|
|
|
@Column({ name: 'invited_by', type: 'uuid', nullable: true })
|
|
invitedBy: string;
|
|
|
|
@Column({ name: 'joined_at', type: 'timestamp', nullable: true })
|
|
joinedAt: Date;
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
|
|
@ManyToOne(() => Tenant, (tenant) => tenant.memberships)
|
|
@JoinColumn({ name: 'tenant_id' })
|
|
tenant: Tenant;
|
|
|
|
@ManyToOne(() => User)
|
|
@JoinColumn({ name: 'user_id' })
|
|
user: User;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 4: Crear DTOs
|
|
|
|
```typescript
|
|
// src/modules/tenants/dto/create-tenant.dto.ts
|
|
import { IsString, IsOptional, MaxLength, Matches } from 'class-validator';
|
|
|
|
export class CreateTenantDto {
|
|
@IsString()
|
|
@MaxLength(255)
|
|
name: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(100)
|
|
@Matches(/^[a-z0-9-]+$/, {
|
|
message: 'Slug solo puede contener letras minúsculas, números y guiones',
|
|
})
|
|
slug?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
domain?: string;
|
|
}
|
|
|
|
// src/modules/tenants/dto/update-tenant.dto.ts
|
|
import { PartialType } from '@nestjs/mapped-types';
|
|
import { CreateTenantDto } from './create-tenant.dto';
|
|
import { IsOptional, IsObject } from 'class-validator';
|
|
import { TenantSettings } from '../entities/tenant.entity';
|
|
|
|
export class UpdateTenantDto extends PartialType(CreateTenantDto) {
|
|
@IsOptional()
|
|
@IsObject()
|
|
settings?: TenantSettings;
|
|
}
|
|
|
|
// src/modules/tenants/dto/invite-member.dto.ts
|
|
import { IsEmail, IsString, IsIn } from 'class-validator';
|
|
import { MembershipRole } from '../entities/membership.entity';
|
|
|
|
export class InviteMemberDto {
|
|
@IsEmail()
|
|
email: string;
|
|
|
|
@IsString()
|
|
@IsIn(['admin', 'member', 'viewer'])
|
|
role: MembershipRole;
|
|
}
|
|
|
|
// src/modules/tenants/dto/update-member-role.dto.ts
|
|
import { IsString, IsIn } from 'class-validator';
|
|
import { MembershipRole } from '../entities/membership.entity';
|
|
|
|
export class UpdateMemberRoleDto {
|
|
@IsString()
|
|
@IsIn(['admin', 'member', 'viewer'])
|
|
role: MembershipRole;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 5: Crear Interfaz de Contexto
|
|
|
|
```typescript
|
|
// src/common/interfaces/tenant-context.interface.ts
|
|
import { MembershipRole } from '../../modules/tenants/entities/membership.entity';
|
|
|
|
export interface TenantContext {
|
|
tenantId: string;
|
|
role: MembershipRole;
|
|
}
|
|
|
|
// Extender Request de Express
|
|
declare global {
|
|
namespace Express {
|
|
interface Request {
|
|
tenantContext?: TenantContext;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 6: Crear Servicios
|
|
|
|
### TenantService
|
|
|
|
```typescript
|
|
// src/modules/tenants/services/tenant.service.ts
|
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { Tenant } from '../entities/tenant.entity';
|
|
import { CreateTenantDto, UpdateTenantDto } from '../dto';
|
|
|
|
@Injectable()
|
|
export class TenantService {
|
|
constructor(
|
|
@InjectRepository(Tenant)
|
|
private readonly tenantRepository: Repository<Tenant>,
|
|
) {}
|
|
|
|
async create(dto: CreateTenantDto): Promise<Tenant> {
|
|
const slug = dto.slug || this.generateSlug(dto.name);
|
|
|
|
// Verificar slug único
|
|
const existing = await this.tenantRepository.findOne({ where: { slug } });
|
|
if (existing) {
|
|
throw new ConflictException('El slug ya está en uso');
|
|
}
|
|
|
|
const tenant = this.tenantRepository.create({
|
|
...dto,
|
|
slug,
|
|
settings: {
|
|
theme: 'default',
|
|
features: {},
|
|
language: 'es',
|
|
timezone: 'America/Mexico_City',
|
|
},
|
|
});
|
|
|
|
return this.tenantRepository.save(tenant);
|
|
}
|
|
|
|
async findById(id: string): Promise<Tenant> {
|
|
const tenant = await this.tenantRepository.findOne({ where: { id } });
|
|
if (!tenant) {
|
|
throw new NotFoundException('Tenant no encontrado');
|
|
}
|
|
return tenant;
|
|
}
|
|
|
|
async findBySlug(slug: string): Promise<Tenant> {
|
|
const tenant = await this.tenantRepository.findOne({ where: { slug } });
|
|
if (!tenant) {
|
|
throw new NotFoundException('Tenant no encontrado');
|
|
}
|
|
return tenant;
|
|
}
|
|
|
|
async update(id: string, dto: UpdateTenantDto): Promise<Tenant> {
|
|
const tenant = await this.findById(id);
|
|
|
|
if (dto.slug && dto.slug !== tenant.slug) {
|
|
const existing = await this.tenantRepository.findOne({
|
|
where: { slug: dto.slug },
|
|
});
|
|
if (existing) {
|
|
throw new ConflictException('El slug ya está en uso');
|
|
}
|
|
}
|
|
|
|
Object.assign(tenant, dto);
|
|
return this.tenantRepository.save(tenant);
|
|
}
|
|
|
|
async checkLimits(tenantId: string): Promise<{ canAddUser: boolean; currentUsers: number; maxUsers: number }> {
|
|
const tenant = await this.findById(tenantId);
|
|
const currentUsers = await this.tenantRepository
|
|
.createQueryBuilder('t')
|
|
.leftJoin('t.memberships', 'm')
|
|
.where('t.id = :tenantId', { tenantId })
|
|
.andWhere('m.status = :status', { status: 'active' })
|
|
.getCount();
|
|
|
|
return {
|
|
canAddUser: currentUsers < tenant.maxUsers,
|
|
currentUsers,
|
|
maxUsers: tenant.maxUsers,
|
|
};
|
|
}
|
|
|
|
private generateSlug(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
}
|
|
```
|
|
|
|
### MembershipService
|
|
|
|
```typescript
|
|
// src/modules/tenants/services/membership.service.ts
|
|
import {
|
|
Injectable,
|
|
NotFoundException,
|
|
ForbiddenException,
|
|
ConflictException,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { Membership, MembershipRole } from '../entities/membership.entity';
|
|
import { TenantService } from './tenant.service';
|
|
|
|
@Injectable()
|
|
export class MembershipService {
|
|
constructor(
|
|
@InjectRepository(Membership)
|
|
private readonly membershipRepository: Repository<Membership>,
|
|
private readonly tenantService: TenantService,
|
|
) {}
|
|
|
|
async findByUserAndTenant(userId: string, tenantId: string): Promise<Membership | null> {
|
|
return this.membershipRepository.findOne({
|
|
where: { userId, tenantId },
|
|
});
|
|
}
|
|
|
|
async findAllByUser(userId: string): Promise<Membership[]> {
|
|
return this.membershipRepository.find({
|
|
where: { userId, status: 'active' },
|
|
relations: ['tenant'],
|
|
});
|
|
}
|
|
|
|
async findAllByTenant(tenantId: string): Promise<Membership[]> {
|
|
return this.membershipRepository.find({
|
|
where: { tenantId },
|
|
relations: ['user'],
|
|
});
|
|
}
|
|
|
|
async createOwnerMembership(userId: string, tenantId: string): Promise<Membership> {
|
|
const membership = this.membershipRepository.create({
|
|
userId,
|
|
tenantId,
|
|
role: 'owner',
|
|
status: 'active',
|
|
joinedAt: new Date(),
|
|
});
|
|
return this.membershipRepository.save(membership);
|
|
}
|
|
|
|
async inviteUser(
|
|
tenantId: string,
|
|
inviterId: string,
|
|
targetUserId: string,
|
|
role: MembershipRole,
|
|
): Promise<Membership> {
|
|
// Verificar límites
|
|
const limits = await this.tenantService.checkLimits(tenantId);
|
|
if (!limits.canAddUser) {
|
|
throw new ForbiddenException(
|
|
`Límite de usuarios alcanzado (${limits.maxUsers})`,
|
|
);
|
|
}
|
|
|
|
// Verificar membresía existente
|
|
const existing = await this.findByUserAndTenant(targetUserId, tenantId);
|
|
if (existing) {
|
|
throw new ConflictException('Usuario ya es miembro del tenant');
|
|
}
|
|
|
|
const membership = this.membershipRepository.create({
|
|
userId: targetUserId,
|
|
tenantId,
|
|
role,
|
|
status: 'active',
|
|
invitedBy: inviterId,
|
|
joinedAt: new Date(),
|
|
});
|
|
|
|
return this.membershipRepository.save(membership);
|
|
}
|
|
|
|
async updateRole(
|
|
tenantId: string,
|
|
userId: string,
|
|
newRole: MembershipRole,
|
|
): Promise<Membership> {
|
|
const membership = await this.findByUserAndTenant(userId, tenantId);
|
|
if (!membership) {
|
|
throw new NotFoundException('Membresía no encontrada');
|
|
}
|
|
|
|
if (membership.role === 'owner') {
|
|
throw new ForbiddenException('No se puede cambiar el rol del owner');
|
|
}
|
|
|
|
membership.role = newRole;
|
|
return this.membershipRepository.save(membership);
|
|
}
|
|
|
|
async removeMember(tenantId: string, userId: string): Promise<void> {
|
|
const membership = await this.findByUserAndTenant(userId, tenantId);
|
|
if (!membership) {
|
|
throw new NotFoundException('Membresía no encontrada');
|
|
}
|
|
|
|
if (membership.role === 'owner') {
|
|
throw new ForbiddenException('No se puede remover al owner');
|
|
}
|
|
|
|
await this.membershipRepository.remove(membership);
|
|
}
|
|
|
|
async hasRole(userId: string, tenantId: string, roles: MembershipRole[]): Promise<boolean> {
|
|
const membership = await this.findByUserAndTenant(userId, tenantId);
|
|
return membership?.status === 'active' && roles.includes(membership.role);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 7: Crear Middleware de Tenant Context
|
|
|
|
```typescript
|
|
// src/common/middleware/tenant-context.middleware.ts
|
|
import {
|
|
Injectable,
|
|
NestMiddleware,
|
|
ForbiddenException,
|
|
} from '@nestjs/common';
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import { MembershipService } from '../../modules/tenants/services/membership.service';
|
|
|
|
@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 autenticado
|
|
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,
|
|
};
|
|
} else {
|
|
// Usuario no autenticado pero con tenant (para rutas públicas)
|
|
req.tenantContext = {
|
|
tenantId,
|
|
role: 'viewer',
|
|
};
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
private extractTenantId(req: Request): string | null {
|
|
// Opción 1: Header X-Tenant-ID
|
|
const headerTenant = req.headers['x-tenant-id'] as string;
|
|
if (headerTenant) return headerTenant;
|
|
|
|
// Opción 2: Subdomain
|
|
const host = req.hostname || req.headers.host || '';
|
|
const parts = host.split('.');
|
|
if (parts.length >= 3) {
|
|
const subdomain = parts[0];
|
|
if (subdomain !== 'www' && subdomain !== 'app' && subdomain !== 'api') {
|
|
return subdomain; // Este sería el slug, convertir a ID si es necesario
|
|
}
|
|
}
|
|
|
|
// Opción 3: Query param
|
|
const queryTenant = req.query.tenant as string;
|
|
if (queryTenant) return queryTenant;
|
|
|
|
return null;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 8: Crear Guards
|
|
|
|
```typescript
|
|
// src/common/guards/tenant-member.guard.ts
|
|
import {
|
|
Injectable,
|
|
CanActivate,
|
|
ExecutionContext,
|
|
ForbiddenException,
|
|
} from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { MembershipRole } from '../../modules/tenants/entities/membership.entity';
|
|
|
|
export const TENANT_ROLES_KEY = 'tenant_roles';
|
|
|
|
@Injectable()
|
|
export class TenantMemberGuard implements CanActivate {
|
|
constructor(private reflector: Reflector) {}
|
|
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const request = context.switchToHttp().getRequest();
|
|
|
|
if (!request.tenantContext) {
|
|
throw new ForbiddenException('Tenant context requerido');
|
|
}
|
|
|
|
// Verificar roles si están definidos
|
|
const requiredRoles = this.reflector.getAllAndOverride<MembershipRole[]>(
|
|
TENANT_ROLES_KEY,
|
|
[context.getHandler(), context.getClass()],
|
|
);
|
|
|
|
if (!requiredRoles || requiredRoles.length === 0) {
|
|
return true; // Solo requiere membresía activa
|
|
}
|
|
|
|
if (!requiredRoles.includes(request.tenantContext.role)) {
|
|
throw new ForbiddenException(
|
|
`Rol requerido: ${requiredRoles.join(' o ')}`,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// src/common/guards/tenant-admin.guard.ts
|
|
import {
|
|
Injectable,
|
|
CanActivate,
|
|
ExecutionContext,
|
|
ForbiddenException,
|
|
} from '@nestjs/common';
|
|
|
|
@Injectable()
|
|
export class TenantAdminGuard implements CanActivate {
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const request = context.switchToHttp().getRequest();
|
|
|
|
if (!request.tenantContext) {
|
|
throw new ForbiddenException('Tenant context requerido');
|
|
}
|
|
|
|
const allowedRoles = ['owner', 'admin'];
|
|
if (!allowedRoles.includes(request.tenantContext.role)) {
|
|
throw new ForbiddenException('Se requiere rol de administrador');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// src/common/guards/tenant-owner.guard.ts
|
|
import {
|
|
Injectable,
|
|
CanActivate,
|
|
ExecutionContext,
|
|
ForbiddenException,
|
|
} from '@nestjs/common';
|
|
|
|
@Injectable()
|
|
export class TenantOwnerGuard implements CanActivate {
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const request = context.switchToHttp().getRequest();
|
|
|
|
if (!request.tenantContext) {
|
|
throw new ForbiddenException('Tenant context requerido');
|
|
}
|
|
|
|
if (request.tenantContext.role !== 'owner') {
|
|
throw new ForbiddenException('Se requiere rol de owner');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 9: Crear Decoradores
|
|
|
|
```typescript
|
|
// src/common/decorators/current-tenant.decorator.ts
|
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
import { TenantContext } from '../interfaces/tenant-context.interface';
|
|
|
|
export const CurrentTenant = createParamDecorator(
|
|
(data: keyof TenantContext | undefined, ctx: ExecutionContext) => {
|
|
const request = ctx.switchToHttp().getRequest();
|
|
const tenantContext = request.tenantContext;
|
|
|
|
if (!tenantContext) {
|
|
return null;
|
|
}
|
|
|
|
return data ? tenantContext[data] : tenantContext;
|
|
},
|
|
);
|
|
|
|
// src/common/decorators/tenant-roles.decorator.ts
|
|
import { SetMetadata } from '@nestjs/common';
|
|
import { MembershipRole } from '../../modules/tenants/entities/membership.entity';
|
|
import { TENANT_ROLES_KEY } from '../guards/tenant-member.guard';
|
|
|
|
export const TenantRoles = (...roles: MembershipRole[]) =>
|
|
SetMetadata(TENANT_ROLES_KEY, roles);
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 10: Crear Controller
|
|
|
|
```typescript
|
|
// src/modules/tenants/controllers/tenant.controller.ts
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
UseGuards,
|
|
ParseUUIDPipe,
|
|
} from '@nestjs/common';
|
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
|
import { TenantMemberGuard, TenantAdminGuard, TenantOwnerGuard } from '../../../common/guards';
|
|
import { CurrentTenant, TenantRoles } from '../../../common/decorators';
|
|
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
|
import { TenantService } from '../services/tenant.service';
|
|
import { MembershipService } from '../services/membership.service';
|
|
import { CreateTenantDto, UpdateTenantDto, InviteMemberDto, UpdateMemberRoleDto } from '../dto';
|
|
import { TenantContext } from '../../../common/interfaces/tenant-context.interface';
|
|
|
|
@Controller('tenants')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class TenantController {
|
|
constructor(
|
|
private readonly tenantService: TenantService,
|
|
private readonly membershipService: MembershipService,
|
|
) {}
|
|
|
|
// Listar tenants del usuario actual
|
|
@Get()
|
|
async getMyTenants(@CurrentUser('id') userId: string) {
|
|
const memberships = await this.membershipService.findAllByUser(userId);
|
|
return memberships.map((m) => ({
|
|
...m.tenant,
|
|
role: m.role,
|
|
}));
|
|
}
|
|
|
|
// Crear nuevo tenant (usuario se convierte en owner)
|
|
@Post()
|
|
async create(
|
|
@CurrentUser('id') userId: string,
|
|
@Body() dto: CreateTenantDto,
|
|
) {
|
|
const tenant = await this.tenantService.create(dto);
|
|
await this.membershipService.createOwnerMembership(userId, tenant.id);
|
|
return tenant;
|
|
}
|
|
|
|
// Obtener detalle de tenant (requiere membresía)
|
|
@Get(':id')
|
|
@UseGuards(TenantMemberGuard)
|
|
async getOne(
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@CurrentTenant() tenant: TenantContext,
|
|
) {
|
|
return this.tenantService.findById(id);
|
|
}
|
|
|
|
// Actualizar tenant (solo admin+)
|
|
@Put(':id')
|
|
@UseGuards(TenantAdminGuard)
|
|
async update(
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@Body() dto: UpdateTenantDto,
|
|
) {
|
|
return this.tenantService.update(id, dto);
|
|
}
|
|
|
|
// Listar miembros del tenant
|
|
@Get(':id/members')
|
|
@UseGuards(TenantMemberGuard)
|
|
async getMembers(@Param('id', ParseUUIDPipe) tenantId: string) {
|
|
return this.membershipService.findAllByTenant(tenantId);
|
|
}
|
|
|
|
// Invitar usuario (solo admin+)
|
|
@Post(':id/invite')
|
|
@UseGuards(TenantAdminGuard)
|
|
async inviteMember(
|
|
@Param('id', ParseUUIDPipe) tenantId: string,
|
|
@CurrentUser('id') inviterId: string,
|
|
@Body() dto: InviteMemberDto,
|
|
) {
|
|
// Aquí deberías buscar el usuario por email y obtener su ID
|
|
// Por simplicidad, asumimos que tienes un UserService
|
|
// const user = await this.userService.findByEmail(dto.email);
|
|
// return this.membershipService.inviteUser(tenantId, inviterId, user.id, dto.role);
|
|
}
|
|
|
|
// Cambiar rol de miembro (solo owner)
|
|
@Put(':id/members/:userId/role')
|
|
@UseGuards(TenantOwnerGuard)
|
|
async updateMemberRole(
|
|
@Param('id', ParseUUIDPipe) tenantId: string,
|
|
@Param('userId', ParseUUIDPipe) userId: string,
|
|
@Body() dto: UpdateMemberRoleDto,
|
|
) {
|
|
return this.membershipService.updateRole(tenantId, userId, dto.role);
|
|
}
|
|
|
|
// Remover miembro (solo admin+)
|
|
@Delete(':id/members/:userId')
|
|
@UseGuards(TenantAdminGuard)
|
|
async removeMember(
|
|
@Param('id', ParseUUIDPipe) tenantId: string,
|
|
@Param('userId', ParseUUIDPipe) userId: string,
|
|
) {
|
|
await this.membershipService.removeMember(tenantId, userId);
|
|
return { message: 'Miembro removido exitosamente' };
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 11: Crear Módulo
|
|
|
|
```typescript
|
|
// src/modules/tenants/tenants.module.ts
|
|
import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { Tenant } from './entities/tenant.entity';
|
|
import { Membership } from './entities/membership.entity';
|
|
import { TenantService } from './services/tenant.service';
|
|
import { MembershipService } from './services/membership.service';
|
|
import { TenantController } from './controllers/tenant.controller';
|
|
import { TenantContextMiddleware } from '../../common/middleware/tenant-context.middleware';
|
|
|
|
@Module({
|
|
imports: [TypeOrmModule.forFeature([Tenant, Membership])],
|
|
controllers: [TenantController],
|
|
providers: [TenantService, MembershipService],
|
|
exports: [TenantService, MembershipService],
|
|
})
|
|
export class TenantsModule {
|
|
configure(consumer: MiddlewareConsumer) {
|
|
consumer
|
|
.apply(TenantContextMiddleware)
|
|
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 12: Registrar en AppModule
|
|
|
|
```typescript
|
|
// src/app.module.ts
|
|
import { Module } from '@nestjs/common';
|
|
import { TenantsModule } from './modules/tenants/tenants.module';
|
|
|
|
@Module({
|
|
imports: [
|
|
// ... otros módulos
|
|
TenantsModule,
|
|
],
|
|
})
|
|
export class AppModule {}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 13: Migraciones SQL
|
|
|
|
```sql
|
|
-- migrations/001_create_tenants.sql
|
|
CREATE SCHEMA IF NOT EXISTS auth_management;
|
|
|
|
-- Tabla de tenants
|
|
CREATE TABLE auth_management.tenants (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name VARCHAR(255) NOT NULL,
|
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
|
domain VARCHAR(255),
|
|
logo_url TEXT,
|
|
subscription_tier VARCHAR(20) DEFAULT 'free',
|
|
max_users INTEGER DEFAULT 10,
|
|
max_storage_gb INTEGER DEFAULT 1,
|
|
is_active BOOLEAN DEFAULT true,
|
|
trial_ends_at TIMESTAMP,
|
|
settings JSONB DEFAULT '{}',
|
|
metadata JSONB DEFAULT '{}',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Tabla de memberships
|
|
CREATE TABLE auth_management.memberships (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES auth_management.users(id) ON DELETE CASCADE,
|
|
tenant_id UUID NOT NULL REFERENCES auth_management.tenants(id) ON DELETE CASCADE,
|
|
role VARCHAR(20) DEFAULT 'member',
|
|
status VARCHAR(20) DEFAULT 'pending',
|
|
invited_by UUID REFERENCES auth_management.users(id),
|
|
joined_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, tenant_id)
|
|
);
|
|
|
|
-- Índices
|
|
CREATE INDEX idx_tenants_slug ON auth_management.tenants(slug);
|
|
CREATE INDEX idx_memberships_user ON auth_management.memberships(user_id);
|
|
CREATE INDEX idx_memberships_tenant ON auth_management.memberships(tenant_id);
|
|
CREATE INDEX idx_memberships_status ON auth_management.memberships(status);
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 14: Row Level Security (Opcional)
|
|
|
|
Para seguridad adicional a nivel de base de datos:
|
|
|
|
```sql
|
|
-- Habilitar RLS en tablas con datos por tenant
|
|
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Crear policy de aislamiento
|
|
CREATE POLICY tenant_isolation ON projects
|
|
USING (
|
|
tenant_id IN (
|
|
SELECT tenant_id FROM auth_management.memberships
|
|
WHERE user_id = current_setting('app.current_user_id')::uuid
|
|
AND status = 'active'
|
|
)
|
|
);
|
|
|
|
-- En el middleware o interceptor, setear el user_id antes de cada query:
|
|
-- SET app.current_user_id = 'user-uuid';
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 15: Uso en Otros Servicios
|
|
|
|
```typescript
|
|
// Ejemplo: ProjectService con filtro por tenant
|
|
@Injectable()
|
|
export class ProjectService {
|
|
constructor(
|
|
@InjectRepository(Project)
|
|
private readonly projectRepository: Repository<Project>,
|
|
) {}
|
|
|
|
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);
|
|
}
|
|
|
|
async findOne(tenantId: string, projectId: string): Promise<Project> {
|
|
const project = await this.projectRepository.findOne({
|
|
where: { id: projectId, tenant_id: tenantId }, // SIEMPRE filtrar por tenant
|
|
});
|
|
if (!project) {
|
|
throw new NotFoundException('Proyecto no encontrado');
|
|
}
|
|
return project;
|
|
}
|
|
}
|
|
|
|
// En el controller
|
|
@Get('projects')
|
|
@UseGuards(JwtAuthGuard, TenantMemberGuard)
|
|
async getProjects(@CurrentTenant('tenantId') tenantId: string) {
|
|
return this.projectService.findAll(tenantId);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Variables de Entorno
|
|
|
|
```env
|
|
# Multi-tenancy
|
|
ENABLE_MULTITENANCY=true
|
|
DEFAULT_TENANT_SLUG=main
|
|
|
|
# Límites por defecto para nuevos tenants
|
|
DEFAULT_MAX_USERS=10
|
|
DEFAULT_MAX_STORAGE_GB=1
|
|
|
|
# Tiers de suscripción (JSON para configuración)
|
|
SUBSCRIPTION_TIERS={"free":{"maxUsers":10,"maxStorage":1},"basic":{"maxUsers":50,"maxStorage":10},"pro":{"maxUsers":200,"maxStorage":50},"enterprise":{"maxUsers":-1,"maxStorage":-1}}
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist de Implementación
|
|
|
|
- [ ] Entidades Tenant y Membership creadas
|
|
- [ ] DTOs de validación creados
|
|
- [ ] TenantService implementado
|
|
- [ ] MembershipService implementado
|
|
- [ ] TenantContextMiddleware configurado
|
|
- [ ] Guards (TenantMember, TenantAdmin, TenantOwner) creados
|
|
- [ ] Decoradores (CurrentTenant, TenantRoles) creados
|
|
- [ ] TenantController con endpoints básicos
|
|
- [ ] TenantsModule registrado en AppModule
|
|
- [ ] Migraciones SQL ejecutadas
|
|
- [ ] Variables de entorno configuradas
|
|
- [ ] Build pasa sin errores
|
|
- [ ] Tests de integración pasan
|
|
|
|
---
|
|
|
|
## Verificar Funcionamiento
|
|
|
|
```bash
|
|
# 1. Crear tenant
|
|
curl -X POST http://localhost:3000/api/tenants \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"name": "Mi Empresa"}'
|
|
|
|
# 2. Listar mis tenants
|
|
curl http://localhost:3000/api/tenants \
|
|
-H "Authorization: Bearer $TOKEN"
|
|
|
|
# 3. Acceder con header X-Tenant-ID
|
|
curl http://localhost:3000/api/projects \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H "X-Tenant-ID: tenant-uuid"
|
|
|
|
# 4. Verificar acceso denegado a otro tenant
|
|
curl http://localhost:3000/api/projects \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H "X-Tenant-ID: otro-tenant-uuid"
|
|
# Debería retornar 403 Forbidden
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Error: "No tienes acceso a este tenant"
|
|
- Verificar que el usuario tenga membresía activa en el tenant
|
|
- Verificar que el tenant_id en el header sea correcto (UUID o slug)
|
|
|
|
### Error: "Tenant context requerido"
|
|
- El guard TenantMemberGuard requiere header X-Tenant-ID
|
|
- Verificar que el middleware esté configurado correctamente
|
|
|
|
### Los datos se mezclan entre tenants
|
|
- Verificar que TODOS los queries filtren por tenant_id
|
|
- Considerar implementar RLS para seguridad adicional
|
|
|
|
### Límite de usuarios no funciona
|
|
- Verificar que checkLimits() se llame antes de crear membresías
|
|
- Verificar configuración de maxUsers en el tenant
|
|
|
|
---
|
|
|
|
**Versión:** 1.0.0
|
|
**Sistema:** SIMCO Catálogo
|