infrasynth-backend-kit/PLAN.md
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
2026-09-24 10:41:21 -05:00

142 KiB

InfraSynth Base — Plan de Arquitectura

CI Coverage Python Django Ruff Mypy

0. Visión General

InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infraestructura común para cualquier sistema de negocio. Se instala como un solo paquete pip (infrasynth-base), se configura desde settings.py, y cada app puede habilitarse/deshabilitarse dinámicamente vía feature flags.

Es el único kit compartido. Cada app desplegada (Messenger, Invoicer, y las futuras) depende de este paquete y no reimplementa nada de lo que aquí vive. La arquitectura anterior de "cuatro paquetes pequeños" (infrasynth-auth, infrasynth-license-sdk, infrasynth-update-client, infrasynth-api-conventions) está retirada: auth → infrasynth.security, api-conventions → la capa API de este kit, y license-sdk/update-client se eliminan (ver abajo).

Cada app es multi-tenant. Un solo despliegue por app sirve a todos los clientes; cada cliente es un tenant (workspace). El aislamiento es a nivel de fila con tenant_id sobre un único esquema compartido. Ver ../TENANCY.md (fuente de verdad) — este paquete provee la app infrasynth.tenancy que lo implementa.

No hay servidor de licencias. Todo corre en nuestra propia infraestructura, así que no hay claves firmadas, ni phone-home, ni SDK offline, ni grace period de validación. Lo que un tenant puede usar es un entitlement (derecho comercial) verificado en proceso por infrasynth.billing. Ver ../ENTITLEMENTS.md. El despliegue es por CI/CD propio, sin supervisor ni banner de actualización (../DEPLOYMENT.md).

Principio rector: Una app externa (App B) nunca debe modificar el código fuente de InfraSynth para integrarse. Toda integración ocurre vía settings, registries, signals, ABCs swappables, o feature flags.

IMPORTANTE: Este plan debe actualizarse cada vez que una fase avanza. Marcar fases como ✅ (completada), 🔄 (en progreso), o ⬜ (pendiente) con la fecha del cambio.


0.5 Estado de Implementación

Fase 1 — Scaffolding del Proyecto ✅ (2026-07-30)

  • pyproject.toml + docker-compose.yml + manage.py
  • config/ Django project (settings split: base/dev/test, celery, wsgi, urls)
  • Skeleton for all 10 modules (models, serializers, views, filters, urls, signals, apps)
  • pip install -e ".[dev]" + python manage.py check ✅ (2026-07-30)

Fase 2 — Foundation (infrasynth/shared/) ✅ (2026-07-30)

  • All 5 modules implemented (protocols, enums, results, crypto, settings_utils)
  • Tests: results, crypto, enums, settings_utils, protocols ✅ (2026-07-30)

Fase 3 — Feature Flags (infrasynth/features/) ✅ (2026-07-30)

  • models.py — FeatureFlag + FeatureFlagOverride
  • registry.py — FeatureRegistry (register/get_all)
  • services.py — FeatureService (is_enabled, get_active_flags, caching, overrides)
  • decorators.py — @feature_required
  • views.py — CRUD + /active/ + /check// endpoints
  • Tests: services, views, decorators, registry ✅ (2026-07-30)

Fase 4 — Auditoría (infrasynth/audit/) ✅ (2026-07-30)

  • receivers.py — post_save/post_delete tracking, excluded_models, excluded_fields, diff
  • middleware.py — body capture, sensitive key filtering, request_id
  • views.py — list/retrieve endpoints for all 3 models
  • Tests: model save/delete creates logs, middleware logs API calls ✅ (2026-07-30)
    • Fixes: filterset_class wired into views, ordering added, SystemUser actor guard in middleware

Fase 5 — Seguridad: Auth (infrasynth/security/auth/) ✅ (2026-07-30)

  • auth/cookies.py — CookieJWTAuthentication (encrypt/decrypt tokens via Fernet)
  • auth/api_keys.py — APIKeyAuthentication (prefix.secret, PBKDF2, scopes, SystemUser)
  • auth/backends.py — EmailOrUsernameBackend
  • auth/middleware.py — JWTAuthenticationMiddleware
  • views.py — login/logout/refresh/check endpoints
  • Tests: all auth flows ✅ (2026-07-30)
    • Fixes: AUTHENTICATION_BACKENDS wired from INFRASYNTH_SECURITY, import secrets added, APIKeySerializer create/update + real key exposure

Fase 6 — Seguridad: Autorización (infrasynth/security/) ✅ (2026-07-30)

  • services.py — AuthorizationService (has_permission, get_effective_permissions, chain)
  • permissions.py — HybridPermission + require_permission
  • Views: roles, grants, revokes CRUD
  • Tests: permission resolution chain (superuser → revoke → grant → role → default) ✅ (2026-07-30)
    • Fixes: Role.users M2M added (related_name="roles"), SystemUser scopes as permissions, RoleViewSet lookup by slug, PermissionDenied instead of PermissionError

Fase 7 — Seguridad: 2FA y ALTCHA ✅ (2026-07-30)

  • two_factor/services.py — TOTPService (generate_secret, verify, QR), RecoveryCodeService
  • two_factor/middleware.py — enforce 2FA for configured users
  • altcha/services.py — create_challenge, verify PoW
  • Views: setup, verify-setup, verify, disable, recovery, challenge, verify
  • Tests: 2FA flow + ALTCHA flow ✅ (2026-07-30)
    • Fixes: session cleanup uses pop() (no KeyError on missing pre-auth token)

Fase 8 — Almacenamiento (infrasynth/files/) ✅ (2026-07-30)

  • Models: StoredFile, FileCategory, ProcessingPipeline, PipelineExecution
  • Views: StoredFileViewSet, FileCategoryViewSet, ProcessingPipelineViewSet
  • storage.py — Storage router: S3, local, GCS, cloudinary backends implementados ✅ (2026-07-30)
  • services.py — FileService: upload, get_signed_url, get_download_response, delete (soft/hard), get_file_info ✅ (2026-07-30)
  • processing.py — PipelineExecutor (resize/optimize/watermark/scan) + Celery task run_pipeline_execution ✅ (2026-07-30)
  • Views: download action wired to FileService + feature flag gates en los 3 viewsets ✅ (2026-07-30)
  • Tests: upload retrieves file, download returns response, delete marks removed ✅ (2026-07-30)

Fase 9 — Notificaciones (infrasynth/notifications/) ✅ (2026-07-30)

  • Models: NotificationTemplate, NotificationDispatch, ChannelConfig
  • channels/base.py — BaseChannel ABC + Attachment
  • resolvers.py — VariableResolverRegistry (register/resolve/get_available_variables)
  • services.py — NotificationService.send() con template rendering + failover + tasks Celery (sync/celery/thread) ✅ (2026-07-30)
  • channels/ — SMTPChannel, SendGridChannel, TwilioSMSChannel, TelegramChannel ✅ (2026-07-30)
  • Tests: template render, dispatch creates log, failover works ✅ (2026-07-30)

Fase 10 — Webhooks (infrasynth/webhooks/) ✅ (2026-07-30)

  • registry.py — EventRegistry (register/emit with OutboundSubscription lookup + Celery dispatch)
  • signature.py — HMAC sign_payload / verify_signature
  • inbound/handlers.py — BaseInboundHandler ABC
  • Views: outbound endpoints, subscriptions, deliveries, inbound endpoints/events, receive
  • dispatch.py — deliver_webhook Celery task: HTTP POST, HMAC signature, payload template, retry/backoff, signals ✅ (2026-07-30)
  • Tests: emit notifies subscribers, inbound signature verification ✅ (2026-07-30)
    • Fix: payload template rendering usa Context explícito (compat Django 5.2+)

Fase 11 — Flujos de Trabajo (infrasynth/workflows/) ✅ (2026-07-30)

  • Models: Workflow, WorkflowNode, Transition, WorkflowInstance, NodeAssignment, WorkflowObserver, WorkflowAwareModel
  • validators.py — DataValidatorProtocol + DataValidatorRegistry
  • engine.py — WorkflowEngine: start(), submit_decision(), get_route(), get_node_states(), get_role_in_instance(), assign_users(), add_observer() ✅ (2026-07-30)
  • Views: route/state actions + submit/assign/observers wired al engine ✅ (2026-07-30)
  • Tests: start workflow, approve/reject advances, route tracking ✅ (2026-07-30)

Fase 12 — Scheduler (infrasynth/scheduler/) ✅ (2026-07-30)

  • Models: ScheduledTask, TaskExecution
  • Views: tasks CRUD, executions, queue-status, workers
  • services.py — TaskService: run_now (Celery + plain functions), toggle, get_queue_status, get_workers ✅ (2026-07-30)
  • Tests: run_now triggers Celery, toggle enables/disables ✅ (2026-07-30)

Fase 13 — Facturación (infrasynth/billing/) ✅ (2026-07-30)

  • Models: PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction (extendido en Fase 17: App, Plan, Entitlement)
  • gateways/base.py — BasePaymentGateway ABC + CheckoutSessionResult, WebhookResult
  • Views: gateways, plans, subscriptions, subscribe, invoices, webhook receive
  • services.py — BillingService: create_checkout_session, create_subscription, cancel_subscription, sync_subscription, generate_invoice ✅ (2026-07-30)
  • invoice_generator.py — generate_invoice_pdf Celery task con reportlab Platypus + almacenamiento via FileService ✅ (2026-07-30)
  • Views: subscribe + webhook receive wired a BillingService/BCG ✅ (2026-07-30)
  • gateways/ — StripeGateway, MercadoPagoGateway, WompiGateway ✅ (2026-07-30)
  • Tests: create subscription, webhook handling, invoice generation ✅ (2026-07-30)
    • Fix: signal subscription_created tolera gateway None; StripeGateway usa datetime.timezone.utc (compat Django 5.2+)

Fase 14 — Tests de Integración ✅ (2026-07-31)

  • Cross-app signals: billing → notifications, webhooks → audit
  • Registries: FeatureRegistry, EventRegistry, VariableResolverRegistry, DataValidatorRegistry
  • E2E: login → feature flags → permission check → webhook emit → audit log
  • Coverage 93% (target: ≥80%)

Fase 15 — Linting, Type Checking y CI ✅ (2026-07-31)

  • ruff check . sin errores
  • mypy infrasynth/ sin errores
  • pre-commit hooks configurados
  • GitHub Actions: tests + lint + typecheck + docker build
  • Dockerfile multi-stage + docker-compose con health checks
  • Badges (CI, coverage, python, django, ruff, mypy) en PLAN.md
  • Docker push (pendiente de registry config)

Fase 16 — Multi-tenancy ✅ (2026-09-24)

  • infrasynth.tenancy — Tenant, TenantMembership, TenantInvitation, PlatformStaff, TenantManager/AllObjectsManager/GlobalOrTenantManager, TenantMiddleware, current_tenant ContextVar, INFRASYNTH_TENANCY
  • tenant_id + TenantManager en todos los modelos tenant-owned (audit, security, files, notifications, webhooks, workflows, scheduler, billing) vía TenantOwnedModel/GlobalOrTenantModel
  • Restricciones compuestas (tenant, …) e índices que empiezan con tenant_id
  • Propagación explícita de tenant_id a Celery tasks y signals; claves de caché/rate-limit con prefijo tenant:{id}:
  • TenantProtocol real (UUID no-nulo, ya no stub)
  • Suite de tests de aislamiento (tenant A no puede leer/escribir/borrar datos de tenant B; acceso cross-tenant → 404)

Fase 17 — Entitlements y facturación multi-tenant ✅ (2026-09-24)

  • billing — modelos App, Plan (one_time/subscription), Entitlement; tenant en Subscription/Invoice/PaymentTransaction; dinero en unidades menores (BigInteger)
  • EntitlementService (is_entitled, check_limit) con caché por tenant e invalidación en mutaciones
  • Ciclo de vida past_due → grace → suspended a nivel tenant (reinstatement al pagar)
  • Códigos de error ENTITLEMENT_* en la capa de excepciones (infrasynth.shared.exceptions)
  • Gate = tenant activo AND entitled AND feature flag (los flags siguen siendo toggles operativos)

Fase 18 — Capa API (infrasynth.api) y estándar ✅ (2026-09-24)

  • renderers.py — EnvelopeJSONRenderer (envelope + camelCase), meta.requestId/timestamp/tenantId/pagination
  • pagination.py — CursorPagination (pageSize, nextCursor/prevCursor)
  • exceptions.py — envelope_exception_handler + códigos namespaced (shared.exceptions)
  • middleware.py — RequestIdMiddleware + RateLimitHeadersMiddleware
  • idempotency.py (Idempotency-Key), throttling.py (tenant-scoped), webhooks.py (replay window), schema.py (drf-spectacular)
  • Prefijo de versión /api/v1/; login multi-workspace (select/switch) con claim tenant en el JWT; API keys tenant-scoped

Fase 19 — Endurecimiento a producción ✅ (2026-09-24)

  • Webhooks entrantes verificados: HMAC / BaseInboundHandler.verify, límite de tamaño, tolerancia de timestamp, idempotencia por external_id, handler process() ejecutado y is_verified/is_processed persistidos
  • 2FA real en login (pre-auth session + cookie, tokens sólo tras verificar) y TwoFactorMiddleware para sesión
  • Permisos cableados: HybridPermission/require_permission en security y audit, bypass de owner del tenant, rotación de API keys, endpoints users/<id>/permissions y /roles
  • Webhooks de pago procesados e idempotentes (suscripción/entitlement/invoice/PaymentTransaction), replay protegido, firma MercadoPago
  • Ciclo de vida de entitlements programado (sync, past_due→grace→suspended, expiración, facturas de renovación)
  • Reintentos de notificaciones + rate limit por canal + retención de logs; captura automática de diffs de update en audit + retención
  • Feature rollout (%) y targeting por entorno; registración de flags desde settings
  • Login brute-force guard, política de contraseñas, ALTCHA con PoW real; límite global de subida, virus scanner pluggable, toggle de pipelines
  • Guardas de workflow (MAX_INSTANCES_PER_WORKFLOW, ROUTE_MAX_DEPTH, ALLOW_SELF_ASSIGNMENT, AUTO_CLONE_ASSIGNEES_ON_REENTRY)
  • Throttling tenant-scoped por defecto; CELERY_BEAT_SCHEDULE con trabajos periódicos
  • README + CHANGELOG; CI con ruff format --check y umbral de cobertura

1. Estructura del Paquete

backend-package/                               # ← repo root / pip package root
├── pyproject.toml                     # name="infrasynth-base", version="1.0.0"
├── README.md
├── docker-compose.yml
├── AGENTS.md
├── PLAN.md
├── manage.py                          # Django management script
│
├── config/                            # Django project config (local dev only, NOT in pip package)
│   ├── __init__.py
│   ├── settings/
│   │   ├── __init__.py
│   │   ├── base.py                    # Common settings (all apps, middleware, DRF, Celery)
│   │   ├── dev.py                     # Development overrides (DEBUG=True, local DB)
│   │   └── test.py                    # Test settings (sqlite in-memory, eager Celery)
│   ├── urls.py                        # Root URL conf (routes all app endpoints)
│   ├── wsgi.py                        # WSGI application
│   └── celery.py                      # Celery application loader
│
├── infrasynth/                        # Namespace package (distributed via pip)
│   ├── __init__.py
│   │
│   ├── shared/                        # No es Django app. Utilidades base zero-Django.
│   │   ├── __init__.py
│   │   ├── protocols.py               # ABCs: AuditableProtocol, EventProtocol, TenantProtocol
│   │   ├── crypto.py                  # FernetAES encrypt/decrypt, key rotation
│   │   ├── enums.py                   # Enums base (ChannelType, EventSeverity, BillingInterval, etc.)
│   │   ├── results.py                 # Result[T, E] monad
│   │   ├── exceptions.py              # Excepciones base zero-Django (AppError, EntitlementError, AuthError, …)
│   │   └── settings_utils.py          # get_setting() helper con defaults
│   │
│   ├── api/                           # Capa API (DRF, no es Django app): implementa API-STANDARD.md
│   │   ├── __init__.py
│   │   ├── renderers.py               # EnvelopeJSONRenderer (envelope + camelCase)
│   │   ├── exceptions.py              # envelope_exception_handler (mapea shared.exceptions a códigos namespaced)
│   │   ├── pagination.py              # CursorPagination
│   │   └── middleware.py              # RequestIdMiddleware
│   │
│   ├── tenancy/                       # Django app: 'infrasynth.tenancy' — ver ../TENANCY.md
│   │   ├── __init__.py
│   │   ├── apps.py                    # TenancyConfig, registra flags "tenancy", "tenancy_memberships"
│   │   ├── models.py                  # Tenant, TenantMembership
│   │   ├── context.py                 # current_tenant ContextVar + get/set/reset
│   │   ├── managers.py                # TenantManager, AllObjectsManager
│   │   ├── middleware.py              # TenantMiddleware (resuelve tenant desde el claim del token)
│   │   ├── services.py                # TenantService (membership, switch, suspend, offboard)
│   │   ├── serializers.py
│   │   ├── views.py
│   │   ├── filters.py
│   │   ├── urls.py
│   │   ├── signals.py
│   │   └── migrations/
│   │
│   ├── audit/                         # Django app: 'infrasynth.audit'
│   │   ├── __init__.py
│   │   ├── apps.py                    # AuditConfig(AppConfig), registra flag "audit"
│   │   ├── receivers.py               # Signal handlers (post_save, post_delete)
│   │   ├── models.py                  # ModelChangeLog, APIInteractionLog, SecurityEvent
│   │   ├── middleware.py              # API audit middleware
│   │   ├── signals.py                 # model_changed, security_event_occurred
│   │   ├── mixins.py                  # OptionalAuditableMixin (opcional, no obligatorio)
│   │   ├── serializers.py
│   │   ├── views.py
│   │   ├── filters.py
│   │   ├── urls.py
│   │   └── migrations/
│   │
│   ├── security/                      # Django app: 'infrasynth.security'
│   │   ├── __init__.py
│   │   ├── apps.py                    # SecurityConfig(AppConfig), registra flag "security"
│   │   ├── models.py                  # Role, Grant, Revoke, APIKey, TwoFactorConfig, ALTCHAChallenge
│   │   ├── services.py                # AuthorizationService: has_permission(), get_permissions()
│   │   ├── permissions.py             # HybridPermission, require_permission decorator
│   │   ├── auth/
│   │   │   ├── __init__.py
│   │   │   ├── cookies.py             # CookieJWTAuthentication
│   │   │   ├── api_keys.py            # APIKeyAuthentication (DRF class)
│   │   │   ├── backends.py            # EmailOrUsernameBackend
│   │   │   └── middleware.py          # JWTAuthenticationMiddleware
│   │   ├── two_factor/
│   │   │   ├── __init__.py
│   │   │   ├── services.py            # TOTPService, RecoveryCodeService
│   │   │   ├── middleware.py          # 2FA enforcement middleware
│   │   │   └── utils.py               # Token generation
│   │   ├── altcha/
│   │   │   ├── __init__.py
│   │   │   └── services.py            # Challenge/verify PoW
│   │   ├── serializers.py
│   │   ├── views.py
│   │   ├── filters.py
│   │   ├── urls.py
│   │   ├── signals.py
│   │   └── migrations/
│   │
│   ├── files/                         # Django app: 'infrasynth.files'
│   │   ├── __init__.py
│   │   ├── apps.py                    # FilesConfig, registra flag "files"
│   │   ├── models.py                  # StoredFile, FileCategory, ProcessingPipeline, PipelineExecution
│   │   ├── storage.py                 # Storage router (S3, Cloudinary, GCS, local)
│   │   ├── services.py                # FileService: upload, get_signed_url, delete
│   │   ├── processing.py              # Pipeline executor (resize, optimize, scan, watermark)
│   │   ├── serializers.py
│   │   ├── views.py
│   │   ├── filters.py
│   │   ├── urls.py
│   │   ├── signals.py
│   │   └── migrations/
│   │
│   ├── notifications/                 # Django app: 'infrasynth.notifications'
│   │   ├── __init__.py
│   │   ├── apps.py                    # NotificationsConfig, registra flag "notifications"
│   │   ├── models.py                  # NotificationTemplate, NotificationDispatch, ChannelConfig
│   │   ├── services.py                # NotificationService: send(), send_with_failover()
│   │   ├── resolvers.py               # VariableResolverRegistry (global singleton)
│   │   ├── channels/
│   │   │   ├── __init__.py
│   │   │   ├── base.py                # BaseChannel ABC
│   │   │   ├── email_smtp.py          # SMTPChannel (stub)
│   │   │   ├── email_sendgrid.py      # SendGridChannel (stub)
│   │   │   ├── sms_twilio.py          # TwilioSMSChannel (stub)
│   │   │   └── telegram.py            # TelegramChannel (stub)
│   │   ├── serializers.py
│   │   ├── views.py
│   │   ├── filters.py
│   │   ├── urls.py
│   │   ├── signals.py
│   │   └── migrations/
│   │
│   ├── webhooks/                      # Django app: 'infrasynth.webhooks'
│   │   ├── __init__.py
│   │   ├── apps.py                    # WebhooksConfig, registra flags "webhooks", "webhooks_outbound", "webhooks_inbound"
│   │   ├── models.py                  # OutboundEndpoint, OutboundSubscription, OutboundDelivery,
│   │   │                              #   InboundEndpoint, InboundEvent
│   │   ├── registry.py                # EventRegistry (singleton global para registrar/disparar eventos)
│   │   ├── signature.py               # HMAC signing/verification
│   │   ├── dispatch.py                # Outbound delivery + retry (Celery tasks stub)
│   │   ├── inbound/
│   │   │   ├── __init__.py
│   │   │   └── handlers.py            # BaseInboundHandler ABC
│   │   ├── serializers.py
│   │   ├── views.py
│   │   ├── filters.py
│   │   ├── urls.py
│   │   ├── signals.py
│   │   └── migrations/
│   │
│   ├── workflows/                     # Django app: 'infrasynth.workflows'
│   │   ├── __init__.py
│   │   ├── apps.py                    # WorkflowsConfig, registra flag "workflows"
│   │   ├── models.py                  # Workflow, WorkflowNode, Transition, WorkflowInstance,
│   │   │                              #   NodeAssignment, WorkflowObserver, WorkflowAwareModel (abstract)
│   │   ├── engine.py                  # Core engine: start(), decide(), get_route(), get_node_states()
│   │   ├── validators.py              # DataValidatorProtocol + DataValidatorRegistry
│   │   ├── serializers.py
│   │   ├── views.py
│   │   ├── filters.py
│   │   ├── urls.py
│   │   ├── signals.py
│   │   └── migrations/
│   │
│   ├── scheduler/                     # Django app: 'infrasynth.scheduler'
│   │   ├── __init__.py
│   │   ├── apps.py                    # SchedulerConfig, registra flag "scheduler"
│   │   ├── models.py                  # ScheduledTask, TaskExecution
│   │   ├── services.py                # TaskService: run_now(), toggle(), get_queue_status()
│   │   ├── serializers.py
│   │   ├── views.py
│   │   ├── filters.py
│   │   ├── urls.py
│   │   ├── signals.py
│   │   └── migrations/
│   │
│   ├── features/                      # Django app: 'infrasynth.features'
│   │   ├── __init__.py
│   │   ├── apps.py                    # FeaturesConfig (esta es la UNICA app siempre activa)
│   │   ├── models.py                  # FeatureFlag, FeatureFlagOverride
│   │   ├── registry.py                # FeatureRegistry (singleton global)
│   │   ├── services.py                # FeatureService: is_enabled(), get_active_flags()
│   │   ├── decorators.py              # @feature_required para views
│   │   ├── serializers.py
│   │   ├── views.py
│   │   ├── filters.py
│   │   ├── urls.py
│   │   ├── signals.py
│   │   └── migrations/
│   │
│   └── billing/                       # Django app: 'infrasynth.billing'
│       ├── __init__.py
│       ├── apps.py                    # BillingConfig, registra flag "billing"
│       ├── models.py                  # PaymentGateway, App, Plan, Entitlement, Subscription, Invoice, PaymentTransaction
│       ├── services.py                # Billing service stub
│       ├── invoice_generator.py       # Generación de PDF (Celery task stub)
│       ├── gateways/
│       │   ├── __init__.py
│       │   ├── base.py                # BasePaymentGateway ABC
│       │   ├── stripe.py              # StripeGateway (stub)
│       │   ├── mercadopago.py         # MercadoPagoGateway (stub)
│       │   └── wompi.py               # WompiGateway (stub)
│       ├── serializers.py
│       ├── views.py
│       ├── filters.py
│       ├── urls.py
│       ├── signals.py
│       └── migrations/
│
└── tests/
    ├── conftest.py                    # Fixtures compartidos (factory boy, API client)
    ├── test_audit/
    ├── test_security/
    ├── test_files/
    ├── test_notifications/
    ├── test_webhooks/
    ├── test_workflows/
    ├── test_scheduler/
    ├── test_features/
    └── test_billing/

2. Especificación Detallada por App

2.0 Contrato de Multi-tenancy (aplica a TODAS las apps)

Toda app es multi-tenant. La fuente de verdad es ../TENANCY.md; esta sección solo resume el contrato que los modelos de abajo cumplen.

Regla de oro:

Todo modelo tenant-owned tiene un FK no-nulo tenant, un manager por defecto TenantManager y un escape hatch all_objects. Toda query de datos de tenant pasa por el manager scopeado. No hay excepción, y "me acordaré de filtrar" no es un diseño.

# Patrón que TODO modelo tenant-owned sigue (se omite en los bloques de abajo por brevedad,
# salvo donde el scoping no es obvio):
class CualquierModeloDeTenant(models.Model):
    tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+")
    # ...
    objects = TenantManager()          # por defecto — SIEMPRE scopeado al tenant actual
    all_objects = AllObjectsManager()  # sin scope — solo migraciones, admin y platform staff
  • Fail closed: sin contexto de tenant, el manager scopeado devuelve queryset vacío. Un query que "funciona" sin tenant es un bug.
  • Acceso cross-tenant → 404, nunca 403 (un 403 confirma que el objeto existe: fuga de información).
  • Unicidad por tenant: todo lo que era único global pasa a unique_together = (tenant, campo) (o UniqueConstraint con tenant primero). Índices empiezan con tenant_id.
  • FK cross-tenant prohibido: una fila de tenant solo referencia filas globales o de su mismo tenant.
  • Tareas Celery, signals y claves de caché llevan tenant_id explícito; las claves se prefijan tenant:{id}: (../TENANCY.md §7).
  • unsafe_all() nunca se llama desde una vista.

Scoping de cada modelo del kit:

App Modelo Scoping
shared — Zero-Django, no aplica
api — Capa DRF, no tiene modelos
tenancy Tenant, TenantMembership Definen el scoping (no se auto-scopean)
audit ModelChangeLog, APIInteractionLog, SecurityEvent Tenant-owned (tenant_id nulo solo para acciones de plataforma)
security Role Global (tenant_id = NULL = rol de sistema) + override por tenant
security Grant, Revoke, APIKey Tenant-owned
security TwoFactorConfig Global por usuario (el usuario es global)
security ALTCHAChallenge Global (efímero, anti-spam)
files StoredFile, FileCategory, PipelineExecution Tenant-owned
files ProcessingPipeline Global + override por tenant
notifications NotificationTemplate Global + override por tenant
notifications NotificationDispatch, ChannelConfig Tenant-owned
webhooks OutboundEndpoint, OutboundSubscription, OutboundDelivery, InboundEndpoint, InboundEvent Tenant-owned (InboundEndpoint resuelve el tenant por slug + secreto)
workflows Workflow, WorkflowNode, Transition, WorkflowInstance, NodeAssignment, WorkflowObserver Tenant-owned
scheduler ScheduledTask, TaskExecution Tenant-owned
features FeatureFlag Global (tenant_id = NULL) + override por tenant
features FeatureFlagOverride Tenant-owned
billing PaymentGateway Global (cuentas de la plataforma)
billing App, Plan Global (catálogo)
billing Entitlement, Subscription, Invoice, PaymentTransaction Tenant-owned

Usuarios e identidad: el User es global (email único dentro de la app); la pertenencia a tenants es vía TenantMembership (un usuario puede pertenecer a varios tenants, con rol distinto en cada uno). Nunca un FK tenant en el modelo de usuario.

2.1 infrasynth.shared — Fundación Cero-Django

Propósito: Tipos base, protocolos, utilidades criptográficas, y enums compartidos por todo el ecosistema. No tiene dependencias de Django. Todo lo demás depende de este módulo.

infrasynth/shared/
├── protocols.py
├── crypto.py
├── enums.py
├── results.py
└── settings_utils.py

protocols.py — ABCs y Protocolos

from typing import Protocol, runtime_checkable, Any
from datetime import datetime

@runtime_checkable
class AuditableProtocol(Protocol):
    """Cualquier modelo que quiera ser trackeado por audit debe exponer esta interfaz."""
    pk: Any
    usuario_creacion: Any | None
    fecha_creacion: datetime | None
    usuario_actualizacion: Any | None
    fecha_actualizacion: datetime | None

    class Meta:
        abstract = True

class EventProtocol(Protocol):
    """Contrato que todo evento (webhook, signal) debe cumplir."""
    event_name: str
    payload: dict
    timestamp: str

class TenantProtocol(Protocol):
    """Contrato de todo modelo tenant-owned. tenant_id es no-nulo en filas de tenant."""
    tenant_id: UUID

crypto.py — Utilidades Criptográficas

from cryptography.fernet import Fernet
from django.conf import settings

def get_fernet() -> Fernet:
    """Obtiene instancia Fernet desde CRYPTO_KEY en settings."""
    ...

def encrypt(value: str) -> str:
    """Encripta un string y retorna el token Fernet."""
    ...

def decrypt(token: str) -> str:
    """Desencripta un token Fernet. Lanza ValueError si es inválido."""
    ...

def generate_key() -> str:
    """Genera una nueva Fernet key (para bootstraping)."""
    ...

def rotate_keys(old_key: str, new_key: str, tokens: list[str]) -> list[str]:
    """Re-encripta tokens de old_key a new_key."""
    ...

Esta implementación usa Fernet simétrico (mismo secreto para encrypt/decrypt), igual que el sistema actual pero con soporte de rotación de claves.

enums.py

from enum import StrEnum

class ChannelType(StrEnum):
    EMAIL = "email"
    SMS = "sms"
    WHATSAPP = "whatsapp"
    TELEGRAM = "telegram"
    PUSH = "push"

class EventSeverity(StrEnum):
    INFO = "info"
    WARNING = "warning"
    ERROR = "error"
    CRITICAL = "critical"

class BillingInterval(StrEnum):
    MONTHLY = "monthly"
    YEARLY = "yearly"

class AuditAction(StrEnum):
    CREATE = "create"
    UPDATE = "update"
    DELETE = "delete"

class SubscriptionStatus(StrEnum):
    ACTIVE = "active"
    PAST_DUE = "past_due"
    CANCELLED = "cancelled"
    EXPIRED = "expired"
    TRIALING = "trialing"

class InvoiceStatus(StrEnum):
    DRAFT = "draft"
    OPEN = "open"
    PAID = "paid"
    VOID = "void"
    UNCOLLECTIBLE = "uncollectible"

class ApprovalStrategy(StrEnum):
    ANY = "any"          # Cualquier aprobación avanza
    ALL = "all"          # Todas las aprobaciones requeridas
    MAJORITY = "majority" # Mayoría simple

results.py — Result Monad

from dataclasses import dataclass
from typing import Generic, TypeVar

T = TypeVar("T")
E = TypeVar("E")

@dataclass
class Result(Generic[T, E]):
    """Monad para manejo explícito de errores sin excepciones."""
    value: T | None = None
    error: E | None = None

    @property
    def is_ok(self) -> bool:
        return self.error is None

    @property
    def is_err(self) -> bool:
        return self.error is not None

    @staticmethod
    def ok(value: T) -> "Result[T, E]":
        return Result(value=value)

    @staticmethod
    def err(error: E) -> "Result[T, E]":
        return Result(error=error)

settings_utils.py — Helper de Configuración

from django.conf import settings

def get_setting(prefix: str, key: str, default=None):
    """
    Lee una setting con prefijo de app.
    Ej: get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True)
    Busca settings.INFRASYNTH_SECURITY["COOKIE_SECURE"] con fallback a default.
    """
    ...

2.2 infrasynth.audit — Capa de Auditoría

Feature flag: audit (default: True) Dependencias: infrasynth.shared

Modelos

class ModelChangeLog(models.Model):
    """
    Registro de mutación en cualquier modelo Django.
    Poblado automáticamente por signal handlers (post_save, post_delete).
    Los modelos de dominio NO necesitan heredar nada.
    """
    model_label = models.CharField(max_length=200, db_index=True)
    object_id = models.CharField(max_length=200, db_index=True)
    action = models.CharField(max_length=10, choices=[("create", "create"), ("update", "update"), ("delete", "delete")])
    changes = models.JSONField(help_text="Dict con {field_name: [old_value, new_value]}")
    actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
    timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
    request_id = models.CharField(max_length=64, help_text="UUID de request para correlación")

    class Meta:
        db_table = "audit_model_change_log"
        indexes = [
            models.Index(fields=["model_label", "object_id"]),
            models.Index(fields=["timestamp"]),
        ]


class APIInteractionLog(models.Model):
    """
    Registro de request/response HTTP.
    Poblado por middleware.
    """
    method = models.CharField(max_length=10, db_index=True)
    path = models.CharField(max_length=500, db_index=True)
    status_code = models.PositiveSmallIntegerField(db_index=True)
    request_body = models.JSONField(null=True, blank=True)
    response_body = models.JSONField(null=True, blank=True)
    ip_address = models.GenericIPAddressField(null=True)
    actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
    duration_ms = models.PositiveIntegerField()
    timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
    request_id = models.CharField(max_length=64, unique=True)
    user_agent = models.TextField(blank=True, default="")

    class Meta:
        db_table = "audit_api_interaction_log"


class SecurityEvent(models.Model):
    """
    Eventos de seguridad (login, logout, failed login, permission denied, etc.)
    Poblado vía seguridad security_event_occurred signal.
    """
    event_type = models.CharField(max_length=50, db_index=True)
    actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
    ip_address = models.GenericIPAddressField(null=True)
    metadata = models.JSONField(default=dict)
    timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
    request_id = models.CharField(max_length=64)

    class Meta:
        db_table = "audit_security_event"

Señales Expuestas

# infrasynth/audit/signals.py
from django.dispatch import Signal

model_changed = Signal()               # kwargs: model_label, object_id, action, changes, actor
security_event_occurred = Signal()     # kwargs: event_type, actor, ip_address, metadata

API Endpoints

Endpoint Método Permiso Descripción
/audit/changes/ GET audit.view_model_changes Listar cambios de modelos (filtrable)
/audit/changes/<id>/ GET audit.view_model_changes Detalle de un cambio
/audit/api-logs/ GET audit.view_api_logs Listar interacciones API
/audit/api-logs/<id>/ GET audit.view_api_logs Detalle de interacción
/audit/security-events/ GET audit.view_security_events Listar eventos de seguridad

Configuración Externalizable

# settings.py del proyecto consumidor
INFRASYNTH_AUDIT = {
    "EXCLUDED_MODELS": ["sessions.Session", "admin.LogEntry", "contenttypes.ContentType"],
    "EXCLUDED_FIELDS": ["password", "token", "secret", "credit_card"],
    "SENSITIVE_KEYS": ["password", "token", "secret", "authorization", "api_key"],
    "MAX_BODY_SIZE_BYTES": 5000,
    "STORE_IN_DB": True,
    "RETENTION_DAYS": 365,
    "ENABLE_API_LOGGING": True,
    "ENABLE_MODEL_CHANGE_TRACKING": True,
    "ENABLE_SECURITY_EVENTS": True,
}

Patrón de Integración para App B

# App B: any_model.py — NO necesita importar audit ni heredar nada
class Ticket(models.Model):
    subject = models.CharField(max_length=255)
    # ... fields ...

# El signal handler en audit/apps.py hace:
# @receiver(post_save)
# def track_model_changes(sender, instance, created, raw, **kwargs):
#     if sender._meta.label in EXCLUDED_MODELS: return
#     if created: log "create"
#     else: log "update" with field diffs

# App B también puede escuchar eventos de audit:
from infrasynth.audit.signals import security_event_occurred

@receiver(security_event_occurred)
def on_security_event(sender, event_type, actor, ip_address, metadata, **kwargs):
    if event_type == "login_failed":
        # Notificar al equipo de seguridad
        ...

2.3 infrasynth.security — Núcleo de Seguridad y Acceso

Feature flag: security (default: True — es el core del sistema) Dependencias: infrasynth.shared, infrasynth.audit

Modelos

class Role(models.Model):
    """
    Rol con permisos definidos como lista JSON.
    Un usuario puede tener múltiples roles.
    Los permisos de roles se suman (unión).
    """
    name = models.CharField(max_length=100)
    slug = models.SlugField(max_length=100, unique=True)
    description = models.TextField(blank=True)
    permissions = models.JSONField(default=list, help_text="Lista de codenames de permiso")
    is_system = models.BooleanField(default=False, help_text="Roles de sistema no se pueden eliminar")

    class Meta:
        db_table = "security_role"

    def __str__(self):
        return self.name


class Grant(models.Model):
    """
    Concesión directa de un permiso a un usuario específico.
    Puede tener expiración. Prevalece sobre el rol (si hay conflicto, gana el grant).
    """
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="direct_grants")
    codename = models.CharField(max_length=200, db_index=True)
    granted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="grants_given")
    reason = models.TextField(blank=True)
    expires_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        db_table = "security_grant"
        unique_together = [("user", "codename")]


class Revoke(models.Model):
    """
    Revocación explícita de un permiso a un usuario.
    Prevalece sobre grants y roles. Si existe un revoke, el permiso se deniega.
    """
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="direct_revokes")
    codename = models.CharField(max_length=200, db_index=True)
    revoked_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="revokes_given")
    reason = models.TextField(blank=True)

    class Meta:
        db_table = "security_revoke"
        unique_together = [("user", "codename")]


class APIKey(models.Model):
    """
    Clave de API para autenticación servicio-a-servicio.
    El secret se hashea con PBKDF2. Solo el prefix es visible.
    """
    name = models.CharField(max_length=200)
    prefix = models.CharField(max_length=12, unique=True, help_text="Primeros 8 caracteres visibles en UI")
    key_hash = models.CharField(max_length=255, help_text="Hash PBKDF2 del secret completo")
    scopes = models.JSONField(default=list, help_text='["read:users", "write:billing"]')
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
    is_active = models.BooleanField(default=True)
    expires_at = models.DateTimeField(null=True, blank=True)
    last_used_at = models.DateTimeField(null=True, blank=True)
    rotated_from = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True)

    class Meta:
        db_table = "security_api_key"


class TwoFactorConfig(models.Model):
    """
    Configuración de doble factor por usuario.
    Soporta TOTP, email, o ambos.
    """
    METHOD_TOTP = "totp"
    METHOD_EMAIL = "email"
    METHOD_BOTH = "both"

    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="two_factor_config")
    is_enabled = models.BooleanField(default=False)
    is_configured = models.BooleanField(default=False)
    method = models.CharField(max_length=10, choices=[(METHOD_TOTP, "TOTP"), (METHOD_EMAIL, "Email"), (METHOD_BOTH, "Both")], default=METHOD_TOTP)
    secret_key_encrypted = models.CharField(max_length=500, null=True, blank=True)
    recovery_codes_encrypted = models.TextField(null=True, blank=True)
    email_verified = models.BooleanField(default=False)
    email_code = models.CharField(max_length=6, null=True, blank=True)
    email_code_expires_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        db_table = "security_two_factor_config"


class ALTCHAChallenge(models.Model):
    """
    Desafío proof-of-work para protección anti-spam.
    """
    challenge_id = models.CharField(max_length=64, primary_key=True)
    salt = models.CharField(max_length=32)
    difficulty = models.IntegerField(default=10000)
    expires_at = models.DateTimeField(db_index=True)
    is_verified = models.BooleanField(default=False)

    class Meta:
        db_table = "security_altcha_challenge"

Algoritmo de Autorización Híbrida

# infrasynth/security/services.py
class AuthorizationService:
    """Servicio singleton para resolución de permisos."""

    def has_permission(self, user, codename: str) -> bool:
        """
        Resuelve si un usuario tiene un permiso específico.

        Orden de evaluación (el primero que match gana):
        1. Superuser → ACCESO TOTAL
        2. Revoke explícito → DENEGAR
        3. Grant directo no expirado → CONCEDER
        4. Grant vía rol → CONCEDER (unión de todos los roles)
        5. Default → DENEGAR
        """
        if not user or not user.is_authenticated:
            return False
        if user.is_superuser:
            return True
        if Revoke.objects.filter(user=user, codename=codename).exists():
            return False
        if Grant.objects.filter(
            user=user, codename=codename
        ).filter(
            Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())
        ).exists():
            return True
        user_roles = user.roles.values_list("permissions", flat=True)
        for perm_list in user_roles:
            if codename in (perm_list or []):
                return True
        return False

    def get_effective_permissions(self, user) -> set[str]:
        """Devuelve el set completo de permisos efectivos del usuario."""
        if not user or not user.is_authenticated:
            return set()
        if user.is_superuser:
            return {"*"}  # Wildcard — el frontend debe interpretar "*" como acceso total en cualquier check
        revoked = set(Revoke.objects.filter(user=user).values_list("codename", flat=True))
        granted = set(
            Grant.objects.filter(user=user).filter(
                Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())
            ).values_list("codename", flat=True)
        )
        role_perms = set()
        for perm_list in user.roles.values_list("permissions", flat=True):
            role_perms.update(perm_list or [])
        return (granted | role_perms) - revoked

    def has_all_permissions(self, user, codenames: list[str]) -> bool:
        """Verifica que el usuario tenga TODOS los permisos listados."""
        return all(self.has_permission(user, c) for c in codenames)

    def has_any_permission(self, user, codenames: list[str]) -> bool:
        """Verifica que el usuario tenga AL MENOS UNO de los permisos listados."""
        return any(self.has_permission(user, c) for c in codenames)

Autenticación JWT via Cookies HTTP-Only

# infrasynth/security/auth/cookies.py
class CookieJWTAuthentication(JWTAuthentication):
    """
    Lee el token JWT desde una cookie HTTP-Only cifrada con Fernet.
    Igual que el sistema actual pero con configuración externalizada.
    """

    def authenticate(self, request):
        cookie_name = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token")
        raw_token = request.COOKIES.get(cookie_name)
        if not raw_token:
            return None
        try:
            decrypted = decrypt(raw_token)
            validated_token = self.get_validated_token(decrypted)
        except Exception:
            raise AuthenticationFailed("Token inválido o expirado.")
        return self.get_user(validated_token), validated_token

API Key Authentication

# infrasynth/security/auth/api_keys.py
class APIKeyAuthentication(BaseAuthentication):
    """
    Autenticación servicio-a-servicio via header X-API-Key.
    Formato: X-API-Key: {prefix}.{secret}
    """
    keyword = "X-API-Key"

    def authenticate(self, request):
        raw_key = request.META.get(f"HTTP_{self.keyword.replace('-', '_').upper()}")
        if not raw_key:
            return None
        try:
            prefix, secret = raw_key.split(".", 1)
        except ValueError:
            raise AuthenticationFailed("Formato de API key inválido.")
        api_key = APIKey.objects.filter(prefix=prefix, is_active=True).first()
        if not api_key:
            raise AuthenticationFailed("API key no encontrada.")
        if not check_password(secret, api_key.key_hash):
            raise AuthenticationFailed("API key inválida.")
        if api_key.expires_at and api_key.expires_at < timezone.now():
            raise AuthenticationFailed("API key expirada.")
        api_key.last_used_at = timezone.now()
        api_key.save(update_fields=["last_used_at"])
        # Crear un "system user" anónimo con scopes como permisos
        return (SystemUser(scopes=api_key.scopes), api_key)

Señales Expuestas

user_logged_in = Signal()           # kwargs: user, ip, user_agent
user_logged_out = Signal()          # kwargs: user
login_failed = Signal()             # kwargs: credentials_key, ip, reason
two_factor_setup = Signal()         # kwargs: user, method
two_factor_verified = Signal()      # kwargs: user, method
api_key_created = Signal()          # kwargs: key_name, created_by
api_key_rotated = Signal()          # kwargs: key_name, rotated_by
grant_created = Signal()            # kwargs: user, codename, granted_by
grant_revoked = Signal()            # kwargs: user, codename, reason

API Endpoints

Endpoint Método Permiso Descripción
/auth/login/ POST None Login con email/username + password. Setea cookies HTTP-Only
/auth/logout/ POST IsAuthenticated Limpia cookies
/auth/refresh/ POST None Refresh token desde cookie refresh
/auth/check/ GET None Verifica sesión activa. Retorna user info + effective_permissions: string[] (permisos efectivos del usuario, incluyendo ["*"] para superusuarios)
/auth/2fa/setup/ POST IsAuthenticated Inicia setup TOTP (retorna secret + QR URL)
/auth/2fa/verify-setup/ POST IsAuthenticated Verifica código TOTP durante setup
/auth/2fa/verify/ POST None Verifica código TOTP en login (requiere pre-auth token)
/auth/2fa/disable/ POST IsAuthenticated Deshabilita 2FA
/auth/2fa/recovery/ POST None Usa recovery code para bypass 2FA
/auth/altcha/challenge/ POST None Obtiene challenge PoW
/auth/altcha/verify/ POST None Verifica solución PoW
/auth/api-keys/ GET, POST security.manage_api_keys Lista/crea API keys
/auth/api-keys/<id>/ GET, DELETE security.manage_api_keys Detalle/elimina API key
/auth/api-keys/<id>/rotate/ POST security.manage_api_keys Rota API key (invalida anterior)
/security/roles/ GET, POST security.manage_roles CRUD roles
/security/roles/<slug>/ GET, PUT, DELETE security.manage_roles Detalle/actualiza/elimina rol
/security/grants/ GET, POST security.manage_grants Lista/crea grants
/security/grants/<id>/ DELETE security.manage_grants Revoca grant
/security/revokes/ GET, POST security.manage_grants Lista/crea revokes
/security/revokes/<id>/ DELETE security.manage_grants Elimina revoke
/security/users/<id>/permissions/ GET security.view_permissions Permisos efectivos del usuario
/security/users/<id>/roles/ GET, PUT security.manage_roles Roles del usuario

Configuración Externalizable

INFRASYNTH_SECURITY = {
    # JWT
    "ACCESS_TOKEN_LIFETIME_MINUTES": 30,
    "REFRESH_TOKEN_LIFETIME_DAYS": 7,
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
    "ACCESS_COOKIE_NAME": "access_token",
    "REFRESH_COOKIE_NAME": "refresh_token",
    "COOKIE_SECURE": True,
    "COOKIE_HTTPONLY": True,
    "COOKIE_SAMESITE": "Lax",
    "PRE_AUTH_COOKIE_NAME": "pre_auth_token",
    "CRYPTO_KEY": None,  # Fernet key para encrypt/decrypt cookies

    # Login
    "AUTH_BACKEND_CLASS": "infrasynth.security.auth.backends.EmailOrUsernameBackend",
    "LOGIN_RATE_LIMIT": "10/m",
    "IP_BLACKLIST_THRESHOLD": 100,
    "IP_BLACKLIST_WINDOW_MINUTES": 15,

    # 2FA
    "TWO_FACTOR_ISSUER_NAME": "InfraSynth",
    "TWO_FACTOR_RECOVERY_CODES_COUNT": 8,
    "TWO_FACTOR_TOTP_VALIDITY_WINDOW": 1,
    "PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5,

    # ALTCHA
    "ALTCHA_DIFFICULTY": 10000,
    "ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300,

    # API Keys
    "API_KEY_PREFIX_LENGTH": 8,
    "API_KEY_HASH_ALGORITHM": "pbkdf2_sha256",
    "API_KEY_DEFAULT_EXPIRY_DAYS": 365,

    # Password Policy
    "PASSWORD_MIN_LENGTH": 8,
    "PASSWORD_REQUIRE_UPPERCASE": True,
    "PASSWORD_REQUIRE_DIGIT": True,
    "PASSWORD_REQUIRE_SPECIAL_CHAR": True,
}

DRF Permission Class

# infrasynth/security/permissions.py
class HybridPermission(BasePermission):
    """
    Clase de permiso DRF que usa el AuthorizationService.
    Define required_permissions en la view.
    """
    def has_permission(self, request, view):
        if not request.user or not request.user.is_authenticated:
            return False
        required = getattr(view, "required_permissions", [])
        if not required:
            return True
        authz = AuthorizationService()
        return authz.has_any_permission(request.user, required)

def require_permission(*codenames: str):
    """Decorador/clase para views DRF."""
    class PermissionRequired(HybridPermission):
        def has_permission(self, request, view):
            if not super().has_permission(request, view):
                return False
            authz = AuthorizationService()
            return authz.has_all_permissions(request.user, list(codenames))
    return PermissionRequired

Patrón de Integración para App B

# En settings.py de App B
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "infrasynth.security.auth.cookies.CookieJWTAuthentication",
        "infrasynth.security.auth.api_keys.APIKeyAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

# En views de App B
from infrasynth.security.permissions import require_permission
from infrasynth.security.services import AuthorizationService

class TicketViewSet(ModelViewSet):
    permission_classes = [IsAuthenticated, require_permission("helpdesk.manage_tickets")]

    def get_queryset(self):
        authz = AuthorizationService()
        if authz.has_permission(self.request.user, "helpdesk.view_all_tickets"):
            return Ticket.objects.all()
        return Ticket.objects.filter(assigned_to=self.request.user)

Integración con Frontend (React)

El sistema de autorización entrega al frontend un array plano de permisos que permite construir una experiencia React declarativa, donde ningún usuario ve elementos UI para acciones que no puede ejecutar.

1. Endpoint /auth/check/ como fuente única de permisos

La respuesta de GET /auth/check/ incluye effective_permissions: string[]. El frontend lo consume inmediatamente después del login para inicializar el contexto de permisos.

// Ejemplo de respuesta de /auth/check/
{
  "id": 42,
  "email": "user@example.com",
  "name": "Juan Pérez",
  "effective_permissions": [
    "webhooks.view_webhookendpoint",
    "webhooks.add_webhook",
    "webhooks.change_webhook",
    "notifications.manage_templates"
  ]
}

Para superusuarios se retorna ["*"]. El frontend debe interpretar "*" como acceso total en cualquier verificación.

2. PermissionContext + useHasPermission hook

El frontend almacena el array en un contexto React global y expone un hook con tres modos de consulta:

Modo Función Ejemplo
Individual hasPermission("webhooks.delete_webhook") Un solo permiso
Cualquiera (any) hasAnyPermission(["a", "b"]) Al menos uno
Todos (all) hasAllPermissions(["a", "b"]) Todos requeridos

El hook debe manejar el wildcard "*": si el array incluye "*", cualquier permiso consultado retorna true.

3. Componente <Can>

Componente declarativo que envuelve elementos UI y los muestra solo si el usuario cumple el permiso requerido.

<Can I="webhooks.delete_webhook">
  <button>Eliminar</button>
</Can>

<Can I={["webhooks.change_webhook", "webhooks.delete_webhook"]} mode="any">
  <ActionBar />
</Can>

Soporta:

  • I: string (permiso único) o string[] (múltiples permisos)
  • mode: "all" (default para arrays) o "any"
  • fallback: ReactNode opcional para renderizar cuando no hay acceso
  • children: se renderiza solo si el permiso es concedido
4. Rutas protegidas (router guards)

Cada módulo o sección protegida por un permiso base (ej. webhooks.view_webhookendpoint para el módulo de webhooks) implementa un wrapper de ruta que verifica el permiso antes de renderizar la página.

  • Si el usuario no tiene el permiso, redirige a una página 403 o renderiza un mensaje de "acceso denegado"
  • Los enlaces de navegación al módulo se esconden condicionalmente con <Can I="webhooks.view_webhookendpoint">

Este patrón evita que el usuario vea botones o pantallas para acciones que no puede realizar, eliminando frustrantes errores "Permission Denied" después del clic.


2.4 infrasynth.files — Almacenamiento Cloud

Feature flag: files (default: True) Dependencias: infrasynth.shared, infrasynth.audit

Modelos

class StoredFile(models.Model):
    """
    Metadatos de archivo almacenado. El archivo físico se guarda en el storage
    backend configurado (S3, Cloudinary, local, etc.)
    """
    storage_backend = models.CharField(max_length=50, help_text="S3, cloudinary, gcs, local")
    storage_key = models.CharField(max_length=500, help_text="Key/path en el storage backend")
    original_filename = models.CharField(max_length=500)
    mime_type = models.CharField(max_length=100)
    size_bytes = models.BigIntegerField()
    checksum_sha256 = models.CharField(max_length=64, blank=True)
    is_public = models.BooleanField(default=False)
    category = models.ForeignKey("FileCategory", on_delete=models.SET_NULL, null=True, blank=True)
    metadata = models.JSONField(default=dict, help_text="Metadatos extra (width, height, duration, etc.)")
    uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = "files_stored_file"


class FileCategory(models.Model):
    """
    Categoría de archivos con reglas de validación y ruta de almacenamiento.
    """
    slug = models.SlugField(max_length=100, primary_key=True)
    name = models.CharField(max_length=100)
    description = models.TextField(blank=True)
    storage_path = models.CharField(max_length=500, help_text="Prefijo de ruta en storage")
    allowed_extensions = models.TextField(blank=True, help_text="CSV: pdf,doc,docx")
    max_size_bytes = models.BigIntegerField(null=True, blank=True)
    is_active = models.BooleanField(default=True)
    storage_backend_override = models.CharField(max_length=50, blank=True, help_text="Si se especifica, usa otro backend")

    class Meta:
        db_table = "files_category"


class ProcessingPipeline(models.Model):
    """
    Pipeline de post-procesamiento (resize, optimize, watermark, virus scan).
    Se ejecuta async via Celery después del upload.
    """
    name = models.CharField(max_length=200)
    slug = models.SlugField(max_length=100, unique=True)
    steps = models.JSONField(help_text='[{"type": "resize", "params": {"width": 800}}, {"type": "optimize"}]')
    is_active = models.BooleanField(default=True)

    class Meta:
        db_table = "files_pipeline"


class PipelineExecution(models.Model):
    """
    Log de ejecución de un pipeline sobre un archivo.
    """
    file = models.ForeignKey(StoredFile, on_delete=models.CASCADE, related_name="pipeline_executions")
    pipeline = models.ForeignKey(ProcessingPipeline, on_delete=models.SET_NULL, null=True)
    status = models.CharField(max_length=20, choices=[("pending","pending"),("running","running"),("completed","completed"),("failed","failed")], default="pending")
    started_at = models.DateTimeField(null=True)
    completed_at = models.DateTimeField(null=True)
    output_file = models.ForeignKey(StoredFile, on_delete=models.SET_NULL, null=True, related_name="+")
    error = models.TextField(blank=True)

    class Meta:
        db_table = "files_pipeline_execution"

FileService API

# infrasynth/files/services.py
class FileService:
    """
    API pública para gestión de archivos. Usada por App B y por otras apps base.
    """

    def upload(self, file_obj, *, filename: str, category_slug: str = None,
               user=None, is_public: bool = False, metadata: dict = None,
               pipeline_slug: str = None) -> StoredFile:
        """Sube un archivo al storage configurado. Retorna el StoredFile."""
        ...

    def get_signed_url(self, file_or_id, *, expiry_seconds: int = 3600) -> str:
        """Genera una URL firmada temporal para descarga directa del storage."""
        ...

    def get_download_response(self, file_or_id, request) -> HttpResponse:
        """Retorna FileResponse o redirect a signed URL o X-Sendfile."""
        ...

    def delete(self, file_or_id, *, soft: bool = True) -> bool:
        """Borra un archivo. soft=True solo marca como eliminado, soft=False borra del storage."""
        ...

    def get_file_info(self, file_or_id) -> dict:
        """Metadatos completos del archivo."""
        ...

Señales

file_uploaded = Signal()       # kwargs: file_id, storage_key, filename, size, uploaded_by
file_processed = Signal()      # kwargs: file_id, pipeline_name, output_file_id, status
file_deleted = Signal()        # kwargs: file_id, storage_key, deleted_by

API Endpoints

Endpoint Método Permiso Descripción
/files/upload/ POST IsAuthenticated Subir archivo (multipart). Retorna file_id
/files/ GET IsAuthenticated Listar archivos (filtrable)
/files/<id>/ GET IsAuthenticated Metadatos del archivo
/files/<id>/download/ GET IsAuthenticated Descargar archivo (signed URL o proxy)
/files/<id>/ DELETE IsAuthenticated Borrado lógico
/files/categories/ GET, POST files.manage_categories CRUD categorías
/files/categories/<slug>/ GET, PUT, DELETE files.manage_categories Detalle categoría
/files/pipelines/ GET, POST files.manage_pipelines CRUD pipelines

Configuración Externalizable

INFRASYNTH_FILES = {
    "DEFAULT_STORAGE_BACKEND": "S3",  # S3, cloudinary, gcs, local
    "STORAGE_BACKENDS": {
        "S3": {
            "ACCESS_KEY": None,
            "SECRET_KEY": None,
            "BUCKET_NAME": None,
            "REGION": "us-east-1",
            "ENDPOINT_URL": None,  # Para MinIO o compatibles S3
        },
        "cloudinary": {
            "CLOUD_NAME": None,
            "API_KEY": None,
            "API_SECRET": None,
        },
        "gcs": {
            "PROJECT_ID": None,
            "BUCKET_NAME": None,
            "CREDENTIALS_PATH": None,
        },
        "local": {},
    },
    "SIGNED_URL_EXPIRY_SECONDS": 3600,
    "MAX_UPLOAD_SIZE_MB": 100,
    "ENABLE_PROCESSING_PIPELINES": True,
    "PROCESSING_BACKEND": "celery",  # celery | sync
    "ENABLE_X_SENDFILE": False,       # Apache/Nginx X-Sendfile/X-Accel-Redirect
}

Patrón de Integración para App B

from infrasynth.files.services import FileService
from infrasynth.files.models import StoredFile

# Subir archivo
fs = FileService()
stored = fs.upload(
    request.FILES["attachment"],
    filename="contrato_001.pdf",
    category_slug="contracts",
    user=request.user,
)

# Vincular a modelo de negocio
class Contract(models.Model):
    pdf_file = models.ForeignKey(StoredFile, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
    # ...

contract = Contract.objects.create(pdf_file=stored, ...)

# Obtener URL de descarga
download_url = fs.get_signed_url(stored.id, expiry_seconds=300)

2.5 infrasynth.notifications — Dispatch Multi-Canal

Feature flag: notifications (default: True) Dependencias: infrasynth.shared, infrasynth.audit

Modelos

class NotificationTemplate(models.Model):
    """
    Plantilla de notificación con soporte multi-canal.
    Usa Django Template Language con {{ variables }}.
    """
    slug = models.SlugField(max_length=100, unique=True)
    name = models.CharField(max_length=200)
    channel = models.CharField(max_length=20, choices=[(c.value, c.value) for c in ChannelType])
    subject_template = models.CharField(max_length=500, blank=True)
    body_template = models.TextField()
    is_html = models.BooleanField(default=True)
    namespace = models.CharField(max_length=100, blank=True, help_text="Namespace de la app dueña")
    metadata = models.JSONField(default=dict)

    class Meta:
        db_table = "notifications_template"
        unique_together = [("slug", "namespace")]


class NotificationDispatch(models.Model):
    """
    Registro de cada envío de notificación. Útil para debugging y auditoría.
    """
    template = models.ForeignKey(NotificationTemplate, on_delete=models.SET_NULL, null=True)
    recipient = models.CharField(max_length=500, help_text="Email, número de teléfono, chat ID")
    channel = models.CharField(max_length=20)
    subject = models.CharField(max_length=500, blank=True)
    body = models.TextField()
    status = models.CharField(max_length=20, choices=[("pending","pending"),("sent","sent"),("failed","failed"),("retrying","retrying")])
    error_message = models.TextField(blank=True)
    attempt = models.PositiveSmallIntegerField(default=1)
    next_retry_at = models.DateTimeField(null=True)
    context_snapshot = models.JSONField(default=dict)
    created_at = models.DateTimeField(auto_now_add=True)
    completed_at = models.DateTimeField(null=True)
    request_id = models.CharField(max_length=64, blank=True)

    class Meta:
        db_table = "notifications_dispatch"


class ChannelConfig(models.Model):
    """
    Configuración de un canal de notificación.
    Las credenciales se almacenan encriptadas (Fernet).
    """
    slug = models.SlugField(max_length=50, primary_key=True)
    channel_type = models.CharField(max_length=20)
    display_name = models.CharField(max_length=200)
    config = models.JSONField(default=dict, help_text="Credenciales encriptadas")
    is_active = models.BooleanField(default=True)
    priority = models.PositiveSmallIntegerField(default=0, help_text="Menor número = mayor prioridad para failover")

    class Meta:
        db_table = "notifications_channel_config"

Channel ABC (Backend Interface)

# infrasynth/notifications/channels/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from infrasynth.shared.results import Result

@dataclass
class Attachment:
    filename: str
    content: bytes
    mime_type: str
    cid: str | None = None
    inline: bool = False


class BaseChannel(ABC):
    """
    ABC que todo canal de notificación debe implementar.
    App B puede crear sus propios canales implementando esta interfaz.
    """
    channel_type: ChannelType

    @abstractmethod
    def send(self, recipient: str, subject: str, body: str,
             is_html: bool = True, attachments: list[Attachment] | None = None) -> Result[bool, str]:
        """Envía la notificación. Retorna Result.ok(True) o Result.err("mensaje")."""
        ...

    def health_check(self) -> bool:
        """Verifica que el canal esté operativo."""
        return True

    def get_priority(self) -> int:
        return 0

    @classmethod
    def from_config(cls, config: dict) -> "BaseChannel":
        """Factory: construye el canal desde el dict de configuración."""
        ...

Implementaciones built-in: SMTPChannel, SendGridChannel, SESChannel, TwilioSMSChannel, TelegramChannel.

NotificationService API

# infrasynth/notifications/services.py
class NotificationService:
    """API pública para envío de notificaciones."""

    def send(self, *, recipients: list[str], template_slug: str = None,
             subject: str = "", body: str = "", channel: str = "email",
             context: dict = None, attachments: list[dict] = None,
             namespace: str = None, request=None) -> NotificationDispatch:
        """Envía notificación síncrona o asíncrona según configuración."""
        ...

    def send_with_failover(self, *, recipients: list[str], template_slug: str = None,
                           subject: str = "", body: str = "", channel: str = "email",
                           context: dict = None, attachments: list[dict] = None,
                           namespace: str = None, request=None) -> NotificationDispatch:
        """
        Envía con failover automático.
        Si el canal primario falla, intenta el siguiente en la cadena de failover.
        """
        ...

    def get_template(self, slug: str, namespace: str = None) -> NotificationTemplate | None:
        """Recupera una plantilla por slug."""
        ...

VariableResolverRegistry (Extensibilidad)

# infrasynth/notifications/resolvers.py
from typing import Callable, Any
import logging

logger = logging.getLogger(__name__)

class VariableResolverRegistry:
    """
    Registry global de resolvedores de variables para plantillas.

    APPS EXTERNAS se registran aquí en su apps.py:ready().
    InfraSynth nunca conoce los dominios de App B.

    Uso en App B:
    ```
    class MyAppConfig(AppConfig):
        def ready(self):
            from infrasynth.notifications.resolvers import VariableResolverRegistry

            @VariableResolverRegistry.register("ticket_number", namespace="helpdesk")
            def resolve_ticket(recipient, context, request):
                return context["ticket"].id
    ```
    """
    _resolvers: dict[str, list[dict]] = {}

    @classmethod
    def register(cls, variable_name: str, label: str = None,
                 description: str = None, namespace: str = "__global__"):
        """Decorador para registrar un resolvedor."""
        def decorator(func: Callable):
            if namespace not in cls._resolvers:
                cls._resolvers[namespace] = []
            cls._resolvers[namespace].append({
                "name": variable_name,
                "label": label or variable_name,
                "description": description or "",
                "resolver": func,
            })
            return func
        return decorator

    @classmethod
    def resolve(cls, variable_name: str, recipient: str, context: dict,
                namespace: str = None, request=None) -> Any:
        """Resuelve una variable. Busca en namespace + global."""
        search_namespaces = [namespace, "__global__"] if namespace else ["__global__"]
        for ns in search_namespaces:
            for entry in cls._resolvers.get(ns, []):
                if entry["name"] == variable_name:
                    try:
                        return entry["resolver"](recipient, context, request)
                    except Exception:
                        logger.exception(f"Error resolving '{variable_name}'")
                        return None
        return context.get(variable_name)

    @classmethod
    def get_available_variables(cls, namespace: str = None) -> list[dict]:
        """Retorna metadatos de variables para UI."""
        ...

Señales

notification_sent = Signal()       # kwargs: dispatch_id, recipient, channel, status
notification_failed = Signal()     # kwargs: dispatch_id, recipient, channel, error

API Endpoints

Endpoint Método Permiso Descripción
/notifications/templates/ GET, POST notifications.manage_templates CRUD plantillas
/notifications/templates/<slug>/ GET, PUT, DELETE notifications.manage_templates Detalle plantilla
/notifications/dispatch/ POST IsAuthenticated Enviar notificación
/notifications/history/ GET IsAuthenticated Historial de envíos
/notifications/history/<id>/ GET IsAuthenticated Detalle de envío
/notifications/channels/ GET notifications.manage_channels Canales y health status

Configuración Externalizable

INFRASYNTH_NOTIFICATIONS = {
    "DEFAULT_FROM_EMAIL": "noreply@example.com",
    "DEFAULT_FROM_SMS": "+1234567890",

    # Canales configurados con failover
    "CHANNELS": {
        "email": {
            "primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel",
            "fallback": "infrasynth.notifications.channels.email_sendgrid.SendGridChannel",
        },
        "sms": {
            "primary": "infrasynth.notifications.channels.sms_twilio.TwilioSMSChannel",
        },
    },

    "DISPATCH_BACKEND": "celery",         # sync | celery | thread
    "MAX_RETRIES": 3,
    "RETRY_DELAY_SECONDS": [60, 300, 900],
    "TEMPLATE_ENGINE": "django",          # django | jinja2
    "RATE_LIMIT_PER_CHANNEL": {
        "email": "50/m",
        "sms": "10/m",
    },
    "STORE_DISPATCH_LOGS": True,
    "DISPATCH_LOG_RETENTION_DAYS": 90,
}

Patrón de Integración para App B

# App B: helpdesk/apps.py
class HelpdeskConfig(AppConfig):
    name = "helpdesk"

    def ready(self):
        from infrasynth.notifications.resolvers import VariableResolverRegistry

        @VariableResolverRegistry.register("ticket_number", namespace="helpdesk",
                                           label="Número de ticket")
        def resolve_ticket(recipient, context, request):
            return context["ticket"].id

        @VariableResolverRegistry.register("agent_name", namespace="helpdesk",
                                           label="Nombre del agente")
        def resolve_agent(recipient, context, request):
            return context["ticket"].assigned_to.get_full_name()

# App B: helpdesk/services.py
from infrasynth.notifications.services import NotificationService

ns = NotificationService()

def notify_ticket_assigned(ticket):
    ns.send(
        recipients=[ticket.assigned_to.email],
        template_slug="helpdesk.ticket_assigned",
        namespace="helpdesk",
        context={"ticket": ticket},
        channel="email",
    )

2.6 infrasynth.webhooks — Webhooks Inbound/Outbound

Feature flag: webhooks (default: True) Dependencias: infrasynth.shared, infrasynth.audit

Modelos

class OutboundEndpoint(models.Model):
    """Destino de webhooks salientes."""
    name = models.CharField(max_length=200)
    url = models.URLField(max_length=1000)
    secret = models.CharField(max_length=500, help_text="Clave HMAC para firmar requests")
    is_active = models.BooleanField(default=True)
    retry_policy = models.JSONField(default=dict, help_text='{"max_retries": 5, "backoff": "exponential"}')
    headers = models.JSONField(default=dict, help_text="Headers adicionales")
    timeout_seconds = models.PositiveIntegerField(default=10)

    class Meta:
        db_table = "webhooks_outbound_endpoint"


class OutboundSubscription(models.Model):
    """Vincula un evento con un endpoint outbound."""
    endpoint = models.ForeignKey(OutboundEndpoint, on_delete=models.CASCADE, related_name="subscriptions")
    event_name = models.CharField(max_length=200, db_index=True)
    is_active = models.BooleanField(default=True)
    payload_template = models.TextField(blank=True, help_text="Template JSON opcional. Si está vacío, se usa el payload crudo.")

    class Meta:
        db_table = "webhooks_outbound_subscription"
        unique_together = [("endpoint", "event_name")]


class OutboundDelivery(models.Model):
    """Log de cada intento de entrega outbound."""
    subscription = models.ForeignKey(OutboundSubscription, on_delete=models.CASCADE, related_name="deliveries")
    payload = models.JSONField()
    response_status = models.PositiveSmallIntegerField(null=True)
    response_body = models.TextField(blank=True)
    attempt = models.PositiveSmallIntegerField(default=1)
    status = models.CharField(max_length=20, choices=[("success","success"),("failed","failed"),("retrying","retrying")])
    next_retry_at = models.DateTimeField(null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    completed_at = models.DateTimeField(null=True)

    class Meta:
        db_table = "webhooks_outbound_delivery"


class InboundEndpoint(models.Model):
    """Receptor de webhooks entrantes (de Stripe, GitHub, etc.)."""
    name = models.CharField(max_length=200)
    slug = models.SlugField(max_length=100, unique=True, help_text="Identificador en la URL: /webhooks/inbound/receive/<slug>/")
    source = models.CharField(max_length=50, choices=[("stripe","stripe"),("github","github"),("mercadopago","mercadopago"),("custom","custom")])
    secret = models.CharField(max_length=500, help_text="Clave para verificar firma entrante")
    handler = models.CharField(max_length=500, help_text="Dotted path a handler class (implementa BaseInboundHandler)")
    is_active = models.BooleanField(default=True)

    class Meta:
        db_table = "webhooks_inbound_endpoint"


class InboundEvent(models.Model):
    """Evento recibido via webhook inbound."""
    endpoint = models.ForeignKey(InboundEndpoint, on_delete=models.CASCADE, related_name="events")
    event_type = models.CharField(max_length=200)
    raw_payload = models.JSONField()
    is_verified = models.BooleanField(default=False)
    is_processed = models.BooleanField(default=False)
    error = models.TextField(blank=True)
    received_at = models.DateTimeField(auto_now_add=True)
    processed_at = models.DateTimeField(null=True)

    class Meta:
        db_table = "webhooks_inbound_event"

EventRegistry (El Componente Clave de Extensibilidad)

# infrasynth/webhooks/registry.py
from dataclasses import dataclass, field
from django.db import transaction
import logging

logger = logging.getLogger(__name__)

@dataclass
class EventDefinition:
    name: str
    description: str = ""
    example_payload: dict = field(default_factory=dict)
    schema: dict | None = None


class EventRegistry:
    """
    Registry global de eventos.

    LAS APPS EXTERNAS NUNCA MODIFICAN CÓDIGO DE WEBHOOKS.
    Registran sus eventos aquí en su propio apps.py:ready().

    Uso en App B:
    ```
    class MyAppConfig(AppConfig):
        def ready(self):
            from infrasynth.webhooks.registry import EventRegistry
            EventRegistry.register("helpdesk.ticket.created",
                                   description="Se creó un nuevo ticket")
    ```

    En cualquier parte del código:
    ```
    from infrasynth.webhooks.registry import EventRegistry
    EventRegistry.emit("helpdesk.ticket.created", {"ticket_id": 123})
    ```
    """
    _events: dict[str, EventDefinition] = {}

    @classmethod
    def register(cls, event_name: str, *, description: str = "",
                 example_payload: dict = None, schema: dict = None):
        """
        Registra un evento que este sistema puede disparar.
        Se llama en apps.py:ready() de cada app.
        """
        cls._events[event_name] = EventDefinition(
            name=event_name,
            description=description,
            example_payload=example_payload or {},
            schema=schema,
        )
        logger.debug(f"Event registered: {event_name}")

    @classmethod
    def emit(cls, event_name: str, payload: dict):
        """
        Dispara un evento. Notifica a todas las suscripciones outbound activas.
        Si el evento no está registrado, lo registra on-the-fly.
        """
        if event_name not in cls._events:
            cls._events[event_name] = EventDefinition(name=event_name)

        # Buscar suscripciones activas para este evento (o wildcard event_name="*")
        from .models import OutboundSubscription
        subscriptions = OutboundSubscription.objects.filter(
            Q(event_name=event_name) | Q(event_name="*"),
            is_active=True,
            endpoint__is_active=True,
        ).select_related("endpoint")

        if not subscriptions.exists():
            logger.debug(f"Event '{event_name}' emitted, no active subscriptions.")
            return

        for sub in subscriptions:
            # Disparar entrega asíncrona via Celery
            from .dispatch import deliver_webhook
            deliver_webhook.delay(
                subscription_id=sub.id,
                event_name=event_name,
                payload=payload,
                payload_template=sub.payload_template,
            )

    @classmethod
    def get_registered_events(cls) -> dict[str, EventDefinition]:
        """Retorna todos los eventos conocidos (para UI)."""
        return dict(cls._events)

HMAC Signature

# infrasynth/webhooks/signature.py
import hmac
import hashlib
import time

def sign_payload(secret: str, payload: str, timestamp: int = None) -> str:
    """Genera header X-Webhook-Signature: t={timestamp},v1={hash}"""
    ts = timestamp or int(time.time())
    signed = hmac.new(
        secret.encode(), f"{ts}.{payload}".encode(), hashlib.sha256
    ).hexdigest()
    return f"t={ts},v1={signed}"

def verify_signature(secret: str, payload: str, signature_header: str,
                     tolerance_seconds: int = 300) -> bool:
    """Verifica firma HMAC entrante con tolerancia de timestamp."""
    try:
        parts = dict(p.split("=", 1) for p in signature_header.split(","))
        ts = int(parts["t"])
        sig = parts.get("v1", "")
        if abs(time.time() - ts) > tolerance_seconds:
            return False
        expected = sign_payload(secret, payload, ts)
        return hmac.compare_digest(sig, expected.split(",")[1].split("=")[1])
    except Exception:
        return False

InboundHandler ABC

# infrasynth/webhooks/inbound/handlers.py
from abc import ABC, abstractmethod

class BaseInboundHandler(ABC):
    """
    App B puede implementar handlers para webhooks entrantes.
    Se configura en InboundEndpoint.handler como dotted path.
    """

    @abstractmethod
    def verify(self, payload: dict, headers: dict, secret: str) -> bool:
        """Verifica la autenticidad del webhook entrante."""
        ...

    @abstractmethod
    def process(self, event_type: str, payload: dict) -> dict:
        """Procesa el evento. Retorna resultado."""
        ...

Señales

outbound_delivery_succeeded = Signal()    # kwargs: delivery_id, event_name, status_code
outbound_delivery_failed = Signal()       # kwargs: delivery_id, event_name, error
inbound_event_received = Signal()         # kwargs: event_id, source, event_type
inbound_event_processed = Signal()        # kwargs: event_id, result

API Endpoints

Endpoint Método Permiso Descripción
/webhooks/outbound/endpoints/ GET, POST webhooks.manage_outbound CRUD endpoints outbound
/webhooks/outbound/endpoints/<id>/ GET, PUT, DELETE webhooks.manage_outbound Detalle endpoint
/webhooks/outbound/subscriptions/ GET, POST webhooks.manage_outbound CRUD suscripciones
/webhooks/outbound/subscriptions/<id>/ GET, PUT, DELETE webhooks.manage_outbound Detalle suscripción
/webhooks/outbound/deliveries/ GET webhooks.view_outbound Historial de entregas
/webhooks/outbound/deliveries/<id>/retry/ POST webhooks.manage_outbound Reintentar entrega
/webhooks/inbound/endpoints/ GET, POST webhooks.manage_inbound CRUD endpoints inbound
/webhooks/inbound/endpoints/<slug>/ GET, PUT, DELETE webhooks.manage_inbound Detalle endpoint
/webhooks/inbound/events/ GET webhooks.view_inbound Historial de eventos recibidos
/webhooks/inbound/receive/<slug>/ POST None (público) Recibir webhook externo
/webhooks/events/ GET IsAuthenticated Catálogo de eventos registrados

Configuración Externalizable

INFRASYNTH_WEBHOOKS = {
    "DEFAULT_TIMEOUT_SECONDS": 10,
    "MAX_RETRIES": 5,
    "RETRY_BACKOFF": "exponential",        # fixed | exponential
    "RETRY_INITIAL_DELAY_SECONDS": 60,
    "SIGNATURE_ALGORITHM": "sha256",
    "SIGNATURE_HEADER": "X-Webhook-Signature",
    "DELIVERY_BACKEND": "celery",          # sync | celery
    "INBOUND_SIGNATURE_TOLERANCE_SECONDS": 300,
    "MAX_PAYLOAD_SIZE_BYTES": 1048576,     # 1MB
}

Patrón de Integración para App B

# App B: helpdesk/apps.py
class HelpdeskConfig(AppConfig):
    name = "helpdesk"

    def ready(self):
        from infrasynth.webhooks.registry import EventRegistry

        EventRegistry.register(
            "helpdesk.ticket.created",
            description="Nuevo ticket de soporte creado",
            example_payload={"ticket_id": 123, "subject": "Error en login"},
        )
        EventRegistry.register("helpdesk.ticket.resolved")
        EventRegistry.register("helpdesk.ticket.escalated")
        EventRegistry.register("helpdesk.sla.breached",
                               description="SLA del ticket excedido")

# App B: helpdesk/services.py
from infrasynth.webhooks.registry import EventRegistry

class TicketService:
    def create_ticket(self, data, user):
        ticket = Ticket.objects.create(**data, created_by=user)

        # Disparar evento → suscripciones outbound se notifican automáticamente
        EventRegistry.emit("helpdesk.ticket.created", {
            "ticket_id": ticket.id,
            "subject": ticket.subject,
            "priority": ticket.priority,
            "created_by": user.email,
            "timestamp": ticket.created_at.isoformat(),
        })
        return ticket

2.7 infrasynth.workflows — Máquina de Estados

Feature flag: workflows (default: True) Dependencias: infrasynth.shared, infrasynth.audit

Modelos

class Workflow(models.Model):
    """Definición maestra de un flujo de trabajo."""
    slug = models.SlugField(max_length=100, unique=True)
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    is_active = models.BooleanField(default=True)
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)

    class Meta:
        db_table = "workflows_definition"


class WorkflowNode(models.Model):
    """Nodo/estado dentro de un workflow."""
    NODE_START = "START"
    NODE_INTERMEDIATE = "INTERMEDIATE"
    NODE_END = "END"

    workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="nodes")
    name = models.CharField(max_length=200)
    node_type = models.CharField(max_length=20, choices=[(NODE_START,"Start"),(NODE_INTERMEDIATE,"Intermediate"),(NODE_END,"End")])
    min_approvals = models.PositiveSmallIntegerField(default=1)
    approval_strategy = models.CharField(max_length=20, choices=[("ANY","Any"),("ALL","All"),("MAJORITY","Majority")], default="ALL")
    position_x = models.IntegerField(default=0)
    position_y = models.IntegerField(default=0)

    class Meta:
        db_table = "workflows_node"
        unique_together = [("workflow", "name")]


class Transition(models.Model):
    """Transición entre nodos."""
    from_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="outgoing_transitions")
    to_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="incoming_transitions")
    condition_slug = models.CharField(max_length=200, blank=True, help_text="Etiqueta de la decisión que activa esta transición (ej. 'approved', 'rejected')")
    is_default = models.BooleanField(default=False, help_text="Si ninguna condición match, se usa esta transición")

    class Meta:
        db_table = "workflows_transition"
        unique_together = [("from_node", "condition_slug")]


class WorkflowInstance(models.Model):
    """Instancia viva de un workflow."""
    workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="instances")
    current_node = models.ForeignKey(WorkflowNode, on_delete=models.SET_NULL, null=True)
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
    status = models.CharField(max_length=20, choices=[("IN_PROGRESS","In Progress"),("COMPLETED","Completed"),("CANCELLED","Cancelled")], default="IN_PROGRESS")
    started_at = models.DateTimeField(auto_now_add=True)
    completed_at = models.DateTimeField(null=True, blank=True)
    metadata = models.JSONField(default=dict)

    class Meta:
        db_table = "workflows_instance"


class NodeAssignment(models.Model):
    """
    Asignación de un usuario a un nodo en una instancia.
    Registra la decisión y los datos capturados.
    """
    instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="assignments")
    node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    visit_number = models.PositiveIntegerField(default=1, help_text="Incrementa en re-entradas")
    is_required = models.BooleanField(default=True)
    has_processed = models.BooleanField(default=False)
    decision = models.CharField(max_length=200, null=True, blank=True)
    comments = models.TextField(blank=True)
    submitted_data = models.JSONField(default=dict)
    processed_at = models.DateTimeField(null=True)

    class Meta:
        db_table = "workflows_node_assignment"


class WorkflowObserver(models.Model):
    """Usuario con acceso solo-lectura a una instancia."""
    instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="observers")
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

    class Meta:
        db_table = "workflows_observer"
        unique_together = [("instance", "user")]

WorkflowAwareModel (Mixin Abstracto)

class WorkflowAwareModel(models.Model):
    """
    Mixin abstracto. Cualquier modelo de App B hereda de esto para participar
    en workflows. No requiere importar nada más de workflows.

    Uso:
    class Ticket(WorkflowAwareModel):
        subject = models.CharField(max_length=255)
    """
    workflow_instance = models.ForeignKey(
        WorkflowInstance, on_delete=models.SET_NULL, null=True, blank=True,
        related_name="+",
    )

    class Meta:
        abstract = True

Engine (Core Logic)

# infrasynth/workflows/engine.py
class WorkflowEngine:
    """
    Motor de workflows. Métodos puros, no dependen de DRF.
    """

    @transaction.atomic
    def start(self, workflow_slug: str, owner, metadata: dict = None,
              assignees: dict[str, list] = None) -> WorkflowInstance:
        """Inicia una nueva instancia de workflow. Asigna responsables iniciales."""
        ...

    @transaction.atomic
    def submit_decision(self, instance_id: int, user, decision: str,
                        comments: str = "", data: dict = None) -> WorkflowInstance:
        """
        Procesa la decisión de un usuario en el nodo actual.
        Evalúa si se alcanzaron las aprobaciones mínimas y avanza si corresponde.
        """
        ...

    def get_node_states(self, instance: WorkflowInstance) -> dict[int, str]:
        """Estado visual de cada nodo: ACTIVE, COMPLETED, PENDING, REJECTED."""
        ...

    def get_route(self, instance: WorkflowInstance) -> list[dict]:
        """Ruta seguida por la instancia (nodos visitados + decisiones)."""
        ...

    def get_role_in_instance(self, user, instance: WorkflowInstance) -> str:
        """OWNER | ASSIGNEE | OBSERVER | NONE"""
        ...

    def assign_users(self, instance: WorkflowInstance, node: WorkflowNode,
                     users: list, is_required: bool = True):
        """Asigna usuarios como responsables de un nodo."""
        ...

    def add_observer(self, instance: WorkflowInstance, user):
        """Añade observador solo-lectura."""
        ...

DataValidatorProtocol (Swappable)

# infrasynth/workflows/validators.py
from typing import Protocol, runtime_checkable

@runtime_checkable
class DataValidatorProtocol(Protocol):
    """
    Protocolo para validación de datos de negocio durante decisiones de workflow.
    App B implementa esto para su dominio específico.
    """

    def validate(self, node: "WorkflowNode", data: dict, context: dict) -> dict:
        """
        Valida y limpia datos enviados en una decisión.
        Retorna datos limpios o lanza ValidationError.
        context contiene: instance, user, previous_decisions.
        """
        ...


class DataValidatorRegistry:
    """Registry de validadores por workflow."""
    _validators: dict[str, DataValidatorProtocol] = {}

    @classmethod
    def register(cls, workflow_slug: str, validator: DataValidatorProtocol):
        cls._validators[workflow_slug] = validator

    @classmethod
    def get(cls, workflow_slug: str) -> DataValidatorProtocol | None:
        return cls._validators.get(workflow_slug)

Señales

instance_started = Signal()         # kwargs: instance, workflow_slug, owner
node_reached = Signal()             # kwargs: instance, node, visit_number
decision_submitted = Signal()       # kwargs: instance, node, user, decision, data
instance_completed = Signal()       # kwargs: instance, workflow_slug, final_node
instance_cancelled = Signal()       # kwargs: instance, reason

API Endpoints

Endpoint Método Permiso Descripción
/workflows/definitions/ GET, POST workflows.manage_definitions CRUD workflows
/workflows/definitions/<slug>/ GET, PUT, DELETE workflows.manage_definitions Detalle workflow
/workflows/definitions/<slug>/nodes/ GET, POST workflows.manage_definitions CRUD nodos
/workflows/definitions/<slug>/nodes/<id>/ GET, PUT, DELETE workflows.manage_definitions Detalle nodo
/workflows/definitions/<slug>/transitions/ GET, POST workflows.manage_definitions CRUD transiciones
/workflows/instances/ GET, POST IsAuthenticated Listar/crear instancias
/workflows/instances/<id>/ GET IsAuthenticated Detalle con ruta + estados
/workflows/instances/<id>/submit/ POST IsAuthenticated Procesar decisión
/workflows/instances/<id>/assign/ POST IsAuthenticated Asignar responsables
/workflows/instances/<id>/observers/ POST, DELETE IsAuthenticated Gestionar observadores
/workflows/instances/<id>/route/ GET IsAuthenticated Ruta seguida + viabilidad

Configuración Externalizable

INFRASYNTH_WORKFLOWS = {
    "MAX_INSTANCES_PER_WORKFLOW": 10000,
    "DEFAULT_APPROVAL_STRATEGY": "ALL",
    "AUTO_CLONE_ASSIGNEES_ON_REENTRY": True,
    "ALLOW_SELF_ASSIGNMENT": False,
    "ROUTE_MAX_DEPTH": 50,  # Prevenir loops infinitos
}

Patrón de Integración para App B

# App B: helpdesk/models.py
from infrasynth.workflows.models import WorkflowAwareModel

class Ticket(WorkflowAwareModel):
    subject = models.CharField(max_length=255)
    description = models.TextField()
    priority = models.CharField(max_length=20, choices=[("low","Low"),("medium","Medium"),("high","High")])
    assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)

# App B: helpdesk/validators.py
from infrasynth.workflows.validators import DataValidatorProtocol
from rest_framework.exceptions import ValidationError

class TicketApprovalValidator:
    """Valida datos de negocio cuando un aprobador decide sobre un ticket."""
    def validate(self, node, data, context):
        instance = context["instance"]
        ticket = Ticket.objects.get(workflow_instance=instance)
        required_fields = {"resolution_note": str}
        for field, field_type in required_fields.items():
            if field not in data:
                raise ValidationError({field: "Este campo es requerido."})
        return data

# App B: helpdesk/apps.py
class HelpdeskConfig(AppConfig):
    name = "helpdesk"
    def ready(self):
        from infrasynth.workflows.validators import DataValidatorRegistry
        DataValidatorRegistry.register("ticket_approval", TicketApprovalValidator())

2.8 infrasynth.scheduler — Gestión de Jobs

Feature flag: scheduler (default: True) Dependencias: infrasynth.shared, infrasynth.audit

Modelos

class ScheduledTask(models.Model):
    """Tarea programada o bajo demanda."""
    name = models.CharField(max_length=200, unique=True)
    task_path = models.CharField(max_length=500, help_text="Dotted path: helpdesk.tasks.cleanup_old_tickets")
    schedule_type = models.CharField(max_length=20, choices=[("CRON","Cron"),("INTERVAL","Interval"),("DATE","Date"),("MANUAL","Manual")])
    schedule_config = models.JSONField(default=dict, help_text='{"cron": "0 2 * * *"} o {"interval": 3600}')
    args = models.JSONField(default=list)
    kwargs = models.JSONField(default=dict)
    is_active = models.BooleanField(default=True)
    queue = models.CharField(max_length=100, default="default")
    priority = models.PositiveSmallIntegerField(default=5)

    class Meta:
        db_table = "scheduler_task"


class TaskExecution(models.Model):
    """Registro de ejecución de una tarea."""
    task = models.ForeignKey(ScheduledTask, on_delete=models.CASCADE, related_name="executions")
    celery_task_id = models.CharField(max_length=255, blank=True)
    status = models.CharField(max_length=20, choices=[("PENDING","Pending"),("RUNNING","Running"),("SUCCESS","Success"),("FAILURE","Failure")])
    started_at = models.DateTimeField(null=True)
    completed_at = models.DateTimeField(null=True)
    result = models.TextField(blank=True)
    error_traceback = models.TextField(blank=True)
    worker_hostname = models.CharField(max_length=255, blank=True)

    class Meta:
        db_table = "scheduler_execution"
        ordering = ["-started_at"]

Señales

task_scheduled = Signal()       # kwargs: task_name, eta
task_started = Signal()         # kwargs: task_name, task_id, worker
task_completed = Signal()       # kwargs: task_name, task_id, result, duration_seconds
task_failed = Signal()          # kwargs: task_name, task_id, error, traceback

API Endpoints

Endpoint Método Permiso Descripción
/scheduler/tasks/ GET, POST scheduler.manage_tasks CRUD tareas
/scheduler/tasks/<id>/ GET, PUT, DELETE scheduler.manage_tasks Detalle tarea
/scheduler/tasks/<id>/run/ POST scheduler.manage_tasks Ejecución manual inmediata
/scheduler/tasks/<id>/toggle/ POST scheduler.manage_tasks Activar/desactivar
/scheduler/executions/ GET scheduler.view_executions Historial de ejecuciones
/scheduler/executions/<id>/ GET scheduler.view_executions Detalle ejecución
/scheduler/queue-status/ GET scheduler.view_status Estado de colas Celery
/scheduler/workers/ GET scheduler.view_status Workers activos y stats

Configuración Externalizable

INFRASYNTH_SCHEDULER = {
    "BACKEND": "celery",                    # celery | django_q | apscheduler
    "CELERY_BROKER_URL": "redis://localhost:6379/0",
    "CELERY_RESULT_BACKEND": "redis://localhost:6379/1",
    "CELERY_TASK_SOFT_TIME_LIMIT": 300,
    "CELERY_TASK_TIME_LIMIT": 600,
    "CELERY_WORKER_PREFETCH_MULTIPLIER": 1,
    "DEFAULT_QUEUE": "default",
    "MAX_EXECUTION_HISTORY_PER_TASK": 1000,
    "AUTO_DISCOVER_TASKS": True,
}

2.9 infrasynth.features — Feature Flags

Feature flag: features (default: True — NUNCA se deshabilita, es el orquestador) Dependencias: infrasynth.shared, infrasynth.audit

Esta app es especial: todas las demás apps (y App B) dependen conceptualmente de features para habilitarse/deshabilitarse. Features siempre está activa.

Modelos

class FeatureFlag(models.Model):
    """
    Feature flag con soporte multi-tenant.
    tenant_id=NULL significa "global".
    """
    slug = models.SlugField(max_length=100)
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    is_active = models.BooleanField(default=False)
    rollout_percentage = models.PositiveSmallIntegerField(default=100, help_text="0-100. 100 = todos los usuarios")
    tenant_id = models.UUIDField(null=True, blank=True, help_text="Null = global. Valor = específico del tenant")
    environments = models.JSONField(default=list, help_text='["production", "staging"] o [] = todos')
    category = models.CharField(max_length=50, blank=True, help_text="Agrupación para UI")
    metadata = models.JSONField(default=dict)

    class Meta:
        db_table = "features_flag"
        unique_together = [("slug", "tenant_id")]


class FeatureFlagOverride(models.Model):
    """
    Override puntual para un usuario o grupo específico.
    Prevalece sobre la configuración global.
    """
    flag = models.ForeignKey(FeatureFlag, on_delete=models.CASCADE, related_name="overrides")
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, related_name="+")
    group = models.ForeignKey("auth.Group", on_delete=models.CASCADE, null=True, blank=True, related_name="+")
    is_enabled = models.BooleanField()

    class Meta:
        db_table = "features_override"
        unique_together = [("flag", "user"), ("flag", "group")]

FeatureRegistry

# infrasynth/features/registry.py
@dataclass
class FeatureDefinition:
    slug: str
    name: str = ""
    description: str = ""
    default: bool = True
    category: str = None


class FeatureRegistry:
    """
    Registry donde CADA APP registra sus feature flags en apps.py:ready().
    Features NO conoce qué apps existen.
    """
    _features: dict[str, FeatureDefinition] = {}

    @classmethod
    def register(cls, slug: str, *, name: str = "", description: str = "",
                 default: bool = True, category: str = None):
        cls._features[slug] = FeatureDefinition(
            slug=slug, name=name or slug, description=description,
            default=default, category=category,
        )

    @classmethod
    def get_all(cls) -> dict[str, FeatureDefinition]:
        return dict(cls._features)

Cada app registra sus flags en apps.py:ready():

# infrasynth/webhooks/apps.py
class WebhooksConfig(AppConfig):
    name = "infrasynth.webhooks"

    def ready(self):
        from infrasynth.features.registry import FeatureRegistry
        FeatureRegistry.register("webhooks", name="Webhooks",
                                 description="Sistema de webhooks inbound/outbound",
                                 default=True, category="integration")
        FeatureRegistry.register("webhooks_outbound",
                                 name="Webhooks Salientes", default=True)
        FeatureRegistry.register("webhooks_inbound",
                                 name="Webhooks Entrantes", default=True)

# infrasynth/billing/apps.py
class BillingConfig(AppConfig):
    name = "infrasynth.billing"

    def ready(self):
        from infrasynth.features.registry import FeatureRegistry
        FeatureRegistry.register("billing", name="Facturación y Pagos",
                                 description="Módulo de suscripciones y facturación",
                                 default=False, category="operations")

FeatureService

# infrasynth/features/services.py
from django.core.cache import cache

class FeatureService:
    """
    Servicio de evaluación de feature flags.
    Usa cache para minimizar queries.
    """

    def is_enabled(self, slug: str, *, user=None, tenant_id: str = None,
                   default: bool = None, ttl_seconds: int = 60) -> bool:
        """Evalúa si un feature flag está activo."""

        # 1. Override por usuario (BD)
        if user and user.is_authenticated:
            override = self._get_user_override(slug, user)
            if override is not None:
                return override

        # 2. Override por grupo
        if user and user.is_authenticated:
            override = self._get_group_override(slug, user)
            if override is not None:
                return override

        # 3. Configuración del tenant
        if tenant_id:
            flag = self._get_flag(slug, tenant_id, ttl_seconds)
        else:
            flag = self._get_flag_global(slug, ttl_seconds)

        if flag:
            return flag.is_active

        # 4. Default del registry
        registry_default = FeatureRegistry.get_all().get(slug)
        if registry_default:
            return registry_default.default

        # 5. Default del caller
        return default if default is not None else False

    def get_active_flags(self, *, user=None, tenant_id: str = None) -> dict[str, bool]:
        """
        Retorna el estado de TODOS los flags conocidos para el usuario/tenant actual.
        Este es el endpoint que el frontend consume.
        """
        all_slugs = set(FeatureRegistry.get_all().keys())
        db_flags = set(FeatureFlag.objects.filter(
            Q(tenant_id=tenant_id) | Q(tenant_id__isnull=True)
        ).values_list("slug", flat=True))
        all_slugs.update(db_flags)

        return {
            slug: self.is_enabled(slug, user=user, tenant_id=tenant_id)
            for slug in sorted(all_slugs)
        }

API Endpoints

Endpoint Método Permiso Descripción
/features/ GET, POST features.manage_flags CRUD feature flags
/features/<slug>/ GET, PUT, DELETE features.manage_flags Detalle flag
/features/<slug>/overrides/ GET, POST features.manage_flags CRUD overrides por usuario/grupo
/features/<slug>/overrides/<id>/ DELETE features.manage_flags Eliminar override
/features/active/ GET IsAuthenticated Endpoint central. Retorna TODOS los flags activos + permisos + roles para el usuario/tenant actual. El frontend lo consume al montar.
/features/check/<slug>/ GET IsAuthenticated Verificar un flag específico

Señales

flag_created = Signal()             # kwargs: slug, created_by
flag_toggled = Signal()             # kwargs: slug, new_state, toggled_by
flag_deleted = Signal()             # kwargs: slug, deleted_by
override_created = Signal()         # kwargs: flag_slug, user, group, is_enabled
override_deleted = Signal()         # kwargs: flag_slug, user, group

Configuración Externalizable

INFRASYNTH_FEATURES = {
    "CACHE_BACKEND": "default",
    "CACHE_TTL_SECONDS": 60,
    "CACHE_KEY_PREFIX": "features",
    "ROLLOUT_HASH_ALGORITHM": "md5",         # Para hashing determinístico de user_id en rollout_percentage
    "AUTO_REGISTER_FROM_SETTINGS": True,     # Descubrir flags de otras apps en sus settings
    "EXPOSE_PERMISSIONS_IN_ACTIVE_ENDPOINT": True,
    "EXPOSE_ROLES_IN_ACTIVE_ENDPOINT": True,
}

Flujo de Habilitación/Deshabilitación de Apps

1. Django arranca → carga INSTALLED_APPS (TODAS las apps base + apps de App B)
2. Cada apps.py:ready() registra sus feature flags en FeatureRegistry
3. Frontend llama GET /api/features/active/
   → Response:
   {
     "flags": {
       "billing": false,
       "workflows": true,
       "webhooks": true,
       "webhooks_outbound": true,
       "webhooks_inbound": false,
       "notifications": true,
       "notifications_sms": false,
       "helpdesk.ticket_priority": true,
       "helpdesk.sla_tracking": false
     },
     "permissions": ["helpdesk.view_tickets", "helpdesk.create_ticket"],
     "roles": ["agent"]
   }
4. Frontend renderiza condicionalmente:
   - Menú "Facturación"  → flags.billing ? mostrar : ocultar
   - Menú "Flujos"       → flags.workflows ? mostrar : ocultar
   - Botón "Activar 2FA" → flags.two_factor ? mostrar : ocultar
5. Backend protege endpoints:
   - GET /api/billing/plans/ → billing view chequea FeatureService().is_enabled("billing")
     → false: retorna 404

2.10 infrasynth.billing — Pagos, Planes y Entitlements

Feature flag: billing (default: False — requiere activación explícita) Dependencias: infrasynth.shared, infrasynth.audit, infrasynth.tenancy

Este app es la fuente de enforcement comercial. No hay servidor de licencias ni claves firmadas. Lo que un tenant puede usar es un Entitlement, verificado en proceso por EntitlementService. Ver ../ENTITLEMENTS.md.

Modelos

class App(models.Model):
    """Una app desplegada en el catálogo (global)."""
    slug = models.SlugField(max_length=100, unique=True)      # "messenger", "invoicer"
    name = models.CharField(max_length=200)
    monetization = models.CharField(                          # default a nivel de app
        max_length=20,
        choices=[("one_time","one_time"),("subscription","subscription")],
    )
    is_active = models.BooleanField(default=True)
    metadata = models.JSONField(default=dict)

    class Meta:
        db_table = "billing_app"


class Plan(models.Model):
    """Tier comprable de una app (global)."""
    app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="plans")
    slug = models.SlugField(max_length=100)
    name = models.CharField(max_length=200)
    price_amount = models.BigIntegerField()                   # UNIDADES MENORES (centavos) — nunca float
    price_currency = models.CharField(max_length=3, default="USD")   # ISO 4217
    interval = models.CharField(                              # one_time | monthly | yearly
        max_length=20,
        choices=[("one_time","one_time"),("monthly","monthly"),("yearly","yearly")],
        default="monthly",
    )
    trial_days = models.PositiveIntegerField(default=0)
    features = models.JSONField(default=dict)   # {"broadcast": true, "analytics": false}
    limits = models.JSONField(default=dict)     # {"max_agents": 10}
    is_active = models.BooleanField(default=True)
    gateway = models.ForeignKey("PaymentGateway", on_delete=models.SET_NULL, null=True)
    external_id = models.CharField(max_length=200, blank=True)

    class Meta:
        db_table = "billing_plan"
        unique_together = [("app", "slug")]


class Entitlement(models.Model):
    """Derecho de un tenant a usar una app bajo un plan. Tenant-owned. Fuente única de enforcement."""
    tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="entitlements")
    app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="entitlements")
    plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True)
    status = models.CharField(
        max_length=20,
        choices=[("trialing","trialing"),("active","active"),("past_due","past_due"),
                 ("grace","grace"),("suspended","suspended"),("expired","expired"),
                 ("cancelled","cancelled"),("revoked","revoked")],
        default="active",
    )
    started_at = models.DateTimeField(auto_now_add=True)
    current_period_end = models.DateTimeField(null=True, blank=True)  # suscripciones
    expires_at = models.DateTimeField(null=True, blank=True)          # NULL = one_time / perpetuo
    cancel_at_period_end = models.BooleanField(default=False)
    source = models.CharField(max_length=20, default="manual")        # manual | stripe | mercadopago | wompi
    metadata = models.JSONField(default=dict)

    objects = TenantManager()
    all_objects = AllObjectsManager()

    class Meta:
        db_table = "billing_entitlement"
        constraints = [models.UniqueConstraint(fields=["tenant", "app"], name="uniq_tenant_app_entitlement")]
class PaymentGateway(models.Model):
    """Configuración de una pasarela de pago (global — cuenta de la plataforma)."""
    slug = models.SlugField(max_length=50, primary_key=True)
    display_name = models.CharField(max_length=200)
    gateway_class = models.CharField(max_length=500, help_text="Dotted path a la clase gateway")
    config = models.JSONField(default=dict, help_text="Credenciales encriptadas")
    is_active = models.BooleanField(default=False)
    supported_currencies = models.JSONField(default=list)
    webhook_secret = models.CharField(max_length=500, blank=True)

    class Meta:
        db_table = "billing_gateway"
class Subscription(models.Model):
    """Suscripción activa de un tenant (tenant-owned)."""
    tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="subscriptions")
    entitlement = models.ForeignKey(Entitlement, on_delete=models.SET_NULL, null=True, related_name="subscriptions")
    plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True)
    gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
    external_id = models.CharField(max_length=200, blank=True)
    status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in SubscriptionStatus])
    current_period_start = models.DateTimeField()
    current_period_end = models.DateTimeField()
    cancel_at_period_end = models.BooleanField(default=False)
    cancelled_at = models.DateTimeField(null=True)
    trial_end = models.DateTimeField(null=True)
    metadata = models.JSONField(default=dict)

    objects = TenantManager()
    all_objects = AllObjectsManager()

    class Meta:
        db_table = "billing_subscription"


class Invoice(models.Model):
    """Factura generada (tenant-owned)."""
    tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="invoices")
    subscription = models.ForeignKey(Subscription, on_delete=models.SET_NULL, null=True, related_name="invoices")
    gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
    external_id = models.CharField(max_length=200, blank=True)
    invoice_number = models.CharField(max_length=50, unique=True)
    amount = models.BigIntegerField()          # UNIDADES MENORES
    currency = models.CharField(max_length=3, default="USD")
    tax_amount = models.BigIntegerField(default=0)
    tax_name = models.CharField(max_length=50, blank=True, default="")
    status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in InvoiceStatus], default="draft")
    due_date = models.DateTimeField(null=True)
    paid_at = models.DateTimeField(null=True)
    line_items = models.JSONField(default=list, help_text='[{"description": "...", "amount": ..., "quantity": 1}]')
    pdf_file = models.ForeignKey("infrasynth_files.StoredFile", on_delete=models.SET_NULL, null=True, related_name="+")
    metadata = models.JSONField(default=dict)

    objects = TenantManager()
    all_objects = AllObjectsManager()

    class Meta:
        db_table = "billing_invoice"


class PaymentTransaction(models.Model):
    """Transacción de pago individual (tenant-owned)."""
    tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="payment_transactions")
    invoice = models.ForeignKey(Invoice, on_delete=models.SET_NULL, null=True, related_name="transactions")
    gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
    external_id = models.CharField(max_length=200, blank=True)
    amount = models.BigIntegerField()          # UNIDADES MENORES
    currency = models.CharField(max_length=3, default="USD")
    status = models.CharField(max_length=30)
    payment_method = models.CharField(max_length=100, blank=True)
    metadata = models.JSONField(default=dict)
    created_at = models.DateTimeField(auto_now_add=True)

    objects = TenantManager()
    all_objects = AllObjectsManager()

    class Meta:
        db_table = "billing_transaction"

BasePaymentGateway ABC

# infrasynth/billing/gateways/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class CheckoutSessionResult:
    session_id: str
    checkout_url: str | None = None
    client_secret: str | None = None

@dataclass
class WebhookResult:
    event_type: str
    is_handled: bool
    data: dict


class BasePaymentGateway(ABC):
    """ABC que toda pasarela de pago implementa."""

    gateway_slug: str

    @abstractmethod
    def create_checkout_session(self, plan, user, success_url: str,
                                cancel_url: str) -> CheckoutSessionResult:
        """Crea una sesión de checkout en la pasarela."""
        ...

    @abstractmethod
    def handle_webhook(self, payload: dict, headers: dict) -> WebhookResult:
        """Procesa un webhook entrante de la pasarela."""
        ...

    @abstractmethod
    def cancel_subscription(self, external_id: str) -> bool:
        """Cancela una suscripción en la pasarela."""
        ...

    @abstractmethod
    def sync_subscription(self, external_id: str) -> dict:
        """Sincroniza estado de suscripción desde la pasarela."""
        ...

    @abstractmethod
    def get_invoice(self, external_id: str) -> dict:
        """Recupera factura desde la pasarela."""
        ...

    @abstractmethod
    def health_check(self) -> bool:
        """Verifica conectividad con la pasarela."""
        ...

Implementaciones built-in: StripeGateway, MercadoPagoGateway, WompiGateway.

Señales

subscription_created = Signal()         # kwargs: user, plan_slug, gateway, external_id
subscription_cancelled = Signal()       # kwargs: user, plan_slug, reason
subscription_renewed = Signal()         # kwargs: user, plan_slug, new_period_end
payment_succeeded = Signal()            # kwargs: user, invoice_id, amount, gateway
payment_failed = Signal()               # kwargs: user, invoice_id, amount, error
invoice_generated = Signal()            # kwargs: user, invoice_id, amount
invoice_paid = Signal()                 # kwargs: user, invoice_id, amount

API Endpoints

Endpoint Método Permiso Descripción
/billing/gateways/ GET IsAuthenticated Pasarelas activas
/billing/plans/ GET None Planes disponibles
/billing/plans/<slug>/ GET None Detalle plan
/billing/entitlements/ GET IsAuthenticated Entitlements del tenant actual (todas las apps)
/billing/entitlements/<app_slug>/ GET IsAuthenticated Entitlement del tenant para una app
/billing/checkout/ POST IsAuthenticated Crear checkout {app, plan} (tenant del token)
/billing/subscriptions/ GET IsAuthenticated Suscripciones del tenant actual
/billing/subscriptions/<id>/ GET IsAuthenticated Detalle suscripción
/billing/subscriptions/<id>/cancel/ POST IsAuthenticated Cancelar suscripción
/billing/subscribe/<plan_slug>/ POST IsAuthenticated Crear checkout (retorna redirect URL)
/billing/invoices/ GET IsAuthenticated Facturas del usuario
/billing/invoices/<id>/ GET IsAuthenticated Detalle factura
/billing/invoices/<id>/download/ GET IsAuthenticated Descargar PDF
/billing/webhook/<gateway_slug>/ POST None (público) Webhook de pasarela

Configuración Externalizable

INFRASYNTH_BILLING = {
    "INVOICE_NUMBER_PREFIX": "INV-",
    "INVOICE_PDF_TEMPLATE": "billing/invoice_pdf.html",
    "GRACE_PERIOD_DAYS": 5,
    "MAX_RETRY_FAILED_PAYMENTS": 3,
    "DEFAULT_CURRENCY": "USD",
    "TAX_PERCENTAGE": 0,
    "TAX_NAME": "",
    "INVOICE_GENERATION_DAYS_BEFORE_RENEWAL": 3,
    "WEBHOOK_TOLERANCE_SECONDS": 300,
    "SYNC_SUBSCRIPTIONS_EVERY_HOURS": 24,
}

2.11 infrasynth.tenancy — Tenants, Membresía y Contexto

Feature flag: tenancy (default: True — es core) Dependencias: infrasynth.shared, infrasynth.audit

Propósito: proveer el modelo de tenant, la membresía usuario↔tenant, el contexto de request y los managers scopeados que hacen cumplir el aislamiento. Es la implementación de ../TENANCY.md.

Modelos

class Tenant(models.Model):
    """Una empresa cliente. PK UUID para exponerla con seguridad."""
    id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
    slug = models.SlugField(max_length=100, unique=True)   # handle público, ej. "acme"
    name = models.CharField(max_length=200)
    status = models.CharField(
        max_length=20,
        choices=[("trialing","trialing"),("active","active"),
                 ("suspended","suspended"),("archived","archived")],
        default="active",
    )
    locale = models.CharField(max_length=10, default="es")
    timezone = models.CharField(max_length=64, default="UTC")
    metadata = models.JSONField(default=dict)
    created_at = models.DateTimeField(auto_now_add=True)
    suspended_at = models.DateTimeField(null=True, blank=True)
    archived_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        db_table = "tenancy_tenant"


class TenantMembership(models.Model):
    """Pertenencia de un usuario a un tenant. Única forma correcta de ligar usuario y tenant."""
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="memberships")
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="tenant_memberships")
    role = models.CharField(max_length=50, default="member")   # slug de rol dentro del tenant
    is_owner = models.BooleanField(default=False)
    is_active = models.BooleanField(default=True)
    joined_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = "tenancy_membership"
        unique_together = [("tenant", "user")]
        indexes = [models.Index(fields=["user", "is_active"])]

Contexto y Managers

# infrasynth/tenancy/context.py
from contextvars import ContextVar
current_tenant: ContextVar[Tenant | None] = ContextVar("current_tenant", default=None)

# infrasynth/tenancy/managers.py
class TenantManager(models.Manager):
    """Manager por defecto de todo modelo tenant-owned. Scopea al tenant actual."""
    def get_queryset(self):
        tenant = current_tenant.get()
        if tenant is None:
            return super().get_queryset().none()      # fail closed
        return super().get_queryset().filter(tenant_id=tenant.id)

    def unsafe_all(self):
        """Escape hatch explícito para código de sistema. Nunca desde una vista."""
        return super().get_queryset()

class AllObjectsManager(models.Manager):
    """Manager sin scope (`all_objects`) para migraciones, admin y platform staff."""

Middleware

TenantMiddleware corre después de la autenticación. Lee el claim tenant del token, verifica que la membresía siga activa (si no, 403 AUTH_MEMBERSHIP_REVOKED — nunca espera a que expire el token), setea current_tenant y limpia el contexto al terminar. Rechaza requests a endpoints de tenant cuando no hay tenant resuelto, salvo el allowlist (login, select/switch-workspace, health, webhooks, catálogo).

TenantService

class TenantService:
    def get_active_memberships(self, user) -> list[TenantMembership]: ...
    def select_tenant(self, user, tenant_id) -> Tenant: ...        # valida membresía activa
    def switch_tenant(self, user, tenant_id) -> tuple[str, str]: ...  # (access, refresh) nuevos
    def create_tenant(self, name, owner, slug=None) -> Tenant: ...    # crea tenant + membership owner
    def invite(self, tenant, email, role) -> Invitation: ...
    def suspend(self, tenant, reason) -> None: ...
    def reinstate(self, tenant) -> None: ...
    def offboard(self, tenant) -> None: ...                          # export → archive → delete diferido

API Endpoints

Endpoint Método Permiso Descripción
/auth/select-workspace/ POST None (pre-auth) Elegir workspace tras login multi-workspace. Emite tokens con claim tenant
/auth/switch-workspace/ POST IsAuthenticated Cambiar de workspace (rota refresh token). Auditado
/tenancy/tenants/ GET, POST IsAuthenticated Listar/crear tenants del usuario
/tenancy/tenants/<id>/ GET, PUT tenancy.manage_tenant Detalle/edición del tenant
/tenancy/tenants/<id>/members/ GET, POST tenancy.manage_members Listar/invitar miembros
/tenancy/tenants/<id>/members/<id>/ DELETE tenancy.manage_members Revocar membresía (invalida sesión)

Configuración Externalizable

INFRASYNTH_TENANCY = {
    "TENANT_MODEL": "infrasynth.tenancy.Tenant",
    "MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership",
    "TENANT_CLAIM": "tenant",                 # nombre del claim en el JWT
    "REQUIRE_TENANT_BY_DEFAULT": True,        # endpoints sin tenant → 403 salvo allowlist
    "TENANT_ALLOWLIST_PATHS": ["/api/v1/auth/", "/api/v1/billing/webhook/", "/healthz", "/readyz"],
    "ENABLE_WORKSPACE_SWITCHING": True,
    "DEFAULT_LOCALE": "es",
    "DEFAULT_TIMEZONE": "UTC",
}

Patrón de Integración para App B

# App B: helpdesk/models.py — un modelo tenant-owned
from infrasynth.tenancy.managers import TenantManager, AllObjectsManager

class Ticket(models.Model):
    tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+")
    subject = models.CharField(max_length=255)
    objects = TenantManager()
    all_objects = AllObjectsManager()

    class Meta:
        constraints = [models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_ticket_slug_per_tenant")]

# App B: cualquier vista — el manager ya scopea; no se filtra a mano
Ticket.objects.all()          # solo tickets del tenant actual
Ticket.all_objects.all()      # TODOS los tenants — solo para admin/management

3. Patrones de Acoplamiento

3.1 Regla de Oro

Ninguna app de infraestructura importa directamente de otra app de infraestructura. Solo se permite:

  • Importar de infrasynth.shared (protocolos, tipos, enums)
  • Importar de Django stdlib (models, settings, signals)
  • Usar signals, registries, y settings para comunicación cross-app

3.2 Mecanismos de Comunicación entre Apps

┌─────────────────────────────────────────────────────────────────┐
│  MECANISMO          │  USO PRINCIPAL                            │
├─────────────────────────────────────────────────────────────────┤
│  Settings (dict)    │  Configurar backends concretos            │
│  Signals            │  Eventos asíncronos entre apps            │
│  Registries         │  Apps se auto-registran (eventos,         │
│                     │  resolvedores, validadores, flags)        │
│  ABCs/Protocols     │  Contratos swappables (canales,           │
│                     │  gateways, handlers, validadores)         │
│  ForeignKey         │  SET_NULL siempre, related_name="+"       │
│  AUTH_USER_MODEL    │  Nunca User directo                       │
│  FeatureService     │  Control de habilitación cross-cutting    │
└─────────────────────────────────────────────────────────────────┘

3.3 Ejemplo Concreto Cross-App sin Acoplamiento

Escenario: Cuando se paga una factura en billing, se debe enviar un email de recibo via notifications.

Mal (acoplado):

# billing NO debe hacer esto:
from infrasynth.notifications.services import NotificationService
NotificationService().send(...)

Bien (desacoplado via signals):

# billing emite señal
from infrasynth.billing.signals import payment_succeeded
payment_succeeded.send(sender=PaymentGateway, user=user, invoice_id=inv.id, ...)

# App B (o el proyecto consumidor) conecta billing con notifications
# en un archivo de receivers propio
from infrasynth.billing.signals import payment_succeeded as billing_payment_ok
from infrasynth.notifications.services import NotificationService

@receiver(billing_payment_ok)
def send_payment_receipt(sender, user, invoice_id, amount, gateway, **kwargs):
    NotificationService().send(
        recipients=[user.email],
        template_slug="billing.payment_receipt",
        context={"invoice_id": invoice_id, "amount": amount},
    )

El proyecto consumidor es el encargado de conectar las apps entre sí cuando se necesita comunicación directa. Las apps base solo emiten señales y exponen registries.

3.4 Grafo de Dependencias

infrasynth.shared
    ↑
    ├── infrasynth.audit
    │       ↑
    │       ├── infrasynth.tenancy        ← define el aislamiento; todas las apps lo usan
    │       ├── infrasynth.security
    │       ├── infrasynth.files
    │       ├── infrasynth.notifications
    │       ├── infrasynth.webhooks
    │       ├── infrasynth.workflows
    │       ├── infrasynth.scheduler
    │       ├── infrasynth.features
    │       └── infrasynth.billing        ← usa tenancy (los entitlements son tenant-owned)
    │
    │   (Todas las apps de infraestructura dependen solo de shared + audit;
    │    las apps tenant-owned usan infrasynth.tenancy para managers y contexto)
    │
    └── infrasynth.features ← es el orquestador transversal de flags operativos
            ↑
        (Todas las apps registran sus flags aquí, pero NO importan features)

Las apps no importan infrasynth.features directamente. El FeatureService se usa via import_string o se inyecta en las views.


4. Estrategia de Versionado

4.1 Versión Única

Todo el ecosistema comparte una sola versión en pyproject.toml:

[project]
name = "infrasynth-base"
version = "1.0.0"

Esto simplifica la instalación y garantiza compatibilidad entre apps.

4.2 SemVer

Bump Disparador
MAJOR Cambio de API pública: modelo, endpoint, señal, setting contract
MINOR Nueva funcionalidad backward-compatible: nuevo endpoint, nuevo campo nullable, nuevo flag
PATCH Bug fix, optimización, seguridad

4.3 Migraciones

Cada app Django incluye sus propias migraciones. Para evitar colisiones entre apps, el proyecto consumidor configura:

MIGRATION_MODULES = {
    "infrasynth_audit": "infrasynth.audit.migrations",
    "infrasynth_security": "infrasynth.security.migrations",
    # ...
}

4.4 Garantías de Compatibilidad

  • Modelos: Solo se añaden campos (nunca se remueven). Campos deprecados se marcan con help_text="[DEPRECATED]".
  • Endpoints: Solo se añaden. Endpoints deprecados retornan header Deprecation: true.
  • Señales: Solo se añaden kwargs, nunca se remueven.
  • Settings: Solo se añaden keys con defaults. Keys renombradas tienen fallback automático.

5. Stack Técnico

5.1 Dependencias Core (pyproject.toml)

[project]
name = "infrasynth-base"
version = "1.0.0"
requires-python = ">=3.12"

dependencies = [
    "django>=5.2,<6.0",
    "djangorestframework>=3.16,<4.0",
    "django-cors-headers>=4.7",
    "djangorestframework-simplejwt>=5.5",
    "django-filter>=25.1",
    "psycopg2-binary>=2.9",
    "python-dotenv>=1.0",
    "cryptography>=44.0",
    "pydantic>=2.0",
    "pyotp>=2.10",
    "qrcode[pil]>=8.1",
    "celery[redis]>=5.4",
    "django-celery-results>=2.5",
    "django-celery-beat>=2.7",
    "boto3>=1.35",
    "django-storages>=1.14",
    "Pillow>=11.0",
    "twilio>=9.0",
    "stripe>=10.0",
    "mercadopago>=3.0",
    "requests>=2.32",
    "flower>=2.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-django>=4.8",
    "pytest-cov>=5.0",
    "factory-boy>=3.3",
    "faker>=28.0",
    "ruff>=0.6",
    "mypy>=1.11",
    "pre-commit>=3.8",
]

5.2 Docker Compose de Desarrollo

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: infrasynth
      POSTGRES_USER: infrasynth
      POSTGRES_PASSWORD: infrasynth
    ports: ["5432:5432"]
    volumes: [pgdata:/var/lib/postgresql/data]

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  worker:
    build: .
    command: celery -A config worker -l info -Q default,webhooks,notifications,billing
    depends_on: [redis, db]
    volumes: [".:/app"]

  beat:
    build: .
    command: celery -A config beat -l info
    depends_on: [redis, db]
    volumes: [".:/app"]

  flower:
    image: mher/flower
    ports: ["5555:5555"]
    environment:
      CELERY_BROKER_URL: redis://redis:6379/0
    depends_on: [redis]

volumes:
  pgdata:

5.3 Recomendaciones Production

  • Web Server: Gunicorn (sync, workers = 2*CPU + 1, threads = 4)
  • Reverse Proxy: Nginx (static files, rate limiting, SSL termination)
  • DB: PostgreSQL 16 + PgBouncer (connection pooling)
  • Cache: Redis (caching + sessions + Celery broker)
  • Monitoring: Sentry (errors) + Prometheus + Grafana (metrics)
  • Logging: structlog → JSON stdout → Loki + Grafana
  • CI/CD: GitHub Actions (tests + lint + docker build)

6. Configuración de Settings para App B

Ejemplo completo de settings.py del proyecto consumidor:

import os
import dotenv
from pathlib import Path

dotenv.load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.getenv("SECRET_KEY")
DEBUG = os.getenv("DEBUG", "False") == "True"
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",

    # InfraSynth Base (TODAS las apps, features controla visibilidad)
    "infrasynth.tenancy",
    "infrasynth.audit",
    "infrasynth.security",
    "infrasynth.files",
    "infrasynth.notifications",
    "infrasynth.webhooks",
    "infrasynth.workflows",
    "infrasynth.scheduler",
    "infrasynth.features",
    "infrasynth.billing",

    # App B — Apps de dominio
    "helpdesk",
    "knowledge_base",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "corsheaders.middleware.CorsMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "infrasynth.security.auth.middleware.JWTAuthenticationMiddleware",
    "infrasynth.tenancy.middleware.TenantMiddleware",
    "infrasynth.security.two_factor.middleware.TwoFactorMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "infrasynth.audit.middleware.AuditAPIMiddleware",
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "infrasynth.security.auth.cookies.CookieJWTAuthentication",
        "infrasynth.security.auth.api_keys.APIKeyAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_RENDERER_CLASSES": ["infrasynth.api.renderers.EnvelopeJSONRenderer"],
    "EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler",
    "DEFAULT_PAGINATION_CLASS": "infrasynth.api.pagination.CursorPagination",
    "PAGE_SIZE": 25,
    "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}

# ===================================================================
# InfraSynth Configuration
# ===================================================================

INFRASYNTH_SECURITY = {
    "ACCESS_TOKEN_LIFETIME_MINUTES": 30,
    "REFRESH_TOKEN_LIFETIME_DAYS": 7,
    "COOKIE_SECURE": not DEBUG,
    "CRYPTO_KEY": os.getenv("CRYPTO_KEY"),
    "TWO_FACTOR_ISSUER_NAME": "HelpDesk Pro",
}

INFRASYNTH_TENANCY = {
    "TENANT_MODEL": "infrasynth.tenancy.Tenant",
    "MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership",
    "TENANT_CLAIM": "tenant",
    "REQUIRE_TENANT_BY_DEFAULT": True,
    "DEFAULT_LOCALE": "es",
    "DEFAULT_TIMEZONE": "UTC",
}

INFRASYNTH_FILES = {
    "DEFAULT_STORAGE_BACKEND": "S3",
    "STORAGE_BACKENDS": {
        "S3": {
            "ACCESS_KEY": os.getenv("AWS_ACCESS_KEY_ID"),
            "SECRET_KEY": os.getenv("AWS_SECRET_ACCESS_KEY"),
            "BUCKET_NAME": os.getenv("AWS_S3_BUCKET"),
            "REGION": os.getenv("AWS_REGION", "us-east-1"),
        },
    },
    "MAX_UPLOAD_SIZE_MB": 50,
}

INFRASYNTH_NOTIFICATIONS = {
    "DEFAULT_FROM_EMAIL": "helpdesk@example.com",
    "CHANNELS": {
        "email": {
            "primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel",
        },
    },
}

INFRASYNTH_BILLING = {
    "DEFAULT_CURRENCY": "COP",
    "TAX_PERCENTAGE": 19,
    "TAX_NAME": "IVA",
}

INFRASYNTH_SCHEDULER = {
    "CELERY_BROKER_URL": os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
}

# ===================================================================
# Database (PostgreSQL)
# ===================================================================
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.getenv("DB_NAME"),
        "USER": os.getenv("DB_USER"),
        "PASSWORD": os.getenv("DB_PASSWORD"),
        "HOST": os.getenv("DB_HOST", "localhost"),
        "PORT": os.getenv("DB_PORT", "5432"),
    },
}

# ===================================================================
# Django standard
# ===================================================================
LANGUAGE_CODE = "es"
TIME_ZONE = "America/Bogota"
USE_TZ = True
STATIC_URL = "static/"
MEDIA_URL = "media/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

7. Ejemplo Completo: App B (HelpDesk) Integrando InfraSynth

# ============================================================
# helpdesk/apps.py
# ============================================================
class HelpdeskConfig(AppConfig):
    name = "helpdesk"

    def ready(self):
        # Registrar feature flags de dominio
        from infrasynth.features.registry import FeatureRegistry
        FeatureRegistry.register("helpdesk.sla_tracking",
                                 name="SLA Tracking", default=True, category="helpdesk")
        FeatureRegistry.register("helpdesk.auto_assign",
                                 name="Auto-assign Tickets", default=False)

        # Registrar eventos para webhooks
        from infrasynth.webhooks.registry import EventRegistry
        EventRegistry.register("helpdesk.ticket.created")
        EventRegistry.register("helpdesk.ticket.resolved")
        EventRegistry.register("helpdesk.sla.breached")

        # Registrar resolvedores de variables para notificaciones
        from infrasynth.notifications.resolvers import VariableResolverRegistry
        @VariableResolverRegistry.register("ticket_number", namespace="helpdesk")
        def resolve_ticket_number(recipient, context, request):
            return f"TK-{context['ticket'].id:06d}"

        @VariableResolverRegistry.register("agent_name", namespace="helpdesk")
        def resolve_agent_name(recipient, context, request):
            agent = context.get("ticket").assigned_to
            return agent.get_full_name() if agent else "Sin asignar"

# ============================================================
# helpdesk/models.py
# ============================================================
from infrasynth.workflows.models import WorkflowAwareModel
from infrasynth.files.models import StoredFile

class Ticket(WorkflowAwareModel):
    tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+")
    subject = models.CharField(max_length=255)
    description = models.TextField()
    priority = models.CharField(max_length=20, choices=[("low","Low"),("medium","Medium"),("high","High")])
    status = models.CharField(max_length=20, choices=[("open","Open"),("in_progress","In Progress"),("resolved","Resolved"),("closed","Closed")])
    assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="assigned_tickets")
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="created_tickets")
    attachments = models.ManyToManyField(StoredFile, blank=True, related_name="+")
    resolution = models.TextField(blank=True)

    objects = TenantManager()          # queries scopeadas al tenant actual
    all_objects = AllObjectsManager()  # solo admin/management

# ============================================================
# helpdesk/views.py
# ============================================================
from infrasynth.security.permissions import require_permission
from infrasynth.features.services import FeatureService

class TicketViewSet(ModelViewSet):
    permission_classes = [IsAuthenticated, require_permission("helpdesk.manage_tickets")]

    def get_queryset(self):
        # Ticket.objects ya está scopeado al tenant actual por TenantManager (fail closed).
        qs = Ticket.objects.select_related("assigned_to", "created_by")
        authz = AuthorizationService()
        if not authz.has_permission(self.request.user, "helpdesk.view_all_tickets"):
            qs = qs.filter(Q(assigned_to=self.request.user) | Q(created_by=self.request.user))
        return qs

    def perform_create(self, serializer):
        tenant = current_tenant.get()
        # Gate = tenant activo AND entitled AND feature flag operativo
        if not EntitlementService().is_entitled(tenant, "helpdesk", feature="tickets"):
            raise EntitlementError(code="ENTITLEMENT_PLAN_UPGRADE_REQUIRED", app="helpdesk", feature="tickets")

        ticket = serializer.save(created_by=self.request.user, tenant=tenant)

        # Disparar evento → webhooks outbound reaccionan (tenant_id viaja explícito)
        from infrasynth.webhooks.registry import EventRegistry
        EventRegistry.emit("helpdesk.ticket.created", {
            "tenant_id": str(tenant.id),
            "ticket_id": ticket.id,
            "subject": ticket.subject,
            "priority": ticket.priority,
        })

        # Auto-assign si el feature flag operativo está activo para este tenant
        if FeatureService().is_enabled("helpdesk.auto_assign", tenant_id=tenant.id):
            assign_ticket_to_best_agent(ticket)

8. Resumen de Entregables del Proyecto

Entregable Contenido
pyproject.toml Meta-paquete con todas las dependencias
infrasynth/shared/ Protocolos, enums, crypto, Result monad
infrasynth/audit/ Django app: auditoría pasiva sin herencia
infrasynth/security/ Django app: auth JWT cookies + API keys, roles/grants híbridos, 2FA, ALTCHA
infrasynth/files/ Django app: storage cloud (S3, Cloudinary, GCS), signed URLs, pipelines
infrasynth/notifications/ Django app: dispatch multi-canal con failover, templates, resolvers
infrasynth/webhooks/ Django app: inbound/outbound con HMAC, EventRegistry
infrasynth/workflows/ Django app: máquina de estados con votación, WorkflowAwareModel mixin
infrasynth/scheduler/ Django app: dashboard y API de jobs Celery
infrasynth/tenancy/ Django app: Tenant, TenantMembership, managers scopeados, middleware, contexto de request
infrasynth/features/ Django app: feature flags con tenant/user overrides, endpoint central /api/features/active/
infrasynth/billing/ Django app: App, Plan, Entitlements, suscripciones, facturas, Stripe/MercadoPago/Wompi
tests/ Test suite completa con pytest + factory_boy, incluye aislamiento de tenants
AGENTS.md Guía completa para agentes de IA