- 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)
207 lines
6.6 KiB
Python
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(),
|
|
)
|