[CONSEJO — Doc 10] Contrarian: MindStack tiene un perfil de riesgo mayor que los módulos individuales porque concentra la relación de facturación con el cliente. Un breach en MindStack no solo expone datos — expone métodos de pago, facturas históricas, y la confianza que el cliente tiene con toda la suite Cheryx. Invertir en seguridad del portal es invertir en la reputación de los 4 módulos simultáneamente. Executor: El 80% del riesgo de seguridad de un portal SaaS viene de 3 lugares: tokens JWT mal validados, webhooks sin verificar firma, y passwords sin hashear con bcrypt/argon2. Resolver estos 3 primero antes de preocuparse por threat modeling avanzado. La complejidad de seguridad se agrega después, cuando hay más superficie de ataque real.
1. Modelo de autenticación
JWT + Refresh Token
# Duración de tokens
ACCESS_TOKEN_TTL = timedelta(minutes=15) # Vida corta
REFRESH_TOKEN_TTL = timedelta(days=30) # Solo en cookie httpOnly
# Payload del JWT
{
"sub": "tenant_id:user_id",
"tenant_id": "uuid",
"rol": "owner|admin|viewer",
"modulos": ["stockmind", "salesmind"], # Módulos activos del tenant
"exp": 1234567890,
"jti": "uuid" # Para revocación en Redis
}
Refresh token rotation: cada refresh genera un nuevo par access+refresh. El refresh anterior queda inválido inmediatamente. Si se detecta reuso del mismo refresh token → invalidar todos los tokens del usuario (posible robo).
MFA — TOTP
import pyotp
class MFAService:
def setup_mfa(self, user_id: UUID) -> SetupResponse:
secret = pyotp.random_base32()
encrypted = self.crypto.encrypt(secret) # AES-256 antes de guardar
await self.db.execute(
"UPDATE ms_usuarios SET mfa_secret=:s WHERE id=:id",
{'s': encrypted, 'id': user_id}
)
totp = pyotp.TOTP(secret)
return SetupResponse(
secret=secret,
qr_uri=totp.provisioning_uri(name=user.email, issuer_name="MindStack")
)
def verify_totp(self, user_id: UUID, code: str) -> bool:
encrypted = await self.db.get_mfa_secret(user_id)
secret = self.crypto.decrypt(encrypted)
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1) # ±30 segundos
MFA obligatorio para: owners con suscripción active + roles admin. Opcional para viewers. Recordatorio en dashboard hasta activar.
OAuth Google
- Solo para usuarios con dominio corporativo verificado (email ≠ gmail.com libre).
- Al primer login OAuth → auto-create cuenta con
rol=ownersi el email no existe. - Si el email ya existe → vincular al usuario existente, no duplicar.
2. RBAC — Control de acceso por rol
| Permiso | Owner | Admin | Viewer |
|---|---|---|---|
| Ver facturas | ✓ | ✓ | ✗ |
| Cambiar plan | ✓ | ✗ | ✗ |
| Cancelar suscripción | ✓ | ✗ | ✗ |
| Agregar usuarios | ✓ | ✓ | ✗ |
| Ver métricas de uso | ✓ | ✓ | ✓ |
| Acceder a módulo activo | ✓ | ✓ | ✓ |
| Activar nuevo módulo (trial) | ✓ | ✗ | ✗ |
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.rol not in roles:
raise HTTPException(403, "Rol insuficiente")
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
# Uso
@router.post("/subscriptions/cancel")
@require_role("owner")
async def cancelar_suscripcion(...)
3. Protección de webhooks ONVO Pay
import hmac, hashlib
def verificar_firma_onvo(payload: bytes, signature_header: str) -> bool:
if not signature_header:
return False
expected = hmac.new(
key=settings.ONVO_WEBHOOK_SECRET.encode(),
msg=payload,
digestmod=hashlib.sha256
).hexdigest()
received = signature_header.replace("sha256=", "")
# Comparación de tiempo constante — prevenir timing attacks
return hmac.compare_digest(expected, received)
@router.post("/webhooks/onvo/payment_success")
async def onvo_webhook(request: Request):
body = await request.body()
signature = request.headers.get("X-ONVO-Signature", "")
if not verificar_firma_onvo(body, signature):
raise HTTPException(403, "Firma inválida")
# Idempotencia: verificar que el payment_id no fue procesado ya
payload = ONVOWebhookPayload.model_validate_json(body)
if await redis.exists(f"webhook:processed:{payload.payment_id}"):
return {"status": "already_processed"}
await redis.setex(f"webhook:processed:{payload.payment_id}", 86400, "1")
# ... procesar webhook
Reglas de webhooks: - Rechazar con 403 si la firma falta o no coincide — log del intento. - Idempotencia obligatoria — ONVO puede reenviar el mismo evento hasta 3 veces. - Tiempo de respuesta < 5 segundos — procesar en background (Celery) si es necesario. - IP allowlist de ONVO Pay en el firewall de Cloudflare (cuando estén documentadas).
4. PCI DSS scope
MindStack no es un merchant directo — usa ONVO Pay hosted checkout. El cliente es redirigido al dominio de ONVO para ingresar datos de tarjeta. Implicaciones:
| Aspecto | Estado |
|---|---|
| Datos de tarjeta almacenados | ❌ Nunca — ONVO los guarda |
| PCI compliance requerido | SAQ A (más simple) — solo redirect |
| Token de pago almacenado | onvo_payment_id (referencia, no datos sensibles) |
| Checkout iframe embebido | ❌ Evitar — preferir redirect para mantener SAQ A |
Documentación: mantener registro de que MindStack usa redirect checkout (no embedded) para auditorías PCI. Cambiar a embedded iframe requeriría escalar a SAQ A-EP.
5. Protección de datos — Ley 8968 (Costa Rica)
Ley de Protección de la Persona frente al tratamiento de sus Datos Personales
N° 8968, vigente desde 2011.
Datos recopilados en MindStack
| Dato | Categoría | Base legal | Retención |
|---|---|---|---|
| email_admin | Personal identificable | Ejecución de contrato | Duración suscripción + 5 años |
| nombre_empresa | Organizacional | Ejecución de contrato | Ídem |
| mfa_secret | Sensible (cifrado) | Ejecución de contrato | Hasta que usuario deshabilite MFA |
| onvo_payment_id | Referencia de pago | Ejecución de contrato | 5 años (fiscal) |
| ms_billing_events | Historial financiero | Obligación legal | 5 años mínimo |
| ms_uso_modulo | Uso del servicio | Interés legítimo | 24 meses rolling |
| ultimo_login | Seguridad | Interés legítimo | 12 meses rolling |
Derechos del usuario (Arts. 8-13, Ley 8968)
/tenant/data-rights
GET /export → Exportar todos los datos del tenant (JSON/CSV)
POST /delete → Solicitar eliminación (requiere cancelar suscripción primero)
GET /portability → Exportar datos en formato portable
Plazo de respuesta: 5 días hábiles para cualquier solicitud de derechos.
Eliminación: datos personales anonimizados en 30 días desde solicitud. ms_billing_events se retiene anonimizado (requerimiento fiscal) — el billing_event queda, el tenant_id se reemplaza por NULL o un UUID anónimo.
6. Seguridad de infraestructura
Variables de entorno (nunca en código)
# .env (nunca commitear)
DATABASE_URL=postgresql+asyncpg://...
REDIS_URL=redis://...
JWT_SECRET=<256-bit aleatorio>
JWT_REFRESH_SECRET=<256-bit aleatorio diferente>
ONVO_WEBHOOK_SECRET=<proporcionado por ONVO>
ONVO_API_KEY=<proporcionado por ONVO>
MFA_ENCRYPTION_KEY=<AES-256 key>
RESEND_API_KEY=...
SENTRY_DSN=...
Headers de seguridad HTTP
# FastAPI middleware
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' plausible.io; "
"connect-src 'self' api.onvo.me sentry.io"
)
return response
Rate limiting
from slowapi import Limiter
limiter = Limiter(key_func=get_remote_address)
@router.post("/auth/login")
@limiter.limit("5/minute") # Anti-brute force
async def login(request: Request, ...): ...
@router.post("/webhooks/onvo/payment_success")
@limiter.limit("100/minute") # Webhooks son legítimos en volumen
async def webhook(...): ...
7. Monitoreo de seguridad
| Evento | Respuesta | Destino |
|---|---|---|
| Login fallido × 5 | Bloquear IP 15 min + notificar usuario | Redis + Sentry |
| Firma webhook inválida | Log + alerta | Sentry |
| Refresh token reutilizado | Invalidar todas las sesiones del usuario | Redis + email usuario |
| MFA disabled en cuenta owner | Email de confirmación al dueño | Resend |
| Cambio de plan | Email de confirmación + billing_event | Resend + DB |
| Cancelación de suscripción | Email de confirmación + encuesta NPS | Resend |
Ver también: Doc 07 (SRD Backend — webhook handler) · Doc 08 (Modelo de Datos — ms_billing_events inmutable) · Doc 09 (Motor Core — guardrails G4, G6)