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
174 lines
5.4 KiB
Python
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(),
|
|
)
|