# 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; 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; @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, ) {} async create(dto: CreateTenantDto): Promise { 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 { const tenant = await this.tenantRepository.findOne({ where: { id } }); if (!tenant) { throw new NotFoundException('Tenant no encontrado'); } return tenant; } async findBySlug(slug: string): Promise { 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 { 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, private readonly tenantService: TenantService, ) {} async findByUserAndTenant(userId: string, tenantId: string): Promise { return this.membershipRepository.findOne({ where: { userId, tenantId }, }); } async findAllByUser(userId: string): Promise { return this.membershipRepository.find({ where: { userId, status: 'active' }, relations: ['tenant'], }); } async findAllByTenant(tenantId: string): Promise { return this.membershipRepository.find({ where: { tenantId }, relations: ['user'], }); } async createOwnerMembership(userId: string, tenantId: string): Promise { 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 { // 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 { 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 { 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 { 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( 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, ) {} async findAll(tenantId: string): Promise { return this.projectRepository.find({ where: { tenant_id: tenantId }, }); } async create(tenantId: string, dto: CreateProjectDto): Promise { const project = this.projectRepository.create({ ...dto, tenant_id: tenantId, // SIEMPRE asignar tenant }); return this.projectRepository.save(project); } async findOne(tenantId: string, projectId: string): Promise { 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