This commit is contained in:
2025-11-11 15:51:54 +00:00
parent 99ae905cb0
commit 3e0c5d2ecc
114 changed files with 9278 additions and 929 deletions

View File

@@ -0,0 +1,3 @@
default_app_config = "apps.audit.apps.AuditConfig"

91
app/apps/audit/admin.py Normal file
View 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
View 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()

View 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')],
},
),
]

View 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'),
),
]

View File

@@ -0,0 +1,3 @@
# empty

112
app/apps/audit/models.py Normal file
View 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
View 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 {},
)