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

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

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

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

174 lines
5.4 KiB
Python

import uuid
from django.conf import settings
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from .models import ModelChangeLog, SecurityEvent
from .signals import security_event_occurred
def _current_tenant():
from infrasynth.tenancy.context import get_current_tenant
return get_current_tenant()
def _get_excluded_models():
config = getattr(settings, "INFRASYNTH_AUDIT", {})
return set(config.get("EXCLUDED_MODELS", []))
def _get_excluded_fields():
config = getattr(settings, "INFRASYNTH_AUDIT", {})
return set(config.get("EXCLUDED_FIELDS", []))
def _get_request_id(request=None):
if request:
return getattr(request, "request_id", "") or str(uuid.uuid4())[:8]
return str(uuid.uuid4())[:8]
def _store_enabled() -> bool:
return bool(getattr(settings, "INFRASYNTH_AUDIT", {}).get("STORE_IN_DB", True))
@receiver(pre_save)
def capture_previous_state(sender, instance, raw, **kwargs):
"""Snapshots the current DB row onto ``instance._previous_state`` before save.
This is what lets :func:`track_model_change` compute an update diff without
requiring every domain model to opt in. Creates (no existing row) are left
alone. Excluded models are skipped so the audit tables never audit themselves.
"""
if raw or instance.pk is None:
return
config = getattr(settings, "INFRASYNTH_AUDIT", {})
if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True):
return
if sender._meta.label in _get_excluded_models():
return
manager = getattr(sender, "all_objects", None) or sender._base_manager
try:
previous = manager.filter(pk=instance.pk).first()
except Exception: # noqa: BLE001 - audit must never break a write
return
if previous is not None:
instance._previous_state = previous
@receiver(post_save)
def track_model_change(sender, instance, created, raw, **kwargs):
if raw:
return
label = sender._meta.label
if label in _get_excluded_models():
return
config = getattr(settings, "INFRASYNTH_AUDIT", {})
if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True):
return
if not _store_enabled():
return
if created:
ModelChangeLog.objects.create(
tenant=_current_tenant(),
model_label=label,
object_id=str(instance.pk),
action="create",
changes=_get_created_changes(instance),
actor=_get_actor_from_instance(instance),
request_id=_get_request_id(),
)
else:
if hasattr(instance, "_previous_state"):
changes = _compute_changes(instance._previous_state, instance)
if changes:
ModelChangeLog.objects.create(
tenant=_current_tenant(),
model_label=label,
object_id=str(instance.pk),
action="update",
changes=changes,
actor=_get_actor_from_instance(instance),
request_id=_get_request_id(),
)
@receiver(post_delete)
def track_model_delete(sender, instance, **kwargs):
label = sender._meta.label
if label in _get_excluded_models():
return
config = getattr(settings, "INFRASYNTH_AUDIT", {})
if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True):
return
if not _store_enabled():
return
ModelChangeLog.objects.create(
tenant=_current_tenant(),
model_label=label,
object_id=str(instance.pk),
action="delete",
changes={},
actor=_get_actor_from_instance(instance),
request_id=_get_request_id(),
)
def _get_actor_from_instance(instance):
for field in ["actor", "user", "usuario_creacion", "created_by", "uploaded_by"]:
val = getattr(instance, field, None)
if val is not None:
return val
return None
def _get_created_changes(instance):
excluded = _get_excluded_fields()
changes = {}
for field in instance._meta.get_fields():
if field.name in excluded:
continue
if hasattr(field, "serialize") and field.serialize:
val = getattr(instance, field.name, None)
if val is not None:
changes[field.name] = [None, str(val)]
return changes
def _compute_changes(old, new):
excluded = _get_excluded_fields()
changes = {}
for field in new._meta.get_fields():
if field.name in excluded:
continue
if not hasattr(field, "column") or field.column is None:
continue
old_val = getattr(old, field.name, None)
new_val = getattr(new, field.name, None)
if old_val != new_val:
changes[field.name] = [
str(old_val) if old_val is not None else None,
str(new_val) if new_val is not None else None,
]
return changes
@receiver(security_event_occurred)
def log_security_event(sender, **kwargs):
config = getattr(settings, "INFRASYNTH_AUDIT", {})
if not config.get("ENABLE_SECURITY_EVENTS", True):
return
if not _store_enabled():
return
SecurityEvent.objects.create(
tenant=_current_tenant(),
event_type=kwargs.get("event_type", "unknown"),
actor=kwargs.get("actor"),
ip_address=kwargs.get("ip_address"),
metadata=kwargs.get("metadata", {}),
request_id=_get_request_id(),
)