45 KiB
RF-AUTH-003: Multi-tenancy por Constructora
📋 Metadata
| Campo | Valor |
|---|---|
| ID | RF-AUTH-003 |
| Épica | MAI-001 - Fundamentos |
| Módulo | Autenticación y Multi-tenancy |
| Prioridad | Crítica |
| Estado | 🚧 Planificado |
| Versión | 1.0 |
| Fecha creación | 2025-11-17 |
| Última actualización | 2025-11-17 |
| Esfuerzo estimado | 18h |
| Story Points | 8 SP |
🔗 Referencias
Especificación Técnica
📐 ET-AUTH-003: Multi-tenancy Implementation (Pendiente)
Origen (GAMILIT)
♻️ Reutilización: 0% - Funcionalidad nueva
- Justificación: GAMILIT no requiere multi-tenancy a nivel de organización
- Adaptación: Implementación completa desde cero para construcción
- Beneficio: Permite que profesionales trabajen en múltiples constructoras
Documentos Relacionados
- 📄 RF-AUTH-001: Sistema de Roles - Roles pueden variar por constructora
- 📄 RF-AUTH-002: Estados de Cuenta - Estados pueden variar por constructora
- 📄 US-FUND-001: Autenticación Básica JWT - Login con selector de constructora
Implementación DDL
🗄️ Tablas Principales:
-- apps/database/ddl/schemas/auth_management/tables/constructoras.sql
CREATE TABLE auth_management.constructoras (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
nombre VARCHAR(255) NOT NULL,
razon_social VARCHAR(500) NOT NULL,
rfc VARCHAR(13) NOT NULL UNIQUE,
logo_url VARCHAR(1000),
settings JSONB DEFAULT '{}'::JSONB,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_constructoras_rfc ON auth_management.constructoras(rfc);
CREATE INDEX idx_constructoras_active ON auth_management.constructoras(active);
-- apps/database/ddl/schemas/auth_management/tables/user-constructoras.sql
CREATE TABLE auth_management.user_constructoras (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth_management.profiles(id) ON DELETE CASCADE,
constructora_id UUID NOT NULL REFERENCES auth_management.constructoras(id) ON DELETE CASCADE,
role construction_role NOT NULL,
status user_status NOT NULL DEFAULT 'active',
is_primary BOOLEAN DEFAULT FALSE,
invited_by UUID REFERENCES auth_management.profiles(id),
invited_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
joined_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, constructora_id)
);
CREATE INDEX idx_user_constructoras_user ON auth_management.user_constructoras(user_id);
CREATE INDEX idx_user_constructoras_constructora ON auth_management.user_constructoras(constructora_id);
CREATE INDEX idx_user_constructoras_status ON auth_management.user_constructoras(user_id, status);
-- Constraint: Solo una constructora primaria por usuario
CREATE UNIQUE INDEX idx_user_primary_constructora
ON auth_management.user_constructoras(user_id)
WHERE is_primary = TRUE;
🗄️ Funciones de Context:
-- apps/database/ddl/schemas/auth_management/functions/context.sql
-- Obtener constructora actual del usuario (desde JWT)
CREATE OR REPLACE FUNCTION auth_management.get_current_constructora_id()
RETURNS UUID AS $$
BEGIN
RETURN NULLIF(current_setting('app.current_constructora_id', true), '')::UUID;
EXCEPTION WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
-- Verificar si usuario tiene acceso a constructora
CREATE OR REPLACE FUNCTION auth_management.user_has_access_to_constructora(
p_user_id UUID,
p_constructora_id UUID
) RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM auth_management.user_constructoras
WHERE user_id = p_user_id
AND constructora_id = p_constructora_id
AND status = 'active'
);
END;
$$ LANGUAGE plpgsql STABLE;
-- Obtener rol del usuario en constructora actual
CREATE OR REPLACE FUNCTION auth_management.get_user_role_in_constructora(
p_user_id UUID,
p_constructora_id UUID
) RETURNS construction_role AS $$
DECLARE
v_role construction_role;
BEGIN
SELECT role INTO v_role
FROM auth_management.user_constructoras
WHERE user_id = p_user_id
AND constructora_id = p_constructora_id
AND status = 'active';
RETURN v_role;
END;
$$ LANGUAGE plpgsql STABLE;
-- Obtener constructoras activas del usuario
CREATE OR REPLACE FUNCTION auth_management.get_user_active_constructoras(
p_user_id UUID
) RETURNS TABLE (
constructora_id UUID,
nombre VARCHAR,
role construction_role,
is_primary BOOLEAN
) AS $$
BEGIN
RETURN QUERY
SELECT
c.id,
c.nombre,
uc.role,
uc.is_primary
FROM auth_management.user_constructoras uc
INNER JOIN auth_management.constructoras c ON c.id = uc.constructora_id
WHERE uc.user_id = p_user_id
AND uc.status = 'active'
AND c.active = TRUE
ORDER BY uc.is_primary DESC, c.nombre ASC;
END;
$$ LANGUAGE plpgsql STABLE;
🗄️ Row Level Security (RLS) Policies:
-- Ejemplo: Tabla de proyectos con RLS por constructora
CREATE POLICY "users_view_own_constructora_projects"
ON projects.projects
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_access_to_constructora(
auth_management.get_current_user_id(),
constructora_id
)
);
CREATE POLICY "users_create_in_own_constructora"
ON projects.projects
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = auth_management.get_current_constructora_id()
);
Backend
💻 Implementación:
// apps/backend/src/modules/auth/entities/constructora.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('constructoras', { schema: 'auth_management' })
export class Constructora {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
nombre: string;
@Column({ type: 'varchar', length: 500 })
razonSocial: string;
@Column({ type: 'varchar', length: 13, unique: true })
rfc: string;
@Column({ type: 'varchar', length: 1000, nullable: true })
logoUrl: string;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
@Column({ type: 'boolean', default: true })
active: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
// apps/backend/src/modules/auth/entities/user-constructora.entity.ts
@Entity('user_constructoras', { schema: 'auth_management' })
export class UserConstructora {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
userId: string;
@Column({ type: 'uuid' })
constructoraId: string;
@Column({ type: 'enum', enum: ConstructionRole })
role: ConstructionRole;
@Column({ type: 'enum', enum: UserStatus, default: UserStatus.ACTIVE })
status: UserStatus;
@Column({ type: 'boolean', default: false })
isPrimary: boolean;
@Column({ type: 'uuid', nullable: true })
invitedBy: string;
@Column({ type: 'timestamp with time zone', default: () => 'NOW()' })
invitedAt: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
joinedAt: Date;
@ManyToOne(() => Profile)
@JoinColumn({ name: 'user_id' })
user: Profile;
@ManyToOne(() => Constructora)
@JoinColumn({ name: 'constructora_id' })
constructora: Constructora;
}
// apps/backend/src/modules/auth/guards/constructora.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
@Injectable()
export class ConstructoraGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
// Validar que el usuario tenga acceso a la constructora
if (!user.constructoraId) {
throw new ForbiddenException('No se ha seleccionado una constructora');
}
const hasAccess = await this.userConstructoraRepository.findOne({
where: {
userId: user.id,
constructoraId: user.constructoraId,
status: UserStatus.ACTIVE,
},
});
if (!hasAccess) {
throw new ForbiddenException(
'No tienes acceso a esta constructora o tu acceso está inactivo'
);
}
return true;
}
}
Frontend
🎨 Componentes:
// apps/frontend/src/features/auth/ConstructoraSelector.tsx
interface ConstructoraSelectorProps {
constructoras: Constructora[];
onSelect: (constructoraId: string) => void;
}
export const ConstructoraSelector: React.FC<ConstructoraSelectorProps> = ({
constructoras,
onSelect,
}) => {
return (
<div className="constructora-selector">
<h3>Selecciona una constructora</h3>
<div className="constructora-grid">
{constructoras.map(constructora => (
<button
key={constructora.id}
onClick={() => onSelect(constructora.id)}
className="constructora-card"
>
<img src={constructora.logoUrl} alt={constructora.nombre} />
<h4>{constructora.nombre}</h4>
<span className="role-badge">{constructora.role}</span>
{constructora.isPrimary && <span className="primary-badge">Principal</span>}
</button>
))}
</div>
</div>
);
};
// apps/frontend/src/stores/constructora-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ConstructoraStore {
currentConstructora: Constructora | null;
constructoras: Constructora[];
setCurrentConstructora: (constructora: Constructora) => void;
setConstructoras: (constructoras: Constructora[]) => void;
switchConstructora: (constructoraId: string) => Promise<void>;
}
export const useConstructoraStore = create<ConstructoraStore>()(
persist(
(set, get) => ({
currentConstructora: null,
constructoras: [],
setCurrentConstructora: (constructora) => set({ currentConstructora: constructora }),
setConstructoras: (constructoras) => set({ constructoras }),
switchConstructora: async (constructoraId) => {
const response = await api.post('/auth/switch-constructora', {
constructoraId,
});
const newToken = response.data.accessToken;
localStorage.setItem('accessToken', newToken);
const constructora = get().constructoras.find(c => c.id === constructoraId);
set({ currentConstructora: constructora });
// Recargar página para actualizar contexto
window.location.reload();
},
}),
{
name: 'constructora-storage',
}
)
);
Trazabilidad
📝 Descripción del Requerimiento
Contexto
En la industria de la construcción, es común que profesionales trabajen simultáneamente para múltiples empresas constructoras:
Ejemplos del Mundo Real:
- 🏗️ Ingeniero Freelance: Proporciona servicios de planeación a 3 constructoras diferentes
- 👨💼 Residente de Obra: Trabaja medio tiempo en "Constructora A" y medio tiempo en "Constructora B"
- 💼 Director de Proyectos: Socio en 2 constructoras y consultor externo en 1 más
- 📊 Contador Externo: Lleva contabilidad de 5 constructoras pequeñas
- 🛒 Coordinador de Compras: Trabaja para 2 constructoras del mismo grupo empresarial
Necesidad del Negocio
Problema (Sin Multi-tenancy): Sin un sistema multi-tenant robusto:
- ❌ Usuario necesita múltiples cuentas: Un ingeniero que trabaja en 3 constructoras necesitaría 3 emails diferentes
- ❌ No hay aislamiento de datos: Riesgo de que constructora A vea datos de constructora B
- ❌ Complejidad en permisos: No se puede modelar que un usuario sea "director" en una empresa y "residente" en otra
- ❌ Experiencia de usuario pobre: Usuario debe cerrar sesión y volver a iniciar en cada empresa
- ❌ Difícil auditoría: No queda claro en qué contexto (constructora) se realizó cada acción
Solución (Multi-tenancy por Constructora): ✅ Un email, múltiples constructoras: Usuario accede con un solo email a todas sus constructoras ✅ Aislamiento total de datos: RLS garantiza que datos de cada constructora estén separados ✅ Roles por constructora: Usuario puede ser "director" en A y "resident" en B simultáneamente ✅ Cambio fluido: Usuario cambia de constructora sin cerrar sesión (switch token) ✅ Auditoría clara: Cada acción registra en qué constructora se realizó
🎯 Requerimiento Funcional
RF-AUTH-003.1: Modelo de Datos Multi-tenant
El sistema DEBE implementar un modelo de multi-tenancy donde:
Entidades Principales
1. Constructora (Tenant)
interface Constructora {
id: string; // UUID
nombre: string; // "Constructora ABC S.A. de C.V."
razonSocial: string; // Razón social oficial
rfc: string; // RFC único (13 caracteres)
logoUrl: string; // URL del logo
settings: {
timezone?: string; // "America/Mexico_City"
currency?: string; // "MXN"
locale?: string; // "es-MX"
fiscalRegime?: string; // "601 - General de Ley Personas Morales"
mainAddress?: Address;
billingConfig?: BillingConfig;
};
active: boolean; // true = operando, false = inactiva
createdAt: Date;
updatedAt: Date;
}
Validaciones:
rfc: Debe ser RFC válido mexicano (13 caracteres para persona moral)nombre: Mínimo 3 caracteres, máximo 255razonSocial: Mínimo 5 caracteres, máximo 500active: Solo super_admin puede cambiar
2. User-Constructora (Relación Many-to-Many)
interface UserConstructora {
id: string;
userId: string; // Usuario
constructoraId: string; // Constructora
role: ConstructionRole; // Rol del usuario EN ESTA constructora
status: UserStatus; // Estado EN ESTA constructora
isPrimary: boolean; // true = constructora principal del usuario
invitedBy: string; // UUID del usuario que invitó
invitedAt: Date; // Fecha de invitación
joinedAt: Date | null; // Fecha en que aceptó invitación (verificó email)
createdAt: Date;
updatedAt: Date;
}
Validaciones:
userId+constructoraId: Unique constraint (usuario no puede estar duplicado en misma constructora)isPrimary: Solo UNA constructora puede ser primaria por usuariorole: Puede ser diferente en cada constructorastatus: Puede ser diferente en cada constructora (ej: activo en A, suspendido en B)
RF-AUTH-003.2: Flujo de Invitación a Constructora
Caso 1: Usuario Nuevo (No existe en sistema)
Flujo:
- Director de Constructora A invita a "ingeniero@email.com"
- Sistema verifica que email NO existe en
profiles - Sistema crea registro en
invitations:{ email: "ingeniero@email.com", constructoraId: "constructora-a-uuid", role: "engineer", invitedBy: "director-uuid", token: "random-secure-token", expiresAt: NOW() + 7 days } - Sistema envía email:
Asunto: Has sido invitado a Constructora ABC Hola, El Director López te ha invitado a unirte a Constructora ABC como Ingeniero. Para aceptar la invitación, haz click aquí: https://app.constructora.com/auth/accept-invitation?token=xyz123 Esta invitación expira en 7 días. - Usuario hace click en link
- Sistema muestra formulario de registro:
- Email: ingeniero@email.com (pre-llenado, readonly)
- Nombre completo
- Contraseña
- Confirmar contraseña
- Usuario completa formulario y hace click en "Registrarme"
- Sistema ejecuta transacción:
BEGIN; -- Crear perfil INSERT INTO auth_management.profiles (email, password_hash, full_name, status) VALUES ('ingeniero@email.com', hash, 'Juan Pérez', 'pending'); -- Asociar a constructora INSERT INTO auth_management.user_constructoras ( user_id, constructora_id, role, status, is_primary, invited_by, joined_at ) VALUES ( new_user_id, 'constructora-a-uuid', 'engineer', 'pending', true, 'director-uuid', NOW() ); -- Marcar invitación como aceptada UPDATE invitations SET status = 'accepted' WHERE token = 'xyz123'; COMMIT; - Sistema envía email de verificación
- Usuario verifica email →
profiles.statusyuser_constructoras.statuscambian a'active' - Usuario puede hacer login
Resultado: Usuario nuevo creado y asociado a su primera constructora
Caso 2: Usuario Existente (Ya registrado)
Flujo:
- Director de Constructora B invita a "ingeniero@email.com" (que ya trabaja en Constructora A)
- Sistema detecta que email YA existe en
profiles - Sistema crea invitación:
{ email: "ingeniero@email.com", userId: "existing-user-uuid", // Ya conocido constructoraId: "constructora-b-uuid", role: "resident", // Diferente rol invitedBy: "director-b-uuid", token: "random-secure-token", expiresAt: NOW() + 7 days } - Sistema envía email:
Asunto: Nueva invitación a Constructora XYZ Hola Juan, El Director Gómez te ha invitado a unirte a Constructora XYZ como Residente de Obra. Para aceptar la invitación, haz click aquí: https://app.constructora.com/auth/accept-invitation?token=abc456 Esta invitación expira en 7 días. - Usuario (ya tiene cuenta) hace click en link
- Sistema detecta que usuario está autenticado O solicita login
- Sistema muestra confirmación:
Constructora XYZ te ha invitado como Residente de Obra ¿Deseas aceptar esta invitación? [Aceptar] [Rechazar] - Usuario hace click en "Aceptar"
- Sistema asocia usuario a nueva constructora:
INSERT INTO auth_management.user_constructoras ( user_id, constructora_id, role, status, is_primary, invited_by, joined_at ) VALUES ( 'existing-user-uuid', 'constructora-b-uuid', 'resident', 'active', false, 'director-b-uuid', NOW() ); - Sistema muestra:
¡Listo! Ahora tienes acceso a Constructora XYZ Tus constructoras: - Constructora ABC (Ingeniero) ⭐ Principal - Constructora XYZ (Residente de Obra) [Ir a Constructora XYZ]
Resultado: Usuario existente asociado a nueva constructora con rol diferente
RF-AUTH-003.3: Login Multi-tenant
El sistema DEBE manejar login de usuarios con acceso a múltiples constructoras:
Flujo de Login
Paso 1: Credenciales
// POST /api/auth/login
{
"email": "ingeniero@email.com",
"password": "password123"
}
Paso 2: Validación
async login(email: string, password: string) {
// 1. Buscar usuario
const user = await this.profileRepository.findOne({ where: { email } });
// 2. Validar password
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) throw new UnauthorizedException('Invalid credentials');
// 3. Validar estado global
if (user.status === 'banned') {
throw new UnauthorizedException('Account banned');
}
// 4. Obtener constructoras activas
const constructoras = await this.getActiveConstructoras(user.id);
if (constructoras.length === 0) {
throw new UnauthorizedException('No active access to any constructora');
}
// 5A. Si solo tiene 1 constructora: login directo
if (constructoras.length === 1) {
const token = this.generateJwt(user, constructoras[0]);
return {
accessToken: token,
user: user,
currentConstructora: constructoras[0],
};
}
// 5B. Si tiene múltiples: retornar lista para que elija
return {
requiresConstructoraSelection: true,
user: user,
constructoras: constructoras, // Usuario debe elegir
};
}
Paso 3A: Respuesta (1 sola constructora)
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "user-uuid",
"email": "ingeniero@email.com",
"fullName": "Juan Pérez"
},
"currentConstructora": {
"id": "constructora-a-uuid",
"nombre": "Constructora ABC",
"role": "engineer"
}
}
Paso 3B: Respuesta (Múltiples constructoras)
{
"requiresConstructoraSelection": true,
"user": {
"id": "user-uuid",
"email": "ingeniero@email.com",
"fullName": "Juan Pérez"
},
"constructoras": [
{
"id": "constructora-a-uuid",
"nombre": "Constructora ABC",
"logoUrl": "https://...",
"role": "engineer",
"isPrimary": true
},
{
"id": "constructora-b-uuid",
"nombre": "Constructora XYZ",
"logoUrl": "https://...",
"role": "resident",
"isPrimary": false
}
]
}
Paso 4: Usuario selecciona constructora
// POST /api/auth/select-constructora
{
"userId": "user-uuid",
"constructoraId": "constructora-b-uuid",
"tempToken": "temp-token-from-step-3" // Token temporal de 5 min
}
// Respuesta:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"currentConstructora": {
"id": "constructora-b-uuid",
"nombre": "Constructora XYZ",
"role": "resident"
}
}
JWT Payload:
interface JwtPayload {
sub: string; // userId
email: string;
fullName: string;
constructoraId: string; // 🔑 Constructora seleccionada
role: ConstructionRole; // 🔑 Rol EN ESTA constructora
iat: number;
exp: number;
}
RF-AUTH-003.4: Cambio de Constructora (Switch)
El sistema DEBE permitir cambiar de constructora sin cerrar sesión:
Flujo de Switch
Paso 1: Usuario hace click en selector de constructora en UI
<ConstructoraSwitcher
current={currentConstructora}
available={userConstructoras}
onSwitch={(constructoraId) => switchConstructora(constructoraId)}
/>
Paso 2: Request al backend
// POST /api/auth/switch-constructora
// Headers: Authorization: Bearer <current-token>
{
"constructoraId": "constructora-b-uuid"
}
Paso 3: Backend valida y genera nuevo token
@Post('switch-constructora')
@UseGuards(JwtAuthGuard) // Requiere estar autenticado
async switchConstructora(
@CurrentUser() user: User,
@Body() dto: SwitchConstructoraDto
): Promise<{ accessToken: string }> {
// 1. Validar que usuario tiene acceso a constructora destino
const hasAccess = await this.userConstructoraRepository.findOne({
where: {
userId: user.id,
constructoraId: dto.constructoraId,
status: UserStatus.ACTIVE,
},
relations: ['constructora'],
});
if (!hasAccess) {
throw new ForbiddenException('No tienes acceso a esta constructora');
}
// 2. Generar nuevo JWT con nueva constructora
const newToken = this.jwtService.sign({
sub: user.id,
email: user.email,
fullName: user.fullName,
constructoraId: dto.constructoraId,
role: hasAccess.role, // Nuevo rol (puede ser diferente)
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60), // 24h
});
// 3. Auditar cambio de contexto
await this.auditService.log({
action: 'switch_constructora',
userId: user.id,
details: {
from: user.constructoraId,
to: dto.constructoraId,
timestamp: new Date(),
},
});
return { accessToken: newToken };
}
Paso 4: Frontend actualiza token y contexto
async function switchConstructora(constructoraId: string) {
const response = await api.post('/auth/switch-constructora', {
constructoraId,
});
// Actualizar token en localStorage
localStorage.setItem('accessToken', response.data.accessToken);
// Actualizar estado global
useConstructoraStore.getState().setCurrentConstructora(
constructoras.find(c => c.id === constructoraId)
);
// Recargar aplicación para aplicar nuevo contexto
window.location.reload();
}
Resultado:
- ✅ Usuario cambia de constructora sin volver a hacer login
- ✅ Nuevo token con
constructoraIdyroleactualizados - ✅ RLS en base de datos usa nuevo contexto automáticamente
- ✅ UI se actualiza mostrando datos de nueva constructora
RF-AUTH-003.5: Aislamiento de Datos (Row Level Security)
El sistema DEBE garantizar aislamiento TOTAL de datos entre constructoras usando RLS:
Implementación RLS
Paso 1: Configurar contexto en cada request
// apps/backend/src/common/interceptors/set-rls-context.interceptor.ts
@Injectable()
export class SetRlsContextInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user = request.user; // Del JWT
if (user) {
// Configurar variables de sesión de PostgreSQL
return from(
this.dataSource.query(`
SELECT set_config('app.current_user_id', $1, true),
set_config('app.current_constructora_id', $2, true),
set_config('app.current_user_role', $3, true)
`, [user.id, user.constructoraId, user.role])
).pipe(
switchMap(() => next.handle())
);
}
return next.handle();
}
}
Paso 2: Políticas RLS en todas las tablas de negocio
-- Ejemplo: Tabla de proyectos
CREATE POLICY "users_view_own_constructora_projects"
ON projects.projects
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
);
CREATE POLICY "users_create_in_own_constructora"
ON projects.projects
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = auth_management.get_current_constructora_id()
);
-- Ejemplo: Tabla de empleados
CREATE POLICY "users_view_own_constructora_employees"
ON hr.employees
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
);
-- Ejemplo: Tabla de presupuestos
CREATE POLICY "directors_engineers_view_budgets"
ON budgets.budgets
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.get_current_user_role() IN ('director', 'engineer', 'finance')
);
Paso 3: Testing de aislamiento
describe('Multi-tenancy Data Isolation', () => {
it('should NOT allow user to see projects from other constructora', async () => {
// Setup: 2 constructoras con 1 proyecto cada una
const constructoraA = await createConstructora({ nombre: 'Constructora A' });
const constructoraB = await createConstructora({ nombre: 'Constructora B' });
const projectA = await createProject({
nombre: 'Proyecto A',
constructoraId: constructoraA.id,
});
const projectB = await createProject({
nombre: 'Proyecto B',
constructoraId: constructoraB.id,
});
// Usuario con acceso SOLO a constructora A
const user = await createUser();
await assignToConstructora(user.id, constructoraA.id, 'engineer');
const token = await loginAs(user, constructoraA.id);
// Act: Solicitar todos los proyectos
const response = await request(app.getHttpServer())
.get('/projects')
.set('Authorization', `Bearer ${token}`)
.expect(200);
// Assert: Solo debe ver proyecto de constructora A
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].id).toBe(projectA.id);
expect(response.body.data[0].nombre).toBe('Proyecto A');
// Proyecto B NO debe aparecer
const projectBInResponse = response.body.data.find(p => p.id === projectB.id);
expect(projectBInResponse).toBeUndefined();
});
});
RF-AUTH-003.6: Constructora Principal (Primary)
El sistema DEBE soportar concepto de "constructora principal":
Características
Definición:
- Usuario designa UNA constructora como "principal"
- Al hacer login con múltiples constructoras, se selecciona automáticamente la principal
- Usuario puede cambiar cuál es la principal en cualquier momento
Reglas:
- Solo UNA constructora puede ser principal por usuario
- Constraint a nivel de base de datos garantiza unicidad
- Si usuario elimina su única constructora principal, debe designar otra
Implementación:
// PATCH /api/user/set-primary-constructora
async setPrimaryConstructora(
userId: string,
constructoraId: string
): Promise<void> {
// 1. Validar que usuario tiene acceso
const access = await this.userConstructoraRepository.findOne({
where: { userId, constructoraId, status: UserStatus.ACTIVE },
});
if (!access) {
throw new NotFoundException('No tienes acceso a esta constructora');
}
// 2. Transacción: quitar primary de anterior y asignar a nueva
await this.dataSource.transaction(async (manager) => {
// Quitar is_primary de todas las constructoras del usuario
await manager.update(
UserConstructora,
{ userId },
{ isPrimary: false }
);
// Asignar is_primary a la nueva
await manager.update(
UserConstructora,
{ userId, constructoraId },
{ isPrimary: true }
);
});
// 3. Auditar
await this.auditService.log({
action: 'set_primary_constructora',
userId,
details: { constructoraId },
});
}
UI:
// Componente de lista de constructoras del usuario
<div className="constructora-list">
{userConstructoras.map(uc => (
<div key={uc.id} className="constructora-item">
<img src={uc.constructora.logoUrl} />
<h4>{uc.constructora.nombre}</h4>
<span className="role-badge">{uc.role}</span>
{uc.isPrimary ? (
<span className="primary-badge">⭐ Principal</span>
) : (
<button onClick={() => setPrimary(uc.constructoraId)}>
Marcar como principal
</button>
)}
</div>
))}
</div>
📊 Casos de Uso
UC-MT-001: Ingeniero freelance trabaja en 3 constructoras
Actor: Ingeniero de Planeación Precondiciones: Usuario registrado
Flujo:
- Constructora A invita a ingeniero@email.com como "engineer"
- Ingeniero acepta, verifica email, tiene acceso a Constructora A
- Ingeniero marca Constructora A como principal
- Constructora B invita a ingeniero@email.com como "engineer"
- Ingeniero (ya autenticado) acepta invitación desde panel
- Ingeniero ahora tiene acceso a:
- Constructora A (Ingeniero) ⭐ Principal
- Constructora B (Ingeniero)
- Constructora C invita a ingeniero@email.com como "director"
- Ingeniero acepta
- Ingeniero ahora tiene:
- Constructora A (Ingeniero) ⭐ Principal
- Constructora B (Ingeniero)
- Constructora C (Director) ← Rol diferente
- Ingeniero hace login una vez
- Sistema le muestra selector de 3 constructoras
- Ingeniero selecciona Constructora A (principal pre-seleccionada)
- Ingeniero trabaja en Constructora A
- Ingeniero hace click en selector de constructora en header
- Ingeniero selecciona "Constructora C"
- Sistema regenera token con
constructoraId=Cyrole=director - UI se actualiza mostrando dashboard de director
- Ingeniero trabaja en Constructora C con permisos de director
Resultado:
- ✅ Un email, 3 constructoras
- ✅ Roles diferentes en cada constructora
- ✅ Cambio fluido entre contextos
- ✅ Datos aislados por constructora
UC-MT-002: Director crea nueva constructora e invita equipo
Actor: Director de Construcción Precondiciones: Usuario con acceso al sistema
Flujo:
- Director navega a
/admin/constructoras/create - Director completa formulario:
- Nombre: "Constructora Nueva S.A."
- Razón Social: "Constructora Nueva Sociedad Anónima de Capital Variable"
- RFC: "CNN123456ABC"
- Logo: (sube imagen)
- Director hace click en "Crear Constructora"
- Sistema crea constructora
- Sistema automáticamente asocia al director como primer usuario:
INSERT INTO user_constructoras (user_id, constructora_id, role, status, is_primary) VALUES (director_id, new_constructora_id, 'director', 'active', false); - Director navega a
/admin/users/invite - Director invita usuarios:
- ingeniero@email.com → Ingeniero
- residente1@email.com → Residente de Obra
- residente2@email.com → Residente de Obra
- compras@email.com → Compras
- Sistema envía 4 emails de invitación
- Usuarios aceptan invitaciones y verifican emails
- Director ve en panel de usuarios:
Usuarios activos en Constructora Nueva S.A.: - Director López (Director) ⭐ Tú - Ing. Juan Pérez (Ingeniero) - Residente Carlos Gómez (Residente de Obra) - Residente Ana Martínez (Residente de Obra) - María Torres (Compras)
Resultado:
- ✅ Constructora creada
- ✅ Equipo completo invitado
- ✅ Usuarios pueden acceder con diferentes roles
UC-MT-003: Usuario suspendido en constructora A pero activo en B
Actor: Director de Constructora A Precondiciones:
- Residente trabaja en Constructora A y Constructora B
- Residente cometió falta grave en Constructora A
Flujo:
- Director de Constructora A suspende al residente por 14 días
- Sistema actualiza:
UPDATE user_constructoras SET status = 'suspended' WHERE user_id = residente_id AND constructora_id = constructora_a_id; - Residente intenta hacer login
- Sistema detecta que tiene:
- Constructora A: status = 'suspended'
- Constructora B: status = 'active'
- Sistema muestra solo Constructora B en selector
- Residente hace login en Constructora B exitosamente
- Residente puede trabajar normalmente en Constructora B
- Residente intenta cambiar a Constructora A desde selector
- Sistema muestra error: "Tu acceso a Constructora A está suspendido. Contacta al administrador."
- Después de 14 días, Director de Constructora A levanta suspensión
- Sistema actualiza status a 'active'
- Residente ahora puede acceder a ambas constructoras
Resultado:
- ✅ Suspensión aislada por constructora
- ✅ No afecta acceso a otras constructoras
- ✅ Usuario puede seguir trabajando donde no está suspendido
🔐 Consideraciones de Seguridad
1. Prevención de Data Leakage entre Constructoras
Problema: Query malicioso podría intentar acceder a datos de otra constructora
Solución: RLS aplicado en TODAS las tablas de negocio
-- OBLIGATORIO en CADA tabla con constructora_id
ALTER TABLE projects.projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.employees ENABLE ROW LEVEL SECURITY;
ALTER TABLE budgets.budgets ENABLE ROW LEVEL SECURITY;
-- ... etc
-- Política base (repetir en cada tabla)
CREATE POLICY "constructora_isolation"
ON [tabla]
FOR ALL
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
);
Testing:
it('should prevent SQL injection to access other constructora data', async () => {
const userA = await createUserInConstructora('constructora-a');
const projectB = await createProjectInConstructora('constructora-b');
// Intentar inyección SQL
const maliciousQuery = `
SELECT * FROM projects.projects
WHERE constructora_id = '${projectB.constructoraId}'
-- Intentar bypass
`;
// Debe retornar 0 resultados (RLS bloquea)
const result = await executeAsUser(userA, maliciousQuery);
expect(result).toHaveLength(0);
});
2. Validación de Acceso en Cambio de Constructora
Problema: Usuario intenta cambiar a constructora a la que no tiene acceso
Solución: Validar en backend antes de generar token
async switchConstructora(userId: string, constructoraId: string) {
// 1. Verificar acceso
const hasAccess = await this.db.query(`
SELECT 1
FROM auth_management.user_constructoras
WHERE user_id = $1
AND constructora_id = $2
AND status = 'active'
`, [userId, constructoraId]);
if (hasAccess.rows.length === 0) {
throw new ForbiddenException({
statusCode: 403,
message: 'No tienes acceso a esta constructora',
errorCode: 'CONSTRUCTORA_ACCESS_DENIED',
});
}
// 2. Generar token solo si tiene acceso
return this.generateJwt(userId, constructoraId);
}
3. Auditoría de Cambios de Contexto
Problema: Difícil rastrear en qué constructora se realizó cada acción
Solución: Incluir constructora_id en TODOS los audit logs
-- Trigger en cada tabla
CREATE TRIGGER trg_audit_with_constructora
AFTER INSERT OR UPDATE OR DELETE ON [tabla]
FOR EACH ROW
EXECUTE FUNCTION audit_logging.log_action_with_constructora();
-- Función
CREATE OR REPLACE FUNCTION audit_logging.log_action_with_constructora()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logging.audit_logs (
action,
table_name,
record_id,
user_id,
constructora_id, -- 🔑 Incluir siempre
old_data,
new_data,
timestamp
) VALUES (
TG_OP,
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
auth_management.get_current_user_id(),
auth_management.get_current_constructora_id(), -- 🔑 Contexto
to_jsonb(OLD),
to_jsonb(NEW),
NOW()
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
Query de auditoría:
-- Ver todas las acciones del usuario en Constructora A
SELECT *
FROM audit_logging.audit_logs
WHERE user_id = 'user-uuid'
AND constructora_id = 'constructora-a-uuid'
ORDER BY timestamp DESC;
4. Expiración de Invitaciones
Problema: Invitaciones pendientes indefinidamente
Solución: Cleanup automático de invitaciones expiradas
// Cron job: cada día a las 2 AM
@Cron('0 2 * * *')
async cleanupExpiredInvitations() {
const result = await this.invitationRepository.delete({
expiresAt: LessThan(new Date()),
status: 'pending',
});
this.logger.log(`Deleted ${result.affected} expired invitations`);
}
✅ Criterios de Aceptación
AC-001: Modelo de Datos
- Tabla
constructorascreada con campos obligatorios - Tabla
user_constructorascreada con unique constraint (user_id, constructora_id) - Constraint de una sola constructora principal por usuario funciona
- Índices creados en columnas de búsqueda frecuente
AC-002: Invitaciones
- Director puede invitar usuario nuevo (no registrado)
- Director puede invitar usuario existente
- Email de invitación enviado con token válido
- Invitación expira después de 7 días
- Usuario puede aceptar invitación y asociarse a constructora
- Usuario puede rechazar invitación
AC-003: Login Multi-tenant
- Usuario con 1 constructora: login directo
- Usuario con múltiples constructoras: muestra selector
- JWT incluye
constructoraIdyrolecorrecto - Constructora principal se pre-selecciona
- Login valida que usuario tenga al menos 1 constructora activa
AC-004: Switch de Constructora
- Usuario puede cambiar de constructora sin cerrar sesión
- Nuevo token generado con
constructoraIdyroleactualizados - UI se actualiza mostrando datos de nueva constructora
- Cambio auditado en
audit_logs - Error claro si usuario no tiene acceso a constructora destino
AC-005: Aislamiento de Datos (RLS)
- RLS habilitado en TODAS las tablas con
constructora_id - Usuario NO puede ver datos de otras constructoras
- Queries automáticamente filtran por
constructora_idactual - Testing demuestra aislamiento completo
AC-006: Roles por Constructora
- Usuario puede tener diferentes roles en diferentes constructoras
- JWT incluye rol correcto según constructora actual
- Permisos se evalúan según rol en constructora actual
AC-007: Estados por Constructora
- Usuario puede estar suspendido en A pero activo en B
- Login muestra solo constructoras donde status = 'active'
- Cambio a constructora suspendida bloqueado con error claro
AC-008: Constructora Principal
- Usuario puede marcar una constructora como principal
- Solo una constructora puede ser principal (constraint)
- Login pre-selecciona constructora principal
🧪 Testing
Test Suite: Multi-tenancy
Test 1: Data isolation between constructoras
describe('Multi-tenancy Data Isolation', () => {
it('should completely isolate data between constructoras', async () => {
// Setup
const constructoraA = await createConstructora({ nombre: 'A' });
const constructoraB = await createConstructora({ nombre: 'B' });
const userA = await createUser({ email: 'usera@test.com' });
const userB = await createUser({ email: 'userb@test.com' });
await assignToConstructora(userA.id, constructoraA.id, 'engineer');
await assignToConstructora(userB.id, constructoraB.id, 'engineer');
const projectA = await createProject({ constructoraId: constructoraA.id });
const projectB = await createProject({ constructoraId: constructoraB.id });
// Act: User A requests all projects
const tokenA = await loginAs(userA, constructoraA.id);
const responseA = await request(app.getHttpServer())
.get('/projects')
.set('Authorization', `Bearer ${tokenA}`)
.expect(200);
// Assert: User A sees ONLY projectA
expect(responseA.body.data).toHaveLength(1);
expect(responseA.body.data[0].id).toBe(projectA.id);
// Act: User B requests all projects
const tokenB = await loginAs(userB, constructoraB.id);
const responseB = await request(app.getHttpServer())
.get('/projects')
.set('Authorization', `Bearer ${tokenB}`)
.expect(200);
// Assert: User B sees ONLY projectB
expect(responseB.body.data).toHaveLength(1);
expect(responseB.body.data[0].id).toBe(projectB.id);
});
});
Test 2: User with multiple constructoras can switch
it('should allow user to switch between constructoras', async () => {
const user = await createUser();
const constructoraA = await createConstructora({ nombre: 'A' });
const constructoraB = await createConstructora({ nombre: 'B' });
await assignToConstructora(user.id, constructoraA.id, 'engineer');
await assignToConstructora(user.id, constructoraB.id, 'director');
// Login in constructora A
let token = await loginAs(user, constructoraA.id);
let decoded = jwt.decode(token);
expect(decoded.constructoraId).toBe(constructoraA.id);
expect(decoded.role).toBe('engineer');
// Switch to constructora B
const switchResponse = await request(app.getHttpServer())
.post('/auth/switch-constructora')
.set('Authorization', `Bearer ${token}`)
.send({ constructoraId: constructoraB.id })
.expect(200);
const newToken = switchResponse.body.accessToken;
decoded = jwt.decode(newToken);
expect(decoded.constructoraId).toBe(constructoraB.id);
expect(decoded.role).toBe('director'); // Role changed!
});
Test 3: Invitation flow for new user
it('should allow director to invite new user and user to accept', async () => {
const director = await createDirector();
const constructora = director.constructoras[0];
// Director invites new user
await loginAs(director);
const inviteResponse = await request(app.getHttpServer())
.post('/admin/users/invite')
.send({
email: 'newuser@test.com',
constructoraId: constructora.id,
role: 'resident',
})
.expect(201);
const invitationToken = inviteResponse.body.token;
// New user accepts invitation
const registerResponse = await request(app.getHttpServer())
.post('/auth/register-by-invitation')
.send({
token: invitationToken,
password: 'SecurePass123!',
fullName: 'Juan Pérez',
})
.expect(201);
// Verify user was created and associated
const newUser = await getUserByEmail('newuser@test.com');
expect(newUser).toBeDefined();
expect(newUser.status).toBe('pending'); // Needs email verification
const association = await getUserConstructoraAssociation(newUser.id, constructora.id);
expect(association.role).toBe('resident');
expect(association.status).toBe('pending');
});
📚 Referencias Adicionales
Documentos Relacionados
- 📄 RF-AUTH-001: Sistema de Roles
- 📄 RF-AUTH-002: Estados de Cuenta
- 📄 US-FUND-001: Autenticación Básica JWT
Recursos Técnicos
📅 Historial de Cambios
| Versión | Fecha | Autor | Cambios |
|---|---|---|---|
| 1.0 | 2025-11-17 | Tech Team | Creación inicial - Funcionalidad completamente nueva para construcción |
Documento: MAI-001-fundamentos/requerimientos/RF-AUTH-003-multi-tenancy.md
Ruta absoluta: [RUTA-LEGACY-ELIMINADA]/docs/01-fase-alcance-inicial/MAI-001-fundamentos/requerimientos/RF-AUTH-003-multi-tenancy.md
Generado: 2025-11-17
Mantenedores: @tech-lead @backend-team @database-team