Skip to content

Commit 0661d83

Browse files
feat(pro): audit logs forwarding capabilities (#4333)
* step1: prerequisite for auditlog data enrichment with folder * backend requirements * auditlog sink * pass #1 on frontend * fixup * fix webhooks that cannot be disabled * wip * cef_experiment * combine migrations * fixup * reduce scope for now * fixup * wip * fixup * misc --------- Co-authored-by: Eric <71850047+eric-intuitem@users.noreply.github.com>
1 parent 1c2ff1c commit 0661d83

27 files changed

Lines changed: 1775 additions & 77 deletions

File tree

backend/core/base_models.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.db import models, transaction
22
from django.utils.translation import gettext_lazy as _
33
from django.urls.base import reverse_lazy
4-
from django.core.exceptions import ValidationError
4+
from django.core.exceptions import ObjectDoesNotExist, ValidationError
55
import uuid
66

77

@@ -31,6 +31,24 @@ def scoped_id(self, scope: models.QuerySet) -> int:
3131
def __str__(self) -> str:
3232
return self.name if hasattr(self, "name") and self.name else str(self.id)
3333

34+
def get_additional_data(self) -> dict:
35+
# Attached to every django-auditlog LogEntry at creation (create/update/
36+
# delete and m2m). Runs with the instance in memory, so the folder is
37+
# captured even on delete — used to scope audit events forwarded to SIEMs.
38+
folder_id = getattr(self, "folder_id", None)
39+
if folder_id is None:
40+
from iam.models import Folder
41+
42+
# Runs in auditlog's synchronous delete receiver: a cascade may have
43+
# already removed the FK target get_folder traverses, raising
44+
# DoesNotExist. Never let metadata enrichment break the delete.
45+
try:
46+
folder = Folder.get_folder(self)
47+
folder_id = folder.id if folder else None
48+
except ObjectDoesNotExist:
49+
folder_id = None
50+
return {"folder_id": str(folder_id) if folder_id else None}
51+
3452
def is_unique_in_scope(self, scope: models.QuerySet, fields_to_check: list) -> bool:
3553
"""
3654
Checks if the object is unique in the given scope based on the given fields.

backend/core/custom_middleware.py

Lines changed: 6 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,16 @@
44
from auditlog.cid import correlation_id, set_cid
55
from auditlog.context import set_extra_data
66
from django.utils.functional import SimpleLazyObject
7-
from auditlog.models import LogEntry
8-
from django.db.models.signals import post_save
9-
from django.dispatch import receiver
10-
11-
import structlog
12-
13-
logger = structlog.getLogger(__name__)
147

158

169
# Fix the actor resolution with the custom middleware
1710
# Based on https://stackoverflow.com/questions/40740061/auditlog-with-django-and-drf
11+
#
12+
# Folder/actor enrichment of LogEntry is no longer done here via a post_save
13+
# signal: folder is captured inline at log creation through
14+
# AbstractBaseModel.get_additional_data(), and actor/actor_email are native
15+
# LogEntry columns. This fixes delete events (instance still in memory) and
16+
# covers m2m changes.
1817
class AuditlogMiddleware(middleware.AuditlogMiddleware):
1918
@staticmethod
2019
def _get_actor(request):
@@ -33,42 +32,3 @@ def __call__(self, request):
3332

3433
with set_extra_data(context_data=self.get_extra_data(request)):
3534
return self.get_response(request)
36-
37-
38-
# Add a post-save signal to add the additional info after the log entry is saved
39-
# Think about the potential perf overhead of this
40-
@receiver(post_save, sender=LogEntry)
41-
def add_user_info_to_log_entry(sender, instance, created, **kwargs):
42-
if not created or not instance.actor_id:
43-
return
44-
45-
obj = None
46-
model_class = instance.content_type.model_class()
47-
if model_class is not None:
48-
try:
49-
obj = model_class.objects.get(pk=instance.object_pk)
50-
except model_class.DoesNotExist:
51-
logger.debug("audit log enrichment failed: no model_class.")
52-
pass
53-
54-
# Only update if this is a new log entry and it has an actor
55-
try:
56-
user_uuid = str(instance.actor_id)
57-
user_email = instance.actor.email
58-
folder = (
59-
"/".join([f.name for f in obj.get_folder_full_path()])
60-
if obj and hasattr(obj, "get_folder_full_path")
61-
else None
62-
)
63-
64-
LogEntry.objects.filter(pk=instance.pk).update(
65-
additional_data={
66-
"user_uuid": user_uuid,
67-
"user_email": user_email,
68-
"folder": folder,
69-
}
70-
)
71-
except Exception:
72-
# Fail silently if there's any issue
73-
logger.debug("audit log enrichment with actor failed.")
74-
pass

backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dependencies = [
2828
"fido2>=2.1.1,<3",
2929
"humanize>=4.15.0,<5",
3030
"huey>=2.5.5,<3",
31+
"kafka-python>=2.0.6,<3",
3132
"python-docx>=1.2.0,<2",
3233
"docxtpl>=0.20.2,<0.21",
3334
"numpy>=2.4.1,<3",

backend/serdes/views.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@
2020
from iam.models import User
2121
from serdes.serializers import LoadBackupSerializer
2222

23-
from auditlog.models import LogEntry
2423
from django.db.models.signals import post_save
25-
from core.custom_middleware import add_user_info_to_log_entry
2624
from django.apps import apps
2725
from django.conf import settings
2826
from auditlog.context import disable_auditlog
@@ -77,9 +75,9 @@ class LoadBackupView(APIView):
7775
serializer_class = LoadBackupSerializer
7876

7977
def load_backup(self, request, decompressed_data, backup_version, current_version):
80-
# Temporarily disconnect the problematic signal
81-
post_save.disconnect(add_user_info_to_log_entry, sender=LogEntry)
82-
78+
# The LogEntry enrichment receiver disconnected here in #1707 is gone:
79+
# folder is now captured inline via AbstractBaseModel.get_additional_data,
80+
# so loaddata of auditlog.logentry fixtures no longer triggers enrichment.
8381
backup_buffer = io.StringIO()
8482
try:
8583
management.call_command(
@@ -178,7 +176,6 @@ def fixture_callback(sender, **kwargs):
178176
return Response({}, status=status.HTTP_400_BAD_REQUEST)
179177
finally:
180178
post_save.disconnect(fixture_callback)
181-
post_save.connect(add_user_info_to_log_entry, sender=LogEntry)
182179

183180
# Enforce LICENSE_SEATS after successful restore
184181
license_seats = getattr(settings, "LICENSE_SEATS", None)

backend/uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/webhooks/apps.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
class WebhooksConfig(AppConfig):
55
default_auto_field = "django.db.models.BigAutoField"
66
name = "webhooks"
7+
8+
def ready(self):
9+
from . import signals # noqa: F401 connects the audit-log forwarding receiver
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Generated by Django 6.0.6 on 2026-06-15 09:04
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("webhooks", "0003_remove_webhookendpoint_new_owner_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="webhookendpoint",
14+
name="body_format",
15+
field=models.CharField(
16+
choices=[
17+
("ciso_native", "CISO Assistant (HMAC-signed)"),
18+
("ocsf", "OCSF"),
19+
("raw", "Raw LogEntry"),
20+
],
21+
default="ocsf",
22+
help_text="Canonical event schema for audit sinks.",
23+
max_length=20,
24+
),
25+
),
26+
migrations.AddField(
27+
model_name="webhookendpoint",
28+
name="headers",
29+
field=models.JSONField(
30+
blank=True,
31+
default=dict,
32+
help_text='Static headers added to each request, e.g. {"Authorization": "Splunk <token>"}. Used for audit-sink auth.',
33+
),
34+
),
35+
migrations.AddField(
36+
model_name="webhookendpoint",
37+
name="kafka_config",
38+
field=models.JSONField(
39+
blank=True,
40+
default=dict,
41+
help_text="Kafka transport: {bootstrap_servers, topic, config:{...}}.",
42+
),
43+
),
44+
migrations.AddField(
45+
model_name="webhookendpoint",
46+
name="kind",
47+
field=models.CharField(
48+
choices=[("integration", "Integration"), ("audit_sink", "Audit sink")],
49+
default="integration",
50+
max_length=20,
51+
),
52+
),
53+
migrations.AddField(
54+
model_name="webhookendpoint",
55+
name="transport",
56+
field=models.CharField(
57+
choices=[("http", "HTTP"), ("kafka", "Kafka")],
58+
default="http",
59+
help_text="Delivery transport (audit sinks only).",
60+
max_length=10,
61+
),
62+
),
63+
migrations.AlterField(
64+
model_name="webhookendpoint",
65+
name="secret",
66+
field=models.CharField(
67+
blank=True,
68+
default="",
69+
help_text="HMAC signing secret (integration webhooks only).",
70+
max_length=100,
71+
),
72+
),
73+
migrations.AlterField(
74+
model_name="webhookendpoint",
75+
name="url",
76+
field=models.URLField(
77+
blank=True,
78+
default="",
79+
help_text="Consumer URL (HTTP transport).",
80+
max_length=512,
81+
),
82+
),
83+
]

backend/webhooks/models.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ class PayloadFormats(models.TextChoices):
3333
THIN = "thin", "Thin"
3434
FULL = "full", "Full"
3535

36+
class Kind(models.TextChoices):
37+
INTEGRATION = "integration", "Integration"
38+
AUDIT_SINK = "audit_sink", "Audit sink"
39+
40+
class Transport(models.TextChoices):
41+
HTTP = "http", "HTTP"
42+
KAFKA = "kafka", "Kafka"
43+
44+
class BodyFormat(models.TextChoices):
45+
CISO_NATIVE = "ciso_native", "CISO Assistant (HMAC-signed)"
46+
OCSF = "ocsf", "OCSF"
47+
RAW = "raw", "Raw LogEntry"
48+
3649
payload_format = models.CharField(
3750
verbose_name="Payload Format",
3851
max_length=10,
@@ -41,6 +54,33 @@ class PayloadFormats(models.TextChoices):
4154
help_text="The format of the webhook payload sent to this endpoint.",
4255
)
4356

57+
# An "audit_sink" forwards the audit log (LogEntry stream) to an external
58+
# SIEM; an "integration" is the user-facing model-event webhook. Audit sinks
59+
# are admin/org-managed and hidden from the user webhook list.
60+
kind = models.CharField(
61+
max_length=20,
62+
choices=Kind.choices,
63+
default=Kind.INTEGRATION,
64+
)
65+
transport = models.CharField(
66+
max_length=10,
67+
choices=Transport.choices,
68+
default=Transport.HTTP,
69+
help_text="Delivery transport (audit sinks only).",
70+
)
71+
body_format = models.CharField(
72+
max_length=20,
73+
choices=BodyFormat.choices,
74+
default=BodyFormat.OCSF,
75+
help_text="Canonical event schema for audit sinks.",
76+
)
77+
headers = models.JSONField(
78+
default=dict,
79+
blank=True,
80+
help_text="Static headers added to each request, e.g. "
81+
'{"Authorization": "Splunk <token>"}. Used for audit-sink auth.',
82+
)
83+
4484
owner = models.ForeignKey(
4585
Actor,
4686
related_name="webhook_endpoints",
@@ -51,10 +91,24 @@ class PayloadFormats(models.TextChoices):
5191
)
5292

5393
url = models.URLField(
54-
max_length=512, help_text="The consumer URL to send webhook events to."
94+
max_length=512,
95+
blank=True,
96+
default="",
97+
help_text="Consumer URL (HTTP transport).",
5598
)
5699

57-
secret = models.CharField(max_length=100, help_text="HMAC signing secret.")
100+
kafka_config = models.JSONField(
101+
default=dict,
102+
blank=True,
103+
help_text="Kafka transport: {bootstrap_servers, topic, config:{...}}.",
104+
)
105+
106+
secret = models.CharField(
107+
max_length=100,
108+
blank=True,
109+
default="",
110+
help_text="HMAC signing secret (integration webhooks only).",
111+
)
58112

59113
event_types = models.ManyToManyField(
60114
WebhookEventType,
@@ -78,6 +132,8 @@ def __str__(self):
78132

79133
def clean(self):
80134
super().clean()
135+
if self.transport != self.Transport.HTTP:
136+
return
81137
if getattr(settings, "WEBHOOK_ALLOW_PRIVATE_IPS", False):
82138
return
83139
try:
@@ -91,8 +147,6 @@ def clean(self):
91147
)
92148

93149
def save(self, *args, **kwargs):
94-
"""
95-
On save, ensure a secret exists if one wasn't provided.
96-
"""
97-
self.full_clean() # Run validation
150+
"""Run full model validation (clean + field checks) before persisting."""
151+
self.full_clean()
98152
super().save(*args, **kwargs)

0 commit comments

Comments
 (0)