New projects created: - michangarrito (marketplace mobile) - template-saas (SaaS template) - clinica-dental (dental ERP) - clinica-veterinaria (veterinary ERP) Architecture updates: - Move catalog from core/ to shared/ - Add MCP servers structure and templates - Add git management scripts - Update SUBREPOSITORIOS.md with 15 new repos - Update .gitignore for new projects Repository infrastructure: - 4 main repositories - 11 subrepositorios - Gitea remotes configured 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
27 KiB
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