Backoffice y operación multi-tenant
El backoffice es el panel interno de operación de Zentto ERP: desde ahí el equipo de plataforma
administra tenants (clientes SaaS), planes y licencias, bases de datos dedicadas, respaldos, la cola de limpieza
y el soporte. No es visible para clientes — vive en app.zentto.net/backoffice dentro del shell del
frontend modular y toda su API está bajo /v1/backoffice/*.
| Componente | Ruta en el repo DatqBoxWeb |
|---|---|
| API (rutas principales) | web/api/src/modules/backoffice/backoffice.routes.ts |
| API (auth 2FA) | web/api/src/modules/backoffice/backoffice-auth.routes.ts |
| API (catálogo de planes) | web/api/src/modules/catalog/admin.routes.ts |
| Middleware de acceso | web/api/src/middleware/master-key.ts |
| Frontend (panel) | web/modular-frontend/apps/shell/src/app/(dashboard)/backoffice/ |
Autenticación: Master Key + TOTP (2FA)
Todos los endpoints del backoffice pasan por el middleware requireMasterKey
(web/api/src/middleware/master-key.ts), que acepta dos mecanismos en este orden:
X-Backoffice-Token— JWT de sesión emitido tras completar el flujo 2FA. Firmado conMASTER_API_KEY, expira en 8 horas, incluyejtiúnico y claimssub=backoffice,role=SYSADMIN. Si el token viene presente pero es inválido, responde 401 sin caer al mecanismo 2 (no hay fallback silencioso).X-Master-Key— clave estática (MASTER_API_KEYdel entorno), solo para compatibilidad interna y scripts de CI. En producción el flujo recomendado es siempre el 2FA.
Flujo TOTP (Google Authenticator)
El segundo factor es TOTP RFC 6238 (compatible con Google Authenticator, Authy, 1Password, Bitwarden),
implementado en backoffice-auth.routes.ts:
| Endpoint | Función |
|---|---|
GET /v1/backoffice/auth/status | Informa si el TOTP ya fue configurado (sin auth) |
POST /v1/backoffice/auth/setup | Valida MasterKey + captcha Turnstile, genera secret base32 + QR (solo si aún no hay setup) |
POST /v1/backoffice/auth/setup/confirm | Verifica el primer código de 6 dígitos y persiste el secret en BD |
POST /v1/backoffice/auth/login | MasterKey + código TOTP → emite el JWT de sesión (8h) |
POST /v1/backoffice/auth/setup/regenerate + /confirm | Rota el secret (caso "perdí mi autenticador"); el anterior deja de funcionar al confirmar |
Medidas de seguridad del flujo:
- Rate limit: 5 intentos por IP cada 15 minutos en
/login(429). - Retardo constante (~200-300 ms) en todas las validaciones, contra timing attacks.
- Ventana de tolerancia de reloj de ±1 token (códigos rotan cada 30 s).
- El issuer distingue ambiente: en dev el usuario ve "Zentto Backoffice (dev)" como entrada separada en su app, con secret independiente del de producción.
- Auditoría en cada evento (
backoffice.auth.login.success,invalid_key,invalid_totp, …) vía el módulo de observabilidad.
cfg.BackofficeAuth
(SPs usp_cfg_backoffice_auth_get/_set, cache en memoria de 60 s). El fallback a la variable de
entorno BACKOFFICE_TOTP_SECRET fue eliminado (alerta ALERT-2): permitía que un operador con acceso al
.env pero no a la BD tomara control del backoffice. Consecuencias operativas:
- Si la BD no responde,
/logindevuelve 503backoffice_auth_unavailable— nunca acepta un secret de entorno. - Si la BD responde pero no hay secret,
/logindevuelve 428totp_not_configuredy el frontend arranca el setup. - Bootstrap / recovery por consola:
node web/api/scripts/backoffice-setup-totp.cjs(idempotente;--forceregenera).
En el frontend, además del modal 2FA, el layout del backoffice
(backoffice/layout.tsx) exige que la sesión ERP autenticada tenga rol admin: un usuario logueado
sin isAdmin ve "Acceso denegado — requiere rol SYSADMIN".
Funciones del backoffice
Paneles disponibles en el sidebar del frontend (apps/shell/src/app/(dashboard)/backoffice/):
| Panel | Ruta | Qué hace |
|---|---|---|
| Dashboard | /backoffice | Métricas globales: tenants, MRR, limpieza pendiente, tamaño total de BDs, tickets de soporte (GitHub zentto-erp/zentto-support) |
| Tenants | /backoffice/tenants | Lista/detalle de clientes con licencia, provisión completa, apply-plan, backups por tenant |
| Planes y Licencias | /backoffice/planes | CRUD del catálogo de planes + sincronización con Paddle |
| Recursos | /backoffice/recursos | Uso de recursos (tamaño de BD, último login) por tenant |
| Bases de Datos | /backoffice/bases-datos | Panel de versiones goose de las BDs dedicadas (actual vs target) + migración manual |
| Respaldos | /backoffice/respaldos | Último backup por tenant, lanzamiento manual, progreso, restore |
| Cola de Limpieza | /backoffice/limpieza | Tenants candidatos a eliminación (trials vencidos, inactivos) con flujo blindado |
| Soporte | /backoffice/soporte | Tickets de soporte (issues del repo zentto-support) |
API de tenants y operación (/v1/backoffice/*)
Endpoints expuestos por backoffice.routes.ts (todos tras requireMasterKey):
| Endpoint | Función |
|---|---|
GET /tenants | Lista paginada de tenants con licencia (SP usp_Sys_Backoffice_TenantList; filtros status/plan/search) |
GET /tenants/:companyId | Detalle: licencia, plan, PaddleSubId, subdominio, usuarios, último login (usp_Sys_Backoffice_TenantDetail) |
POST /tenants/provision-full | Provisioning unificado: crea company + admin + BD dedicada + subdominio + email de bienvenida (mismo pipeline que la compra por Paddle, pero manual) |
POST /tenants/:companyId/apply-plan | Aplica los módulos del plan (FREE/STARTER/PRO/ENTERPRISE) vía applyPlanModules del módulo de licencias |
GET /revenue | Métricas de MRR estimado por plan/tipo de licencia (usp_Sys_Backoffice_RevenueMetrics) |
GET /resources · GET /cleanup | Recursos por tenant y cola de limpieza (usp_Sys_Cleanup_List) |
POST /cleanup/scan | Scan automático de candidatos a limpieza (usp_Sys_Cleanup_Scan) |
POST /cleanup/:queueId/action | Acciones NO destructivas: CANCEL, NOTIFY, ARCHIVE |
POST /cleanup/:queueId/delete | Borrado real del tenant, blindado con 5 gates (ver "Operación segura") |
GET /dashboard | KPIs agregados del servidor (el endpoint /kafka/topics quedó legacy — el stack Kafka se retiró en 2026-07; la analítica corre sobre Loki) |
GET /analytics/overview|performance|business | Observabilidad cross-tenant sobre Loki/LogQL (requests, latencias p50-p99, eventos de negocio); degrada a vacío con timeout de 5 s si Loki no responde |
GET /backups · GET /tenants/:companyId/backups | Último backup por tenant / historial de un tenant |
POST /tenants/:companyId/backup · GET .../backup/progress | Backup manual asíncrono (202) + progreso en tiempo real |
POST /tenants/:companyId/restore/:backupId | Restore asíncrono desde un backup (operación de alto riesgo) |
GET /storage/status | Verifica conexión con Hetzner Object Storage |
GET /tenant-databases | Versiones goose de BDs dedicadas: actual (cross-db) vs target (repo/imagen) |
POST /tenant-databases/:companyId/migrate · POST /tenant-databases/migrate-all | Migra una BD dedicada / todas las desactualizadas (backup + goose up; exige confirm: true) |
Catálogo de planes + sync Paddle (/v1/backoffice/catalog)
Definido en web/api/src/modules/catalog/admin.routes.ts, también tras requireMasterKey:
GET /plansyGET /plans/:slug— lista completa (incluye inactivos y trial-only), filtrable por vertical.POST /plans— upsert por slug: nombre, vertical, precios mensual/anual, límites (usuarios, transacciones), features, módulos, addon/trial, orden.PATCH /plans/:planId/toggle— activa/desactiva un plan.GET /paddle/pending,POST /paddle/sync/:planId,POST /paddle/sync-all— sincroniza los planes locales con productos/precios en Paddle (el proveedor de billing).
Arquitectura multi-tenant
Resolución del tenant por subdominio
Cada cliente puede operar bajo su propio subdominio (acme.zentto.net). El middleware
web/api/src/middleware/subdomain-tenant.ts corre antes de la validación JWT:
- Extrae el hostname del header
Origin(oReferer). - Si es un dominio "conocido" del ecosistema (
app.zentto.net,pos.zentto.net,docs.zentto.net, verticales, localhost…) hace bypass — no es un tenant. - Si es un slug de tenant, lo resuelve contra la BD master con
usp_cfg_tenant_resolvesubdomain(cache en memoria de 5 min, con cache negativo para slugs inexistentes) e inyectareq._tenantCompanyId. - Slug no registrado → 404
tenant_not_found. Nunca hay fallback a otra base de datos.
En el frontend, el componente TenantGuard
(web/modular-frontend/packages/shared-auth/src/TenantGuard.tsx) hace lo equivalente en las micro-apps:
resuelve el subdominio y lo persiste para que authHeader() mande el x-company-id correcto.
La sesión NextAuth no depende de él — viaja en la cookie compartida del dominio .zentto.net
(cross-subdominio); el guard solo fija el contexto de tenant (empresa activa / BD dedicada).
BD compartida vs BD dedicada
Conviven dos modelos, y el flujo de limpieza/borrado los distingue explícitamente (TenantModel):
| Modelo | Dónde viven los datos | Aislamiento |
|---|---|---|
| SHARED | BD principal (zentto_prod), filas separadas por CompanyId | Row-Level Security (RLS) de PostgreSQL por company; el borrado administrativo usa BYPASSRLS controlado por SP |
| DEDICATED (Enterprise) | BD física propia zentto_tenant_*, registrada en sys.TenantDatabase | Aislamiento a nivel de base de datos: pool, migraciones y backups independientes |
El mapeo CompanyId → BD lo hace web/api/src/db/tenant-resolver.ts consultando
usp_sys_tenantdb_resolve en la master (cache 5 min). Tiene dos modos:
para dominios conocidos, si no hay registro cae a la BD master/demo (retrocompatibilidad); en
modo estricto (peticiones que llegaron por subdominio de tenant) un fallo de resolución
lanza error — jamás degrada a otra BD. El middleware auth.ts usa
req._tenantCompanyId para elegir el modo.
Pool manager
web/api/src/db/pg-pool-manager.ts reemplaza el pool singleton por un cache de pools
dbName → Pool:
getMasterPool()— pool de la BD principal (PG_DATABASE): login, health, resolución de tenants.getTenantPool(config)— crea (o reutiliza) el pool de la BD dedicada, con host/user/pool size propios si el registro desys.TenantDatabaselos define (default: max 5 conexiones por tenant).getPoolStats()+ monitor periódico (ALERT-4): si algún pool acumulawaitingCount > 0se loguea la cola. El pool principal usaPG_POOL_MAX=40por defecto.closeTenantPool()/closeAllPools()para provisioning, borrado y shutdown limpio.
Migraciones y versionado por tenant
La fuente de verdad de cambios de BD son las migraciones goose de web/api/migrations/postgres/.
Cuando se publica una migración nueva, la BD principal se actualiza en el deploy, pero las BDs dedicadas
zentto_tenant_* quedarían desfasadas si nadie las migra. Hay dos vías, ambas
sobre el mismo motor:
1. CD automático en cada deploy
El workflow .github/workflows/deploy-api.yml (job migrate-pg) primero migra la BD
principal (goose-deploy-all.sh, provisto por zentto-infra) y luego ejecuta
web/api/scripts/migrate-tenant-dbs.sh: lee sys.TenantDatabase y aplica
goose up + recreación de funciones por cada BD dedicada. Es no bloqueante:
un tenant roto se reporta pero no aborta el deploy de la principal.
2. Panel de versiones + migración manual
El panel Bases de Datos del backoffice consume GET /v1/backoffice/tenant-databases
(servicio web/api/src/modules/backoffice/tenant-db-migrate.service.ts), que compara por BD:
- Versión actual:
MAX(version_id)depublic.goose_db_versionleída cross-db en cadazentto_tenant_*. - Versión target: la migración más alta presente en el repo/imagen (
/app/migrations/postgres). - Estado resultante:
up_to_date·outdated·unknown(BD inaccesible), más el tamaño en MB.
La migración manual (POST .../migrate o migrate-all) sigue un flujo fijo e idempotente:
- Backup lógico (
pg_dump→ Object Storage) antes de migrar. Si el backup falla, no se migra (regla de seguridad, salvoskipBackupexplícito). goose -allow-missing upsobre la BD del tenant (tolera migraciones out-of-order de PRs concurrentes, igual que el deploy).- Re-ejecución de
run-functions.sql(funciones idempotentes desqlweb-pg). - Auditoría completa (versión antes/después, actor, backupId) y notificación de plataforma si cambió la versión.
En migrate-all, el fallo de una BD no detiene las demás; se devuelve un resumen por BD.
Respaldos y retención
El backoffice orquesta los respaldos por tenant (backup.service.ts: pg_dump -Fc →
Hetzner Object Storage S3-compatible, registro en sys.TenantBackup, progreso en vivo y restore),
pero la política completa de respaldo, cifrado, verificación y retención de 10 años está
documentada en su propia página:
Respaldo y retención (10 años). Esta página no la duplica.
Operación segura
Provisioning de BD dedicada: nunca con baseline + goose stamp
zentto_prod.
El flujo antiguo (baseline estático + goose stamp) marcaba todas las migraciones como aplicadas
sin ejecutarlas sobre un baseline desfasado → tenants Enterprise nacían con schema incompleto
(columnas y SPs faltantes) y el ERP respondía 500. Está prohibido volver a ese flujo.
El flujo vigente (web/api/src/db/provision-tenant-db.ts) clona el master en vivo:
CREATE DATABASE(ownerzentto_app) + extensiones (pg_trgm,uuid-ossp,btree_gin).- Clona el schema real de producción:
pg_dump --schema-only --no-owner --no-privilegesdel master →psqlsobre la BD nueva. - Copia solo los catálogos de referencia global (países, permisos/roles, planes/módulos, métodos de pago, config fiscal, constantes de nómina) — nunca datos de otras empresas.
- Sincroniza
public.goose_db_versioncon el del master, para que futurosgoose upapliquen solo migraciones nuevas. - Grants + registro en
sys.TenantDatabase+ invalidación del cache del resolver.
Borrado de tenants: 5 gates
POST /v1/backoffice/cleanup/:queueId/delete es la única vía de borrado real, y exige que se cumplan
todos estos gates (implementados entre la ruta y los SPs de confirmación):
Status=CONFIRMEDexplícito — lo fijausp_Sys_Cleanup_Confirm, nunca automático.- Periodo de gracia (
DeleteAfter) vencido —forceGracesolo salta este gate, nunca los demás. - Master key (router) +
confirm: trueen el body — doble confirmación. - Backup en estado DONE posterior al flag de limpieza — sin backup reciente no hay borrado.
- Auditoría completa (quién/cuándo/por qué) en
audit.AuditLog.
Además, la empresa demo/sistema (CompanyId <= 1) está protegida (403 demo_protected).
Según el modelo, el borrado ejecuta DROP DATABASE (DEDICATED) o borrado de filas por CompanyId bajo
BYPASSRLS controlado (SHARED).
Otras reglas operativas
- Nunca migrar una BD dedicada sin backup previo (el servicio lo bloquea por defecto).
- El header
x-backoffice-actoridentifica al operador en las auditorías de migración y borrado. - Restore y backup manual son asíncronos (202): el estado se sigue por el endpoint de progreso y por
sys.TenantBackup. - Recovery del 2FA sin acceso al panel:
node web/api/scripts/backoffice-setup-totp.cjscontra la BD (idempotente).