Make access gating a first-class, pip-consumable extension point so a consuming app can gate any of its own views behind 2FA / ALTCHA / entitlement / feature flag / permission, or gate nothing, without editing the kit. - infrasynth.gates: Gate, GateResult, GatePermission, @gated and built-ins TwoFactorGate, AltchaGate, EntitlementGate, FeatureGate, PermissionGate; denials raise the correct namespaced error/status (per-endpoint, opt-in, default is no gating) - mint a `2fa` JWT claim only after verification (preserved across workspace selection) so TwoFactorGate is meaningful for API/multi-workspace clients - GatePermission added to DEFAULT_PERMISSION_CLASSES; HybridPermission evaluates declared gates so kit permissions gate automatically - document the extension surface and stable import paths in README
3472 lines
142 KiB
Markdown
3472 lines
142 KiB
Markdown
# InfraSynth Base — Plan de Arquitectura
|
|
|
|
[](https://github.com/anomalyco/infrasynth-base/actions/workflows/ci.yml)
|
|
[](https://codecov.io/gh/anomalyco/infrasynth-base)
|
|
[](https://www.python.org/downloads/)
|
|
[](https://www.djangoproject.com/)
|
|
[](https://github.com/astral-sh/ruff)
|
|
[](https://mypy-lang.org/)
|
|
|
|
## 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)_
|
|
- [x] `pyproject.toml` + docker-compose.yml + manage.py
|
|
- [x] `config/` Django project (settings split: base/dev/test, celery, wsgi, urls)
|
|
- [x] Skeleton for all 10 modules (models, serializers, views, filters, urls, signals, apps)
|
|
- [x] `pip install -e ".[dev]"` + `python manage.py check` ✅ _(2026-07-30)_
|
|
|
|
### Fase 2 — Foundation (`infrasynth/shared/`) ✅ _(2026-07-30)_
|
|
- [x] All 5 modules implemented (protocols, enums, results, crypto, settings_utils)
|
|
- [x] Tests: results, crypto, enums, settings_utils, protocols ✅ _(2026-07-30)_
|
|
|
|
### Fase 3 — Feature Flags (`infrasynth/features/`) ✅ _(2026-07-30)_
|
|
- [x] `models.py` — FeatureFlag + FeatureFlagOverride
|
|
- [x] `registry.py` — FeatureRegistry (register/get_all)
|
|
- [x] `services.py` — FeatureService (is_enabled, get_active_flags, caching, overrides)
|
|
- [x] `decorators.py` — @feature_required
|
|
- [x] `views.py` — CRUD + /active/ + /check/<slug>/ endpoints
|
|
- [x] Tests: services, views, decorators, registry ✅ _(2026-07-30)_
|
|
|
|
### Fase 4 — Auditoría (`infrasynth/audit/`) ✅ _(2026-07-30)_
|
|
- [x] `receivers.py` — post_save/post_delete tracking, excluded_models, excluded_fields, diff
|
|
- [x] `middleware.py` — body capture, sensitive key filtering, request_id
|
|
- [x] `views.py` — list/retrieve endpoints for all 3 models
|
|
- [x] 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)_
|
|
- [x] `auth/cookies.py` — CookieJWTAuthentication (encrypt/decrypt tokens via Fernet)
|
|
- [x] `auth/api_keys.py` — APIKeyAuthentication (prefix.secret, PBKDF2, scopes, SystemUser)
|
|
- [x] `auth/backends.py` — EmailOrUsernameBackend
|
|
- [x] `auth/middleware.py` — JWTAuthenticationMiddleware
|
|
- [x] `views.py` — login/logout/refresh/check endpoints
|
|
- [x] 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)_
|
|
- [x] `services.py` — AuthorizationService (has_permission, get_effective_permissions, chain)
|
|
- [x] `permissions.py` — HybridPermission + require_permission
|
|
- [x] Views: roles, grants, revokes CRUD
|
|
- [x] 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)_
|
|
- [x] `two_factor/services.py` — TOTPService (generate_secret, verify, QR), RecoveryCodeService
|
|
- [x] `two_factor/middleware.py` — enforce 2FA for configured users
|
|
- [x] `altcha/services.py` — create_challenge, verify PoW
|
|
- [x] Views: setup, verify-setup, verify, disable, recovery, challenge, verify
|
|
- [x] 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)_
|
|
- [x] Models: StoredFile, FileCategory, ProcessingPipeline, PipelineExecution
|
|
- [x] Views: StoredFileViewSet, FileCategoryViewSet, ProcessingPipelineViewSet
|
|
- [x] `storage.py` — Storage router: S3, local, GCS, cloudinary backends implementados ✅ _(2026-07-30)_
|
|
- [x] `services.py` — FileService: upload, get_signed_url, get_download_response, delete (soft/hard), get_file_info ✅ _(2026-07-30)_
|
|
- [x] `processing.py` — PipelineExecutor (resize/optimize/watermark/scan) + Celery task run_pipeline_execution ✅ _(2026-07-30)_
|
|
- [x] Views: download action wired to FileService + feature flag gates en los 3 viewsets ✅ _(2026-07-30)_
|
|
- [x] Tests: upload retrieves file, download returns response, delete marks removed ✅ _(2026-07-30)_
|
|
|
|
### Fase 9 — Notificaciones (`infrasynth/notifications/`) ✅ _(2026-07-30)_
|
|
- [x] Models: NotificationTemplate, NotificationDispatch, ChannelConfig
|
|
- [x] `channels/base.py` — BaseChannel ABC + Attachment
|
|
- [x] `resolvers.py` — VariableResolverRegistry (register/resolve/get_available_variables)
|
|
- [x] `services.py` — NotificationService.send() con template rendering + failover + tasks Celery (sync/celery/thread) ✅ _(2026-07-30)_
|
|
- [x] `channels/` — SMTPChannel, SendGridChannel, TwilioSMSChannel, TelegramChannel ✅ _(2026-07-30)_
|
|
- [x] Tests: template render, dispatch creates log, failover works ✅ _(2026-07-30)_
|
|
|
|
### Fase 10 — Webhooks (`infrasynth/webhooks/`) ✅ _(2026-07-30)_
|
|
- [x] `registry.py` — EventRegistry (register/emit with OutboundSubscription lookup + Celery dispatch)
|
|
- [x] `signature.py` — HMAC sign_payload / verify_signature
|
|
- [x] `inbound/handlers.py` — BaseInboundHandler ABC
|
|
- [x] Views: outbound endpoints, subscriptions, deliveries, inbound endpoints/events, receive
|
|
- [x] `dispatch.py` — deliver_webhook Celery task: HTTP POST, HMAC signature, payload template, retry/backoff, signals ✅ _(2026-07-30)_
|
|
- [x] 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)_
|
|
- [x] Models: Workflow, WorkflowNode, Transition, WorkflowInstance, NodeAssignment, WorkflowObserver, WorkflowAwareModel
|
|
- [x] `validators.py` — DataValidatorProtocol + DataValidatorRegistry
|
|
- [x] `engine.py` — WorkflowEngine: start(), submit_decision(), get_route(), get_node_states(), get_role_in_instance(), assign_users(), add_observer() ✅ _(2026-07-30)_
|
|
- [x] Views: route/state actions + submit/assign/observers wired al engine ✅ _(2026-07-30)_
|
|
- [x] Tests: start workflow, approve/reject advances, route tracking ✅ _(2026-07-30)_
|
|
|
|
### Fase 12 — Scheduler (`infrasynth/scheduler/`) ✅ _(2026-07-30)_
|
|
- [x] Models: ScheduledTask, TaskExecution
|
|
- [x] Views: tasks CRUD, executions, queue-status, workers
|
|
- [x] `services.py` — TaskService: run_now (Celery + plain functions), toggle, get_queue_status, get_workers ✅ _(2026-07-30)_
|
|
- [x] Tests: run_now triggers Celery, toggle enables/disables ✅ _(2026-07-30)_
|
|
|
|
### Fase 13 — Facturación (`infrasynth/billing/`) ✅ _(2026-07-30)_
|
|
- [x] Models: PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction _(extendido en Fase 17: App, Plan, Entitlement)_
|
|
- [x] `gateways/base.py` — BasePaymentGateway ABC + CheckoutSessionResult, WebhookResult
|
|
- [x] Views: gateways, plans, subscriptions, subscribe, invoices, webhook receive
|
|
- [x] `services.py` — BillingService: create_checkout_session, create_subscription, cancel_subscription, sync_subscription, generate_invoice ✅ _(2026-07-30)_
|
|
- [x] `invoice_generator.py` — generate_invoice_pdf Celery task con reportlab Platypus + almacenamiento via FileService ✅ _(2026-07-30)_
|
|
- [x] Views: subscribe + webhook receive wired a BillingService/BCG ✅ _(2026-07-30)_
|
|
- [x] `gateways/` — StripeGateway, MercadoPagoGateway, WompiGateway ✅ _(2026-07-30)_
|
|
- [x] 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)_
|
|
- [x] Cross-app signals: billing → notifications, webhooks → audit
|
|
- [x] Registries: FeatureRegistry, EventRegistry, VariableResolverRegistry, DataValidatorRegistry
|
|
- [x] E2E: login → feature flags → permission check → webhook emit → audit log
|
|
- [x] Coverage 93% (target: ≥80%)
|
|
|
|
### Fase 15 — Linting, Type Checking y CI ✅ _(2026-07-31)_
|
|
- [x] ruff check . sin errores
|
|
- [x] mypy infrasynth/ sin errores
|
|
- [x] pre-commit hooks configurados
|
|
- [x] GitHub Actions: tests + lint + typecheck + docker build
|
|
- [x] Dockerfile multi-stage + docker-compose con health checks
|
|
- [x] Badges (CI, coverage, python, django, ruff, mypy) en PLAN.md
|
|
- [ ] Docker push (pendiente de registry config)
|
|
|
|
### Fase 16 — Multi-tenancy ✅ _(2026-09-24)_
|
|
- [x] `infrasynth.tenancy` — Tenant, TenantMembership, TenantInvitation, PlatformStaff, TenantManager/AllObjectsManager/GlobalOrTenantManager, TenantMiddleware, `current_tenant` ContextVar, `INFRASYNTH_TENANCY`
|
|
- [x] `tenant_id` + `TenantManager` en todos los modelos tenant-owned (audit, security, files, notifications, webhooks, workflows, scheduler, billing) vía `TenantOwnedModel`/`GlobalOrTenantModel`
|
|
- [x] Restricciones compuestas `(tenant, …)` e índices que empiezan con `tenant_id`
|
|
- [x] Propagación explícita de `tenant_id` a Celery tasks y signals; claves de caché/rate-limit con prefijo `tenant:{id}:`
|
|
- [x] `TenantProtocol` real (UUID no-nulo, ya no stub)
|
|
- [x] 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)_
|
|
- [x] `billing` — modelos `App`, `Plan` (one_time/subscription), `Entitlement`; `tenant` en Subscription/Invoice/PaymentTransaction; dinero en unidades menores (BigInteger)
|
|
- [x] `EntitlementService` (`is_entitled`, `check_limit`) con caché por tenant e invalidación en mutaciones
|
|
- [x] Ciclo de vida `past_due` → `grace` → `suspended` a nivel tenant (reinstatement al pagar)
|
|
- [x] Códigos de error `ENTITLEMENT_*` en la capa de excepciones (`infrasynth.shared.exceptions`)
|
|
- [x] 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)_
|
|
- [x] `renderers.py` — EnvelopeJSONRenderer (envelope + camelCase), `meta.requestId/timestamp/tenantId/pagination`
|
|
- [x] `pagination.py` — CursorPagination (`pageSize`, `nextCursor`/`prevCursor`)
|
|
- [x] `exceptions.py` — envelope_exception_handler + códigos namespaced (`shared.exceptions`)
|
|
- [x] `middleware.py` — RequestIdMiddleware + RateLimitHeadersMiddleware
|
|
- [x] `idempotency.py` (Idempotency-Key), `throttling.py` (tenant-scoped), `webhooks.py` (replay window), `schema.py` (drf-spectacular)
|
|
- [x] 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)_
|
|
- [x] 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
|
|
- [x] 2FA real en login (pre-auth session + cookie, tokens sólo tras verificar) y `TwoFactorMiddleware` para sesión
|
|
- [x] 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`
|
|
- [x] Webhooks de pago procesados e idempotentes (suscripción/entitlement/invoice/`PaymentTransaction`), replay protegido, firma MercadoPago
|
|
- [x] Ciclo de vida de entitlements programado (sync, past_due→grace→suspended, expiración, facturas de renovación)
|
|
- [x] Reintentos de notificaciones + rate limit por canal + retención de logs; captura automática de diffs de update en audit + retención
|
|
- [x] Feature rollout (%) y targeting por entorno; registración de flags desde settings
|
|
- [x] Login brute-force guard, política de contraseñas, ALTCHA con PoW real; límite global de subida, virus scanner pluggable, toggle de pipelines
|
|
- [x] Guardas de workflow (`MAX_INSTANCES_PER_WORKFLOW`, `ROUTE_MAX_DEPTH`, `ALLOW_SELF_ASSIGNMENT`, `AUTO_CLONE_ASSIGNEES_ON_REENTRY`)
|
|
- [x] Throttling tenant-scoped por defecto; `CELERY_BEAT_SCHEDULE` con trabajos periódicos
|
|
- [x] README + CHANGELOG; CI con `ruff format --check` y umbral de cobertura
|
|
- [x] `infrasynth.gates` — gates por endpoint (`TwoFactorGate`, `AltchaGate`, `EntitlementGate`, `FeatureGate`, `PermissionGate`) declarables sin tocar el kit; `GatePermission` por defecto; claim `2fa` en el JWT
|
|
|
|
## 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.
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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.
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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)
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
# 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)
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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()`:
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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")]
|
|
```
|
|
|
|
```python
|
|
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"
|
|
```
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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`
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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):**
|
|
```python
|
|
# billing NO debe hacer esto:
|
|
from infrasynth.notifications.services import NotificationService
|
|
NotificationService().send(...)
|
|
```
|
|
|
|
**Bien (desacoplado via signals):**
|
|
```python
|
|
# 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`:
|
|
|
|
```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:
|
|
|
|
```python
|
|
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)
|
|
|
|
```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
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# ============================================================
|
|
# 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 |
|