Docs StockMind
10
Seguridad y Cumplimiento — StockMind
Autenticación, cifrado, RBAC, Ley 8968 PRODHAB, STRIDE y audit trail
Versión
v1.0
Fecha
Mayo 2026
Audiencia
Engineering · Legal · Founders
Estado
DRAFT
STOCKMIND
Inteligencia estadística para inventarios que no se quiebran.
Módulo #1 — Cheryx Suite

[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+)


2. Autenticación y gestión de sesiones

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

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)

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)

8.3 CSRF (Cross-Site Request Forgery)

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

  1. Detección: Sentry alerta o reporte de usuario
  2. Contención: revocar tokens comprometidos, activar modo de solo lectura si necesario
  3. Análisis: determinar qué datos fueron expuestos y a quién
  4. Notificación a PRODHAB: dentro de 72h si hay datos personales afectados (Ley 8968, Art. 6)
  5. Notificación a clientes: email personalizado a cada tenant afectado con descripción del incidente
  6. 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