django-activity-audit 1.3.0.dev10__tar.gz → 1.3.0.dev12__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.dev10 → django_activity_audit-1.3.0.dev12}/PKG-INFO +1 -1
- django_activity_audit-1.3.0.dev12/activity_audit/REVIEW.md +297 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/apps.py +3 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/formatters.py +3 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/handlers.py +13 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/middleware.py +18 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/settings.py +0 -43
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/signals.py +3 -2
- django_activity_audit-1.3.0.dev12/activity_audit/unregistered.py +33 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12/docs}/django-activity-audit.md +83 -2
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/pyproject.toml +1 -1
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/.gitignore +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/.pre-commit-config.yaml +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/LICENSE +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/MANIFEST.in +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/README.md +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/README.rst +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/__init__.py +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/constants.py +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/logger_levels.py +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/protocols.py +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/utils.py +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12/docs}/user-activity-feed-plan.md +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/hatch +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/pytest.ini +0 -0
- {django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/uv.lock +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.dev12
|
|
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
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# activity_audit — Code Review
|
|
2
|
+
|
|
3
|
+
> Reviewed: 2026-06-08
|
|
4
|
+
> Reviewer: Claude Code
|
|
5
|
+
> Scope: Full app audit following bug fixes for `AppRegistryNotReady` and `request_id` thread-local propagation
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Recent Fixes (Verified Correct)
|
|
10
|
+
|
|
11
|
+
| Fix | File | Status |
|
|
12
|
+
|-----|------|--------|
|
|
13
|
+
| `AppRegistryNotReady` — model imports deferred to `unregistered.py`, loaded in `apps.ready()` | `unregistered.py`, `apps.py`, `settings.py` | ✅ Correct |
|
|
14
|
+
| `request_id` not written to `app.log` — captured via `prepare()` in handler (producer thread) before record is queued to background `QueueListener` thread | `handlers.py` | ✅ Correct |
|
|
15
|
+
| `watchfiles` reload loop — log files moved to `/logs/` (outside `/app/`), `watchfiles` logger silenced at WARNING | `config/settings/local.py`, `local.yml` | ✅ Correct |
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Critical Issues (Must Fix)
|
|
20
|
+
|
|
21
|
+
### 1. Cross-request PHI data leak — shared `self.log_data` in middleware
|
|
22
|
+
**File:** `middleware.py:127–140`
|
|
23
|
+
|
|
24
|
+
`self.log_data` is constructed once in `__init__` and stored on the middleware instance. Middleware instances are shared across all requests. Under concurrent ASGI (multiple coroutines on one instance), request A's `user_id`, `request_repr`, `response_repr` overwrite request B's in the same dict — PHI from one patient's session can be written into another patient's audit record.
|
|
25
|
+
|
|
26
|
+
**Fix:** Build `log_data` as a local dict inside `__call__` and `__acall__`, not on `self`.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# Before (in __init__)
|
|
30
|
+
self.log_data = {"service_name": SERVICE_NAME, ...}
|
|
31
|
+
|
|
32
|
+
# After (top of __call__ and __acall__)
|
|
33
|
+
log_data = {"service_name": SERVICE_NAME, ...}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### 2. `QuerySet.bulk_create/bulk_update` monkey-patched N times — wrappers nest
|
|
39
|
+
**File:** `signals.py:129–130, 244–247`
|
|
40
|
+
|
|
41
|
+
Inside `patch_model_event`, the patching logic does:
|
|
42
|
+
```python
|
|
43
|
+
original_bulk_create = models.QuerySet.bulk_create # captured per call
|
|
44
|
+
...
|
|
45
|
+
models.QuerySet.bulk_create = bulk_create_with_signals # assigned globally
|
|
46
|
+
```
|
|
47
|
+
This runs once per audited model. The second model captures the already-patched
|
|
48
|
+
method as its "original", the third wraps the double-patched version, and so on.
|
|
49
|
+
After N models are patched, every `bulk_create` call traverses N nested wrappers.
|
|
50
|
+
|
|
51
|
+
**Fix:** Extract the `QuerySet` patches into a separate function called exactly once from `setup_model_signals()`, before the per-model loop.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
### 3. `get_calling_model()` frame-walking is broken — bulk auditing is non-functional
|
|
56
|
+
**File:** `signals.py:57–79, 193–196, 225–228`
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
if calling_model == model_class.__name__:
|
|
60
|
+
```
|
|
61
|
+
`calling_model` is `module_name.split(".")[-1]` (e.g. `"views"`, `"tasks"`).
|
|
62
|
+
`model_class.__name__` is a class name (e.g. `"Patient"`, `"Appointment"`).
|
|
63
|
+
These will almost never match, so bulk audit logs are silently never written for virtually all models.
|
|
64
|
+
|
|
65
|
+
**Fix:** Rethink bulk auditing using Django signals (`post_bulk_create` is unavailable natively, but a custom `QuerySet` mixin on a project-level base queryset is more reliable than frame introspection).
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
### 4. `request.body` access can crash real requests
|
|
70
|
+
**File:** `middleware.py:165–170, 229–234`
|
|
71
|
+
|
|
72
|
+
Only `json.JSONDecodeError` is caught. The following are unhandled and will 500 the request:
|
|
73
|
+
- `RawPostDataException` — body stream already consumed by DRF/multipart parsers
|
|
74
|
+
- `RequestDataTooBig` — body exceeds `DATA_UPLOAD_MAX_MEMORY_SIZE`
|
|
75
|
+
- `UnicodeDecodeError` — binary upload body
|
|
76
|
+
|
|
77
|
+
Audit middleware must never break actual requests.
|
|
78
|
+
|
|
79
|
+
**Fix:**
|
|
80
|
+
```python
|
|
81
|
+
try:
|
|
82
|
+
body = json.loads(request.body)
|
|
83
|
+
request_data["body"] = body
|
|
84
|
+
except Exception:
|
|
85
|
+
pass # never let audit logging crash the request
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Warnings (Should Fix)
|
|
91
|
+
|
|
92
|
+
### 5. PHI/SSN written into `audit.log` via `instance_to_dict`
|
|
93
|
+
**File:** `signals.py:117–118`
|
|
94
|
+
|
|
95
|
+
`model_to_dict(instance, fields=[f.name for f in instance._meta.fields])` dumps all
|
|
96
|
+
concrete fields including encrypted `social_security_number` and other PHI directly
|
|
97
|
+
into the audit log file. This directly violates HIPAA rules documented in `CLAUDE.md`:
|
|
98
|
+
> "Never log PHI to console" / "SSN handling — only access via `get_ssn` endpoint"
|
|
99
|
+
|
|
100
|
+
**Fix:** Add a per-model field deny-list or use a model-level `AUDIT_EXCLUDE_FIELDS`
|
|
101
|
+
attribute. At minimum, exclude all `EncryptedField` instances.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### 6. Regex patterns recompiled on every request in `should_log_url`
|
|
106
|
+
**File:** `middleware.py:84–100`
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
for unregistered_url in UNREGISTERED_URLS:
|
|
110
|
+
pattern = re.compile(unregistered_url) # compiled on every request
|
|
111
|
+
```
|
|
112
|
+
This is in the hot path for every HTTP request.
|
|
113
|
+
|
|
114
|
+
**Fix:** Compile once at module load:
|
|
115
|
+
```python
|
|
116
|
+
_UNREGISTERED_PATTERNS = [re.compile(p) for p in UNREGISTERED_URLS]
|
|
117
|
+
_REGISTERED_PATTERNS = [re.compile(p) for p in REGISTERED_URLS]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
### 7. `clear_request()` not called on early return — thread-local leaks
|
|
123
|
+
**File:** `middleware.py:148–153`
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
set_current_request(request)
|
|
127
|
+
set_request_id(request_id)
|
|
128
|
+
|
|
129
|
+
if not should_log_url(request.path):
|
|
130
|
+
return self.get_response(request) # clear_request() never called
|
|
131
|
+
```
|
|
132
|
+
The thread-local `request` and `request_id` persist on the worker thread and
|
|
133
|
+
are seen by the next request handled by that thread.
|
|
134
|
+
|
|
135
|
+
**Fix:** Use `try/finally`:
|
|
136
|
+
```python
|
|
137
|
+
set_current_request(request)
|
|
138
|
+
set_request_id(request_id)
|
|
139
|
+
try:
|
|
140
|
+
if not should_log_url(request.path):
|
|
141
|
+
return self.get_response(request)
|
|
142
|
+
...
|
|
143
|
+
finally:
|
|
144
|
+
clear_request()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
### 8. Bulk wrappers never call `should_audit` — unregistered models audited on bulk paths
|
|
150
|
+
**File:** `signals.py:179–242`
|
|
151
|
+
|
|
152
|
+
`bulk_create_with_signals` and `bulk_update_with_signals` have no `should_audit` guard,
|
|
153
|
+
so `Session`, `Permission`, `Migration` etc. are audited on bulk paths but excluded on
|
|
154
|
+
single-save paths. Inconsistent and wasteful.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
### 9. `SFTPClient.upload` — unconditional assignment clobbers success and real errors
|
|
159
|
+
**File:** `protocols.py:209–237`
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
if not error:
|
|
163
|
+
try:
|
|
164
|
+
...upload...
|
|
165
|
+
except Exception:
|
|
166
|
+
self.log_payload["error_message"] = str(e) # real error
|
|
167
|
+
|
|
168
|
+
# This runs unconditionally — overwrites both the success case and the real exception:
|
|
169
|
+
self.log_payload["error_message"] = f"Path validation failed. Error: {str(error)}"
|
|
170
|
+
```
|
|
171
|
+
On a fully successful upload, `error_message` is set to `"Path validation failed. Error: None"`.
|
|
172
|
+
|
|
173
|
+
**Fix:** Wrap the final assignment in an `else` block.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
### 10. `HTTPClient.request` returns `None` on exception and calls `.json()` unconditionally
|
|
178
|
+
**File:** `protocols.py:97–118`
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
except Exception:
|
|
182
|
+
...
|
|
183
|
+
return response # response is None here
|
|
184
|
+
```
|
|
185
|
+
Callers expecting a `requests.Response` will raise `AttributeError`. On the success path,
|
|
186
|
+
`response.json()` is called regardless of `Content-Type`, which will raise for non-JSON
|
|
187
|
+
responses and discard the real response object.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
### 11. `push_usage_log` imports `get_user_details` through `signals` — unwanted side effect
|
|
192
|
+
**File:** `utils.py:139`
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from .signals import get_user_details
|
|
196
|
+
```
|
|
197
|
+
Importing `signals` triggers `setup_model_signals()` at the bottom of that module as a
|
|
198
|
+
side effect. Import directly from the canonical source:
|
|
199
|
+
```python
|
|
200
|
+
from .middleware import get_user_details
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
### 12. Sentry filter attached at settings-load time — before Sentry is initialised
|
|
206
|
+
**File:** `settings.py:24–27`
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
root_logger = logging.getLogger()
|
|
210
|
+
for handler in root_logger.handlers:
|
|
211
|
+
if handler.__class__.__name__.startswith("Sentry"):
|
|
212
|
+
handler.addFilter(AuditToSentryFilter())
|
|
213
|
+
```
|
|
214
|
+
At settings-load time Sentry SDK has not yet added its handler, so this loop finds nothing
|
|
215
|
+
and the filter is never attached. Move to `apps.ready()`.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Suggestions (Consider)
|
|
220
|
+
|
|
221
|
+
### 13. `default_app_config` is dead
|
|
222
|
+
**File:** `__init__.py:1`
|
|
223
|
+
|
|
224
|
+
Deprecated in Django 3.2, removed in Django 4.1. It is a no-op. Remove it.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
### 14. Timestamp formatting duplicated across all 4 formatters
|
|
229
|
+
**File:** `formatters.py:42–44` (and 3 other formatters)
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
datetime.datetime.fromtimestamp(record.created).strftime(self.timestamp_format)[:-3]
|
|
233
|
+
```
|
|
234
|
+
This block is copy-pasted in `JsonFormatter`, `APIFormatter`, `AuditFormatter`, and
|
|
235
|
+
`LoginFormatter`. Also uses server local time with no timezone. Extract a shared:
|
|
236
|
+
```python
|
|
237
|
+
class BaseAuditFormatter(logging.Formatter):
|
|
238
|
+
def _timestamp(self, record) -> str:
|
|
239
|
+
return datetime.datetime.fromtimestamp(
|
|
240
|
+
record.created, tz=datetime.timezone.utc
|
|
241
|
+
).strftime(self.timestamp_format)[:-3]
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
### 15. `apps.ready()` no-op attribute accesses are misleading
|
|
247
|
+
**File:** `apps.py:14–16`
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
logger_levels.AUDIT
|
|
251
|
+
logger_levels.API
|
|
252
|
+
logger_levels.LOGIN
|
|
253
|
+
```
|
|
254
|
+
These are bare attribute reads that do nothing — the level registration already
|
|
255
|
+
happened when `logger_levels` was imported. The import itself is the side effect.
|
|
256
|
+
Remove the three lines or replace with a comment.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
### 16. `AuditToSentryFilter` mutates `record.levelname` — corrupts file logs
|
|
261
|
+
**File:** `settings.py:8–20`
|
|
262
|
+
|
|
263
|
+
Overwriting `record.levelname = "INFO"` means formatters downstream will log `"INFO"`
|
|
264
|
+
instead of `"AUDIT"` / `"API"` in file logs too (filters run before formatting).
|
|
265
|
+
If Sentry integration is needed, use a `before_send` hook in Sentry config instead.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
### 17. Minor style issues
|
|
270
|
+
|
|
271
|
+
| Location | Issue |
|
|
272
|
+
|----------|-------|
|
|
273
|
+
| `middleware.py:64` | `id = str(user.id)` shadows the builtin `id` |
|
|
274
|
+
| `protocols.py:193` | `f"Connection not established"` — f-string with no placeholders |
|
|
275
|
+
| `signals.py:88` | `instance_repr: str` type hint but callers pass `dict` |
|
|
276
|
+
| `middleware.py:21–25` | `MockRequest.__init__` passes `*args, **kwargs` to `object.__init__` which rejects them |
|
|
277
|
+
| `formatters.py`, `signals.py` | Several lines exceed 80-char limit — run `make format` |
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Priority Order
|
|
282
|
+
|
|
283
|
+
| Priority | Issue | File |
|
|
284
|
+
|----------|-------|------|
|
|
285
|
+
| 🔴 P0 | Cross-request PHI data leak (`self.log_data`) | `middleware.py` |
|
|
286
|
+
| 🔴 P0 | PHI/SSN in `audit.log` via `instance_to_dict` | `signals.py` |
|
|
287
|
+
| 🔴 P0 | `request.body` access can crash requests | `middleware.py` |
|
|
288
|
+
| 🟠 P1 | `QuerySet` patch nested N times | `signals.py` |
|
|
289
|
+
| 🟠 P1 | Bulk auditing effectively broken (`get_calling_model`) | `signals.py` |
|
|
290
|
+
| 🟠 P1 | `clear_request()` missing on early return | `middleware.py` |
|
|
291
|
+
| 🟡 P2 | Regex recompiled on every request | `middleware.py` |
|
|
292
|
+
| 🟡 P2 | Bulk wrappers skip `should_audit` | `signals.py` |
|
|
293
|
+
| 🟡 P2 | `SFTPClient.upload` error message clobbered | `protocols.py` |
|
|
294
|
+
| 🟡 P2 | `HTTPClient.request` returns `None` silently | `protocols.py` |
|
|
295
|
+
| 🟡 P2 | `push_usage_log` imports via `signals` | `utils.py` |
|
|
296
|
+
| 🟡 P2 | Sentry filter never attaches | `settings.py` |
|
|
297
|
+
| 🔵 P3 | Timestamp duplication, `default_app_config`, style nits | various |
|
{django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/formatters.py
RENAMED
|
@@ -47,6 +47,7 @@ class JsonFormatter(logging.Formatter):
|
|
|
47
47
|
"path": record.pathname,
|
|
48
48
|
"module": record.module,
|
|
49
49
|
"function": record.funcName,
|
|
50
|
+
"request_id": getattr(record, "request_id", "") or "",
|
|
50
51
|
"message": record.getMessage(),
|
|
51
52
|
"exception": "",
|
|
52
53
|
# "extra": {},
|
|
@@ -86,6 +87,7 @@ class APIFormatter(logging.Formatter):
|
|
|
86
87
|
"service_name",
|
|
87
88
|
"request_type",
|
|
88
89
|
"protocol",
|
|
90
|
+
"request_id",
|
|
89
91
|
"user_id",
|
|
90
92
|
"user_info",
|
|
91
93
|
"request_repr",
|
|
@@ -117,6 +119,7 @@ class AuditFormatter(logging.Formatter):
|
|
|
117
119
|
audit_fields = [
|
|
118
120
|
"model",
|
|
119
121
|
"event_type",
|
|
122
|
+
"request_id",
|
|
120
123
|
"instance_id",
|
|
121
124
|
"instance_repr",
|
|
122
125
|
"user_id",
|
{django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/handlers.py
RENAMED
|
@@ -4,6 +4,7 @@ import queue
|
|
|
4
4
|
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
|
|
5
5
|
|
|
6
6
|
from .formatters import APIFormatter, AuditFormatter, JsonFormatter, LoginFormatter
|
|
7
|
+
from .middleware import get_request_id
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class BaseAuditHandler(RotatingFileHandler):
|
|
@@ -108,6 +109,13 @@ class AsyncBaseAuditHandler(QueueHandler):
|
|
|
108
109
|
)
|
|
109
110
|
self._listener.start()
|
|
110
111
|
|
|
112
|
+
def prepare(self, record):
|
|
113
|
+
# Capture request_id in the calling thread before the record is queued,
|
|
114
|
+
# since thread-locals are not accessible from the QueueListener thread.
|
|
115
|
+
record = super().prepare(record)
|
|
116
|
+
record.request_id = get_request_id() or ""
|
|
117
|
+
return record
|
|
118
|
+
|
|
111
119
|
def close(self):
|
|
112
120
|
self._listener.stop()
|
|
113
121
|
super().close()
|
|
@@ -161,6 +169,11 @@ class AsyncJsonHandler(QueueHandler):
|
|
|
161
169
|
)
|
|
162
170
|
self._listener.start()
|
|
163
171
|
|
|
172
|
+
def prepare(self, record):
|
|
173
|
+
record = super().prepare(record)
|
|
174
|
+
record.request_id = get_request_id() or ""
|
|
175
|
+
return record
|
|
176
|
+
|
|
164
177
|
def close(self):
|
|
165
178
|
self._listener.stop()
|
|
166
179
|
super().close()
|
{django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/middleware.py
RENAMED
|
@@ -3,6 +3,7 @@ import json
|
|
|
3
3
|
import logging
|
|
4
4
|
import re
|
|
5
5
|
import time
|
|
6
|
+
import uuid
|
|
6
7
|
|
|
7
8
|
from asgiref.local import Local
|
|
8
9
|
from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async
|
|
@@ -32,6 +33,14 @@ def set_current_request(request):
|
|
|
32
33
|
_thread_locals.request = request
|
|
33
34
|
|
|
34
35
|
|
|
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
|
+
|
|
35
44
|
def get_current_user():
|
|
36
45
|
request = get_current_request()
|
|
37
46
|
if request:
|
|
@@ -68,6 +77,8 @@ def get_user_details():
|
|
|
68
77
|
def clear_request():
|
|
69
78
|
with contextlib.suppress(AttributeError):
|
|
70
79
|
del _thread_locals.request
|
|
80
|
+
with contextlib.suppress(AttributeError):
|
|
81
|
+
del _thread_locals.request_id
|
|
71
82
|
|
|
72
83
|
|
|
73
84
|
def should_log_url(url):
|
|
@@ -119,6 +130,7 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
119
130
|
"service_name": SERVICE_NAME,
|
|
120
131
|
"request_type": REQUEST_TYPES[0],
|
|
121
132
|
"protocol": None,
|
|
133
|
+
"request_id": "",
|
|
122
134
|
"user_id": "",
|
|
123
135
|
"user_info": {},
|
|
124
136
|
"request_repr": {},
|
|
@@ -134,6 +146,8 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
134
146
|
if iscoroutinefunction(self):
|
|
135
147
|
return self.__acall__(request)
|
|
136
148
|
set_current_request(request)
|
|
149
|
+
request_id = str(uuid.uuid4())
|
|
150
|
+
set_request_id(request_id)
|
|
137
151
|
|
|
138
152
|
if not should_log_url(request.path):
|
|
139
153
|
return self.get_response(request)
|
|
@@ -184,6 +198,7 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
184
198
|
|
|
185
199
|
self.log_data["execution_time"] = end_time - start_time
|
|
186
200
|
self.log_data["protocol"] = "https" if request.is_secure() else "http"
|
|
201
|
+
self.log_data["request_id"] = request_id
|
|
187
202
|
self.log_data["request_repr"] = request_data
|
|
188
203
|
self.log_data["response_repr"] = response_data
|
|
189
204
|
|
|
@@ -195,6 +210,8 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
195
210
|
|
|
196
211
|
async def __acall__(self, request):
|
|
197
212
|
set_current_request(request)
|
|
213
|
+
request_id = str(uuid.uuid4())
|
|
214
|
+
set_request_id(request_id)
|
|
198
215
|
|
|
199
216
|
if not should_log_url(request.path):
|
|
200
217
|
return await self.get_response(request)
|
|
@@ -247,6 +264,7 @@ class AuditLoggingMiddleware(MiddlewareMixin):
|
|
|
247
264
|
|
|
248
265
|
self.log_data["execution_time"] = end_time - start_time
|
|
249
266
|
self.log_data["protocol"] = "https" if request.is_secure() else "http"
|
|
267
|
+
self.log_data["request_id"] = request_id
|
|
250
268
|
self.log_data["request_repr"] = request_data
|
|
251
269
|
self.log_data["response_repr"] = response_data
|
|
252
270
|
|
{django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/settings.py
RENAMED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
from django.apps import apps
|
|
4
3
|
from django.conf import settings
|
|
5
|
-
from django.contrib.auth.models import Permission
|
|
6
|
-
from django.contrib.contenttypes.models import ContentType
|
|
7
|
-
from django.contrib.sessions.models import Session
|
|
8
|
-
from django.db.migrations import Migration
|
|
9
|
-
from django.db.migrations.recorder import MigrationRecorder
|
|
10
4
|
|
|
11
5
|
|
|
12
6
|
# Handles AUDIT/API level as INFO for Sentry
|
|
@@ -32,43 +26,6 @@ for handler in root_logger.handlers:
|
|
|
32
26
|
if handler.__class__.__name__.startswith("Sentry"):
|
|
33
27
|
handler.addFilter(AuditToSentryFilter())
|
|
34
28
|
|
|
35
|
-
UNREGISTERED_CLASSES = [
|
|
36
|
-
Migration,
|
|
37
|
-
Session,
|
|
38
|
-
Permission,
|
|
39
|
-
ContentType,
|
|
40
|
-
MigrationRecorder.Migration,
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
# Remove silk models audit logging
|
|
44
|
-
SILK_INSTALLED = apps.is_installed("silk")
|
|
45
|
-
if SILK_INSTALLED:
|
|
46
|
-
from silk.models import (
|
|
47
|
-
BaseProfile,
|
|
48
|
-
Profile,
|
|
49
|
-
Request,
|
|
50
|
-
Response,
|
|
51
|
-
SQLQuery,
|
|
52
|
-
SQLQueryManager,
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
UNREGISTERED_CLASSES.extend(
|
|
56
|
-
[
|
|
57
|
-
Request,
|
|
58
|
-
Response,
|
|
59
|
-
SQLQueryManager,
|
|
60
|
-
SQLQuery,
|
|
61
|
-
BaseProfile,
|
|
62
|
-
Profile,
|
|
63
|
-
]
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
# Import and unregister LogEntry class only if Django Admin app is installed
|
|
67
|
-
if apps.is_installed("django.contrib.admin"):
|
|
68
|
-
from django.contrib.admin.models import LogEntry
|
|
69
|
-
|
|
70
|
-
UNREGISTERED_CLASSES.extend([LogEntry])
|
|
71
|
-
|
|
72
29
|
# URL patterns to exclude from logging
|
|
73
30
|
UNREGISTERED_URLS = [r"^/admin/", r"^/static/", r"^/favicon.ico$"]
|
|
74
31
|
UNREGISTERED_URLS = getattr(
|
{django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/signals.py
RENAMED
|
@@ -15,8 +15,8 @@ from django.db.models.signals import (
|
|
|
15
15
|
from django.dispatch import receiver
|
|
16
16
|
from django.forms.models import model_to_dict
|
|
17
17
|
|
|
18
|
-
from activity_audit.middleware import get_user_details
|
|
19
|
-
from activity_audit.
|
|
18
|
+
from activity_audit.middleware import get_request_id, get_user_details
|
|
19
|
+
from activity_audit.unregistered import UNREGISTERED_CLASSES
|
|
20
20
|
|
|
21
21
|
logger = logging.getLogger("audit.model")
|
|
22
22
|
|
|
@@ -96,6 +96,7 @@ def push_log(
|
|
|
96
96
|
"model": model,
|
|
97
97
|
"instance_id": str(instance_id),
|
|
98
98
|
"event_type": event_type,
|
|
99
|
+
"request_id": get_request_id() or "",
|
|
99
100
|
"user_id": user_id,
|
|
100
101
|
"user_info": user_info,
|
|
101
102
|
"instance_repr": instance_repr,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django.apps import apps
|
|
2
|
+
from django.contrib.auth.models import Permission
|
|
3
|
+
from django.contrib.contenttypes.models import ContentType
|
|
4
|
+
from django.contrib.sessions.models import Session
|
|
5
|
+
from django.db.migrations import Migration
|
|
6
|
+
from django.db.migrations.recorder import MigrationRecorder
|
|
7
|
+
|
|
8
|
+
UNREGISTERED_CLASSES = [
|
|
9
|
+
Migration,
|
|
10
|
+
Session,
|
|
11
|
+
Permission,
|
|
12
|
+
ContentType,
|
|
13
|
+
MigrationRecorder.Migration,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
if apps.is_installed("silk"):
|
|
17
|
+
from silk.models import (
|
|
18
|
+
BaseProfile,
|
|
19
|
+
Profile,
|
|
20
|
+
Request,
|
|
21
|
+
Response,
|
|
22
|
+
SQLQuery,
|
|
23
|
+
SQLQueryManager,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
UNREGISTERED_CLASSES.extend(
|
|
27
|
+
[Request, Response, SQLQueryManager, SQLQuery, BaseProfile, Profile]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if apps.is_installed("django.contrib.admin"):
|
|
31
|
+
from django.contrib.admin.models import LogEntry
|
|
32
|
+
|
|
33
|
+
UNREGISTERED_CLASSES.extend([LogEntry])
|
|
@@ -620,6 +620,87 @@ INSERT INTO rbp_stag_logs.audit FORMAT JSONEachRow
|
|
|
620
620
|
|
|
621
621
|
---
|
|
622
622
|
|
|
623
|
-
##
|
|
623
|
+
## Part E: Async Handlers
|
|
624
624
|
|
|
625
|
-
-
|
|
625
|
+
All four handler types have non-blocking async variants. Each async handler subclasses `QueueHandler` — the calling (request) thread only enqueues the log record, and a dedicated background thread owned by a `QueueListener` performs the actual file I/O.
|
|
626
|
+
|
|
627
|
+
### How It Works
|
|
628
|
+
|
|
629
|
+
```
|
|
630
|
+
Request thread Background thread
|
|
631
|
+
────────────── ─────────────────
|
|
632
|
+
emit(record)
|
|
633
|
+
└─ queue.put() ───► QueueListener.dequeue()
|
|
634
|
+
(returns immediately) └─ RotatingFileHandler.emit()
|
|
635
|
+
└─ write to file + rotate if needed
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
### Async Handler Classes
|
|
639
|
+
|
|
640
|
+
| Async Handler | Wraps | Formatter |
|
|
641
|
+
|---|---|---|
|
|
642
|
+
| `AsyncAPILogHandler` | `APILogHandler` | `APIFormatter` |
|
|
643
|
+
| `AsyncAuditLogHandler` | `AuditLogHandler` | `AuditFormatter` |
|
|
644
|
+
| `AsyncLoginLogHandler` | `LoginLogHandler` | `LoginFormatter` |
|
|
645
|
+
| `AsyncJsonHandler` | `RotatingFileHandler` | `JsonFormatter` |
|
|
646
|
+
|
|
647
|
+
Each handler starts its `QueueListener` thread on `__init__` and stops it cleanly on `close()` (called automatically by Django on shutdown).
|
|
648
|
+
|
|
649
|
+
### Utility Functions
|
|
650
|
+
|
|
651
|
+
Async counterparts match the signatures of their sync equivalents:
|
|
652
|
+
|
|
653
|
+
```python
|
|
654
|
+
from activity_audit import (
|
|
655
|
+
get_async_json_handler,
|
|
656
|
+
get_async_api_handler,
|
|
657
|
+
get_async_audit_handler,
|
|
658
|
+
get_async_login_handler,
|
|
659
|
+
)
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
#### `get_async_json_handler`
|
|
663
|
+
|
|
664
|
+
```python
|
|
665
|
+
get_async_json_handler(
|
|
666
|
+
level="DEBUG",
|
|
667
|
+
filename="audit_logs/app.log",
|
|
668
|
+
max_bytes=1024 * 1024 * 10, # 10MB
|
|
669
|
+
backup_count=5,
|
|
670
|
+
)
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
> Note: unlike `get_json_handler`, there is no `formatter` parameter — `JsonFormatter` is embedded directly on the inner handler so it applies on the background thread.
|
|
674
|
+
|
|
675
|
+
#### `get_async_api_handler`
|
|
676
|
+
|
|
677
|
+
```python
|
|
678
|
+
get_async_api_handler(filename="audit_logs/api.log")
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
#### `get_async_audit_handler`
|
|
682
|
+
|
|
683
|
+
```python
|
|
684
|
+
get_async_audit_handler(filename="audit_logs/audit.log")
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
#### `get_async_login_handler`
|
|
688
|
+
|
|
689
|
+
```python
|
|
690
|
+
get_async_login_handler(filename="audit_logs/login.log")
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
### Example Django LOGGING Configuration
|
|
694
|
+
|
|
695
|
+
```python
|
|
696
|
+
LOGGING = {
|
|
697
|
+
"version": 1,
|
|
698
|
+
"handlers": {
|
|
699
|
+
"app_file": get_async_json_handler(level="INFO", filename="audit_logs/app.log"),
|
|
700
|
+
"api_file": get_async_api_handler(filename="audit_logs/api.log"),
|
|
701
|
+
"audit_file": get_async_audit_handler(filename="audit_logs/audit.log"),
|
|
702
|
+
"login_file": get_async_login_handler(filename="audit_logs/login.log"),
|
|
703
|
+
},
|
|
704
|
+
...
|
|
705
|
+
}
|
|
706
|
+
```
|
|
File without changes
|
{django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/.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.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/__init__.py
RENAMED
|
File without changes
|
{django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/protocols.py
RENAMED
|
File without changes
|
{django_activity_audit-1.3.0.dev10 → django_activity_audit-1.3.0.dev12}/activity_audit/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|