django-activity-audit 1.3.0.dev18__tar.gz → 1.3.0.dev19__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_activity_audit-1.3.0.dev19/.claude/settings.local.json +7 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/PKG-INFO +4 -1
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/__init__.py +3 -0
- django_activity_audit-1.3.0.dev19/activity_audit/apps.py +17 -0
- django_activity_audit-1.3.0.dev19/activity_audit/config.py +66 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/formatters.py +4 -2
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/handlers.py +14 -8
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/middleware.py +21 -29
- django_activity_audit-1.3.0.dev19/activity_audit/shared_processors.py +128 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/signals.py +20 -17
- django_activity_audit-1.3.0.dev19/docs/hipaa-audit-gaps.md +133 -0
- django_activity_audit-1.3.0.dev19/docs/structlog-integration.md +419 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/pyproject.toml +3 -1
- django_activity_audit-1.3.0.dev18/activity_audit/apps.py +0 -22
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/.gitignore +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/.pre-commit-config.yaml +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/LICENSE +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/MANIFEST.in +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/README.md +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/README.rst +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/constants.py +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/logger_levels.py +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/protocols.py +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/settings.py +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/unregistered.py +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/utils.py +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/docs/django-activity-audit.md +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/docs/improvements.md +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/docs/user-activity-feed-plan.md +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/hatch +0 -0
- {django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/pytest.ini +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-activity-audit
|
|
3
|
-
Version: 1.3.0.
|
|
3
|
+
Version: 1.3.0.dev19
|
|
4
4
|
Summary: A Django package for easy CRUD operation logging and container logs
|
|
5
5
|
Project-URL: Homepage, https://github.com/shree256/django-activity-audit
|
|
6
6
|
Project-URL: Repository, https://github.com/shree256/django-activity-audit
|
|
@@ -30,12 +30,15 @@ Provides-Extra: dev
|
|
|
30
30
|
Requires-Dist: pre-commit>=3.5; (python_version >= '3.9') and extra == 'dev'
|
|
31
31
|
Requires-Dist: pre-commit~=3.5; (python_version < '3.9') and extra == 'dev'
|
|
32
32
|
Requires-Dist: ruff>=0.1.11; extra == 'dev'
|
|
33
|
+
Provides-Extra: structlog
|
|
34
|
+
Requires-Dist: structlog>=24.0; extra == 'structlog'
|
|
33
35
|
Provides-Extra: test
|
|
34
36
|
Requires-Dist: djangorestframework>=3.14.0; extra == 'test'
|
|
35
37
|
Requires-Dist: factory-boy>=3.2.0; extra == 'test'
|
|
36
38
|
Requires-Dist: pytest-cov>=4.0.0; extra == 'test'
|
|
37
39
|
Requires-Dist: pytest-django>=4.0.0; extra == 'test'
|
|
38
40
|
Requires-Dist: pytest>=6.0.0; extra == 'test'
|
|
41
|
+
Requires-Dist: structlog>=24.0; extra == 'test'
|
|
39
42
|
Description-Content-Type: text/markdown
|
|
40
43
|
|
|
41
44
|
# Django Activity Audit
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/__init__.py
RENAMED
|
@@ -16,6 +16,8 @@ from activity_audit.utils import (
|
|
|
16
16
|
get_login_handler,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
from .config import get_logger
|
|
20
|
+
|
|
19
21
|
from . import logger_levels
|
|
20
22
|
|
|
21
23
|
__all__ = [
|
|
@@ -32,4 +34,5 @@ __all__ = [
|
|
|
32
34
|
"get_async_api_handler",
|
|
33
35
|
"get_async_audit_handler",
|
|
34
36
|
"get_async_login_handler",
|
|
37
|
+
"get_logger",
|
|
35
38
|
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuditLoggingConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "activity_audit"
|
|
7
|
+
verbose_name = "Django Activity Audit"
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
from . import (
|
|
11
|
+
logger_levels,
|
|
12
|
+
signals, # noqa
|
|
13
|
+
unregistered, # noqa
|
|
14
|
+
)
|
|
15
|
+
from .config import configure
|
|
16
|
+
|
|
17
|
+
configure()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import structlog
|
|
2
|
+
|
|
3
|
+
from activity_audit.shared_processors import (
|
|
4
|
+
_json_default,
|
|
5
|
+
_orjson_dumps,
|
|
6
|
+
shared_processors,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuditBoundLogger(structlog.stdlib.BoundLogger):
|
|
11
|
+
"""Extends structlog BoundLogger with AUDIT, API, and LOGIN log levels."""
|
|
12
|
+
|
|
13
|
+
def audit(self, event=None, *args, **kw):
|
|
14
|
+
return self._proxy_to_logger("audit", event, *args, **kw)
|
|
15
|
+
|
|
16
|
+
def api(self, event=None, *args, **kw):
|
|
17
|
+
return self._proxy_to_logger("api", event, *args, **kw)
|
|
18
|
+
|
|
19
|
+
def login(self, event=None, *args, **kw):
|
|
20
|
+
return self._proxy_to_logger("login", event, *args, **kw)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_stdlib_formatter() -> dict:
|
|
24
|
+
"""
|
|
25
|
+
LOGGING formatter dict that routes stdlib loggers through the structlog
|
|
26
|
+
processor chain. Use for any logger that isn't a native structlog logger
|
|
27
|
+
(e.g. celery, django, uvicorn).
|
|
28
|
+
"""
|
|
29
|
+
return {
|
|
30
|
+
"()": structlog.stdlib.ProcessorFormatter,
|
|
31
|
+
"processors": [
|
|
32
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
33
|
+
structlog.processors.JSONRenderer(
|
|
34
|
+
serializer=_orjson_dumps, default=_json_default
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
"foreign_pre_chain": shared_processors,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def configure() -> None:
|
|
42
|
+
"""
|
|
43
|
+
Configure structlog for console-only JSON output.
|
|
44
|
+
|
|
45
|
+
Produces the same JSON structure as the existing AuditFormatter /
|
|
46
|
+
APIFormatter / LoginFormatter — same field names, same timestamp format,
|
|
47
|
+
same uppercase level names — but outputs to stdout via StreamHandler.
|
|
48
|
+
No file handlers are used.
|
|
49
|
+
|
|
50
|
+
merge_contextvars is wired in so Phase 2 (replacing thread-locals with
|
|
51
|
+
structlog contextvars in middleware) only requires middleware changes;
|
|
52
|
+
this function and signals.py do not need to change again.
|
|
53
|
+
"""
|
|
54
|
+
structlog.configure(
|
|
55
|
+
processors=shared_processors
|
|
56
|
+
+ [
|
|
57
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
58
|
+
],
|
|
59
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
60
|
+
wrapper_class=AuditBoundLogger,
|
|
61
|
+
cache_logger_on_first_use=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_logger(name: str) -> AuditBoundLogger:
|
|
66
|
+
return structlog.get_logger(name)
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/formatters.py
RENAMED
|
@@ -4,8 +4,9 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
import uuid
|
|
6
6
|
|
|
7
|
+
import structlog.contextvars as ctx
|
|
8
|
+
|
|
7
9
|
from .constants import LogType
|
|
8
|
-
from .middleware import get_request_id
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _json_default(obj):
|
|
@@ -55,7 +56,8 @@ class AppFormatter(logging.Formatter):
|
|
|
55
56
|
"path": record.pathname,
|
|
56
57
|
"module": record.module,
|
|
57
58
|
"function": record.funcName,
|
|
58
|
-
"request_id": getattr(record, "request_id", None)
|
|
59
|
+
"request_id": getattr(record, "request_id", None)
|
|
60
|
+
or ctx.get_contextvars().get("request_id", ""),
|
|
59
61
|
"message": record.getMessage(),
|
|
60
62
|
"exception": "",
|
|
61
63
|
"log_type": self.log_type,
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/handlers.py
RENAMED
|
@@ -3,8 +3,14 @@ import queue
|
|
|
3
3
|
|
|
4
4
|
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import structlog.contextvars as ctx
|
|
7
|
+
|
|
8
|
+
from .formatters import (
|
|
9
|
+
APIFormatter,
|
|
10
|
+
AppFormatter,
|
|
11
|
+
AuditFormatter,
|
|
12
|
+
LoginFormatter,
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
class BaseAuditHandler(RotatingFileHandler):
|
|
@@ -110,10 +116,10 @@ class AsyncBaseAuditHandler(QueueHandler):
|
|
|
110
116
|
self._listener.start()
|
|
111
117
|
|
|
112
118
|
def prepare(self, record):
|
|
113
|
-
#
|
|
114
|
-
#
|
|
119
|
+
# Snapshot request_id from contextvars before the record is queued —
|
|
120
|
+
# the QueueListener thread runs in a different context.
|
|
115
121
|
record = super().prepare(record)
|
|
116
|
-
record.request_id =
|
|
122
|
+
record.request_id = ctx.get_contextvars().get("request_id", "")
|
|
117
123
|
return record
|
|
118
124
|
|
|
119
125
|
def close(self):
|
|
@@ -170,10 +176,10 @@ class AsyncJsonHandler(QueueHandler):
|
|
|
170
176
|
self._listener.start()
|
|
171
177
|
|
|
172
178
|
def prepare(self, record):
|
|
173
|
-
#
|
|
174
|
-
#
|
|
179
|
+
# Snapshot request_id from contextvars before the record is queued —
|
|
180
|
+
# the QueueListener thread runs in a different context.
|
|
175
181
|
record = super().prepare(record)
|
|
176
|
-
record.request_id =
|
|
182
|
+
record.request_id = ctx.get_contextvars().get("request_id", "")
|
|
177
183
|
return record
|
|
178
184
|
|
|
179
185
|
def close(self):
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/middleware.py
RENAMED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import json
|
|
3
|
-
import logging
|
|
4
3
|
import re
|
|
5
4
|
import time
|
|
6
5
|
import uuid
|
|
7
6
|
|
|
7
|
+
import structlog.contextvars as ctx
|
|
8
|
+
|
|
8
9
|
from asgiref.local import Local
|
|
9
|
-
from asgiref.sync import
|
|
10
|
+
from asgiref.sync import (
|
|
11
|
+
iscoroutinefunction,
|
|
12
|
+
markcoroutinefunction,
|
|
13
|
+
sync_to_async,
|
|
14
|
+
)
|
|
10
15
|
from django.http import HttpResponse
|
|
11
16
|
from django.utils.deprecation import MiddlewareMixin
|
|
12
17
|
|
|
18
|
+
from .config import get_logger
|
|
13
19
|
from .constants import REQUEST_TYPES
|
|
14
20
|
from .settings import REGISTERED_URLS, SERVICE_NAME, UNREGISTERED_URLS
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
_log = get_logger("audit.request")
|
|
17
23
|
|
|
18
24
|
_thread_locals = Local()
|
|
19
25
|
|
|
@@ -33,14 +39,6 @@ def set_current_request(request):
|
|
|
33
39
|
_thread_locals.request = request
|
|
34
40
|
|
|
35
41
|
|
|
36
|
-
def get_request_id():
|
|
37
|
-
return getattr(_thread_locals, "request_id", None)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def set_request_id(request_id):
|
|
41
|
-
_thread_locals.request_id = request_id
|
|
42
|
-
|
|
43
|
-
|
|
44
42
|
def get_current_user():
|
|
45
43
|
request = get_current_request()
|
|
46
44
|
if request:
|
|
@@ -77,8 +75,6 @@ def get_user_details():
|
|
|
77
75
|
def clear_request():
|
|
78
76
|
with contextlib.suppress(AttributeError):
|
|
79
77
|
del _thread_locals.request
|
|
80
|
-
with contextlib.suppress(AttributeError):
|
|
81
|
-
del _thread_locals.request_id
|
|
82
78
|
|
|
83
79
|
|
|
84
80
|
def should_log_url(url):
|
|
@@ -135,7 +131,8 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
135
131
|
return self.__acall__(request)
|
|
136
132
|
set_current_request(request)
|
|
137
133
|
request_id = str(uuid.uuid4())
|
|
138
|
-
|
|
134
|
+
ctx.clear_contextvars()
|
|
135
|
+
ctx.bind_contextvars(request_id=request_id)
|
|
139
136
|
|
|
140
137
|
if not should_log_url(request.path):
|
|
141
138
|
return self.get_response(request)
|
|
@@ -144,9 +141,6 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
144
141
|
"service_name": SERVICE_NAME,
|
|
145
142
|
"request_type": REQUEST_TYPES[0],
|
|
146
143
|
"protocol": None,
|
|
147
|
-
"request_id": "",
|
|
148
|
-
"user_id": "",
|
|
149
|
-
"user_info": {},
|
|
150
144
|
"request_repr": {},
|
|
151
145
|
"response_repr": {},
|
|
152
146
|
"error_message": None,
|
|
@@ -175,8 +169,7 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
175
169
|
|
|
176
170
|
# Capture user details AFTER authentication has happened
|
|
177
171
|
user_id, user_info = get_user_details()
|
|
178
|
-
|
|
179
|
-
log_data["user_info"] = user_info
|
|
172
|
+
ctx.bind_contextvars(user_id=user_id, user_info=user_info)
|
|
180
173
|
|
|
181
174
|
# TODO: Find way to add status code to response_data
|
|
182
175
|
|
|
@@ -198,20 +191,22 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
198
191
|
|
|
199
192
|
log_data["execution_time"] = end_time - start_time
|
|
200
193
|
log_data["protocol"] = "https" if request.is_secure() else "http"
|
|
201
|
-
log_data["request_id"] = request_id
|
|
202
194
|
log_data["request_repr"] = request_data
|
|
203
195
|
log_data["response_repr"] = response_data
|
|
204
196
|
|
|
205
|
-
|
|
197
|
+
bound = _log.bind(**log_data)
|
|
198
|
+
bound.api("Audit Internal Request")
|
|
206
199
|
|
|
207
200
|
clear_request()
|
|
201
|
+
ctx.clear_contextvars()
|
|
208
202
|
|
|
209
203
|
return response
|
|
210
204
|
|
|
211
205
|
async def __acall__(self, request):
|
|
212
206
|
set_current_request(request)
|
|
213
207
|
request_id = str(uuid.uuid4())
|
|
214
|
-
|
|
208
|
+
ctx.clear_contextvars()
|
|
209
|
+
ctx.bind_contextvars(request_id=request_id)
|
|
215
210
|
|
|
216
211
|
if not should_log_url(request.path):
|
|
217
212
|
return await self.get_response(request)
|
|
@@ -220,9 +215,6 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
220
215
|
"service_name": SERVICE_NAME,
|
|
221
216
|
"request_type": REQUEST_TYPES[0],
|
|
222
217
|
"protocol": None,
|
|
223
|
-
"request_id": "",
|
|
224
|
-
"user_id": "",
|
|
225
|
-
"user_info": {},
|
|
226
218
|
"request_repr": {},
|
|
227
219
|
"response_repr": {},
|
|
228
220
|
"error_message": None,
|
|
@@ -253,8 +245,7 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
253
245
|
user_id, user_info = await sync_to_async(
|
|
254
246
|
get_user_details, thread_sensitive=True
|
|
255
247
|
)()
|
|
256
|
-
|
|
257
|
-
log_data["user_info"] = user_info
|
|
248
|
+
ctx.bind_contextvars(user_id=user_id, user_info=user_info)
|
|
258
249
|
|
|
259
250
|
# TODO: Find way to add status code to response_data
|
|
260
251
|
|
|
@@ -276,12 +267,13 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
276
267
|
|
|
277
268
|
log_data["execution_time"] = end_time - start_time
|
|
278
269
|
log_data["protocol"] = "https" if request.is_secure() else "http"
|
|
279
|
-
log_data["request_id"] = request_id
|
|
280
270
|
log_data["request_repr"] = request_data
|
|
281
271
|
log_data["response_repr"] = response_data
|
|
282
272
|
|
|
283
|
-
|
|
273
|
+
bound = _log.bind(**log_data)
|
|
274
|
+
bound.api("Audit Internal Request")
|
|
284
275
|
|
|
285
276
|
clear_request()
|
|
277
|
+
ctx.clear_contextvars()
|
|
286
278
|
|
|
287
279
|
return response
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import decimal
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import orjson
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
from structlog.processors import CallsiteParameter
|
|
9
|
+
|
|
10
|
+
from activity_audit.constants import LogType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _orjson_dumps(*args, **kwargs):
|
|
14
|
+
return orjson.dumps(*args, **kwargs).decode()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _json_default(obj):
|
|
18
|
+
if isinstance(obj, decimal.Decimal):
|
|
19
|
+
return float(obj)
|
|
20
|
+
if isinstance(obj, set):
|
|
21
|
+
return list(obj)
|
|
22
|
+
if isinstance(obj, bytes):
|
|
23
|
+
return obj.decode("utf-8", errors="replace")
|
|
24
|
+
return str(obj)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _audit_timestamp(logger, method, event_dict):
|
|
28
|
+
"""Timestamp in the same format as the existing formatters: 'YYYY-MM-DD HH:MM:SS.mmm'."""
|
|
29
|
+
event_dict["timestamp"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[
|
|
30
|
+
:-3
|
|
31
|
+
]
|
|
32
|
+
return event_dict
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _uppercase_level(logger, method, event_dict):
|
|
36
|
+
"""Uppercase level name to match AUDIT / API / LOGIN convention."""
|
|
37
|
+
if "level" in event_dict:
|
|
38
|
+
event_dict["level"] = event_dict["level"].upper()
|
|
39
|
+
return event_dict
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _rename_structlog_keys(logger, method, event_dict):
|
|
43
|
+
"""
|
|
44
|
+
Rename structlog default keys to match the existing JSON field names:
|
|
45
|
+
event → message
|
|
46
|
+
logger → name
|
|
47
|
+
"""
|
|
48
|
+
if "event" in event_dict:
|
|
49
|
+
event_dict["message"] = event_dict.pop("event")
|
|
50
|
+
if "logger" in event_dict:
|
|
51
|
+
event_dict["name"] = event_dict.pop("logger")
|
|
52
|
+
return event_dict
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _detect_process_log_type() -> str:
|
|
56
|
+
argv = " ".join(sys.argv)
|
|
57
|
+
if "beat" in argv:
|
|
58
|
+
return LogType.CELERYBEAT
|
|
59
|
+
if "worker" in argv or "celery" in argv:
|
|
60
|
+
return LogType.CELERYWORKER
|
|
61
|
+
return LogType.AUDIT
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_PROCESS_LOG_TYPE = _detect_process_log_type()
|
|
65
|
+
|
|
66
|
+
_LOG_TYPE_MAP = {
|
|
67
|
+
"audit.model": _PROCESS_LOG_TYPE,
|
|
68
|
+
"audit.request": LogType.API,
|
|
69
|
+
"audit.login": LogType.LOGIN,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _add_log_type(logger, method, event_dict):
|
|
74
|
+
"""Add log_type based on logger name; all celery.* loggers get the process type."""
|
|
75
|
+
name = event_dict.get("name", "")
|
|
76
|
+
if name in _LOG_TYPE_MAP:
|
|
77
|
+
event_dict["log_type"] = _LOG_TYPE_MAP[name]
|
|
78
|
+
elif name.startswith("celery"):
|
|
79
|
+
event_dict["log_type"] = _PROCESS_LOG_TYPE
|
|
80
|
+
else:
|
|
81
|
+
event_dict["log_type"] = LogType.APP
|
|
82
|
+
return event_dict
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_STANDARD_KEYS = frozenset(
|
|
86
|
+
{
|
|
87
|
+
"timestamp",
|
|
88
|
+
"level",
|
|
89
|
+
"name",
|
|
90
|
+
"message",
|
|
91
|
+
"log_type",
|
|
92
|
+
"filename",
|
|
93
|
+
"func_name",
|
|
94
|
+
"stack_info",
|
|
95
|
+
"exception",
|
|
96
|
+
"request_id",
|
|
97
|
+
"_record",
|
|
98
|
+
"_from_structlog",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _collect_extra(logger, method, event_dict):
|
|
104
|
+
"""Move all caller-supplied kwargs that aren't standard fields into 'extra'."""
|
|
105
|
+
extra = {k: event_dict.pop(k) for k in list(event_dict) if k not in _STANDARD_KEYS}
|
|
106
|
+
if extra:
|
|
107
|
+
event_dict["extra"] = extra
|
|
108
|
+
return event_dict
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
shared_processors = [
|
|
112
|
+
structlog.contextvars.merge_contextvars,
|
|
113
|
+
structlog.stdlib.add_log_level,
|
|
114
|
+
structlog.stdlib.add_logger_name,
|
|
115
|
+
_audit_timestamp,
|
|
116
|
+
_uppercase_level,
|
|
117
|
+
_rename_structlog_keys,
|
|
118
|
+
_add_log_type,
|
|
119
|
+
structlog.processors.CallsiteParameterAdder(
|
|
120
|
+
[
|
|
121
|
+
CallsiteParameter.FILENAME,
|
|
122
|
+
CallsiteParameter.FUNC_NAME,
|
|
123
|
+
]
|
|
124
|
+
),
|
|
125
|
+
structlog.processors.StackInfoRenderer(),
|
|
126
|
+
structlog.processors.ExceptionRenderer(),
|
|
127
|
+
_collect_extra,
|
|
128
|
+
]
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/signals.py
RENAMED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import inspect
|
|
2
|
-
import logging
|
|
3
2
|
|
|
4
3
|
from functools import wraps
|
|
5
4
|
from typing import Any, List
|
|
6
5
|
|
|
6
|
+
import structlog.contextvars as ctx
|
|
7
|
+
|
|
7
8
|
from django.apps import apps
|
|
8
9
|
from django.db import models, transaction
|
|
9
10
|
from django.db.models.signals import (
|
|
@@ -15,10 +16,10 @@ from django.db.models.signals import (
|
|
|
15
16
|
from django.dispatch import receiver
|
|
16
17
|
from django.forms.models import model_to_dict
|
|
17
18
|
|
|
18
|
-
from activity_audit.
|
|
19
|
+
from activity_audit.config import get_logger
|
|
19
20
|
from activity_audit.unregistered import UNREGISTERED_CLASSES
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
_log = get_logger("audit.model")
|
|
22
23
|
|
|
23
24
|
EVENT_TYPES = [
|
|
24
25
|
"CREATE",
|
|
@@ -129,27 +130,29 @@ def push_log(
|
|
|
129
130
|
extra: dict = {},
|
|
130
131
|
) -> None:
|
|
131
132
|
try:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
|
|
133
|
+
# Snapshot contextvars now — on_commit fires after the transaction commits
|
|
134
|
+
# and the middleware may have already cleared contextvars by then.
|
|
135
|
+
ctx_vars = ctx.get_contextvars()
|
|
136
|
+
bound = _log.bind(
|
|
137
|
+
model=model,
|
|
138
|
+
instance_id=str(instance_id),
|
|
139
|
+
event_type=event_type,
|
|
140
|
+
request_id=ctx_vars.get("request_id", ""),
|
|
141
|
+
user_id=ctx_vars.get("user_id", ""),
|
|
142
|
+
user_info=ctx_vars.get("user_info", {}),
|
|
143
|
+
instance_repr=instance_repr,
|
|
144
|
+
extra=extra,
|
|
145
|
+
)
|
|
143
146
|
|
|
144
147
|
def safe_audit_log():
|
|
145
148
|
try:
|
|
146
|
-
|
|
149
|
+
bound.audit(message)
|
|
147
150
|
except Exception as e:
|
|
148
|
-
|
|
151
|
+
_log.error("Failed to write audit log", error=str(e))
|
|
149
152
|
|
|
150
153
|
transaction.on_commit(safe_audit_log)
|
|
151
154
|
except Exception as e:
|
|
152
|
-
|
|
155
|
+
_log.error("Failed to prepare audit log", error=str(e))
|
|
153
156
|
|
|
154
157
|
|
|
155
158
|
def instance_to_dict(instance: models.Model) -> dict:
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# HIPAA Audit Gap Analysis
|
|
2
|
+
|
|
3
|
+
Architecture: Django → stdout → Docker logs → Vector → ClickHouse (self-hosted, in-VPC) → Grafana
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Critical — Must Fix
|
|
8
|
+
|
|
9
|
+
### 1. Sentry Integration Shipping PHI Outside VPC — §164.314
|
|
10
|
+
|
|
11
|
+
`settings.py` maps AUDIT/API log levels to Sentry. If Sentry is active in production, full request/response payloads and model reprs containing patient data are leaving the VPC to a third-party with no BAA coverage.
|
|
12
|
+
|
|
13
|
+
**Fix:** Disable Sentry for `audit.*` loggers in production, or strip PHI fields before they reach the Sentry handler.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
### 2. Response Status Code Missing from API Logs — §164.312(b)
|
|
18
|
+
|
|
19
|
+
`middleware.py:181` has a `# TODO` for status code. HIPAA requires distinguishing authorized access (200) from denied access (403/401) to identify unauthorized access attempts.
|
|
20
|
+
|
|
21
|
+
**Fix:** `response.status_code` is directly available on the Django `HttpResponse` object — add it to `response_repr`.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### 3. Log Integrity / Tamper-Evidence — §164.312(c)(1)
|
|
26
|
+
|
|
27
|
+
Audit logs must be protected from unauthorized alteration.
|
|
28
|
+
|
|
29
|
+
**Fix:**
|
|
30
|
+
- ClickHouse ingest user must have `INSERT` only — no `ALTER`/`DELETE`/`TRUNCATE`
|
|
31
|
+
- Docker daemon socket access restricted to the Vector service account only
|
|
32
|
+
- Separate admin-only ClickHouse role for schema changes
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
### 4. Log Retention Not Enforced — §164.530(j)
|
|
37
|
+
|
|
38
|
+
HIPAA requires audit log retention for 6 years (2190 days). `backupCount` on local file handlers is irrelevant with Docker log streaming, but ClickHouse TTL and Vector buffering are not configured.
|
|
39
|
+
|
|
40
|
+
**Fix:**
|
|
41
|
+
- Set ClickHouse TTL policy to 6+ years explicitly on audit tables
|
|
42
|
+
- Set Vector sink buffer to `type = "disk"` — the default in-memory buffer silently drops events when ClickHouse is unavailable
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
### 5. No Audit of Audit-Log Access — §164.312(b)
|
|
47
|
+
|
|
48
|
+
Grafana queries against ClickHouse constitute access to ePHI audit trails. Those accesses are not themselves logged or reviewed.
|
|
49
|
+
|
|
50
|
+
**Fix:** Enable ClickHouse query logging and retain Grafana access logs. Review them periodically as part of your access management program.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
### 6. Failed Login Attempts Not Captured — §164.312(d)
|
|
55
|
+
|
|
56
|
+
`push_usage_log` only runs when explicitly called by the application. Django's `user_login_failed` signal is not wired, so brute-force attacks against patient portals leave no trace in the audit trail.
|
|
57
|
+
|
|
58
|
+
**Fix:** Wire `user_login_failed` (and optionally `user_logged_out`) signals in `apps.py:ready()`.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### 7. Admin Interface Excluded from Logging — §164.308(a)(1)
|
|
63
|
+
|
|
64
|
+
`UNREGISTERED_URLS` defaults include `r"^/admin/"` and `LogEntry` is in `UNREGISTERED_CLASSES`. Superuser admin actions carry the highest insider-threat risk and must be in the audit trail.
|
|
65
|
+
|
|
66
|
+
**Fix:** Remove `^/admin/` from the default exclusion list and remove `LogEntry` from `UNREGISTERED_CLASSES`, or capture admin actions via a dedicated signal.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
### 8. Acting User's `sex` and `date_of_birth` in Every Log Entry
|
|
71
|
+
|
|
72
|
+
`get_user_details()` in `middleware.py:66–73` captures `sex` and `date_of_birth` of the user performing the action — not the patient. This unnecessarily expands the ePHI footprint across every single log entry and is directly at risk from point 1 (Sentry).
|
|
73
|
+
|
|
74
|
+
**Fix:** Strip `sex` and `date_of_birth` from `get_user_details()`. Audit identity only needs `user_id`, `email`, and `role`.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Medium Priority
|
|
79
|
+
|
|
80
|
+
### 9. `pre_save` + `on_commit` Double-Logging
|
|
81
|
+
|
|
82
|
+
`handle_pre_save` in `signals.py` calls `push_log`, which wraps emission in `transaction.on_commit`. The pre-commit state semantic is lost since the log is deferred to after commit anyway. Every save emits two entries: PRE_CREATE + CREATE (or PRE_UPDATE + UPDATE), doubling ClickHouse storage for every write.
|
|
83
|
+
|
|
84
|
+
**Fix:** Remove the `pre_save` signals unless there is a documented use case for capturing pre-commit field state.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### 10. Bulk Operations Log Only the First Object's Repr
|
|
89
|
+
|
|
90
|
+
`signals.py:84–91` — `bulk_create_with_signals` logs `instance_repr` for `created_objs[0]` only, with a `total_count` field. If 500 patient records are bulk-created, the audit shows only one record's state.
|
|
91
|
+
|
|
92
|
+
**Fix:** Either log all object IDs in the `extra` field, or formally document that bulk operations are audited at the batch level only (not per-record).
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### 11. SFTP Client Uses `AutoAddPolicy` — `protocols.py:131`
|
|
97
|
+
|
|
98
|
+
`paramiko.AutoAddPolicy()` accepts any host key silently, making PHI file transfers vulnerable to man-in-the-middle attacks.
|
|
99
|
+
|
|
100
|
+
**Fix:** Use `paramiko.RejectPolicy()` with explicitly pinned trusted host keys.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### 12. Celery / Async Context Loses User Identity Silently
|
|
105
|
+
|
|
106
|
+
Background Celery tasks that trigger model saves have no request context. `get_user_details()` returns `"", {}` silently — audit entries for async PHI processing have no actor identity, making them useless for accountability.
|
|
107
|
+
|
|
108
|
+
**Fix:** Explicitly inject the acting user into task context and call `set_current_user()` at the start of tasks that perform auditable operations.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Architecture-Specific
|
|
113
|
+
|
|
114
|
+
### 13. Silent Log Loss on Container Crash
|
|
115
|
+
|
|
116
|
+
`transaction.on_commit` in `signals.py:150` defers the audit log write until after the database commits. If the container crashes in the window between DB commit and stdout emission, the database change is committed but the audit event is never emitted — silently lost with no fallback.
|
|
117
|
+
|
|
118
|
+
**Fix:** Document this as a known gap in the threat model, or add a DB-side audit table as a secondary sink for critical events.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
### 14. Vector Sink Buffer Not Configured for Durability
|
|
123
|
+
|
|
124
|
+
The default Vector buffer is in-memory. If ClickHouse is temporarily unavailable, events in the buffer are dropped with no recovery.
|
|
125
|
+
|
|
126
|
+
**Fix:** Set `type = "disk"` with a defined `max_size` in the Vector ClickHouse sink buffer configuration.
|
|
127
|
+
|
|
128
|
+
```toml
|
|
129
|
+
[sinks.clickhouse.buffer]
|
|
130
|
+
type = "disk"
|
|
131
|
+
max_size = 268435488 # 256MB
|
|
132
|
+
when_full = "block"
|
|
133
|
+
```
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# Structlog Integration — Design & Migration Guide
|
|
2
|
+
|
|
3
|
+
> Authored: 2026-06-16
|
|
4
|
+
> Scope: Evaluating structlog as a replacement / complement to the stdlib `logging` layer in `django-activity-audit`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Current Architecture (Baseline)
|
|
9
|
+
|
|
10
|
+
| Component | File | Responsibility |
|
|
11
|
+
|-----------|------|----------------|
|
|
12
|
+
| Custom log levels (`AUDIT=21`, `API=22`, `LOGIN=23`) | `logger_levels.py` | Monkey-patched onto `logging.Logger` |
|
|
13
|
+
| Named loggers (`audit.model`, `audit.request`, `audit.login`) | `signals.py:21`, `middleware.py:16`, `utils.py:150` | One logger per concern |
|
|
14
|
+
| Context carriers (`request_id`, `user_id`) | `middleware.py:8–82` | `asgiref.local.Local()` thread-locals |
|
|
15
|
+
| Payload construction | `signals.py:123–152` (`push_log`) | Manually builds dict, passed as `extra={}` |
|
|
16
|
+
| Formatters | `formatters.py` | Manually serialize `LogRecord.extra` to JSON |
|
|
17
|
+
| Handlers | `handlers.py` | `RotatingFileHandler` + async `QueueHandler` variants |
|
|
18
|
+
|
|
19
|
+
### What push_log() does today
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
# signals.py:123-152
|
|
23
|
+
def push_log(message, model, event_type, instance_id, instance_repr, extra={}):
|
|
24
|
+
user_id, user_info = get_user_details() # pulls from thread-local
|
|
25
|
+
request_id = get_request_id() # pulls from thread-local
|
|
26
|
+
payload = {
|
|
27
|
+
"model": model,
|
|
28
|
+
"event_type": event_type,
|
|
29
|
+
"request_id": request_id or "",
|
|
30
|
+
"user_id": user_id,
|
|
31
|
+
"user_info": user_info,
|
|
32
|
+
"instance_id": str(instance_id),
|
|
33
|
+
"instance_repr": instance_repr,
|
|
34
|
+
"extra": extra,
|
|
35
|
+
}
|
|
36
|
+
transaction.on_commit(lambda: logger.audit(message, extra=payload))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Every field is packed manually, every call site must import and invoke `get_request_id()` / `get_user_details()`.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Pros of Structlog
|
|
44
|
+
|
|
45
|
+
### 1. Context binding replaces manual dict packing
|
|
46
|
+
|
|
47
|
+
With structlog, fields bound at request entry flow automatically to every log call in that request — no explicit retrieval needed:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
# middleware.py — bind once at request start
|
|
51
|
+
import structlog.contextvars as ctx
|
|
52
|
+
|
|
53
|
+
ctx.bind_contextvars(
|
|
54
|
+
request_id=request_id,
|
|
55
|
+
user_id=user_id,
|
|
56
|
+
user_info=user_info,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# signals.py — push_log becomes lean
|
|
60
|
+
log = structlog.get_logger("audit.model")
|
|
61
|
+
|
|
62
|
+
def push_log(message, model, event_type, instance_id, instance_repr, extra={}):
|
|
63
|
+
transaction.on_commit(lambda: log.audit(
|
|
64
|
+
message,
|
|
65
|
+
model=model,
|
|
66
|
+
event_type=event_type,
|
|
67
|
+
instance_id=str(instance_id),
|
|
68
|
+
instance_repr=instance_repr,
|
|
69
|
+
extra=extra,
|
|
70
|
+
))
|
|
71
|
+
# request_id, user_id, user_info injected automatically by merge_contextvars
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 2. `structlog.contextvars` replaces `asgiref.local.Local()`
|
|
75
|
+
|
|
76
|
+
The entire `_thread_locals` block in `middleware.py:8–82` (`set_request_id`, `get_request_id`, `set_current_request`, `get_current_user`, `clear_request`, etc.) is replaced by three calls:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# request start
|
|
80
|
+
structlog.contextvars.bind_contextvars(request_id=uuid, user_id=uid, user_info=info)
|
|
81
|
+
|
|
82
|
+
# request end (in finally block)
|
|
83
|
+
structlog.contextvars.clear_contextvars()
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`contextvars` is async-native — it scopes to the current `asyncio.Task`, so concurrent async requests never bleed context into each other. The current `asgiref.local.Local()` approach requires careful `clear_request()` discipline to avoid leaks (see `improvements.md` — issue #7).
|
|
87
|
+
|
|
88
|
+
### 3. Processor pipeline — composable and testable
|
|
89
|
+
|
|
90
|
+
The four monolithic formatters (`AppFormatter`, `APIFormatter`, `AuditFormatter`, `LoginFormatter`) in `formatters.py` each manually extract fields from `LogRecord` and serialize to JSON (~150 lines total). Structlog replaces them with a composable processor list:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import structlog
|
|
94
|
+
from structlog.processors import CallsiteParameter
|
|
95
|
+
|
|
96
|
+
shared_processors = [
|
|
97
|
+
structlog.contextvars.merge_contextvars, # inject request_id, user_id, user_info
|
|
98
|
+
structlog.processors.add_log_level, # adds "level" key
|
|
99
|
+
structlog.processors.TimeStamper(fmt="iso"), # ISO 8601 timestamp
|
|
100
|
+
structlog.processors.CallsiteParameterAdder([ # file + line — see below
|
|
101
|
+
CallsiteParameter.FILENAME,
|
|
102
|
+
CallsiteParameter.LINENO,
|
|
103
|
+
CallsiteParameter.FUNC_NAME,
|
|
104
|
+
]),
|
|
105
|
+
structlog.processors.JSONRenderer(), # final JSON serialization
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
structlog.configure(
|
|
109
|
+
processors=shared_processors,
|
|
110
|
+
wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
|
|
111
|
+
context_class=dict,
|
|
112
|
+
logger_factory=structlog.PrintLoggerFactory(),
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Each processor is a pure function — easy to test or swap individually. Adding a new field means appending one processor, not editing four formatters.
|
|
117
|
+
|
|
118
|
+
### 4. `CallsiteParameterAdder` — file and line in every signal log
|
|
119
|
+
|
|
120
|
+
**This directly addresses the question: "when performing audit action in signals, can I get file local too along logger?"**
|
|
121
|
+
|
|
122
|
+
`CallsiteParameterAdder` walks the call stack once per log call and injects:
|
|
123
|
+
|
|
124
|
+
| Parameter | Captured value (example) |
|
|
125
|
+
|-----------|--------------------------|
|
|
126
|
+
| `FILENAME` | `signals.py` |
|
|
127
|
+
| `LINENO` | `146` |
|
|
128
|
+
| `FUNC_NAME` | `push_log` |
|
|
129
|
+
| `MODULE` | `signals` |
|
|
130
|
+
| `PATHNAME` | `/app/activity_audit/signals.py` |
|
|
131
|
+
|
|
132
|
+
No changes to `push_log()` — the processor chain handles it. Every `push_log()` call gets:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"timestamp": "2026-06-16T10:22:45.123Z",
|
|
137
|
+
"level": "audit",
|
|
138
|
+
"event": "CREATE event for Author (id: abc123)",
|
|
139
|
+
"filename": "signals.py",
|
|
140
|
+
"lineno": 146,
|
|
141
|
+
"func_name": "push_log",
|
|
142
|
+
"model": "Author",
|
|
143
|
+
"event_type": "CREATE",
|
|
144
|
+
"request_id": "f3c9a1b2-...",
|
|
145
|
+
"user_id": "cae8ffb4-..."
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
> If you want the callsite that triggered `.save()` in user code (not `signals.py` itself), that requires `inspect.stack()` traversal. This is expensive and fragile — the signal-layer callsite is the correct and meaningful audit origin for most use cases.
|
|
150
|
+
|
|
151
|
+
### 5. `structlog.testing.capture_logs()` — structured test assertions
|
|
152
|
+
|
|
153
|
+
Current tests assert against formatted log strings or check handler call counts. With structlog:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
import structlog.testing
|
|
157
|
+
|
|
158
|
+
def test_create_event_logged(db):
|
|
159
|
+
with structlog.testing.capture_logs() as logs:
|
|
160
|
+
Author.objects.create(name="Test", bio="Bio")
|
|
161
|
+
|
|
162
|
+
audit_logs = [l for l in logs if l.get("event_type") == "CREATE"]
|
|
163
|
+
assert len(audit_logs) == 1
|
|
164
|
+
assert audit_logs[0]["model"] == "Author"
|
|
165
|
+
assert audit_logs[0]["event_type"] == "CREATE"
|
|
166
|
+
assert "instance_repr" in audit_logs[0]
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
No string parsing. No mock handlers. The captured entries are plain dicts.
|
|
170
|
+
|
|
171
|
+
### 6. Lazy rendering — zero overhead when filtered
|
|
172
|
+
|
|
173
|
+
`push_log()` today builds the full payload dict unconditionally, even if `AUDIT` level is filtered out by the handler. Structlog defers all dict construction and serialization until the processor chain confirms the level passes — filtered calls are nearly free.
|
|
174
|
+
|
|
175
|
+
### 7. Per-logger context with `bind()`
|
|
176
|
+
|
|
177
|
+
Signal handlers can pre-bind model-level context at module load:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
# signals.py
|
|
181
|
+
log = structlog.get_logger("audit.model")
|
|
182
|
+
|
|
183
|
+
def push_log(message, model, event_type, instance_id, instance_repr, extra={}):
|
|
184
|
+
bound = log.bind(model=model, event_type=event_type)
|
|
185
|
+
transaction.on_commit(
|
|
186
|
+
lambda: bound.audit(message, instance_id=str(instance_id), ...)
|
|
187
|
+
)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The bound logger carries `model` and `event_type` into all subsequent calls — useful when a signal handler emits multiple events in sequence.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Cons of Structlog
|
|
195
|
+
|
|
196
|
+
### 1. Custom log levels are second-class citizens
|
|
197
|
+
|
|
198
|
+
`AUDIT=21`, `API=22`, `LOGIN=23` are monkey-patched onto `logging.Logger` in `logger_levels.py`. Structlog's `BoundLogger` has no `.audit()` / `.api()` / `.login()` methods by default.
|
|
199
|
+
|
|
200
|
+
**Mitigation:** Use `structlog.make_filtering_bound_logger()` with a custom wrapper:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
import structlog
|
|
204
|
+
|
|
205
|
+
class AuditBoundLogger(structlog.stdlib.BoundLogger):
|
|
206
|
+
def audit(self, event, **kw):
|
|
207
|
+
return self._proxy_to_logger("audit", event, **kw)
|
|
208
|
+
|
|
209
|
+
def api(self, event, **kw):
|
|
210
|
+
return self._proxy_to_logger("api", event, **kw)
|
|
211
|
+
|
|
212
|
+
def login(self, event, **kw):
|
|
213
|
+
return self._proxy_to_logger("login", event, **kw)
|
|
214
|
+
|
|
215
|
+
structlog.configure(wrapper_class=AuditBoundLogger, ...)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
This requires registering the custom integer levels on the stdlib side first (same as now via `logger_levels.py`), then bridging through `structlog.stdlib.ProcessorFormatter`.
|
|
219
|
+
|
|
220
|
+
### 2. Two-layer configuration when using stdlib handlers
|
|
221
|
+
|
|
222
|
+
If the existing `RotatingFileHandler` / `QueueHandler` chain is kept (to avoid rewriting the async handler infrastructure), both layers must be configured:
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
structlog processor chain → stdlib logging.Logger → existing handlers
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The bridge is `structlog.stdlib.ProcessorFormatter` — it wraps the processor chain as a stdlib `Formatter`. This is documented and supported but adds conceptual overhead and a config split between `structlog.configure()` and Django's `LOGGING` dict.
|
|
229
|
+
|
|
230
|
+
### 3. Significant migration surface
|
|
231
|
+
|
|
232
|
+
Every `logger.audit(msg, extra=payload)` call across `signals.py`, `middleware.py`, `protocols.py`, and `utils.py` must change. The custom handlers (`AuditLogHandler`, `APILogHandler`) extract fields from `LogRecord.__dict__` populated by `extra=` — that extraction logic must be rewritten or the handlers retired.
|
|
233
|
+
|
|
234
|
+
### 4. Existing formatters become redundant
|
|
235
|
+
|
|
236
|
+
`AuditFormatter`, `APIFormatter`, `LoginFormatter`, `AppFormatter` (~200 lines in `formatters.py`) produce the same JSON that structlog's `JSONRenderer` would generate. They become dead code — a clean break, but a breaking change for any downstream project that subclassed them.
|
|
237
|
+
|
|
238
|
+
### 5. Added dependency
|
|
239
|
+
|
|
240
|
+
`structlog` is a mandatory new dependency for a library package. Not all consumers will have it. A clean opt-in design (e.g. `pip install django-activity-audit[structlog]`) is needed.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Summary: Pros vs Cons
|
|
245
|
+
|
|
246
|
+
| | Structlog | Current stdlib |
|
|
247
|
+
|--|-----------|----------------|
|
|
248
|
+
| Context propagation | `bind_contextvars` — automatic, async-safe | `asgiref.local.Local()` — manual, leak-prone |
|
|
249
|
+
| File/line in logs | `CallsiteParameterAdder` — zero call-site code | Not captured in audit formatter |
|
|
250
|
+
| Payload construction | Keyword args on log call | Manual dict + `extra={}` |
|
|
251
|
+
| Formatter logic | Composable processor list | ~200 lines across 4 classes |
|
|
252
|
+
| Test assertions | `capture_logs()` → typed dicts | String matching / mock handlers |
|
|
253
|
+
| Performance | Lazy — skipped if level filtered | Dict always constructed |
|
|
254
|
+
| Custom log levels | Requires `BoundLogger` wrapper | Monkey-patched, works today |
|
|
255
|
+
| Handler compatibility | Needs `ProcessorFormatter` bridge | Works natively |
|
|
256
|
+
| Migration cost | High — all call sites change | Zero |
|
|
257
|
+
| Dependency | New required dep | None |
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Recommended Migration Strategy
|
|
262
|
+
|
|
263
|
+
### Phase 1 — Hybrid: structlog processors, stdlib handlers (low risk)
|
|
264
|
+
|
|
265
|
+
Keep all existing handlers and Django `LOGGING` config. Replace the formatters with `structlog.stdlib.ProcessorFormatter`:
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
# formatters.py replacement
|
|
269
|
+
import structlog
|
|
270
|
+
from structlog.stdlib import ProcessorFormatter
|
|
271
|
+
|
|
272
|
+
renderer = ProcessorFormatter(
|
|
273
|
+
processor=structlog.processors.JSONRenderer(),
|
|
274
|
+
foreign_pre_chain=[
|
|
275
|
+
structlog.contextvars.merge_contextvars,
|
|
276
|
+
structlog.processors.add_log_level,
|
|
277
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
278
|
+
structlog.processors.CallsiteParameterAdder([
|
|
279
|
+
structlog.processors.CallsiteParameter.FILENAME,
|
|
280
|
+
structlog.processors.CallsiteParameter.LINENO,
|
|
281
|
+
structlog.processors.CallsiteParameter.FUNC_NAME,
|
|
282
|
+
]),
|
|
283
|
+
],
|
|
284
|
+
)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Wire it into the existing Django `LOGGING` dict:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
LOGGING = {
|
|
291
|
+
"formatters": {
|
|
292
|
+
"audit_json": {"()": lambda: renderer},
|
|
293
|
+
},
|
|
294
|
+
"handlers": {
|
|
295
|
+
"audit_file": {
|
|
296
|
+
**get_async_audit_handler(filename="audit_logs/audit.log"),
|
|
297
|
+
"formatter": "audit_json",
|
|
298
|
+
},
|
|
299
|
+
...
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
This unlocks `CallsiteParameterAdder` and cleaner processor composition with zero changes to handlers or call sites.
|
|
305
|
+
|
|
306
|
+
### Phase 2 — Context migration
|
|
307
|
+
|
|
308
|
+
Replace `asgiref.local.Local()` thread-locals in `middleware.py:8–82` with `structlog.contextvars`:
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
# middleware.py
|
|
312
|
+
|
|
313
|
+
import structlog.contextvars as ctx
|
|
314
|
+
|
|
315
|
+
class AuditLoggingMiddleware:
|
|
316
|
+
def __call__(self, request):
|
|
317
|
+
request_id = str(uuid.uuid4())
|
|
318
|
+
ctx.clear_contextvars()
|
|
319
|
+
ctx.bind_contextvars(request_id=request_id)
|
|
320
|
+
try:
|
|
321
|
+
response = self.get_response(request)
|
|
322
|
+
user_id, user_info = _extract_user(request)
|
|
323
|
+
ctx.bind_contextvars(user_id=user_id, user_info=user_info)
|
|
324
|
+
...
|
|
325
|
+
finally:
|
|
326
|
+
ctx.clear_contextvars()
|
|
327
|
+
return response
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Remove `get_request_id()`, `get_user_details()`, `set_current_request()`, `clear_request()` from `push_log()` — the processor chain picks them up automatically via `merge_contextvars`.
|
|
331
|
+
|
|
332
|
+
This also fixes the thread-local leak described in `improvements.md` (issue #7) as a side effect.
|
|
333
|
+
|
|
334
|
+
### Phase 3 — Call-site cleanup
|
|
335
|
+
|
|
336
|
+
Refactor `push_log()` to use structlog native calls, retire `extra={}` pattern, update tests to use `capture_logs()`.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## File/Line Info in Signal Handlers — Implementation Detail
|
|
341
|
+
|
|
342
|
+
Add `CallsiteParameterAdder` to the processor chain (Phase 1). The processor requires a `_record` key to be present, which `ProcessorFormatter` injects automatically when bridging from stdlib.
|
|
343
|
+
|
|
344
|
+
Every log entry emitted from `push_log()` (signals.py:146) will include:
|
|
345
|
+
|
|
346
|
+
```json
|
|
347
|
+
{
|
|
348
|
+
"filename": "signals.py",
|
|
349
|
+
"lineno": 146,
|
|
350
|
+
"func_name": "push_log"
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
To also capture the Django model file that owns the `.save()` call, add a lightweight stack inspector:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
import inspect
|
|
358
|
+
|
|
359
|
+
def _get_save_callsite() -> dict:
|
|
360
|
+
for frame_info in inspect.stack():
|
|
361
|
+
if frame_info.filename.endswith("signals.py"):
|
|
362
|
+
continue
|
|
363
|
+
if frame_info.function in ("save", "bulk_create", "bulk_update"):
|
|
364
|
+
continue
|
|
365
|
+
return {
|
|
366
|
+
"trigger_file": frame_info.filename,
|
|
367
|
+
"trigger_line": frame_info.lineno,
|
|
368
|
+
"trigger_func": frame_info.function,
|
|
369
|
+
}
|
|
370
|
+
return {}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Pass the result as `extra` in `push_log()`. Use sparingly — `inspect.stack()` is expensive and should be guarded behind a debug flag or sampled.
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## Output Log Shape (Post-Migration)
|
|
378
|
+
|
|
379
|
+
```json
|
|
380
|
+
{
|
|
381
|
+
"timestamp": "2026-06-16T10:22:45.123Z",
|
|
382
|
+
"level": "audit",
|
|
383
|
+
"logger": "audit.model",
|
|
384
|
+
"event": "CREATE event for Author (id: abc123)",
|
|
385
|
+
"filename": "signals.py",
|
|
386
|
+
"lineno": 146,
|
|
387
|
+
"func_name": "push_log",
|
|
388
|
+
"request_id": "f3c9a1b2-0001-4abc-beef-deadbeef0001",
|
|
389
|
+
"user_id": "cae8ffb4-ba52-409c-9a6f-e10362bfaf97",
|
|
390
|
+
"user_info": {
|
|
391
|
+
"email": "user@example.com",
|
|
392
|
+
"first_name": "Ada",
|
|
393
|
+
"last_name": "Lovelace"
|
|
394
|
+
},
|
|
395
|
+
"model": "Author",
|
|
396
|
+
"event_type": "CREATE",
|
|
397
|
+
"instance_id": "9f1b2c3d-...",
|
|
398
|
+
"instance_repr": { "name": "Ada Lovelace", "bio": "Mathematician" },
|
|
399
|
+
"extra": {}
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
All fields from the current `AuditFormatter` output are preserved. New fields: `filename`, `lineno`, `func_name`. The `request_id` / `user_id` / `user_info` fields are no longer passed manually — they come from the processor chain.
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Dependency
|
|
408
|
+
|
|
409
|
+
```toml
|
|
410
|
+
# pyproject.toml
|
|
411
|
+
[project.optional-dependencies]
|
|
412
|
+
structlog = ["structlog>=24.0"]
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
```bash
|
|
416
|
+
pip install django-activity-audit[structlog]
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The core package remains dependency-free. Structlog features activate only when the optional extra is installed and `structlog.configure()` is called by the host application.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "django-activity-audit"
|
|
3
|
-
version = "1.3.0.
|
|
3
|
+
version = "1.3.0.dev19"
|
|
4
4
|
description = "A Django package for easy CRUD operation logging and container logs"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Shreeshan", email = "shreeshan256@gmail.com" }
|
|
@@ -50,7 +50,9 @@ test = [
|
|
|
50
50
|
"pytest-django>=4.0.0",
|
|
51
51
|
"pytest-cov>=4.0.0",
|
|
52
52
|
"djangorestframework>=3.14.0",
|
|
53
|
+
"structlog>=24.0",
|
|
53
54
|
]
|
|
55
|
+
structlog = ["structlog>=24.0"]
|
|
54
56
|
|
|
55
57
|
[build-system]
|
|
56
58
|
requires = ["hatchling"]
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
from django.apps import AppConfig
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class AuditLoggingConfig(AppConfig):
|
|
5
|
-
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
-
name = "activity_audit"
|
|
7
|
-
verbose_name = "Django Activity Audit"
|
|
8
|
-
|
|
9
|
-
def ready(self):
|
|
10
|
-
# Import and register custom log levels
|
|
11
|
-
from . import logger_levels
|
|
12
|
-
|
|
13
|
-
# Force registration of custom levels
|
|
14
|
-
logger_levels.AUDIT
|
|
15
|
-
logger_levels.API
|
|
16
|
-
logger_levels.LOGIN
|
|
17
|
-
|
|
18
|
-
# Populate UNREGISTERED_CLASSES (requires app registry to be ready)
|
|
19
|
-
from . import unregistered # noqa
|
|
20
|
-
|
|
21
|
-
# Initialize signals
|
|
22
|
-
from . import signals # noqa
|
|
File without changes
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/.pre-commit-config.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/protocols.py
RENAMED
|
File without changes
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/settings.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/activity_audit/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_activity_audit-1.3.0.dev18 → django_activity_audit-1.3.0.dev19}/docs/improvements.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|