- infrasynth.configs: typed multi-tenant config store (registry, service, secrets, cache) + public config_changed/config_reset signals and API - emit the declared-but-dead signals (features flags/overrides, scheduler task_completed/task_failed, tenancy tenant_updated, audit model_changed) and per-model audit field exclusions - security: permission catalog (security_permission), Django-style model-derived AutoPermission, PermissionRegistry, RoleAssignment, global-or-tenant Grant/Revoke, catalog API - consolidate the permission surface: PermissionRegistry only (drop the settings dict), IsAuthenticatedAndPermitted aliases HybridPermission, require_permission replaced by required_permissions + require_all - packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
158 KiB
InfraSynth Base — Plan de Arquitectura
0. Visión General
InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infraestructura común para cualquier sistema de negocio. Se instala como un solo paquete pip (infrasynth-base), se configura desde settings.py, y cada app puede habilitarse/deshabilitarse dinámicamente vía feature flags.
Es el único kit compartido. Cada app desplegada (Messenger, Invoicer, y las futuras) depende de este paquete y no reimplementa nada de lo que aquí vive. La arquitectura anterior de "cuatro paquetes pequeños" (infrasynth-auth, infrasynth-license-sdk, infrasynth-update-client, infrasynth-api-conventions) está retirada: auth → infrasynth.security, api-conventions → la capa API de este kit, y license-sdk/update-client se eliminan (ver abajo).
Cada app es multi-tenant. Un solo despliegue por app sirve a todos los clientes; cada cliente es un tenant (workspace). El aislamiento es a nivel de fila con tenant_id sobre un único esquema compartido. Ver ../TENANCY.md (fuente de verdad) — este paquete provee la app infrasynth.tenancy que lo implementa.
No hay servidor de licencias. Todo corre en nuestra propia infraestructura, así que no hay claves firmadas, ni phone-home, ni SDK offline, ni grace period de validación. Lo que un tenant puede usar es un entitlement (derecho comercial) verificado en proceso por infrasynth.billing. Ver ../ENTITLEMENTS.md. El despliegue es por CI/CD propio, sin supervisor ni banner de actualización (../DEPLOYMENT.md).
Principio rector: Una app externa (App B) nunca debe modificar el código fuente de InfraSynth para integrarse. Toda integración ocurre vía settings, registries, signals, ABCs swappables, o feature flags.
IMPORTANTE: Este plan debe actualizarse cada vez que una fase avanza. Marcar fases como
✅(completada),🔄(en progreso), o⬜(pendiente) con la fecha del cambio.
0.5 Estado de Implementación
Fase 1 — Scaffolding del Proyecto ✅ (2026-07-30)
pyproject.toml+ docker-compose.yml + manage.pyconfig/Django project (settings split: base/dev/test, celery, wsgi, urls)- Skeleton for all 10 modules (models, serializers, views, filters, urls, signals, apps)
pip install -e ".[dev]"+python manage.py check✅ (2026-07-30)
Fase 2 — Foundation (infrasynth/shared/) ✅ (2026-07-30)
- All 5 modules implemented (protocols, enums, results, crypto, settings_utils)
- Tests: results, crypto, enums, settings_utils, protocols ✅ (2026-07-30)
Fase 3 — Feature Flags (infrasynth/features/) ✅ (2026-07-30)
models.py— FeatureFlag + FeatureFlagOverrideregistry.py— FeatureRegistry (register/get_all)services.py— FeatureService (is_enabled, get_active_flags, caching, overrides)decorators.py— @feature_requiredviews.py— CRUD + /active/ + /check// endpoints- Tests: services, views, decorators, registry ✅ (2026-07-30)
Fase 4 — Auditoría (infrasynth/audit/) ✅ (2026-07-30)
receivers.py— post_save/post_delete tracking, excluded_models, excluded_fields, diffmiddleware.py— body capture, sensitive key filtering, request_idviews.py— list/retrieve endpoints for all 3 models- Tests: model save/delete creates logs, middleware logs API calls ✅ (2026-07-30)
- Fixes: filterset_class wired into views, ordering added, SystemUser actor guard in middleware
Fase 5 — Seguridad: Auth (infrasynth/security/auth/) ✅ (2026-07-30)
auth/cookies.py— CookieJWTAuthentication (encrypt/decrypt tokens via Fernet)auth/api_keys.py— APIKeyAuthentication (prefix.secret, PBKDF2, scopes, SystemUser)auth/backends.py— EmailOrUsernameBackendauth/middleware.py— JWTAuthenticationMiddlewareviews.py— login/logout/refresh/check endpoints- Tests: all auth flows ✅ (2026-07-30)
- Fixes: AUTHENTICATION_BACKENDS wired from INFRASYNTH_SECURITY,
import secretsadded, APIKeySerializer create/update + real key exposure
- Fixes: AUTHENTICATION_BACKENDS wired from INFRASYNTH_SECURITY,
Fase 6 — Seguridad: Autorización (infrasynth/security/) ✅ (2026-07-30)
services.py— AuthorizationService (has_permission, get_effective_permissions, chain)permissions.py— HybridPermission (required_permissions+require_all) + AutoPermission- Views: roles, grants, revokes CRUD
- Tests: permission resolution chain (superuser → revoke → grant → role → default) ✅ (2026-07-30)
- Fixes: Role.users M2M added (related_name="roles"), SystemUser scopes as permissions, RoleViewSet lookup by slug, PermissionDenied instead of PermissionError
Fase 7 — Seguridad: 2FA y ALTCHA ✅ (2026-07-30)
two_factor/services.py— TOTPService (generate_secret, verify, QR), RecoveryCodeServicetwo_factor/middleware.py— enforce 2FA for configured usersaltcha/services.py— create_challenge, verify PoW- Views: setup, verify-setup, verify, disable, recovery, challenge, verify
- Tests: 2FA flow + ALTCHA flow ✅ (2026-07-30)
- Fixes: session cleanup uses pop() (no KeyError on missing pre-auth token)
Fase 8 — Almacenamiento (infrasynth/files/) ✅ (2026-07-30)
- Models: StoredFile, FileCategory, ProcessingPipeline, PipelineExecution
- Views: StoredFileViewSet, FileCategoryViewSet, ProcessingPipelineViewSet
storage.py— Storage router: S3, local, GCS, cloudinary backends implementados ✅ (2026-07-30)services.py— FileService: upload, get_signed_url, get_download_response, delete (soft/hard), get_file_info ✅ (2026-07-30)processing.py— PipelineExecutor (resize/optimize/watermark/scan) + Celery task run_pipeline_execution ✅ (2026-07-30)- Views: download action wired to FileService + feature flag gates en los 3 viewsets ✅ (2026-07-30)
- Tests: upload retrieves file, download returns response, delete marks removed ✅ (2026-07-30)
Fase 9 — Notificaciones (infrasynth/notifications/) ✅ (2026-07-30)
- Models: NotificationTemplate, NotificationDispatch, ChannelConfig
channels/base.py— BaseChannel ABC + Attachmentresolvers.py— VariableResolverRegistry (register/resolve/get_available_variables)services.py— NotificationService.send() con template rendering + failover + tasks Celery (sync/celery/thread) ✅ (2026-07-30)channels/— SMTPChannel, SendGridChannel, TwilioSMSChannel, TelegramChannel ✅ (2026-07-30)- Tests: template render, dispatch creates log, failover works ✅ (2026-07-30)
Fase 10 — Webhooks (infrasynth/webhooks/) ✅ (2026-07-30)
registry.py— EventRegistry (register/emit with OutboundSubscription lookup + Celery dispatch)signature.py— HMAC sign_payload / verify_signatureinbound/handlers.py— BaseInboundHandler ABC- Views: outbound endpoints, subscriptions, deliveries, inbound endpoints/events, receive
dispatch.py— deliver_webhook Celery task: HTTP POST, HMAC signature, payload template, retry/backoff, signals ✅ (2026-07-30)- Tests: emit notifies subscribers, inbound signature verification ✅ (2026-07-30)
- Fix: payload template rendering usa
Contextexplícito (compat Django 5.2+)
- Fix: payload template rendering usa
Fase 11 — Flujos de Trabajo (infrasynth/workflows/) ✅ (2026-07-30)
- Models: Workflow, WorkflowNode, Transition, WorkflowInstance, NodeAssignment, WorkflowObserver, WorkflowAwareModel
validators.py— DataValidatorProtocol + DataValidatorRegistryengine.py— WorkflowEngine: start(), submit_decision(), get_route(), get_node_states(), get_role_in_instance(), assign_users(), add_observer() ✅ (2026-07-30)- Views: route/state actions + submit/assign/observers wired al engine ✅ (2026-07-30)
- Tests: start workflow, approve/reject advances, route tracking ✅ (2026-07-30)
Fase 12 — Scheduler (infrasynth/scheduler/) ✅ (2026-07-30)
- Models: ScheduledTask, TaskExecution
- Views: tasks CRUD, executions, queue-status, workers
services.py— TaskService: run_now (Celery + plain functions), toggle, get_queue_status, get_workers ✅ (2026-07-30)- Tests: run_now triggers Celery, toggle enables/disables ✅ (2026-07-30)
Fase 13 — Facturación (infrasynth/billing/) ✅ (2026-07-30)
- Models: PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction (extendido en Fase 17: App, Plan, Entitlement)
gateways/base.py— BasePaymentGateway ABC + CheckoutSessionResult, WebhookResult- Views: gateways, plans, subscriptions, subscribe, invoices, webhook receive
services.py— BillingService: create_checkout_session, create_subscription, cancel_subscription, sync_subscription, generate_invoice ✅ (2026-07-30)invoice_generator.py— generate_invoice_pdf Celery task con reportlab Platypus + almacenamiento via FileService ✅ (2026-07-30)- Views: subscribe + webhook receive wired a BillingService/BCG ✅ (2026-07-30)
gateways/— StripeGateway, MercadoPagoGateway, WompiGateway ✅ (2026-07-30)- Tests: create subscription, webhook handling, invoice generation ✅ (2026-07-30)
- Fix: signal
subscription_createdtolera gateway None;StripeGatewayusadatetime.timezone.utc(compat Django 5.2+)
- Fix: signal
Fase 14 — Tests de Integración ✅ (2026-07-31)
- Cross-app signals: billing → notifications, webhooks → audit
- Registries: FeatureRegistry, EventRegistry, VariableResolverRegistry, DataValidatorRegistry
- E2E: login → feature flags → permission check → webhook emit → audit log
- Coverage 93% (target: ≥80%)
Fase 15 — Linting, Type Checking y CI ✅ (2026-07-31)
- ruff check . sin errores
- mypy infrasynth/ sin errores
- pre-commit hooks configurados
- GitHub Actions: tests + lint + typecheck + docker build
- Dockerfile multi-stage + docker-compose con health checks
- Badges (CI, coverage, python, django, ruff, mypy) en PLAN.md
- Docker push (pendiente de registry config)
Fase 16 — Multi-tenancy ✅ (2026-09-24)
infrasynth.tenancy— Tenant, TenantMembership, TenantInvitation, PlatformStaff, TenantManager/AllObjectsManager/GlobalOrTenantManager, TenantMiddleware,current_tenantContextVar,INFRASYNTH_TENANCYtenant_id+TenantManageren todos los modelos tenant-owned (audit, security, files, notifications, webhooks, workflows, scheduler, billing) víaTenantOwnedModel/GlobalOrTenantModel- Restricciones compuestas
(tenant, …)e índices que empiezan contenant_id - Propagación explícita de
tenant_ida Celery tasks y signals; claves de caché/rate-limit con prefijotenant:{id}: TenantProtocolreal (UUID no-nulo, ya no stub)- Suite de tests de aislamiento (tenant A no puede leer/escribir/borrar datos de tenant B; acceso cross-tenant → 404)
Fase 17 — Entitlements y facturación multi-tenant ✅ (2026-09-24)
billing— modelosApp,Plan(one_time/subscription),Entitlement;tenanten Subscription/Invoice/PaymentTransaction; dinero en unidades menores (BigInteger)EntitlementService(is_entitled,check_limit) con caché por tenant e invalidación en mutaciones- Ciclo de vida
past_due→grace→suspendeda nivel tenant (reinstatement al pagar) - Códigos de error
ENTITLEMENT_*en la capa de excepciones (infrasynth.shared.exceptions) - Gate = tenant activo AND entitled AND feature flag (los flags siguen siendo toggles operativos)
Fase 18 — Capa API (infrasynth.api) y estándar ✅ (2026-09-24)
renderers.py— EnvelopeJSONRenderer (envelope + camelCase),meta.requestId/timestamp/tenantId/paginationpagination.py— CursorPagination (pageSize,nextCursor/prevCursor)exceptions.py— envelope_exception_handler + códigos namespaced (shared.exceptions)middleware.py— RequestIdMiddleware + RateLimitHeadersMiddlewareidempotency.py(Idempotency-Key),throttling.py(tenant-scoped),webhooks.py(replay window),schema.py(drf-spectacular)- Prefijo de versión
/api/v1/; login multi-workspace (select/switch) con claimtenanten el JWT; API keys tenant-scoped
Fase 19 — Endurecimiento a producción ✅ (2026-09-24)
- Webhooks entrantes verificados: HMAC /
BaseInboundHandler.verify, límite de tamaño, tolerancia de timestamp, idempotencia porexternal_id, handlerprocess()ejecutado yis_verified/is_processedpersistidos - 2FA real en login (pre-auth session + cookie, tokens sólo tras verificar) y
TwoFactorMiddlewarepara sesión - Permisos cableados:
HybridPermissionen security y audit, bypass de owner del tenant, rotación de API keys, endpointsusers/<id>/permissionsy/roles - Webhooks de pago procesados e idempotentes (suscripción/entitlement/invoice/
PaymentTransaction), replay protegido, firma MercadoPago - Ciclo de vida de entitlements programado (sync, past_due→grace→suspended, expiración, facturas de renovación)
- Reintentos de notificaciones + rate limit por canal + retención de logs; captura automática de diffs de update en audit + retención
- Feature rollout (%) y targeting por entorno; registración de flags desde settings
- Login brute-force guard, política de contraseñas, ALTCHA con PoW real; límite global de subida, virus scanner pluggable, toggle de pipelines
- Guardas de workflow (
MAX_INSTANCES_PER_WORKFLOW,ROUTE_MAX_DEPTH,ALLOW_SELF_ASSIGNMENT,AUTO_CLONE_ASSIGNEES_ON_REENTRY) - Throttling tenant-scoped por defecto;
CELERY_BEAT_SCHEDULEcon trabajos periódicos - README + CHANGELOG; CI con
ruff format --checky umbral de cobertura infrasynth.gates— gates por endpoint (TwoFactorGate,AltchaGate,EntitlementGate,FeatureGate,PermissionGate) declarables sin tocar el kit;GatePermissionpor defecto; claim2faen el JWT
Fase 20 — Configuración multi-tenant (infrasynth.configs) y señales reales ✅ (2026-09-24)
- App
infrasynth.configs(label="infrasynth_configs"): modeloConfigValue(GlobalOrTenantModel: fila global contenant IS NULL+ override por tenant), registro tipadoConfigRegistry/ConfigDefinition/ConfigTypey registro declarativoINFRASYNTH_CONFIGS["DEFINITIONS"] 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- Secretos con Fernet cifrados en reposo, descifrados al leer y enmascarados en API/señales/audit;
INFRASYNTH_AUDIT["EXCLUDED_MODEL_FIELDS"]evita registrarConfigValue.value - API
/api/v1/configs/(valores efectivos con?group=/?keys=,PUT/DELETEde override,definitions/,PUT global/<key>/) con permisosconfigs.manage/configs.manage_globaly bypass de owner - Señales públicas
config_changed/config_resetemitidas desde el servicio (secretos enmascarados,tenant_idexplícito) - 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 - 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)
- Catálogo
security.Permission(global):codename,name,app_label,model,action,group,is_custom,is_active; sincronizado pormanage.py sync_permissionsy enpost_migrate - Codenames derivados estilo Django
{app_label}.{verb}_{model}(view/add/change/delete) para todos los modelos (kit y consumidor), con denylist de internos PermissionRegistrypara permisos custom declarados en el código de la app consumidora, sin tocar el kitAutoPermission+HybridPermissionderivan y aplican el codename según la acción DRF sinrequired_permissions; modoAUTO_PERMISSIONS=global(default) /opt_in/off; bypass de owner/superuser;action_permissionspara acciones custom- ViewSets base del kit (
InfraSynthModelViewSet/InfraSynthReadOnlyModelViewSet) y migración de los viewsets del kit a enforcement automático security.RoleAssignment(roles múltiples por usuario y tenant) además del M2M globalRole.users; separación rol global (platform.roles.manage) vs rol de tenant (security.manage_roles)Grant/Revokeglobal-o-tenant (tenant IS NULL= global) conscopeen la API; precedencia revoke → grant → rol- API de catálogo
GET /api/v1/auth/permissions/(?app_label=/?group=) y validación estricta opcional (STRICT_PERMISSION_VALIDATION) - 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 defectoTenantManagery un escape hatchall_objects. Toda query de datos de tenant pasa por el manager scopeado. No hay excepción, y "me acordaré de filtrar" no es un diseño.
# Patrón que TODO modelo tenant-owned sigue (se omite en los bloques de abajo por brevedad,
# salvo donde el scoping no es obvio):
class CualquierModeloDeTenant(models.Model):
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+")
# ...
objects = TenantManager() # por defecto — SIEMPRE scopeado al tenant actual
all_objects = AllObjectsManager() # sin scope — solo migraciones, admin y platform staff
- Fail closed: sin contexto de tenant, el manager scopeado devuelve queryset vacío. Un query que "funciona" sin tenant es un bug.
- Acceso cross-tenant →
404, nunca403(un403confirma que el objeto existe: fuga de información). - Unicidad por tenant: todo lo que era único global pasa a
unique_together = (tenant, campo)(oUniqueConstraintcontenantprimero). Índices empiezan contenant_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_idexplícito; las claves se prefijantenant:{id}:(../TENANCY.md§7). unsafe_all()nunca se llama desde una vista.
Scoping de cada modelo del kit:
| App | Modelo | Scoping |
|---|---|---|
shared |
— | Zero-Django, no aplica |
api |
— | Capa DRF, no tiene modelos |
tenancy |
Tenant, TenantMembership |
Definen el scoping (no se auto-scopean) |
audit |
ModelChangeLog, APIInteractionLog, SecurityEvent |
Tenant-owned (tenant_id nulo solo para acciones de plataforma) |
security |
Role |
Global (tenant_id = NULL = rol de sistema) + override por tenant |
security |
Grant, Revoke, APIKey |
Tenant-owned |
security |
TwoFactorConfig |
Global por usuario (el usuario es global) |
security |
ALTCHAChallenge |
Global (efímero, anti-spam) |
files |
StoredFile, FileCategory, PipelineExecution |
Tenant-owned |
files |
ProcessingPipeline |
Global + override por tenant |
notifications |
NotificationTemplate |
Global + override por tenant |
notifications |
NotificationDispatch, ChannelConfig |
Tenant-owned |
webhooks |
OutboundEndpoint, OutboundSubscription, OutboundDelivery, InboundEndpoint, InboundEvent |
Tenant-owned (InboundEndpoint resuelve el tenant por slug + secreto) |
workflows |
Workflow, WorkflowNode, Transition, WorkflowInstance, NodeAssignment, WorkflowObserver |
Tenant-owned |
scheduler |
ScheduledTask, TaskExecution |
Tenant-owned |
features |
FeatureFlag |
Global (tenant_id = NULL) + override por tenant |
features |
FeatureFlagOverride |
Tenant-owned |
billing |
PaymentGateway |
Global (cuentas de la plataforma) |
billing |
App, Plan |
Global (catálogo) |
billing |
Entitlement, Subscription, Invoice, PaymentTransaction |
Tenant-owned |
Usuarios e identidad: el User es global (email único dentro de la app); la pertenencia a tenants es vía TenantMembership (un usuario puede pertenecer a varios tenants, con rol distinto en cada uno). Nunca un FK tenant en el modelo de usuario.
2.1 infrasynth.shared — Fundación Cero-Django
Propósito: Tipos base, protocolos, utilidades criptográficas, y enums compartidos por todo el ecosistema. No tiene dependencias de Django. Todo lo demás depende de este módulo.
infrasynth/shared/
├── protocols.py
├── crypto.py
├── enums.py
├── results.py
└── settings_utils.py
protocols.py — ABCs y Protocolos
from typing import Protocol, runtime_checkable, Any
from datetime import datetime
@runtime_checkable
class AuditableProtocol(Protocol):
"""Cualquier modelo que quiera ser trackeado por audit debe exponer esta interfaz."""
pk: Any
usuario_creacion: Any | None
fecha_creacion: datetime | None
usuario_actualizacion: Any | None
fecha_actualizacion: datetime | None
class Meta:
abstract = True
class EventProtocol(Protocol):
"""Contrato que todo evento (webhook, signal) debe cumplir."""
event_name: str
payload: dict
timestamp: str
class TenantProtocol(Protocol):
"""Contrato de todo modelo tenant-owned. tenant_id es no-nulo en filas de tenant."""
tenant_id: UUID
crypto.py — Utilidades Criptográficas
from cryptography.fernet import Fernet
from django.conf import settings
def get_fernet() -> Fernet:
"""Obtiene instancia Fernet desde CRYPTO_KEY en settings."""
...
def encrypt(value: str) -> str:
"""Encripta un string y retorna el token Fernet."""
...
def decrypt(token: str) -> str:
"""Desencripta un token Fernet. Lanza ValueError si es inválido."""
...
def generate_key() -> str:
"""Genera una nueva Fernet key (para bootstraping)."""
...
def rotate_keys(old_key: str, new_key: str, tokens: list[str]) -> list[str]:
"""Re-encripta tokens de old_key a new_key."""
...
Esta implementación usa Fernet simétrico (mismo secreto para encrypt/decrypt), igual que el sistema actual pero con soporte de rotación de claves.
enums.py
from enum import StrEnum
class ChannelType(StrEnum):
EMAIL = "email"
SMS = "sms"
WHATSAPP = "whatsapp"
TELEGRAM = "telegram"
PUSH = "push"
class EventSeverity(StrEnum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"
class BillingInterval(StrEnum):
MONTHLY = "monthly"
YEARLY = "yearly"
class AuditAction(StrEnum):
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
class SubscriptionStatus(StrEnum):
ACTIVE = "active"
PAST_DUE = "past_due"
CANCELLED = "cancelled"
EXPIRED = "expired"
TRIALING = "trialing"
class InvoiceStatus(StrEnum):
DRAFT = "draft"
OPEN = "open"
PAID = "paid"
VOID = "void"
UNCOLLECTIBLE = "uncollectible"
class ApprovalStrategy(StrEnum):
ANY = "any" # Cualquier aprobación avanza
ALL = "all" # Todas las aprobaciones requeridas
MAJORITY = "majority" # Mayoría simple
results.py — Result Monad
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
E = TypeVar("E")
@dataclass
class Result(Generic[T, E]):
"""Monad para manejo explícito de errores sin excepciones."""
value: T | None = None
error: E | None = None
@property
def is_ok(self) -> bool:
return self.error is None
@property
def is_err(self) -> bool:
return self.error is not None
@staticmethod
def ok(value: T) -> "Result[T, E]":
return Result(value=value)
@staticmethod
def err(error: E) -> "Result[T, E]":
return Result(error=error)
settings_utils.py — Helper de Configuración
from django.conf import settings
def get_setting(prefix: str, key: str, default=None):
"""
Lee una setting con prefijo de app.
Ej: get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True)
Busca settings.INFRASYNTH_SECURITY["COOKIE_SECURE"] con fallback a default.
"""
...
2.2 infrasynth.audit — Capa de Auditoría
Feature flag: audit (default: True)
Dependencias: infrasynth.shared
Modelos
class ModelChangeLog(models.Model):
"""
Registro de mutación en cualquier modelo Django.
Poblado automáticamente por signal handlers (post_save, post_delete).
Los modelos de dominio NO necesitan heredar nada.
"""
model_label = models.CharField(max_length=200, db_index=True)
object_id = models.CharField(max_length=200, db_index=True)
action = models.CharField(max_length=10, choices=[("create", "create"), ("update", "update"), ("delete", "delete")])
changes = models.JSONField(help_text="Dict con {field_name: [old_value, new_value]}")
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
request_id = models.CharField(max_length=64, help_text="UUID de request para correlación")
class Meta:
db_table = "audit_model_change_log"
indexes = [
models.Index(fields=["model_label", "object_id"]),
models.Index(fields=["timestamp"]),
]
class APIInteractionLog(models.Model):
"""
Registro de request/response HTTP.
Poblado por middleware.
"""
method = models.CharField(max_length=10, db_index=True)
path = models.CharField(max_length=500, db_index=True)
status_code = models.PositiveSmallIntegerField(db_index=True)
request_body = models.JSONField(null=True, blank=True)
response_body = models.JSONField(null=True, blank=True)
ip_address = models.GenericIPAddressField(null=True)
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
duration_ms = models.PositiveIntegerField()
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
request_id = models.CharField(max_length=64, unique=True)
user_agent = models.TextField(blank=True, default="")
class Meta:
db_table = "audit_api_interaction_log"
class SecurityEvent(models.Model):
"""
Eventos de seguridad (login, logout, failed login, permission denied, etc.)
Poblado vía seguridad security_event_occurred signal.
"""
event_type = models.CharField(max_length=50, db_index=True)
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
ip_address = models.GenericIPAddressField(null=True)
metadata = models.JSONField(default=dict)
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
request_id = models.CharField(max_length=64)
class Meta:
db_table = "audit_security_event"
Señales Expuestas
# infrasynth/audit/signals.py
from django.dispatch import Signal
model_changed = Signal() # kwargs: 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/<id>/ |
GET | audit.view_model_changes |
Detalle de un cambio |
/audit/api-logs/ |
GET | audit.view_api_logs |
Listar interacciones API |
/audit/api-logs/<id>/ |
GET | audit.view_api_logs |
Detalle de interacción |
/audit/security-events/ |
GET | audit.view_security_events |
Listar eventos de seguridad |
Configuración Externalizable
# settings.py del proyecto consumidor
INFRASYNTH_AUDIT = {
"EXCLUDED_MODELS": ["sessions.Session", "admin.LogEntry", "contenttypes.ContentType"],
"EXCLUDED_FIELDS": ["password", "token", "secret", "credit_card"],
"SENSITIVE_KEYS": ["password", "token", "secret", "authorization", "api_key"],
"MAX_BODY_SIZE_BYTES": 5000,
"STORE_IN_DB": True,
"RETENTION_DAYS": 365,
"ENABLE_API_LOGGING": True,
"ENABLE_MODEL_CHANGE_TRACKING": True,
"ENABLE_SECURITY_EVENTS": True,
}
Patrón de Integración para App B
# App B: any_model.py — NO necesita importar audit ni heredar nada
class Ticket(models.Model):
subject = models.CharField(max_length=255)
# ... fields ...
# El signal handler en audit/apps.py hace:
# @receiver(post_save)
# def track_model_changes(sender, instance, created, raw, **kwargs):
# if sender._meta.label in EXCLUDED_MODELS: return
# if created: log "create"
# else: log "update" with field diffs
# App B también puede escuchar eventos de audit:
from infrasynth.audit.signals import security_event_occurred
@receiver(security_event_occurred)
def on_security_event(sender, event_type, actor, ip_address, metadata, **kwargs):
if event_type == "login_failed":
# Notificar al equipo de seguridad
...
2.3 infrasynth.security — Núcleo de Seguridad y Acceso
Feature flag: security (default: True — es el core del sistema)
Dependencias: infrasynth.shared, infrasynth.audit
Modelos
class Role(models.Model):
"""
Rol con permisos definidos como lista JSON.
Un usuario puede tener múltiples roles.
Los permisos de roles se suman (unión).
"""
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100, unique=True)
description = models.TextField(blank=True)
permissions = models.JSONField(default=list, help_text="Lista de codenames de permiso")
is_system = models.BooleanField(default=False, help_text="Roles de sistema no se pueden eliminar")
class Meta:
db_table = "security_role"
def __str__(self):
return self.name
class Grant(models.Model):
"""
Concesión directa de un permiso a un usuario específico.
Puede tener expiración. Prevalece sobre el rol (si hay conflicto, gana el grant).
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="direct_grants")
codename = models.CharField(max_length=200, db_index=True)
granted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="grants_given")
reason = models.TextField(blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "security_grant"
unique_together = [("user", "codename")]
class Revoke(models.Model):
"""
Revocación explícita de un permiso a un usuario.
Prevalece sobre grants y roles. Si existe un revoke, el permiso se deniega.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="direct_revokes")
codename = models.CharField(max_length=200, db_index=True)
revoked_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="revokes_given")
reason = models.TextField(blank=True)
class Meta:
db_table = "security_revoke"
unique_together = [("user", "codename")]
class APIKey(models.Model):
"""
Clave de API para autenticación servicio-a-servicio.
El secret se hashea con PBKDF2. Solo el prefix es visible.
"""
name = models.CharField(max_length=200)
prefix = models.CharField(max_length=12, unique=True, help_text="Primeros 8 caracteres visibles en UI")
key_hash = models.CharField(max_length=255, help_text="Hash PBKDF2 del secret completo")
scopes = models.JSONField(default=list, help_text='["read:users", "write:billing"]')
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
is_active = models.BooleanField(default=True)
expires_at = models.DateTimeField(null=True, blank=True)
last_used_at = models.DateTimeField(null=True, blank=True)
rotated_from = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True)
class Meta:
db_table = "security_api_key"
class TwoFactorConfig(models.Model):
"""
Configuración de doble factor por usuario.
Soporta TOTP, email, o ambos.
"""
METHOD_TOTP = "totp"
METHOD_EMAIL = "email"
METHOD_BOTH = "both"
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="two_factor_config")
is_enabled = models.BooleanField(default=False)
is_configured = models.BooleanField(default=False)
method = models.CharField(max_length=10, choices=[(METHOD_TOTP, "TOTP"), (METHOD_EMAIL, "Email"), (METHOD_BOTH, "Both")], default=METHOD_TOTP)
secret_key_encrypted = models.CharField(max_length=500, null=True, blank=True)
recovery_codes_encrypted = models.TextField(null=True, blank=True)
email_verified = models.BooleanField(default=False)
email_code = models.CharField(max_length=6, null=True, blank=True)
email_code_expires_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "security_two_factor_config"
class ALTCHAChallenge(models.Model):
"""
Desafío proof-of-work para protección anti-spam.
"""
challenge_id = models.CharField(max_length=64, primary_key=True)
salt = models.CharField(max_length=32)
difficulty = models.IntegerField(default=10000)
expires_at = models.DateTimeField(db_index=True)
is_verified = models.BooleanField(default=False)
class Meta:
db_table = "security_altcha_challenge"
Algoritmo de Autorización Híbrida
# infrasynth/security/services.py
class AuthorizationService:
"""Servicio singleton para resolución de permisos."""
def has_permission(self, user, codename: str) -> bool:
"""
Resuelve si un usuario tiene un permiso específico.
Orden de evaluación (el primero que match gana):
1. Superuser → ACCESO TOTAL
2. Revoke explícito → DENEGAR
3. Grant directo no expirado → CONCEDER
4. Grant vía rol → CONCEDER (unión de todos los roles)
5. Default → DENEGAR
"""
if not user or not user.is_authenticated:
return False
if user.is_superuser:
return True
if Revoke.objects.filter(user=user, codename=codename).exists():
return False
if Grant.objects.filter(
user=user, codename=codename
).filter(
Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())
).exists():
return True
user_roles = user.roles.values_list("permissions", flat=True)
for perm_list in user_roles:
if codename in (perm_list or []):
return True
return False
def get_effective_permissions(self, user) -> set[str]:
"""Devuelve el set completo de permisos efectivos del usuario."""
if not user or not user.is_authenticated:
return set()
if user.is_superuser:
return {"*"} # Wildcard — el frontend debe interpretar "*" como acceso total en cualquier check
revoked = set(Revoke.objects.filter(user=user).values_list("codename", flat=True))
granted = set(
Grant.objects.filter(user=user).filter(
Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())
).values_list("codename", flat=True)
)
role_perms = set()
for perm_list in user.roles.values_list("permissions", flat=True):
role_perms.update(perm_list or [])
return (granted | role_perms) - revoked
def has_all_permissions(self, user, codenames: list[str]) -> bool:
"""Verifica que el usuario tenga TODOS los permisos listados."""
return all(self.has_permission(user, c) for c in codenames)
def has_any_permission(self, user, codenames: list[str]) -> bool:
"""Verifica que el usuario tenga AL MENOS UNO de los permisos listados."""
return any(self.has_permission(user, c) for c in codenames)
Autenticación JWT via Cookies HTTP-Only
# infrasynth/security/auth/cookies.py
class CookieJWTAuthentication(JWTAuthentication):
"""
Lee el token JWT desde una cookie HTTP-Only cifrada con Fernet.
Igual que el sistema actual pero con configuración externalizada.
"""
def authenticate(self, request):
cookie_name = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token")
raw_token = request.COOKIES.get(cookie_name)
if not raw_token:
return None
try:
decrypted = decrypt(raw_token)
validated_token = self.get_validated_token(decrypted)
except Exception:
raise AuthenticationFailed("Token inválido o expirado.")
return self.get_user(validated_token), validated_token
API Key Authentication
# infrasynth/security/auth/api_keys.py
class APIKeyAuthentication(BaseAuthentication):
"""
Autenticación servicio-a-servicio via header X-API-Key.
Formato: X-API-Key: {prefix}.{secret}
"""
keyword = "X-API-Key"
def authenticate(self, request):
raw_key = request.META.get(f"HTTP_{self.keyword.replace('-', '_').upper()}")
if not raw_key:
return None
try:
prefix, secret = raw_key.split(".", 1)
except ValueError:
raise AuthenticationFailed("Formato de API key inválido.")
api_key = APIKey.objects.filter(prefix=prefix, is_active=True).first()
if not api_key:
raise AuthenticationFailed("API key no encontrada.")
if not check_password(secret, api_key.key_hash):
raise AuthenticationFailed("API key inválida.")
if api_key.expires_at and api_key.expires_at < timezone.now():
raise AuthenticationFailed("API key expirada.")
api_key.last_used_at = timezone.now()
api_key.save(update_fields=["last_used_at"])
# Crear un "system user" anónimo con scopes como permisos
return (SystemUser(scopes=api_key.scopes), api_key)
Señales Expuestas
user_logged_in = Signal() # kwargs: user, ip, user_agent
user_logged_out = Signal() # kwargs: user
login_failed = Signal() # kwargs: credentials_key, ip, reason
two_factor_setup = Signal() # kwargs: user, method
two_factor_verified = Signal() # kwargs: user, method
api_key_created = Signal() # kwargs: key_name, created_by
api_key_rotated = Signal() # kwargs: key_name, rotated_by
grant_created = Signal() # kwargs: user, codename, granted_by
grant_revoked = Signal() # kwargs: user, codename, reason
API Endpoints
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
/auth/login/ |
POST | None | Login con email/username + password. Setea cookies HTTP-Only |
/auth/logout/ |
POST | IsAuthenticated | Limpia cookies |
/auth/refresh/ |
POST | None | Refresh token desde cookie refresh |
/auth/check/ |
GET | None | Verifica sesión activa. Retorna user info + effective_permissions: string[] (permisos efectivos del usuario, incluyendo ["*"] para superusuarios) |
/auth/2fa/setup/ |
POST | IsAuthenticated | Inicia setup TOTP (retorna secret + QR URL) |
/auth/2fa/verify-setup/ |
POST | IsAuthenticated | Verifica código TOTP durante setup |
/auth/2fa/verify/ |
POST | None | Verifica código TOTP en login (requiere pre-auth token) |
/auth/2fa/disable/ |
POST | IsAuthenticated | Deshabilita 2FA |
/auth/2fa/recovery/ |
POST | None | Usa recovery code para bypass 2FA |
/auth/altcha/challenge/ |
POST | None | Obtiene challenge PoW |
/auth/altcha/verify/ |
POST | None | Verifica solución PoW |
/auth/api-keys/ |
GET, POST | security.manage_api_keys |
Lista/crea API keys |
/auth/api-keys/<id>/ |
GET, DELETE | security.manage_api_keys |
Detalle/elimina API key |
/auth/api-keys/<id>/rotate/ |
POST | security.manage_api_keys |
Rota API key (invalida anterior) |
/security/roles/ |
GET, POST | security.manage_roles |
CRUD roles |
/security/roles/<slug>/ |
GET, PUT, DELETE | security.manage_roles |
Detalle/actualiza/elimina rol |
/security/grants/ |
GET, POST | security.manage_grants |
Lista/crea grants |
/security/grants/<id>/ |
DELETE | security.manage_grants |
Revoca grant |
/security/revokes/ |
GET, POST | security.manage_grants |
Lista/crea revokes |
/security/revokes/<id>/ |
DELETE | security.manage_grants |
Elimina revoke |
/security/users/<id>/permissions/ |
GET | security.view_permissions |
Permisos efectivos del usuario |
/security/users/<id>/roles/ |
GET, PUT | security.manage_roles |
Roles del usuario |
Configuración Externalizable
INFRASYNTH_SECURITY = {
# JWT
"ACCESS_TOKEN_LIFETIME_MINUTES": 30,
"REFRESH_TOKEN_LIFETIME_DAYS": 7,
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"ACCESS_COOKIE_NAME": "access_token",
"REFRESH_COOKIE_NAME": "refresh_token",
"COOKIE_SECURE": True,
"COOKIE_HTTPONLY": True,
"COOKIE_SAMESITE": "Lax",
"PRE_AUTH_COOKIE_NAME": "pre_auth_token",
"CRYPTO_KEY": None, # Fernet key para encrypt/decrypt cookies
# Login
"AUTH_BACKEND_CLASS": "infrasynth.security.auth.backends.EmailOrUsernameBackend",
"LOGIN_RATE_LIMIT": "10/m",
"IP_BLACKLIST_THRESHOLD": 100,
"IP_BLACKLIST_WINDOW_MINUTES": 15,
# 2FA
"TWO_FACTOR_ISSUER_NAME": "InfraSynth",
"TWO_FACTOR_RECOVERY_CODES_COUNT": 8,
"TWO_FACTOR_TOTP_VALIDITY_WINDOW": 1,
"PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5,
# ALTCHA
"ALTCHA_DIFFICULTY": 10000,
"ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300,
# API Keys
"API_KEY_PREFIX_LENGTH": 8,
"API_KEY_HASH_ALGORITHM": "pbkdf2_sha256",
"API_KEY_DEFAULT_EXPIRY_DAYS": 365,
# Password Policy
"PASSWORD_MIN_LENGTH": 8,
"PASSWORD_REQUIRE_UPPERCASE": True,
"PASSWORD_REQUIRE_DIGIT": True,
"PASSWORD_REQUIRE_SPECIAL_CHAR": True,
}
DRF Permission Class
# infrasynth/security/permissions.py
class HybridPermission(BasePermission):
"""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
# 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.
// Ejemplo de respuesta de /auth/check/
{
"id": 42,
"email": "user@example.com",
"name": "Juan Pérez",
"effective_permissions": [
"webhooks.view_webhookendpoint",
"webhooks.add_webhook",
"webhooks.change_webhook",
"notifications.manage_templates"
]
}
Para superusuarios se retorna ["*"]. El frontend debe interpretar "*" como acceso total en cualquier verificación.
2. PermissionContext + useHasPermission hook
El frontend almacena el array en un contexto React global y expone un hook con tres modos de consulta:
| Modo | Función | Ejemplo |
|---|---|---|
| Individual | hasPermission("webhooks.delete_webhook") |
Un solo permiso |
| Cualquiera (any) | hasAnyPermission(["a", "b"]) |
Al menos uno |
| Todos (all) | hasAllPermissions(["a", "b"]) |
Todos requeridos |
El hook debe manejar el wildcard "*": si el array incluye "*", cualquier permiso consultado retorna true.
3. Componente <Can>
Componente declarativo que envuelve elementos UI y los muestra solo si el usuario cumple el permiso requerido.
<Can I="webhooks.delete_webhook">
<button>Eliminar</button>
</Can>
<Can I={["webhooks.change_webhook", "webhooks.delete_webhook"]} mode="any">
<ActionBar />
</Can>
Soporta:
I: string (permiso único) ostring[](múltiples permisos)mode:"all"(default para arrays) o"any"fallback: ReactNode opcional para renderizar cuando no hay accesochildren: se renderiza solo si el permiso es concedido
4. Rutas protegidas (router guards)
Cada módulo o sección protegida por un permiso base (ej. webhooks.view_webhookendpoint para el módulo de webhooks) implementa un wrapper de ruta que verifica el permiso antes de renderizar la página.
- Si el usuario no tiene el permiso, redirige a una página 403 o renderiza un mensaje de "acceso denegado"
- Los enlaces de navegación al módulo se esconden condicionalmente con
<Can I="webhooks.view_webhookendpoint">
Este patrón evita que el usuario vea botones o pantallas para acciones que no puede realizar, eliminando frustrantes errores "Permission Denied" después del clic.
2.4 infrasynth.files — Almacenamiento Cloud
Feature flag: files (default: True)
Dependencias: infrasynth.shared, infrasynth.audit
Modelos
class StoredFile(models.Model):
"""
Metadatos de archivo almacenado. El archivo físico se guarda en el storage
backend configurado (S3, Cloudinary, local, etc.)
"""
storage_backend = models.CharField(max_length=50, help_text="S3, cloudinary, gcs, local")
storage_key = models.CharField(max_length=500, help_text="Key/path en el storage backend")
original_filename = models.CharField(max_length=500)
mime_type = models.CharField(max_length=100)
size_bytes = models.BigIntegerField()
checksum_sha256 = models.CharField(max_length=64, blank=True)
is_public = models.BooleanField(default=False)
category = models.ForeignKey("FileCategory", on_delete=models.SET_NULL, null=True, blank=True)
metadata = models.JSONField(default=dict, help_text="Metadatos extra (width, height, duration, etc.)")
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "files_stored_file"
class FileCategory(models.Model):
"""
Categoría de archivos con reglas de validación y ruta de almacenamiento.
"""
slug = models.SlugField(max_length=100, primary_key=True)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
storage_path = models.CharField(max_length=500, help_text="Prefijo de ruta en storage")
allowed_extensions = models.TextField(blank=True, help_text="CSV: pdf,doc,docx")
max_size_bytes = models.BigIntegerField(null=True, blank=True)
is_active = models.BooleanField(default=True)
storage_backend_override = models.CharField(max_length=50, blank=True, help_text="Si se especifica, usa otro backend")
class Meta:
db_table = "files_category"
class ProcessingPipeline(models.Model):
"""
Pipeline de post-procesamiento (resize, optimize, watermark, virus scan).
Se ejecuta async via Celery después del upload.
"""
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=100, unique=True)
steps = models.JSONField(help_text='[{"type": "resize", "params": {"width": 800}}, {"type": "optimize"}]')
is_active = models.BooleanField(default=True)
class Meta:
db_table = "files_pipeline"
class PipelineExecution(models.Model):
"""
Log de ejecución de un pipeline sobre un archivo.
"""
file = models.ForeignKey(StoredFile, on_delete=models.CASCADE, related_name="pipeline_executions")
pipeline = models.ForeignKey(ProcessingPipeline, on_delete=models.SET_NULL, null=True)
status = models.CharField(max_length=20, choices=[("pending","pending"),("running","running"),("completed","completed"),("failed","failed")], default="pending")
started_at = models.DateTimeField(null=True)
completed_at = models.DateTimeField(null=True)
output_file = models.ForeignKey(StoredFile, on_delete=models.SET_NULL, null=True, related_name="+")
error = models.TextField(blank=True)
class Meta:
db_table = "files_pipeline_execution"
FileService API
# infrasynth/files/services.py
class FileService:
"""
API pública para gestión de archivos. Usada por App B y por otras apps base.
"""
def upload(self, file_obj, *, filename: str, category_slug: str = None,
user=None, is_public: bool = False, metadata: dict = None,
pipeline_slug: str = None) -> StoredFile:
"""Sube un archivo al storage configurado. Retorna el StoredFile."""
...
def get_signed_url(self, file_or_id, *, expiry_seconds: int = 3600) -> str:
"""Genera una URL firmada temporal para descarga directa del storage."""
...
def get_download_response(self, file_or_id, request) -> HttpResponse:
"""Retorna FileResponse o redirect a signed URL o X-Sendfile."""
...
def delete(self, file_or_id, *, soft: bool = True) -> bool:
"""Borra un archivo. soft=True solo marca como eliminado, soft=False borra del storage."""
...
def get_file_info(self, file_or_id) -> dict:
"""Metadatos completos del archivo."""
...
Señales
file_uploaded = Signal() # kwargs: file_id, storage_key, filename, size, uploaded_by
file_processed = Signal() # kwargs: file_id, pipeline_name, output_file_id, status
file_deleted = Signal() # kwargs: file_id, storage_key, deleted_by
API Endpoints
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
/files/upload/ |
POST | IsAuthenticated | Subir archivo (multipart). Retorna file_id |
/files/ |
GET | IsAuthenticated | Listar archivos (filtrable) |
/files/<id>/ |
GET | IsAuthenticated | Metadatos del archivo |
/files/<id>/download/ |
GET | IsAuthenticated | Descargar archivo (signed URL o proxy) |
/files/<id>/ |
DELETE | IsAuthenticated | Borrado lógico |
/files/categories/ |
GET, POST | files.manage_categories |
CRUD categorías |
/files/categories/<slug>/ |
GET, PUT, DELETE | files.manage_categories |
Detalle categoría |
/files/pipelines/ |
GET, POST | files.manage_pipelines |
CRUD pipelines |
Configuración Externalizable
INFRASYNTH_FILES = {
"DEFAULT_STORAGE_BACKEND": "S3", # S3, cloudinary, gcs, local
"STORAGE_BACKENDS": {
"S3": {
"ACCESS_KEY": None,
"SECRET_KEY": None,
"BUCKET_NAME": None,
"REGION": "us-east-1",
"ENDPOINT_URL": None, # Para MinIO o compatibles S3
},
"cloudinary": {
"CLOUD_NAME": None,
"API_KEY": None,
"API_SECRET": None,
},
"gcs": {
"PROJECT_ID": None,
"BUCKET_NAME": None,
"CREDENTIALS_PATH": None,
},
"local": {},
},
"SIGNED_URL_EXPIRY_SECONDS": 3600,
"MAX_UPLOAD_SIZE_MB": 100,
"ENABLE_PROCESSING_PIPELINES": True,
"PROCESSING_BACKEND": "celery", # celery | sync
"ENABLE_X_SENDFILE": False, # Apache/Nginx X-Sendfile/X-Accel-Redirect
}
Patrón de Integración para App B
from infrasynth.files.services import FileService
from infrasynth.files.models import StoredFile
# Subir archivo
fs = FileService()
stored = fs.upload(
request.FILES["attachment"],
filename="contrato_001.pdf",
category_slug="contracts",
user=request.user,
)
# Vincular a modelo de negocio
class Contract(models.Model):
pdf_file = models.ForeignKey(StoredFile, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
# ...
contract = Contract.objects.create(pdf_file=stored, ...)
# Obtener URL de descarga
download_url = fs.get_signed_url(stored.id, expiry_seconds=300)
2.5 infrasynth.notifications — Dispatch Multi-Canal
Feature flag: notifications (default: True)
Dependencias: infrasynth.shared, infrasynth.audit
Modelos
class NotificationTemplate(models.Model):
"""
Plantilla de notificación con soporte multi-canal.
Usa Django Template Language con {{ variables }}.
"""
slug = models.SlugField(max_length=100, unique=True)
name = models.CharField(max_length=200)
channel = models.CharField(max_length=20, choices=[(c.value, c.value) for c in ChannelType])
subject_template = models.CharField(max_length=500, blank=True)
body_template = models.TextField()
is_html = models.BooleanField(default=True)
namespace = models.CharField(max_length=100, blank=True, help_text="Namespace de la app dueña")
metadata = models.JSONField(default=dict)
class Meta:
db_table = "notifications_template"
unique_together = [("slug", "namespace")]
class NotificationDispatch(models.Model):
"""
Registro de cada envío de notificación. Útil para debugging y auditoría.
"""
template = models.ForeignKey(NotificationTemplate, on_delete=models.SET_NULL, null=True)
recipient = models.CharField(max_length=500, help_text="Email, número de teléfono, chat ID")
channel = models.CharField(max_length=20)
subject = models.CharField(max_length=500, blank=True)
body = models.TextField()
status = models.CharField(max_length=20, choices=[("pending","pending"),("sent","sent"),("failed","failed"),("retrying","retrying")])
error_message = models.TextField(blank=True)
attempt = models.PositiveSmallIntegerField(default=1)
next_retry_at = models.DateTimeField(null=True)
context_snapshot = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True)
request_id = models.CharField(max_length=64, blank=True)
class Meta:
db_table = "notifications_dispatch"
class ChannelConfig(models.Model):
"""
Configuración de un canal de notificación.
Las credenciales se almacenan encriptadas (Fernet).
"""
slug = models.SlugField(max_length=50, primary_key=True)
channel_type = models.CharField(max_length=20)
display_name = models.CharField(max_length=200)
config = models.JSONField(default=dict, help_text="Credenciales encriptadas")
is_active = models.BooleanField(default=True)
priority = models.PositiveSmallIntegerField(default=0, help_text="Menor número = mayor prioridad para failover")
class Meta:
db_table = "notifications_channel_config"
Channel ABC (Backend Interface)
# infrasynth/notifications/channels/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from infrasynth.shared.results import Result
@dataclass
class Attachment:
filename: str
content: bytes
mime_type: str
cid: str | None = None
inline: bool = False
class BaseChannel(ABC):
"""
ABC que todo canal de notificación debe implementar.
App B puede crear sus propios canales implementando esta interfaz.
"""
channel_type: ChannelType
@abstractmethod
def send(self, recipient: str, subject: str, body: str,
is_html: bool = True, attachments: list[Attachment] | None = None) -> Result[bool, str]:
"""Envía la notificación. Retorna Result.ok(True) o Result.err("mensaje")."""
...
def health_check(self) -> bool:
"""Verifica que el canal esté operativo."""
return True
def get_priority(self) -> int:
return 0
@classmethod
def from_config(cls, config: dict) -> "BaseChannel":
"""Factory: construye el canal desde el dict de configuración."""
...
Implementaciones built-in: SMTPChannel, SendGridChannel, SESChannel, TwilioSMSChannel, TelegramChannel.
NotificationService API
# infrasynth/notifications/services.py
class NotificationService:
"""API pública para envío de notificaciones."""
def send(self, *, recipients: list[str], template_slug: str = None,
subject: str = "", body: str = "", channel: str = "email",
context: dict = None, attachments: list[dict] = None,
namespace: str = None, request=None) -> NotificationDispatch:
"""Envía notificación síncrona o asíncrona según configuración."""
...
def send_with_failover(self, *, recipients: list[str], template_slug: str = None,
subject: str = "", body: str = "", channel: str = "email",
context: dict = None, attachments: list[dict] = None,
namespace: str = None, request=None) -> NotificationDispatch:
"""
Envía con failover automático.
Si el canal primario falla, intenta el siguiente en la cadena de failover.
"""
...
def get_template(self, slug: str, namespace: str = None) -> NotificationTemplate | None:
"""Recupera una plantilla por slug."""
...
VariableResolverRegistry (Extensibilidad)
# infrasynth/notifications/resolvers.py
from typing import Callable, Any
import logging
logger = logging.getLogger(__name__)
class VariableResolverRegistry:
"""
Registry global de resolvedores de variables para plantillas.
APPS EXTERNAS se registran aquí en su apps.py:ready().
InfraSynth nunca conoce los dominios de App B.
Uso en App B:
```
class MyAppConfig(AppConfig):
def ready(self):
from infrasynth.notifications.resolvers import VariableResolverRegistry
@VariableResolverRegistry.register("ticket_number", namespace="helpdesk")
def resolve_ticket(recipient, context, request):
return context["ticket"].id
```
"""
_resolvers: dict[str, list[dict]] = {}
@classmethod
def register(cls, variable_name: str, label: str = None,
description: str = None, namespace: str = "__global__"):
"""Decorador para registrar un resolvedor."""
def decorator(func: Callable):
if namespace not in cls._resolvers:
cls._resolvers[namespace] = []
cls._resolvers[namespace].append({
"name": variable_name,
"label": label or variable_name,
"description": description or "",
"resolver": func,
})
return func
return decorator
@classmethod
def resolve(cls, variable_name: str, recipient: str, context: dict,
namespace: str = None, request=None) -> Any:
"""Resuelve una variable. Busca en namespace + global."""
search_namespaces = [namespace, "__global__"] if namespace else ["__global__"]
for ns in search_namespaces:
for entry in cls._resolvers.get(ns, []):
if entry["name"] == variable_name:
try:
return entry["resolver"](recipient, context, request)
except Exception:
logger.exception(f"Error resolving '{variable_name}'")
return None
return context.get(variable_name)
@classmethod
def get_available_variables(cls, namespace: str = None) -> list[dict]:
"""Retorna metadatos de variables para UI."""
...
Señales
notification_sent = Signal() # kwargs: dispatch_id, recipient, channel, status
notification_failed = Signal() # kwargs: dispatch_id, recipient, channel, error
API Endpoints
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
/notifications/templates/ |
GET, POST | notifications.manage_templates |
CRUD plantillas |
/notifications/templates/<slug>/ |
GET, PUT, DELETE | notifications.manage_templates |
Detalle plantilla |
/notifications/dispatch/ |
POST | IsAuthenticated | Enviar notificación |
/notifications/history/ |
GET | IsAuthenticated | Historial de envíos |
/notifications/history/<id>/ |
GET | IsAuthenticated | Detalle de envío |
/notifications/channels/ |
GET | notifications.manage_channels |
Canales y health status |
Configuración Externalizable
INFRASYNTH_NOTIFICATIONS = {
"DEFAULT_FROM_EMAIL": "noreply@example.com",
"DEFAULT_FROM_SMS": "+1234567890",
# Canales configurados con failover
"CHANNELS": {
"email": {
"primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel",
"fallback": "infrasynth.notifications.channels.email_sendgrid.SendGridChannel",
},
"sms": {
"primary": "infrasynth.notifications.channels.sms_twilio.TwilioSMSChannel",
},
},
"DISPATCH_BACKEND": "celery", # sync | celery | thread
"MAX_RETRIES": 3,
"RETRY_DELAY_SECONDS": [60, 300, 900],
"TEMPLATE_ENGINE": "django", # django | jinja2
"RATE_LIMIT_PER_CHANNEL": {
"email": "50/m",
"sms": "10/m",
},
"STORE_DISPATCH_LOGS": True,
"DISPATCH_LOG_RETENTION_DAYS": 90,
}
Patrón de Integración para App B
# App B: helpdesk/apps.py
class HelpdeskConfig(AppConfig):
name = "helpdesk"
def ready(self):
from infrasynth.notifications.resolvers import VariableResolverRegistry
@VariableResolverRegistry.register("ticket_number", namespace="helpdesk",
label="Número de ticket")
def resolve_ticket(recipient, context, request):
return context["ticket"].id
@VariableResolverRegistry.register("agent_name", namespace="helpdesk",
label="Nombre del agente")
def resolve_agent(recipient, context, request):
return context["ticket"].assigned_to.get_full_name()
# App B: helpdesk/services.py
from infrasynth.notifications.services import NotificationService
ns = NotificationService()
def notify_ticket_assigned(ticket):
ns.send(
recipients=[ticket.assigned_to.email],
template_slug="helpdesk.ticket_assigned",
namespace="helpdesk",
context={"ticket": ticket},
channel="email",
)
2.6 infrasynth.webhooks — Webhooks Inbound/Outbound
Feature flag: webhooks (default: True)
Dependencias: infrasynth.shared, infrasynth.audit
Modelos
class OutboundEndpoint(models.Model):
"""Destino de webhooks salientes."""
name = models.CharField(max_length=200)
url = models.URLField(max_length=1000)
secret = models.CharField(max_length=500, help_text="Clave HMAC para firmar requests")
is_active = models.BooleanField(default=True)
retry_policy = models.JSONField(default=dict, help_text='{"max_retries": 5, "backoff": "exponential"}')
headers = models.JSONField(default=dict, help_text="Headers adicionales")
timeout_seconds = models.PositiveIntegerField(default=10)
class Meta:
db_table = "webhooks_outbound_endpoint"
class OutboundSubscription(models.Model):
"""Vincula un evento con un endpoint outbound."""
endpoint = models.ForeignKey(OutboundEndpoint, on_delete=models.CASCADE, related_name="subscriptions")
event_name = models.CharField(max_length=200, db_index=True)
is_active = models.BooleanField(default=True)
payload_template = models.TextField(blank=True, help_text="Template JSON opcional. Si está vacío, se usa el payload crudo.")
class Meta:
db_table = "webhooks_outbound_subscription"
unique_together = [("endpoint", "event_name")]
class OutboundDelivery(models.Model):
"""Log de cada intento de entrega outbound."""
subscription = models.ForeignKey(OutboundSubscription, on_delete=models.CASCADE, related_name="deliveries")
payload = models.JSONField()
response_status = models.PositiveSmallIntegerField(null=True)
response_body = models.TextField(blank=True)
attempt = models.PositiveSmallIntegerField(default=1)
status = models.CharField(max_length=20, choices=[("success","success"),("failed","failed"),("retrying","retrying")])
next_retry_at = models.DateTimeField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True)
class Meta:
db_table = "webhooks_outbound_delivery"
class InboundEndpoint(models.Model):
"""Receptor de webhooks entrantes (de Stripe, GitHub, etc.)."""
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=100, unique=True, help_text="Identificador en la URL: /webhooks/inbound/receive/<slug>/")
source = models.CharField(max_length=50, choices=[("stripe","stripe"),("github","github"),("mercadopago","mercadopago"),("custom","custom")])
secret = models.CharField(max_length=500, help_text="Clave para verificar firma entrante")
handler = models.CharField(max_length=500, help_text="Dotted path a handler class (implementa BaseInboundHandler)")
is_active = models.BooleanField(default=True)
class Meta:
db_table = "webhooks_inbound_endpoint"
class InboundEvent(models.Model):
"""Evento recibido via webhook inbound."""
endpoint = models.ForeignKey(InboundEndpoint, on_delete=models.CASCADE, related_name="events")
event_type = models.CharField(max_length=200)
raw_payload = models.JSONField()
is_verified = models.BooleanField(default=False)
is_processed = models.BooleanField(default=False)
error = models.TextField(blank=True)
received_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(null=True)
class Meta:
db_table = "webhooks_inbound_event"
EventRegistry (El Componente Clave de Extensibilidad)
# infrasynth/webhooks/registry.py
from dataclasses import dataclass, field
from django.db import transaction
import logging
logger = logging.getLogger(__name__)
@dataclass
class EventDefinition:
name: str
description: str = ""
example_payload: dict = field(default_factory=dict)
schema: dict | None = None
class EventRegistry:
"""
Registry global de eventos.
LAS APPS EXTERNAS NUNCA MODIFICAN CÓDIGO DE WEBHOOKS.
Registran sus eventos aquí en su propio apps.py:ready().
Uso en App B:
```
class MyAppConfig(AppConfig):
def ready(self):
from infrasynth.webhooks.registry import EventRegistry
EventRegistry.register("helpdesk.ticket.created",
description="Se creó un nuevo ticket")
```
En cualquier parte del código:
```
from infrasynth.webhooks.registry import EventRegistry
EventRegistry.emit("helpdesk.ticket.created", {"ticket_id": 123})
```
"""
_events: dict[str, EventDefinition] = {}
@classmethod
def register(cls, event_name: str, *, description: str = "",
example_payload: dict = None, schema: dict = None):
"""
Registra un evento que este sistema puede disparar.
Se llama en apps.py:ready() de cada app.
"""
cls._events[event_name] = EventDefinition(
name=event_name,
description=description,
example_payload=example_payload or {},
schema=schema,
)
logger.debug(f"Event registered: {event_name}")
@classmethod
def emit(cls, event_name: str, payload: dict):
"""
Dispara un evento. Notifica a todas las suscripciones outbound activas.
Si el evento no está registrado, lo registra on-the-fly.
"""
if event_name not in cls._events:
cls._events[event_name] = EventDefinition(name=event_name)
# Buscar suscripciones activas para este evento (o wildcard event_name="*")
from .models import OutboundSubscription
subscriptions = OutboundSubscription.objects.filter(
Q(event_name=event_name) | Q(event_name="*"),
is_active=True,
endpoint__is_active=True,
).select_related("endpoint")
if not subscriptions.exists():
logger.debug(f"Event '{event_name}' emitted, no active subscriptions.")
return
for sub in subscriptions:
# Disparar entrega asíncrona via Celery
from .dispatch import deliver_webhook
deliver_webhook.delay(
subscription_id=sub.id,
event_name=event_name,
payload=payload,
payload_template=sub.payload_template,
)
@classmethod
def get_registered_events(cls) -> dict[str, EventDefinition]:
"""Retorna todos los eventos conocidos (para UI)."""
return dict(cls._events)
HMAC Signature
# infrasynth/webhooks/signature.py
import hmac
import hashlib
import time
def sign_payload(secret: str, payload: str, timestamp: int = None) -> str:
"""Genera header X-Webhook-Signature: t={timestamp},v1={hash}"""
ts = timestamp or int(time.time())
signed = hmac.new(
secret.encode(), f"{ts}.{payload}".encode(), hashlib.sha256
).hexdigest()
return f"t={ts},v1={signed}"
def verify_signature(secret: str, payload: str, signature_header: str,
tolerance_seconds: int = 300) -> bool:
"""Verifica firma HMAC entrante con tolerancia de timestamp."""
try:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
ts = int(parts["t"])
sig = parts.get("v1", "")
if abs(time.time() - ts) > tolerance_seconds:
return False
expected = sign_payload(secret, payload, ts)
return hmac.compare_digest(sig, expected.split(",")[1].split("=")[1])
except Exception:
return False
InboundHandler ABC
# infrasynth/webhooks/inbound/handlers.py
from abc import ABC, abstractmethod
class BaseInboundHandler(ABC):
"""
App B puede implementar handlers para webhooks entrantes.
Se configura en InboundEndpoint.handler como dotted path.
"""
@abstractmethod
def verify(self, payload: dict, headers: dict, secret: str) -> bool:
"""Verifica la autenticidad del webhook entrante."""
...
@abstractmethod
def process(self, event_type: str, payload: dict) -> dict:
"""Procesa el evento. Retorna resultado."""
...
Señales
outbound_delivery_succeeded = Signal() # kwargs: delivery_id, event_name, status_code
outbound_delivery_failed = Signal() # kwargs: delivery_id, event_name, error
inbound_event_received = Signal() # kwargs: event_id, source, event_type
inbound_event_processed = Signal() # kwargs: event_id, result
API Endpoints
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
/webhooks/outbound/endpoints/ |
GET, POST | webhooks.manage_outbound |
CRUD endpoints outbound |
/webhooks/outbound/endpoints/<id>/ |
GET, PUT, DELETE | webhooks.manage_outbound |
Detalle endpoint |
/webhooks/outbound/subscriptions/ |
GET, POST | webhooks.manage_outbound |
CRUD suscripciones |
/webhooks/outbound/subscriptions/<id>/ |
GET, PUT, DELETE | webhooks.manage_outbound |
Detalle suscripción |
/webhooks/outbound/deliveries/ |
GET | webhooks.view_outbound |
Historial de entregas |
/webhooks/outbound/deliveries/<id>/retry/ |
POST | webhooks.manage_outbound |
Reintentar entrega |
/webhooks/inbound/endpoints/ |
GET, POST | webhooks.manage_inbound |
CRUD endpoints inbound |
/webhooks/inbound/endpoints/<slug>/ |
GET, PUT, DELETE | webhooks.manage_inbound |
Detalle endpoint |
/webhooks/inbound/events/ |
GET | webhooks.view_inbound |
Historial de eventos recibidos |
/webhooks/inbound/receive/<slug>/ |
POST | None (público) | Recibir webhook externo |
/webhooks/events/ |
GET | IsAuthenticated | Catálogo de eventos registrados |
Configuración Externalizable
INFRASYNTH_WEBHOOKS = {
"DEFAULT_TIMEOUT_SECONDS": 10,
"MAX_RETRIES": 5,
"RETRY_BACKOFF": "exponential", # fixed | exponential
"RETRY_INITIAL_DELAY_SECONDS": 60,
"SIGNATURE_ALGORITHM": "sha256",
"SIGNATURE_HEADER": "X-Webhook-Signature",
"DELIVERY_BACKEND": "celery", # sync | celery
"INBOUND_SIGNATURE_TOLERANCE_SECONDS": 300,
"MAX_PAYLOAD_SIZE_BYTES": 1048576, # 1MB
}
Patrón de Integración para App B
# App B: helpdesk/apps.py
class HelpdeskConfig(AppConfig):
name = "helpdesk"
def ready(self):
from infrasynth.webhooks.registry import EventRegistry
EventRegistry.register(
"helpdesk.ticket.created",
description="Nuevo ticket de soporte creado",
example_payload={"ticket_id": 123, "subject": "Error en login"},
)
EventRegistry.register("helpdesk.ticket.resolved")
EventRegistry.register("helpdesk.ticket.escalated")
EventRegistry.register("helpdesk.sla.breached",
description="SLA del ticket excedido")
# App B: helpdesk/services.py
from infrasynth.webhooks.registry import EventRegistry
class TicketService:
def create_ticket(self, data, user):
ticket = Ticket.objects.create(**data, created_by=user)
# Disparar evento → suscripciones outbound se notifican automáticamente
EventRegistry.emit("helpdesk.ticket.created", {
"ticket_id": ticket.id,
"subject": ticket.subject,
"priority": ticket.priority,
"created_by": user.email,
"timestamp": ticket.created_at.isoformat(),
})
return ticket
2.7 infrasynth.workflows — Máquina de Estados
Feature flag: workflows (default: True)
Dependencias: infrasynth.shared, infrasynth.audit
Modelos
class Workflow(models.Model):
"""Definición maestra de un flujo de trabajo."""
slug = models.SlugField(max_length=100, unique=True)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
class Meta:
db_table = "workflows_definition"
class WorkflowNode(models.Model):
"""Nodo/estado dentro de un workflow."""
NODE_START = "START"
NODE_INTERMEDIATE = "INTERMEDIATE"
NODE_END = "END"
workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="nodes")
name = models.CharField(max_length=200)
node_type = models.CharField(max_length=20, choices=[(NODE_START,"Start"),(NODE_INTERMEDIATE,"Intermediate"),(NODE_END,"End")])
min_approvals = models.PositiveSmallIntegerField(default=1)
approval_strategy = models.CharField(max_length=20, choices=[("ANY","Any"),("ALL","All"),("MAJORITY","Majority")], default="ALL")
position_x = models.IntegerField(default=0)
position_y = models.IntegerField(default=0)
class Meta:
db_table = "workflows_node"
unique_together = [("workflow", "name")]
class Transition(models.Model):
"""Transición entre nodos."""
from_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="outgoing_transitions")
to_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="incoming_transitions")
condition_slug = models.CharField(max_length=200, blank=True, help_text="Etiqueta de la decisión que activa esta transición (ej. 'approved', 'rejected')")
is_default = models.BooleanField(default=False, help_text="Si ninguna condición match, se usa esta transición")
class Meta:
db_table = "workflows_transition"
unique_together = [("from_node", "condition_slug")]
class WorkflowInstance(models.Model):
"""Instancia viva de un workflow."""
workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="instances")
current_node = models.ForeignKey(WorkflowNode, on_delete=models.SET_NULL, null=True)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
status = models.CharField(max_length=20, choices=[("IN_PROGRESS","In Progress"),("COMPLETED","Completed"),("CANCELLED","Cancelled")], default="IN_PROGRESS")
started_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True, blank=True)
metadata = models.JSONField(default=dict)
class Meta:
db_table = "workflows_instance"
class NodeAssignment(models.Model):
"""
Asignación de un usuario a un nodo en una instancia.
Registra la decisión y los datos capturados.
"""
instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="assignments")
node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
visit_number = models.PositiveIntegerField(default=1, help_text="Incrementa en re-entradas")
is_required = models.BooleanField(default=True)
has_processed = models.BooleanField(default=False)
decision = models.CharField(max_length=200, null=True, blank=True)
comments = models.TextField(blank=True)
submitted_data = models.JSONField(default=dict)
processed_at = models.DateTimeField(null=True)
class Meta:
db_table = "workflows_node_assignment"
class WorkflowObserver(models.Model):
"""Usuario con acceso solo-lectura a una instancia."""
instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="observers")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
class Meta:
db_table = "workflows_observer"
unique_together = [("instance", "user")]
WorkflowAwareModel (Mixin Abstracto)
class WorkflowAwareModel(models.Model):
"""
Mixin abstracto. Cualquier modelo de App B hereda de esto para participar
en workflows. No requiere importar nada más de workflows.
Uso:
class Ticket(WorkflowAwareModel):
subject = models.CharField(max_length=255)
"""
workflow_instance = models.ForeignKey(
WorkflowInstance, on_delete=models.SET_NULL, null=True, blank=True,
related_name="+",
)
class Meta:
abstract = True
Engine (Core Logic)
# infrasynth/workflows/engine.py
class WorkflowEngine:
"""
Motor de workflows. Métodos puros, no dependen de DRF.
"""
@transaction.atomic
def start(self, workflow_slug: str, owner, metadata: dict = None,
assignees: dict[str, list] = None) -> WorkflowInstance:
"""Inicia una nueva instancia de workflow. Asigna responsables iniciales."""
...
@transaction.atomic
def submit_decision(self, instance_id: int, user, decision: str,
comments: str = "", data: dict = None) -> WorkflowInstance:
"""
Procesa la decisión de un usuario en el nodo actual.
Evalúa si se alcanzaron las aprobaciones mínimas y avanza si corresponde.
"""
...
def get_node_states(self, instance: WorkflowInstance) -> dict[int, str]:
"""Estado visual de cada nodo: ACTIVE, COMPLETED, PENDING, REJECTED."""
...
def get_route(self, instance: WorkflowInstance) -> list[dict]:
"""Ruta seguida por la instancia (nodos visitados + decisiones)."""
...
def get_role_in_instance(self, user, instance: WorkflowInstance) -> str:
"""OWNER | ASSIGNEE | OBSERVER | NONE"""
...
def assign_users(self, instance: WorkflowInstance, node: WorkflowNode,
users: list, is_required: bool = True):
"""Asigna usuarios como responsables de un nodo."""
...
def add_observer(self, instance: WorkflowInstance, user):
"""Añade observador solo-lectura."""
...
DataValidatorProtocol (Swappable)
# infrasynth/workflows/validators.py
from typing import Protocol, runtime_checkable
@runtime_checkable
class DataValidatorProtocol(Protocol):
"""
Protocolo para validación de datos de negocio durante decisiones de workflow.
App B implementa esto para su dominio específico.
"""
def validate(self, node: "WorkflowNode", data: dict, context: dict) -> dict:
"""
Valida y limpia datos enviados en una decisión.
Retorna datos limpios o lanza ValidationError.
context contiene: instance, user, previous_decisions.
"""
...
class DataValidatorRegistry:
"""Registry de validadores por workflow."""
_validators: dict[str, DataValidatorProtocol] = {}
@classmethod
def register(cls, workflow_slug: str, validator: DataValidatorProtocol):
cls._validators[workflow_slug] = validator
@classmethod
def get(cls, workflow_slug: str) -> DataValidatorProtocol | None:
return cls._validators.get(workflow_slug)
Señales
instance_started = Signal() # kwargs: instance, workflow_slug, owner
node_reached = Signal() # kwargs: instance, node, visit_number
decision_submitted = Signal() # kwargs: instance, node, user, decision, data
instance_completed = Signal() # kwargs: instance, workflow_slug, final_node
instance_cancelled = Signal() # kwargs: instance, reason
API Endpoints
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
/workflows/definitions/ |
GET, POST | workflows.manage_definitions |
CRUD workflows |
/workflows/definitions/<slug>/ |
GET, PUT, DELETE | workflows.manage_definitions |
Detalle workflow |
/workflows/definitions/<slug>/nodes/ |
GET, POST | workflows.manage_definitions |
CRUD nodos |
/workflows/definitions/<slug>/nodes/<id>/ |
GET, PUT, DELETE | workflows.manage_definitions |
Detalle nodo |
/workflows/definitions/<slug>/transitions/ |
GET, POST | workflows.manage_definitions |
CRUD transiciones |
/workflows/instances/ |
GET, POST | IsAuthenticated | Listar/crear instancias |
/workflows/instances/<id>/ |
GET | IsAuthenticated | Detalle con ruta + estados |
/workflows/instances/<id>/submit/ |
POST | IsAuthenticated | Procesar decisión |
/workflows/instances/<id>/assign/ |
POST | IsAuthenticated | Asignar responsables |
/workflows/instances/<id>/observers/ |
POST, DELETE | IsAuthenticated | Gestionar observadores |
/workflows/instances/<id>/route/ |
GET | IsAuthenticated | Ruta seguida + viabilidad |
Configuración Externalizable
INFRASYNTH_WORKFLOWS = {
"MAX_INSTANCES_PER_WORKFLOW": 10000,
"DEFAULT_APPROVAL_STRATEGY": "ALL",
"AUTO_CLONE_ASSIGNEES_ON_REENTRY": True,
"ALLOW_SELF_ASSIGNMENT": False,
"ROUTE_MAX_DEPTH": 50, # Prevenir loops infinitos
}
Patrón de Integración para App B
# App B: helpdesk/models.py
from infrasynth.workflows.models import WorkflowAwareModel
class Ticket(WorkflowAwareModel):
subject = models.CharField(max_length=255)
description = models.TextField()
priority = models.CharField(max_length=20, choices=[("low","Low"),("medium","Medium"),("high","High")])
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
# App B: helpdesk/validators.py
from infrasynth.workflows.validators import DataValidatorProtocol
from rest_framework.exceptions import ValidationError
class TicketApprovalValidator:
"""Valida datos de negocio cuando un aprobador decide sobre un ticket."""
def validate(self, node, data, context):
instance = context["instance"]
ticket = Ticket.objects.get(workflow_instance=instance)
required_fields = {"resolution_note": str}
for field, field_type in required_fields.items():
if field not in data:
raise ValidationError({field: "Este campo es requerido."})
return data
# App B: helpdesk/apps.py
class HelpdeskConfig(AppConfig):
name = "helpdesk"
def ready(self):
from infrasynth.workflows.validators import DataValidatorRegistry
DataValidatorRegistry.register("ticket_approval", TicketApprovalValidator())
2.8 infrasynth.scheduler — Gestión de Jobs
Feature flag: scheduler (default: True)
Dependencias: infrasynth.shared, infrasynth.audit
Modelos
class ScheduledTask(models.Model):
"""Tarea programada o bajo demanda."""
name = models.CharField(max_length=200, unique=True)
task_path = models.CharField(max_length=500, help_text="Dotted path: helpdesk.tasks.cleanup_old_tickets")
schedule_type = models.CharField(max_length=20, choices=[("CRON","Cron"),("INTERVAL","Interval"),("DATE","Date"),("MANUAL","Manual")])
schedule_config = models.JSONField(default=dict, help_text='{"cron": "0 2 * * *"} o {"interval": 3600}')
args = models.JSONField(default=list)
kwargs = models.JSONField(default=dict)
is_active = models.BooleanField(default=True)
queue = models.CharField(max_length=100, default="default")
priority = models.PositiveSmallIntegerField(default=5)
class Meta:
db_table = "scheduler_task"
class TaskExecution(models.Model):
"""Registro de ejecución de una tarea."""
task = models.ForeignKey(ScheduledTask, on_delete=models.CASCADE, related_name="executions")
celery_task_id = models.CharField(max_length=255, blank=True)
status = models.CharField(max_length=20, choices=[("PENDING","Pending"),("RUNNING","Running"),("SUCCESS","Success"),("FAILURE","Failure")])
started_at = models.DateTimeField(null=True)
completed_at = models.DateTimeField(null=True)
result = models.TextField(blank=True)
error_traceback = models.TextField(blank=True)
worker_hostname = models.CharField(max_length=255, blank=True)
class Meta:
db_table = "scheduler_execution"
ordering = ["-started_at"]
Señales
task_scheduled = Signal() # kwargs: task_name, eta
task_started = Signal() # kwargs: task_name, task_id, worker
task_completed = Signal() # kwargs: 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/<id>/ |
GET, PUT, DELETE | scheduler.manage_tasks |
Detalle tarea |
/scheduler/tasks/<id>/run/ |
POST | scheduler.manage_tasks |
Ejecución manual inmediata |
/scheduler/tasks/<id>/toggle/ |
POST | scheduler.manage_tasks |
Activar/desactivar |
/scheduler/executions/ |
GET | scheduler.view_executions |
Historial de ejecuciones |
/scheduler/executions/<id>/ |
GET | scheduler.view_executions |
Detalle ejecución |
/scheduler/queue-status/ |
GET | scheduler.view_status |
Estado de colas Celery |
/scheduler/workers/ |
GET | scheduler.view_status |
Workers activos y stats |
Configuración Externalizable
INFRASYNTH_SCHEDULER = {
"BACKEND": "celery", # celery | django_q | apscheduler
"CELERY_BROKER_URL": "redis://localhost:6379/0",
"CELERY_RESULT_BACKEND": "redis://localhost:6379/1",
"CELERY_TASK_SOFT_TIME_LIMIT": 300,
"CELERY_TASK_TIME_LIMIT": 600,
"CELERY_WORKER_PREFETCH_MULTIPLIER": 1,
"DEFAULT_QUEUE": "default",
"MAX_EXECUTION_HISTORY_PER_TASK": 1000,
"AUTO_DISCOVER_TASKS": True,
}
2.9 infrasynth.features — Feature Flags
Feature flag: features (default: True — NUNCA se deshabilita, es el orquestador)
Dependencias: infrasynth.shared, infrasynth.audit
Esta app es especial: todas las demás apps (y App B) dependen conceptualmente de features para habilitarse/deshabilitarse. Features siempre está activa.
Modelos
class FeatureFlag(models.Model):
"""
Feature flag con soporte multi-tenant.
tenant_id=NULL significa "global".
"""
slug = models.SlugField(max_length=100)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
is_active = models.BooleanField(default=False)
rollout_percentage = models.PositiveSmallIntegerField(default=100, help_text="0-100. 100 = todos los usuarios")
tenant_id = models.UUIDField(null=True, blank=True, help_text="Null = global. Valor = específico del tenant")
environments = models.JSONField(default=list, help_text='["production", "staging"] o [] = todos')
category = models.CharField(max_length=50, blank=True, help_text="Agrupación para UI")
metadata = models.JSONField(default=dict)
class Meta:
db_table = "features_flag"
unique_together = [("slug", "tenant_id")]
class FeatureFlagOverride(models.Model):
"""
Override puntual para un usuario o grupo específico.
Prevalece sobre la configuración global.
"""
flag = models.ForeignKey(FeatureFlag, on_delete=models.CASCADE, related_name="overrides")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, related_name="+")
group = models.ForeignKey("auth.Group", on_delete=models.CASCADE, null=True, blank=True, related_name="+")
is_enabled = models.BooleanField()
class Meta:
db_table = "features_override"
unique_together = [("flag", "user"), ("flag", "group")]
FeatureRegistry
# infrasynth/features/registry.py
@dataclass
class FeatureDefinition:
slug: str
name: str = ""
description: str = ""
default: bool = True
category: str = None
class FeatureRegistry:
"""
Registry donde CADA APP registra sus feature flags en apps.py:ready().
Features NO conoce qué apps existen.
"""
_features: dict[str, FeatureDefinition] = {}
@classmethod
def register(cls, slug: str, *, name: str = "", description: str = "",
default: bool = True, category: str = None):
cls._features[slug] = FeatureDefinition(
slug=slug, name=name or slug, description=description,
default=default, category=category,
)
@classmethod
def get_all(cls) -> dict[str, FeatureDefinition]:
return dict(cls._features)
Cada app registra sus flags en apps.py:ready():
# infrasynth/webhooks/apps.py
class WebhooksConfig(AppConfig):
name = "infrasynth.webhooks"
def ready(self):
from infrasynth.features.registry import FeatureRegistry
FeatureRegistry.register("webhooks", name="Webhooks",
description="Sistema de webhooks inbound/outbound",
default=True, category="integration")
FeatureRegistry.register("webhooks_outbound",
name="Webhooks Salientes", default=True)
FeatureRegistry.register("webhooks_inbound",
name="Webhooks Entrantes", default=True)
# infrasynth/billing/apps.py
class BillingConfig(AppConfig):
name = "infrasynth.billing"
def ready(self):
from infrasynth.features.registry import FeatureRegistry
FeatureRegistry.register("billing", name="Facturación y Pagos",
description="Módulo de suscripciones y facturación",
default=False, category="operations")
FeatureService
# infrasynth/features/services.py
from django.core.cache import cache
class FeatureService:
"""
Servicio de evaluación de feature flags.
Usa cache para minimizar queries.
"""
def is_enabled(self, slug: str, *, user=None, tenant_id: str = None,
default: bool = None, ttl_seconds: int = 60) -> bool:
"""Evalúa si un feature flag está activo."""
# 1. Override por usuario (BD)
if user and user.is_authenticated:
override = self._get_user_override(slug, user)
if override is not None:
return override
# 2. Override por grupo
if user and user.is_authenticated:
override = self._get_group_override(slug, user)
if override is not None:
return override
# 3. Configuración del tenant
if tenant_id:
flag = self._get_flag(slug, tenant_id, ttl_seconds)
else:
flag = self._get_flag_global(slug, ttl_seconds)
if flag:
return flag.is_active
# 4. Default del registry
registry_default = FeatureRegistry.get_all().get(slug)
if registry_default:
return registry_default.default
# 5. Default del caller
return default if default is not None else False
def get_active_flags(self, *, user=None, tenant_id: str = None) -> dict[str, bool]:
"""
Retorna el estado de TODOS los flags conocidos para el usuario/tenant actual.
Este es el endpoint que el frontend consume.
"""
all_slugs = set(FeatureRegistry.get_all().keys())
db_flags = set(FeatureFlag.objects.filter(
Q(tenant_id=tenant_id) | Q(tenant_id__isnull=True)
).values_list("slug", flat=True))
all_slugs.update(db_flags)
return {
slug: self.is_enabled(slug, user=user, tenant_id=tenant_id)
for slug in sorted(all_slugs)
}
API Endpoints
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
/features/ |
GET, POST | features.manage_flags |
CRUD feature flags |
/features/<slug>/ |
GET, PUT, DELETE | features.manage_flags |
Detalle flag |
/features/<slug>/overrides/ |
GET, POST | features.manage_flags |
CRUD overrides por usuario/grupo |
/features/<slug>/overrides/<id>/ |
DELETE | features.manage_flags |
Eliminar override |
/features/active/ |
GET | IsAuthenticated | Endpoint central. Retorna TODOS los flags activos + permisos + roles para el usuario/tenant actual. El frontend lo consume al montar. |
/features/check/<slug>/ |
GET | IsAuthenticated | Verificar un flag específico |
Señales
flag_created = Signal() # kwargs: 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
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 porEntitlementService. Ver../ENTITLEMENTS.md.
Modelos
class App(models.Model):
"""Una app desplegada en el catálogo (global)."""
slug = models.SlugField(max_length=100, unique=True) # "messenger", "invoicer"
name = models.CharField(max_length=200)
monetization = models.CharField( # default a nivel de app
max_length=20,
choices=[("one_time","one_time"),("subscription","subscription")],
)
is_active = models.BooleanField(default=True)
metadata = models.JSONField(default=dict)
class Meta:
db_table = "billing_app"
class Plan(models.Model):
"""Tier comprable de una app (global)."""
app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="plans")
slug = models.SlugField(max_length=100)
name = models.CharField(max_length=200)
price_amount = models.BigIntegerField() # UNIDADES MENORES (centavos) — nunca float
price_currency = models.CharField(max_length=3, default="USD") # ISO 4217
interval = models.CharField( # one_time | monthly | yearly
max_length=20,
choices=[("one_time","one_time"),("monthly","monthly"),("yearly","yearly")],
default="monthly",
)
trial_days = models.PositiveIntegerField(default=0)
features = models.JSONField(default=dict) # {"broadcast": true, "analytics": false}
limits = models.JSONField(default=dict) # {"max_agents": 10}
is_active = models.BooleanField(default=True)
gateway = models.ForeignKey("PaymentGateway", on_delete=models.SET_NULL, null=True)
external_id = models.CharField(max_length=200, blank=True)
class Meta:
db_table = "billing_plan"
unique_together = [("app", "slug")]
class Entitlement(models.Model):
"""Derecho de un tenant a usar una app bajo un plan. Tenant-owned. Fuente única de enforcement."""
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="entitlements")
app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="entitlements")
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True)
status = models.CharField(
max_length=20,
choices=[("trialing","trialing"),("active","active"),("past_due","past_due"),
("grace","grace"),("suspended","suspended"),("expired","expired"),
("cancelled","cancelled"),("revoked","revoked")],
default="active",
)
started_at = models.DateTimeField(auto_now_add=True)
current_period_end = models.DateTimeField(null=True, blank=True) # suscripciones
expires_at = models.DateTimeField(null=True, blank=True) # NULL = one_time / perpetuo
cancel_at_period_end = models.BooleanField(default=False)
source = models.CharField(max_length=20, default="manual") # manual | stripe | mercadopago | wompi
metadata = models.JSONField(default=dict)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta:
db_table = "billing_entitlement"
constraints = [models.UniqueConstraint(fields=["tenant", "app"], name="uniq_tenant_app_entitlement")]
class PaymentGateway(models.Model):
"""Configuración de una pasarela de pago (global — cuenta de la plataforma)."""
slug = models.SlugField(max_length=50, primary_key=True)
display_name = models.CharField(max_length=200)
gateway_class = models.CharField(max_length=500, help_text="Dotted path a la clase gateway")
config = models.JSONField(default=dict, help_text="Credenciales encriptadas")
is_active = models.BooleanField(default=False)
supported_currencies = models.JSONField(default=list)
webhook_secret = models.CharField(max_length=500, blank=True)
class Meta:
db_table = "billing_gateway"
class Subscription(models.Model):
"""Suscripción activa de un tenant (tenant-owned)."""
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="subscriptions")
entitlement = models.ForeignKey(Entitlement, on_delete=models.SET_NULL, null=True, related_name="subscriptions")
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True)
gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
external_id = models.CharField(max_length=200, blank=True)
status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in SubscriptionStatus])
current_period_start = models.DateTimeField()
current_period_end = models.DateTimeField()
cancel_at_period_end = models.BooleanField(default=False)
cancelled_at = models.DateTimeField(null=True)
trial_end = models.DateTimeField(null=True)
metadata = models.JSONField(default=dict)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta:
db_table = "billing_subscription"
class Invoice(models.Model):
"""Factura generada (tenant-owned)."""
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="invoices")
subscription = models.ForeignKey(Subscription, on_delete=models.SET_NULL, null=True, related_name="invoices")
gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
external_id = models.CharField(max_length=200, blank=True)
invoice_number = models.CharField(max_length=50, unique=True)
amount = models.BigIntegerField() # UNIDADES MENORES
currency = models.CharField(max_length=3, default="USD")
tax_amount = models.BigIntegerField(default=0)
tax_name = models.CharField(max_length=50, blank=True, default="")
status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in InvoiceStatus], default="draft")
due_date = models.DateTimeField(null=True)
paid_at = models.DateTimeField(null=True)
line_items = models.JSONField(default=list, help_text='[{"description": "...", "amount": ..., "quantity": 1}]')
pdf_file = models.ForeignKey("infrasynth_files.StoredFile", on_delete=models.SET_NULL, null=True, related_name="+")
metadata = models.JSONField(default=dict)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta:
db_table = "billing_invoice"
class PaymentTransaction(models.Model):
"""Transacción de pago individual (tenant-owned)."""
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="payment_transactions")
invoice = models.ForeignKey(Invoice, on_delete=models.SET_NULL, null=True, related_name="transactions")
gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
external_id = models.CharField(max_length=200, blank=True)
amount = models.BigIntegerField() # UNIDADES MENORES
currency = models.CharField(max_length=3, default="USD")
status = models.CharField(max_length=30)
payment_method = models.CharField(max_length=100, blank=True)
metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta:
db_table = "billing_transaction"
BasePaymentGateway ABC
# infrasynth/billing/gateways/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class CheckoutSessionResult:
session_id: str
checkout_url: str | None = None
client_secret: str | None = None
@dataclass
class WebhookResult:
event_type: str
is_handled: bool
data: dict
class BasePaymentGateway(ABC):
"""ABC que toda pasarela de pago implementa."""
gateway_slug: str
@abstractmethod
def create_checkout_session(self, plan, user, success_url: str,
cancel_url: str) -> CheckoutSessionResult:
"""Crea una sesión de checkout en la pasarela."""
...
@abstractmethod
def handle_webhook(self, payload: dict, headers: dict) -> WebhookResult:
"""Procesa un webhook entrante de la pasarela."""
...
@abstractmethod
def cancel_subscription(self, external_id: str) -> bool:
"""Cancela una suscripción en la pasarela."""
...
@abstractmethod
def sync_subscription(self, external_id: str) -> dict:
"""Sincroniza estado de suscripción desde la pasarela."""
...
@abstractmethod
def get_invoice(self, external_id: str) -> dict:
"""Recupera factura desde la pasarela."""
...
@abstractmethod
def health_check(self) -> bool:
"""Verifica conectividad con la pasarela."""
...
Implementaciones built-in: StripeGateway, MercadoPagoGateway, WompiGateway.
Señales
subscription_created = Signal() # kwargs: user, plan_slug, gateway, external_id
subscription_cancelled = Signal() # kwargs: user, plan_slug, reason
subscription_renewed = Signal() # kwargs: user, plan_slug, new_period_end
payment_succeeded = Signal() # kwargs: user, invoice_id, amount, gateway
payment_failed = Signal() # kwargs: user, invoice_id, amount, error
invoice_generated = Signal() # kwargs: user, invoice_id, amount
invoice_paid = Signal() # kwargs: user, invoice_id, amount
API Endpoints
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
/billing/gateways/ |
GET | IsAuthenticated | Pasarelas activas |
/billing/plans/ |
GET | None | Planes disponibles |
/billing/plans/<slug>/ |
GET | None | Detalle plan |
/billing/entitlements/ |
GET | IsAuthenticated | Entitlements del tenant actual (todas las apps) |
/billing/entitlements/<app_slug>/ |
GET | IsAuthenticated | Entitlement del tenant para una app |
/billing/checkout/ |
POST | IsAuthenticated | Crear checkout {app, plan} (tenant del token) |
/billing/subscriptions/ |
GET | IsAuthenticated | Suscripciones del tenant actual |
/billing/subscriptions/<id>/ |
GET | IsAuthenticated | Detalle suscripción |
/billing/subscriptions/<id>/cancel/ |
POST | IsAuthenticated | Cancelar suscripción |
/billing/subscribe/<plan_slug>/ |
POST | IsAuthenticated | Crear checkout (retorna redirect URL) |
/billing/invoices/ |
GET | IsAuthenticated | Facturas del usuario |
/billing/invoices/<id>/ |
GET | IsAuthenticated | Detalle factura |
/billing/invoices/<id>/download/ |
GET | IsAuthenticated | Descargar PDF |
/billing/webhook/<gateway_slug>/ |
POST | None (público) | Webhook de pasarela |
Configuración Externalizable
INFRASYNTH_BILLING = {
"INVOICE_NUMBER_PREFIX": "INV-",
"INVOICE_PDF_TEMPLATE": "billing/invoice_pdf.html",
"GRACE_PERIOD_DAYS": 5,
"MAX_RETRY_FAILED_PAYMENTS": 3,
"DEFAULT_CURRENCY": "USD",
"TAX_PERCENTAGE": 0,
"TAX_NAME": "",
"INVOICE_GENERATION_DAYS_BEFORE_RENEWAL": 3,
"WEBHOOK_TOLERANCE_SECONDS": 300,
"SYNC_SUBSCRIPTIONS_EVERY_HOURS": 24,
}
2.11 infrasynth.tenancy — Tenants, Membresía y Contexto
Feature flag: tenancy (default: True — es core)
Dependencias: infrasynth.shared, infrasynth.audit
Propósito: proveer el modelo de tenant, la membresía usuario↔tenant, el contexto de request y los managers scopeados que hacen cumplir el aislamiento. Es la implementación de ../TENANCY.md.
Modelos
class Tenant(models.Model):
"""Una empresa cliente. PK UUID para exponerla con seguridad."""
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
slug = models.SlugField(max_length=100, unique=True) # handle público, ej. "acme"
name = models.CharField(max_length=200)
status = models.CharField(
max_length=20,
choices=[("trialing","trialing"),("active","active"),
("suspended","suspended"),("archived","archived")],
default="active",
)
locale = models.CharField(max_length=10, default="es")
timezone = models.CharField(max_length=64, default="UTC")
metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
suspended_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "tenancy_tenant"
class TenantMembership(models.Model):
"""Pertenencia de un usuario a un tenant. Única forma correcta de ligar usuario y tenant."""
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="memberships")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="tenant_memberships")
role = models.CharField(max_length=50, default="member") # slug de rol dentro del tenant
is_owner = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "tenancy_membership"
unique_together = [("tenant", "user")]
indexes = [models.Index(fields=["user", "is_active"])]
Contexto y Managers
# infrasynth/tenancy/context.py
from contextvars import ContextVar
current_tenant: ContextVar[Tenant | None] = ContextVar("current_tenant", default=None)
# infrasynth/tenancy/managers.py
class TenantManager(models.Manager):
"""Manager por defecto de todo modelo tenant-owned. Scopea al tenant actual."""
def get_queryset(self):
tenant = current_tenant.get()
if tenant is None:
return super().get_queryset().none() # fail closed
return super().get_queryset().filter(tenant_id=tenant.id)
def unsafe_all(self):
"""Escape hatch explícito para código de sistema. Nunca desde una vista."""
return super().get_queryset()
class AllObjectsManager(models.Manager):
"""Manager sin scope (`all_objects`) para migraciones, admin y platform staff."""
Middleware
TenantMiddleware corre después de la autenticación. Lee el claim tenant del token, verifica que la membresía siga activa (si no, 403 AUTH_MEMBERSHIP_REVOKED — nunca espera a que expire el token), setea current_tenant y limpia el contexto al terminar. Rechaza requests a endpoints de tenant cuando no hay tenant resuelto, salvo el allowlist (login, select/switch-workspace, health, webhooks, catálogo).
TenantService
class TenantService:
def get_active_memberships(self, user) -> list[TenantMembership]: ...
def select_tenant(self, user, tenant_id) -> Tenant: ... # valida membresía activa
def switch_tenant(self, user, tenant_id) -> tuple[str, str]: ... # (access, refresh) nuevos
def create_tenant(self, name, owner, slug=None) -> Tenant: ... # crea tenant + membership owner
def invite(self, tenant, email, role) -> Invitation: ...
def suspend(self, tenant, reason) -> None: ...
def reinstate(self, tenant) -> None: ...
def offboard(self, tenant) -> None: ... # export → archive → delete diferido
API Endpoints
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
/auth/select-workspace/ |
POST | None (pre-auth) | Elegir workspace tras login multi-workspace. Emite tokens con claim tenant |
/auth/switch-workspace/ |
POST | IsAuthenticated | Cambiar de workspace (rota refresh token). Auditado |
/tenancy/tenants/ |
GET, POST | IsAuthenticated | Listar/crear tenants del usuario |
/tenancy/tenants/<id>/ |
GET, PUT | tenancy.manage_tenant |
Detalle/edición del tenant |
/tenancy/tenants/<id>/members/ |
GET, POST | tenancy.manage_members |
Listar/invitar miembros |
/tenancy/tenants/<id>/members/<id>/ |
DELETE | tenancy.manage_members |
Revocar membresía (invalida sesión) |
Configuración Externalizable
INFRASYNTH_TENANCY = {
"TENANT_MODEL": "infrasynth.tenancy.Tenant",
"MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership",
"TENANT_CLAIM": "tenant", # nombre del claim en el JWT
"REQUIRE_TENANT_BY_DEFAULT": True, # endpoints sin tenant → 403 salvo allowlist
"TENANT_ALLOWLIST_PATHS": ["/api/v1/auth/", "/api/v1/billing/webhook/", "/healthz", "/readyz"],
"ENABLE_WORKSPACE_SWITCHING": True,
"DEFAULT_LOCALE": "es",
"DEFAULT_TIMEZONE": "UTC",
}
Patrón de Integración para App B
# App B: helpdesk/models.py — un modelo tenant-owned
from infrasynth.tenancy.managers import TenantManager, AllObjectsManager
class Ticket(models.Model):
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+")
subject = models.CharField(max_length=255)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta:
constraints = [models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_ticket_slug_per_tenant")]
# App B: cualquier vista — el manager ya scopea; no se filtra a mano
Ticket.objects.all() # solo tickets del tenant actual
Ticket.all_objects.all() # TODOS los tenants — solo para admin/management
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
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
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
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(nuncaNonesilencioso). - Fail closed: sin tenant sólo se lee la fila global y el default; nunca una fila de otro tenant.
tenant=Nonecae aget_current_tenant(). - Tipado/secretos: los secretos se descifran al leer y nunca se devuelve cifrado.
- Caché: alias
INFRASYNTH_CONFIGS["CACHE_BACKEND"], prefijoCACHE_KEY_PREFIX, TTLCACHE_TTL_SECONDS. Clavesf"{prefix}:tenant:{pk}:configs:{key}"yf"{prefix}:tenant:global:configs:{key}". Escrituras yresetinvalidan. - Escrituras:
transaction.atomic+update_or_createsobreall_objects,invalidate, y emisión de señales.set_globalrequiereALLOW_GLOBAL_WRITES(si no,AuthError).
Señales públicas (signals.py)
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/<key>/ |
GET | autenticado | Valor efectivo + metadata (type, is_secret, is_overridden, default) |
/configs/<key>/ |
PUT | configs.manage |
Set del override del tenant (coercido/validado) |
/configs/<key>/ |
DELETE | configs.manage |
Reset del override al global/default |
/configs/definitions/ |
GET | autenticado | Esquema de claves registradas (para formularios) |
/configs/global/<key>/ |
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
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
# 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)
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:
- Derivación de modelos — todo modelo concreto aporta
{app_label}.{verb}_{model}paraview/add/change/delete(denylist de internos: sesiones, admin, contenttypes, celery, token_blacklist, logs de audit, el propio catálogo). PermissionRegistry.register(...)— permisos custom declarados enapps.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)
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 M2MRole.users, aplican en todos los tenants; requierenplatform.roles.managepara crear/editar. - Roles de tenant: se asignan por
RoleAssignment(tenant, user, role)(varios roles por usuario y tenant); requierensecurity.manage_roles. Grant/Revokeson global-o-tenant (tenant IS NULL= global). Al crear sinscopese fijan al tenant actual;{"scope": "global"}(requiereplatform.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/<id>/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
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
# 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):
# billing NO debe hacer esto:
from infrasynth.notifications.services import NotificationService
NotificationService().send(...)
Bien (desacoplado via signals):
# billing emite señal
from infrasynth.billing.signals import payment_succeeded
payment_succeeded.send(sender=PaymentGateway, user=user, invoice_id=inv.id, ...)
# App B (o el proyecto consumidor) conecta billing con notifications
# en un archivo de receivers propio
from infrasynth.billing.signals import payment_succeeded as billing_payment_ok
from infrasynth.notifications.services import NotificationService
@receiver(billing_payment_ok)
def send_payment_receipt(sender, user, invoice_id, amount, gateway, **kwargs):
NotificationService().send(
recipients=[user.email],
template_slug="billing.payment_receipt",
context={"invoice_id": invoice_id, "amount": amount},
)
El proyecto consumidor es el encargado de conectar las apps entre sí cuando se necesita comunicación directa. Las apps base solo emiten señales y exponen registries.
3.4 Grafo de Dependencias
infrasynth.shared
↑
├── infrasynth.audit
│ ↑
│ ├── infrasynth.tenancy ← define el aislamiento; todas las apps lo usan
│ ├── infrasynth.security
│ ├── infrasynth.files
│ ├── infrasynth.notifications
│ ├── infrasynth.webhooks
│ ├── infrasynth.workflows
│ ├── infrasynth.scheduler
│ ├── infrasynth.features
│ └── infrasynth.billing ← usa tenancy (los entitlements son tenant-owned)
│
│ (Todas las apps de infraestructura dependen solo de shared + audit;
│ las apps tenant-owned usan infrasynth.tenancy para managers y contexto)
│
└── infrasynth.features ← es el orquestador transversal de flags operativos
↑
(Todas las apps registran sus flags aquí, pero NO importan features)
Las apps no importan infrasynth.features directamente. El FeatureService se usa via import_string o se inyecta en las views.
4. Estrategia de Versionado
4.1 Versión Única
Todo el ecosistema comparte una sola versión en pyproject.toml:
[project]
name = "infrasynth-base"
version = "1.0.0"
Esto simplifica la instalación y garantiza compatibilidad entre apps.
4.2 SemVer
| Bump | Disparador |
|---|---|
| MAJOR | Cambio de API pública: modelo, endpoint, señal, setting contract |
| MINOR | Nueva funcionalidad backward-compatible: nuevo endpoint, nuevo campo nullable, nuevo flag |
| PATCH | Bug fix, optimización, seguridad |
4.3 Migraciones
Cada app Django incluye sus propias migraciones. Para evitar colisiones entre apps, el proyecto consumidor configura:
MIGRATION_MODULES = {
"infrasynth_audit": "infrasynth.audit.migrations",
"infrasynth_security": "infrasynth.security.migrations",
# ...
}
4.4 Garantías de Compatibilidad
- Modelos: Solo se añaden campos (nunca se remueven). Campos deprecados se marcan con
help_text="[DEPRECATED]". - Endpoints: Solo se añaden. Endpoints deprecados retornan header
Deprecation: true. - Señales: Solo se añaden kwargs, nunca se remueven.
- Settings: Solo se añaden keys con defaults. Keys renombradas tienen fallback automático.
5. Stack Técnico
5.1 Dependencias Core (pyproject.toml)
[project]
name = "infrasynth-base"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [
"django>=5.2,<6.0",
"djangorestframework>=3.16,<4.0",
"django-cors-headers>=4.7",
"djangorestframework-simplejwt>=5.5",
"django-filter>=25.1",
"psycopg2-binary>=2.9",
"python-dotenv>=1.0",
"cryptography>=44.0",
"pydantic>=2.0",
"pyotp>=2.10",
"qrcode[pil]>=8.1",
"celery[redis]>=5.4",
"django-celery-results>=2.5",
"django-celery-beat>=2.7",
"boto3>=1.35",
"django-storages>=1.14",
"Pillow>=11.0",
"twilio>=9.0",
"stripe>=10.0",
"mercadopago>=3.0",
"requests>=2.32",
"flower>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-django>=4.8",
"pytest-cov>=5.0",
"factory-boy>=3.3",
"faker>=28.0",
"ruff>=0.6",
"mypy>=1.11",
"pre-commit>=3.8",
]
5.2 Docker Compose de Desarrollo
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: infrasynth
POSTGRES_USER: infrasynth
POSTGRES_PASSWORD: infrasynth
ports: ["5432:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
worker:
build: .
command: celery -A config worker -l info -Q default,webhooks,notifications,billing
depends_on: [redis, db]
volumes: [".:/app"]
beat:
build: .
command: celery -A config beat -l info
depends_on: [redis, db]
volumes: [".:/app"]
flower:
image: mher/flower
ports: ["5555:5555"]
environment:
CELERY_BROKER_URL: redis://redis:6379/0
depends_on: [redis]
volumes:
pgdata:
5.3 Recomendaciones Production
- Web Server: Gunicorn (sync,
workers = 2*CPU + 1,threads = 4) - Reverse Proxy: Nginx (static files, rate limiting, SSL termination)
- DB: PostgreSQL 16 + PgBouncer (connection pooling)
- Cache: Redis (caching + sessions + Celery broker)
- Monitoring: Sentry (errors) + Prometheus + Grafana (metrics)
- Logging: structlog → JSON stdout → Loki + Grafana
- CI/CD: GitHub Actions (tests + lint + docker build)
6. Configuración de Settings para App B
Ejemplo completo de settings.py del proyecto consumidor:
import os
import dotenv
from pathlib import Path
dotenv.load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv("SECRET_KEY")
DEBUG = os.getenv("DEBUG", "False") == "True"
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# InfraSynth Base (TODAS las apps, features controla visibilidad)
"infrasynth.tenancy",
"infrasynth.audit",
"infrasynth.security",
"infrasynth.files",
"infrasynth.notifications",
"infrasynth.webhooks",
"infrasynth.workflows",
"infrasynth.scheduler",
"infrasynth.features",
"infrasynth.billing",
# App B — Apps de dominio
"helpdesk",
"knowledge_base",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"infrasynth.security.auth.middleware.JWTAuthenticationMiddleware",
"infrasynth.tenancy.middleware.TenantMiddleware",
"infrasynth.security.two_factor.middleware.TwoFactorMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"infrasynth.audit.middleware.AuditAPIMiddleware",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"infrasynth.security.auth.cookies.CookieJWTAuthentication",
"infrasynth.security.auth.api_keys.APIKeyAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_RENDERER_CLASSES": ["infrasynth.api.renderers.EnvelopeJSONRenderer"],
"EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler",
"DEFAULT_PAGINATION_CLASS": "infrasynth.api.pagination.CursorPagination",
"PAGE_SIZE": 25,
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}
# ===================================================================
# InfraSynth Configuration
# ===================================================================
INFRASYNTH_SECURITY = {
"ACCESS_TOKEN_LIFETIME_MINUTES": 30,
"REFRESH_TOKEN_LIFETIME_DAYS": 7,
"COOKIE_SECURE": not DEBUG,
"CRYPTO_KEY": os.getenv("CRYPTO_KEY"),
"TWO_FACTOR_ISSUER_NAME": "HelpDesk Pro",
}
INFRASYNTH_TENANCY = {
"TENANT_MODEL": "infrasynth.tenancy.Tenant",
"MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership",
"TENANT_CLAIM": "tenant",
"REQUIRE_TENANT_BY_DEFAULT": True,
"DEFAULT_LOCALE": "es",
"DEFAULT_TIMEZONE": "UTC",
}
INFRASYNTH_FILES = {
"DEFAULT_STORAGE_BACKEND": "S3",
"STORAGE_BACKENDS": {
"S3": {
"ACCESS_KEY": os.getenv("AWS_ACCESS_KEY_ID"),
"SECRET_KEY": os.getenv("AWS_SECRET_ACCESS_KEY"),
"BUCKET_NAME": os.getenv("AWS_S3_BUCKET"),
"REGION": os.getenv("AWS_REGION", "us-east-1"),
},
},
"MAX_UPLOAD_SIZE_MB": 50,
}
INFRASYNTH_NOTIFICATIONS = {
"DEFAULT_FROM_EMAIL": "helpdesk@example.com",
"CHANNELS": {
"email": {
"primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel",
},
},
}
INFRASYNTH_BILLING = {
"DEFAULT_CURRENCY": "COP",
"TAX_PERCENTAGE": 19,
"TAX_NAME": "IVA",
}
INFRASYNTH_SCHEDULER = {
"CELERY_BROKER_URL": os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
}
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
# ============================================================
# 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 |