erp-construccion/docs/02-definicion-modulos/MAI-001-fundamentos/requerimientos/RF-AUTH-003-multi-tenancy.md

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

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

📊 TRACEABILITY.yml


📝 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)

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)

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:
    {
      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:
    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:
    {
      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:
    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

// 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 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

// 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:

  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:
    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:
  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:
    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

-- 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 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

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

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