[CONSEJO — Doc 10] Contrarian: La mayoría de los SaaS early-stage tiene brechas de seguridad no por falta de criptografía sino por falta de higiene básica: variables de entorno hardcodeadas en el repositorio, logs con datos de clientes, magic links sin expiración. El 90% de los ataques reales no explotan criptografía — explotan descuidos operacionales. First Principles: StockMind maneja datos de inventario y ventas de empresas — información comercialmente sensible pero no clasificada como "datos personales sensibles" bajo Ley 8968 (a diferencia de datos de salud o datos biométricos). El nivel de protección requerido es alto, pero no es el más alto posible. Proporcionar controles equivalentes al riesgo real. Expansionist: El cumplimiento de Ley 8968 (CR) ahora facilita el cumplimiento de RGPD (EU) y LGPD (BR) después. El principio de minimización de datos y el derecho al olvido son comunes a los tres marcos. Construir bien desde CR hace que la expansión sea más barata. Outsider: El riesgo de seguridad más probable para StockMind no es un hacker sofisticado — es un empleado descontento de un cliente que filtra los datos de inventario a un competidor. El control de acceso interno (RBAC + audit trail) es más importante que el perimetro externo. Executor: El magic link es seguro si y solo si: el token es de un solo uso, expira en 15 minutos, se almacena solo su hash, y el enlace se envía por HTTPS. Si falla cualquiera de esas 4 condiciones, el mecanismo completo es inseguro. Security Auditor: La peor decisión de seguridad es no documentar el modelo de amenazas. Si el equipo no puede articular cuáles son las amenazas reales (STRIDE), no puede priorizar los controles. Este documento existe para que eso no ocurra. Chairman: La seguridad no es un feature — es una promesa. Cuando un dueño de PyME entrega sus datos de inventario a StockMind, está confiando su información competitiva más valiosa. Romper esa confianza una sola vez destruye el producto.
1. Modelo de amenazas (STRIDE)
1.1 Superficie de ataque de StockMind
Internet ──► Cloudflare (WAF/DDoS) ──► Nginx (TLS termination)
──► FastAPI (Auth middleware) ──► Services ──► PostgreSQL
──► Celery Workers ──► Redis
──► Integraciones externas (Alegra, ONVO Pay)
──► Email transaccional (Resend)
1.2 Análisis STRIDE
| Amenaza | Descripción | Control principal |
|---|---|---|
| S — Spoofing | Suplantación de identidad (token robado, phishing de magic link) | JWT de corta vida, magic link de un solo uso, HTTPS obligatorio |
| T — Tampering | Modificación de datos en tránsito o en reposo | TLS 1.3, firmas HMAC en webhooks, checksums en imports |
| R — Repudiation | Negar haber aprobado una OC o modificado datos | Audit trail inmutable en sm_audit_log, timestamps firmados |
| I — Information Disclosure | Fuga de datos de inventario entre tenants | RLS PostgreSQL, tenant_id en JWT, error boundaries |
| D — Denial of Service | Saturar el servidor o el motor | Cloudflare rate limiting, task queue con prioridad, timeouts |
| E — Elevation of Privilege | Usuario Viewer accediendo a funciones de Owner | RBAC estricto en cada endpoint, roles verificados en middleware |
1.3 Riesgos no contemplados en MVP (V2.0+)
- SOC 2 Type II: no aplica en MVP. Evaluar cuando existan clientes Enterprise con requisito contractual.
- Pen testing externo: planificado para antes de primer cliente Enterprise. No requerido en Starter/Growth.
- Bug bounty: programa informal (disclosure directa a douglas.mora@cheryxgroup.com). Programa formal en V2.0.
2. Autenticación y gestión de sesiones
2.1 Magic link (mecanismo primario)
Flujo completo con controles de seguridad:
async def send_magic_link(email: str, db: AsyncSession) -> None:
# 1. Verificar que el email pertenece a un usuario activo
user = await db.get_user_by_email(email)
if not user:
# Retornar el mismo mensaje que si existiera (evitar user enumeration)
return
# 2. Generar token criptográficamente seguro (32 bytes = 256 bits)
raw_token = secrets.token_urlsafe(32)
# 3. Almacenar SOLO el hash SHA-256 del token (nunca el token en claro)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
# 4. Expiración: 15 minutos
expires_at = datetime.utcnow() + timedelta(minutes=15)
# 5. Guardar en sm_magic_link_tokens (solo hash + user_id + expires_at)
await db.create_magic_link_token(
user_id=user.id,
token_hash=token_hash,
expires_at=expires_at
)
# 6. Enviar el token en claro por email (HTTPS en tránsito)
await send_email(
to=email,
subject="Tu enlace de acceso a StockMind",
body=f"https://app.stockmind.io/auth/verify?token={raw_token}"
)
async def verify_magic_link(raw_token: str, db: AsyncSession) -> User:
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
# Buscar token no usado y no expirado
token_record = await db.get_magic_link_token(token_hash)
if not token_record:
raise HTTPException(401, "Token inválido o ya utilizado")
if token_record.expires_at < datetime.utcnow():
raise HTTPException(401, "Token expirado. Solicita uno nuevo.")
if token_record.used_at:
raise HTTPException(401, "Token ya utilizado")
# Marcar como usado (un solo uso garantizado)
await db.mark_token_used(token_record.id)
return await db.get_user(token_record.user_id)
Propiedades de seguridad: - Token: 32 bytes = 256 bits de entropía (resistente a fuerza bruta) - Almacenamiento: solo hash SHA-256 (si hay brecha de DB, los tokens no son recuperables) - Un solo uso: una vez verificado, el token queda marcado como usado - Expiración: 15 minutos desde generación - Protección a enumeración: mismo mensaje si el email existe o no
2.2 JWT (sesión post-autenticación)
JWT_PAYLOAD_STRUCTURE = {
"sub": "user_uuid", # user ID
"tid": "tenant_uuid", # tenant ID (CRÍTICO para aislamiento)
"role": "owner|manager|viewer",
"iat": 1715000000, # issued at
"exp": 1717592000, # 30 días desde issued at
}
# El JWT se firma con HS256 usando JWT_SECRET_KEY (256-bit random)
# Rotación de JWT_SECRET_KEY: manual, solo si hay sospecha de compromiso
# No se implementa rotación automática en MVP (complejidad no justificada)
Almacenamiento en el cliente:
- El JWT se almacena en una httpOnly cookie (no en localStorage ni sessionStorage)
- Cookie settings: httpOnly=True, secure=True, samesite='strict'
- Esto previene XSS de robar el JWT y CSRF por el samesite=strict
2.3 Logout y revocación de sesión
En MVP, el logout invalida la cookie del cliente pero el JWT sigue siendo técnicamente válido hasta su expiración (sin lista de revocación en MVP). Este es un trade-off aceptado: el JWT dura 30 días pero si el usuario hace logout, la cookie se elimina y no puede usarse desde ese cliente.
Para V1.1: implementar lista de revocación de tokens en Redis con TTL = tiempo restante del JWT.
3. Autorización (RBAC)
3.1 Matriz de permisos
| Recurso | Owner | Manager | Viewer | Cheryx Admin |
|---|---|---|---|---|
| Ver inventario (todos los SKUs) | ✅ | ✅ | ✅ | ✅ |
| Crear/editar SKUs | ✅ | ✅ | ❌ | ✅ |
| Ver forecast | ✅ | ✅ | ✅ | ✅ |
| Ejecutar motor manualmente | ✅ | ✅ | ❌ | ✅ |
| Aprobar OC | ✅ | ✅ | ❌ | ✅ |
| Rechazar OC | ✅ | ✅ | ❌ | ✅ |
| Ver proveedores | ✅ | ✅ | ✅ | ✅ |
| Crear/editar proveedores | ✅ | ✅ | ❌ | ✅ |
| Ver facturación | ✅ | ❌ | ❌ | ✅ |
| Cambiar plan | ✅ | ❌ | ❌ | ✅ |
| Invitar usuarios | ✅ | ❌ | ❌ | ✅ |
| Configurar motor | ✅ | ❌ | ❌ | ✅ |
| Conectar integraciones | ✅ | ❌ | ❌ | ✅ |
| Acceso a /admin (Cheryx) | ❌ | ❌ | ❌ | ✅ |
3.2 Implementación en FastAPI
from functools import wraps
from fastapi import Depends, HTTPException
def require_role(*roles: str):
def decorator(func):
@wraps(func)
async def wrapper(*args, current_user: User = Depends(get_current_user), **kwargs):
if current_user.role not in roles:
raise HTTPException(
status_code=403,
detail="No tienes permiso para esta acción"
)
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
# Uso en endpoints:
@router.post("/orders/{order_id}/approve")
@require_role('owner', 'manager')
async def approve_order(order_id: UUID, current_user: User = Depends(get_current_user)):
...
4. Cumplimiento Ley 8968 (PRODHAB — Costa Rica)
4.1 Roles bajo Ley 8968
StockMind actúa como Encargado (Processor): - El cliente PyME es el Responsable (Controller) de los datos de inventario de su empresa - StockMind procesa esos datos bajo las instrucciones del Responsable (el cliente) - StockMind no es Responsable de los datos de inventario — no toma decisiones sobre su uso
Implicaciones: - StockMind no puede usar los datos de un cliente para fines distintos del servicio (ej: benchmarking de mercado anónimo requiere consentimiento explícito) - StockMind debe notificar al cliente en <72h si detecta una brecha de datos (Artículo 6 Ley 8968) - StockMind debe facilitar el ejercicio de derechos ARCO del cliente (Acceso, Rectificación, Cancelación, Oposición)
4.2 Minimización de datos
StockMind no recolecta datos personales de empleados del cliente. Los datos procesados son: - Datos de inventario: códigos de SKU, cantidades, precios de costo, movimientos - Datos del negocio: nombre de empresa, proveedor, volúmenes de venta - Datos del usuario del sistema: nombre, email del encargado del inventario
Los datos de empleados, datos bancarios del cliente (más allá de lo necesario para facturación), y datos de clientes finales del cliente no se procesan ni almacenan en StockMind.
4.3 Derechos ARCO — implementación técnica
| Derecho | Implementación |
|---|---|
| Acceso | Export completo de datos del tenant vía GET /account/export-data (JSON/CSV) |
| Rectificación | El cliente puede editar sus datos directamente en la app (SKUs, movimientos) |
| Cancelación | DELETE /account ejecuta soft-delete de todos los datos + tarea async de anonimización |
| Oposición | Opt-out de emails de marketing (no aplica a emails transaccionales de la suscripción) |
Retención de datos post-cancelación: 30 días en soft-delete (por si el cliente se arrepiente), luego anonimización de datos personales + retención de datos agregados anónimos por 5 años (obligación fiscal CR).
4.4 DPA (Data Processing Agreement)
StockMind incluye un DPA en los ToS que especifica: - Subencargados autorizados (sub-processors) - Obligaciones de confidencialidad - Medidas de seguridad técnicas y organizativas - Procedimiento de notificación de brechas
Lista de sub-encargados aprobados (debe actualizarse en cada adición):
| Sub-encargado | Propósito | País |
|---|---|---|
| Hostinger International Ltd. | Infraestructura VPS | CY |
| Cloudflare Inc. | CDN, WAF, DNS | US |
| ONVO Technologies S.A. | Procesamiento de pagos | CR |
| Resend Inc. | Email transaccional | US |
| Sentry (Functional Software Inc.) | Error monitoring | US |
| Tilopay (backup) | Procesamiento de pagos | CR |
5. Cifrado en reposo y en tránsito
5.1 En tránsito
- TLS 1.3 obligatorio en todas las conexiones. TLS 1.2 como mínimo aceptado. TLS <1.2 rechazado.
- Cloudflare gestiona los certificados TLS (Let's Encrypt renovación automática + Cloudflare Origin Certificates para la conexión Cloudflare → servidor)
- HSTS habilitado:
Strict-Transport-Security: max-age=31536000; includeSubDomains - Certificados verificados en conexiones a APIs externas (Alegra, ONVO Pay, Resend) — no
verify=False
5.2 En reposo
Base de datos:
- Disco del VPS Hostinger cifrado en reposo (LUKS)
- Credenciales de integraciones (Alegra API keys): cifradas en columna con pgcrypto.pgp_sym_encrypt() usando llave gestionada en Coolify Secrets
- Backups: cifrados antes de subir a Backblaze B2 (gpg encryption)
Campos cifrados en PostgreSQL:
-- Almacenar credencial Alegra:
UPDATE sm_integrations
SET credentials_enc = pgp_sym_encrypt(
'{"api_key": "xxx", "email": "user@example.com"}'::text,
current_setting('app.encryption_key')
)
WHERE id = :integration_id;
-- Leer credencial Alegra:
SELECT pgp_sym_decrypt(credentials_enc, current_setting('app.encryption_key'))::jsonb
FROM sm_integrations
WHERE id = :integration_id AND tenant_id = :tenant_id;
Campos NO cifrados (demasiado frecuentemente consultados por el motor para justificar el overhead de descifrado):
- sm_skus.unit_cost — sensible pero consultado en cada run del motor. Mitigación: RLS + restricción de acceso al servidor.
- Datos de movimientos de inventario — núcleo del producto, consultados miles de veces por run.
5.3 Secretos de aplicación
Gestión: Coolify Secrets (encriptado en reposo, accessible solo al container)
Rotación:
- JWT_SECRET_KEY: rotación manual si hay sospecha de compromiso
- API keys de terceros: rotación anual o ante compromiso
- ONVO_PAY_WEBHOOK_SECRET: rotación si se detectan webhooks maliciosos
NUNCA en el repositorio de código:
- Ninguna variable de entorno de producción
- Ningún archivo .env de producción
- Ningún token de API en el código fuente
6. Audit trail
Todas las acciones relevantes del negocio se registran en una tabla de audit trail inmutable:
CREATE TABLE sm_audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL,
user_id UUID, -- NULL si es acción del sistema (motor)
resource_type TEXT NOT NULL, -- 'sku', 'purchase_order', 'user', 'integration', etc.
resource_id UUID,
action TEXT NOT NULL, -- 'create', 'update', 'delete', 'approve', 'reject', etc.
changes JSONB, -- {before: {...}, after: {...}} para updates
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- NO updated_at, NO deleted_at — el audit log es inmutable
);
-- Política: los registros de audit log NUNCA se modifican ni eliminan
-- (excepto anonimización ARCO, que reemplaza user_id con NULL y PII con '[anonimizado]')
ALTER TABLE sm_audit_log DISABLE ROW LEVEL SECURITY; -- Solo lectura para Cheryx Admin
REVOKE UPDATE, DELETE ON sm_audit_log FROM app_user; -- La app no puede modificar audit log
Eventos auditados (mandatory): - Login exitoso + login fallido - Creación, edición, eliminación de SKUs - Aprobación, rechazo, modificación de OC - Cambio de rol de usuario - Conexión/desconexión de integración - Cambio de plan de suscripción - Exportación de datos (derecho de Acceso ARCO) - Eliminación de cuenta
7. Seguridad de infraestructura
7.1 Hardening del VPS (Hostinger KVM2)
- SSH acceso solo por clave pública (no contraseñas). Clave de 4096-bit RSA o Ed25519.
- Puerto SSH no estándar (no 22)
- UFW firewall: solo puertos 80, 443 y SSH expuestos. Todo lo demás rechazado.
- fail2ban habilitado: bloqueo de IP tras 5 intentos de login SSH fallidos
- Actualizaciones de seguridad automáticas habilitadas (unattended-upgrades)
- PostgreSQL y Redis: no expuestos a internet (solo acceso interno del VPS)
7.2 Secrets management
Coolify Secrets → Docker Secrets → Container Environment
↑
NO en variables de entorno del sistema del host
NO en archivos en disco del host
NO en logs (structlog filtra variables sensibles)
7.3 Protección Cloudflare
| Control | Configuración |
|---|---|
| Rate limiting | 100 req/min por IP en /auth/magic-link |
| Bot protection | Challenge page para scrapers conocidos |
| WAF rules | OWASP Core Rule Set habilitado |
| DDoS mitigation | Modo I'm Under Attack si hay señal de DDoS |
| IP filtering | Bloqueo de IPs de Tor exit nodes (opcional, evaluar impacto en usuarios legítimos) |
8. Seguridad en el código (OWASP Top 10)
8.1 SQL Injection
Mitigado 100% por SQLAlchemy ORM con parámetros tipados. Nunca string concatenation para SQL.
# CORRECTO:
result = await db.execute(
select(Sku).where(Sku.tenant_id == tenant_id, Sku.code == code)
)
# NUNCA HACER:
query = f"SELECT * FROM sm_skus WHERE code = '{code}'" # SQL injection
8.2 XSS (Cross-Site Scripting)
- El frontend Next.js con React escapa automáticamente el contenido renderizado
explanation_htmldel motor: sanitizado conbleachantes de almacenar y de enviar al frontend- Content-Security-Policy header:
default-src 'self'; script-src 'self'; img-src 'self' data:
8.3 CSRF (Cross-Site Request Forgery)
- La cookie de sesión tiene
samesite='strict': los navegadores no la envían en requests cross-site - Para APIs REST con JWT en header: no vulnerable a CSRF por definición (el header no se envía automáticamente)
8.4 Insecure Direct Object Reference (IDOR)
El tenant_id siempre viene del JWT (no del request). RLS en PostgreSQL como segunda línea. Nunca aceptar tenant_id del body o de query params.
8.5 Mass Assignment
Pydantic v2 con modelos explícitos. Los campos que el usuario no puede escribir (created_at, tenant_id, role) están excluidos del schema de input. model_config = ConfigDict(extra='ignore') en todos los schemas de request.
9. Incident response plan
9.1 Clasificación de incidentes
| Severidad | Definición | Tiempo de respuesta |
|---|---|---|
| P0 — Crítico | Brecha de datos confirmada, sistema caído completamente | <1h (Douglas + notificar clientes afectados) |
| P1 — Alto | Motor produce resultados incorrectos, acceso no autorizado detectado | <4h |
| P2 — Medio | Feature inaccesible para subset de clientes, degradación de performance | <24h |
| P3 — Bajo | Bug cosmético, error en email, funcionalidad menor | <72h (siguiente sprint) |
9.2 Procedimiento ante brecha de datos
- Detección: Sentry alerta o reporte de usuario
- Contención: revocar tokens comprometidos, activar modo de solo lectura si necesario
- Análisis: determinar qué datos fueron expuestos y a quién
- Notificación a PRODHAB: dentro de 72h si hay datos personales afectados (Ley 8968, Art. 6)
- Notificación a clientes: email personalizado a cada tenant afectado con descripción del incidente
- Remediación: parche de seguridad, post-mortem escrito, actualización de controles
Ver también: Doc 07 (SRD Backend — implementación técnica de auth y middleware) · Doc 08 (Modelo de Datos — schema de audit_log y magic_link_tokens) · Doc 14 (Marco Legal — ToS, DPA y cláusulas de limitación de responsabilidad) · wiki/07-legal-fiscal-cr.md §Ley 8968