1508 lines
45 KiB
Markdown
1508 lines
45 KiB
Markdown
# 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<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:**
|
|
|
|
```typescript
|
|
// 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](../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
|
|
<ConstructoraSwitcher
|
|
current={currentConstructora}
|
|
available={userConstructoras}
|
|
onSwitch={(constructoraId) => switchConstructora(constructoraId)}
|
|
/>
|
|
```
|
|
|
|
**Paso 2: Request al backend**
|
|
```typescript
|
|
// POST /api/auth/switch-constructora
|
|
// Headers: Authorization: Bearer <current-token>
|
|
{
|
|
"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<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**
|
|
|
|
```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<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:**
|
|
|
|
```tsx
|
|
// 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:
|
|
```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
|