infrasynth-backend-kit/infrasynth/audit/receivers.py
jcv-dev a2930426b4 feat: tenant configs, live signals, and automatic permission management
- 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)
2026-09-29 17:06:54 -05:00

207 lines
6.6 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 model_changed, 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_excluded_model_fields():
config = getattr(settings, "INFRASYNTH_AUDIT", {})
return config.get("EXCLUDED_MODEL_FIELDS", {}) or {}
def _excluded_fields_for(label: str) -> set[str]:
"""Global excluded fields plus the per-model exclusions for ``label``.
Per-model exclusions let a model keep its audit trail while withholding a
specific field — e.g. ``infrasynth_configs.ConfigValue`` is audited, but its
(possibly encrypted) ``value`` payload never enters the log.
"""
fields = set(_get_excluded_fields())
fields.update(_get_excluded_model_fields().get(label, []) or [])
return fields
def _emit_model_changed(sender, log: ModelChangeLog) -> None:
model_changed.send(
sender=sender,
tenant_id=str(log.tenant_id) if log.tenant_id else None,
model_label=log.model_label,
object_id=log.object_id,
action=log.action,
changes=log.changes,
actor=log.actor,
request_id=log.request_id,
)
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:
log = 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(),
)
_emit_model_changed(sender, log)
else:
if hasattr(instance, "_previous_state"):
changes = _compute_changes(instance._previous_state, instance)
if changes:
log = 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(),
)
_emit_model_changed(sender, log)
@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
log = 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(),
)
_emit_model_changed(sender, log)
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 = _excluded_fields_for(instance._meta.label)
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 = _excluded_fields_for(new._meta.label)
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(),
)