PROJECT_01

Skilled Proyectos Industriales

Skilled ERP

ERP for payroll, employees, inventory and tools, built for an electrical engineering firm.

TYPE
Full Stack · Security
STATUS
IN PRODUCTION
DIAGRAMS
4
SCREENSHOTS
12
  • React 18
  • Vite
  • TailwindCSS
  • Python 3.12
  • Flask 3
  • SQLAlchemy 2
  • PostgreSQL
  • Redis
  • Socket.IO
  • Docker
  • Nginx
  • Cloudflare R2

01 What it is

A JSON API in Flask, no HTML, serving the Skilled ERP SPA (React + Vite, hosted on Vercel). This repo exposes /api/* and the Socket.IO channel; the frontend lives in its own repo. It covers payroll, employees, projects, worked hours, loans, materials and tools inventory, with real-time notifications and audit log.

  • Employees: create, edit and deactivate; employment, personal, medical and financial fields with a per-role whitelist. Internal "chatter" notes on the record.
  • Payroll: weekly pre-payroll, deductions, extra deposits, loans with repayments and the per-period Inbursa adjustment.
  • Hours: weekly reports, daily records, absences, vacation balance and RFID or QR clock-in.
  • M:N projects: a worker can belong to several projects; record and credentials derive only from active projects.
  • Inventory: products, warehouses and QR-labelled shelves; movements with an anti-concurrency lock; requests with a PENDING → APPROVED / REJECTED / DELIVERED flow and partial delivery; physical counts with automatic adjustment; Avery labels and purchase orders.
  • Tools: catalogue and physical units traceable by serial or QR; assignments to workers, maintenance, incidents and authorized write-off.
  • Systems panel: infrastructure status, traffic and percentiles, active sessions, account lockouts, security events and orphan files on R2.
  • Reports: PDF (receipts, certificates, requests, counts, POs) and Excel (per-project totals, history), sanitized against formula injection. Bulk import of employees and products from .xlsx.

02 Architecture

Everything comes in through Cloudflare, down the tunnel, through Nginx and ends at Gunicorn. The origin server publishes no inbound ports: the tunnel is an outbound connection and the firewall drops any direct inbound attempt. Locally, the same path comes up with docker compose up.

PieceWhat for
Flask 3 + SQLAlchemy 2API with 18 blueprints and 216 endpoints; PyJWT, Flask-Limiter and Flask-Talisman
PostgreSQL (psycopg v3)Database; migrations with Alembic
RedisRate limiting, escalating lockout, TOTP anti-replay and the Socket.IO message_queue. Required: create_app() aborts if it cannot connect
Socket.IOReal time: gevent in production (real WebSocket), threading in development
Cloudflare R2 + ClamAVPublic bucket for the catalogue and a private one for documents and photos, with disk as fallback; every upload is scanned
pandas + openpyxl · xhtml2pdfExcel and PDF over Jinja templates
Gunicorn → Nginx → Cloudflare Tunnel4 gevent workers × 1000 connections; tunnel with protocol: http2, required for stable WebSockets

The 4 gevent workers are separate processes, so everything that must be shared (rate limiting, lockout, 2FA anti-replay, Socket.IO events) lives in Redis. Files are not stored as they come: an external image is checked against SSRF, its magic bytes are verified, it is rewritten with Pillow to WebP and uploaded to R2 with the SHA-256 of its content as the key. In inventory, two movements on the same stock cannot overwrite each other because the row is locked with SELECT … FOR UPDATE before writing.

03 Getting started

Requirements: Python 3.12, PostgreSQL 14+, Redis and, for the recommended path, Docker with Docker Compose. With Docker the API comes up with its own PostgreSQL, Redis and ClamAV using the root .env. It is the only way on Windows to test the real WebSocket path with gevent and the antivirus, which do not start in a native venv.

> 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:5000

Environment variables

.env.example documents every variable with its generation command. The ones that matter:

VariableNotes
SECRET_KEYRequired: the app does not start without it
DATABASE_URLpsycopg v3 driver: postgresql+psycopg://…
TOTP_ENCRYPTION_KEYFernet key to encrypt 2FA secrets in the DB
REDIS_URLRate limiting, lockout, TOTP anti-replay and the Socket.IO message queue
CORS_ORIGINSDev: Vite (5173). Prod: Vercel or custom domains
RT_COOKIE_SAMESITELax same-origin (dev); None cross-origin (prod)
SOCKETIO_ASYNC_MODEthreading in dev (default); gevent in prod and in the containers
DB_POOL_SIZE / DB_MAX_OVERFLOWPer-process pool (10 + 10). Total = workers × (pool + overflow) ≤ max_connections
R2_* / R2_PRIVADO_*Public catalogue bucket and private documents bucket. If R2_PRIVADO_BUCKET is empty, everything stays on disk (uploads/)
CLAMAV_HOST / CLAMAV_SOCKETAntivirus daemon. CLAMAV_FAIL_CLOSED=true in prod
IMG_MAX_DOWNLOAD_BYTES / IMG_MAX_PIXELSLimits for external image downloads
USE_X_ACCEL_REDIRECTtrue only in prod with Nginx configured
HSTS_PRELOADfalse unless you are sure: it is semi-irreversible

04 Code layout

> 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 pytest

create_app() boots in a fixed order: config → wait for Redis → extensions → CORS on /api/* only → Talisman and CSP → register the 18 blueprints (CSRF-exempt, JWT-protected) → global error handlers → security headers and Cache-Control: no-store → auxiliary tables → ProxyFix → Socket.IO last, so it sees the corrected headers.

  • Each blueprint is a sub-package: _core.py with the blueprint, decorators and serializers, plus one module per topic.
  • Shared _api_helpers.py: current_user(), is_admin(), require_admin(), require_roles() and the api_transactional decorator (automatic rollback and log on exception).
  • Every relevant mutation calls log_action(...): it writes to audit_log with user, IP and action, and the insert triggers the bitacora:new push.
  • Standard pagination page / per_page with a {items, total, pages} response; workers, loans and projects also accept sort / dir with a column whitelist.
  • Excel with _sanitize_rows (anti =formula) and styles shared across 5 packages. PDF: Jinja → xhtml2pdf.
  • models/ is plain SQLAlchemy, no business logic; re-exported flat from app/models/__init__.py.

05 API reference

216 routes grouped by blueprint. All require a JWT (Authorization: Bearer …) except login, refresh and /health. Standard error responses: 401 (missing or expired JWT), 403 (role), 404, 422 (validation), 429 (rate limit) and 419 (CSRF on cookie flows).

BlueprintPrefixWhat it exposes
api_auth/api/authTwo-step login, refresh and logout, own profile, active sessions, 2FA and backup codes
api_trabajadores/api/trabajadoresCRUD with per-role whitelist, soft delete, timeline, notes, credentials, photo, documents, Excel import and export
api_proyectos/api/proyectosProjects, participants and coordinator; recalculates the record of those affected
api_horas/api/horasWeekly reports, daily records (idempotent bulk upsert), QR and RFID clock-in, coordinator mobile screen
api_prenomina/api/prenominaLive preview, save and close week, deductions, deposits, per-diems, holidays, PDF, Excel and receipts by email
api_prestamos · api_ajustes/api/prestamos · /api/ajustesLoans with repayments and settlement; per-period Inbursa adjustment
api_proyecto_total · api_historico/api/proyecto-total · /api/historicoPer-project payroll totals and closed weeks, read-only and export
api_users/api/usersAccount administration: only super_admin creates or deletes admins
api_dashboard · api_bitacora · api_metricas · api_notificaciones · api_search/api/…KPIs and alerts, paginated audit log, metrics, notifications and global search
inventario_api/api/v1Products, warehouses and QR shelves, locked movements, requests, physical counts, labels and purchase orders
herramientas_api/api/v1Catalogue → physical units → assignment, maintenance, incident and authorized write-off

06 Roles

RoleAccess
super_adminEverything. The only one that manages other admins
adminEverything except creating or deleting other admins
sistemasIT panel (/api/sistemas): infrastructure, sessions, lockouts, security events. Requires active 2FA
inventarioFull inventory module, no user administration
coordinadorOnly /horas and the medical and contact fields of the workers on their projects
solicitante_materialOnly /inventario/mis-pedidos
userDefault role for a new account; no access to administrative modules

Authorization goes through decorators (require_admin, require_roles, _require_inventario…) plus a per-role whitelist of editable worker fields: the coordinator cannot touch salaries or tax PII, and anything forbidden is ignored and returned in warnings. The coordinator has ownership: they only see and edit what belongs to their own projects.

07 Real time

The connect handler validates the JWT and each connection joins the user:{id} and role:{rol} rooms; join:reporte adds reporte:{id} for the capture kiosk. The worker that makes a change publishes it to Redis and the rest forward the event to their sockets. The event carries only {id, action}: the browser fetches the data again over REST, where the permission is validated again. ORM hooks emit only if the commit succeeded.

EventTriggerAudience
notif:newInsert of NotificacionRecipient user:{id}
bitacora:newInsert of AuditLogAdmin roles
abono:newInsert of AbonoPrestamo, manual or via pre-payrollAdmin roles
reporte:estado_cambioState change of ReporteSemanalRoom reporte:{id}
reporte:registros_cambioChanges in RegistroDiarioHoras; replaced the kiosk pollingRoom reporte:{id}
nota:changedPOST or DELETE on /trabajadores/<id>/notasAdmin and coordinator

In-app notifications: REPORTE_CERRADO when an hours report closes and PRENOMINA_CERRADA when a pre-payroll closes; the push goes over notif:new and polling stays as fallback. Read ones are deleted after 30 days on each GET /resumen, no external cron. The SPA session renews itself 60 s before expiry and, if a 401 still arrives, several in-flight requests share a single refresh.

08 Security

The system went through a full offensive audit (pentest, code review and infrastructure): score 5.8 → 8.4/10, with 4 critical and 7 high findings closed in code.

Authentication

  • HS256 JWT with iss=skilled-erp-api and aud=skilled-erp-spa. Short access token and rotating refresh in an httpOnly cookie with replay detection.
  • Two-step 2FA: the password returns a single-use stepToken (with its jti burned in Redis) and only with it can the TOTP be verified. The secret is stored encrypted with Fernet.
  • CSRF: /api/auth/refresh and /api/auth/logout require X-Requested-With: XMLHttpRequest; the header forces a preflight and blocks cross-site POST from a <form>.
  • Escalating lockout per IP and user in Redis (10 min → 24 h), 90 s TOTP anti-replay and constant-time comparison on login.
  • Changing the password invalidates JWTs in use through password_version.

Files and inputs

  • Worker documents: PDF, JPG, PNG or HEIC, ≤ 20 MB, validated by magic bytes rather than extension, and scanned with ClamAV (fail closed in production).
  • External images: the host is resolved and rejected if it lands on an internal network (anti-SSRF, redirects included), byte and pixel caps, and full rewrite to WebP with Pillow.
  • imagen_url accepts only https:// or local /static/… paths; it blocks javascript:, data:, http:// and file:///.
  • Pre-payroll validates tipo as an enum, concepto of 1 to 250 characters, monto ≤ $999,999.99 and a non-future fecha_incidencia.

Infrastructure

  • Two-layer rate limiting: Nginx (api_general 30/s, api_auth 30/min) and Flask-Limiter per user and IP in Redis, with the real IP validated against the official Cloudflare CIDRs.
  • Strict CSP (default-src none), Talisman with HSTS, frame-ancestors: none, COOP and CORP; CORS restricted to known origins and Cache-Control: no-store on all of /api/*.
  • Gunicorn with --forwarded-allow-ips=127.0.0.1 closes CF-Connecting-IP spoofing.
  • systemd hardening: ProtectSystem=strict, NoNewPrivileges, empty CapabilityBoundingSet and MemoryDenyWriteExecute. The .env is chmod 640 and readable only by the service user.
  • Every response goes through an after_request hook that increments Redis counters and stores the detail of slow and failed ones: that is where the systems panel p50/p95/p99 come from.
Ongoing hygieneFrequencyHow
pip-audit and npm auditMonthlypip-audit -r requirements.txt · npm audit --production
Review audit logWeekly/bitacora UI or a query on audit_log filtering %fallido%
DB and file backupDailypg_dump nominas | gzip > backup_$(date +%F).sql.gz
Rotate SECRET_KEYTwice a yearsecrets.token_urlsafe(64) and a service restart
External pentestYearly—

09 Production

Chain: Cloudflare Tunnel → Nginx (127.0.0.1:80) → Gunicorn (127.0.0.1:8000) → Flask. The installed unit is called nominas.service; the file in the repo is 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 GeventWebSocketWorker workers × 1000 connections. gthread does not implement the WebSocket upgrade and eventlet is incompatible with psycopg3.
  • --max-requests 1000 with jitter recycles workers to avoid pandas, openpyxl and xhtml2pdf leaks.
  • Nginx: per-IP rate limiting as a second layer, Cloudflare real IP, anti-Slowloris and anti request smuggling, a dedicated /socket.io/ block with proxy_read_timeout 3600s, method whitelist and blocking of commonly scanned paths (.env, wp-login.php…).
  • Security headers and CORS are set by Flask; they are not duplicated in Nginx.
  • Frontend on Vercel with VITE_API_URL=https://api.<domain>/api; the backend carries CORS_ORIGINS with the Vercel domain, RT_COOKIE_SAMESITE=None, FLASK_ENV=production and USE_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 Database

PostgreSQL through SQLAlchemy and Alembic (flask db …). Models are split by domain in app/models/. Conventions: auto-increment id PK except on M:N mappings, UTC timestamps, amounts as Numeric(10,2) and states as uppercase strings.

DomainKey tables
Authusers, refresh_tokens, totp_backup_codes, audit_log
Employeestrabajadores (~60 columns), credenciales_plantas, documentos_trabajador, trabajador_notas
Projectsproyectos, M:N with workers via proyecto_trabajador
Hoursreportes_semanales, registros_diarios_horas, saldo_vacaciones, ausencias
Payroll and loansprenominas, descuentos_prenomina, depositos_extra, prestamos, abonos_prestamo
Inbursa adjustmentajuste_periodos, ajuste_trabajadores_periodo, ajuste_descuentos
Inventoryalmacenes, estantes, productos, stock_por_almacen, movimientos_inventario, tomas_inventario, solicitudes_material
Toolsherramientas, herramienta_unidades, asignaciones_herramienta, mantenimientos_herramienta, incidencias_herramienta, solicitudes_baja_herramienta
Notificationsnotificaciones (in-app, purged after 30 days)
> 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

DIAGRAMS

01 04

01 The path of a request
HTTPS TUNNEL HTTP WSGI SQL User browser · PWA Cloudflare TLS · CDN · WAF CF Tunnel outbound Nginx proxy · rate limit Gunicorn Flask · 4 workers Data PostgreSQL · Redis

▶ request · ◀ response and live events over WebSocket · 6 roles: permission is checked in the frontend and enforced again on every endpoint

SCREENSHOTS

Home: quick actions, active worker and project indicators, and charts by project and by position
Home and KPIs
My account: personal information, account security and active sessions
My account
Security: password change, 2FA activation, active sessions with revoke option and appearance preferences
Security and 2FA
Employee record with file progress: identity, employment, contact and pending documents
Employee record
Employee file: internal notes, personal data, compensation, emergency contact and IMSS
File and notes
Project list with status, coordinator, participants and creation date
Projects
Weekly payment summary: total to pay, approved workers and per-person breakdown with PDF and Excel receipts
Weekly pre-payroll
Payroll statistical breakdown by project: earnings, deductions and net deposited per worker
Payroll history
Loans: amount, remaining, repayment progress, weekly deduction and status per worker
Loans
Inventory product catalogue with low-stock alerts, Excel import and gallery or table view
Inventory catalogue
Protective equipment gallery with stock, minimums and per-product actions
Product gallery
Material requests: requester, project, approval flow status and delivery button
Material requests

01 01