Module Gating · SaaSModule + Plan en lugar de feature flags genéricos
ADR — Module Gating con SaaSModule + Plan
Contexto
CreaRack Pro es una plataforma SaaS multi-tenant cuyas funcionalidades se agrupan en módulos discretos: Rack Editor, Network Maps, Network Management, Network Observatory, Wireless Monitor, UPS Monitor y Digital Signage. Cada organización contrata un plan (starter, pro, custom) y debe poder usar únicamente los módulos contemplados por su suscripción, más eventuales extras à la carte. La habilitación afecta a URLs de páginas, endpoints de la API REST (~509), navegación lateral y CTAs en plantillas.
Hasta v1.0.48 (2026-04-01, commit 806214ce) no existía un mecanismo formal: las funcionalidades se asumían disponibles para toda organización autenticada. La salida comercial del producto requería un sistema explícito que permitiese vender planes diferenciados sin desplegar ramas distintas ni mantener forks por cliente.
Problema
Hace falta un mecanismo que cumpla simultáneamente:
- Bloqueo en runtime sin cambiar código entre clientes. La org no debe poder llegar a una vista o endpoint cuyo módulo no tiene activo.
- Granularidad por módulo, no por permission individual. El producto se vende por bloques funcionales completos, no por acción granular (eso lo cubre
ModulePermissionaparte para roles dentro de la org). - Composición plan + à la carte. El plan define un set base; la org puede tener extras concedidos comercialmente sin saltar de tier.
- Auditable y editable desde Django Admin sin redeploy ni intervención de ingeniería para cada cambio comercial.
- Compatible con la arquitectura existente (Django 6 + Ninja + Channels, middleware stack), sin introducir un servicio externo de feature flags ni un SDK adicional que complique despliegue, secrets y observabilidad.
Opciones consideradas
1. Feature flags genéricos (django-waffle, LaunchDarkly, Unleash) — biblioteca o SaaS externo pensados para flags booleanos por usuario, porcentaje o segmento. Soporte rico para A/B testing y rollouts graduales. Descartada: el caso de uso no es experimentación, es derecho de uso comercial vinculado a contrato. Añade dependencia externa (en el caso SaaS) o tablas y panel paralelos al Admin (en el caso self-hosted), sin aportar el modelado plan↔módulo que necesitamos. Para LaunchDarkly/Unleash añadiría además latencia de red en cada request o complejidad de SDK con cache local.
2. RBAC granular por permission individual — usar el sistema de permissions de Django (auth.Permission) o django-guardian para gobernar acceso a cada vista. Descartada: granularidad equivocada. El producto se vende en bloques (network-observatory entero, no “puede ver targets pero no editarlos”); modelar esto a nivel permission obliga a definir y mantener decenas de permisos sintéticos por módulo y a una lógica de checks compleja en cada view. La granularidad fina dentro de un módulo ya la resuelve ModulePermission para roles internos de la org, que es problema distinto.
3. Plan tiers como enum hardcoded en settings — STARTER_FEATURES = [...], comprobaciones tipo if org.plan == "pro" en cada vista. Descartada: cada nuevo plan o cada extra à la carte requiere edición de código y redeploy. Imposibilita configuración comercial dinámica desde Admin. Genera duplicación entre frontend (mostrar/ocultar) y backend (autorizar).
4. Tabla M:N Plan ↔ SaaSModule + middleware con registry URL → módulo — modelar módulos como filas en BD (SaaSModule con slug, is_core, url_prefixes), planes como filas (Plan) con M2M a módulos, y Organization con FK a Plan más M2M extra_modules para resolver à la carte. Un ModuleGatingMiddleware resuelve el módulo de cada path vía un registry estático (URL_TO_MODULE con longest-prefix-match) y bloquea en 403 si la org no lo tiene activo. Template tag {% ifmodule "slug" %} para gating en navegación. Seleccionada.
Decisión
Adoptamos la opción 4. El sistema queda definido por:
- Modelo
SaaSModule(core/models.py) conslug,is_core,url_prefixes(campo informativo) ysort_order. Los módulosis_core=Truese conceden incondicionalmente a toda organización. - Modelo
Plancon M2MmoduleshaciaSaaSModuleque define el set base del plan. Organization.plan(FK nullable) yOrganization.extra_modules(M2M) que materializa la unión efectiva.Organization.get_active_modules()devuelvesetde slugs combinando core + extras.core.module_registry.URL_TO_MODULEconresolve_module(path)(longest-prefix-match) eis_exempt(path)para auth, admin, static, health.ModuleGatingMiddlewarecolocado trasAuthenticationMiddlewareeImpersonationMiddleware. Bypassa superusers y rutas exentas; bloquea con HTML 403 (errors/module_disabled.html) o JSON 403 según el path empiece o no por/api/.- Template tag
{% ifmodule "slug" %}y context processoractive_modulespara condicionales en plantillas y JS. - Seed inicial (migration
0013_module_gating) crea 7 módulos y 3 planes; orgs preexistentes migran al plancustomcon todos los extras (compatibilidad hacia atrás).
Consecuencias
- Positivo: la configuración comercial vive en Admin. Activar/desactivar un módulo a una org no requiere despliegue ni intervención de ingeniería.
- Positivo: bloqueo centralizado en un único middleware. No depende de que cada view recuerde aplicar un decorador, lo que reduce la superficie de fallos por omisión.
- Positivo: el set efectivo se calcula por request y se cachea en
request._active_modules; templates lo consumen vía context processor sin queries adicionales. - Negativo: añadir un módulo nuevo es un cambio coordinado con varias caras: migration que crea la fila
SaaSModule, entrada enURL_TO_MODULE, sincronización con el set inicial de planes que deben incluirlo y revisión de la navegación lateral. El registry URL→slug es una segunda fuente de verdad respecto aSaaSModule.url_prefixes; éste último queda como referencia documental, no de runtime. - Negativo: granularidad fija por módulo. No se puede “vender medio módulo”. Si en el futuro hace falta partir un módulo (p.ej. observatory básico vs avanzado), implica migration de datos y división del slug, no es transparente.
- Negativo: tests que cubren rutas de varios módulos deben enlazar todas las
SaaSModulea la organización de test (resuelto entests/conftest.pyytests/api/test_tenant_isolation.py). Olvidar este setup produce 403 ruidosos en suites nuevas. - Negativo: el bloqueo del middleware se aplica al request completo. APIs WebSocket de Channels quedan fuera del path HTTP del middleware y necesitan check explícito en consumers si exponen funcionalidad gated (deuda conocida, no resuelta por este ADR).
Status
Accepted — implementado en commit 806214ce (2026-04-01, v1.0.48). En producción desde entonces. Migrations posteriores 0014_fix_custom_plan_modules y 0015_add_core_to_extra_modules ajustan el seed sin alterar la decisión arquitectural.
Véase también
- [[concept—saas—module-gating]] — descripción operativa del sistema
- [[entity—core—model—saasmodule]] — contrato del modelo
- [[entity—core—model—plan]] — contrato del modelo de plan
- [[entity—core—model—organization]] — consumidor vía
get_active_modules()