Implement row-level security (RLS) context management for database sessions. Refactor invoice processing and reverting tasks to utilize scoped database sessions with RLS context. Update middleware to extract and set company ID from requests. Enhance task dispatching to propagate RLS context via Celery headers. Update architecture documentation to reflect RLS implementation details.

This commit is contained in:
2026-04-24 18:01:46 -05:00
parent 7acf77994f
commit 73a6d92863
13 changed files with 1052 additions and 306 deletions

View File

@@ -331,7 +331,11 @@ def digitalizar_expediente_archivo(
"request_data": body.model_dump(),
"company_id": company_id,
"tenant_id": tenant_id,
}
},
headers={
"rls_tenant_id": str(int(tenant_id)),
"rls_company_id": str(int(company_id)),
},
)
return DigitalizarResponse(
@@ -367,7 +371,11 @@ def registrar_digitalizacion(
"request_data": {"rfc_consulta": body.rfc_consulta},
"company_id": company_id,
"tenant_id": tenant_id,
}
},
headers={
"rls_tenant_id": str(int(tenant_id)),
"rls_company_id": str(int(company_id)),
},
)
launched.append({"id": record_id, "task_id": task.id})

View File

@@ -1,7 +1,7 @@
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.database import scoped_core_db
from core.exceptions import ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
@@ -18,43 +18,40 @@ def process_export_invoice_task(self: Task, invoice_id: int, tenant_id: str, com
Procesa una factura de exportación ejecutando todas las validaciones y
actualizaciones del proceso principal de exportación con reporte de progreso.
"""
db = CoreSessionLocal()
try:
# ── Paso 1: Cargar factura ────────────────────────────────────────────
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
with scoped_core_db(tenant_id=int(tenant_id), company_id=int(company_id)) as db:
try:
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
_progress(self, 10, "Verificando estatus de seguridad...")
if invoice.status == InvoiceStatus.PROCESSED:
return {
"status": "error",
"message": f"La factura {invoice.invoice_number} ya se encuentra procesada.",
"errors": [{"field": "status", "message": "Factura ya procesada."}],
}
_progress(self, 15, "Iniciando proceso principal de exportación...")
result = main_process(db, invoice, tenant_id, company_id, username=username)
db.commit()
_progress(self, 100, "Proceso completado.")
return {**result, "invoice_id": invoice_id}
except ValidationException as exc:
db.rollback()
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
_progress(self, 10, "Verificando estatus de seguridad...")
if invoice.status == InvoiceStatus.PROCESSED:
return {
"status": "error",
"message": f"La factura {invoice.invoice_number} ya se encuentra procesada.",
"errors": [{"field": "status", "message": "Factura ya procesada."}],
}
_progress(self, 15, "Iniciando proceso principal de exportación...")
result = main_process(db, invoice, tenant_id, company_id, username=username)
db.commit()
_progress(self, 100, "Proceso completado.")
return {**result, "invoice_id": invoice_id}
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc
finally:
db.close()
except Exception as exc:
db.rollback()
raise exc

View File

@@ -1,7 +1,7 @@
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.database import scoped_core_db
from core.exceptions import ErrorCollector, ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader
@@ -26,73 +26,67 @@ def revert_invoice_task(
validaciones y reversiones del proceso principal (revert/main_process) con
reporte de progreso.
"""
db = CoreSessionLocal()
try:
# ── Paso 1: Cargar factura ────────────────────────────────────────────
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
with scoped_core_db(tenant_id=int(tenant_id), company_id=int(company_id)) as db:
try:
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
_progress(self, 10, "Verificando estatus de seguridad...")
from api.v1.modules.a76.invoices.models import InvoiceStatus
if invoice.status != InvoiceStatus.PROCESSED:
return {
"status": "error",
"message": f"La factura {invoice.invoice_number} no se puede revertir porque no está procesada.",
"errors": [{"field": "status", "message": "Factura no procesada."}],
}
_progress(self, 10, "Verificando estatus de seguridad...")
from api.v1.modules.a76.invoices.models import InvoiceStatus
if invoice.status != InvoiceStatus.PROCESSED:
return {
"status": "error",
"message": f"La factura {invoice.invoice_number} no se puede revertir porque no está procesada.",
"errors": [{"field": "status", "message": "Factura no procesada."}],
}
errors = ErrorCollector()
errors = ErrorCollector()
# ── Paso 2: Pre-validaciones ──────────────────────────────────────────
_progress(self, 10, "Validando estatus de la factura...")
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
if not lines:
errors.add_error(
field="line_items",
message="La factura no contiene partidas para revertir",
solution=["Verifique que la factura tenga partidas antes de intentar revertirla"],
code="NO_LINE_ITEMS",
_progress(self, 10, "Validando estatus de la factura...")
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
if not lines:
errors.add_error(
field="line_items",
message="La factura no contiene partidas para revertir",
solution=["Verifique que la factura tenga partidas antes de intentar revertirla"],
code="NO_LINE_ITEMS",
)
errors.raise_if_errors()
_progress(self, 40, "Verificando saldos de partidas...")
sql_errors = revert_process(
db=db,
invoice=invoice,
lines=lines,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
cancelled_by=cancelled_by,
)
errors.raise_if_errors()
# ── Paso 3: Validar cantidades y ejecutar reversión ───────────────────
_progress(self, 40, "Verificando saldos de partidas...")
sql_errors = revert_process(
db=db,
invoice=invoice,
lines=lines,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
cancelled_by=cancelled_by,
)
_progress(self, 95, "Anulando saldos de inventario y confirmando...")
db.flush()
db.commit()
# ── Paso 4: Confirmar transacción ─────────────────────────────────────
_progress(self, 95, "Anulando saldos de inventario y confirmando...")
db.flush()
db.commit()
return {
"status": "success",
"invoice_id": invoice_id,
"sql_errors": sql_errors,
}
return {
"status": "success",
"invoice_id": invoice_id,
"sql_errors": sql_errors,
}
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc
finally:
db.close()
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc

View File

@@ -3,7 +3,7 @@ import logging
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.database import scoped_core_db
from core.exceptions import ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
@@ -22,56 +22,49 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id
Procesa una factura de importación ejecutando todas las validaciones y
actualizaciones del proceso principal (main_process) con reporte de progreso.
"""
db = CoreSessionLocal()
try:
# ── Paso 1: Cargar factura ────────────────────────────────────────────
# ── Paso 1: Cargar factura ────────────────────────────────────────────
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
with scoped_core_db(tenant_id=int(tenant_id), company_id=int(company_id)) as db:
try:
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
_progress(self, 10, "Verificando estatus de seguridad...")
if invoice.status == InvoiceStatus.PROCESSED:
return {
"status": "error",
"message": f"La factura {invoice.invoice_number} ya se encuentra procesada.",
"errors": [{"field": "status", "message": "Factura ya procesada."}],
}
_progress(self, 20, "Iniciando procesamiento de factura...")
result = main_process(
db=db,
invoice=invoice,
tenant_id=tenant_id,
company_id=company_id,
username=username,
)
_progress(self, 95, "Confirmando cambios...")
db.commit()
_progress(self, 100, "Proceso completado.")
return result
except ValidationException as exc:
db.rollback()
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
_progress(self, 10, "Verificando estatus de seguridad...")
if invoice.status == InvoiceStatus.PROCESSED:
return {
"status": "error",
"message": f"La factura {invoice.invoice_number} ya se encuentra procesada.",
"errors": [{"field": "status", "message": "Factura ya procesada."}],
}
# ── Paso 2: Ejecutar Proceso Principal ───────────────────────────────
# Unificamos lógica: El task solo llama al main_process centralizado.
_progress(self, 20, "Iniciando procesamiento de factura...")
result = main_process(
db=db,
invoice=invoice,
tenant_id=tenant_id,
company_id=company_id,
username=username
)
# ── Paso 3: Confirmar transacción ─────────────────────────────────────
_progress(self, 95, "Confirmando cambios...")
db.commit()
_progress(self, 100, "Proceso completado.")
return result
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
logger.error(f"Error en process_invoice_task: {str(exc)}", exc_info=True)
raise exc
finally:
db.close()
except Exception as exc:
db.rollback()
logger.error(f"Error en process_invoice_task: {str(exc)}", exc_info=True)
raise exc

View File

@@ -1,7 +1,7 @@
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.database import scoped_core_db
from core.exceptions import ErrorCollector, ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader
@@ -26,72 +26,66 @@ def revert_invoice_task(
validaciones y reversiones del proceso principal (revert/main_process) con
reporte de progreso.
"""
db = CoreSessionLocal()
try:
# ── Paso 1: Cargar factura ────────────────────────────────────────────
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
with scoped_core_db(tenant_id=int(tenant_id), company_id=int(company_id)) as db:
try:
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
_progress(self, 10, "Verificando estatus de seguridad...")
from api.v1.modules.a76.invoices.models import InvoiceStatus
if invoice.status != InvoiceStatus.PROCESSED:
return {
"status": "error",
"message": f"La factura {invoice.invoice_number} no se puede revertir porque no está procesada.",
"errors": [{"field": "status", "message": "Factura no procesada."}],
}
_progress(self, 10, "Verificando estatus de seguridad...")
from api.v1.modules.a76.invoices.models import InvoiceStatus
if invoice.status != InvoiceStatus.PROCESSED:
return {
"status": "error",
"message": f"La factura {invoice.invoice_number} no se puede revertir porque no está procesada.",
"errors": [{"field": "status", "message": "Factura no procesada."}],
}
errors = ErrorCollector()
errors = ErrorCollector()
# ── Paso 2: Pre-validaciones ──────────────────────────────────────────
_progress(self, 10, "Validando estatus de la factura...")
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
if not lines:
errors.add_error(
field="line_items",
message="La factura no contiene partidas para revertir",
solution=["Verifique que la factura tenga partidas antes de intentar revertirla"],
code="NO_LINE_ITEMS",
_progress(self, 10, "Validando estatus de la factura...")
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
if not lines:
errors.add_error(
field="line_items",
message="La factura no contiene partidas para revertir",
solution=["Verifique que la factura tenga partidas antes de intentar revertirla"],
code="NO_LINE_ITEMS",
)
errors.raise_if_errors()
_progress(self, 40, "Verificando saldos de partidas...")
sql_errors = revert_process(
db=db,
invoice=invoice,
lines=lines,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
)
errors.raise_if_errors()
# ── Paso 3: Validar cantidades y ejecutar reversión ───────────────────
_progress(self, 40, "Verificando saldos de partidas...")
sql_errors = revert_process(
db=db,
invoice=invoice,
lines=lines,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
)
_progress(self, 95, "Anulando saldos de inventario y confirmando...")
db.flush()
db.commit()
# ── Paso 4: Confirmar transacción ─────────────────────────────────────
_progress(self, 95, "Anulando saldos de inventario y confirmando...")
db.flush()
db.commit()
return {
"status": "success",
"invoice_id": invoice_id,
"sql_errors": sql_errors,
}
return {
"status": "success",
"invoice_id": invoice_id,
"sql_errors": sql_errors,
}
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc
finally:
db.close()
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc

View File

@@ -3,6 +3,8 @@ from typing import Any
from celery import Task
from sqlalchemy.orm import Session
from core.database import rls_company_var, rls_tenant_var
from .service import TaskTrackerService
@@ -21,7 +23,25 @@ def track_and_dispatch(
task_id: str | None = None,
meta_payload: dict[str, Any] | None = None,
):
celery_task = task.apply_async(args=args or [], kwargs=kwargs or {}, task_id=task_id)
# Propaga contexto RLS vía Celery headers (leídos en task_prerun) y
# ContextVars (para modo eager, donde before_task_publish no dispara).
headers = {"rls_tenant_id": str(int(tenant_id))}
if company_id is not None:
headers["rls_company_id"] = str(int(company_id))
token_t = rls_tenant_var.set(int(tenant_id))
token_c = rls_company_var.set(int(company_id) if company_id is not None else None)
try:
celery_task = task.apply_async(
args=args or [],
kwargs=kwargs or {},
task_id=task_id,
headers=headers,
)
finally:
rls_tenant_var.reset(token_t)
rls_company_var.reset(token_c)
tracker = TaskTrackerService(db)
tracker.register_dispatch(
task_id=celery_task.id,