PROYECTO_01
Skilled ERP
ERP de nóminas, empleados, inventario y herramientas para una empresa de ingeniería eléctrica.
- TIPO
- Full Stack · Seguridad
- ESTADO
- EN PRODUCCIÓN
- DIAGRAMAS
- 4
- CAPTURAS
- 12
01 Qué es
API JSON en Flask, sin HTML, que sirve al SPA de Skilled ERP (React + Vite, hosteado en Vercel). Este repo expone /api/* y el canal de Socket.IO; el frontend vive en su propio repo. Cubre nóminas, empleados, proyectos, horas trabajadas, préstamos, inventario de materiales y herramientas, con notificaciones y bitácora en tiempo real.
- Empleados: alta, baja y edición; campos laborales, personales, médicos y financieros con whitelist por rol. Notas internas tipo «chatter» en la ficha.
- Nómina: prenómina semanal, descuentos, depósitos extra, préstamos con abonos y el ajuste Inbursa por periodo.
- Horas: reportes semanales, registros diarios, ausencias, saldo de vacaciones y checada por RFID o QR.
- Proyectos M:N: un trabajador puede estar en varios proyectos; expediente y credenciales se derivan solo de los proyectos activos.
- Inventario: productos, almacenes y estantes con QR; movimientos con bloqueo anti-concurrencia; solicitudes con flujo PENDIENTE → APROBADA / RECHAZADA / ENTREGADA y entrega parcial; tomas físicas con ajuste automático; etiquetas Avery y órdenes de compra.
- Herramientas: catálogo y unidades físicas rastreables por serie o QR; asignaciones a trabajadores, mantenimientos, incidencias y baja con autorización.
- Panel de sistemas: estado de la infraestructura, tráfico y percentiles, sesiones activas, bloqueos de cuenta, eventos de seguridad y archivos huérfanos en R2.
- Reportes: PDF (recibos, constancias, solicitudes, tomas, OC) y Excel (totales por proyecto, histórico), saneados contra inyección de fórmulas. Carga masiva de empleados y productos desde
.xlsx.
02 Arquitectura
Todo entra por Cloudflare, baja por el túnel, pasa por Nginx y termina en Gunicorn. El servidor de origen no publica ningún puerto hacia afuera: el túnel es una conexión saliente y el firewall descarta cualquier intento de conexión entrante directa. En local ese mismo camino se levanta con docker compose up.
| Pieza | Para qué |
|---|---|
| Flask 3 + SQLAlchemy 2 | API con 18 blueprints y 216 endpoints; PyJWT, Flask-Limiter y Flask-Talisman |
| PostgreSQL (psycopg v3) | Base de datos; migraciones con Alembic |
| Redis | Rate-limit, lockout escalado, anti-replay de TOTP y message_queue de Socket.IO. Obligatorio: create_app() aborta si no conecta |
| Socket.IO | Tiempo real: gevent en producción (WebSocket real), threading en desarrollo |
| Cloudflare R2 + ClamAV | Bucket público para el catálogo y privado para documentos y fotos, con disco como respaldo; todo lo que se sube se escanea |
| pandas + openpyxl · xhtml2pdf | Excel y PDF sobre plantillas Jinja |
| Gunicorn → Nginx → Cloudflare Tunnel | 4 workers gevent × 1000 conexiones; túnel con protocol: http2, requerido para WebSockets estables |
Los 4 workers de gevent son procesos distintos, así que todo lo que debe ser común (rate-limit, lockout, anti-replay de 2FA, eventos de Socket.IO) vive en Redis. Los archivos no se guardan tal cual: una imagen externa se valida contra SSRF, se comprueban sus magic bytes, se reescribe con Pillow a WebP y se sube a R2 con el SHA-256 de su contenido como llave. En inventario, dos movimientos sobre el mismo stock no se pisan porque la fila se toma con SELECT … FOR UPDATE antes de escribir.
03 Puesta en marcha
Requisitos: Python 3.12, PostgreSQL 14+, Redis y, por el camino recomendado, Docker con Docker Compose. Con Docker se levanta la API con su propio PostgreSQL, Redis y ClamAV usando el .env de la raíz. Es la única forma de probar en Windows el camino real de WebSockets con gevent y el antivirus, que en el venv nativo no arrancan.
> cp .env.example .env # rellenar SECRET_KEY, DATABASE_URL, REDIS_URL…
> docker compose up --build # api en http://localhost:5000
> docker compose logs -f api # logs de la API
> docker compose exec api pytest # tests dentro del contenedor> python -m venv venv
> source venv/bin/activate # Linux / Mac; en Windows: venv\Scripts\activate
> pip install -r requirements.txt
> flask db upgrade # crea la BD en Postgres antes
> python run.py # http://localhost:5000Variables de entorno
.env.example documenta todas las variables con su comando de generación. Las que importan:
| Variable | Notas |
|---|---|
SECRET_KEY | Obligatoria: la app no arranca sin ella |
DATABASE_URL | Driver psycopg v3: postgresql+psycopg://… |
TOTP_ENCRYPTION_KEY | Clave Fernet para cifrar los secretos 2FA en la BD |
REDIS_URL | Rate-limit, lockout, anti-replay TOTP y message queue de Socket.IO |
CORS_ORIGINS | Dev: Vite (5173). Prod: dominios de Vercel o propio |
RT_COOKIE_SAMESITE | Lax same-origin (dev); None cross-origin (prod) |
SOCKETIO_ASYNC_MODE | threading en dev (default); gevent en prod y en los contenedores |
DB_POOL_SIZE / DB_MAX_OVERFLOW | Pool por proceso (10 + 10). Total = workers × (pool + overflow) ≤ max_connections |
R2_* / R2_PRIVADO_* | Bucket público del catálogo y privado de documentos. Si R2_PRIVADO_BUCKET va vacía, todo se queda en disco (uploads/) |
CLAMAV_HOST / CLAMAV_SOCKET | Demonio del antivirus. CLAMAV_FAIL_CLOSED=true en prod |
IMG_MAX_DOWNLOAD_BYTES / IMG_MAX_PIXELS | Topes de la descarga de imágenes externas |
USE_X_ACCEL_REDIRECT | true solo en prod con Nginx configurado |
HSTS_PRELOAD | false salvo que estés seguro: es semi-irreversible |
04 Estructura del código
> run.py # entry point; monkey-patch de gevent si SOCKETIO_ASYNC_MODE=gevent
> Dockerfile # imagen de la API (builder + runtime)
> docker-compose.yml # stack de desarrollo: api, db, redis, clamav
> docker-compose.prod.yml # stack del VPS
> nginx.config # config de Nginx
> gunicorn.serviceee # unit de systemd; se instala como nominas.service
> app/__init__.py # create_app(): CORS, Talisman, Limiter, blueprints, Socket.IO
> app/extensions.py # db, limiter, mail; IP real tras Cloudflare; EncryptedString
> app/realtime.py # Socket.IO: handshake, salas, hooks ORM, emit_to_*
> app/observabilidad.py # after_request: contadores, histograma y detalle en Redis
> app/models/ # SQLAlchemy, 12 módulos por dominio
> app/routes/ # 18 blueprints, cada uno como sub-paquete con _core.py
> app/utils/ # seguridad, archivos, R2, antivirus, imágenes, horas, nómina
> templates/ # Jinja solo para PDFs (xhtml2pdf)
> migrations/ # Alembic
> tests/ # 36 módulos de pytestcreate_app() arranca en orden fijo: config → espera a Redis → extensiones → CORS solo en /api/* → Talisman y CSP → registra los 18 blueprints (exentos de CSRF, protegidos por JWT) → error handlers globales → headers de seguridad y Cache-Control: no-store → tablas auxiliares → ProxyFix → Socket.IO al final, para ver los headers ya corregidos.
- Cada blueprint es un sub-paquete:
_core.pycon el blueprint, los decoradores y los serializers, y un módulo por tema. _api_helpers.pycompartido:current_user(),is_admin(),require_admin(),require_roles()y el decoradorapi_transactional(rollback y log automático ante excepción).- Toda mutación relevante llama a
log_action(...): escribe enaudit_logcon usuario, IP y acción, y el insert dispara el pushbitacora:new. - Paginación estándar
page/per_pagecon respuesta{items, total, pages}; trabajadores, préstamos y proyectos aceptan ademássort/dircon whitelist de columnas. - Excel con
_sanitize_rows(anti=fórmula) y estilos compartidos entre 5 paquetes. PDF: Jinja → xhtml2pdf. models/es SQLAlchemy puro, sin lógica de negocio; se re-exporta plano desdeapp/models/__init__.py.
05 Referencia de la API
216 rutas agrupadas por blueprint. Todas requieren JWT (Authorization: Bearer …) salvo login, refresh y /health. Respuestas de error estándar: 401 (JWT ausente o expirado), 403 (rol), 404, 422 (validación), 429 (rate-limit) y 419 (CSRF en flujos con cookie).
| Blueprint | Prefijo | Qué expone |
|---|---|---|
api_auth | /api/auth | Login en dos pasos, refresh y logout, perfil propio, sesiones activas, 2FA y códigos de respaldo |
api_trabajadores | /api/trabajadores | CRUD con whitelist por rol, baja lógica, timeline, notas, credenciales, foto, documentos, importar y exportar Excel |
api_proyectos | /api/proyectos | Proyectos, participantes y coordinador; recalcula el expediente de los afectados |
api_horas | /api/horas | Reportes semanales, registros diarios (upsert idempotente en bulk), checada por QR y RFID, pantalla para celular del coordinador |
api_prenomina | /api/prenomina | Preview en vivo, guardar y cerrar semana, descuentos, depósitos, viáticos, festivos, PDF, Excel y recibos por correo |
api_prestamos · api_ajustes | /api/prestamos · /api/ajustes | Préstamos con abonos y liquidación; ajuste Inbursa por periodo |
api_proyecto_total · api_historico | /api/proyecto-total · /api/historico | Acumulados de nómina por proyecto y semanas cerradas, solo lectura y export |
api_users | /api/users | Administración de cuentas: solo super_admin crea o elimina admins |
api_dashboard · api_bitacora · api_metricas · api_notificaciones · api_search | /api/… | KPIs y alertas, audit log paginado, métricas, notificaciones y búsqueda global |
inventario_api | /api/v1 | Productos, almacenes y estantes con QR, movimientos con lock, solicitudes, tomas físicas, etiquetas y órdenes de compra |
herramientas_api | /api/v1 | Catálogo → unidades físicas → asignación, mantenimiento, incidencia y baja con autorización |
06 Roles
| Rol | Acceso |
|---|---|
super_admin | Todo. Único que administra otros admins |
admin | Todo excepto crear o eliminar otros admins |
sistemas | Panel de TI (/api/sistemas): infraestructura, sesiones, bloqueos, eventos de seguridad. Exige 2FA activo |
inventario | Módulo de inventario completo, sin administración de usuarios |
coordinador | Solo /horas y los campos médicos y de contacto de los trabajadores de sus proyectos |
solicitante_material | Solo /inventario/mis-pedidos |
user | Rol por defecto al crear una cuenta; sin acceso a los módulos administrativos |
La autorización va por decoradores (require_admin, require_roles, _require_inventario…) más whitelist de campos editables por rol en trabajadores: el coordinador no toca salarios ni PII fiscal, y lo prohibido se ignora y regresa en warnings. El coordinador tiene «ownership»: solo ve y edita lo de sus propios proyectos.
07 Tiempo real
El connect valida el JWT y cada conexión se une a las salas user:{id} y role:{rol}; join:reporte agrega reporte:{id} para el kiosko de captura. El worker que hace un cambio lo publica en Redis y el resto reenvía el evento a sus sockets. El evento solo lleva {id, action}: el navegador vuelve a pedir el dato por REST, donde el permiso se valida otra vez. Los hooks ORM emiten solo si el commit fue exitoso.
| Evento | Dispara | Audiencia |
|---|---|---|
notif:new | Insert de Notificacion | user:{id} destinatario |
bitacora:new | Insert de AuditLog | Roles admin |
abono:new | Insert de AbonoPrestamo, manual o por prenómina | Roles admin |
reporte:estado_cambio | Cambio de estado de ReporteSemanal | Sala reporte:{id} |
reporte:registros_cambio | Cambios en RegistroDiarioHoras; sustituyó al polling del kiosko | Sala reporte:{id} |
nota:changed | POST o DELETE en /trabajadores/<id>/notas | Admin y coordinador |
Notificaciones in-app: REPORTE_CERRADO al cerrar un reporte de horas y PRENOMINA_CERRADA al cerrar una prenómina; el push va por notif:new y el polling queda como respaldo. Las leídas se eliminan a los 30 días en cada GET /resumen, sin cron externo. La sesión del SPA se renueva sola 60 s antes de expirar y, si aun así llega un 401, varias peticiones en vuelo comparten un único refresh.
08 Seguridad
El sistema pasó por una auditoría ofensiva completa (pentest, revisión de código e infraestructura): score 5.8 → 8.4/10, con 4 críticas y 7 altas cerradas en código.
Autenticación
- JWT HS256 con
iss=skilled-erp-apiyaud=skilled-erp-spa. Access token corto y refresh rotativo en cookie httpOnly con detección de replay. - 2FA en dos pasos: la contraseña devuelve un
stepTokende un solo uso (conjtiquemado en Redis) y solo con él se puede verificar el TOTP. El secreto se guarda cifrado con Fernet. - CSRF:
/api/auth/refreshy/api/auth/logoutexigenX-Requested-With: XMLHttpRequest; el header fuerza preflight y bloquea POST cross-site desde un<form>. - Lockout escalado por IP y usuario en Redis (10 min → 24 h), anti-replay de TOTP durante 90 s y comparación constant-time en login.
- Cambiar la contraseña invalida los JWT en uso mediante
password_version.
Archivos e inputs
- Documentos de trabajador: PDF, JPG, PNG o HEIC, ≤ 20 MB, validados por magic bytes y no por extensión, y escaneados con ClamAV (
fail closeden producción). - Imágenes externas: el host se resuelve y se rechaza si cae en red interna (anti-SSRF, incluidos redirects), tope de bytes y de píxeles, y reescritura completa a WebP con Pillow.
imagen_urlsolo aceptahttps://o paths/static/…locales; bloqueajavascript:,data:,http://yfile:///.- Prenómina valida
tipoenum,conceptode 1 a 250 caracteres,monto≤ $999,999.99 yfecha_incidenciano futura.
Infraestructura
- Rate-limit en dos capas: Nginx (
api_general30/s,api_auth30/min) y Flask-Limiter por usuario e IP en Redis, con la IP real validada contra los CIDR oficiales de Cloudflare. - CSP estricta (
default-src none), Talisman con HSTS,frame-ancestors: none, COOP y CORP; CORS restringido a orígenes conocidos yCache-Control: no-storeen todo/api/*. - Gunicorn con
--forwarded-allow-ips=127.0.0.1cierra el spoofing deCF-Connecting-IP. - Hardening de systemd:
ProtectSystem=strict,NoNewPrivileges,CapabilityBoundingSetvacío yMemoryDenyWriteExecute. El.envva conchmod 640y solo lo lee el usuario del servicio. - Cada respuesta pasa por un
after_requestque suma contadores en Redis y guarda el detalle de las lentas y las que fallan: de ahí salen los p50/p95/p99 del panel de sistemas.
| Higiene continua | Frecuencia | Cómo |
|---|---|---|
pip-audit y npm audit | Mensual | pip-audit -r requirements.txt · npm audit --production |
| Revisar bitácora | Semanal | UI /bitacora o consulta a audit_log filtrando %fallido% |
| Backup de BD y archivos | Diario | pg_dump nominas | gzip > backup_$(date +%F).sql.gz |
Rotar SECRET_KEY | Semestral | secrets.token_urlsafe(64) y reinicio del servicio |
| Pentest externo | Anual | — |
09 Producción
Cadena: Cloudflare Tunnel → Nginx (127.0.0.1:80) → Gunicorn (127.0.0.1:8000) → Flask. El unit instalado se llama nominas.service; el archivo del repo es gunicorn.serviceee.
> sudo cp gunicorn.serviceee /etc/systemd/system/nominas.service
> sudo systemctl daemon-reload && sudo systemctl enable --now nominas
> sudo cp nginx.config /etc/nginx/sites-available/skilled
> sudo ln -s /etc/nginx/sites-available/skilled /etc/nginx/sites-enabled/skilled
> sudo nginx -t && sudo systemctl reload nginx- Gunicorn: 4 workers
GeventWebSocketWorker× 1000 conexiones. gthread no implementa el upgrade a WebSocket y eventlet es incompatible con psycopg3. --max-requests 1000con jitter recicla workers para evitar fugas de pandas, openpyxl y xhtml2pdf.- Nginx: rate-limit por IP como segunda capa, IP real de Cloudflare, anti-Slowloris y anti request smuggling, bloque dedicado para
/socket.io/conproxy_read_timeout 3600s, whitelist de métodos y bloqueo de paths escaneados (.env,wp-login.php…). - Los headers de seguridad y CORS los pone Flask; no se duplican en Nginx.
- Frontend en Vercel con
VITE_API_URL=https://api.<dominio>/api; el backend llevaCORS_ORIGINScon el dominio de Vercel,RT_COOKIE_SAMESITE=None,FLASK_ENV=productionyUSE_X_ACCEL_REDIRECT=true.
> curl https://api.<dominio>/health # {"status":"ok"}
> sudo journalctl -u nominas -n 20 --no-pager # IPs reales, no 127.0.0.1
> sudo tail -n 20 /var/log/nginx/skilled_api.access.log # upstream=127.0.0.1:8000 request_time=…10 Base de datos
PostgreSQL vía SQLAlchemy y Alembic (flask db …). Los modelos están partidos por dominio en app/models/. Convenciones: PK id autoincremental salvo en los mapeos M:N, timestamps en UTC, montos en Numeric(10,2) y estados como cadenas en mayúsculas.
| Dominio | Tablas clave |
|---|---|
| Auth | users, refresh_tokens, totp_backup_codes, audit_log |
| Empleados | trabajadores (~60 columnas), credenciales_plantas, documentos_trabajador, trabajador_notas |
| Proyectos | proyectos, M:N con trabajadores vía proyecto_trabajador |
| Horas | reportes_semanales, registros_diarios_horas, saldo_vacaciones, ausencias |
| Nómina y préstamos | prenominas, descuentos_prenomina, depositos_extra, prestamos, abonos_prestamo |
| Ajuste Inbursa | ajuste_periodos, ajuste_trabajadores_periodo, ajuste_descuentos |
| Inventario | almacenes, estantes, productos, stock_por_almacen, movimientos_inventario, tomas_inventario, solicitudes_material |
| Herramientas | herramientas, herramienta_unidades, asignaciones_herramienta, mantenimientos_herramienta, incidencias_herramienta, solicitudes_baja_herramienta |
| Notificaciones | notificaciones (in-app, purga a 30 días) |
> flask db migrate -m "descripción" # nueva migración
> flask db upgrade # aplicar
> flask db current # revisión actual
> pytest tests/ # correr tras cualquier cambio