- 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>
341 lines
7.0 KiB
Markdown
341 lines
7.0 KiB
Markdown
# 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.
|
|
|
|
```sql
|
|
-- 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.
|
|
|
|
```sql
|
|
-- 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.
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// SIEMPRE asignar tenant_id en creacion
|
|
const client = this.clientRepository.create({
|
|
...createClientDto,
|
|
tenant_id: tenantId, // OBLIGATORIO
|
|
});
|
|
```
|
|
|
|
### En Queries
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|