# 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](../especificaciones/ET-AUTH-003-multi-tenancy.md) *(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](./RF-AUTH-001-roles-construccion.md) - Roles pueden variar por constructora - 📄 [RF-AUTH-002: Estados de Cuenta](./RF-AUTH-002-estados-cuenta.md) - Estados pueden variar por constructora - 📄 [US-FUND-001: Autenticación Básica JWT](../historias-usuario/US-FUND-001-autenticacion-basica-jwt.md) - Login con selector de constructora ### Implementación DDL 🗄️ **Tablas Principales:** ```sql -- 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:** ```sql -- 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:** ```sql -- 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:** ```typescript // 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; @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 { 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:** ```typescript // apps/frontend/src/features/auth/ConstructoraSelector.tsx interface ConstructoraSelectorProps { constructoras: Constructora[]; onSelect: (constructoraId: string) => void; } export const ConstructoraSelector: React.FC = ({ constructoras, onSelect, }) => { return (

Selecciona una constructora

{constructoras.map(constructora => ( ))}
); }; // 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; } export const useConstructoraStore = create()( 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 📊 [TRACEABILITY.yml](../implementacion/TRACEABILITY.yml#L15-L44) --- ## 📝 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: 1. ❌ **Usuario necesita múltiples cuentas:** Un ingeniero que trabaja en 3 constructoras necesitaría 3 emails diferentes 2. ❌ **No hay aislamiento de datos:** Riesgo de que constructora A vea datos de constructora B 3. ❌ **Complejidad en permisos:** No se puede modelar que un usuario sea "director" en una empresa y "residente" en otra 4. ❌ **Experiencia de usuario pobre:** Usuario debe cerrar sesión y volver a iniciar en cada empresa 5. ❌ **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)** ```typescript 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 255 - `razonSocial`: Mínimo 5 caracteres, máximo 500 - `active`: Solo super_admin puede cambiar **2. User-Constructora (Relación Many-to-Many)** ```typescript 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 usuario - `role`: Puede ser diferente en cada constructora - `status`: 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:** 1. **Director de Constructora A** invita a "ingeniero@email.com" 2. Sistema verifica que email NO existe en `profiles` 3. Sistema crea registro en `invitations`: ```typescript { email: "ingeniero@email.com", constructoraId: "constructora-a-uuid", role: "engineer", invitedBy: "director-uuid", token: "random-secure-token", expiresAt: NOW() + 7 days } ``` 4. 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. ``` 5. Usuario hace click en link 6. Sistema muestra formulario de registro: - Email: ingeniero@email.com (pre-llenado, readonly) - Nombre completo - Contraseña - Confirmar contraseña 7. Usuario completa formulario y hace click en "Registrarme" 8. Sistema ejecuta transacción: ```sql 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; ``` 9. Sistema envía email de verificación 10. Usuario verifica email → `profiles.status` y `user_constructoras.status` cambian a `'active'` 11. Usuario puede hacer login **Resultado:** Usuario nuevo creado y asociado a su primera constructora --- #### Caso 2: Usuario Existente (Ya registrado) **Flujo:** 1. **Director de Constructora B** invita a "ingeniero@email.com" (que ya trabaja en Constructora A) 2. Sistema detecta que email YA existe en `profiles` 3. Sistema crea invitación: ```typescript { 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 } ``` 4. 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. ``` 5. Usuario (ya tiene cuenta) hace click en link 6. Sistema detecta que usuario está autenticado O solicita login 7. Sistema muestra confirmación: ``` Constructora XYZ te ha invitado como Residente de Obra ¿Deseas aceptar esta invitación? [Aceptar] [Rechazar] ``` 8. Usuario hace click en "Aceptar" 9. Sistema asocia usuario a nueva constructora: ```sql 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() ); ``` 10. 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** ```typescript // POST /api/auth/login { "email": "ingeniero@email.com", "password": "password123" } ``` **Paso 2: Validación** ```typescript 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)** ```json { "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)** ```json { "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** ```typescript // 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:** ```typescript 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** ```tsx switchConstructora(constructoraId)} /> ``` **Paso 2: Request al backend** ```typescript // POST /api/auth/switch-constructora // Headers: Authorization: Bearer { "constructoraId": "constructora-b-uuid" } ``` **Paso 3: Backend valida y genera nuevo token** ```typescript @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** ```typescript 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 `constructoraId` y `role` actualizados - ✅ 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** ```typescript // apps/backend/src/common/interceptors/set-rls-context.interceptor.ts @Injectable() export class SetRlsContextInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { 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** ```sql -- 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** ```typescript 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:** ```typescript // PATCH /api/user/set-primary-constructora async setPrimaryConstructora( userId: string, constructoraId: string ): Promise { // 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:** ```tsx // Componente de lista de constructoras del usuario
{userConstructoras.map(uc => (

{uc.constructora.nombre}

{uc.role} {uc.isPrimary ? ( ⭐ Principal ) : ( )}
))}
``` --- ## 📊 Casos de Uso ### UC-MT-001: Ingeniero freelance trabaja en 3 constructoras **Actor:** Ingeniero de Planeación **Precondiciones:** Usuario registrado **Flujo:** 1. **Constructora A** invita a ingeniero@email.com como "engineer" 2. Ingeniero acepta, verifica email, tiene acceso a Constructora A 3. Ingeniero marca Constructora A como principal 4. **Constructora B** invita a ingeniero@email.com como "engineer" 5. Ingeniero (ya autenticado) acepta invitación desde panel 6. Ingeniero ahora tiene acceso a: - Constructora A (Ingeniero) ⭐ Principal - Constructora B (Ingeniero) 7. **Constructora C** invita a ingeniero@email.com como "director" 8. Ingeniero acepta 9. Ingeniero ahora tiene: - Constructora A (Ingeniero) ⭐ Principal - Constructora B (Ingeniero) - Constructora C (Director) ← Rol diferente 10. Ingeniero hace login una vez 11. Sistema le muestra selector de 3 constructoras 12. Ingeniero selecciona Constructora A (principal pre-seleccionada) 13. Ingeniero trabaja en Constructora A 14. Ingeniero hace click en selector de constructora en header 15. Ingeniero selecciona "Constructora C" 16. Sistema regenera token con `constructoraId=C` y `role=director` 17. UI se actualiza mostrando dashboard de director 18. 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:** 1. Director navega a `/admin/constructoras/create` 2. Director completa formulario: - Nombre: "Constructora Nueva S.A." - Razón Social: "Constructora Nueva Sociedad Anónima de Capital Variable" - RFC: "CNN123456ABC" - Logo: (sube imagen) 3. Director hace click en "Crear Constructora" 4. Sistema crea constructora 5. Sistema automáticamente asocia al director como primer usuario: ```sql INSERT INTO user_constructoras (user_id, constructora_id, role, status, is_primary) VALUES (director_id, new_constructora_id, 'director', 'active', false); ``` 6. Director navega a `/admin/users/invite` 7. Director invita usuarios: - ingeniero@email.com → Ingeniero - residente1@email.com → Residente de Obra - residente2@email.com → Residente de Obra - compras@email.com → Compras 8. Sistema envía 4 emails de invitación 9. Usuarios aceptan invitaciones y verifican emails 10. 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:** 1. Director de Constructora A suspende al residente por 14 días 2. Sistema actualiza: ```sql UPDATE user_constructoras SET status = 'suspended' WHERE user_id = residente_id AND constructora_id = constructora_a_id; ``` 3. Residente intenta hacer login 4. Sistema detecta que tiene: - Constructora A: status = 'suspended' - Constructora B: status = 'active' 5. Sistema muestra solo Constructora B en selector 6. Residente hace login en Constructora B exitosamente 7. Residente puede trabajar normalmente en Constructora B 8. Residente intenta cambiar a Constructora A desde selector 9. Sistema muestra error: "Tu acceso a Constructora A está suspendido. Contacta al administrador." 10. Después de 14 días, Director de Constructora A levanta suspensión 11. Sistema actualiza status a 'active' 12. 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** ```sql -- 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:** ```typescript 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** ```typescript 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** ```sql -- 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:** ```sql -- 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** ```typescript // 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 `constructoras` creada con campos obligatorios - [ ] Tabla `user_constructoras` creada 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 `constructoraId` y `role` correcto - [ ] 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 `constructoraId` y `role` actualizados - [ ] 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_id` actual - [ ] 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 ```typescript 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 ```typescript 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 ```typescript 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-001-roles-construccion.md) - 📄 [RF-AUTH-002: Estados de Cuenta](./RF-AUTH-002-estados-cuenta.md) - 📄 [US-FUND-001: Autenticación Básica JWT](../historias-usuario/US-FUND-001-autenticacion-basica-jwt.md) ### Recursos Técnicos - [PostgreSQL Row Level Security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) - [Multi-tenancy Patterns](https://docs.microsoft.com/en-us/azure/architecture/patterns/category/data-management) - [JWT Best Practices](https://datatracker.ietf.org/doc/html/rfc8725) --- ## 📅 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