workspace-v1/shared/libs/multi-tenancy/IMPLEMENTATION.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
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
2026-01-04 03:37:42 -06:00

27 KiB

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

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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

-- 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:

-- 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

// 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

# 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

# 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