Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 90 additions & 7 deletions django-backend/soroscan/ingest/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
import json

from .models import (
AdminAction,
AdminAuditLog,
AlertExecution,
AlertRule,
AdminAction,
APIKey,
ArchivalAuditLog,
ArchivedEventBatch,
Expand Down Expand Up @@ -81,18 +82,44 @@ def _normalize_changes(self, message):
return {"message": str(message)}

def _audit(self, request, obj, action: str, message) -> None:
user = request.user if getattr(request.user, "is_authenticated", False) else None
ip = self._client_ip(request)
changes = self._normalize_changes(message)
content_type = f"{obj._meta.app_label}.{obj._meta.model_name}"
object_id = str(getattr(obj, "pk", ""))
object_repr = str(obj)[:200]

try:
AdminAction.objects.create(
user=request.user if getattr(request.user, "is_authenticated", False) else None,
user=user,
action=action,
object_type=obj._meta.model_name[:32],
object_id=str(getattr(obj, "pk", "")),
ip_address=self._client_ip(request),
changes=self._normalize_changes(message),
object_id=object_id,
ip_address=ip,
changes=changes,
)
except Exception:
# Audit failures should not block admin operations.
return
pass

try:
# Map legacy action names to AdminAuditLog choices
audit_action = {
"add": AdminAuditLog.ACTION_CREATE,
"change": AdminAuditLog.ACTION_UPDATE,
"delete": AdminAuditLog.ACTION_DELETE,
}.get(action, action)
AdminAuditLog.objects.create(
user=user,
action=audit_action,
object_repr=object_repr,
object_id=object_id,
content_type=content_type,
ip_address=ip,
changes=changes,
)
except Exception:
# Audit failures should never block admin operations.
pass

def log_addition(self, request, obj, message):
super().log_addition(request, obj, message)
Expand Down Expand Up @@ -1213,3 +1240,59 @@ class ContractABIVersionAdmin(admin.ModelAdmin):
list_filter = ["has_breaking_changes", "created_at"]
search_fields = ["contract__contract_id", "contract__name"]
readonly_fields = ["created_at"]


# ---------------------------------------------------------------------------
# AdminAuditLog — read-only view of all admin CRUD actions
# ---------------------------------------------------------------------------

@admin.register(AdminAuditLog)
class AdminAuditLogAdmin(admin.ModelAdmin):
"""Read-only admin view for the AdminAuditLog audit trail."""

list_display = [
"timestamp",
"action_colored",
"content_type",
"object_id",
"object_repr",
"user",
"ip_address",
]
list_filter = ["action", "content_type", "timestamp"]
search_fields = ["object_id", "object_repr", "user__username", "ip_address", "content_type"]
readonly_fields = [
"user",
"action",
"object_repr",
"object_id",
"content_type",
"changes",
"ip_address",
"timestamp",
]
ordering = ["-timestamp"]
date_hierarchy = "timestamp"

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False

@admin.display(description="Action")
def action_colored(self, obj):
colors = {
AdminAuditLog.ACTION_CREATE: "#28a745",
AdminAuditLog.ACTION_UPDATE: "#007bff",
AdminAuditLog.ACTION_DELETE: "#dc3545",
}
color = colors.get(obj.action, "#6c757d")
return format_html(
'<span style="color:{};font-weight:bold">{}</span>',
color,
obj.get_action_display(),
)
123 changes: 123 additions & 0 deletions django-backend/soroscan/ingest/migrations/0037_adminauditlog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""
Migration: add AdminAuditLog model for tracking Django Admin CRUD actions.
"""
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("ingest", "0036_trackedcontract_network"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="AdminAuditLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"action",
models.CharField(
choices=[
("create", "Create"),
("update", "Update"),
("delete", "Delete"),
],
db_index=True,
help_text="Type of admin action performed",
max_length=16,
),
),
(
"object_repr",
models.CharField(
help_text="String representation of the affected object",
max_length=200,
),
),
(
"object_id",
models.CharField(
db_index=True,
help_text="Primary key of the affected object",
max_length=255,
),
),
(
"content_type",
models.CharField(
db_index=True,
help_text="app_label.model_name of the affected object",
max_length=100,
),
),
(
"changes",
models.JSONField(
default=dict,
help_text="Field-level changes: {field: [old, new]} for updates",
),
),
(
"ip_address",
models.GenericIPAddressField(
blank=True,
help_text="IP address of the admin user",
null=True,
),
),
(
"timestamp",
models.DateTimeField(auto_now_add=True, db_index=True),
),
(
"user",
models.ForeignKey(
blank=True,
help_text="Admin user who performed the action",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="admin_audit_logs",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Admin Audit Log",
"verbose_name_plural": "Admin Audit Logs",
"ordering": ["-timestamp"],
},
),
migrations.AddIndex(
model_name="adminauditlog",
index=models.Index(
fields=["action", "timestamp"],
name="ingest_adminauditlog_action_ts_idx",
),
),
migrations.AddIndex(
model_name="adminauditlog",
index=models.Index(
fields=["content_type", "object_id"],
name="ingest_adminauditlog_ct_obj_idx",
),
),
migrations.AddIndex(
model_name="adminauditlog",
index=models.Index(
fields=["user", "timestamp"],
name="ingest_adminauditlog_user_ts_idx",
),
),
]
84 changes: 84 additions & 0 deletions django-backend/soroscan/ingest/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2105,3 +2105,87 @@ class Meta:

def __str__(self):
return f"ABI v{self.version_number} for {self.contract.contract_id[:8]}... (ledger {self.valid_from_ledger}–{self.valid_to_ledger or '∞'})"


# ---------------------------------------------------------------------------
# AdminAuditLog: dedicated audit trail for Django Admin CRUD actions
# ---------------------------------------------------------------------------

class AdminAuditLog(models.Model):
"""
Immutable audit trail for every create, update, and delete action
performed through the Django Admin interface.

Records are append-only: save() blocks updates and delete() is blocked
entirely to preserve the integrity of the audit trail.
"""

ACTION_CREATE = "create"
ACTION_UPDATE = "update"
ACTION_DELETE = "delete"
ACTION_CHOICES = [
(ACTION_CREATE, "Create"),
(ACTION_UPDATE, "Update"),
(ACTION_DELETE, "Delete"),
]

user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="admin_audit_logs",
help_text="Admin user who performed the action",
)
action = models.CharField(
max_length=16,
choices=ACTION_CHOICES,
db_index=True,
help_text="Type of admin action performed",
)
object_repr = models.CharField(
max_length=200,
help_text="String representation of the affected object",
)
object_id = models.CharField(
max_length=255,
db_index=True,
help_text="Primary key of the affected object",
)
content_type = models.CharField(
max_length=100,
db_index=True,
help_text="app_label.model_name of the affected object",
)
changes = models.JSONField(
default=dict,
help_text="Field-level changes: {field: [old, new]} for updates",
)
ip_address = models.GenericIPAddressField(
null=True,
blank=True,
help_text="IP address of the admin user",
)
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)

class Meta:
ordering = ["-timestamp"]
verbose_name = "Admin Audit Log"
verbose_name_plural = "Admin Audit Logs"
indexes = [
models.Index(fields=["action", "timestamp"], name="ingest_adminauditlog_action_ts_idx"),
models.Index(fields=["content_type", "object_id"], name="ingest_adminauditlog_ct_obj_idx"),
models.Index(fields=["user", "timestamp"], name="ingest_adminauditlog_user_ts_idx"),
]

def save(self, *args, **kwargs):
if self.pk:
raise ValidationError("AdminAuditLog is immutable and cannot be updated.")
super().save(*args, **kwargs)

def delete(self, *args, **kwargs):
raise ValidationError("AdminAuditLog is immutable and cannot be deleted.")

def __str__(self):
username = self.user.username if self.user else "anonymous"
return f"[{self.action}] {self.content_type}:{self.object_id} by {username} @ {self.timestamp}"
14 changes: 14 additions & 0 deletions django-backend/soroscan/ingest/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from factory.django import DjangoModelFactory

from soroscan.ingest.models import (
AdminAuditLog,
ContractABI,
ContractEvent,
ContractMetadata,
Expand Down Expand Up @@ -152,3 +153,16 @@ class Meta:
documentation_url = ""
github_repo = ""
team_email = ""


class AdminAuditLogFactory(DjangoModelFactory):
class Meta:
model = AdminAuditLog

user = factory.SubFactory(UserFactory)
action = AdminAuditLog.ACTION_UPDATE
object_repr = factory.Sequence(lambda n: f"Object {n}")
object_id = factory.Sequence(lambda n: str(n))
content_type = "ingest.trackedcontract"
changes = factory.LazyFunction(dict)
ip_address = "127.0.0.1"
Loading
Loading