# InfraSynth Base — Plan de Arquitectura [![CI](https://github.com/anomalyco/infrasynth-base/actions/workflows/ci.yml/badge.svg)](https://github.com/anomalyco/infrasynth-base/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/anomalyco/infrasynth-base/branch/master/graph/badge.svg)](https://codecov.io/gh/anomalyco/infrasynth-base) [![Python](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) [![Django](https://img.shields.io/badge/django-5.2+-green.svg)](https://www.djangoproject.com/) [![Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) [![Mypy](https://img.shields.io/badge/type%20checked-mypy-2a5075.svg)](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// 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 (`required_permissions` + `require_all`) + AutoPermission - [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` en security y audit, bypass de owner del tenant, rotación de API keys, endpoints `users//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 ### Fase 20 — Configuración multi-tenant (`infrasynth.configs`) y señales reales ✅ _(2026-09-24)_ - [x] App `infrasynth.configs` (`label="infrasynth_configs"`): modelo `ConfigValue` (`GlobalOrTenantModel`: fila global con `tenant IS NULL` + override por tenant), registro tipado `ConfigRegistry`/`ConfigDefinition`/`ConfigType` y registro declarativo `INFRASYNTH_CONFIGS["DEFINITIONS"]` - [x] `ConfigService`: precedencia tenant → global → default, coerción/validación tipada (`string/int/float/bool/decimal/json/choice/duration`, validadores por dotted path), caché por tenant con invalidación en escritura - [x] Secretos con Fernet cifrados en reposo, descifrados al leer y **enmascarados** en API/señales/audit; `INFRASYNTH_AUDIT["EXCLUDED_MODEL_FIELDS"]` evita registrar `ConfigValue.value` - [x] API `/api/v1/configs/` (valores efectivos con `?group=`/`?keys=`, `PUT`/`DELETE` de override, `definitions/`, `PUT global//`) con permisos `configs.manage`/`configs.manage_global` y bypass de owner - [x] Señales públicas `config_changed`/`config_reset` emitidas desde el servicio (secretos enmascarados, `tenant_id` explícito) - [x] Señales antes declaradas y nunca emitidas ahora reales: `features.flag_created/flag_toggled/flag_deleted` + `override_created/override_deleted` (con invalidación de caché), `scheduler.task_completed/task_failed` (transición terminal única), `tenancy.tenant_updated`, `audit.model_changed` - [x] Suite `tests/test_configs/` (registry, services, isolation, signals, views) + tests de señales de features/scheduler/tenancy/audit ### Fase 21 — Gestión automática de permisos ✅ _(2026-09-24)_ - [x] Catálogo `security.Permission` (global): `codename`, `name`, `app_label`, `model`, `action`, `group`, `is_custom`, `is_active`; sincronizado por `manage.py sync_permissions` y en `post_migrate` - [x] Codenames derivados estilo Django `{app_label}.{verb}_{model}` (view/add/change/delete) para **todos** los modelos (kit y consumidor), con denylist de internos - [x] `PermissionRegistry` para permisos custom declarados en el código de la app consumidora, sin tocar el kit - [x] `AutoPermission` + `HybridPermission` derivan y aplican el codename según la acción DRF sin `required_permissions`; modo `AUTO_PERMISSIONS` = `global` (default) / `opt_in` / `off`; bypass de owner/superuser; `action_permissions` para acciones custom - [x] ViewSets base del kit (`InfraSynthModelViewSet`/`InfraSynthReadOnlyModelViewSet`) y migración de los viewsets del kit a enforcement automático - [x] `security.RoleAssignment` (roles múltiples por usuario y tenant) además del M2M global `Role.users`; separación rol global (`platform.roles.manage`) vs rol de tenant (`security.manage_roles`) - [x] `Grant`/`Revoke` global-o-tenant (`tenant IS NULL` = global) con `scope` en la API; precedencia revoke → grant → rol - [x] API de catálogo `GET /api/v1/auth/permissions/` (`?app_label=`/`?group=`) y validación estricta opcional (`STRICT_PERMISSION_VALIDATION`) - [x] Tests `tests/test_security/test_permissions.py` + verificación en dev server (miembro sin rol → 403; asignar rol de tenant → 200) ## 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, RoleAssignment, Permission, APIKey, TwoFactorConfig, ALTCHAChallenge │ │ ├── services.py # AuthorizationService: has_permission(), get_permissions() │ │ ├── permissions.py # HybridPermission, AutoPermission │ │ ├── registry.py # PermissionRegistry (permisos custom) │ │ ├── catalog.py # derivación {app}.{verb}_{model} + sync_permissions() │ │ ├── viewsets.py # InfraSynthModelViewSet (permisos automáticos) │ │ ├── management/commands/ # sync_permissions │ │ ├── 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/ │ │ │ ├── configs/ # Django app: 'infrasynth.configs' │ │ ├── __init__.py │ │ ├── apps.py # ConfigsConfig, registra flag "configs" + DEFINITIONS │ │ ├── registry.py # ConfigType, ConfigDefinition, ConfigRegistry │ │ ├── models.py # ConfigValue (global default + tenant override) │ │ ├── services.py # ConfigService: get/set/set_global/reset, secrets, caché │ │ ├── serializers.py │ │ ├── views.py │ │ ├── urls.py │ │ ├── signals.py # config_changed, config_reset (públicas) │ │ └── 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_configs/ └── 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: tenant_id, model_label, object_id, action, changes, actor, request_id 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//` | GET | `audit.view_model_changes` | Detalle de un cambio | | `/audit/api-logs/` | GET | `audit.view_api_logs` | Listar interacciones API | | `/audit/api-logs//` | 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//` | GET, DELETE | `security.manage_api_keys` | Detalle/elimina API key | | `/auth/api-keys//rotate/` | POST | `security.manage_api_keys` | Rota API key (invalida anterior) | | `/security/roles/` | GET, POST | `security.manage_roles` | CRUD roles | | `/security/roles//` | GET, PUT, DELETE | `security.manage_roles` | Detalle/actualiza/elimina rol | | `/security/grants/` | GET, POST | `security.manage_grants` | Lista/crea grants | | `/security/grants//` | DELETE | `security.manage_grants` | Revoca grant | | `/security/revokes/` | GET, POST | `security.manage_grants` | Lista/crea revokes | | `/security/revokes//` | DELETE | `security.manage_grants` | Elimina revoke | | `/security/users//permissions/` | GET | `security.view_permissions` | Permisos efectivos del usuario | | `/security/users//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): """Authenticated, then permission-checked (owner/superuser bypass).""" def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False evaluate_gates(request, view) # los gates aplican a todos if request.user.is_superuser or is_tenant_owner(request.user): return True required = getattr(view, "required_permissions", []) or [] if not required: return evaluate_auto_permission(request, view) # derivado del modelo authz = AuthorizationService() if getattr(view, "require_all", False): # all-of; por defecto any-of return authz.has_all_permissions(request.user, required) return authz.has_any_permission(request.user, required) # Alias de compatibilidad: el nombre idiomático para views del kit. IsAuthenticatedAndPermitted = HybridPermission ``` `required_permissions` es **any-of**; añade `require_all = True` en la vista para exigir **all-of**. No hay una clase factory aparte. #### 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 HybridPermission from infrasynth.security.services import AuthorizationService class TicketViewSet(ModelViewSet): permission_classes = [HybridPermission] required_permissions = ["helpdesk.manage_tickets"] # any-of; require_all=True para all-of 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 `` Componente declarativo que envuelve elementos UI y los muestra solo si el usuario cumple el permiso requerido. ``` ``` 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 `` 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//` | GET | IsAuthenticated | Metadatos del archivo | | `/files//download/` | GET | IsAuthenticated | Descargar archivo (signed URL o proxy) | | `/files//` | DELETE | IsAuthenticated | Borrado lógico | | `/files/categories/` | GET, POST | `files.manage_categories` | CRUD categorías | | `/files/categories//` | 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//` | 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//` | 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//") 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//` | GET, PUT, DELETE | `webhooks.manage_outbound` | Detalle endpoint | | `/webhooks/outbound/subscriptions/` | GET, POST | `webhooks.manage_outbound` | CRUD suscripciones | | `/webhooks/outbound/subscriptions//` | GET, PUT, DELETE | `webhooks.manage_outbound` | Detalle suscripción | | `/webhooks/outbound/deliveries/` | GET | `webhooks.view_outbound` | Historial de entregas | | `/webhooks/outbound/deliveries//retry/` | POST | `webhooks.manage_outbound` | Reintentar entrega | | `/webhooks/inbound/endpoints/` | GET, POST | `webhooks.manage_inbound` | CRUD endpoints inbound | | `/webhooks/inbound/endpoints//` | GET, PUT, DELETE | `webhooks.manage_inbound` | Detalle endpoint | | `/webhooks/inbound/events/` | GET | `webhooks.view_inbound` | Historial de eventos recibidos | | `/webhooks/inbound/receive//` | 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//` | GET, PUT, DELETE | `workflows.manage_definitions` | Detalle workflow | | `/workflows/definitions//nodes/` | GET, POST | `workflows.manage_definitions` | CRUD nodos | | `/workflows/definitions//nodes//` | GET, PUT, DELETE | `workflows.manage_definitions` | Detalle nodo | | `/workflows/definitions//transitions/` | GET, POST | `workflows.manage_definitions` | CRUD transiciones | | `/workflows/instances/` | GET, POST | IsAuthenticated | Listar/crear instancias | | `/workflows/instances//` | GET | IsAuthenticated | Detalle con ruta + estados | | `/workflows/instances//submit/` | POST | IsAuthenticated | Procesar decisión | | `/workflows/instances//assign/` | POST | IsAuthenticated | Asignar responsables | | `/workflows/instances//observers/` | POST, DELETE | IsAuthenticated | Gestionar observadores | | `/workflows/instances//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: tenant_id, task_name, task_id, duration_ms, result task_failed = Signal() # kwargs: tenant_id, 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//` | GET, PUT, DELETE | `scheduler.manage_tasks` | Detalle tarea | | `/scheduler/tasks//run/` | POST | `scheduler.manage_tasks` | Ejecución manual inmediata | | `/scheduler/tasks//toggle/` | POST | `scheduler.manage_tasks` | Activar/desactivar | | `/scheduler/executions/` | GET | `scheduler.view_executions` | Historial de ejecuciones | | `/scheduler/executions//` | 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//` | GET, PUT, DELETE | `features.manage_flags` | Detalle flag | | `/features//overrides/` | GET, POST | `features.manage_flags` | CRUD overrides por usuario/grupo | | `/features//overrides//` | 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//` | GET | IsAuthenticated | Verificar un flag específico | #### Señales ```python flag_created = Signal() # kwargs: tenant_id, flag_slug, is_active, actor_id flag_toggled = Signal() # kwargs: tenant_id, flag_slug, is_active, previous_is_active, actor_id flag_deleted = Signal() # kwargs: tenant_id, flag_slug, actor_id override_created = Signal() # kwargs: tenant_id, flag_slug, user_id, is_enabled, actor_id override_deleted = Signal() # kwargs: tenant_id, flag_slug, user_id, actor_id ``` #### 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//` | GET | None | Detalle plan | | `/billing/entitlements/` | GET | IsAuthenticated | Entitlements del tenant actual (todas las apps) | | `/billing/entitlements//` | 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//` | GET | IsAuthenticated | Detalle suscripción | | `/billing/subscriptions//cancel/` | POST | IsAuthenticated | Cancelar suscripción | | `/billing/subscribe//` | POST | IsAuthenticated | Crear checkout (retorna redirect URL) | | `/billing/invoices/` | GET | IsAuthenticated | Facturas del usuario | | `/billing/invoices//` | GET | IsAuthenticated | Detalle factura | | `/billing/invoices//download/` | GET | IsAuthenticated | Descargar PDF | | `/billing/webhook//` | 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//` | GET, PUT | `tenancy.manage_tenant` | Detalle/edición del tenant | | `/tenancy/tenants//members/` | GET, POST | `tenancy.manage_members` | Listar/invitar miembros | | `/tenancy/tenants//members//` | 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 ``` --- ### 2.12 `infrasynth.configs` — Configuración Multi-tenant **Feature flag:** `configs` (default: True) **Dependencias:** `infrasynth.shared`, `infrasynth.tenancy` (mixin `GlobalOrTenantModel`) **Propósito:** almacén genérico, tipado y multi-tenant de preferencias escalares/JSON (branding, límites, integraciones). No es un toggle operativo (`features`) ni un derecho comercial (`billing`): es la configuración arbitraria de cada tenant. #### Modelo ```python class ConfigValue(GlobalOrTenantModel): """tenant IS NULL = default de plataforma; no nulo = override del tenant.""" key = models.CharField(max_length=200, db_index=True) value = models.JSONField(default=dict) # token Fernet si el definition es secreto updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = "configs_value" constraints = [ models.UniqueConstraint(fields=["tenant", "key"], name="uniq_config_key_per_tenant"), models.UniqueConstraint(fields=["key"], condition=Q(tenant__isnull=True), name="uniq_global_config_key"), ] indexes = [models.Index(fields=["tenant_id", "key"])] ``` #### `registry.py` — Definiciones tipadas ```python class ConfigType(StrEnum): STRING = "string"; INT = "int"; FLOAT = "float"; BOOL = "bool" DECIMAL = "decimal"; JSON = "json"; CHOICE = "choice"; DURATION = "duration" # int segundos @dataclass(frozen=True) class ConfigDefinition: key: str; type: ConfigType = ConfigType.JSON; default: Any = None choices: tuple = (); is_secret: bool = False label: str = ""; group: str = ""; description: str = "" validator: str | None = None # dotted path; callable(value) -> None | raises class ConfigRegistry: register(key, *, type=ConfigType.JSON, default=None, choices=(), is_secret=False, label="", group="", description="", validator=None) -> ConfigDefinition get(key) -> ConfigDefinition | None all() -> dict[str, ConfigDefinition] clear() -> None # tests coerce(definition, value) -> Any # falla con ValidationAppError(VALIDATION_CONFIG_INVALID) ``` Reglas de `coerce`: `INT`/`FLOAT` rechazan `bool`; `DECIMAL` usa `Decimal(str(value))`; `BOOL` sólo acepta `bool` real; `JSON` debe ser serializable; `CHOICE` valida pertenencia; `DURATION` acepta segundos enteros o `"30s"/"5m"/"2h"/"1d"`. Un `validator` por dotted path se ejecuta tras la coerción y cualquier fallo se normaliza a `ValidationAppError`. Registro: los consumidores llaman `ConfigRegistry.register(...)` en su `apps.py:ready()`; `ConfigsConfig.ready()` además registra `INFRASYNTH_CONFIGS["DEFINITIONS"]` (si `AUTO_REGISTER_FROM_SETTINGS`). #### `services.py` — `ConfigService` ```python class ConfigService: def get(self, key, *, tenant=None, default=_UNSET) -> Any: ... def get_many(self, keys: list[str], *, tenant=None) -> dict[str, Any]: ... def get_all(self, *, tenant=None, group: str | None = None) -> dict[str, Any]: ... def get_metadata(self, key, *, tenant=None) -> dict: ... def set(self, key, value, *, tenant, user=None) -> ConfigValue: ... def set_global(self, key, value, *, user=None) -> ConfigValue: ... def reset(self, key, *, tenant) -> bool: ... def is_overridden(self, key, *, tenant) -> bool: ... def invalidate(self, tenant, key: str | None = None) -> None: ... ``` - **Precedencia:** fila del tenant → fila global → default del registry. Clave desconocida sin fila ni default ⇒ `NotFoundError` (nunca `None` silencioso). - **Fail closed:** sin tenant sólo se lee la fila global y el default; nunca una fila de otro tenant. `tenant=None` cae a `get_current_tenant()`. - **Tipado/secretos:** los secretos se descifran al leer y nunca se devuelve cifrado. - **Caché:** alias `INFRASYNTH_CONFIGS["CACHE_BACKEND"]`, prefijo `CACHE_KEY_PREFIX`, TTL `CACHE_TTL_SECONDS`. Claves `f"{prefix}:tenant:{pk}:configs:{key}"` y `f"{prefix}:tenant:global:configs:{key}"`. Escrituras y `reset` invalidan. - **Escrituras:** `transaction.atomic` + `update_or_create` sobre `all_objects`, `invalidate`, y emisión de señales. `set_global` requiere `ALLOW_GLOBAL_WRITES` (si no, `AuthError`). #### Señales públicas (`signals.py`) ```python config_changed = Signal() # tenant_id, key, scope ("tenant"|"global"), old_value, new_value, actor_id config_reset = Signal() # tenant_id, key, scope, previous_value, actor_id ``` Emitidas **desde el servicio** (no por un receiver) con `tenant_id` explícito; en escrituras globales `tenant_id=None`. Los secretos se enmascaran (`None`) en ambos signals. Los consumidores conectan en `apps.py:ready()`. #### API Endpoints (`/api/v1/configs/`) | Endpoint | Método | Permiso | Descripción | |---|---|---|---| | `/configs/` | GET | autenticado | Valores efectivos (secretos enmascarados); `?group=`, `?keys=a,b` | | `/configs//` | GET | autenticado | Valor efectivo + metadata (`type`, `is_secret`, `is_overridden`, `default`) | | `/configs//` | PUT | `configs.manage` | Set del override del tenant (coercido/validado) | | `/configs//` | DELETE | `configs.manage` | Reset del override al global/default | | `/configs/definitions/` | GET | autenticado | Esquema de claves registradas (para formularios) | | `/configs/global//` | PUT | `configs.manage_global` | Set del default de plataforma (si `ALLOW_GLOBAL_WRITES`) | Clave desconocida ⇒ `404`; valor inválido ⇒ `400 VALIDATION_CONFIG_INVALID`. No se acepta `tenant` en el body: siempre se usa `get_current_tenant()`. `GET /configs/` devuelve un objeto acotado `{"values": [...]}` (excepción documentada a la paginación de colecciones de `API-STANDARD.md` §6; se usa una lista para que el camelCase no deforme las claves con puntos). #### Configuración Externalizable ```python INFRASYNTH_CONFIGS = { "CACHE_BACKEND": "default", "CACHE_KEY_PREFIX": "configs", "CACHE_TTL_SECONDS": 60, "AUTO_REGISTER_FROM_SETTINGS": True, "DEFINITIONS": {}, # {"branding.primary_color": {"type": "string", "default": "#1a3a5c"}} "ALLOW_GLOBAL_WRITES": True, # False en despliegues sólo-tenant } INFRASYNTH_AUDIT["EXCLUDED_MODEL_FIELDS"] = {"infrasynth_configs.ConfigValue": ["value"]} ``` #### Patrón de Integración para App B ```python # helpdesk/apps.py → ready() from infrasynth.configs import ConfigRegistry, config_changed ConfigRegistry.register("helpdesk.sla_hours", type="int", default=24, group="helpdesk") def _refresh_sla(sender, tenant_id, key, **kwargs): if key == "helpdesk.sla_hours": invalidate_sla_cache(tenant_id) config_changed.connect(_refresh_sla, dispatch_uid="helpdesk.sla") # helpdesk/services.py from infrasynth.configs import ConfigService sla_hours = ConfigService().get("helpdesk.sla_hours") # override del tenant → global → default ``` --- ### 2.13 `infrasynth.security` — Gestión Automática de Permisos **Propósito:** que cualquier sistema construido sobre el kit tenga permisos derivados de sus modelos, permisos custom declarados en su propio código, roles/usuarios asignables por UI y enforcement automático sin tocar vistas ni el kit. #### Catálogo (`models.Permission`) ```python class Permission(models.Model): # db_table = "security_permission" (global, no tenant-owned) codename: str # "helpdesk.delete_ticket" name: str # "Can delete ticket" app_label: str; model: str; action: str; group: str description: str; is_custom: bool; is_active: bool ``` `manage.py sync_permissions` (y `post_migrate`) hace upsert desde: 1. **Derivación de modelos** — todo modelo concreto aporta `{app_label}.{verb}_{model}` para `view/add/change/delete` (denylist de internos: sesiones, admin, contenttypes, celery, token_blacklist, logs de audit, el propio catálogo). 2. **`PermissionRegistry.register(...)`** — permisos custom declarados en `apps.py:ready()` de la app consumidora. Los permisos que dejan de existir se marcan `is_active=False` (nunca se borran) para preservar asignaciones. #### Enforcement automático (`permissions.AutoPermission`) ```python class TicketViewSet(InfraSynthModelViewSet): # infrasynth.security.viewsets queryset = Ticket.objects.all() action_permissions = {"resolve": "helpdesk.resolve_ticket"} # opcional ``` Prioridad: `required_permissions` (explícito; **any-of** por defecto, `require_all = True` para all-of) → `action_permissions[action]` → derivado `{app}.{verb}_{model}` → `[]` (abstiene). Modos: `INFRASYNTH_SECURITY["AUTO_PERMISSIONS"]` = `"global"` (default; también en `DEFAULT_PERMISSION_CLASSES`, abstiene en APIViews sin modelo), `"opt_in"` (solo `auto_permissions=True`), `"off"`. Owner del tenant y superuser hacen bypass. Los viewsets del kit usan `HybridPermission` (alias `IsAuthenticatedAndPermitted`), que integra la evaluación automática. #### Roles y overrides - **Roles globales** (`Role.tenant_id IS NULL`): se asignan por el M2M `Role.users`, aplican en todos los tenants; requieren `platform.roles.manage` para crear/editar. - **Roles de tenant**: se asignan por `RoleAssignment(tenant, user, role)` (varios roles por usuario y tenant); requieren `security.manage_roles`. - **`Grant`/`Revoke`** son global-o-tenant (`tenant IS NULL` = global). Al crear sin `scope` se fijan al tenant actual; `{"scope": "global"}` (requiere `platform.roles.manage`) crea el override global. Precedencia: revoke → grant → roles → default. #### API | Endpoint | Método | Permiso | Descripción | |---|---|---|---| | `/auth/permissions/` | GET | `security.view_permissions` | Catálogo (`?app_label=`, `?group=`) | | `/auth/roles/` | POST/PUT/DELETE | `security.manage_roles` (tenant) / `platform.roles.manage` (global) | Roles | | `/auth/users//roles/` | GET/PUT | `security.manage_roles` | Asignar roles globales y de tenant | | `/auth/grants/`, `/auth/revokes/` | POST/GET/DELETE | `security.manage_grants` (+ `platform.roles.manage` para `scope=global`) | Overrides por usuario | #### Configuración Externalizable ```python INFRASYNTH_SECURITY = { "AUTO_PERMISSIONS": "global", # "global" | "opt_in" | "off" "STRICT_PERMISSION_VALIDATION": False, # rechazar codenames desconocidos al escribir roles "PERMISSION_EXCLUDE_MODELS": [], # labels extra a excluir de la derivación "PERMISSION_APPS": None, # allowlist de app_labels (None = todas) } ``` #### Patrón de Integración para App B ```python # helpdesk/apps.py → ready() from infrasynth.security.registry import PermissionRegistry PermissionRegistry.register("helpdesk.export_ticket", name="Export tickets", group="Helpdesk") # helpdesk/views.py from infrasynth.security.viewsets import InfraSynthModelViewSet class TicketViewSet(InfraSynthModelViewSet): queryset = Ticket.objects.all() serializer_class = TicketSerializer # view/add/change/delete_ticket automáticos ``` --- ## 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"), } INFRASYNTH_CONFIGS = { "CACHE_TTL_SECONDS": 60, "ALLOW_GLOBAL_WRITES": True, "DEFINITIONS": { "branding.primary_color": {"type": "string", "default": "#1a3a5c", "group": "branding"}, }, } # =================================================================== # 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 HybridPermission from infrasynth.features.services import FeatureService class TicketViewSet(ModelViewSet): permission_classes = [HybridPermission] required_permissions = ["helpdesk.manage_tickets"] # any-of; require_all=True para all-of 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/revokes, catálogo de permisos y permisos automáticos por modelo, 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/configs/` | Django app: configuración tipada multi-tenant (`ConfigValue`, `ConfigRegistry`, `ConfigService`), secretos cifrados, señales `config_changed`/`config_reset` | | `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 |