Files
CRM_AGENTES_CARGA/backend/core/s3_keys.py
marcos d97cdd2f73 feat(crm,ops): subida de un paso al expediente, proxy de descarga y UI del estado
Cierra el carril de cara al usuario: adjunta un documento desde la ficha del
embarque y lo ve aparecer con badge "En expediente". Si EFC esta caido lo ve
como "Pendiente de enviar" en vez de perder su trabajo, y el sistema lo entrega
solo cuando EFC vuelve, sin duplicarlo.

La subida es de UN paso -guarda, registra y encola- y responde 201 PASE LO QUE
PASE con EFC. La alternativa era llamar a EFC dentro del request, y ahi un EFC
caido devuelve un error al usuario con el archivo ya subido a medias.

La descarga es un proxy con streaming, no una redireccion: EFC nunca entrega una
URL de MinIO, y reescribir el host de una URL ya firmada invalida su SigV4. El
AsyncClient se crea DENTRO del generador y se cierra en finally; creado fuera,
en un `async with`, se cerraria antes de que empiece el streaming -FastAPI
consume el generador despues de devolver la respuesta- y la descarga moriria a
la mitad. Dos guardas: la pertenencia se valida ANTES de tocar EFC, y se manda
el organizacion_id del expediente para que la verificacion de EFC tambien
dispare. Sin la primera, un document_id que coincidiera leeria el expediente de
otro tenant, y el organizacion_id se deriva del expediente, asi que el CRM iria
a preguntarle a la organizacion de otro cliente.

TRES DEFECTOS PREEXISTENTES QUE ESTA FASE CORRIGE DE PASO:

1. `POST /uploads` hacia `await file.read()` COMPLETO antes de validar el
   tamano: un archivo de 2 GB se bufferizaba entero en RAM del proceso solo para
   responder 422 despues. Ahora la lectura es por partes y aborta al pasar del
   tope.
2. No habia allowlist de extension, a diferencia del avatar y el centro de
   ayuda, que si la tienen. La de aqui coincide con la de EFC: aceptar algo que
   alla se rechaza guardaria el archivo y condenaria su entrega a `failed`.
3. `GET /uploads/url` validaba SOLO que la key empezara con
   tenants/{tid}/companies/{cid}/, lo que permitia firmar una URL de lectura
   para CUALQUIER objeto de esa company -incluidos los certificados de la FIEL
   bajo certificates/- con solo el permiso de modulo crm.access. Ahora exige
   ademas uno de los dos subarboles de documentos del CRM. Hay test por cada
   prefijo sensible.

El borrado DESASOCIA, no destruye en EFC: el gateway de Anexo22 nunca llama al
DELETE de EFC -verificado, ese metodo no existe en su cliente- y record.Document
alla no tiene vigencia ni purga, asi que la politica implicita es conservar. Un
documento que manana puede ser parte del expediente de un pedimento real es
riesgo de retencion fiscal.

En el frontend, abrir un documento del expediente NO usa window.open sobre la
URL del proxy: esa llamada no lleva el header Authorization y el backend
responderia 401. Va por api.getBlob y URL.createObjectURL. La descarga se
ramifica en tres y el ORDEN importa: efc_document_id primero, porque al
confirmar la entrega `delete_local` borra la copia local y file_key queda en
NULL. `postFormData` solo publica `fetchApiFormDataPost`, que ya existia con la
maquinaria de progreso pero sin exportar, asi que nadie podia usarlo.

Verificacion:
  backend  pytest tests/            EXIT=0  178 passed 1 skipped (baseline 70)
  frontend pnpm run i18n:compile    EXIT=0
           pnpm run check           EXIT=1  38 errores/17 warnings en 20 archivos
                                            IDENTICO al baseline: 55 hallazgos
                                            antes y despues, cero agregados. La
                                            unica diferencia es que el hallazgo
                                            preexistente de RelatedManager se
                                            movio de la linea 251 a la 317 al
                                            insertar codigo arriba.
           pnpm run test:unit       EXIT=1  2 failed 43 passed (baseline 2/38);
                                            los 2 rojos son los preexistentes de
                                            backend.test.ts, que pide un backend
                                            vivo en el host `backend` de docker
           pnpm run build           EXIT=0

El verde se vio fallar primero: reintroduciendo la fuga de `/uploads/url` los
tests de certificates/, doda/ y signatures/ se pusieron rojos; volviendo al
read() completo cayo el test que afirma que el tope corta sin leer el archivo
entero; y agregando un tipo de documento solo en el frontend, el test de paridad
lo nombro: "expected [ 'carta_poder' ] to deeply equal []".

Ticket: T2026-08-046 (fase 7 de 7)
2026-08-07 18:25:02 -06:00

452 lines
16 KiB
Python

"""
Convención de claves S3/MinIO para objetos persistidos.
Todas las cargas que usen ``put_object_bytes`` deben obtener la clave mediante
funciones de este módulo (no construir ``tenants/...`` a mano en las rutas HTTP).
Árbol canónico
--------------
**Multi-tenant** (datos de clientes), siempre bajo ``tenants/{tenant_id}/``:
- ``tenants/{tid}/users/{keycloak_sub}/``
Perfil de usuario (avatar). Ver ``tenant_user_prefix``, ``user_avatar_key``.
- ``tenants/{tid}/companies/{company_id}/``
Recursos ligados a una empresa:
- ``.../doda/{doda_id}/report/doda_report.pdf`` — reporte DODA en PDF. ``doda_report_pdf_key``.
- ``.../branding/{filename}`` — logo. ``company_logo_key``.
- ``.../certificates/{tipo}_{timestamp}.{cer|key}`` — CER/KEY FIEL, CFDI, cancelación.
``company_certificate_key``.
- ``.../imports/csv/{job_type}/{job_id}.csv`` — CSV de layouts (import jobs).
``csv_import_key`` (usado por ``storage_s3.s3_key_for_csv_import``).
- ``.../customs_brokers/{broker_id}/certificates/`` — CER del VU (``.cer``).
``customs_broker_vu_certificate_key``.
- ``.../customs_brokers/{broker_id}/keys/`` — llave privada VU (``.key``).
``customs_broker_vu_private_key_key``.
- ``.../customs_brokers/{broker_id}/cove/`` — archivos COVE (xml, zip, etc.).
``customs_broker_vu_cove_key``.
**Sistema global** (no por tenant):
- ``system/help/{carpeta opcional}/{archivo}`` — biblioteca de ayuda (imágenes, PDFs, vídeos).
``help_asset_key``, ``global_system_prefix``. Lectura HTTP mapea a este prefijo.
**Legado / migración**:
- ``imports/csv/{job_type}/{job_id}.csv`` — sin tenant/company. Solo ``legacy_csv_import_key``
(cleanup o compatibilidad).
Constantes públicas
-------------------
``SYSTEM_HELP_PREFIX`` — prefijo literal ``system/help/`` para lecturas y utilidades
que no pasan por ``help_asset_key``.
"""
import re
from typing import Union
# Segmentos permitidos en claves (evita path traversal)
_SAFE_SEGMENT = re.compile(r"^[a-zA-Z0-9._\-]+$")
# Prefijo fijo para objetos de Help Center (debe coincidir con help_asset_key / GET /files/)
SYSTEM_HELP_PREFIX = "system/help/"
def _segment(value: Union[int, str], label: str) -> str:
s = str(value).strip()
if not s or "/" in s or ".." in s:
raise ValueError(f"invalid {label} segment")
if not _SAFE_SEGMENT.match(s):
raise ValueError(f"invalid {label} characters")
return s
def tenant_company_prefix(tenant_id: Union[int, str], company_id: int) -> str:
"""Prefijo `tenants/{tid}/companies/{cid}/` (termina en /)."""
tid = _segment(tenant_id, "tenant_id")
cid = _segment(company_id, "company_id")
return f"tenants/{tid}/companies/{cid}/"
def tenant_user_prefix(tenant_id: Union[int, str], keycloak_user_id: str) -> str:
"""Prefijo `tenants/{tid}/users/{keycloak_sub}/` (avatar de perfil, sin company)."""
tid = _segment(tenant_id, "tenant_id")
kid = _segment(keycloak_user_id, "keycloak_user_id")
return f"tenants/{tid}/users/{kid}/"
def user_avatar_key(
tenant_id: Union[int, str],
keycloak_user_id: str,
ext: str,
) -> str:
ext = ext.lower() if ext.startswith(".") else f".{ext}"
allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp")
if ext not in allowed:
raise ValueError("invalid avatar extension")
return f"{tenant_user_prefix(tenant_id, keycloak_user_id)}avatar{ext}"
def public_user_avatar_api_path(tenant_id: int, keycloak_user_id: str) -> str:
"""Ruta GET pública para servir la imagen (sin host)."""
return f"/api/v1/core/users/avatar/{tenant_id}/{keycloak_user_id}"
def global_system_prefix(subpath: str = "help") -> str:
"""Prefijo bajo `system/` para contenido global (p. ej. help). Termina en /."""
sub = subpath.strip().strip("/")
if not sub:
return SYSTEM_HELP_PREFIX
parts = sub.split("/")
for p in parts:
_segment(p, "system_subpath")
return f"system/{sub}/"
def customs_broker_vu_prefix(
tenant_id: Union[int, str],
company_id: int,
broker_id: int,
) -> str:
"""
Prefijo para el bloque VU del agente aduanal.
Forma: ``tenants/{tid}/companies/{cid}/customs_brokers/{broker_id}/``
"""
bid = _segment(str(broker_id), "broker_id")
return f"{tenant_company_prefix(tenant_id, company_id)}customs_brokers/{bid}/"
def customs_broker_vu_certificate_key(
tenant_id: Union[int, str],
company_id: int,
broker_id: int,
timestamp: str,
file_ext: str,
) -> str:
"""CER del VU bajo ``.../customs_brokers/{id}/certificates/vu_cer_{timestamp}.cer``."""
ts = _segment(timestamp, "timestamp")
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
if ext != ".cer":
raise ValueError("VU certificate must be .cer")
base = f"vu_cer_{ts}{ext}"
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}certificates/{base}"
def customs_broker_vu_private_key_key(
tenant_id: Union[int, str],
company_id: int,
broker_id: int,
timestamp: str,
file_ext: str,
) -> str:
"""Llave privada del VU bajo ``.../customs_brokers/{id}/keys/vu_key_{timestamp}.key``."""
ts = _segment(timestamp, "timestamp")
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
if ext != ".key":
raise ValueError("VU private key must be .key")
base = f"vu_key_{ts}{ext}"
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}keys/{base}"
def customs_broker_vu_cove_key(
tenant_id: Union[int, str],
company_id: int,
broker_id: int,
timestamp: str,
original_filename: str,
) -> str:
"""
Archivos COVE bajo ``.../customs_brokers/{id}/cove/cove_{timestamp}_{filename}``.
Extensiones típicas: .xml, .zip, .txt, .pdf, .json
"""
ts = _segment(timestamp, "timestamp")
fn = safe_filename(original_filename)
parts = fn.rsplit(".", 1)
if len(parts) < 2:
raise ValueError("COVE file must have an extension")
ext = "." + parts[1].lower()
allowed = (".xml", ".zip", ".txt", ".pdf", ".json")
if ext not in allowed:
raise ValueError(f"COVE extension not allowed: {ext}")
base = f"cove_{ts}_{fn}"
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}cove/{base}"
def customs_broker_vu_doda_certificate_key(
tenant_id: Union[int, str],
company_id: int,
broker_id: int,
timestamp: str,
file_ext: str,
) -> str:
"""CER DODA bajo ``.../customs_brokers/{id}/doda/certificates/doda_cer_{timestamp}.cer``."""
ts = _segment(timestamp, "timestamp")
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
if ext != ".cer":
raise ValueError("DODA certificate must be .cer")
base = f"doda_cer_{ts}{ext}"
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/certificates/{base}"
def customs_broker_vu_doda_private_key_key(
tenant_id: Union[int, str],
company_id: int,
broker_id: int,
timestamp: str,
file_ext: str,
) -> str:
"""Llave DODA bajo ``.../customs_brokers/{id}/doda/keys/doda_key_{timestamp}.key``."""
ts = _segment(timestamp, "timestamp")
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
if ext != ".key":
raise ValueError("DODA private key must be .key")
base = f"doda_key_{ts}{ext}"
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/keys/{base}"
def customs_broker_vu_doda_cove_key(
tenant_id: Union[int, str],
company_id: int,
broker_id: int,
timestamp: str,
original_filename: str,
) -> str:
"""
Archivos DODA XML bajo ``.../customs_brokers/{id}/doda/cove/doda_cove_{timestamp}_{filename}``.
Extensiones permitidas: .xml, .zip, .txt, .pdf, .json
"""
ts = _segment(timestamp, "timestamp")
fn = safe_filename(original_filename)
parts = fn.rsplit(".", 1)
if len(parts) < 2:
raise ValueError("DODA file must have an extension")
ext = "." + parts[1].lower()
allowed = (".xml", ".zip", ".txt", ".pdf", ".json")
if ext not in allowed:
raise ValueError(f"DODA extension not allowed: {ext}")
base = f"doda_cove_{ts}_{fn}"
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/cove/{base}"
def job_type_segment(job_type: str) -> str:
if job_type == "" or job_type == "invoice":
return "invoice"
return job_type
def csv_import_key(
tenant_id: Union[int, str],
company_id: int,
job_type: str,
job_id: str,
) -> str:
_segment(job_id, "job_id")
return (
f"{tenant_company_prefix(tenant_id, company_id)}"
f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv"
)
def expediente_document_key(
tenant_id: Union[int, str],
company_id: int,
expediente_id: int,
unique_token: str,
original_filename: str,
) -> str:
"""
Documento de un expediente del CRM, bajo
``tenants/{tid}/companies/{cid}/expedientes/{expediente_id}/documents/{token}_{filename}``.
Es una **copia de tránsito**: el destino final del archivo es el expediente electrónico de EFC,
y al confirmar la entrega esta copia se borra (``delete_local`` del outbox). Vive bajo el árbol
por tenant/company igual que todo lo demás, para que el aislamiento sea el mismo.
NO confundir con ``expediente_archivo_document_key``: aquélla se refiere al expediente del
**importador** de EFC, que cuelga de un RFC y es otro concepto. Es código muerto de la plantilla.
"""
eid = _segment(expediente_id, "expediente_id")
token = _segment(unique_token, "unique_token")
fn = safe_filename(original_filename)
return f"{tenant_company_prefix(tenant_id, company_id)}expedientes/{eid}/documents/{token}_{fn}"
def legacy_csv_import_key(job_type: str, job_id: str) -> str:
"""Clave antigua sin tenant/company (solo migración / cleanup)."""
_segment(job_id, "job_id")
return f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv"
def safe_filename(filename: str) -> str:
"""Nombre de archivo final sin separadores."""
base = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if not base or ".." in base:
raise ValueError("invalid filename")
return base
def company_logo_key(
tenant_id: Union[int, str],
company_id: int,
filename: str,
) -> str:
fn = safe_filename(filename)
return f"{tenant_company_prefix(tenant_id, company_id)}branding/{fn}"
def doda_report_pdf_key(
tenant_id: Union[int, str],
company_id: int,
doda_id: int,
) -> str:
"""
Reporte DODA en PDF bajo ``.../doda/{doda_id}/report/doda_report.pdf`` (clave estable).
"""
did = _segment(doda_id, "doda_id")
return f"{tenant_company_prefix(tenant_id, company_id)}doda/{did}/report/doda_report.pdf"
def company_certificate_key(
tenant_id: Union[int, str],
company_id: int,
certificate_type: str,
timestamp: str,
file_ext: str,
) -> str:
ct = _segment(certificate_type.replace(".", "_"), "certificate_type")
ts = _segment(timestamp, "timestamp")
ext = file_ext.lower() if file_ext.startswith(".") else f".{file_ext}"
if ext not in (".cer", ".key"):
raise ValueError("certificate file must be .cer or .key")
base = f"{ct}_{ts}{ext}"
return f"{tenant_company_prefix(tenant_id, company_id)}certificates/{base}"
def expediente_archivo_document_key(
tenant_id: Union[int, str],
company_id: int,
expediente_id: int,
timestamp: str,
original_filename: str,
) -> str:
"""
Archivo del expediente bajo ``.../expediente_archivos/{id}/documents/expediente_{timestamp}_{filename}``.
Extensiones permitidas: .pdf, .xml, .png, .jpg, .jpeg, .json, .txt, .zip
"""
ts = _segment(timestamp, "timestamp")
eid = _segment(expediente_id, "expediente_id")
fn = safe_filename(original_filename)
parts = fn.rsplit(".", 1)
if len(parts) < 2:
raise ValueError("expediente file must have an extension")
ext = "." + parts[1].lower()
allowed = (".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip")
if ext not in allowed:
raise ValueError(f"expediente file extension not allowed: {ext}")
base = f"expediente_{ts}_{fn}"
return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/documents/{base}"
def expediente_archivo_artifact_key(
tenant_id: Union[int, str],
company_id: int,
expediente_id: int,
artifact_type: str,
timestamp: str,
) -> str:
"""
Artefacto de digitalización bajo ``.../expediente_archivos/{id}/artifacts/{type}_{timestamp}.{ext}``.
artifact_type: acuse | envio_xml | respuesta_xml | consulta_envio_xml | consulta_respuesta_xml
"""
ts = _segment(timestamp, "timestamp")
eid = _segment(expediente_id, "expediente_id")
at = _segment(artifact_type, "artifact_type")
ext = ".pdf" if artifact_type == "acuse" else ".xml"
return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/artifacts/{at}_{ts}{ext}"
def cove_xml_key(
tenant_id: Union[int, str],
company_id: int,
invoice_id: int,
) -> str:
"""
XML de COVE devuelto por Ventanilla Única, bajo
``.../invoices/{invoice_id}/cove/cove.xml`` (clave estable por factura).
"""
iid = _segment(invoice_id, "invoice_id")
return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/cove.xml"
def cove_acuse_pdf_key(
tenant_id: Union[int, str],
company_id: int,
invoice_id: int,
) -> str:
"""
PDF de Acuse de COVE bajo
``.../invoices/{invoice_id}/cove/acuse_cove.pdf`` (clave estable por factura).
"""
iid = _segment(invoice_id, "invoice_id")
return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/acuse_cove.pdf"
def help_asset_key(folder: str, new_filename: str) -> str:
"""
folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/
"""
folder = folder.strip().strip("/")
fn = safe_filename(new_filename)
if folder:
for p in folder.split("/"):
_segment(p, "help_folder")
return f"{global_system_prefix('help')}{folder}/{fn}"
return f"{global_system_prefix('help')}{fn}"
def help_s3_key_to_public_relative_path(key: str) -> str:
"""Parte tras `system/help/` para el path del endpoint público."""
if not key.startswith(SYSTEM_HELP_PREFIX):
raise ValueError("key is not under system/help/")
return key[len(SYSTEM_HELP_PREFIX) :]
def help_public_api_path(relative_under_help: str) -> str:
"""URL de lectura pública bajo el router help-center (sin host)."""
rel = relative_under_help.lstrip("/")
return f"/api/v1/core/help-center/files/{rel}"
def signature_photo_key(
tenant_id: Union[int, str],
company_id: int,
signature_id: int,
timestamp: str,
file_ext: str,
) -> str:
"""
Foto de firma bajo ``.../signatures/{signature_id}/photo_{timestamp}.{ext}``.
Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp
"""
sid = _segment(signature_id, "signature_id")
ts = _segment(timestamp, "timestamp")
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp")
if ext not in allowed:
raise ValueError(f"signature photo extension not allowed: {ext}")
return f"{tenant_company_prefix(tenant_id, company_id)}signatures/{sid}/photo_{ts}{ext}"
def system_help_object_key(relative_path: str) -> str:
"""
Clave S3 completa bajo ``system/help/`` para un path relativo (p. ej. GET /files/...).
``relative_path`` no debe empezar por / ni contener '..'.
"""
rel = relative_path.strip().lstrip("/")
if ".." in rel or not rel:
raise ValueError("invalid help object path")
return f"{SYSTEM_HELP_PREFIX}{rel}"