erp-construccion/docs/02-definicion-modulos/MAI-002-proyectos-estructura/implementacion/ET-PROJ-002-rls-policies.sql

531 lines
20 KiB
PL/PgSQL

-- ============================================================================
-- ET-PROJ-002: Row-Level Security (RLS) Policies
-- Tablas: projects.stages, projects.blocks, projects.lots, projects.housing_units
-- Fecha: 2025-11-17
-- Descripción: Políticas de seguridad para estructura jerárquica multi-tenant
-- ============================================================================
-- Nota: Estas tablas heredan constructora_id de la tabla projects.projects
-- RLS filtra basándose en constructora_id para aislamiento de datos.
-- ============================================================================
-- HABILITAR RLS EN TODAS LAS TABLAS
-- ============================================================================
ALTER TABLE projects.stages ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects.blocks ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects.lots ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects.housing_units ENABLE ROW LEVEL SECURITY;
-- ============================================================================
-- POLÍTICAS RLS: STAGES (Etapas)
-- ============================================================================
-- SELECT: Ver solo etapas de proyectos de la constructora actual
DROP POLICY IF EXISTS "stages_select_own_constructora" ON projects.stages;
CREATE POLICY "stages_select_own_constructora"
ON projects.stages
FOR SELECT
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
);
COMMENT ON POLICY "stages_select_own_constructora" ON projects.stages IS
'Permite ver solo etapas de proyectos de la constructora actual.
Aislamiento: tenant (constructora) level.';
-- INSERT: Crear etapas solo para proyectos propios
DROP POLICY IF EXISTS "stages_insert_own_constructora" ON projects.stages;
CREATE POLICY "stages_insert_own_constructora"
ON projects.stages
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin', 'engineer')
);
COMMENT ON POLICY "stages_insert_own_constructora" ON projects.stages IS
'Permite crear etapas solo para proyectos de la constructora actual.
Roles permitidos: director, admin, engineer.';
-- UPDATE: Actualizar etapas propias
DROP POLICY IF EXISTS "stages_update_own_constructora" ON projects.stages;
CREATE POLICY "stages_update_own_constructora"
ON projects.stages
FOR UPDATE
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin', 'engineer', 'resident')
)
WITH CHECK (
constructora_id = public.get_current_constructora_id()
);
COMMENT ON POLICY "stages_update_own_constructora" ON projects.stages IS
'Permite actualizar etapas propias. Previene cambio de constructora_id.';
-- DELETE: Eliminar etapas propias (solo admin/director)
DROP POLICY IF EXISTS "stages_delete_own_constructora" ON projects.stages;
CREATE POLICY "stages_delete_own_constructora"
ON projects.stages
FOR DELETE
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin')
);
-- ============================================================================
-- POLÍTICAS RLS: BLOCKS (Manzanas)
-- ============================================================================
-- SELECT: Ver solo manzanas de proyectos propios
DROP POLICY IF EXISTS "blocks_select_own_constructora" ON projects.blocks;
CREATE POLICY "blocks_select_own_constructora"
ON projects.blocks
FOR SELECT
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
);
COMMENT ON POLICY "blocks_select_own_constructora" ON projects.blocks IS
'Permite ver solo manzanas de la constructora actual.';
-- INSERT: Crear manzanas solo para proyectos propios
DROP POLICY IF EXISTS "blocks_insert_own_constructora" ON projects.blocks;
CREATE POLICY "blocks_insert_own_constructora"
ON projects.blocks
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin', 'engineer')
);
-- UPDATE: Actualizar manzanas propias
DROP POLICY IF EXISTS "blocks_update_own_constructora" ON projects.blocks;
CREATE POLICY "blocks_update_own_constructora"
ON projects.blocks
FOR UPDATE
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin', 'engineer', 'resident')
)
WITH CHECK (
constructora_id = public.get_current_constructora_id()
);
-- DELETE: Eliminar manzanas (solo admin/director)
DROP POLICY IF EXISTS "blocks_delete_own_constructora" ON projects.blocks;
CREATE POLICY "blocks_delete_own_constructora"
ON projects.blocks
FOR DELETE
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin')
);
-- ============================================================================
-- POLÍTICAS RLS: LOTS (Lotes)
-- ============================================================================
-- SELECT: Ver solo lotes de proyectos propios
DROP POLICY IF EXISTS "lots_select_own_constructora" ON projects.lots;
CREATE POLICY "lots_select_own_constructora"
ON projects.lots
FOR SELECT
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
);
COMMENT ON POLICY "lots_select_own_constructora" ON projects.lots IS
'Permite ver solo lotes de la constructora actual.
Usado en: TreeView, asignación de prototipos, reporte de inventario.';
-- INSERT: Crear lotes (incluyendo bulk create)
DROP POLICY IF EXISTS "lots_insert_own_constructora" ON projects.lots;
CREATE POLICY "lots_insert_own_constructora"
ON projects.lots
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin', 'engineer')
);
COMMENT ON POLICY "lots_insert_own_constructora" ON projects.lots IS
'Permite creación de lotes (individual o masiva hasta 500).
Validación de constructora_id automática.';
-- UPDATE: Actualizar lotes (estado, prototipo, etc.)
DROP POLICY IF EXISTS "lots_update_own_constructora" ON projects.lots;
CREATE POLICY "lots_update_own_constructora"
ON projects.lots
FOR UPDATE
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
AND (
-- Director/Admin/Engineer: Full access
public.get_current_user_role() IN ('director', 'admin', 'engineer')
OR
-- Resident: Solo cambiar estado
public.get_current_user_role() = 'resident'
)
)
WITH CHECK (
constructora_id = public.get_current_constructora_id()
);
COMMENT ON POLICY "lots_update_own_constructora" ON projects.lots IS
'Permisos diferenciados por rol:
- Director/Admin/Engineer: Pueden editar todos los campos
- Resident: Solo puede cambiar estado (validado en app layer)';
-- DELETE: Eliminar lotes (solo si no tiene vivienda)
DROP POLICY IF EXISTS "lots_delete_own_constructora" ON projects.lots;
CREATE POLICY "lots_delete_own_constructora"
ON projects.lots
FOR DELETE
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin')
-- Validación adicional: No eliminar si tiene vivienda (manejado por FK constraint)
);
-- ============================================================================
-- POLÍTICAS RLS: HOUSING_UNITS (Viviendas)
-- ============================================================================
-- SELECT: Ver solo viviendas de proyectos propios
DROP POLICY IF EXISTS "housing_units_select_own_constructora" ON projects.housing_units;
CREATE POLICY "housing_units_select_own_constructora"
ON projects.housing_units
FOR SELECT
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
);
COMMENT ON POLICY "housing_units_select_own_constructora" ON projects.housing_units IS
'Permite ver solo viviendas de la constructora actual.
Usado en: Control de avances, CRM (asignación a derechohabientes).';
-- INSERT: Crear viviendas
DROP POLICY IF EXISTS "housing_units_insert_own_constructora" ON projects.housing_units;
CREATE POLICY "housing_units_insert_own_constructora"
ON projects.housing_units
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin', 'engineer')
);
COMMENT ON POLICY "housing_units_insert_own_constructora" ON projects.housing_units IS
'Permite crear viviendas solo para lotes de la constructora actual.
Hereda prototipo al momento de creación (snapshot).';
-- UPDATE: Actualizar viviendas (avances, estado, etc.)
DROP POLICY IF EXISTS "housing_units_update_own_constructora" ON projects.housing_units;
CREATE POLICY "housing_units_update_own_constructora"
ON projects.housing_units
FOR UPDATE
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
AND (
-- Director/Admin/Engineer: Full access
public.get_current_user_role() IN ('director', 'admin', 'engineer')
OR
-- Resident: Actualizar avances físicos
public.get_current_user_role() = 'resident'
)
)
WITH CHECK (
constructora_id = public.get_current_constructora_id()
);
COMMENT ON POLICY "housing_units_update_own_constructora" ON projects.housing_units IS
'Permisos por rol:
- Director/Admin/Engineer: Editar todo
- Resident: Actualizar campos de avance (progressCimentacion, progressEstructura, etc.)';
-- DELETE: Eliminar viviendas (solo admin/director, validar dependencias)
DROP POLICY IF EXISTS "housing_units_delete_own_constructora" ON projects.housing_units;
CREATE POLICY "housing_units_delete_own_constructora"
ON projects.housing_units
FOR DELETE
TO authenticated
USING (
constructora_id = public.get_current_constructora_id()
AND public.get_current_user_role() IN ('director', 'admin')
);
-- ============================================================================
-- POLÍTICAS SUPER ADMIN (Bypass para soporte)
-- ============================================================================
-- Super Admin puede ver/editar toda la estructura
DROP POLICY IF EXISTS "stages_super_admin_all" ON projects.stages;
CREATE POLICY "stages_super_admin_all" ON projects.stages FOR ALL TO authenticated
USING (public.get_current_user_role() = 'super_admin')
WITH CHECK (public.get_current_user_role() = 'super_admin');
DROP POLICY IF EXISTS "blocks_super_admin_all" ON projects.blocks;
CREATE POLICY "blocks_super_admin_all" ON projects.blocks FOR ALL TO authenticated
USING (public.get_current_user_role() = 'super_admin')
WITH CHECK (public.get_current_user_role() = 'super_admin');
DROP POLICY IF EXISTS "lots_super_admin_all" ON projects.lots;
CREATE POLICY "lots_super_admin_all" ON projects.lots FOR ALL TO authenticated
USING (public.get_current_user_role() = 'super_admin')
WITH CHECK (public.get_current_user_role() = 'super_admin');
DROP POLICY IF EXISTS "housing_units_super_admin_all" ON projects.housing_units;
CREATE POLICY "housing_units_super_admin_all" ON projects.housing_units FOR ALL TO authenticated
USING (public.get_current_user_role() = 'super_admin')
WITH CHECK (public.get_current_user_role() = 'super_admin');
-- ============================================================================
-- ÍNDICES PARA PERFORMANCE
-- ============================================================================
-- Stages
CREATE INDEX IF NOT EXISTS idx_stages_constructora_id ON projects.stages(constructora_id);
CREATE INDEX IF NOT EXISTS idx_stages_project_id ON projects.stages(project_id);
CREATE INDEX IF NOT EXISTS idx_stages_constructora_status ON projects.stages(constructora_id, status);
-- Blocks
CREATE INDEX IF NOT EXISTS idx_blocks_constructora_id ON projects.blocks(constructora_id);
CREATE INDEX IF NOT EXISTS idx_blocks_stage_id ON projects.blocks(stage_id);
CREATE INDEX IF NOT EXISTS idx_blocks_project_id ON projects.blocks(project_id);
-- Lots
CREATE INDEX IF NOT EXISTS idx_lots_constructora_id ON projects.lots(constructora_id);
CREATE INDEX IF NOT EXISTS idx_lots_stage_id ON projects.lots(stage_id);
CREATE INDEX IF NOT EXISTS idx_lots_block_id ON projects.lots(block_id);
CREATE INDEX IF NOT EXISTS idx_lots_project_id ON projects.lots(project_id);
CREATE INDEX IF NOT EXISTS idx_lots_constructora_status ON projects.lots(constructora_id, status);
CREATE INDEX IF NOT EXISTS idx_lots_prototype_id ON projects.lots(prototype_id) WHERE prototype_id IS NOT NULL;
-- Housing Units
CREATE INDEX IF NOT EXISTS idx_housing_units_constructora_id ON projects.housing_units(constructora_id);
CREATE INDEX IF NOT EXISTS idx_housing_units_lot_id ON projects.housing_units(lot_id);
CREATE INDEX IF NOT EXISTS idx_housing_units_project_id ON projects.housing_units(project_id);
CREATE INDEX IF NOT EXISTS idx_housing_units_status ON projects.housing_units(constructora_id, status);
COMMENT ON INDEX projects.idx_lots_constructora_id IS
'Optimiza queries RLS filtrados por constructora_id.';
COMMENT ON INDEX projects.idx_lots_constructora_status IS
'Optimiza query: listar lotes disponibles/vendidos de una constructora.';
-- ============================================================================
-- TRIGGERS PARA HEREDAR constructora_id
-- ============================================================================
-- Función: Auto-asignar constructora_id desde proyecto padre
CREATE OR REPLACE FUNCTION projects.auto_assign_constructora_id()
RETURNS TRIGGER AS $$
BEGIN
-- Si no viene constructora_id, heredar del proyecto
IF NEW.constructora_id IS NULL THEN
SELECT constructora_id INTO NEW.constructora_id
FROM projects.projects
WHERE id = NEW.project_id;
IF NEW.constructora_id IS NULL THEN
RAISE EXCEPTION 'No se pudo determinar constructora_id para %', TG_TABLE_NAME;
END IF;
END IF;
-- Validar que constructora_id coincida con el proyecto
IF NOT EXISTS (
SELECT 1 FROM projects.projects
WHERE id = NEW.project_id
AND constructora_id = NEW.constructora_id
) THEN
RAISE EXCEPTION 'constructora_id no coincide con el proyecto padre';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Aplicar trigger a todas las tablas
DROP TRIGGER IF EXISTS trigger_auto_assign_constructora ON projects.stages;
CREATE TRIGGER trigger_auto_assign_constructora
BEFORE INSERT ON projects.stages
FOR EACH ROW
EXECUTE FUNCTION projects.auto_assign_constructora_id();
DROP TRIGGER IF EXISTS trigger_auto_assign_constructora ON projects.blocks;
CREATE TRIGGER trigger_auto_assign_constructora
BEFORE INSERT ON projects.blocks
FOR EACH ROW
EXECUTE FUNCTION projects.auto_assign_constructora_id();
DROP TRIGGER IF EXISTS trigger_auto_assign_constructora ON projects.lots;
CREATE TRIGGER trigger_auto_assign_constructora
BEFORE INSERT ON projects.lots
FOR EACH ROW
EXECUTE FUNCTION projects.auto_assign_constructora_id();
DROP TRIGGER IF EXISTS trigger_auto_assign_constructora ON projects.housing_units;
CREATE TRIGGER trigger_auto_assign_constructora
BEFORE INSERT ON projects.housing_units
FOR EACH ROW
EXECUTE FUNCTION projects.auto_assign_constructora_id();
COMMENT ON FUNCTION projects.auto_assign_constructora_id() IS
'Auto-asigna constructora_id heredando del proyecto padre.
Validación: Garantiza coherencia del discriminador multi-tenant.';
-- ============================================================================
-- CONSTRAINTS Y VALIDACIONES
-- ============================================================================
-- Asegurar que constructora_id no sea NULL
ALTER TABLE projects.stages ALTER COLUMN constructora_id SET NOT NULL;
ALTER TABLE projects.blocks ALTER COLUMN constructora_id SET NOT NULL;
ALTER TABLE projects.lots ALTER COLUMN constructora_id SET NOT NULL;
ALTER TABLE projects.housing_units ALTER COLUMN constructora_id SET NOT NULL;
-- Foreign Keys hacia constructoras
ALTER TABLE projects.stages
DROP CONSTRAINT IF EXISTS fk_stages_constructora,
ADD CONSTRAINT fk_stages_constructora
FOREIGN KEY (constructora_id) REFERENCES constructoras.constructoras(id) ON DELETE RESTRICT;
ALTER TABLE projects.blocks
DROP CONSTRAINT IF EXISTS fk_blocks_constructora,
ADD CONSTRAINT fk_blocks_constructora
FOREIGN KEY (constructora_id) REFERENCES constructoras.constructoras(id) ON DELETE RESTRICT;
ALTER TABLE projects.lots
DROP CONSTRAINT IF EXISTS fk_lots_constructora,
ADD CONSTRAINT fk_lots_constructora
FOREIGN KEY (constructora_id) REFERENCES constructoras.constructoras(id) ON DELETE RESTRICT;
ALTER TABLE projects.housing_units
DROP CONSTRAINT IF EXISTS fk_housing_units_constructora,
ADD CONSTRAINT fk_housing_units_constructora
FOREIGN KEY (constructora_id) REFERENCES constructoras.constructoras(id) ON DELETE RESTRICT;
-- ============================================================================
-- TESTS DE AISLAMIENTO RLS
-- ============================================================================
-- Test: Verificar que no se puede crear lote en proyecto de otra constructora
DO $$
DECLARE
v_constructora_a UUID := 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
v_constructora_b UUID := 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
v_project_a UUID;
v_stage_a UUID;
v_lot_id UUID;
BEGIN
-- Setup: Crear proyecto de constructora A
PERFORM set_config('app.current_constructora_id', v_constructora_a::TEXT, true);
PERFORM set_config('app.current_user_role', 'director', true);
INSERT INTO projects.projects (code, name, type, status, constructora_id)
VALUES ('TEST-A', 'Proyecto A', 'fraccionamiento', 'adjudicado', v_constructora_a)
RETURNING id INTO v_project_a;
INSERT INTO projects.stages (code, name, project_id, constructora_id)
VALUES ('ETA-1', 'Etapa 1', v_project_a, v_constructora_a)
RETURNING id INTO v_stage_a;
-- Cambiar contexto a constructora B (atacante)
PERFORM set_config('app.current_constructora_id', v_constructora_b::TEXT, true);
-- Intentar crear lote en proyecto de A (debe fallar)
BEGIN
INSERT INTO projects.lots (
code, number, stage_id, project_id, constructora_id
) VALUES (
'LOTE-1', '001', v_stage_a, v_project_a, v_constructora_b
) RETURNING id INTO v_lot_id;
RAISE EXCEPTION 'RLS FAIL: Se permitió crear lote en proyecto de otra constructora';
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Test RLS: PASSED - Cross-tenant INSERT bloqueado';
END;
-- Cleanup
PERFORM set_config('app.current_constructora_id', v_constructora_a::TEXT, true);
DELETE FROM projects.stages WHERE id = v_stage_a;
DELETE FROM projects.projects WHERE id = v_project_a;
END $$;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT, INSERT, UPDATE, DELETE ON projects.stages TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects.blocks TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects.lots TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects.housing_units TO authenticated;
-- ============================================================================
-- VERIFICACIÓN FINAL
-- ============================================================================
DO $$
DECLARE
v_tables TEXT[] := ARRAY['stages', 'blocks', 'lots', 'housing_units'];
v_table TEXT;
v_rls_enabled BOOLEAN;
v_policy_count INTEGER;
BEGIN
FOREACH v_table IN ARRAY v_tables
LOOP
SELECT relrowsecurity INTO v_rls_enabled
FROM pg_class
WHERE relname = v_table AND relnamespace = 'projects'::regnamespace;
SELECT COUNT(*) INTO v_policy_count
FROM pg_policies
WHERE schemaname = 'projects' AND tablename = v_table;
IF NOT v_rls_enabled THEN
RAISE EXCEPTION 'CRITICAL: RLS no habilitado en projects.%', v_table;
END IF;
IF v_policy_count < 4 THEN
RAISE WARNING 'projects.%: Solo % políticas (esperadas: ≥4)', v_table, v_policy_count;
END IF;
RAISE NOTICE '✓ projects.%: RLS habilitado con % políticas', v_table, v_policy_count;
END LOOP;
RAISE NOTICE '✓ Todas las tablas tienen RLS habilitado correctamente';
END $$;