platform-marketing-content/orchestration/directivas/DIRECTIVA-ARQUITECTURA-MULTI-TENANT.md
rckrdmrd 74b5ed7f38 feat: Complete documentation update and orchestration configuration
- Update vision, architecture and technical documentation
- Update module definitions (PMC-001 to PMC-008)
- Update requirements documentation
- Add CONTEXT-MAP.yml and ENVIRONMENT-INVENTORY.yml
- Add orchestration guidelines and references

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:38:31 -06:00

7.0 KiB

Directiva: Arquitectura Multi-Tenant PMC

Version: 1.0.0 Fecha: 2025-12-08 Estado: Activa Referencia Catalogo: @CATALOG_TENANT


Proposito

Define la estrategia de multi-tenancy para PMC, garantizando aislamiento de datos entre organizaciones (agencias) mientras se mantiene eficiencia operativa.


Estrategia Seleccionada

Tipo: Single Database, Shared Schema con Row-Level Security (RLS)

Justificacion: Ver ADR-002-multi-tenancy.md


Principios

P1: Aislamiento Total de Datos

Todo dato de negocio debe estar asociado a un tenant y ser invisible para otros tenants.

-- OBLIGATORIO en todas las tablas de negocio
tenant_id UUID NOT NULL REFERENCES auth.tenants(id)

P2: Filtrado Automatico

El filtrado por tenant debe ser automatico, no depender del desarrollador.

-- RLS policy en cada tabla
CREATE POLICY tenant_isolation ON {tabla}
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

P3: Context Propagation

El tenant_id debe propagarse automaticamente en cada request.

// Middleware extrae tenant del JWT
const tenantId = req.user.tenantId;
await dataSource.query(`SET app.current_tenant = '${tenantId}'`);

P4: Storage Isolation

Archivos deben estar segregados por tenant en storage.

bucket/{tenant_slug}/assets/
bucket/{tenant_slug}/generated/
bucket/{tenant_slug}/models/

Implementacion

1. Middleware de Tenant

@Injectable()
export class TenantMiddleware implements NestMiddleware {
  constructor(private dataSource: DataSource) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const tenantId = req.user?.tenantId;

    if (tenantId) {
      // Set para RLS
      await this.dataSource.query(
        `SET app.current_tenant = '${tenantId}'`
      );

      // Agregar al request context
      req.tenantContext = { tenantId };
    }

    next();
  }
}

2. Decorador de Tenant

export const CurrentTenant = createParamDecorator(
  (data: unknown, ctx: ExecutionContext): string => {
    const request = ctx.switchToHttp().getRequest();
    return request.tenantContext?.tenantId;
  },
);

// Uso
@Get()
async findAll(@CurrentTenant() tenantId: string) {
  // tenantId ya disponible
}

3. BaseEntity con tenant_id

export abstract class TenantAwareEntity {
  @Column('uuid')
  tenant_id: string;

  @CreateDateColumn()
  created_at: Date;

  @UpdateDateColumn()
  updated_at: Date;

  @DeleteDateColumn()
  deleted_at: Date;
}

// Uso
@Entity('clients', { schema: 'crm' })
export class Client extends TenantAwareEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;
}

4. Service Base con Tenant

export abstract class TenantAwareService<T> {
  constructor(
    protected readonly repository: Repository<T>,
  ) {}

  async findAllByTenant(tenantId: string): Promise<T[]> {
    return this.repository.find({
      where: { tenant_id: tenantId } as any,
    });
  }

  async createForTenant(tenantId: string, dto: any): Promise<T> {
    const entity = this.repository.create({
      ...dto,
      tenant_id: tenantId,
    });
    return this.repository.save(entity);
  }
}

Tablas sin tenant_id

Las siguientes tablas son globales (no tienen tenant_id):

Tabla Razon
auth.tenants Es la tabla de tenants
auth.plans Planes son globales
generation.workflow_templates (is_system=true) Templates del sistema
config.feature_flags (tenant_id IS NULL) Flags globales

Validaciones Obligatorias

En Creacion de Entidad

// SIEMPRE asignar tenant_id en creacion
const client = this.clientRepository.create({
  ...createClientDto,
  tenant_id: tenantId, // OBLIGATORIO
});

En Queries

// SIEMPRE filtrar por tenant (aunque RLS lo haga)
const clients = await this.clientRepository.find({
  where: { tenant_id: tenantId },
});

// NUNCA queries sin filtro de tenant en tablas tenant-aware
// INCORRECTO:
const allClients = await this.clientRepository.find(); // PROHIBIDO

En Updates/Deletes

// SIEMPRE verificar propiedad antes de modificar
const client = await this.clientRepository.findOne({
  where: { id: clientId, tenant_id: tenantId },
});

if (!client) {
  throw new NotFoundException('Client not found');
}

Limites y Quotas por Tenant

interface TenantLimits {
  max_users: number;
  max_brands: number;
  max_products: number;
  max_storage_gb: number;
  generations_per_month: number;
  trainings_per_month: number;
}

// Verificar antes de operaciones
async checkQuota(tenantId: string, resource: string): Promise<boolean> {
  const tenant = await this.tenantService.findById(tenantId);
  const usage = await this.usageService.getCurrentUsage(tenantId, resource);

  return usage < tenant.limits[resource];
}

Testing Multi-Tenant

describe('ClientService (multi-tenant)', () => {
  it('should not return clients from other tenants', async () => {
    // Crear clientes en tenant A y B
    await service.create(tenantA, { name: 'Client A' });
    await service.create(tenantB, { name: 'Client B' });

    // Query desde tenant A
    const clients = await service.findAll(tenantA);

    // Solo debe ver sus clientes
    expect(clients).toHaveLength(1);
    expect(clients[0].name).toBe('Client A');
  });
});

Errores Comunes

ERROR 1: Olvidar tenant_id en INSERT

// INCORRECTO
const brand = this.brandRepository.create({
  name: dto.name,
  // Falta tenant_id!
});

// CORRECTO
const brand = this.brandRepository.create({
  name: dto.name,
  tenant_id: tenantId,
});

ERROR 2: Query sin filtro de tenant

// INCORRECTO - Expone datos de otros tenants si RLS falla
const brand = await this.brandRepository.findOne({
  where: { id: brandId },
});

// CORRECTO
const brand = await this.brandRepository.findOne({
  where: { id: brandId, tenant_id: tenantId },
});

ERROR 3: No validar propiedad en acciones

// INCORRECTO - Puede modificar recursos de otro tenant
async delete(brandId: string) {
  await this.brandRepository.delete(brandId);
}

// CORRECTO
async delete(tenantId: string, brandId: string) {
  const result = await this.brandRepository.delete({
    id: brandId,
    tenant_id: tenantId,
  });

  if (result.affected === 0) {
    throw new NotFoundException();
  }
}

Checklist de Implementacion

  • Tabla tiene columna tenant_id
  • Foreign key a auth.tenants
  • Index en tenant_id
  • RLS policy creada
  • Entity extiende TenantAwareEntity
  • Service usa TenantAwareService base
  • Controller usa @CurrentTenant()
  • Tests verifican aislamiento
  • Storage usa prefijo de tenant

Referencias

  • Catalogo: shared/catalog/multi-tenancy/README.md
  • ADR: docs/97-adr/ADR-002-multi-tenancy.md
  • Patrones RLS: shared/catalog/multi-tenancy/patterns/rls-policies.md

Generado por: Requirements-Analyst Fecha: 2025-12-08