PROJECT_01
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
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.
| Piece | What for |
|---|---|
| Flask 3 + SQLAlchemy 2 | API with 18 blueprints and 216 endpoints; PyJWT, Flask-Limiter and Flask-Talisman |
| PostgreSQL (psycopg v3) | Database; migrations with Alembic |
| Redis | Rate limiting, escalating lockout, TOTP anti-replay and the Socket.IO message_queue. Required: create_app() aborts if it cannot connect |
| Socket.IO | Real time: gevent in production (real WebSocket), threading in development |
| Cloudflare R2 + ClamAV | Public bucket for the catalogue and a private one for documents and photos, with disk as fallback; every upload is scanned |
| pandas + openpyxl · xhtml2pdf | Excel and PDF over Jinja templates |
| Gunicorn → Nginx → Cloudflare Tunnel | 4 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:5000Environment variables
.env.example documents every variable with its generation command. The ones that matter:
| Variable | Notes |
|---|---|
SECRET_KEY | Required: the app does not start without it |
DATABASE_URL | psycopg v3 driver: postgresql+psycopg://… |
TOTP_ENCRYPTION_KEY | Fernet key to encrypt 2FA secrets in the DB |
REDIS_URL | Rate limiting, lockout, TOTP anti-replay and the Socket.IO message queue |
CORS_ORIGINS | Dev: Vite (5173). Prod: Vercel or custom domains |
RT_COOKIE_SAMESITE | Lax same-origin (dev); None cross-origin (prod) |
SOCKETIO_ASYNC_MODE | threading in dev (default); gevent in prod and in the containers |
DB_POOL_SIZE / DB_MAX_OVERFLOW | Per-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_SOCKET | Antivirus daemon. CLAMAV_FAIL_CLOSED=true in prod |
IMG_MAX_DOWNLOAD_BYTES / IMG_MAX_PIXELS | Limits for external image downloads |
USE_X_ACCEL_REDIRECT | true only in prod with Nginx configured |
HSTS_PRELOAD | false 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 pytestcreate_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.pywith the blueprint, decorators and serializers, plus one module per topic. - Shared
_api_helpers.py:current_user(),is_admin(),require_admin(),require_roles()and theapi_transactionaldecorator (automatic rollback and log on exception). - Every relevant mutation calls
log_action(...): it writes toaudit_logwith user, IP and action, and the insert triggers thebitacora:newpush. - Standard pagination
page/per_pagewith a{items, total, pages}response; workers, loans and projects also acceptsort/dirwith 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 fromapp/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).
| Blueprint | Prefix | What it exposes |
|---|---|---|
api_auth | /api/auth | Two-step login, refresh and logout, own profile, active sessions, 2FA and backup codes |
api_trabajadores | /api/trabajadores | CRUD with per-role whitelist, soft delete, timeline, notes, credentials, photo, documents, Excel import and export |
api_proyectos | /api/proyectos | Projects, participants and coordinator; recalculates the record of those affected |
api_horas | /api/horas | Weekly reports, daily records (idempotent bulk upsert), QR and RFID clock-in, coordinator mobile screen |
api_prenomina | /api/prenomina | Live preview, save and close week, deductions, deposits, per-diems, holidays, PDF, Excel and receipts by email |
api_prestamos · api_ajustes | /api/prestamos · /api/ajustes | Loans with repayments and settlement; per-period Inbursa adjustment |
api_proyecto_total · api_historico | /api/proyecto-total · /api/historico | Per-project payroll totals and closed weeks, read-only and export |
api_users | /api/users | Account 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/v1 | Products, warehouses and QR shelves, locked movements, requests, physical counts, labels and purchase orders |
herramientas_api | /api/v1 | Catalogue → physical units → assignment, maintenance, incident and authorized write-off |
06 Roles
| Role | Access |
|---|---|
super_admin | Everything. The only one that manages other admins |
admin | Everything except creating or deleting other admins |
sistemas | IT panel (/api/sistemas): infrastructure, sessions, lockouts, security events. Requires active 2FA |
inventario | Full inventory module, no user administration |
coordinador | Only /horas and the medical and contact fields of the workers on their projects |
solicitante_material | Only /inventario/mis-pedidos |
user | Default 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.
| Event | Trigger | Audience |
|---|---|---|
notif:new | Insert of Notificacion | Recipient user:{id} |
bitacora:new | Insert of AuditLog | Admin roles |
abono:new | Insert of AbonoPrestamo, manual or via pre-payroll | Admin roles |
reporte:estado_cambio | State change of ReporteSemanal | Room reporte:{id} |
reporte:registros_cambio | Changes in RegistroDiarioHoras; replaced the kiosk polling | Room reporte:{id} |
nota:changed | POST or DELETE on /trabajadores/<id>/notas | Admin 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-apiandaud=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 itsjtiburned in Redis) and only with it can the TOTP be verified. The secret is stored encrypted with Fernet. - CSRF:
/api/auth/refreshand/api/auth/logoutrequireX-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 closedin 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_urlaccepts onlyhttps://or local/static/…paths; it blocksjavascript:,data:,http://andfile:///.- Pre-payroll validates
tipoas an enum,conceptoof 1 to 250 characters,monto≤ $999,999.99 and a non-futurefecha_incidencia.
Infrastructure
- Two-layer rate limiting: Nginx (
api_general30/s,api_auth30/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 andCache-Control: no-storeon all of/api/*. - Gunicorn with
--forwarded-allow-ips=127.0.0.1closesCF-Connecting-IPspoofing. - systemd hardening:
ProtectSystem=strict,NoNewPrivileges, emptyCapabilityBoundingSetandMemoryDenyWriteExecute. The.envischmod 640and readable only by the service user. - Every response goes through an
after_requesthook 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 hygiene | Frequency | How |
|---|---|---|
pip-audit and npm audit | Monthly | pip-audit -r requirements.txt · npm audit --production |
| Review audit log | Weekly | /bitacora UI or a query on audit_log filtering %fallido% |
| DB and file backup | Daily | pg_dump nominas | gzip > backup_$(date +%F).sql.gz |
Rotate SECRET_KEY | Twice a year | secrets.token_urlsafe(64) and a service restart |
| External pentest | Yearly | — |
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
GeventWebSocketWorkerworkers × 1000 connections. gthread does not implement the WebSocket upgrade and eventlet is incompatible with psycopg3. --max-requests 1000with 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 withproxy_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 carriesCORS_ORIGINSwith the Vercel domain,RT_COOKIE_SAMESITE=None,FLASK_ENV=productionandUSE_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.
| Domain | Key tables |
|---|---|
| Auth | users, refresh_tokens, totp_backup_codes, audit_log |
| Employees | trabajadores (~60 columns), credenciales_plantas, documentos_trabajador, trabajador_notas |
| Projects | proyectos, M:N with workers via proyecto_trabajador |
| Hours | reportes_semanales, registros_diarios_horas, saldo_vacaciones, ausencias |
| Payroll and loans | prenominas, descuentos_prenomina, depositos_extra, prestamos, abonos_prestamo |
| Inbursa adjustment | ajuste_periodos, ajuste_trabajadores_periodo, ajuste_descuentos |
| Inventory | almacenes, estantes, productos, stock_por_almacen, movimientos_inventario, tomas_inventario, solicitudes_material |
| Tools | herramientas, herramienta_unidades, asignaciones_herramienta, mantenimientos_herramienta, incidencias_herramienta, solicitudes_baja_herramienta |
| Notifications | notificaciones (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