errrr
This commit is contained in:
3
app/apps/audit/__init__.py
Normal file
3
app/apps/audit/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
default_app_config = "apps.audit.apps.AuditConfig"
|
||||
|
||||
|
||||
91
app/apps/audit/admin.py
Normal file
91
app/apps/audit/admin.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin
|
||||
from unfold.decorators import action # type: ignore
|
||||
|
||||
from .models import AuditEventType, AuditLog
|
||||
|
||||
|
||||
@admin.register(AuditEventType)
|
||||
class AuditEventTypeAdmin(ModelAdmin):
|
||||
list_display = ("key", "title", "default_severity", "created_at")
|
||||
search_fields = ("key", "title", "description")
|
||||
list_filter = ("default_severity",)
|
||||
ordering = ("key",)
|
||||
compressed_fields = True
|
||||
|
||||
|
||||
@admin.register(AuditLog)
|
||||
class AuditLogAdmin(ModelAdmin):
|
||||
date_hierarchy = "created_at"
|
||||
list_display = (
|
||||
"created_at",
|
||||
"severity",
|
||||
"event_type",
|
||||
"actor",
|
||||
"object_repr",
|
||||
"source",
|
||||
"ip_address",
|
||||
)
|
||||
list_filter = (
|
||||
"severity",
|
||||
"source",
|
||||
"event_type",
|
||||
("actor", admin.RelatedOnlyFieldListFilter),
|
||||
"created_at",
|
||||
)
|
||||
search_fields = (
|
||||
"message",
|
||||
"object_repr",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"request_id",
|
||||
"metadata",
|
||||
"actor__username",
|
||||
"actor__email",
|
||||
)
|
||||
readonly_fields = (
|
||||
"created_at",
|
||||
"actor",
|
||||
"event_type",
|
||||
"message",
|
||||
"severity",
|
||||
"source",
|
||||
"target_content_type",
|
||||
"target_object_id",
|
||||
"object_repr",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"request_id",
|
||||
"metadata",
|
||||
)
|
||||
compressed_fields = True
|
||||
list_per_page = 50
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"created_at",
|
||||
"event_type",
|
||||
"severity",
|
||||
"message",
|
||||
"source",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Actor",
|
||||
{"fields": ("actor", "ip_address", "user_agent", "request_id")},
|
||||
),
|
||||
(
|
||||
"Target",
|
||||
{"fields": ("target_content_type", "target_object_id", "object_repr")},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{"fields": ("metadata",)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
15
app/apps/audit/apps.py
Normal file
15
app/apps/audit/apps.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuditConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.audit"
|
||||
label = "audit"
|
||||
verbose_name = "Audit"
|
||||
|
||||
def ready(self) -> None:
|
||||
# Import signal handlers
|
||||
from . import signals # noqa: F401
|
||||
return super().ready()
|
||||
|
||||
|
||||
60
app/apps/audit/migrations/0001_initial.py
Normal file
60
app/apps/audit/migrations/0001_initial.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 5.1 on 2025-11-11 15:25
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AuditEventType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.SlugField(help_text='Stable machine key, e.g., user_login', max_length=64, unique=True)),
|
||||
('title', models.CharField(help_text='Human-readable title', max_length=128)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('default_severity', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('critical', 'Critical')], db_index=True, default='info', max_length=16)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Audit event type',
|
||||
'verbose_name_plural': 'Audit event types',
|
||||
'ordering': ['key'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AuditLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, editable=False)),
|
||||
('message', models.TextField(help_text='Summary describing the action in human terms, snapshot at time of event')),
|
||||
('severity', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('critical', 'Critical')], db_index=True, default='info', max_length=16)),
|
||||
('source', models.CharField(choices=[('ui', 'UI'), ('api', 'API'), ('system', 'System')], db_index=True, default='ui', max_length=16)),
|
||||
('target_object_id', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('object_repr', models.CharField(blank=True, help_text='String representation of the target object at event time', max_length=255)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('request_id', models.CharField(blank=True, help_text='Correlation id, if available', max_length=64)),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL)),
|
||||
('event_type', models.ForeignKey(help_text='Type of the event', on_delete=django.db.models.deletion.PROTECT, related_name='audit_logs', to='audit.auditeventtype')),
|
||||
('target_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_target', to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Audit log',
|
||||
'verbose_name_plural': 'Audit logs',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['created_at'], name='audit_audit_created_2c1626_idx'), models.Index(fields=['severity'], name='audit_audit_severit_f33e16_idx'), models.Index(fields=['source'], name='audit_audit_source_5d49a7_idx'), models.Index(fields=['actor', 'created_at'], name='audit_audit_actor_i_ec0f72_idx'), models.Index(fields=['target_content_type', 'target_object_id'], name='audit_audit_target__f79146_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
19
app/apps/audit/migrations/0002_alter_auditlog_event_type.py
Normal file
19
app/apps/audit/migrations/0002_alter_auditlog_event_type.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1 on 2025-11-11 15:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('audit', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='auditlog',
|
||||
name='event_type',
|
||||
field=models.ForeignKey(help_text='Type of event', on_delete=django.db.models.deletion.PROTECT, related_name='audit_logs', to='audit.auditeventtype'),
|
||||
),
|
||||
]
|
||||
3
app/apps/audit/migrations/__init__.py
Normal file
3
app/apps/audit/migrations/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# empty
|
||||
|
||||
|
||||
112
app/apps/audit/models.py
Normal file
112
app/apps/audit/models.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class AuditEventType(models.Model):
|
||||
"""
|
||||
Catalog of audit event types (e.g., user_login, secret_updated).
|
||||
Useful for consistent naming, severity, and descriptions.
|
||||
"""
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
INFO = "info", "Info"
|
||||
WARNING = "warning", "Warning"
|
||||
ERROR = "error", "Error"
|
||||
CRITICAL = "critical", "Critical"
|
||||
|
||||
key = models.SlugField(max_length=64, unique=True, help_text="Stable machine key, e.g., user_login")
|
||||
title = models.CharField(max_length=128, help_text="Human-readable title")
|
||||
description = models.TextField(blank=True)
|
||||
default_severity = models.CharField(
|
||||
max_length=16, choices=Severity.choices, default=Severity.INFO, db_index=True
|
||||
)
|
||||
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Audit event type"
|
||||
verbose_name_plural = "Audit event types"
|
||||
ordering = ["key"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.key} ({self.default_severity})"
|
||||
|
||||
|
||||
class AuditLog(models.Model):
|
||||
"""
|
||||
An immutable audit record of something that happened in the system.
|
||||
"""
|
||||
|
||||
class Source(models.TextChoices):
|
||||
UI = "ui", "UI"
|
||||
API = "api", "API"
|
||||
SYSTEM = "system", "System"
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
INFO = "info", "Info"
|
||||
WARNING = "warning", "Warning"
|
||||
ERROR = "error", "Error"
|
||||
CRITICAL = "critical", "Critical"
|
||||
|
||||
created_at = models.DateTimeField(default=timezone.now, db_index=True, editable=False)
|
||||
|
||||
# Who did it
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="audit_logs",
|
||||
)
|
||||
|
||||
# What happened
|
||||
event_type = models.ForeignKey(
|
||||
AuditEventType, on_delete=models.PROTECT, related_name="audit_logs", help_text="Type of event"
|
||||
)
|
||||
message = models.TextField(
|
||||
help_text="Summary describing the action in human terms, snapshot at time of event"
|
||||
)
|
||||
severity = models.CharField(
|
||||
max_length=16, choices=Severity.choices, default=Severity.INFO, db_index=True
|
||||
)
|
||||
source = models.CharField(max_length=16, choices=Source.choices, default=Source.UI, db_index=True)
|
||||
|
||||
# Which object it touched (optional)
|
||||
target_content_type = models.ForeignKey(
|
||||
ContentType, null=True, blank=True, on_delete=models.SET_NULL, related_name="audit_target"
|
||||
)
|
||||
target_object_id = models.CharField(max_length=64, null=True, blank=True)
|
||||
target = GenericForeignKey("target_content_type", "target_object_id")
|
||||
object_repr = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="String representation of the target object at event time",
|
||||
)
|
||||
|
||||
# Request context
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True)
|
||||
request_id = models.CharField(max_length=64, blank=True, help_text="Correlation id, if available")
|
||||
|
||||
# Arbitrary extra data
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Audit log"
|
||||
verbose_name_plural = "Audit logs"
|
||||
indexes = [
|
||||
models.Index(fields=["created_at"]),
|
||||
models.Index(fields=["severity"]),
|
||||
models.Index(fields=["source"]),
|
||||
models.Index(fields=["actor", "created_at"]),
|
||||
models.Index(fields=["target_content_type", "target_object_id"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
actor = getattr(self.actor, "username", "system")
|
||||
return f"[{self.created_at:%Y-%m-%d %H:%M:%S}] {actor}: {self.message}"
|
||||
52
app/apps/audit/signals.py
Normal file
52
app/apps/audit/signals.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import AuditEventType, AuditLog
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _get_or_create_event(key: str, title: str, severity: str = AuditEventType.Severity.INFO) -> AuditEventType:
|
||||
event, _ = AuditEventType.objects.get_or_create(
|
||||
key=key,
|
||||
defaults={"title": title, "default_severity": severity},
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def on_user_logged_in(sender, request, user: User, **kwargs):
|
||||
event = _get_or_create_event("user_login", "User logged in", AuditEventType.Severity.INFO)
|
||||
AuditLog.objects.create(
|
||||
created_at=timezone.now(),
|
||||
actor=user,
|
||||
event_type=event,
|
||||
message=f"User {user} logged in",
|
||||
severity=event.default_severity,
|
||||
source=AuditLog.Source.UI,
|
||||
ip_address=(request.META.get("REMOTE_ADDR") if request else None),
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
|
||||
metadata={"path": request.path} if request else {},
|
||||
)
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def on_user_logged_out(sender, request, user: User, **kwargs):
|
||||
event = _get_or_create_event("user_logout", "User logged out", AuditEventType.Severity.INFO)
|
||||
AuditLog.objects.create(
|
||||
created_at=timezone.now(),
|
||||
actor=user,
|
||||
event_type=event,
|
||||
message=f"User {user} logged out",
|
||||
severity=event.default_severity,
|
||||
source=AuditLog.Source.UI,
|
||||
ip_address=(request.META.get("REMOTE_ADDR") if request else None),
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
|
||||
metadata={"path": request.path} if request else {},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user